From a0a622581e949d8a7ad20d4fa6251d29dbdbe731 Mon Sep 17 00:00:00 2001 From: Jannes Stubbemann Date: Mon, 23 Mar 2026 18:06:27 +0100 Subject: [PATCH 1/3] feat: add automatic image update polling via OCI registry digest checks When spec.image.autoUpdate.enabled is true, the operator periodically queries the container registry for the current digest of the configured tag. When a new digest is detected, it injects a pod annotation that triggers a rolling StatefulSet update. - AutoUpdateSpec on ImageSpec with enabled/interval fields - AutoUpdateStatus tracking lastCheckTime, resolvedDigest, lastUpdateTime - OCI registry client (internal/registry) with bearer token auth flow - Supports GHCR, Docker Hub, and private registries via imagePullSecrets - Pod annotation-based rollout (same mechanism as kubectl rollout restart) - Requeue-based polling (idiomatic controller-runtime, leader-election safe) - 11 new tests (registry client + StatefulSet annotation injection) Usage: spec: image: tag: latest autoUpdate: enabled: true interval: 5m Co-Authored-By: Claude Opus 4.6 (1M context) --- api/v1alpha1/paperclipinstance_types.go | 41 +++ api/v1alpha1/zz_generated.deepcopy.go | 48 ++++ .../crds/paperclip.inc_instances.yaml | 39 +++ cmd/main.go | 8 +- config/crd/bases/paperclip.inc_instances.yaml | 39 +++ internal/controller/instance_controller.go | 115 ++++++++- internal/registry/client.go | 244 ++++++++++++++++++ internal/registry/client_test.go | 211 +++++++++++++++ internal/resources/resources_test.go | 27 +- internal/resources/statefulset.go | 5 +- 10 files changed, 760 insertions(+), 17 deletions(-) create mode 100644 internal/registry/client.go create mode 100644 internal/registry/client_test.go diff --git a/api/v1alpha1/paperclipinstance_types.go b/api/v1alpha1/paperclipinstance_types.go index e4a77d7..c69cae6 100644 --- a/api/v1alpha1/paperclipinstance_types.go +++ b/api/v1alpha1/paperclipinstance_types.go @@ -155,6 +155,24 @@ type ImageSpec struct { // PullSecrets specifies image pull secrets. // +optional PullSecrets []corev1.LocalObjectReference `json:"pullSecrets,omitempty"` + + // AutoUpdate enables automatic image updates by polling the registry for new digests. + // +optional + AutoUpdate *AutoUpdateSpec `json:"autoUpdate,omitempty"` +} + +// AutoUpdateSpec configures automatic image update polling. +type AutoUpdateSpec struct { + // Enabled controls whether auto-update polling is active. + // +kubebuilder:default=false + // +optional + Enabled bool `json:"enabled,omitempty"` + + // Interval is the polling interval (e.g. "5m", "1h"). Minimum is 1m. + // +kubebuilder:default="5m" + // +kubebuilder:validation:Pattern=`^\d+(s|m|h)$` + // +optional + Interval string `json:"interval,omitempty"` } // DeploymentSpec controls deployment mode and exposure. @@ -727,6 +745,10 @@ type InstanceStatus struct { // Restore tracks the state of the latest restore operation. // +optional Restore *RestoreStatus `json:"restore,omitempty"` + + // AutoUpdate tracks the state of automatic image update checks. + // +optional + AutoUpdate *AutoUpdateStatus `json:"autoUpdate,omitempty"` } // ManagedResources tracks the names of managed Kubernetes resources. @@ -775,6 +797,25 @@ type RestoreStatus struct { Result string `json:"result,omitempty"` } +// AutoUpdateStatus tracks the state of automatic image update checks. +type AutoUpdateStatus struct { + // LastCheckTime is when the operator last queried the registry. + // +optional + LastCheckTime *metav1.Time `json:"lastCheckTime,omitempty"` + + // ResolvedDigest is the most recently observed digest for the configured tag. + // +optional + ResolvedDigest string `json:"resolvedDigest,omitempty"` + + // LastUpdateTime is when the digest last changed and a rollout was triggered. + // +optional + LastUpdateTime *metav1.Time `json:"lastUpdateTime,omitempty"` + + // LastError records the most recent error from a registry check, if any. + // +optional + LastError string `json:"lastError,omitempty"` +} + // +kubebuilder:object:root=true // +kubebuilder:subresource:status // +kubebuilder:resource:shortName=pci diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index b1f2b1f..5a0c6c2 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -117,6 +117,44 @@ func (in *AutoScalingSpec) DeepCopy() *AutoScalingSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AutoUpdateSpec) DeepCopyInto(out *AutoUpdateSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AutoUpdateSpec. +func (in *AutoUpdateSpec) DeepCopy() *AutoUpdateSpec { + if in == nil { + return nil + } + out := new(AutoUpdateSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AutoUpdateStatus) DeepCopyInto(out *AutoUpdateStatus) { + *out = *in + if in.LastCheckTime != nil { + in, out := &in.LastCheckTime, &out.LastCheckTime + *out = (*in).DeepCopy() + } + if in.LastUpdateTime != nil { + in, out := &in.LastUpdateTime, &out.LastUpdateTime + *out = (*in).DeepCopy() + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AutoUpdateStatus. +func (in *AutoUpdateStatus) DeepCopy() *AutoUpdateStatus { + if in == nil { + return nil + } + out := new(AutoUpdateStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AvailabilitySpec) DeepCopyInto(out *AvailabilitySpec) { *out = *in @@ -322,6 +360,11 @@ func (in *ImageSpec) DeepCopyInto(out *ImageSpec) { *out = make([]v1.LocalObjectReference, len(*in)) copy(*out, *in) } + if in.AutoUpdate != nil { + in, out := &in.AutoUpdate, &out.AutoUpdate + *out = new(AutoUpdateSpec) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ImageSpec. @@ -571,6 +614,11 @@ func (in *InstanceStatus) DeepCopyInto(out *InstanceStatus) { *out = new(RestoreStatus) (*in).DeepCopyInto(*out) } + if in.AutoUpdate != nil { + in, out := &in.AutoUpdate, &out.AutoUpdate + *out = new(AutoUpdateStatus) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InstanceStatus. diff --git a/charts/paperclip-operator/templates/crds/paperclip.inc_instances.yaml b/charts/paperclip-operator/templates/crds/paperclip.inc_instances.yaml index 3849220..a3025f4 100644 --- a/charts/paperclip-operator/templates/crds/paperclip.inc_instances.yaml +++ b/charts/paperclip-operator/templates/crds/paperclip.inc_instances.yaml @@ -3636,6 +3636,22 @@ spec: image: description: Image specifies the Paperclip container image to deploy. properties: + autoUpdate: + description: AutoUpdate enables automatic image updates by polling + the registry for new digests. + properties: + enabled: + default: false + description: Enabled controls whether auto-update polling + is active. + type: boolean + interval: + default: 5m + description: Interval is the polling interval (e.g. "5m", + "1h"). Minimum is 1m. + pattern: ^\d+(s|m|h)$ + type: string + type: object digest: description: Digest overrides the tag with an image digest (e.g. sha256:abc...). @@ -7410,6 +7426,29 @@ spec: status: description: InstanceStatus defines the observed state of Instance. properties: + autoUpdate: + description: AutoUpdate tracks the state of automatic image update + checks. + properties: + lastCheckTime: + description: LastCheckTime is when the operator last queried the + registry. + format: date-time + type: string + lastError: + description: LastError records the most recent error from a registry + check, if any. + type: string + lastUpdateTime: + description: LastUpdateTime is when the digest last changed and + a rollout was triggered. + format: date-time + type: string + resolvedDigest: + description: ResolvedDigest is the most recently observed digest + for the configured tag. + type: string + type: object backup: description: Backup tracks the state of the latest backup operation. properties: diff --git a/cmd/main.go b/cmd/main.go index 81ba1c4..7a07089 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -39,6 +39,7 @@ import ( paperclipv1alpha1 "github.com/paperclipinc/paperclip-operator/api/v1alpha1" "github.com/paperclipinc/paperclip-operator/internal/controller" + "github.com/paperclipinc/paperclip-operator/internal/registry" // +kubebuilder:scaffold:imports ) @@ -203,9 +204,10 @@ func main() { } if err := (&controller.InstanceReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - Recorder: mgr.GetEventRecorderFor("paperclip-operator"), + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: mgr.GetEventRecorderFor("paperclip-operator"), + RegistryClient: registry.NewClient(nil), }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "Instance") os.Exit(1) diff --git a/config/crd/bases/paperclip.inc_instances.yaml b/config/crd/bases/paperclip.inc_instances.yaml index 4c41a4d..9a2d89e 100644 --- a/config/crd/bases/paperclip.inc_instances.yaml +++ b/config/crd/bases/paperclip.inc_instances.yaml @@ -3630,6 +3630,22 @@ spec: image: description: Image specifies the Paperclip container image to deploy. properties: + autoUpdate: + description: AutoUpdate enables automatic image updates by polling + the registry for new digests. + properties: + enabled: + default: false + description: Enabled controls whether auto-update polling + is active. + type: boolean + interval: + default: 5m + description: Interval is the polling interval (e.g. "5m", + "1h"). Minimum is 1m. + pattern: ^\d+(s|m|h)$ + type: string + type: object digest: description: Digest overrides the tag with an image digest (e.g. sha256:abc...). @@ -7404,6 +7420,29 @@ spec: status: description: InstanceStatus defines the observed state of Instance. properties: + autoUpdate: + description: AutoUpdate tracks the state of automatic image update + checks. + properties: + lastCheckTime: + description: LastCheckTime is when the operator last queried the + registry. + format: date-time + type: string + lastError: + description: LastError records the most recent error from a registry + check, if any. + type: string + lastUpdateTime: + description: LastUpdateTime is when the digest last changed and + a rollout was triggered. + format: date-time + type: string + resolvedDigest: + description: ResolvedDigest is the most recently observed digest + for the configured tag. + type: string + type: object backup: description: Backup tracks the state of the latest backup operation. properties: diff --git a/internal/controller/instance_controller.go b/internal/controller/instance_controller.go index e25e85c..0b9d097 100644 --- a/internal/controller/instance_controller.go +++ b/internal/controller/instance_controller.go @@ -39,6 +39,7 @@ import ( logf "sigs.k8s.io/controller-runtime/pkg/log" paperclipv1alpha1 "github.com/paperclipinc/paperclip-operator/api/v1alpha1" + "github.com/paperclipinc/paperclip-operator/internal/registry" "github.com/paperclipinc/paperclip-operator/internal/resources" ) @@ -54,13 +55,18 @@ const ( ConditionStatefulSetReady = "StatefulSetReady" // ConditionServiceReady indicates the Service is ready. ConditionServiceReady = "ServiceReady" + + // AnnotationResolvedDigest is the pod template annotation that records the current resolved digest. + // Changing this annotation triggers a rolling restart. + AnnotationResolvedDigest = "paperclip.inc/resolved-digest" ) // InstanceReconciler reconciles a Instance object. type InstanceReconciler struct { client.Client - Scheme *runtime.Scheme - Recorder record.EventRecorder + Scheme *runtime.Scheme + Recorder record.EventRecorder + RegistryClient *registry.Client } // +kubebuilder:rbac:groups=paperclip.inc,resources=instances,verbs=get;list;watch;create;update;patch;delete @@ -171,8 +177,20 @@ func (r *InstanceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c } } + // 3.5. Auto-update: check registry for new digest + autoUpdateRequeue := ctrl.Result{} + if r.RegistryClient != nil { + autoUpdateRequeue = r.reconcileAutoUpdate(ctx, instance) + } + var extraPodAnnotations map[string]string + if instance.Status.AutoUpdate != nil && instance.Status.AutoUpdate.ResolvedDigest != "" { + extraPodAnnotations = map[string]string{ + AnnotationResolvedDigest: instance.Status.AutoUpdate.ResolvedDigest, + } + } + // 4. StatefulSet - if err := r.reconcileStatefulSet(ctx, instance); err != nil { + if err := r.reconcileStatefulSet(ctx, instance, extraPodAnnotations); err != nil { return r.handleError(ctx, instance, "StatefulSet", err) } @@ -228,7 +246,11 @@ func (r *InstanceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c "All managed resources reconciled successfully") } - return ctrl.Result{RequeueAfter: 5 * time.Minute}, nil + requeueAfter := 5 * time.Minute + if autoUpdateRequeue.RequeueAfter > 0 && autoUpdateRequeue.RequeueAfter < requeueAfter { + requeueAfter = autoUpdateRequeue.RequeueAfter + } + return ctrl.Result{RequeueAfter: requeueAfter}, nil } func (r *InstanceReconciler) reconcileServiceAccount(ctx context.Context, instance *paperclipv1alpha1.Instance) error { @@ -370,8 +392,8 @@ func (r *InstanceReconciler) reconcilePVC(ctx context.Context, instance *papercl return nil } -func (r *InstanceReconciler) reconcileStatefulSet(ctx context.Context, instance *paperclipv1alpha1.Instance) error { - desired := resources.BuildStatefulSet(instance) +func (r *InstanceReconciler) reconcileStatefulSet(ctx context.Context, instance *paperclipv1alpha1.Instance, extraPodAnnotations map[string]string) error { + desired := resources.BuildStatefulSet(instance, extraPodAnnotations) obj := &appsv1.StatefulSet{ ObjectMeta: metav1.ObjectMeta{ Name: desired.Name, @@ -659,6 +681,87 @@ func generatePassword(length int) (string, error) { return hex.EncodeToString(bytes)[:length], nil } +func (r *InstanceReconciler) reconcileAutoUpdate(ctx context.Context, instance *paperclipv1alpha1.Instance) ctrl.Result { + log := logf.FromContext(ctx) + autoUpdate := instance.Spec.Image.AutoUpdate + + if autoUpdate == nil || !autoUpdate.Enabled { + instance.Status.AutoUpdate = nil + return ctrl.Result{} + } + + interval, err := time.ParseDuration(autoUpdate.Interval) + if err != nil || interval < time.Minute { + interval = 5 * time.Minute + } + + if instance.Status.AutoUpdate == nil { + instance.Status.AutoUpdate = &paperclipv1alpha1.AutoUpdateStatus{} + } + + now := metav1.Now() + if instance.Status.AutoUpdate.LastCheckTime != nil { + elapsed := now.Sub(instance.Status.AutoUpdate.LastCheckTime.Time) + if elapsed < interval { + return ctrl.Result{RequeueAfter: interval - elapsed} + } + } + + // Resolve credentials from imagePullSecrets + var dockerConfigJSON []byte + if len(instance.Spec.Image.PullSecrets) > 0 { + secret := &corev1.Secret{} + getErr := r.Get(ctx, types.NamespacedName{ + Name: instance.Spec.Image.PullSecrets[0].Name, + Namespace: instance.Namespace, + }, secret) + if getErr != nil { + log.Error(getErr, "Failed to get imagePullSecret for auto-update") + instance.Status.AutoUpdate.LastError = getErr.Error() + instance.Status.AutoUpdate.LastCheckTime = &now + return ctrl.Result{RequeueAfter: interval} + } + dockerConfigJSON = secret.Data[".dockerconfigjson"] + } + + repo := instance.Spec.Image.Repository + if repo == "" { + repo = "ghcr.io/paperclipinc/paperclip" + } + tag := instance.Spec.Image.Tag + if tag == "" { + tag = "latest" + } + + digest, err := r.RegistryClient.ResolveDigest(ctx, repo, tag, dockerConfigJSON) + instance.Status.AutoUpdate.LastCheckTime = &now + + if err != nil { + log.Error(err, "Failed to resolve image digest", "repo", repo, "tag", tag) + instance.Status.AutoUpdate.LastError = err.Error() + if r.Recorder != nil { + r.Recorder.Eventf(instance, corev1.EventTypeWarning, "AutoUpdateCheckFailed", + "Failed to check registry for %s:%s: %v", repo, tag, err) + } + return ctrl.Result{RequeueAfter: interval} + } + + instance.Status.AutoUpdate.LastError = "" + previousDigest := instance.Status.AutoUpdate.ResolvedDigest + if digest != previousDigest { + log.Info("New image digest detected", "repo", repo, "tag", tag, + "previousDigest", previousDigest, "newDigest", digest) + instance.Status.AutoUpdate.ResolvedDigest = digest + instance.Status.AutoUpdate.LastUpdateTime = &now + if r.Recorder != nil { + r.Recorder.Eventf(instance, corev1.EventTypeNormal, "AutoUpdateDigestChanged", + "New digest detected for %s:%s: %s", repo, tag, digest) + } + } + + return ctrl.Result{RequeueAfter: interval} +} + // SetupWithManager sets up the controller with the Manager. func (r *InstanceReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). diff --git a/internal/registry/client.go b/internal/registry/client.go new file mode 100644 index 0000000..6cc1c7c --- /dev/null +++ b/internal/registry/client.go @@ -0,0 +1,244 @@ +package registry + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// DockerConfig represents the structure of a .dockerconfigjson secret. +type DockerConfig struct { + Auths map[string]DockerAuth `json:"auths"` +} + +// DockerAuth holds credentials for a single registry. +type DockerAuth struct { + Auth string `json:"auth"` // base64(user:pass) +} + +// Client queries OCI registries for manifest digests. +type Client struct { + HTTP *http.Client +} + +// NewClient creates a registry client. If httpClient is nil, a default with 30s timeout is used. +func NewClient(httpClient *http.Client) *Client { + if httpClient == nil { + httpClient = &http.Client{Timeout: 30 * time.Second} + } + return &Client{HTTP: httpClient} +} + +// ResolveDigest queries the registry for the current digest of repo:tag. +// dockerConfigJSON is the raw .dockerconfigjson bytes (may be nil for public repos). +func (c *Client) ResolveDigest(ctx context.Context, repo, tag string, dockerConfigJSON []byte) (string, error) { + host, name := parseRepository(repo) + url := fmt.Sprintf("https://%s/v2/%s/manifests/%s", host, name, tag) + + req, err := http.NewRequestWithContext(ctx, "HEAD", url, nil) + if err != nil { + return "", fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Accept", strings.Join([]string{ + "application/vnd.oci.image.index.v1+json", + "application/vnd.oci.image.manifest.v1+json", + "application/vnd.docker.distribution.manifest.v2+json", + "application/vnd.docker.distribution.manifest.list.v2+json", + }, ", ")) + + var username, password string + if dockerConfigJSON != nil { + username, password = extractCredentials(dockerConfigJSON, host) + } + + resp, err := c.doWithAuth(ctx, req, host, name, username, password) + if err != nil { + return "", fmt.Errorf("querying registry: %w", err) + } + defer func() { _ = resp.Body.Close() }() + _, _ = io.Copy(io.Discard, resp.Body) + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("registry returned HTTP %d for %s:%s", resp.StatusCode, repo, tag) + } + + digest := resp.Header.Get("Docker-Content-Digest") + if digest == "" { + return "", fmt.Errorf("no Docker-Content-Digest header for %s:%s", repo, tag) + } + return digest, nil +} + +func (c *Client) doWithAuth(ctx context.Context, req *http.Request, host, name, username, password string) (*http.Response, error) { + // Try direct request first (works for public repos or if credentials are sufficient as basic auth) + resp, err := c.HTTP.Do(req) //nolint:gosec // URL is constructed from user-provided registry config + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusUnauthorized { + return resp, nil + } + _ = resp.Body.Close() + + // Parse WWW-Authenticate for bearer token exchange + wwwAuth := resp.Header.Get("Www-Authenticate") + realm, service, scope := parseBearerChallenge(wwwAuth) + if realm == "" { + return nil, fmt.Errorf("401 with no bearer challenge for %s", host) + } + + // If no scope was provided, derive it from the image name + if scope == "" { + scope = fmt.Sprintf("repository:%s:pull", name) + } + + token, err := c.fetchToken(ctx, realm, service, scope, username, password) + if err != nil { + return nil, fmt.Errorf("fetching bearer token: %w", err) + } + + // Retry with bearer token + retryReq := req.Clone(ctx) + retryReq.Header.Set("Authorization", "Bearer "+token) + return c.HTTP.Do(retryReq) //nolint:gosec // URL is constructed from user-provided registry config, not untrusted input +} + +func (c *Client) fetchToken(ctx context.Context, realm, service, scope, username, password string) (string, error) { + tokenURL := realm + "?" + if service != "" { + tokenURL += "service=" + service + "&" + } + tokenURL += "scope=" + scope + + req, err := http.NewRequestWithContext(ctx, "GET", tokenURL, nil) //nolint:gosec // realm URL from registry WWW-Authenticate header + if err != nil { + return "", err + } + if username != "" { + req.SetBasicAuth(username, password) + } + + resp, err := c.HTTP.Do(req) //nolint:gosec // token URL from registry WWW-Authenticate header + if err != nil { + return "", err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("token endpoint returned HTTP %d", resp.StatusCode) + } + + var tokenResp struct { + Token string `json:"token"` + AccessToken string `json:"access_token"` + } + if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { + return "", fmt.Errorf("decoding token response: %w", err) + } + + token := tokenResp.Token + if token == "" { + token = tokenResp.AccessToken + } + if token == "" { + return "", fmt.Errorf("empty token from %s", realm) + } + return token, nil +} + +// parseRepository splits "ghcr.io/org/repo" into host and name. +// Handles Docker Hub shorthand (no host = docker.io, library/ prefix for single-segment names). +func parseRepository(repo string) (host, name string) { + parts := strings.SplitN(repo, "/", 2) + if len(parts) == 1 || (!strings.Contains(parts[0], ".") && !strings.Contains(parts[0], ":")) { + // No host prefix — Docker Hub + host = "registry-1.docker.io" + name = repo + if !strings.Contains(name, "/") { + name = "library/" + name + } + return + } + host = parts[0] + name = parts[1] + return +} + +// parseBearerChallenge extracts realm, service, and scope from a WWW-Authenticate header. +// Example: Bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:org/repo:pull" +func parseBearerChallenge(header string) (realm, service, scope string) { + if !strings.HasPrefix(header, "Bearer ") { + return "", "", "" + } + params := header[7:] + for _, part := range splitChallengeParams(params) { + k, v := splitKeyValue(part) + switch k { + case "realm": + realm = v + case "service": + service = v + case "scope": + scope = v + } + } + return +} + +func splitChallengeParams(s string) []string { + var parts []string + var current strings.Builder + inQuote := false + for _, ch := range s { + switch { + case ch == '"': + inQuote = !inQuote + case ch == ',' && !inQuote: + parts = append(parts, strings.TrimSpace(current.String())) + current.Reset() + default: + current.WriteRune(ch) + } + } + if current.Len() > 0 { + parts = append(parts, strings.TrimSpace(current.String())) + } + return parts +} + +func splitKeyValue(s string) (string, string) { + idx := strings.IndexByte(s, '=') + if idx < 0 { + return s, "" + } + return strings.TrimSpace(s[:idx]), strings.TrimSpace(s[idx+1:]) +} + +func extractCredentials(dockerConfigJSON []byte, host string) (string, string) { + var cfg DockerConfig + if err := json.Unmarshal(dockerConfigJSON, &cfg); err != nil { + return "", "" + } + // Try exact match first, then with https:// prefix + auth, ok := cfg.Auths[host] + if !ok { + auth, ok = cfg.Auths["https://"+host] + } + if !ok { + return "", "" + } + decoded, err := base64.StdEncoding.DecodeString(auth.Auth) + if err != nil { + return "", "" + } + parts := strings.SplitN(string(decoded), ":", 2) + if len(parts) != 2 { + return "", "" + } + return parts[0], parts[1] +} diff --git a/internal/registry/client_test.go b/internal/registry/client_test.go new file mode 100644 index 0000000..8b3dd1c --- /dev/null +++ b/internal/registry/client_test.go @@ -0,0 +1,211 @@ +package registry + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestResolveDigest_Anonymous(t *testing.T) { + expectedDigest := "sha256:abc123def456" + + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v2/org/repo/manifests/latest" { + w.Header().Set("Docker-Content-Digest", expectedDigest) + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + client := NewClient(srv.Client()) + // Strip https:// to get the host + host := strings.TrimPrefix(srv.URL, "https://") + + digest, err := client.ResolveDigest(context.Background(), host+"/org/repo", "latest", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if digest != expectedDigest { + t.Errorf("expected digest %q, got %q", expectedDigest, digest) + } +} + +func TestResolveDigest_BearerAuth(t *testing.T) { + expectedDigest := "sha256:bearer123" + + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v2/org/repo/manifests/v1.0": + auth := r.Header.Get("Authorization") + if auth == "" || !strings.HasPrefix(auth, "Bearer ") { + w.Header().Set("Www-Authenticate", + fmt.Sprintf(`Bearer realm="%s/token",service="test-registry",scope="repository:org/repo:pull"`, + "https://"+r.Host)) + w.WriteHeader(http.StatusUnauthorized) + return + } + w.Header().Set("Docker-Content-Digest", expectedDigest) + w.WriteHeader(http.StatusOK) + case "/token": + _ = json.NewEncoder(w).Encode(map[string]string{"token": "test-token"}) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + client := NewClient(srv.Client()) + host := strings.TrimPrefix(srv.URL, "https://") + + digest, err := client.ResolveDigest(context.Background(), host+"/org/repo", "v1.0", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if digest != expectedDigest { + t.Errorf("expected digest %q, got %q", expectedDigest, digest) + } +} + +func TestResolveDigest_WithCredentials(t *testing.T) { + expectedDigest := "sha256:private456" + var receivedBasicAuth string + + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v2/private/repo/manifests/latest": + auth := r.Header.Get("Authorization") + if !strings.HasPrefix(auth, "Bearer ") { + w.Header().Set("Www-Authenticate", + fmt.Sprintf(`Bearer realm="%s/token",service="test"`, "https://"+r.Host)) + w.WriteHeader(http.StatusUnauthorized) + return + } + w.Header().Set("Docker-Content-Digest", expectedDigest) + w.WriteHeader(http.StatusOK) + case "/token": + receivedBasicAuth = r.Header.Get("Authorization") + _ = json.NewEncoder(w).Encode(map[string]string{"token": "authed-token"}) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + host := strings.TrimPrefix(srv.URL, "https://") + dockerConfig := DockerConfig{ + Auths: map[string]DockerAuth{ + host: {Auth: base64.StdEncoding.EncodeToString([]byte("user:pass"))}, + }, + } + configJSON, _ := json.Marshal(dockerConfig) + + client := NewClient(srv.Client()) + digest, err := client.ResolveDigest(context.Background(), host+"/private/repo", "latest", configJSON) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if digest != expectedDigest { + t.Errorf("expected digest %q, got %q", expectedDigest, digest) + } + if receivedBasicAuth == "" { + t.Error("expected basic auth to be forwarded to token endpoint") + } +} + +func TestResolveDigest_NotFound(t *testing.T) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + client := NewClient(srv.Client()) + host := strings.TrimPrefix(srv.URL, "https://") + + _, err := client.ResolveDigest(context.Background(), host+"/org/repo", "missing", nil) + if err == nil { + t.Fatal("expected error for 404") + } +} + +func TestResolveDigest_NoDigestHeader(t *testing.T) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) // 200 but no digest header + })) + defer srv.Close() + + client := NewClient(srv.Client()) + host := strings.TrimPrefix(srv.URL, "https://") + + _, err := client.ResolveDigest(context.Background(), host+"/org/repo", "latest", nil) + if err == nil { + t.Fatal("expected error for missing digest header") + } +} + +func TestParseRepository(t *testing.T) { + tests := []struct { + input string + expectedHost string + expectedName string + }{ + {"ghcr.io/org/repo", "ghcr.io", "org/repo"}, + {"docker.io/library/nginx", "docker.io", "library/nginx"}, + {"nginx", "registry-1.docker.io", "library/nginx"}, + {"myorg/myrepo", "registry-1.docker.io", "myorg/myrepo"}, + {"registry.example.com:5000/my/image", "registry.example.com:5000", "my/image"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + host, name := parseRepository(tt.input) + if host != tt.expectedHost { + t.Errorf("expected host %q, got %q", tt.expectedHost, host) + } + if name != tt.expectedName { + t.Errorf("expected name %q, got %q", tt.expectedName, name) + } + }) + } +} + +func TestParseBearerChallenge(t *testing.T) { + tests := []struct { + header string + realm string + service string + scope string + }{ + { + `Bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:org/repo:pull"`, + "https://ghcr.io/token", "ghcr.io", "repository:org/repo:pull", + }, + { + `Bearer realm="https://auth.docker.io/token",service="registry.docker.io"`, + "https://auth.docker.io/token", "registry.docker.io", "", + }, + {`Basic realm="test"`, "", "", ""}, + {"", "", "", ""}, + } + + for _, tt := range tests { + t.Run(tt.header, func(t *testing.T) { + realm, service, scope := parseBearerChallenge(tt.header) + if realm != tt.realm { + t.Errorf("realm: expected %q, got %q", tt.realm, realm) + } + if service != tt.service { + t.Errorf("service: expected %q, got %q", tt.service, service) + } + if scope != tt.scope { + t.Errorf("scope: expected %q, got %q", tt.scope, scope) + } + }) + } +} diff --git a/internal/resources/resources_test.go b/internal/resources/resources_test.go index 1715c97..0501c21 100644 --- a/internal/resources/resources_test.go +++ b/internal/resources/resources_test.go @@ -53,7 +53,7 @@ func newTestInstance(name string) *paperclipv1alpha1.Instance { func TestBuildStatefulSet(t *testing.T) { instance := newTestInstance("my-paperclip") - sts := BuildStatefulSet(instance) + sts := BuildStatefulSet(instance, nil) if sts.Name != "my-paperclip" { t.Errorf("expected StatefulSet name 'my-paperclip', got %q", sts.Name) @@ -123,7 +123,7 @@ func TestBuildStatefulSet(t *testing.T) { func TestBuildStatefulSetWithDigest(t *testing.T) { instance := newTestInstance("my-paperclip") instance.Spec.Image.Digest = "sha256:abc123" - sts := BuildStatefulSet(instance) + sts := BuildStatefulSet(instance, nil) container := sts.Spec.Template.Spec.Containers[0] expected := "ghcr.io/paperclipinc/paperclip@sha256:abc123" @@ -136,7 +136,7 @@ func TestBuildStatefulSetEnvVars(t *testing.T) { instance := newTestInstance("my-paperclip") instance.Spec.Deployment.PublicURL = "https://paperclip.example.com" instance.Spec.Deployment.AllowedHostnames = []string{"paperclip.example.com"} - sts := BuildStatefulSet(instance) + sts := BuildStatefulSet(instance, nil) container := sts.Spec.Template.Spec.Containers[0] @@ -394,7 +394,7 @@ func TestBuildStatefulSetConnectionsEnvVars(t *testing.T) { instance.Spec.Connections = &paperclipv1alpha1.ConnectionsSpec{ CredentialsSecretRef: corev1.LocalObjectReference{Name: "oauth-creds"}, } - sts := BuildStatefulSet(instance) + sts := BuildStatefulSet(instance, nil) container := sts.Spec.Template.Spec.Containers[0] var found bool @@ -423,7 +423,7 @@ func TestBuildStatefulSetConnectionsCustomKey(t *testing.T) { CredentialsSecretRef: corev1.LocalObjectReference{Name: "oauth-creds"}, CredentialsKey: "custom-key", } - sts := BuildStatefulSet(instance) + sts := BuildStatefulSet(instance, nil) container := sts.Spec.Template.Spec.Containers[0] for _, env := range container.Env { @@ -443,7 +443,7 @@ func TestBuildStatefulSetConnectionsWithProvidersCatalog(t *testing.T) { CredentialsSecretRef: corev1.LocalObjectReference{Name: "oauth-creds"}, ProvidersConfigRef: &corev1.LocalObjectReference{Name: "custom-providers"}, } - sts := BuildStatefulSet(instance) + sts := BuildStatefulSet(instance, nil) container := sts.Spec.Template.Spec.Containers[0] var foundCreds, foundProviders bool @@ -472,7 +472,7 @@ func TestBuildStatefulSetConnectionsWithProvidersCatalog(t *testing.T) { func TestBuildStatefulSetNoConnections(t *testing.T) { instance := newTestInstance("my-paperclip") // Connections is nil by default - sts := BuildStatefulSet(instance) + sts := BuildStatefulSet(instance, nil) container := sts.Spec.Template.Spec.Containers[0] for _, env := range container.Env { @@ -482,6 +482,19 @@ func TestBuildStatefulSetNoConnections(t *testing.T) { } } +func TestBuildStatefulSetAutoUpdateAnnotation(t *testing.T) { + instance := newTestInstance("my-paperclip") + extraAnnotations := map[string]string{ + "paperclip.inc/resolved-digest": "sha256:abc123", + } + sts := BuildStatefulSet(instance, extraAnnotations) + + got := sts.Spec.Template.Annotations["paperclip.inc/resolved-digest"] + if got != "sha256:abc123" { + t.Errorf("expected digest annotation 'sha256:abc123', got %q", got) + } +} + func TestLabels(t *testing.T) { instance := newTestInstance("my-paperclip") labels := Labels(instance) diff --git a/internal/resources/statefulset.go b/internal/resources/statefulset.go index b597a19..b9dd5b0 100644 --- a/internal/resources/statefulset.go +++ b/internal/resources/statefulset.go @@ -12,7 +12,7 @@ import ( ) // BuildStatefulSet constructs the Paperclip server StatefulSet. -func BuildStatefulSet(instance *paperclipv1alpha1.Instance) *appsv1.StatefulSet { +func BuildStatefulSet(instance *paperclipv1alpha1.Instance, extraPodAnnotations map[string]string) *appsv1.StatefulSet { labels := LabelsWithComponent(instance, "server") selectorLabels := SelectorLabels(instance) @@ -80,6 +80,9 @@ func BuildStatefulSet(instance *paperclipv1alpha1.Instance) *appsv1.StatefulSet for k, v := range instance.Spec.PodAnnotations { podAnnotations[k] = v } + for k, v := range extraPodAnnotations { + podAnnotations[k] = v + } sts := &appsv1.StatefulSet{ ObjectMeta: ObjectMeta(instance, StatefulSetName(instance)), From ddac42fff5f933e00423f3d8ed50504873749fcd Mon Sep 17 00:00:00 2001 From: Jannes Stubbemann Date: Mon, 23 Mar 2026 18:12:28 +0100 Subject: [PATCH 2/3] fix: remove unused nolint directives flagged by nolintlint CI uses golangci-lint v2.1.0 which doesn't flag gosec G704 on these lines, making the nolint:gosec directives unused. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/registry/client.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/registry/client.go b/internal/registry/client.go index 6cc1c7c..7b4ac5f 100644 --- a/internal/registry/client.go +++ b/internal/registry/client.go @@ -76,7 +76,7 @@ func (c *Client) ResolveDigest(ctx context.Context, repo, tag string, dockerConf func (c *Client) doWithAuth(ctx context.Context, req *http.Request, host, name, username, password string) (*http.Response, error) { // Try direct request first (works for public repos or if credentials are sufficient as basic auth) - resp, err := c.HTTP.Do(req) //nolint:gosec // URL is constructed from user-provided registry config + resp, err := c.HTTP.Do(req) if err != nil { return nil, err } @@ -105,7 +105,7 @@ func (c *Client) doWithAuth(ctx context.Context, req *http.Request, host, name, // Retry with bearer token retryReq := req.Clone(ctx) retryReq.Header.Set("Authorization", "Bearer "+token) - return c.HTTP.Do(retryReq) //nolint:gosec // URL is constructed from user-provided registry config, not untrusted input + return c.HTTP.Do(retryReq) } func (c *Client) fetchToken(ctx context.Context, realm, service, scope, username, password string) (string, error) { @@ -115,7 +115,7 @@ func (c *Client) fetchToken(ctx context.Context, realm, service, scope, username } tokenURL += "scope=" + scope - req, err := http.NewRequestWithContext(ctx, "GET", tokenURL, nil) //nolint:gosec // realm URL from registry WWW-Authenticate header + req, err := http.NewRequestWithContext(ctx, "GET", tokenURL, nil) if err != nil { return "", err } @@ -123,7 +123,7 @@ func (c *Client) fetchToken(ctx context.Context, realm, service, scope, username req.SetBasicAuth(username, password) } - resp, err := c.HTTP.Do(req) //nolint:gosec // token URL from registry WWW-Authenticate header + resp, err := c.HTTP.Do(req) if err != nil { return "", err } From 37384c18ccf5d8e2f3adc377e7e57cc0f147466c Mon Sep 17 00:00:00 2001 From: Jannes Stubbemann Date: Mon, 23 Mar 2026 18:19:01 +0100 Subject: [PATCH 3/3] fix: add #nosec G704 for standalone gosec SSRF findings The registry client makes HTTP requests to operator-configured registry URLs (not untrusted user input). Add #nosec G704 directives for the standalone gosec scanner used in CI security scan. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/registry/client.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/registry/client.go b/internal/registry/client.go index 7b4ac5f..b49238f 100644 --- a/internal/registry/client.go +++ b/internal/registry/client.go @@ -76,7 +76,7 @@ func (c *Client) ResolveDigest(ctx context.Context, repo, tag string, dockerConf func (c *Client) doWithAuth(ctx context.Context, req *http.Request, host, name, username, password string) (*http.Response, error) { // Try direct request first (works for public repos or if credentials are sufficient as basic auth) - resp, err := c.HTTP.Do(req) + resp, err := c.HTTP.Do(req) // #nosec G704 -- URL from operator-configured registry if err != nil { return nil, err } @@ -105,7 +105,7 @@ func (c *Client) doWithAuth(ctx context.Context, req *http.Request, host, name, // Retry with bearer token retryReq := req.Clone(ctx) retryReq.Header.Set("Authorization", "Bearer "+token) - return c.HTTP.Do(retryReq) + return c.HTTP.Do(retryReq) // #nosec G704 -- URL from operator-configured registry } func (c *Client) fetchToken(ctx context.Context, realm, service, scope, username, password string) (string, error) { @@ -115,7 +115,7 @@ func (c *Client) fetchToken(ctx context.Context, realm, service, scope, username } tokenURL += "scope=" + scope - req, err := http.NewRequestWithContext(ctx, "GET", tokenURL, nil) + req, err := http.NewRequestWithContext(ctx, "GET", tokenURL, nil) // #nosec G704 -- realm URL from registry WWW-Authenticate if err != nil { return "", err } @@ -123,7 +123,7 @@ func (c *Client) fetchToken(ctx context.Context, realm, service, scope, username req.SetBasicAuth(username, password) } - resp, err := c.HTTP.Do(req) + resp, err := c.HTTP.Do(req) // #nosec G704 -- token endpoint from registry if err != nil { return "", err }