Skip to content
This repository was archived by the owner on Mar 3, 2025. It is now read-only.

Commit b1b145a

Browse files
authored
✨ Add PullSecret controller to save pull secret data locally (#425)
* ✨ Add PullSecret controller to save pull secret data locally RFC: https://docs.google.com/document/d/1BXD6kj5zXHcGiqvJOikU2xs8kV26TPnzEKp6n7TKD4M/edit#heading=h.x3tfh25grvnv * main.go: improved cache configuration for watching pull secret Signed-off-by: Joe Lanford <joe.lanford@gmail.com> --------- Signed-off-by: Joe Lanford <joe.lanford@gmail.com>
1 parent dee3337 commit b1b145a

5 files changed

Lines changed: 298 additions & 32 deletions

File tree

cmd/manager/main.go

Lines changed: 71 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -24,20 +24,28 @@ import (
2424
"net/url"
2525
"os"
2626
"path/filepath"
27+
"strings"
2728
"time"
2829

2930
"github.com/containers/image/v5/types"
3031
"github.com/go-logr/logr"
3132
"github.com/spf13/pflag"
33+
corev1 "k8s.io/api/core/v1"
34+
"k8s.io/apimachinery/pkg/fields"
35+
k8slabels "k8s.io/apimachinery/pkg/labels"
3236
"k8s.io/apimachinery/pkg/runtime"
37+
k8stypes "k8s.io/apimachinery/pkg/types"
38+
apimachineryrand "k8s.io/apimachinery/pkg/util/rand"
3339
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
3440
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
3541
"k8s.io/client-go/metadata"
3642
_ "k8s.io/client-go/plugin/pkg/client/auth"
3743
"k8s.io/klog/v2"
3844
"k8s.io/klog/v2/textlogger"
3945
ctrl "sigs.k8s.io/controller-runtime"
46+
crcache "sigs.k8s.io/controller-runtime/pkg/cache"
4047
"sigs.k8s.io/controller-runtime/pkg/certwatcher"
48+
"sigs.k8s.io/controller-runtime/pkg/client"
4149
"sigs.k8s.io/controller-runtime/pkg/healthz"
4250
"sigs.k8s.io/controller-runtime/pkg/metrics"
4351
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
@@ -61,8 +69,8 @@ var (
6169
)
6270

6371
const (
64-
storageDir = "catalogs"
65-
authFilePath = "/etc/catalogd/auth.json"
72+
storageDir = "catalogs"
73+
authFilePrefix = "catalogd-global-pull-secret"
6674
)
6775

6876
func init() {
@@ -88,6 +96,7 @@ func main() {
8896
keyFile string
8997
webhookPort int
9098
caCertDir string
99+
globalPullSecret string
91100
)
92101
flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.")
93102
flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.")
@@ -105,6 +114,7 @@ func main() {
105114
flag.StringVar(&keyFile, "tls-key", "", "The key file used for serving catalog contents over HTTPS. Requires tls-cert.")
106115
flag.IntVar(&webhookPort, "webhook-server-port", 9443, "The port that the mutating webhook server serves at.")
107116
flag.StringVar(&caCertDir, "ca-certs-dir", "", "The directory of CA certificate to use for verifying HTTPS connections to image registries.")
117+
flag.StringVar(&globalPullSecret, "global-pull-secret", "", "The <namespace>/<name> of the global pull secret that is going to be used to pull bundle images.")
108118

109119
klog.InitFlags(flag.CommandLine)
110120

@@ -120,6 +130,17 @@ func main() {
120130

121131
ctrl.SetLogger(textlogger.NewLogger(textlogger.NewConfig()))
122132

133+
authFilePath := filepath.Join(os.TempDir(), fmt.Sprintf("%s-%s.json", authFilePrefix, apimachineryrand.String(8)))
134+
var globalPullSecretKey *k8stypes.NamespacedName
135+
if globalPullSecret != "" {
136+
secretParts := strings.Split(globalPullSecret, "/")
137+
if len(secretParts) != 2 {
138+
setupLog.Error(fmt.Errorf("incorrect number of components"), "value of global-pull-secret should be of the format <namespace>/<name>")
139+
os.Exit(1)
140+
}
141+
globalPullSecretKey = &k8stypes.NamespacedName{Name: secretParts[1], Namespace: secretParts[0]}
142+
}
143+
123144
if (certFile != "" && keyFile == "") || (certFile == "" && keyFile != "") {
124145
setupLog.Error(nil, "unable to configure TLS certificates: tls-cert and tls-key flags must be used together")
125146
os.Exit(1)
@@ -148,6 +169,22 @@ func main() {
148169
},
149170
})
150171

172+
cacheOptions := crcache.Options{
173+
ByObject: map[client.Object]crcache.ByObject{},
174+
}
175+
if globalPullSecretKey != nil {
176+
cacheOptions.ByObject[&corev1.Secret{}] = crcache.ByObject{
177+
Namespaces: map[string]crcache.Config{
178+
globalPullSecretKey.Namespace: {
179+
LabelSelector: k8slabels.Everything(),
180+
FieldSelector: fields.SelectorFromSet(map[string]string{
181+
"metadata.name": globalPullSecretKey.Name,
182+
}),
183+
},
184+
},
185+
}
186+
}
187+
151188
// Create manager
152189
mgr, err := ctrl.NewManager(cfg, ctrl.Options{
153190
Scheme: scheme,
@@ -159,6 +196,7 @@ func main() {
159196
LeaderElection: enableLeaderElection,
160197
LeaderElectionID: "catalogd-operator-lock",
161198
WebhookServer: webhookServer,
199+
Cache: cacheOptions,
162200
})
163201
if err != nil {
164202
setupLog.Error(err, "unable to create manager")
@@ -188,10 +226,20 @@ func main() {
188226
}
189227
unpacker := &source.ContainersImageRegistry{
190228
BaseCachePath: unpackCacheBasePath,
191-
SourceContext: &types.SystemContext{
192-
OCICertPath: caCertDir,
193-
DockerCertPath: caCertDir,
194-
AuthFilePath: authFilePathIfPresent(setupLog),
229+
SourceContextFunc: func(logger logr.Logger) (*types.SystemContext, error) {
230+
srcContext := &types.SystemContext{
231+
DockerCertPath: caCertDir,
232+
OCICertPath: caCertDir,
233+
}
234+
if _, err := os.Stat(authFilePath); err == nil && globalPullSecretKey != nil {
235+
logger.Info("using available authentication information for pulling image")
236+
srcContext.AuthFilePath = authFilePath
237+
} else if os.IsNotExist(err) {
238+
logger.Info("no authentication information found for pulling image, proceeding without auth")
239+
} else {
240+
return nil, fmt.Errorf("could not stat auth file, error: %w", err)
241+
}
242+
return srcContext, nil
195243
},
196244
}
197245

@@ -234,6 +282,19 @@ func main() {
234282
setupLog.Error(err, "unable to create controller", "controller", "ClusterCatalog")
235283
os.Exit(1)
236284
}
285+
286+
if globalPullSecretKey != nil {
287+
setupLog.Info("creating SecretSyncer controller for watching secret", "Secret", globalPullSecret)
288+
err := (&corecontrollers.PullSecretReconciler{
289+
Client: mgr.GetClient(),
290+
AuthFilePath: authFilePath,
291+
SecretKey: *globalPullSecretKey,
292+
}).SetupWithManager(mgr)
293+
if err != nil {
294+
setupLog.Error(err, "unable to create controller", "controller", "SecretSyncer")
295+
os.Exit(1)
296+
}
297+
}
237298
//+kubebuilder:scaffold:builder
238299

239300
if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
@@ -274,6 +335,10 @@ func main() {
274335
setupLog.Error(err, "problem running manager")
275336
os.Exit(1)
276337
}
338+
if err := os.Remove(authFilePath); err != nil {
339+
setupLog.Error(err, "failed to cleanup temporary auth file")
340+
os.Exit(1)
341+
}
277342
}
278343

279344
func podNamespace() string {
@@ -283,17 +348,3 @@ func podNamespace() string {
283348
}
284349
return string(namespace)
285350
}
286-
287-
func authFilePathIfPresent(logger logr.Logger) string {
288-
_, err := os.Stat(authFilePath)
289-
if os.IsNotExist(err) {
290-
logger.Info("auth file not found, skipping configuration of global auth file", "path", authFilePath)
291-
return ""
292-
}
293-
if err != nil {
294-
logger.Error(err, "unable to access auth file path", "path", authFilePath)
295-
os.Exit(1)
296-
}
297-
logger.Info("auth file found, configuring globally for image registry interactions", "path", authFilePath)
298-
return authFilePath
299-
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/*
2+
Copyright 2024.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package core
18+
19+
import (
20+
"context"
21+
"fmt"
22+
"os"
23+
24+
"github.com/go-logr/logr"
25+
corev1 "k8s.io/api/core/v1"
26+
apierrors "k8s.io/apimachinery/pkg/api/errors"
27+
"k8s.io/apimachinery/pkg/types"
28+
ctrl "sigs.k8s.io/controller-runtime"
29+
"sigs.k8s.io/controller-runtime/pkg/client"
30+
"sigs.k8s.io/controller-runtime/pkg/log"
31+
"sigs.k8s.io/controller-runtime/pkg/predicate"
32+
)
33+
34+
// PullSecretReconciler reconciles a specific Secret object
35+
// that contains global pull secrets for pulling Catalog images
36+
type PullSecretReconciler struct {
37+
client.Client
38+
SecretKey types.NamespacedName
39+
AuthFilePath string
40+
}
41+
42+
func (r *PullSecretReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
43+
logger := log.FromContext(ctx)
44+
if req.Name != r.SecretKey.Name || req.Namespace != r.SecretKey.Namespace {
45+
logger.Error(fmt.Errorf("received unexpected request for Secret %v/%v", req.Namespace, req.Name), "reconciliation error")
46+
return ctrl.Result{}, nil
47+
}
48+
49+
secret := &corev1.Secret{}
50+
err := r.Get(ctx, req.NamespacedName, secret)
51+
if err != nil {
52+
if apierrors.IsNotFound(err) {
53+
logger.Info("secret not found")
54+
return r.deleteSecretFile(logger)
55+
}
56+
logger.Error(err, "failed to get Secret")
57+
return ctrl.Result{}, err
58+
}
59+
60+
return r.writeSecretToFile(logger, secret)
61+
}
62+
63+
// SetupWithManager sets up the controller with the Manager.
64+
func (r *PullSecretReconciler) SetupWithManager(mgr ctrl.Manager) error {
65+
_, err := ctrl.NewControllerManagedBy(mgr).
66+
For(&corev1.Secret{}).
67+
WithEventFilter(newSecretPredicate(r.SecretKey)).
68+
Build(r)
69+
70+
return err
71+
}
72+
73+
func newSecretPredicate(key types.NamespacedName) predicate.Predicate {
74+
return predicate.NewPredicateFuncs(func(obj client.Object) bool {
75+
return obj.GetName() == key.Name && obj.GetNamespace() == key.Namespace
76+
})
77+
}
78+
79+
// writeSecretToFile writes the secret data to the specified file
80+
func (r *PullSecretReconciler) writeSecretToFile(logger logr.Logger, secret *corev1.Secret) (ctrl.Result, error) {
81+
// image registry secrets are always stored with the key .dockerconfigjson
82+
// ref: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/#registry-secret-existing-credentials
83+
dockerConfigJSON, ok := secret.Data[".dockerconfigjson"]
84+
if !ok {
85+
logger.Error(fmt.Errorf("expected secret.Data key not found"), "expected secret Data to contain key .dockerconfigjson")
86+
return ctrl.Result{}, nil
87+
}
88+
// expected format for auth.json
89+
// https://github.com/containers/image/blob/main/docs/containers-auth.json.5.md
90+
err := os.WriteFile(r.AuthFilePath, dockerConfigJSON, 0600)
91+
if err != nil {
92+
return ctrl.Result{}, fmt.Errorf("failed to write secret data to file: %w", err)
93+
}
94+
logger.Info("saved global pull secret data locally")
95+
return ctrl.Result{}, nil
96+
}
97+
98+
// deleteSecretFile deletes the auth file if the secret is deleted
99+
func (r *PullSecretReconciler) deleteSecretFile(logger logr.Logger) (ctrl.Result, error) {
100+
logger.Info("deleting local auth file", "file", r.AuthFilePath)
101+
if err := os.Remove(r.AuthFilePath); err != nil {
102+
if os.IsNotExist(err) {
103+
logger.Info("auth file does not exist, nothing to delete")
104+
return ctrl.Result{}, nil
105+
}
106+
return ctrl.Result{}, fmt.Errorf("failed to delete secret file: %w", err)
107+
}
108+
logger.Info("auth file deleted successfully")
109+
return ctrl.Result{}, nil
110+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package core
2+
3+
import (
4+
"context"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
9+
"github.com/stretchr/testify/require"
10+
corev1 "k8s.io/api/core/v1"
11+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
12+
"k8s.io/apimachinery/pkg/types"
13+
ctrl "sigs.k8s.io/controller-runtime"
14+
"sigs.k8s.io/controller-runtime/pkg/client/fake"
15+
)
16+
17+
func TestSecretSyncerReconciler(t *testing.T) {
18+
secretData := []byte(`{"auths":{"exampleRegistry": "exampledata"}}`)
19+
authFileName := "test-auth.json"
20+
for _, tt := range []struct {
21+
name string
22+
secret *corev1.Secret
23+
addSecret bool
24+
wantErr string
25+
fileShouldExistBefore bool
26+
fileShouldExistAfter bool
27+
}{
28+
{
29+
name: "secret exists, content gets saved to authFile",
30+
secret: &corev1.Secret{
31+
ObjectMeta: metav1.ObjectMeta{
32+
Name: "test-secret",
33+
Namespace: "test-secret-namespace",
34+
},
35+
Data: map[string][]byte{
36+
".dockerconfigjson": secretData,
37+
},
38+
},
39+
addSecret: true,
40+
fileShouldExistBefore: false,
41+
fileShouldExistAfter: true,
42+
},
43+
{
44+
name: "secret does not exist, file exists previously, file should get deleted",
45+
secret: &corev1.Secret{
46+
ObjectMeta: metav1.ObjectMeta{
47+
Name: "test-secret",
48+
Namespace: "test-secret-namespace",
49+
},
50+
Data: map[string][]byte{
51+
".dockerconfigjson": secretData,
52+
},
53+
},
54+
addSecret: false,
55+
fileShouldExistBefore: true,
56+
fileShouldExistAfter: false,
57+
},
58+
} {
59+
t.Run(tt.name, func(t *testing.T) {
60+
ctx := context.Background()
61+
tempAuthFile := filepath.Join(t.TempDir(), authFileName)
62+
clientBuilder := fake.NewClientBuilder()
63+
if tt.addSecret {
64+
clientBuilder = clientBuilder.WithObjects(tt.secret)
65+
}
66+
cl := clientBuilder.Build()
67+
68+
secretKey := types.NamespacedName{Namespace: tt.secret.Namespace, Name: tt.secret.Name}
69+
r := &PullSecretReconciler{
70+
Client: cl,
71+
SecretKey: secretKey,
72+
AuthFilePath: tempAuthFile,
73+
}
74+
if tt.fileShouldExistBefore {
75+
err := os.WriteFile(tempAuthFile, secretData, 0600)
76+
require.NoError(t, err)
77+
}
78+
res, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: secretKey})
79+
if tt.wantErr == "" {
80+
require.NoError(t, err)
81+
} else {
82+
require.ErrorContains(t, err, tt.wantErr)
83+
}
84+
require.Equal(t, ctrl.Result{}, res)
85+
86+
if tt.fileShouldExistAfter {
87+
_, err := os.Stat(tempAuthFile)
88+
require.NoError(t, err)
89+
} else {
90+
_, err := os.Stat(tempAuthFile)
91+
require.True(t, os.IsNotExist(err))
92+
}
93+
})
94+
}
95+
}

0 commit comments

Comments
 (0)