Skip to content

Commit f35edf6

Browse files
authored
✨ Add PullSecret controller to save pull secret data locally (#1322)
* ✨ 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 <[email protected]> --------- Signed-off-by: Joe Lanford <[email protected]>
1 parent dd1730a commit f35edf6

File tree

5 files changed

+340
-78
lines changed

5 files changed

+340
-78
lines changed

cmd/manager/main.go

Lines changed: 72 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,18 @@ import (
2323
"net/http"
2424
"os"
2525
"path/filepath"
26+
"strings"
2627
"time"
2728

2829
"github.com/containers/image/v5/types"
2930
"github.com/go-logr/logr"
3031
"github.com/spf13/pflag"
32+
corev1 "k8s.io/api/core/v1"
3133
apiextensionsv1client "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset/typed/apiextensions/v1"
34+
"k8s.io/apimachinery/pkg/fields"
3235
k8slabels "k8s.io/apimachinery/pkg/labels"
36+
k8stypes "k8s.io/apimachinery/pkg/types"
37+
apimachineryrand "k8s.io/apimachinery/pkg/util/rand"
3338
corev1client "k8s.io/client-go/kubernetes/typed/core/v1"
3439
_ "k8s.io/client-go/plugin/pkg/client/auth"
3540
"k8s.io/klog/v2"
@@ -67,7 +72,7 @@ var (
6772
defaultSystemNamespace = "olmv1-system"
6873
)
6974

70-
const authFilePath = "/etc/operator-controller/auth.json"
75+
const authFilePrefix = "operator-controller-global-pull-secrets"
7176

7277
// podNamespace checks whether the controller is running in a Pod vs.
7378
// being run locally by inspecting the namespace file that gets mounted
@@ -90,6 +95,7 @@ func main() {
9095
operatorControllerVersion bool
9196
systemNamespace string
9297
caCertDir string
98+
globalPullSecret string
9399
)
94100
flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.")
95101
flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.")
@@ -100,6 +106,7 @@ func main() {
100106
flag.StringVar(&cachePath, "cache-path", "/var/cache", "The local directory path used for filesystem based caching")
101107
flag.BoolVar(&operatorControllerVersion, "version", false, "Prints operator-controller version information")
102108
flag.StringVar(&systemNamespace, "system-namespace", "", "Configures the namespace that gets used to deploy system resources.")
109+
flag.StringVar(&globalPullSecret, "global-pull-secret", "", "The <namespace>/<name> of the global pull secret that is going to be used to pull bundle images.")
103110

104111
klog.InitFlags(flag.CommandLine)
105112

@@ -116,27 +123,51 @@ func main() {
116123

117124
setupLog.Info("starting up the controller", "version info", version.String())
118125

126+
authFilePath := filepath.Join(os.TempDir(), fmt.Sprintf("%s-%s.json", authFilePrefix, apimachineryrand.String(8)))
127+
var globalPullSecretKey *k8stypes.NamespacedName
128+
if globalPullSecret != "" {
129+
secretParts := strings.Split(globalPullSecret, "/")
130+
if len(secretParts) != 2 {
131+
setupLog.Error(fmt.Errorf("incorrect number of components"), "value of global-pull-secret should be of the format <namespace>/<name>")
132+
os.Exit(1)
133+
}
134+
globalPullSecretKey = &k8stypes.NamespacedName{Name: secretParts[1], Namespace: secretParts[0]}
135+
}
136+
119137
if systemNamespace == "" {
120138
systemNamespace = podNamespace()
121139
}
122140

123141
setupLog.Info("set up manager")
142+
cacheOptions := crcache.Options{
143+
ByObject: map[client.Object]crcache.ByObject{
144+
&ocv1alpha1.ClusterExtension{}: {Label: k8slabels.Everything()},
145+
&catalogd.ClusterCatalog{}: {Label: k8slabels.Everything()},
146+
},
147+
DefaultNamespaces: map[string]crcache.Config{
148+
systemNamespace: {LabelSelector: k8slabels.Everything()},
149+
},
150+
DefaultLabelSelector: k8slabels.Nothing(),
151+
}
152+
if globalPullSecretKey != nil {
153+
cacheOptions.ByObject[&corev1.Secret{}] = crcache.ByObject{
154+
Namespaces: map[string]crcache.Config{
155+
globalPullSecretKey.Namespace: {
156+
LabelSelector: k8slabels.Everything(),
157+
FieldSelector: fields.SelectorFromSet(map[string]string{
158+
"metadata.name": globalPullSecretKey.Name,
159+
}),
160+
},
161+
},
162+
}
163+
}
124164
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
125165
Scheme: scheme.Scheme,
126166
Metrics: server.Options{BindAddress: metricsAddr},
127167
HealthProbeBindAddress: probeAddr,
128168
LeaderElection: enableLeaderElection,
129169
LeaderElectionID: "9c4404e7.operatorframework.io",
130-
Cache: crcache.Options{
131-
ByObject: map[client.Object]crcache.ByObject{
132-
&ocv1alpha1.ClusterExtension{}: {Label: k8slabels.Everything()},
133-
&catalogd.ClusterCatalog{}: {Label: k8slabels.Everything()},
134-
},
135-
DefaultNamespaces: map[string]crcache.Config{
136-
systemNamespace: {LabelSelector: k8slabels.Everything()},
137-
},
138-
DefaultLabelSelector: k8slabels.Nothing(),
139-
},
170+
Cache: cacheOptions,
140171
// LeaderElectionReleaseOnCancel defines if the leader should step down voluntarily
141172
// when the Manager ends. This requires the binary to immediately end when the
142173
// Manager is stopped, otherwise, this setting is unsafe. Setting this significantly
@@ -191,12 +222,21 @@ func main() {
191222

192223
unpacker := &source.ContainersImageRegistry{
193224
BaseCachePath: filepath.Join(cachePath, "unpack"),
194-
SourceContext: &types.SystemContext{
195-
DockerCertPath: caCertDir,
196-
OCICertPath: caCertDir,
197-
AuthFilePath: authFilePathIfPresent(setupLog),
198-
},
199-
}
225+
SourceContextFunc: func(logger logr.Logger) (*types.SystemContext, error) {
226+
srcContext := &types.SystemContext{
227+
DockerCertPath: caCertDir,
228+
OCICertPath: caCertDir,
229+
}
230+
if _, err := os.Stat(authFilePath); err == nil && globalPullSecretKey != nil {
231+
logger.Info("using available authentication information for pulling image")
232+
srcContext.AuthFilePath = authFilePath
233+
} else if os.IsNotExist(err) {
234+
logger.Info("no authentication information found for pulling image, proceeding without auth")
235+
} else {
236+
return nil, fmt.Errorf("could not stat auth file, error: %w", err)
237+
}
238+
return srcContext, nil
239+
}}
200240

201241
clusterExtensionFinalizers := crfinalizer.NewFinalizers()
202242
if err := clusterExtensionFinalizers.Register(controllers.ClusterExtensionCleanupUnpackCacheFinalizer, finalizers.FinalizerFunc(func(ctx context.Context, obj client.Object) (crfinalizer.Result, error) {
@@ -281,6 +321,19 @@ func main() {
281321
os.Exit(1)
282322
}
283323

324+
if globalPullSecretKey != nil {
325+
setupLog.Info("creating SecretSyncer controller for watching secret", "Secret", globalPullSecret)
326+
err := (&controllers.PullSecretReconciler{
327+
Client: mgr.GetClient(),
328+
AuthFilePath: authFilePath,
329+
SecretKey: *globalPullSecretKey,
330+
}).SetupWithManager(mgr)
331+
if err != nil {
332+
setupLog.Error(err, "unable to create controller", "controller", "SecretSyncer")
333+
os.Exit(1)
334+
}
335+
}
336+
284337
//+kubebuilder:scaffold:builder
285338

286339
if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
@@ -298,18 +351,8 @@ func main() {
298351
setupLog.Error(err, "problem running manager")
299352
os.Exit(1)
300353
}
301-
}
302-
303-
func authFilePathIfPresent(logger logr.Logger) string {
304-
_, err := os.Stat(authFilePath)
305-
if os.IsNotExist(err) {
306-
logger.Info("auth file not found, skipping configuration of global auth file", "path", authFilePath)
307-
return ""
308-
}
309-
if err != nil {
310-
logger.Error(err, "unable to access auth file path", "path", authFilePath)
354+
if err := os.Remove(authFilePath); err != nil {
355+
setupLog.Error(err, "failed to cleanup temporary auth file")
311356
os.Exit(1)
312357
}
313-
logger.Info("auth file found, configuring globally for image registry interactions", "path", authFilePath)
314-
return authFilePath
315358
}
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 controllers
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 bundle 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: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package controllers_test
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+
"github.com/operator-framework/operator-controller/internal/controllers"
17+
"github.com/operator-framework/operator-controller/internal/scheme"
18+
)
19+
20+
func TestSecretSyncerReconciler(t *testing.T) {
21+
secretData := []byte(`{"auths":{"exampleRegistry": "exampledata"}}`)
22+
authFileName := "test-auth.json"
23+
for _, tt := range []struct {
24+
name string
25+
secret *corev1.Secret
26+
addSecret bool
27+
wantErr string
28+
fileShouldExistBefore bool
29+
fileShouldExistAfter bool
30+
}{
31+
{
32+
name: "secret exists, content gets saved to authFile",
33+
secret: &corev1.Secret{
34+
ObjectMeta: metav1.ObjectMeta{
35+
Name: "test-secret",
36+
Namespace: "test-secret-namespace",
37+
},
38+
Data: map[string][]byte{
39+
".dockerconfigjson": secretData,
40+
},
41+
},
42+
addSecret: true,
43+
fileShouldExistBefore: false,
44+
fileShouldExistAfter: true,
45+
},
46+
{
47+
name: "secret does not exist, file exists previously, file should get deleted",
48+
secret: &corev1.Secret{
49+
ObjectMeta: metav1.ObjectMeta{
50+
Name: "test-secret",
51+
Namespace: "test-secret-namespace",
52+
},
53+
Data: map[string][]byte{
54+
".dockerconfigjson": secretData,
55+
},
56+
},
57+
addSecret: false,
58+
fileShouldExistBefore: true,
59+
fileShouldExistAfter: false,
60+
},
61+
} {
62+
t.Run(tt.name, func(t *testing.T) {
63+
ctx := context.Background()
64+
tempAuthFile := filepath.Join(t.TempDir(), authFileName)
65+
clientBuilder := fake.NewClientBuilder().WithScheme(scheme.Scheme)
66+
if tt.addSecret {
67+
clientBuilder = clientBuilder.WithObjects(tt.secret)
68+
}
69+
cl := clientBuilder.Build()
70+
71+
secretKey := types.NamespacedName{Namespace: tt.secret.Namespace, Name: tt.secret.Name}
72+
r := &controllers.PullSecretReconciler{
73+
Client: cl,
74+
SecretKey: secretKey,
75+
AuthFilePath: tempAuthFile,
76+
}
77+
if tt.fileShouldExistBefore {
78+
err := os.WriteFile(tempAuthFile, secretData, 0600)
79+
require.NoError(t, err)
80+
}
81+
res, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: secretKey})
82+
if tt.wantErr == "" {
83+
require.NoError(t, err)
84+
} else {
85+
require.ErrorContains(t, err, tt.wantErr)
86+
}
87+
require.Equal(t, ctrl.Result{}, res)
88+
89+
if tt.fileShouldExistAfter {
90+
_, err := os.Stat(tempAuthFile)
91+
require.NoError(t, err)
92+
} else {
93+
_, err := os.Stat(tempAuthFile)
94+
require.True(t, os.IsNotExist(err))
95+
}
96+
})
97+
}
98+
}

0 commit comments

Comments
 (0)