From 179bf6528711224fd42a3b098e860cfffe0dc645 Mon Sep 17 00:00:00 2001 From: hugo-ccabral Date: Tue, 21 Jul 2026 13:40:01 -0300 Subject: [PATCH 1/3] feat(decofile): add s3 target to serve decofile over HTTP off etcd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Content-heavy sites (e.g. econverse: 5.5MB decofile, 85% inline blog HTML) compress to ~1.02MB brotli+base64 — over the ~1MB etcd ConfigMap limit, so publishes start failing. Add an opt-in `spec.target: s3` that uploads the merged decofile JSON to S3 and points pods at it over HTTP (DECO_RELEASE=https://…) instead of mounting a ConfigMap, removing the etcd ceiling entirely. - CRD: `s3` target enum + Status.ContentHash/S3URL + S3ObjectKey/DeploymentIdOrName helpers - controller: s3sink.go (S3Uploader + reconcileS3) — retrieve → hash-gate → PutObject (raw JSON) → reuse pod hot-reload notifier; inert unless DECOFILE_S3_BUCKET is set - webhook: for target=s3, set DECO_RELEASE to the HTTP URL (no volume mount) and merge the host into DECO_ALLOWED_AUTHORITIES preserving runtime defaults - chart: DECOFILE_S3_* env wired via helm-generator + values.decofileS3 - tests: S3ObjectKey + mergeAllowedAuthorities Runtime serves plain JSON (the HTTP decofile provider does fetch+JSON.parse with no brotli/base64 decode). Reversible: flip target back to configmap. Co-Authored-By: Claude Opus 4.8 (1M context) --- api/v1alpha1/decofile_types.go | 40 +++- api/v1alpha1/decofile_types_test.go | 41 ++++ ...sourcedefinition-decofiles.deco.sites.yaml | 11 + ...eployment-operator-controller-manager.yaml | 12 +- chart/values.yaml | 13 ++ cmd/main.go | 9 + config/crd/bases/deco.sites_decofiles.yaml | 11 + hack/helm-generator/main.go | 12 +- internal/controller/decofile_controller.go | 10 + internal/controller/s3sink.go | 216 ++++++++++++++++++ .../webhook/v1/allowed_authorities_test.go | 63 +++++ internal/webhook/v1/service_webhook.go | 115 +++++++++- 12 files changed, 541 insertions(+), 12 deletions(-) create mode 100644 api/v1alpha1/decofile_types_test.go create mode 100644 internal/controller/s3sink.go create mode 100644 internal/webhook/v1/allowed_authorities_test.go diff --git a/api/v1alpha1/decofile_types.go b/api/v1alpha1/decofile_types.go index 5105523..391974f 100644 --- a/api/v1alpha1/decofile_types.go +++ b/api/v1alpha1/decofile_types.go @@ -17,6 +17,8 @@ limitations under the License. package v1alpha1 import ( + "strings" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) @@ -37,6 +39,11 @@ const ( TargetConfigMap = "configmap" // TargetTanstackKV runs a Job that pushes the decofile to Cloudflare KV. TargetTanstackKV = "tanstack-kv" + // TargetS3 uploads the merged decofile JSON to S3 and points the runtime at + // it over HTTP (DECO_RELEASE=https://…) instead of mounting a ConfigMap. + // Escapes the ~1MB etcd ConfigMap ceiling for content-heavy sites. Bucket / + // region / public host come from operator env (DECOFILE_S3_*). + TargetS3 = "s3" ) // DecofileSpec defines the desired state of Decofile. @@ -65,7 +72,9 @@ type DecofileSpec struct { // "configmap" (default) writes a ConfigMap and notifies Knative pods. // "tanstack-kv" runs a self-cleaning Job that pushes the decofile to Cloudflare // KV — the fast-deploy content path for TanStack/Workers sites. - // +kubebuilder:validation:Enum=configmap;tanstack-kv + // "s3" uploads the decofile to S3 and serves it over HTTP (no ConfigMap) — + // the path for content-heavy sites that would exceed the etcd ConfigMap limit. + // +kubebuilder:validation:Enum=configmap;tanstack-kv;s3 // +kubebuilder:default=configmap // +optional Target string `json:"target,omitempty"` @@ -146,6 +155,15 @@ type DecofileStatus struct { // JobName is the K8s Job name for the current tanstack-kv sync (target=tanstack-kv). // +optional JobName string `json:"jobName,omitempty"` + + // ContentHash is the SHA-256 of the last delivered decofile JSON. Used by the + // s3 target to skip re-upload/notify when content is unchanged. + // +optional + ContentHash string `json:"contentHash,omitempty"` + + // S3URL is the HTTP URL the runtime reads from when target=s3. + // +optional + S3URL string `json:"s3URL,omitempty"` } // +kubebuilder:object:root=true @@ -165,6 +183,26 @@ func (d *Decofile) ConfigMapName() string { return "decofile-" + d.Name } +// DeploymentIdOrName returns spec.deploymentId, defaulting to the object name. +func (d *Decofile) DeploymentIdOrName() string { + if d.Spec.DeploymentId != "" { + return d.Spec.DeploymentId + } + return d.Name +} + +// S3ObjectKey returns the deterministic S3 key for this Decofile's config, +// derived the same way by the reconciler (upload) and the Service webhook +// (DECO_RELEASE URL) so neither depends on status. prefix is the operator-wide +// DECOFILE_S3_PREFIX (may be empty). +func (d *Decofile) S3ObjectKey(prefix string) string { + key := d.Namespace + "/" + d.DeploymentIdOrName() + "/decofile.json" + if prefix != "" { + return strings.Trim(prefix, "/") + "/" + key + } + return key +} + // +kubebuilder:object:root=true // DecofileList contains a list of Decofile. diff --git a/api/v1alpha1/decofile_types_test.go b/api/v1alpha1/decofile_types_test.go new file mode 100644 index 0000000..b554ad1 --- /dev/null +++ b/api/v1alpha1/decofile_types_test.go @@ -0,0 +1,41 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +*/ + +package v1alpha1 + +import ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// The S3 object key must be derived identically by the reconciler (upload) and +// the Service webhook (DECO_RELEASE URL), so this pins the shared shape. +func TestDecofileS3ObjectKey(t *testing.T) { + df := &Decofile{ + ObjectMeta: metav1.ObjectMeta{Name: "dep-123", Namespace: "sites-econverse"}, + } + cases := []struct { + name string + deploymentId string + prefix string + want string + }{ + {"name as deploymentId, no prefix", "", "", "sites-econverse/dep-123/decofile.json"}, + {"explicit deploymentId", "abc", "", "sites-econverse/abc/decofile.json"}, + {"prefix trimmed", "", "decofiles/", "decofiles/sites-econverse/dep-123/decofile.json"}, + {"prefix without slash", "", "decofiles", "decofiles/sites-econverse/dep-123/decofile.json"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + df.Spec.DeploymentId = tc.deploymentId + if got := df.S3ObjectKey(tc.prefix); got != tc.want { + t.Fatalf("S3ObjectKey(%q) = %q, want %q", tc.prefix, got, tc.want) + } + }) + } +} diff --git a/chart/templates/customresourcedefinition-decofiles.deco.sites.yaml b/chart/templates/customresourcedefinition-decofiles.deco.sites.yaml index 002db38..1373d4c 100644 --- a/chart/templates/customresourcedefinition-decofiles.deco.sites.yaml +++ b/chart/templates/customresourcedefinition-decofiles.deco.sites.yaml @@ -114,9 +114,12 @@ spec: "configmap" (default) writes a ConfigMap and notifies Knative pods. "tanstack-kv" runs a self-cleaning Job that pushes the decofile to Cloudflare KV — the fast-deploy content path for TanStack/Workers sites. + "s3" uploads the decofile to S3 and serves it over HTTP (no ConfigMap) — + the path for content-heavy sites that would exceed the etcd ConfigMap limit. enum: - configmap - tanstack-kv + - s3 type: string required: - source @@ -191,6 +194,11 @@ spec: description: ConfigMapName is the name of the ConfigMap created for this Decofile type: string + contentHash: + description: |- + ContentHash is the SHA-256 of the last delivered decofile JSON. Used by the + s3 target to skip re-upload/notify when content is unchanged. + type: string githubCommit: description: GitHubCommit stores the commit SHA if using GitHub source type: string @@ -202,6 +210,9 @@ spec: description: LastUpdated is the timestamp of the last update format: date-time type: string + s3URL: + description: S3URL is the HTTP URL the runtime reads from when target=s3. + type: string sourceType: description: SourceType indicates which source was used (inline or github) diff --git a/chart/templates/deployment-operator-controller-manager.yaml b/chart/templates/deployment-operator-controller-manager.yaml index c483a74..1b70623 100644 --- a/chart/templates/deployment-operator-controller-manager.yaml +++ b/chart/templates/deployment-operator-controller-manager.yaml @@ -47,7 +47,7 @@ spec: command: - /manager image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" - {{- if or (and .Values.github (or .Values.github.token .Values.github.existingSecret)) .Values.operatorApi.existingSecret (and .Values.valkey (get .Values.valkey "sentinelUrls")) .Values.cfworkers.existingSecret .Values.cfworkers.builderImage .Values.cfworkers.artifactsBucket .Values.s3.region .Values.s3.logsBucket .Values.s3.stateBucket .Values.build.serviceAccount .Values.build.roleArn .Values.build.nodeSelector .Values.build.tolerations (and .Values.fastDeploy .Values.fastDeploy.syncerImage) }} + {{- if or (and .Values.github (or .Values.github.token .Values.github.existingSecret)) .Values.operatorApi.existingSecret (and .Values.valkey (get .Values.valkey "sentinelUrls")) .Values.cfworkers.existingSecret .Values.cfworkers.builderImage .Values.cfworkers.artifactsBucket .Values.s3.region .Values.s3.logsBucket .Values.s3.stateBucket .Values.build.serviceAccount .Values.build.roleArn .Values.build.nodeSelector .Values.build.tolerations (and .Values.fastDeploy .Values.fastDeploy.syncerImage) (and .Values.decofileS3 .Values.decofileS3.bucket) }} env: {{- if and .Values.github .Values.github.existingSecret }} - name: GITHUB_TOKEN @@ -135,6 +135,16 @@ spec: - name: DECOFILE_SYNCER_IMAGE value: {{ .Values.fastDeploy.syncerImage | quote }} {{- end }} + {{- if and .Values.decofileS3 .Values.decofileS3.bucket }} + - name: DECOFILE_S3_BUCKET + value: {{ .Values.decofileS3.bucket | quote }} + - name: DECOFILE_S3_REGION + value: {{ .Values.decofileS3.region | quote }} + - name: DECOFILE_S3_PUBLIC_HOST + value: {{ .Values.decofileS3.publicHost | quote }} + - name: DECOFILE_S3_PREFIX + value: {{ .Values.decofileS3.prefix | quote }} + {{- end }} {{- if .Values.operatorApi.existingSecret }} {{- if .Values.operatorApi.addr }} - name: OPERATOR_API_ADDR diff --git a/chart/values.yaml b/chart/values.yaml index 22663be..ff31ee8 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -116,6 +116,19 @@ s3: logsBucket: "" # S3 bucket for build logs stateBucket: "" # S3 bucket for site state +# Decofile S3 delivery (target=s3): serves the decofile to pods over HTTP from +# S3 instead of a ConfigMap, escaping the ~1MB etcd ConfigMap limit. Inert +# unless `bucket` is set. The controller writes to S3 with credentials from its +# EKS Pod Identity association (same mechanism as the build buckets) — that +# role needs s3:PutObject on the bucket. `publicHost` is the runtime-facing host +# (a CloudFront/VPC-restricted domain fronting the private bucket — the decofile +# can contain secrets, so the bucket must NOT be public-read). +decofileS3: + bucket: "" # S3 bucket name (required to enable) + region: "" # bucket region (co-locate with the EKS cluster) + publicHost: "" # host for DECO_RELEASE URL, e.g. configs.decocdn.com + prefix: "" # optional key prefix, e.g. "decofiles" + # Build job config — shared across all build platforms build: serviceAccount: "" # K8s ServiceAccount for builder pods (IRSA) diff --git a/cmd/main.go b/cmd/main.go index 5fa10db..143e959 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -357,11 +357,20 @@ func main() { decositesv1alpha1.TargetTanstackKV, deploy.NewTanstackKV(tkvCfg), ) + // s3 target: HTTP-delivered decofile for content-heavy sites. Inert + // (nil) unless DECOFILE_S3_BUCKET is set. + s3Uploader, s3err := controller.NewS3UploaderFromEnv(context.Background()) + if s3err != nil { + setupLog.Error(s3err, "decofile s3 target disabled: invalid DECOFILE_S3_* config") + } else if s3Uploader != nil { + setupLog.Info("decofile s3 target enabled") + } if err = (&controller.DecofileReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), HTTPClient: httpClient, FastDeploy: fastDeployRegistry, + S3: s3Uploader, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "Decofile") os.Exit(1) diff --git a/config/crd/bases/deco.sites_decofiles.yaml b/config/crd/bases/deco.sites_decofiles.yaml index 997a9fd..9d841e7 100644 --- a/config/crd/bases/deco.sites_decofiles.yaml +++ b/config/crd/bases/deco.sites_decofiles.yaml @@ -115,9 +115,12 @@ spec: "configmap" (default) writes a ConfigMap and notifies Knative pods. "tanstack-kv" runs a self-cleaning Job that pushes the decofile to Cloudflare KV — the fast-deploy content path for TanStack/Workers sites. + "s3" uploads the decofile to S3 and serves it over HTTP (no ConfigMap) — + the path for content-heavy sites that would exceed the etcd ConfigMap limit. enum: - configmap - tanstack-kv + - s3 type: string required: - source @@ -192,6 +195,11 @@ spec: description: ConfigMapName is the name of the ConfigMap created for this Decofile type: string + contentHash: + description: |- + ContentHash is the SHA-256 of the last delivered decofile JSON. Used by the + s3 target to skip re-upload/notify when content is unchanged. + type: string githubCommit: description: GitHubCommit stores the commit SHA if using GitHub source type: string @@ -203,6 +211,9 @@ spec: description: LastUpdated is the timestamp of the last update format: date-time type: string + s3URL: + description: S3URL is the HTTP URL the runtime reads from when target=s3. + type: string sourceType: description: SourceType indicates which source was used (inline or github) diff --git a/hack/helm-generator/main.go b/hack/helm-generator/main.go index 1f1d973..18dd04a 100644 --- a/hack/helm-generator/main.go +++ b/hack/helm-generator/main.go @@ -224,7 +224,7 @@ func addEnvVarsToDeployment(templatesDir string) error { contentStr := string(content) // Find the image line and add env vars after it - envBlock := ` {{- if or (and .Values.github (or .Values.github.token .Values.github.existingSecret)) (and .Values.valkey (get .Values.valkey "sentinelUrls")) .Values.cfworkers.existingSecret .Values.cfworkers.builderImage .Values.cfworkers.artifactsBucket .Values.s3.region .Values.s3.logsBucket .Values.s3.stateBucket .Values.build.serviceAccount .Values.build.roleArn .Values.build.nodeSelector .Values.build.tolerations (and .Values.fastDeploy .Values.fastDeploy.syncerImage) }} + envBlock := ` {{- if or (and .Values.github (or .Values.github.token .Values.github.existingSecret)) (and .Values.valkey (get .Values.valkey "sentinelUrls")) .Values.cfworkers.existingSecret .Values.cfworkers.builderImage .Values.cfworkers.artifactsBucket .Values.s3.region .Values.s3.logsBucket .Values.s3.stateBucket .Values.build.serviceAccount .Values.build.roleArn .Values.build.nodeSelector .Values.build.tolerations (and .Values.fastDeploy .Values.fastDeploy.syncerImage) (and .Values.decofileS3 .Values.decofileS3.bucket) }} env: {{- if and .Values.github .Values.github.existingSecret }} - name: GITHUB_TOKEN @@ -312,6 +312,16 @@ func addEnvVarsToDeployment(templatesDir string) error { - name: DECOFILE_SYNCER_IMAGE value: {{ .Values.fastDeploy.syncerImage | quote }} {{- end }} + {{- if and .Values.decofileS3 .Values.decofileS3.bucket }} + - name: DECOFILE_S3_BUCKET + value: {{ .Values.decofileS3.bucket | quote }} + - name: DECOFILE_S3_REGION + value: {{ .Values.decofileS3.region | quote }} + - name: DECOFILE_S3_PUBLIC_HOST + value: {{ .Values.decofileS3.publicHost | quote }} + - name: DECOFILE_S3_PREFIX + value: {{ .Values.decofileS3.prefix | quote }} + {{- end }} {{- end }}` imageLine := ` image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"` diff --git a/internal/controller/decofile_controller.go b/internal/controller/decofile_controller.go index 80456ae..73d437f 100644 --- a/internal/controller/decofile_controller.go +++ b/internal/controller/decofile_controller.go @@ -63,6 +63,9 @@ type DecofileReconciler struct { // FastDeploy dispatches non-configmap Decofile targets (e.g. tanstack-kv) to // a pluggable FastDeployment strategy. Nil = only the default ConfigMap path. FastDeploy *deploy.DeploymentRegistry + // S3 delivers the decofile via S3+HTTP for target=s3 (content-heavy sites + // that would exceed the etcd ConfigMap limit). Nil = s3 target unavailable. + S3 *S3Uploader } // +kubebuilder:rbac:groups=deco.sites,resources=decofiles,verbs=get;list;watch;create;update;patch;delete @@ -100,6 +103,13 @@ func (r *DecofileReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c log.V(1).Info("Fetched Decofile", "duration", time.Since(fetchStart)) + // s3 target: deliver over HTTP from S3 instead of a ConfigMap (escapes the + // etcd ConfigMap limit). Handled inline (not a FastDeployment) because it + // reuses this package's source retrieval + pod notifier. + if decofile.Spec.Target == decositesv1alpha1.TargetS3 { + return r.reconcileS3(ctx, req, decofile) + } + // Pluggable delivery: non-configmap targets (e.g. tanstack-kv KV sync) are // driven by a FastDeployment strategy that owns its own child resources + // status. The default/empty target falls through to the ConfigMap + Knative diff --git a/internal/controller/s3sink.go b/internal/controller/s3sink.go new file mode 100644 index 0000000..f89a848 --- /dev/null +++ b/internal/controller/s3sink.go @@ -0,0 +1,216 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/s3" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrl "sigs.k8s.io/controller-runtime" + logf "sigs.k8s.io/controller-runtime/pkg/log" + + decositesv1alpha1 "github.com/deco-sites/decofile-operator/api/v1alpha1" +) + +// S3Uploader delivers the merged decofile as a plain-JSON object to S3, from +// which the deco runtime reads it over HTTP (DECO_RELEASE=https://…). It is the +// escape hatch for content-heavy sites whose decofile exceeds the ~1MB etcd +// ConfigMap limit. Nil when DECOFILE_S3_BUCKET is unset (s3 target disabled). +type S3Uploader struct { + client *s3.Client + bucket string + region string + prefix string + publicHost string // host used to build the runtime-facing HTTP URL +} + +// NewS3UploaderFromEnv builds an uploader from DECOFILE_S3_* env. Returns +// (nil, nil) when DECOFILE_S3_BUCKET is unset so the operator runs unchanged for +// clusters not using the s3 target. AWS credentials resolve via the default +// chain (IRSA web-identity in EKS). +func NewS3UploaderFromEnv(ctx context.Context) (*S3Uploader, error) { + bucket := os.Getenv("DECOFILE_S3_BUCKET") + if bucket == "" { + return nil, nil + } + region := os.Getenv("DECOFILE_S3_REGION") + cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(region)) + if err != nil { + return nil, fmt.Errorf("load aws config for decofile s3 target: %w", err) + } + return &S3Uploader{ + client: s3.NewFromConfig(cfg), + bucket: bucket, + region: region, + prefix: os.Getenv("DECOFILE_S3_PREFIX"), + publicHost: os.Getenv("DECOFILE_S3_PUBLIC_HOST"), + }, nil +} + +// Upload PUTs the raw decofile JSON. Served as plain JSON so the runtime's HTTP +// provider (fetch + JSON.parse) reads it directly — no compression, so no +// base64/brotli decode is needed on the runtime side. +// ponytail: uncompressed; add gzip + Content-Encoding if in-region egress/latency +// ever measures as a problem (Deno fetch auto-gunzips). +func (u *S3Uploader) Upload(ctx context.Context, key, jsonContent string) error { + _, err := u.client.PutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(u.bucket), + Key: aws.String(key), + Body: strings.NewReader(jsonContent), + ContentType: aws.String("application/json"), + CacheControl: aws.String("no-cache"), + }) + if err != nil { + return fmt.Errorf("put s3://%s/%s: %w", u.bucket, key, err) + } + return nil +} + +// URLFor builds the runtime-facing HTTP URL for an object key. Prefers the +// configured public host (a CloudFront/VPC-restricted domain); falls back to the +// regional virtual-hosted S3 endpoint. +func (u *S3Uploader) URLFor(key string) string { + host := u.publicHost + if host == "" { + host = fmt.Sprintf("%s.s3.%s.amazonaws.com", u.bucket, u.region) + } + return fmt.Sprintf("https://%s/%s", host, key) +} + +func sha256hex(s string) string { + sum := sha256.Sum256([]byte(s)) + return hex.EncodeToString(sum[:]) +} + +// reconcileS3 delivers the decofile via S3+HTTP instead of a ConfigMap. It +// mirrors the ConfigMap path (retrieve → deliver → notify pods) but writes to +// S3 and gates re-work on a content hash so it never blows the etcd limit. +func (r *DecofileReconciler) reconcileS3(ctx context.Context, req ctrl.Request, decofile *decositesv1alpha1.Decofile) (ctrl.Result, error) { + log := logf.FromContext(ctx) + + if r.S3 == nil { + return ctrl.Result{}, fmt.Errorf("decofile target=s3 but operator has no S3 config (DECOFILE_S3_BUCKET unset)") + } + + deploymentId := decofile.DeploymentIdOrName() + + // GitHub gate: if the commit is unchanged and we've delivered before, there's + // nothing to do — skip the (expensive) repo download entirely. + if decofile.Spec.Source == SourceTypeGitHub && decofile.Spec.GitHub != nil && + decofile.Status.GitHubCommit == decofile.Spec.GitHub.Commit && + decofile.Status.ContentHash != "" { + log.V(1).Info("s3: github commit unchanged and already delivered, skipping") + return ctrl.Result{}, nil + } + + source, err := NewSource(r.Client, decofile) + if err != nil { + log.Error(err, "s3: failed to create source") + return ctrl.Result{}, err + } + jsonContent, err := source.Retrieve(ctx) + if err != nil { + log.Error(err, "s3: failed to retrieve source") + return ctrl.Result{}, err + } + + hash := sha256hex(jsonContent) + changed := hash != decofile.Status.ContentHash + key := decofile.S3ObjectKey(r.S3.prefix) + url := r.S3.URLFor(key) + + if changed { + if err := r.S3.Upload(ctx, key, jsonContent); err != nil { + log.Error(err, "s3: upload failed", "key", key) + return ctrl.Result{}, err + } + log.Info("s3: uploaded decofile", "url", url, "bytes", len(jsonContent)) + } else { + log.V(1).Info("s3: content unchanged, skipping upload", "url", url) + } + + // Notify running pods on change (same push path as the ConfigMap target; + // the mounted/URL source is only the cold-start read). + podsNotified := true + var notifyErr string + if changed { + ts := fmt.Sprintf("%d", time.Now().Unix()) + notifier := NewNotifier(r.Client, r.HTTPClient) + if err := notifier.NotifyPodsForDecofile(ctx, decofile.Namespace, deploymentId, ts, jsonContent); err != nil { + log.Error(err, "s3: failed to notify pods", "deploymentId", deploymentId) + podsNotified = false + notifyErr = err.Error() + } else { + log.Info("s3: notified pods", "deploymentId", deploymentId) + } + } + + // Update status on the freshest object to avoid conflicts. + fresh := &decositesv1alpha1.Decofile{} + if err := r.Get(ctx, req.NamespacedName, fresh); err != nil { + log.Error(err, "s3: failed to re-fetch Decofile for status update") + return ctrl.Result{}, err + } + fresh.Status.LastUpdated = metav1.Time{Time: time.Now()} + fresh.Status.SourceType = source.SourceType() + fresh.Status.ContentHash = hash + fresh.Status.S3URL = url + if fresh.Spec.Source == SourceTypeGitHub && fresh.Spec.GitHub != nil { + fresh.Status.GitHubCommit = fresh.Spec.GitHub.Commit + } + updateCondition(fresh, metav1.Condition{ + Type: "Ready", + Status: metav1.ConditionTrue, + Reason: "S3Uploaded", + Message: fmt.Sprintf("Decofile delivered to %s", url), + LastTransitionTime: metav1.Now(), + }) + if changed { + cond := metav1.Condition{ + Type: condTypePodsNotified, + LastTransitionTime: metav1.Now(), + } + if podsNotified { + cond.Status = metav1.ConditionTrue + cond.Reason = "NotificationSucceeded" + cond.Message = fmt.Sprintf("Notified pods for hash:%s", hash[:12]) + } else { + cond.Status = metav1.ConditionFalse + cond.Reason = "NotificationFailed" + cond.Message = fmt.Sprintf("Failed to notify pods for hash:%s: %s", hash[:12], notifyErr) + } + updateCondition(fresh, cond) + } + if err := r.Status().Update(ctx, fresh); err != nil { + log.Error(err, "s3: failed to update status") + return ctrl.Result{}, err + } + + if changed && !podsNotified { + return ctrl.Result{}, fmt.Errorf("s3: failed to notify pods: %s", notifyErr) + } + return ctrl.Result{}, nil +} diff --git a/internal/webhook/v1/allowed_authorities_test.go b/internal/webhook/v1/allowed_authorities_test.go new file mode 100644 index 0000000..b64eb59 --- /dev/null +++ b/internal/webhook/v1/allowed_authorities_test.go @@ -0,0 +1,63 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +*/ + +package v1 + +import "testing" + +// Run without envtest: go test -run TestMergeAllowedAuthorities ./internal/webhook/v1/ +func TestMergeAllowedAuthorities(t *testing.T) { + cases := []struct { + name string + existing string + exists bool + host string + wantValue string + wantSetEnv bool + }{ + { + name: "unset + custom host => defaults plus host", + exists: false, + host: "decofiles.example.com", + wantValue: "configs.decocdn.com,configs.deco.cx,admin.deco.cx,localhost,decofiles.example.com", + wantSetEnv: true, + }, + { + name: "unset + host already a default => no env needed", + exists: false, + host: "configs.decocdn.com", + wantSetEnv: false, + }, + { + name: "existing without host => appended, existing preserved", + existing: "foo.com,bar.com", + exists: true, + host: "decofiles.example.com", + wantValue: "foo.com,bar.com,decofiles.example.com", + wantSetEnv: true, + }, + { + name: "existing already contains host => unchanged (still written to preserve)", + existing: "foo.com,decofiles.example.com", + exists: true, + host: "decofiles.example.com", + wantValue: "foo.com,decofiles.example.com", + wantSetEnv: true, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + value, setEnv := mergeAllowedAuthorities(tc.existing, tc.exists, tc.host) + if setEnv != tc.wantSetEnv { + t.Fatalf("setEnv = %v, want %v", setEnv, tc.wantSetEnv) + } + if setEnv && value != tc.wantValue { + t.Fatalf("value = %q, want %q", value, tc.wantValue) + } + }) + } +} diff --git a/internal/webhook/v1/service_webhook.go b/internal/webhook/v1/service_webhook.go index 3351c0c..19b9602 100644 --- a/internal/webhook/v1/service_webhook.go +++ b/internal/webhook/v1/service_webhook.go @@ -19,6 +19,8 @@ package v1 import ( "context" "fmt" + "os" + "strings" "github.com/google/uuid" corev1 "k8s.io/api/core/v1" @@ -138,6 +140,93 @@ func (d *ServiceCustomDefaulter) injectDecofileVolume(_ context.Context, service return nil } +// defaultAllowedAuthorities mirrors the deco runtime's built-in allowlist +// (engine/trustedAuthority.ts). Setting DECO_ALLOWED_AUTHORITIES replaces (not +// appends to) that default, so when we inject an S3/CloudFront host we must +// re-include the defaults or existing HTTP decofile fetches would break. +var defaultAllowedAuthorities = []string{ + "configs.decocdn.com", "configs.deco.cx", "admin.deco.cx", "localhost", +} + +// injectDecofileHTTP points DECO_RELEASE at the S3-backed HTTP URL and ensures +// the host is in DECO_ALLOWED_AUTHORITIES. No volume/mount is added — the +// decofile is fetched over HTTP, not read from a mounted ConfigMap. Key + host +// are derived deterministically (same as the reconciler) so this does not +// depend on the Decofile having been reconciled yet. +func (d *ServiceCustomDefaulter) injectDecofileHTTP(service *servingknativedevv1.Service, decofile *decositesv1alpha1.Decofile) error { + if len(service.Spec.Template.Spec.Containers) == 0 { + return fmt.Errorf("no containers found in Service spec") + } + host := os.Getenv("DECOFILE_S3_PUBLIC_HOST") + if host == "" { + return fmt.Errorf("decofile target=s3 but DECOFILE_S3_PUBLIC_HOST is not set on the operator") + } + key := decofile.S3ObjectKey(os.Getenv("DECOFILE_S3_PREFIX")) + url := fmt.Sprintf("https://%s/%s", host, key) + + idx := d.findTargetContainer(service) + // Reuses the ConfigMap path's env setter: sets DECO_RELEASE + reload token. + d.addOrUpdateEnvVars(service, idx, url) + d.ensureAllowedAuthority(service, idx, host) + return nil +} + +const allowedAuthoritiesEnv = "DECO_ALLOWED_AUTHORITIES" + +// mergeAllowedAuthorities computes the DECO_ALLOWED_AUTHORITIES value needed to +// let the runtime fetch the decofile from host. exists reports whether the +// container already sets the env (existing is its value). It returns the value +// to write and setEnv=false when no write is needed — i.e. the var is unset and +// host is already one of the runtime's built-in defaults, so the default +// allowlist already covers it. Pure (no k8s types) so it is unit-testable. +func mergeAllowedAuthorities(existing string, exists bool, host string) (value string, setEnv bool) { + var vals []string + if exists { + for _, v := range strings.Split(existing, ",") { + if v = strings.TrimSpace(v); v != "" { + vals = append(vals, v) + } + } + } else { + vals = append(vals, defaultAllowedAuthorities...) + } + for _, v := range vals { + if v == host { + // Already allowed. Only rewrite if the var already exists (to keep + // it); otherwise the runtime default covers it and no env is needed. + return existing, exists + } + } + return strings.Join(append(vals, host), ","), true +} + +// ensureAllowedAuthority makes sure host is present in the container's +// DECO_ALLOWED_AUTHORITIES env var, preserving any existing entries (or the +// runtime defaults when the var is unset). +func (d *ServiceCustomDefaulter) ensureAllowedAuthority(service *servingknativedevv1.Service, containerIdx int, host string) { + container := &service.Spec.Template.Spec.PodSpec.Containers[containerIdx] + + existingIdx := -1 + var existing string + for i, env := range container.Env { + if env.Name == allowedAuthoritiesEnv { + existingIdx = i + existing = env.Value + break + } + } + + value, setEnv := mergeAllowedAuthorities(existing, existingIdx >= 0, host) + if !setEnv { + return + } + if existingIdx >= 0 { + container.Env[existingIdx].Value = value + } else { + container.Env = append(container.Env, corev1.EnvVar{Name: allowedAuthoritiesEnv, Value: value}) + } +} + // addOrUpdateVolume adds or updates the decofile volume func (d *ServiceCustomDefaulter) addOrUpdateVolume(service *servingknativedevv1.Service, configMapName string) { volumeName := "decofile-config" @@ -273,15 +362,23 @@ func (d *ServiceCustomDefaulter) Default(ctx context.Context, obj runtime.Object return nil // Allow Service creation } - // Get mount path from annotation or use default directory - mountDir := "/app/decofile" - if customPath, exists := service.Annotations[decofileMountPathAnnot]; exists { - mountDir = customPath - } + // s3 target: point the runtime at the HTTP URL instead of mounting a + // ConfigMap volume (the decofile lives in S3, not etcd). + if decofile.Spec.Target == decositesv1alpha1.TargetS3 { + if err := d.injectDecofileHTTP(service, decofile); err != nil { + return err + } + } else { + // Get mount path from annotation or use default directory + mountDir := "/app/decofile" + if customPath, exists := service.Annotations[decofileMountPathAnnot]; exists { + mountDir = customPath + } - // Inject Decofile volume and env vars - if err := d.injectDecofileVolume(ctx, service, decofile, mountDir); err != nil { - return err + // Inject Decofile volume and env vars + if err := d.injectDecofileVolume(ctx, service, decofile, mountDir); err != nil { + return err + } } // Explicitly add deploymentId label to pod template for notification @@ -296,7 +393,7 @@ func (d *ServiceCustomDefaulter) Default(ctx context.Context, obj runtime.Object // falling back to deco's FILE_SYSTEM cache in the meantime. d.addOrUpdateValkeyEnvFrom(service) - servicelog.Info("Successfully injected Decofile into Service", "service", service.Name, "deploymentId", deploymentId, "configmap", decofile.ConfigMapName()) + servicelog.Info("Successfully injected Decofile into Service", "service", service.Name, "deploymentId", deploymentId, "target", decofile.Spec.Target) return nil } From 050c830821fc79d8967f0a5f30c5d178fe7b9501 Mon Sep 17 00:00:00 2001 From: hugo-ccabral Date: Tue, 21 Jul 2026 23:30:53 -0300 Subject: [PATCH 2/3] =?UTF-8?q?docs(decofile):=20document=20the=20s3=20tar?= =?UTF-8?q?get=20=E2=80=94=20architecture=20+=20onboarding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ARCHITECTURE.md: cold-start and hot-reload sequence diagrams for target=s3 (Flow 7a/7b), a configmap-vs-s3 comparison table, and the design decisions (no compression, content-hash change detection, shared key derivation, additive DECO_ALLOWED_AUTHORITIES merge). docs/decofile-s3-onboarding.md: operational runbook (mirrors fast-deploy-onboarding.md) — mental model, platform prerequisites, per-site opt-in steps, verification commands, constraints, troubleshooting, rollback. Co-Authored-By: Claude Opus 4.8 (1M context) --- ARCHITECTURE.md | 95 ++++++++++++++++++++++ docs/decofile-s3-onboarding.md | 143 +++++++++++++++++++++++++++++++++ 2 files changed, 238 insertions(+) create mode 100644 docs/decofile-s3-onboarding.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ef6038d..1b78fed 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -242,6 +242,101 @@ sequenceDiagram end ``` +## Decofile Delivery: S3 Target (etcd Offload) + +Content-heavy sites (many/large blocks — e.g. a blog with full-HTML posts inline) +can push the merged decofile's brotli+base64 size past the ~1MB etcd ConfigMap +ceiling, at which point the Kubernetes API server rejects the ConfigMap write and +the site can no longer publish. `spec.target: "s3"` is an **opt-in, per-site** +alternative: the operator uploads the decofile to S3 as plain JSON and points +pods at it over HTTP instead of mounting a ConfigMap. Fully reversible — flip +`target` back to `configmap` (the default) and the next reconcile reverts. + +### Flow 7a: Cold Start (target=s3) + +```mermaid +sequenceDiagram + participant User + participant K8s as Kubernetes API + participant Ctrl as Decofile Controller + participant S3 as S3 Bucket + participant Webhook as Mutating Webhook + participant Pod as Site Pod + + User->>K8s: Create/Update Decofile (spec.target: s3) + K8s->>Ctrl: Reconcile Event + Ctrl->>Ctrl: Retrieve source (github/inline) → JSON + Ctrl->>Ctrl: SHA-256 hash content + alt Hash unchanged (& commit unchanged for github) + Ctrl->>Ctrl: Skip upload — nothing to do + else Hash changed + Ctrl->>S3: PutObject (raw JSON, no compression) + Ctrl->>K8s: Update Status (ContentHash, S3URL) + end + + Note over User,Pod: Separately — Service creation/update + User->>K8s: Create Knative Service (deco.sites/decofile-inject) + K8s->>Webhook: Intercept CREATE/UPDATE + Webhook->>K8s: Get Decofile (target=s3) + Webhook->>Webhook: Derive S3 key + URL (same formula as controller —
Decofile.S3ObjectKey(), no dependency on reconcile order) + Webhook->>Webhook: Set env DECO_RELEASE=https://host/key
(NO volume/volumeMount injected) + Webhook->>Webhook: Merge host into DECO_ALLOWED_AUTHORITIES
(preserves runtime defaults if unset) + K8s->>Pod: Create Pod with injected env + Pod->>S3: GET https://host/key (plain HTTPS, no AWS auth) + S3->>Pod: 200 OK — decofile JSON + Pod->>Pod: fetch().then(JSON.parse) — no brotli/base64 decode +``` + +### Flow 7b: Hot Reload (target=s3) + +Identical to **Flow 4** (Change Notification) — the s3 target reuses +`NotifyPodsForDecofile` unchanged. The only difference from the ConfigMap path +is *what* changed (an S3 object, not a ConfigMap) and that the reconciler +pushes the **uncompressed** JSON in the notify payload (same as it always has — +`NotifyPodsForDecofile` was never brotli-aware; compression is a ConfigMap-side +concern only). Pods that implement `/.decofile/reload` don't need to know which +target delivered their *next* update; only the *initial* fetch path (mounted +file vs. HTTP GET) differs. + +### s3 vs configmap: what changes + +| | `configmap` (default) | `s3` | +|---|---|---| +| Storage | ConfigMap key `decofile.bin` (brotli+base64) | S3 object (plain JSON) | +| Size ceiling | ~1MB (etcd) | none (S3 object limit is 5TB) | +| Cold start | Volume mount, `DECO_RELEASE=file://…` | HTTP env only, `DECO_RELEASE=https://…` | +| Change detection | ConfigMap data diff | SHA-256 content hash (`Status.ContentHash`) | +| Hot reload | `NotifyPodsForDecofile` (unchanged) | `NotifyPodsForDecofile` (unchanged) | +| GC | Owned by Decofile (owner ref) — cascades | **Not GC'd** — a deleted Decofile leaves its S3 object; rely on a bucket lifecycle rule | +| `DECO_ALLOWED_AUTHORITIES` | untouched | webhook merges the S3/CDN host in, preserving runtime defaults | + +### Design decisions + +- **No compression.** The runtime's HTTP decofile provider + (`deco/engine/decofile/fetcher.ts`) does `fetch() → JSON.parse()` with no + brotli/base64 decode — that path only exists for `file://….bin`. Serving + compressed bytes would require a runtime change; S3 has no size pressure + forcing that trade, so the s3 target serves raw JSON. (`gzip` + + `Content-Encoding` — which `fetch` auto-decodes — is a possible future + optimization, not required.) +- **Content-hash gate, not ConfigMap-diff.** There's no "get the existing + object and compare" step for S3 (no cheap read-before-write like the + ConfigMap `Get`); a SHA-256 of the retrieved JSON, stored in + `Status.ContentHash`, decides whether to re-upload and re-notify. For a + `github` source this is checked *after* the (cheaper) commit-unchanged + short-circuit already used by the configmap path. +- **Deterministic key derivation shared by both sides.** `Decofile.S3ObjectKey()` + is called by both the controller (upload) and the webhook (URL for + `DECO_RELEASE`) so the webhook never needs to wait for a reconcile to know + where the object will be — same reasoning as `ConfigMapName()` for the + existing target. +- **`DECO_ALLOWED_AUTHORITIES` is additive, not overwritten.** Setting that env + var **replaces** the runtime's built-in default list (`configs.decocdn.com`, + `configs.deco.cx`, `admin.deco.cx`, `localhost`) rather than appending to it + — so the webhook always merges the s3 host into whatever's already on the + container (existing value or the defaults), never blindly setting it to just + the new host. + ## Key Design Decisions ### 1. Compression Strategy diff --git a/docs/decofile-s3-onboarding.md b/docs/decofile-s3-onboarding.md new file mode 100644 index 0000000..b8a0047 --- /dev/null +++ b/docs/decofile-s3-onboarding.md @@ -0,0 +1,143 @@ +# Decofile S3 Target — Onboarding a Site + +How to move a site's decofile off the etcd ConfigMap and onto S3, for sites +whose decofile is (or is close to) exceeding the **~1MB etcd ConfigMap limit** +(e.g. content-heavy sites with many/large blocks — blog posts with inline +HTML, large collections). Serves the decofile over **HTTP** instead of +mounting a ConfigMap — no size ceiling, same-VPC (no CDN, no internet hop). + +> Architecture + sequence diagrams: [`ARCHITECTURE.md`](../ARCHITECTURE.md#decofile-delivery-s3-target-etcd-offload). +> Companion PRs this feature shipped across: `decocms/operator#33` (this repo), +> `deco-sites/admin#3302`, `decocms/infra_applications#216`, +> `decocms/terraform-foundation-infra#27`, `decocms/terraform-eks-cluster#83`. + +## Mental model (read this first) + +**Nothing changes about how content is edited or published.** A site still +publishes the same way (git commit to `.deco/blocks/**` → admin upserts the +`Decofile` CR). What changes is **delivery**: + +| | `configmap` (default) | `s3` | +|---|---|---| +| Where the decofile lives | A ConfigMap key, mounted as a file | An S3 object, fetched over HTTP | +| How a pod gets it at boot | Reads the mounted file (`DECO_RELEASE=file://…`) | `fetch()`s the URL (`DECO_RELEASE=https://…`) | +| How a pod gets a **live update** | `POST /.decofile/reload` (operator pushes) | **identical** — same push, unchanged | +| Size ceiling | ~1MB (etcd) | none | + +**The only thing that's different for a running pod is the very first read at +boot.** Every subsequent update — someone edits a block and publishes — reaches +already-running pods exactly the same way regardless of target: the operator +detects the change and pushes a reload notification to `/.decofile/reload` on +every matching pod (see `ARCHITECTURE.md` Flow 4). The pod doesn't care whether +the *next* update source is S3 or a ConfigMap; it only mattered for how the pod +found its *first* copy when it started. + +**Nothing is manual per publish.** Once a site is onboarded (below), every +future publish flows through unchanged — the operator uploads the new content +to S3 automatically on each reconcile, exactly like it writes the ConfigMap +today. + +## Prerequisites (one-time, platform-wide) + +These need to exist before **any** site can use `target: s3`. Track via the PRs +above; do not repeat per site. + +1. **Operator ≥ the version shipping `decocms/operator#33`**, deployed with + `decofileS3.bucket`/`region`/`publicHost` set in the chart values + (`infra_applications` → `provisioning/deco-operator/main/values.yaml`). +2. **S3 bucket** `new-deco-decofiles` exists (`terraform-foundation-infra#27`), + with: + - a policy statement granting the operator's controller **Pod Identity** + role read/write (`terraform-eks-cluster#83`, module + `deco_operator_decofiles_pod_identity`) + - a policy statement granting **anonymous `GetObject`** scoped to the + eks-serverless VPC's S3 Gateway Endpoint (`aws:sourceVpce` condition) — + this is how pods read the object without any AWS credentials +3. Both terraform PRs applied with the **real VPC endpoint id** filled in + (see the `TODO` in `s3-setup/local.tf` — a placeholder until applied). + +Quick health check once deployed — create a Decofile with `target: s3` for a +throwaway site and confirm no ConfigMap is created: +```bash +kubectl -n sites- get decofile -o jsonpath='{.status.s3URL}{"\n"}' +kubectl -n sites- get configmap decofile- # should 404 +``` + +## Per-site steps + +### 1. Confirm the site actually needs this +Check the merged decofile's compressed size before flipping the switch — this +target is for sites that need it, not a default: +```bash +# rough check: sum of .deco/blocks/*.json, brotli+base64'd, vs ~1MB +du -sh .deco/blocks/ +``` +If a site is nowhere near the limit, leave it on `configmap` — no reason to +opt in early. + +### 2. Add the site to admin's opt-in list +Admin decides the target per site via the `DECOFILE_S3_SITES` env var +(comma-separated site slugs) on the `admin-env` Secret — see +`deco-sites/admin#3302` (`hosting/kubernetes/actions/decofile/upsert.ts`). +Add the site's slug there. No operator-side per-site config exists; the +`Decofile` CR's `spec.target` is set by admin on every publish. + +### 3. Publish +Trigger a normal publish for the site (git push / admin publish action). +Admin's next `Decofile` upsert will carry `spec.target: "s3"`. + +### 4. Verify +```bash +kubectl -n sites- get decofile -o yaml +# spec.target: s3 +# status.s3URL: https://new-deco-decofiles.s3.us-west-2.amazonaws.com/decofiles/... +# status.contentHash: + +kubectl -n sites- get configmap decofile- # 404 — no longer created + +# a new/rolled pod should show: +kubectl -n sites- get pod -o jsonpath='{.spec.containers[0].env}' | grep -A1 DECO_RELEASE +# DECO_RELEASE=https://new-deco-decofiles.s3.us-west-2.amazonaws.com/decofiles/... +``` +The pod's startup log line (`decofile has been loaded from https://…`, from the +deco runtime's `getProvider()`) confirms it read from S3, not a mounted file. + +### 5. Test the live-update path +Edit a block and publish again. The pod should reload **without a restart** +(same `/.decofile/reload` push as the ConfigMap path) — no extra step needed, +this isn't new behavior to configure. + +## Constraints + +- **Opt-in only, per site.** There is no automatic size-based promotion today — + a site stays on `configmap` until added to `DECOFILE_S3_SITES`. +- **Reads are unauthenticated, scoped by network path.** Any request reaching + the bucket via the eks-serverless VPC's S3 Gateway Endpoint can read any + object in the bucket (the `aws:sourceVpce` condition doesn't scope by key + prefix). Don't put anything in this bucket that shouldn't be readable by any + workload inside that VPC. +- **S3 objects are not Kubernetes-garbage-collected.** Deleting a `Decofile` + CR does not delete its S3 object (unlike the ConfigMap, which has an owner + reference). Rely on the bucket's lifecycle rule, or clean up manually if a + site is fully decommissioned. +- **No compression.** Objects are stored as plain JSON. A very large decofile + (tens of MB) will take proportionally longer to fetch at cold start than a + brotli'd ConfigMap would — there's no ceiling, but it isn't free either. + +## Troubleshooting + +| Symptom | Likely cause | Fix | +|---|---|---| +| Pod stuck without a decofile / `decofile not defined` in logs | `DECOFILE_S3_PUBLIC_HOST` not set on the operator, or the host isn't reachable from the pod (VPC endpoint misconfigured) | check operator env `DECOFILE_S3_PUBLIC_HOST`; confirm the bucket policy's `aws:sourceVpce` matches the *actual* endpoint id (not the placeholder) | +| `AccessDenied` on the operator's `PutObject` | Pod Identity association missing/misconfigured, or bucket policy condition doesn't match the role | `kubectl -n deco-system describe pod ` — check for an AWS credential env/mount; verify `terraform-eks-cluster#83` applied for `workspace=serverless` | +| Still see a ConfigMap after publish | `spec.target` didn't get set to `s3` | check admin's `DECOFILE_S3_SITES` includes the site slug; check the `Decofile` CR's `spec.target` directly | +| Live edits not reaching the pod | Same failure modes as the ConfigMap path — this isn't s3-specific | see `ARCHITECTURE.md` notification troubleshooting; check `PodsNotified` condition on the `Decofile` | +| `authority ... is not allowed to be fetched from` in pod logs | Webhook didn't merge the S3 host into `DECO_ALLOWED_AUTHORITIES` (e.g. Service predates the webhook version) | re-roll the Knative Revision so the webhook re-runs; check the pod's `DECO_ALLOWED_AUTHORITIES` env includes the S3 host | + +## Disable / rollback + +Remove the site's slug from `DECOFILE_S3_SITES` and publish again. The next +`Decofile` upsert sets `spec.target: "configmap"`; the operator creates the +ConfigMap and the webhook mounts it on the next pod roll. The stale S3 object +is harmless (unauthenticated read-only, no owner reference — see Constraints) +but isn't automatically cleaned up. From 3a8430c9dfd32a3daff28b0a7e03c87e2f808deb Mon Sep 17 00:00:00 2001 From: hugo-ccabral Date: Wed, 22 Jul 2026 15:59:50 -0300 Subject: [PATCH 3/3] fix(lint): drop redundant PodSpec selector (staticcheck QF1008) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit service.Spec.Template.Spec.PodSpec is an embedded field of Knative's RevisionSpec, so .Containers is already promoted — .PodSpec.Containers was flagged as a redundant selector by staticcheck in ensureAllowedAuthority. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/webhook/v1/service_webhook.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/webhook/v1/service_webhook.go b/internal/webhook/v1/service_webhook.go index 19b9602..7df0f11 100644 --- a/internal/webhook/v1/service_webhook.go +++ b/internal/webhook/v1/service_webhook.go @@ -204,7 +204,7 @@ func mergeAllowedAuthorities(existing string, exists bool, host string) (value s // DECO_ALLOWED_AUTHORITIES env var, preserving any existing entries (or the // runtime defaults when the var is unset). func (d *ServiceCustomDefaulter) ensureAllowedAuthority(service *servingknativedevv1.Service, containerIdx int, host string) { - container := &service.Spec.Template.Spec.PodSpec.Containers[containerIdx] + container := &service.Spec.Template.Spec.Containers[containerIdx] existingIdx := -1 var existing string