From 91ea7f831c0587baa657c3ae6959125af3e04c24 Mon Sep 17 00:00:00 2001 From: igoramf Date: Thu, 30 Apr 2026 17:17:10 -0300 Subject: [PATCH 01/42] feat: add DecoBuild CRD and BuildReconciler for cfworkers builds - DecoBuild CRD: represents a cfworkers build request; operator creates K8s Jobs - BuildReconciler: watches DecoBuild, generates S3 presigned URLs, creates Job - build/job.go: Job spec builder (cfworkers-builder image, env vars, TTL 24h) - build/s3presign.go: generates presigned URLs for logs/cache using aws-sdk-go-v2 - RBAC: batch/jobs + deco.sites/decobuilds permissions - Chart bumped to v0.3.0; cfworkers env vars injected from ExternalSecret --- api/v1alpha1/decobuild_types.go | 117 +++++++++++++ api/v1alpha1/zz_generated.deepcopy.go | 104 ++++++++++++ chart/Chart.yaml | 4 +- .../clusterrole-operator-manager-role.yaml | 36 ++++ ...ourcedefinition-decobuilds.deco.sites.yaml | 142 ++++++++++++++++ ...eployment-operator-controller-manager.yaml | 26 +++ chart/values.yaml | 6 + cmd/main.go | 30 ++++ go.mod | 3 + internal/build/job.go | 136 +++++++++++++++ internal/build/s3presign.go | 69 ++++++++ internal/controller/build_controller.go | 157 ++++++++++++++++++ 12 files changed, 828 insertions(+), 2 deletions(-) create mode 100644 api/v1alpha1/decobuild_types.go create mode 100644 chart/templates/customresourcedefinition-decobuilds.deco.sites.yaml create mode 100644 internal/build/job.go create mode 100644 internal/build/s3presign.go create mode 100644 internal/controller/build_controller.go diff --git a/api/v1alpha1/decobuild_types.go b/api/v1alpha1/decobuild_types.go new file mode 100644 index 0000000..fdea16e --- /dev/null +++ b/api/v1alpha1/decobuild_types.go @@ -0,0 +1,117 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// DecoBuildPhase is the lifecycle phase of a DecoBuild. +type DecoBuildPhase string + +const ( + DecoBuildPhasePending DecoBuildPhase = "Pending" + DecoBuildPhaseRunning DecoBuildPhase = "Running" + DecoBuildPhaseSucceeded DecoBuildPhase = "Succeeded" + DecoBuildPhaseFailed DecoBuildPhase = "Failed" +) + +// DecoBuildSpec defines the desired state of a Cloudflare Workers build. +type DecoBuildSpec struct { + // Site is the deco site name (used as the Cloudflare Worker name by default). + // +kubebuilder:validation:Required + Site string `json:"site"` + + // Owner is the GitHub repository owner/org. + // +kubebuilder:validation:Required + Owner string `json:"owner"` + + // Repo is the GitHub repository name. + // +kubebuilder:validation:Required + Repo string `json:"repo"` + + // CommitSha is the git commit SHA to build. + // +kubebuilder:validation:Required + CommitSha string `json:"commitSha"` + + // Production indicates whether this is a production deploy. + // When false, a wrangler preview alias is created instead. + // +optional + Production bool `json:"production,omitempty"` + + // BranchRef is the branch name used as the preview alias message (non-production only). + // +optional + BranchRef string `json:"branchRef,omitempty"` + + // WorkerName overrides the Cloudflare Worker name. Defaults to site name. + // +optional + WorkerName string `json:"workerName,omitempty"` + + // EntryPoint is the worker entry file path. Defaults to src/worker-entry.ts. + // +optional + EntryPoint string `json:"entryPoint,omitempty"` + + // CompatDate is the Cloudflare compatibility date. Defaults to 2025-04-01. + // +optional + CompatDate string `json:"compatDate,omitempty"` + + // GithubToken is a GitHub token for cloning private repositories. + // Prefer GithubTokenSecret for production; this field is for convenience. + // +optional + GithubToken string `json:"githubToken,omitempty"` + + // GithubTokenSecret is the name of a K8s Secret (in this namespace) containing + // a "token" key with a GitHub token. Takes precedence over GithubToken. + // +optional + GithubTokenSecret string `json:"githubTokenSecret,omitempty"` +} + +// DecoBuildStatus defines the observed state of a DecoBuild. +type DecoBuildStatus struct { + // Phase is the current lifecycle phase. + // +optional + Phase DecoBuildPhase `json:"phase,omitempty"` + + // JobName is the K8s Job created for this build. + // +optional + JobName string `json:"jobName,omitempty"` + + // StartTime is when the build job was created. + // +optional + StartTime *metav1.Time `json:"startTime,omitempty"` + + // CompletionTime is when the build job finished (succeeded or failed). + // +optional + CompletionTime *metav1.Time `json:"completionTime,omitempty"` + + // Conditions represent the latest observations of the build state. + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="Site",type=string,JSONPath=`.spec.site` +// +kubebuilder:printcolumn:name="Commit",type=string,JSONPath=`.spec.commitSha` +// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` + +// DecoBuild is the Schema for the decobuilds API. +type DecoBuild struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec DecoBuildSpec `json:"spec,omitempty"` + Status DecoBuildStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// DecoBuildList contains a list of DecoBuild. +type DecoBuildList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []DecoBuild `json:"items"` +} + +func init() { + SchemeBuilder.Register(&DecoBuild{}, &DecoBuildList{}) +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 8f1e9be..3615d51 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -25,6 +25,110 @@ import ( "k8s.io/apimachinery/pkg/runtime" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DecoBuild) DeepCopyInto(out *DecoBuild) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DecoBuild. +func (in *DecoBuild) DeepCopy() *DecoBuild { + if in == nil { + return nil + } + out := new(DecoBuild) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DecoBuild) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DecoBuildList) DeepCopyInto(out *DecoBuildList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]DecoBuild, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DecoBuildList. +func (in *DecoBuildList) DeepCopy() *DecoBuildList { + if in == nil { + return nil + } + out := new(DecoBuildList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DecoBuildList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DecoBuildSpec) DeepCopyInto(out *DecoBuildSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DecoBuildSpec. +func (in *DecoBuildSpec) DeepCopy() *DecoBuildSpec { + if in == nil { + return nil + } + out := new(DecoBuildSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DecoBuildStatus) DeepCopyInto(out *DecoBuildStatus) { + *out = *in + if in.StartTime != nil { + in, out := &in.StartTime, &out.StartTime + *out = (*in).DeepCopy() + } + if in.CompletionTime != nil { + in, out := &in.CompletionTime, &out.CompletionTime + *out = (*in).DeepCopy() + } + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DecoBuildStatus. +func (in *DecoBuildStatus) DeepCopy() *DecoBuildStatus { + if in == nil { + return nil + } + out := new(DecoBuildStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Decofile) DeepCopyInto(out *Decofile) { *out = *in diff --git a/chart/Chart.yaml b/chart/Chart.yaml index 51e95c2..b7fa246 100644 --- a/chart/Chart.yaml +++ b/chart/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: deco-operator description: Kubernetes operator for Deco CMS that manages Decofile resources and automatically injects configuration into Knative Services type: application -version: 0.2.0 -appVersion: "0.2.0" +version: 0.3.0 +appVersion: "0.3.0" keywords: - operator - kubernetes diff --git a/chart/templates/clusterrole-operator-manager-role.yaml b/chart/templates/clusterrole-operator-manager-role.yaml index 5d32d08..01bef7b 100644 --- a/chart/templates/clusterrole-operator-manager-role.yaml +++ b/chart/templates/clusterrole-operator-manager-role.yaml @@ -42,6 +42,42 @@ rules: - get - list - watch +- apiGroups: + - batch + resources: + - jobs + verbs: + - create + - delete + - get + - list + - watch +- apiGroups: + - deco.sites + resources: + - decobuilds + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - deco.sites + resources: + - decobuilds/finalizers + verbs: + - update +- apiGroups: + - deco.sites + resources: + - decobuilds/status + verbs: + - get + - patch + - update - apiGroups: - deco.sites resources: diff --git a/chart/templates/customresourcedefinition-decobuilds.deco.sites.yaml b/chart/templates/customresourcedefinition-decobuilds.deco.sites.yaml new file mode 100644 index 0000000..c449408 --- /dev/null +++ b/chart/templates/customresourcedefinition-decobuilds.deco.sites.yaml @@ -0,0 +1,142 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.18.0 + name: decobuilds.deco.sites +spec: + group: deco.sites + names: + kind: DecoBuild + listKind: DecoBuildList + plural: decobuilds + singular: decobuild + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.site + name: Site + type: string + - jsonPath: .spec.commitSha + name: Commit + type: string + - jsonPath: .status.phase + name: Phase + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: DecoBuild is the Schema for the decobuilds API. + properties: + apiVersion: + type: string + kind: + type: string + metadata: + type: object + spec: + description: DecoBuildSpec defines the desired state of a Cloudflare Workers build. + properties: + branchRef: + description: Branch name used as the preview alias message (non-production only). + type: string + commitSha: + description: Git commit SHA to build. + type: string + compatDate: + description: Cloudflare compatibility date. Defaults to 2025-04-01. + type: string + entryPoint: + description: Worker entry file path. Defaults to src/worker-entry.ts. + type: string + githubToken: + description: GitHub token for cloning private repositories. + type: string + githubTokenSecret: + description: Name of a K8s Secret containing a "token" key with a GitHub token. Takes precedence over githubToken. + type: string + owner: + description: GitHub repository owner/org. + type: string + production: + description: When true, deploys to production. When false, creates a wrangler preview alias. + type: boolean + repo: + description: GitHub repository name. + type: string + site: + description: Deco site name (used as the Cloudflare Worker name by default). + type: string + workerName: + description: Cloudflare Worker name override. Defaults to site name. + type: string + required: + - commitSha + - owner + - repo + - site + type: object + status: + description: DecoBuildStatus defines the observed state of a DecoBuild. + properties: + completionTime: + description: When the build job finished. + format: date-time + type: string + conditions: + description: Latest observations of the build state. + items: + description: Condition contains details for one aspect of the current state of this API Resource. + properties: + lastTransitionTime: + format: date-time + type: string + message: + maxLength: 32768 + type: string + observedGeneration: + format: int64 + minimum: 0 + type: integer + reason: + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + enum: + - "True" + - "False" + - Unknown + type: string + type: + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + jobName: + description: K8s Job created for this build. + type: string + phase: + description: Current lifecycle phase (Pending, Running, Succeeded, Failed). + type: string + startTime: + description: When the build job was created. + format: date-time + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/chart/templates/deployment-operator-controller-manager.yaml b/chart/templates/deployment-operator-controller-manager.yaml index 859c1a7..61fc107 100644 --- a/chart/templates/deployment-operator-controller-manager.yaml +++ b/chart/templates/deployment-operator-controller-manager.yaml @@ -62,6 +62,32 @@ spec: {{- end }} {{- end }} {{- end }} + {{- with .Values.cfworkers }} + {{- if .existingSecret }} + - name: CLOUDFLARE_API_WORKERS_TOKEN + valueFrom: + secretKeyRef: + name: {{ .existingSecret | quote }} + key: cf-api-token + - name: CLOUDFLARE_ACCOUNT_ID + valueFrom: + secretKeyRef: + name: {{ .existingSecret | quote }} + key: cf-account-id + - name: S3_ACCESS_KEY_ID + valueFrom: + secretKeyRef: + name: {{ .existingSecret | quote }} + key: s3-access-key-id + - name: S3_SECRET_ACCESS_KEY + valueFrom: + secretKeyRef: + name: {{ .existingSecret | quote }} + key: s3-secret-access-key + {{- end }} + - name: S3_REGION + value: {{ .s3Region | default "sa-east-1" | quote }} + {{- end }} livenessProbe: httpGet: path: /healthz diff --git a/chart/values.yaml b/chart/values.yaml index 361dd04..022b9f7 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -95,6 +95,12 @@ podAnnotations: {} # Pod labels podLabels: {} +# Cloudflare Workers build support +# When existingSecret is set, the operator can create DecoBuild jobs for cfworkers sites. +cfworkers: + existingSecret: "" # Secret with cf-api-token, cf-account-id, s3-access-key-id, s3-secret-access-key + s3Region: "sa-east-1" + # Name overrides nameOverride: "" fullnameOverride: "" diff --git a/cmd/main.go b/cmd/main.go index c689cca..83c3412 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -45,6 +45,7 @@ import ( servingknativedevv1 "knative.dev/serving/pkg/apis/serving/v1" decositesv1alpha1 "github.com/deco-sites/decofile-operator/api/v1alpha1" + "github.com/deco-sites/decofile-operator/internal/build" "github.com/deco-sites/decofile-operator/internal/controller" "github.com/deco-sites/decofile-operator/internal/valkey" webhookv1 "github.com/deco-sites/decofile-operator/internal/webhook/v1" @@ -113,6 +114,21 @@ func main() { os.Getenv("VALKEY_WATCH_FAILOVER") != "false", "Subscribe to Sentinel +switch-master events and trigger immediate ACL resync on failover. "+ "Enabled by default when VALKEY_SENTINEL_URLS is set. Set VALKEY_WATCH_FAILOVER=false to disable.") + var cfApiToken string + var cfAccountId string + var s3Region string + var s3AccessKeyID string + var s3SecretAccessKey string + flag.StringVar(&cfApiToken, "cf-api-token", os.Getenv("CLOUDFLARE_API_WORKERS_TOKEN"), + "Cloudflare Workers API token for build deployments.") + flag.StringVar(&cfAccountId, "cf-account-id", os.Getenv("CLOUDFLARE_ACCOUNT_ID"), + "Cloudflare account ID for build deployments.") + flag.StringVar(&s3Region, "s3-region", getEnvOrDefault("S3_REGION", "sa-east-1"), + "AWS S3 region for build logs and npm cache.") + flag.StringVar(&s3AccessKeyID, "s3-access-key-id", os.Getenv("S3_ACCESS_KEY_ID"), + "AWS access key ID for S3 presigned URL generation.") + flag.StringVar(&s3SecretAccessKey, "s3-secret-access-key", os.Getenv("S3_SECRET_ACCESS_KEY"), + "AWS secret access key for S3 presigned URL generation.") opts := zap.Options{ Development: false, } @@ -328,6 +344,20 @@ func main() { os.Exit(1) } } + if err := (&controller.BuildReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + CfApiToken: cfApiToken, + CfAccountId: cfAccountId, + S3Config: build.S3Config{ + Region: s3Region, + AccessKeyID: s3AccessKeyID, + SecretAccessKey: s3SecretAccessKey, + }, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "DecoBuild") + os.Exit(1) + } // +kubebuilder:scaffold:builder if metricsCertWatcher != nil { diff --git a/go.mod b/go.mod index 7f73982..c41da67 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,9 @@ go 1.24.0 require ( github.com/andybalholm/brotli v1.2.0 + github.com/aws/aws-sdk-go-v2/config v1.29.14 + github.com/aws/aws-sdk-go-v2/credentials v1.17.67 + github.com/aws/aws-sdk-go-v2/service/s3 v1.78.2 github.com/google/uuid v1.6.0 github.com/onsi/ginkgo/v2 v2.22.0 github.com/onsi/gomega v1.36.1 diff --git a/internal/build/job.go b/internal/build/job.go new file mode 100644 index 0000000..13db7e3 --- /dev/null +++ b/internal/build/job.go @@ -0,0 +1,136 @@ +// Package build contains helpers for creating Cloudflare Workers build Jobs. +// It is the Go equivalent of hosting/cfworkers/build.ts in the admin. +package build + +import ( + "crypto/sha256" + "fmt" + + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + decositesv1alpha1 "github.com/deco-sites/decofile-operator/api/v1alpha1" +) + +const ( + BuilderImage = "ghcr.io/decocms/infra_applications/cfworkers-builder:v1.0.0" + DefaultEntryPoint = "src/worker-entry.ts" + DefaultCompatDate = "2025-04-01" + LogsBucket = "deco-sites-build-logs" + CacheBucket = "deco-cfworkers-deployments" + ttlSecondsAfterFinished = int32(24 * 60 * 60) // 24h +) + +// JobName returns a deterministic job name from a commit SHA. +// Mirrors generateJobName() in the admin's build.ts. +func JobName(commitSha string) string { + h := sha256.Sum256([]byte("build-" + commitSha)) + return fmt.Sprintf("build-%x", h[:6]) +} + +// PresignedURLs are the three S3 presigned URLs the build job needs. +type PresignedURLs struct { + LogsUpload string + CacheDownload string + CacheUpload string +} + +// JobOpts are the inputs for NewJob. +type JobOpts struct { + Build *decositesv1alpha1.DecoBuild + JobName string + GithubToken string + CfApiToken string + CfAccountId string + PresignedURLs PresignedURLs +} + +// NewJob builds the batchv1.Job spec for a cfworkers build. +// This is the Go equivalent of buildJobOf() in the admin's build.ts. +func NewJob(opts JobOpts) *batchv1.Job { + spec := opts.Build.Spec + + workerName := spec.WorkerName + if workerName == "" { + workerName = spec.Site + } + entryPoint := spec.EntryPoint + if entryPoint == "" { + entryPoint = DefaultEntryPoint + } + compatDate := spec.CompatDate + if compatDate == "" { + compatDate = DefaultCompatDate + } + isProduction := "false" + if spec.Production { + isProduction = "true" + } + + env := []corev1.EnvVar{ + {Name: "GIT_REPO", Value: fmt.Sprintf("https://github.com/%s/%s", spec.Owner, spec.Repo)}, + {Name: "COMMIT_SHA", Value: spec.CommitSha}, + {Name: "DECO_SITE_NAME", Value: spec.Site}, + {Name: "BUILD_NAME", Value: opts.JobName}, + {Name: "IS_PRODUCTION", Value: isProduction}, + {Name: "WORKER_NAME", Value: workerName}, + {Name: "CF_ACCOUNT_ID", Value: opts.CfAccountId}, + {Name: "CLOUDFLARE_API_TOKEN", Value: opts.CfApiToken}, + {Name: "ENTRY_POINT", Value: entryPoint}, + {Name: "COMPAT_DATE", Value: compatDate}, + {Name: "LOGS_UPLOAD_URL", Value: opts.PresignedURLs.LogsUpload}, + {Name: "CACHE_DOWNLOAD_URL", Value: opts.PresignedURLs.CacheDownload}, + {Name: "CACHE_UPLOAD_URL", Value: opts.PresignedURLs.CacheUpload}, + } + if spec.BranchRef != "" { + env = append(env, corev1.EnvVar{Name: "BRANCH_REF", Value: spec.BranchRef}) + } + if opts.GithubToken != "" { + env = append(env, corev1.EnvVar{Name: "GITHUB_TOKEN", Value: opts.GithubToken}) + } + + backoffLimit := int32(0) + ttl := ttlSecondsAfterFinished + + return &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: opts.JobName, + Namespace: opts.Build.Namespace, + Labels: map[string]string{ + "app.deco/site": spec.Site, + "app.deco/owner": spec.Owner, + "app.deco/repo": spec.Repo, + "app.deco/platform": "cfworkers", + }, + }, + Spec: batchv1.JobSpec{ + BackoffLimit: &backoffLimit, + TTLSecondsAfterFinished: &ttl, + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyNever, + Containers: []corev1.Container{ + { + Name: "builder", + Image: BuilderImage, + Env: env, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceMemory: resource.MustParse("4Gi"), + corev1.ResourceCPU: resource.MustParse("500m"), + corev1.ResourceEphemeralStorage: resource.MustParse("2Gi"), + }, + Limits: corev1.ResourceList{ + corev1.ResourceMemory: resource.MustParse("4Gi"), + corev1.ResourceEphemeralStorage: resource.MustParse("3Gi"), + }, + }, + }, + }, + }, + }, + }, + } +} diff --git a/internal/build/s3presign.go b/internal/build/s3presign.go new file mode 100644 index 0000000..6cedd2b --- /dev/null +++ b/internal/build/s3presign.go @@ -0,0 +1,69 @@ +package build + +import ( + "context" + "fmt" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" +) + +const presignExpiry = time.Hour + +// S3Config holds the AWS credentials used to generate presigned URLs. +type S3Config struct { + Region string + AccessKeyID string + SecretAccessKey string +} + +// GeneratePresignedURLs generates the three presigned URLs the build job needs. +// Mirrors generatePresignedUrls() in the admin's build.ts. +func GeneratePresignedURLs(ctx context.Context, cfg S3Config, site, jobName string) (PresignedURLs, error) { + awsCfg, err := config.LoadDefaultConfig(ctx, + config.WithRegion(cfg.Region), + config.WithCredentialsProvider( + credentials.NewStaticCredentialsProvider(cfg.AccessKeyID, cfg.SecretAccessKey, ""), + ), + ) + if err != nil { + return PresignedURLs{}, fmt.Errorf("loading aws config: %w", err) + } + + presigner := s3.NewPresignClient(s3.NewFromConfig(awsCfg)) + + logsUpload, err := presigner.PresignPutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(LogsBucket), + Key: aws.String(fmt.Sprintf("%s/%s.log", site, jobName)), + }, s3.WithPresignExpires(presignExpiry)) + if err != nil { + return PresignedURLs{}, fmt.Errorf("presigning logs upload: %w", err) + } + + cacheKey := fmt.Sprintf("%s/npm-cache.tar.zst", site) + + cacheDownload, err := presigner.PresignGetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String(CacheBucket), + Key: aws.String(cacheKey), + }, s3.WithPresignExpires(presignExpiry)) + if err != nil { + return PresignedURLs{}, fmt.Errorf("presigning cache download: %w", err) + } + + cacheUpload, err := presigner.PresignPutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(CacheBucket), + Key: aws.String(cacheKey), + }, s3.WithPresignExpires(presignExpiry)) + if err != nil { + return PresignedURLs{}, fmt.Errorf("presigning cache upload: %w", err) + } + + return PresignedURLs{ + LogsUpload: logsUpload.URL, + CacheDownload: cacheDownload.URL, + CacheUpload: cacheUpload.URL, + }, nil +} diff --git a/internal/controller/build_controller.go b/internal/controller/build_controller.go new file mode 100644 index 0000000..f2bf3ab --- /dev/null +++ b/internal/controller/build_controller.go @@ -0,0 +1,157 @@ +package controller + +import ( + "context" + "fmt" + + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + logf "sigs.k8s.io/controller-runtime/pkg/log" + + decositesv1alpha1 "github.com/deco-sites/decofile-operator/api/v1alpha1" + "github.com/deco-sites/decofile-operator/internal/build" +) + +// BuildReconciler reconciles DecoBuild objects. +// It creates a K8s Job for each build and tracks the Job's outcome back to the DecoBuild status. +type BuildReconciler struct { + client.Client + Scheme *runtime.Scheme + CfApiToken string + CfAccountId string + S3Config build.S3Config +} + +// +kubebuilder:rbac:groups=deco.sites,resources=decobuilds,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=deco.sites,resources=decobuilds/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=deco.sites,resources=decobuilds/finalizers,verbs=update +// +kubebuilder:rbac:groups=batch,resources=jobs,verbs=get;list;watch;create;delete + +func (r *BuildReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := logf.FromContext(ctx) + + decoBuild := &decositesv1alpha1.DecoBuild{} + if err := r.Get(ctx, req.NamespacedName, decoBuild); err != nil { + if errors.IsNotFound(err) { + return ctrl.Result{}, nil + } + return ctrl.Result{}, err + } + + // Nothing to do for terminal phases. + if decoBuild.Status.Phase == decositesv1alpha1.DecoBuildPhaseSucceeded || + decoBuild.Status.Phase == decositesv1alpha1.DecoBuildPhaseFailed { + return ctrl.Result{}, nil + } + + jobName := build.JobName(decoBuild.Spec.CommitSha) + + existingJob := &batchv1.Job{} + err := r.Get(ctx, types.NamespacedName{Name: jobName, Namespace: decoBuild.Namespace}, existingJob) + if errors.IsNotFound(err) { + log.Info("Creating build job", "job", jobName, "site", decoBuild.Spec.Site) + return r.createJob(ctx, decoBuild, jobName) + } + if err != nil { + return ctrl.Result{}, err + } + + return r.syncStatus(ctx, decoBuild, existingJob) +} + +func (r *BuildReconciler) createJob(ctx context.Context, decoBuild *decositesv1alpha1.DecoBuild, jobName string) (ctrl.Result, error) { + githubToken, err := r.resolveGithubToken(ctx, decoBuild) + if err != nil { + return ctrl.Result{}, fmt.Errorf("resolving github token: %w", err) + } + + presignedURLs, err := build.GeneratePresignedURLs(ctx, r.S3Config, decoBuild.Spec.Site, jobName) + if err != nil { + return ctrl.Result{}, fmt.Errorf("generating presigned URLs: %w", err) + } + + job := build.NewJob(build.JobOpts{ + Build: decoBuild, + JobName: jobName, + GithubToken: githubToken, + CfApiToken: r.CfApiToken, + CfAccountId: r.CfAccountId, + PresignedURLs: presignedURLs, + }) + + if err := controllerutil.SetControllerReference(decoBuild, job, r.Scheme); err != nil { + return ctrl.Result{}, fmt.Errorf("setting owner reference: %w", err) + } + + if err := r.Create(ctx, job); err != nil && !errors.IsAlreadyExists(err) { + return ctrl.Result{}, fmt.Errorf("creating build job: %w", err) + } + + now := metav1.Now() + decoBuild.Status.Phase = decositesv1alpha1.DecoBuildPhaseRunning + decoBuild.Status.JobName = jobName + decoBuild.Status.StartTime = &now + return ctrl.Result{}, r.Status().Update(ctx, decoBuild) +} + +// syncStatus maps the K8s Job conditions to the DecoBuild phase. +// Mirrors buildStatusOf() in the admin's build.ts. +func (r *BuildReconciler) syncStatus(ctx context.Context, decoBuild *decositesv1alpha1.DecoBuild, job *batchv1.Job) (ctrl.Result, error) { + phase := jobPhase(job) + if phase == decoBuild.Status.Phase { + return ctrl.Result{}, nil + } + + decoBuild.Status.Phase = phase + if phase == decositesv1alpha1.DecoBuildPhaseSucceeded || phase == decositesv1alpha1.DecoBuildPhaseFailed { + now := metav1.Now() + decoBuild.Status.CompletionTime = &now + } + return ctrl.Result{}, r.Status().Update(ctx, decoBuild) +} + +// jobPhase maps batchv1.Job conditions to a DecoBuildPhase. +func jobPhase(job *batchv1.Job) decositesv1alpha1.DecoBuildPhase { + for _, c := range job.Status.Conditions { + if c.Status != corev1.ConditionTrue { + continue + } + switch c.Type { + case batchv1.JobComplete, "SuccessCriteriaMet": + return decositesv1alpha1.DecoBuildPhaseSucceeded + case batchv1.JobFailed: + return decositesv1alpha1.DecoBuildPhaseFailed + } + } + return decositesv1alpha1.DecoBuildPhaseRunning +} + +// resolveGithubToken returns the GitHub token from spec or from the referenced Secret. +// GithubTokenSecret takes precedence over the inline GithubToken field. +func (r *BuildReconciler) resolveGithubToken(ctx context.Context, decoBuild *decositesv1alpha1.DecoBuild) (string, error) { + if secretName := decoBuild.Spec.GithubTokenSecret; secretName != "" { + secret := &corev1.Secret{} + if err := r.Get(ctx, types.NamespacedName{Name: secretName, Namespace: decoBuild.Namespace}, secret); err != nil { + return "", err + } + return string(secret.Data["token"]), nil + } + return decoBuild.Spec.GithubToken, nil +} + +func (r *BuildReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&decositesv1alpha1.DecoBuild{}). + Owns(&batchv1.Job{}). + WithOptions(controller.Options{MaxConcurrentReconciles: 4}). + Named("decobuild"). + Complete(r) +} From 6e20af2b74479d76972d74cde855f6fbc96a4c49 Mon Sep 17 00:00:00 2001 From: igoramf Date: Thu, 30 Apr 2026 17:30:29 -0300 Subject: [PATCH 02/42] =?UTF-8?q?chore:=20go=20mod=20tidy=20=E2=80=94=20ad?= =?UTF-8?q?d=20aws-sdk-go-v2=20go.sum=20entries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go.mod | 19 +++++++++++++++++-- go.sum | 44 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index c41da67..4dbf225 100644 --- a/go.mod +++ b/go.mod @@ -4,12 +4,15 @@ go 1.24.0 require ( github.com/andybalholm/brotli v1.2.0 + github.com/aws/aws-sdk-go-v2 v1.36.3 github.com/aws/aws-sdk-go-v2/config v1.29.14 github.com/aws/aws-sdk-go-v2/credentials v1.17.67 github.com/aws/aws-sdk-go-v2/service/s3 v1.78.2 github.com/google/uuid v1.6.0 github.com/onsi/ginkgo/v2 v2.22.0 github.com/onsi/gomega v1.36.1 + github.com/prometheus/client_golang v1.23.2 + github.com/redis/go-redis/v9 v9.18.0 k8s.io/api v0.33.5 k8s.io/apimachinery v0.33.5 k8s.io/client-go v0.33.5 @@ -20,6 +23,20 @@ require ( require ( cel.dev/expr v0.24.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.0 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.0 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 // indirect + github.com/aws/smithy-go v1.22.2 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/blendle/zapdriver v1.3.1 // indirect @@ -56,11 +73,9 @@ require ( github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.17.0 // indirect - github.com/redis/go-redis/v9 v9.18.0 // indirect github.com/spf13/cobra v1.8.1 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/stoewer/go-strcase v1.3.0 // indirect diff --git a/go.sum b/go.sum index 338285b..b731f34 100644 --- a/go.sum +++ b/go.sum @@ -4,12 +4,52 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= +github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM= +github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 h1:zAybnyUQXIZ5mok5Jqwlf58/TFE7uvd3IAsa1aF9cXs= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10/go.mod h1:qqvMj6gHLR/EXWZw4ZbqlPbQUyenf4h82UQUlKc+l14= +github.com/aws/aws-sdk-go-v2/config v1.29.14 h1:f+eEi/2cKCg9pqKBoAIwRGzVb70MRKqWX4dg1BDcSJM= +github.com/aws/aws-sdk-go-v2/config v1.29.14/go.mod h1:wVPHWcIFv3WO89w0rE10gzf17ZYy+UVS1Geq8Iei34g= +github.com/aws/aws-sdk-go-v2/credentials v1.17.67 h1:9KxtdcIA/5xPNQyZRgUSpYOE6j9Bc4+D7nZua0KGYOM= +github.com/aws/aws-sdk-go-v2/credentials v1.17.67/go.mod h1:p3C44m+cfnbv763s52gCqrjaqyPikj9Sg47kUVaNZQQ= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 h1:x793wxmUWVDhshP8WW2mlnXuFrO4cOd3HLBroh1paFw= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30/go.mod h1:Jpne2tDnYiFascUEs2AWHJL9Yp7A5ZVy3TNyxaAjD6M= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 h1:ZNTqv4nIdE/DiBfUUfXcLZ/Spcuz+RjeziUtNJackkM= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34/go.mod h1:zf7Vcd1ViW7cPqYWEHLHJkS50X0JS2IKz9Cgaj6ugrs= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.0 h1:lguz0bmOoGzozP9XfRJR1QIayEYo+2vP/No3OfLF0pU= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.0/go.mod h1:iu6FSzgt+M2/x3Dk8zhycdIcHjEFb36IS8HVUVFoMg0= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 h1:moLQUoVq91LiqT1nbvzDukyqAlCv89ZmwaHw/ZFlFZg= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15/go.mod h1:ZH34PJUc8ApjBIfgQCFvkWcUDBtl/WTD+uiYHjd8igA= +github.com/aws/aws-sdk-go-v2/service/s3 v1.78.2 h1:jIiopHEV22b4yQP2q36Y0OmwLbsxNWdWwfZRR5QRRO4= +github.com/aws/aws-sdk-go-v2/service/s3 v1.78.2/go.mod h1:U5SNqwhXB3Xe6F47kXvWihPl/ilGaEDe8HD/50Z9wxc= +github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 h1:1Gw+9ajCV1jogloEv1RRnvfRFia2cL6c9cuKV2Ps+G8= +github.com/aws/aws-sdk-go-v2/service/sso v1.25.3/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 h1:hXmVKytPfTy5axZ+fYbR5d0cFmC3JvwLm5kM83luako= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 h1:1XuUZ8mYJw9B6lzAkXhqHlJd/XvaX32evhproijJEZY= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.19/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4= +github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ= +github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/blendle/zapdriver v1.3.1 h1:C3dydBOWYRiOk+B8X9IVZ5IOe+7cl+tGOexN4QqHfpE= github.com/blendle/zapdriver v1.3.1/go.mod h1:mdXfREi6u5MArG4j9fewC+FGnXaBR+T4Ox4J2u4eHCc= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -82,6 +122,8 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -145,6 +187,8 @@ github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18= From a40192d4194d34f8dd223c2e95676658f0aa85fd Mon Sep 17 00:00:00 2001 From: igoramf Date: Thu, 30 Apr 2026 17:44:51 -0300 Subject: [PATCH 03/42] fix: gofmt formatting in build/job.go constants --- internal/build/job.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/build/job.go b/internal/build/job.go index 13db7e3..66d0cd6 100644 --- a/internal/build/job.go +++ b/internal/build/job.go @@ -15,11 +15,11 @@ import ( ) const ( - BuilderImage = "ghcr.io/decocms/infra_applications/cfworkers-builder:v1.0.0" - DefaultEntryPoint = "src/worker-entry.ts" - DefaultCompatDate = "2025-04-01" - LogsBucket = "deco-sites-build-logs" - CacheBucket = "deco-cfworkers-deployments" + BuilderImage = "ghcr.io/decocms/infra_applications/cfworkers-builder:v1.0.0" + DefaultEntryPoint = "src/worker-entry.ts" + DefaultCompatDate = "2025-04-01" + LogsBucket = "deco-sites-build-logs" + CacheBucket = "deco-cfworkers-deployments" ttlSecondsAfterFinished = int32(24 * 60 * 60) // 24h ) From fae09d3b99084b26b4969def58177859c4a14691 Mon Sep 17 00:00:00 2001 From: igoramf Date: Thu, 30 Apr 2026 17:49:20 -0300 Subject: [PATCH 04/42] fix: wire DecoBuild CRD and cfworkers env vars into Helm generator - Add deco.sites_decobuilds.yaml to config/crd/kustomization.yaml so helm-generator picks it up - Add cfworkers env vars block to helm-generator addEnvVarsToDeployment - Regenerate chart templates via make manifests helm --- .../clusterrole-operator-manager-role.yaml | 27 +-- ...ourcedefinition-decobuilds.deco.sites.yaml | 82 ++++++-- ...eployment-operator-controller-manager.yaml | 4 +- config/crd/bases/deco.sites_decobuilds.yaml | 187 ++++++++++++++++++ config/crd/kustomization.yaml | 1 + config/rbac/role.yaml | 13 ++ hack/helm-generator/main.go | 28 ++- 7 files changed, 295 insertions(+), 47 deletions(-) create mode 100644 config/crd/bases/deco.sites_decobuilds.yaml diff --git a/chart/templates/clusterrole-operator-manager-role.yaml b/chart/templates/clusterrole-operator-manager-role.yaml index 01bef7b..1ff0b2d 100644 --- a/chart/templates/clusterrole-operator-manager-role.yaml +++ b/chart/templates/clusterrole-operator-manager-role.yaml @@ -56,31 +56,6 @@ rules: - deco.sites resources: - decobuilds - verbs: - - create - - delete - - get - - list - - patch - - update - - watch -- apiGroups: - - deco.sites - resources: - - decobuilds/finalizers - verbs: - - update -- apiGroups: - - deco.sites - resources: - - decobuilds/status - verbs: - - get - - patch - - update -- apiGroups: - - deco.sites - resources: - decofiles verbs: - create @@ -93,12 +68,14 @@ rules: - apiGroups: - deco.sites resources: + - decobuilds/finalizers - decofiles/finalizers verbs: - update - apiGroups: - deco.sites resources: + - decobuilds/status - decofiles/status verbs: - get diff --git a/chart/templates/customresourcedefinition-decobuilds.deco.sites.yaml b/chart/templates/customresourcedefinition-decobuilds.deco.sites.yaml index c449408..b064984 100644 --- a/chart/templates/customresourcedefinition-decobuilds.deco.sites.yaml +++ b/chart/templates/customresourcedefinition-decobuilds.deco.sites.yaml @@ -32,46 +32,69 @@ spec: description: DecoBuild is the Schema for the decobuilds API. properties: apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources type: string kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds type: string metadata: type: object spec: - description: DecoBuildSpec defines the desired state of a Cloudflare Workers build. + description: DecoBuildSpec defines the desired state of a Cloudflare Workers + build. properties: branchRef: - description: Branch name used as the preview alias message (non-production only). + description: BranchRef is the branch name used as the preview alias + message (non-production only). type: string commitSha: - description: Git commit SHA to build. + description: CommitSha is the git commit SHA to build. type: string compatDate: - description: Cloudflare compatibility date. Defaults to 2025-04-01. + description: CompatDate is the Cloudflare compatibility date. Defaults + to 2025-04-01. type: string entryPoint: - description: Worker entry file path. Defaults to src/worker-entry.ts. + description: EntryPoint is the worker entry file path. Defaults to + src/worker-entry.ts. type: string githubToken: - description: GitHub token for cloning private repositories. + description: |- + GithubToken is a GitHub token for cloning private repositories. + Prefer GithubTokenSecret for production; this field is for convenience. type: string githubTokenSecret: - description: Name of a K8s Secret containing a "token" key with a GitHub token. Takes precedence over githubToken. + description: |- + GithubTokenSecret is the name of a K8s Secret (in this namespace) containing + a "token" key with a GitHub token. Takes precedence over GithubToken. type: string owner: - description: GitHub repository owner/org. + description: Owner is the GitHub repository owner/org. type: string production: - description: When true, deploys to production. When false, creates a wrangler preview alias. + description: |- + Production indicates whether this is a production deploy. + When false, a wrangler preview alias is created instead. type: boolean repo: - description: GitHub repository name. + description: Repo is the GitHub repository name. type: string site: - description: Deco site name (used as the Cloudflare Worker name by default). + description: Site is the deco site name (used as the Cloudflare Worker + name by default). type: string workerName: - description: Cloudflare Worker name override. Defaults to site name. + description: WorkerName overrides the Cloudflare Worker name. Defaults + to site name. type: string required: - commitSha @@ -83,36 +106,57 @@ spec: description: DecoBuildStatus defines the observed state of a DecoBuild. properties: completionTime: - description: When the build job finished. + description: CompletionTime is when the build job finished (succeeded + or failed). format: date-time type: string conditions: - description: Latest observations of the build state. + description: Conditions represent the latest observations of the build + state. items: - description: Condition contains details for one aspect of the current state of this API Resource. + description: Condition contains details for one aspect of the current + state of this API Resource. properties: lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. format: date-time type: string message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. maxLength: 32768 type: string observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. format: int64 minimum: 0 type: integer reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. maxLength: 1024 minLength: 1 pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ type: string status: + description: status of the condition, one of True, False, Unknown. enum: - "True" - "False" - Unknown type: string type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. maxLength: 316 pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ type: string @@ -125,13 +169,13 @@ spec: type: object type: array jobName: - description: K8s Job created for this build. + description: JobName is the K8s Job created for this build. type: string phase: - description: Current lifecycle phase (Pending, Running, Succeeded, Failed). + description: Phase is the current lifecycle phase. type: string startTime: - description: When the build job was created. + description: StartTime is when the build job was created. format: date-time type: string type: object @@ -139,4 +183,4 @@ spec: served: true storage: true subresources: - status: {} + status: {} \ No newline at end of file diff --git a/chart/templates/deployment-operator-controller-manager.yaml b/chart/templates/deployment-operator-controller-manager.yaml index 61fc107..00b7841 100644 --- a/chart/templates/deployment-operator-controller-manager.yaml +++ b/chart/templates/deployment-operator-controller-manager.yaml @@ -31,7 +31,7 @@ spec: command: - /manager image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" - {{- if or (and .Values.github (or .Values.github.token .Values.github.existingSecret)) (and .Values.valkey (get .Values.valkey "sentinelUrls")) }} + {{- if or (and .Values.github (or .Values.github.token .Values.github.existingSecret)) (and .Values.valkey (get .Values.valkey "sentinelUrls")) .Values.cfworkers.existingSecret }} env: {{- if and .Values.github .Values.github.existingSecret }} - name: GITHUB_TOKEN @@ -61,7 +61,6 @@ spec: {{- end }} {{- end }} {{- end }} - {{- end }} {{- with .Values.cfworkers }} {{- if .existingSecret }} - name: CLOUDFLARE_API_WORKERS_TOKEN @@ -88,6 +87,7 @@ spec: - name: S3_REGION value: {{ .s3Region | default "sa-east-1" | quote }} {{- end }} + {{- end }} livenessProbe: httpGet: path: /healthz diff --git a/config/crd/bases/deco.sites_decobuilds.yaml b/config/crd/bases/deco.sites_decobuilds.yaml new file mode 100644 index 0000000..fe74bcf --- /dev/null +++ b/config/crd/bases/deco.sites_decobuilds.yaml @@ -0,0 +1,187 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.18.0 + name: decobuilds.deco.sites +spec: + group: deco.sites + names: + kind: DecoBuild + listKind: DecoBuildList + plural: decobuilds + singular: decobuild + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.site + name: Site + type: string + - jsonPath: .spec.commitSha + name: Commit + type: string + - jsonPath: .status.phase + name: Phase + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: DecoBuild is the Schema for the decobuilds API. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: DecoBuildSpec defines the desired state of a Cloudflare Workers + build. + properties: + branchRef: + description: BranchRef is the branch name used as the preview alias + message (non-production only). + type: string + commitSha: + description: CommitSha is the git commit SHA to build. + type: string + compatDate: + description: CompatDate is the Cloudflare compatibility date. Defaults + to 2025-04-01. + type: string + entryPoint: + description: EntryPoint is the worker entry file path. Defaults to + src/worker-entry.ts. + type: string + githubToken: + description: |- + GithubToken is a GitHub token for cloning private repositories. + Prefer GithubTokenSecret for production; this field is for convenience. + type: string + githubTokenSecret: + description: |- + GithubTokenSecret is the name of a K8s Secret (in this namespace) containing + a "token" key with a GitHub token. Takes precedence over GithubToken. + type: string + owner: + description: Owner is the GitHub repository owner/org. + type: string + production: + description: |- + Production indicates whether this is a production deploy. + When false, a wrangler preview alias is created instead. + type: boolean + repo: + description: Repo is the GitHub repository name. + type: string + site: + description: Site is the deco site name (used as the Cloudflare Worker + name by default). + type: string + workerName: + description: WorkerName overrides the Cloudflare Worker name. Defaults + to site name. + type: string + required: + - commitSha + - owner + - repo + - site + type: object + status: + description: DecoBuildStatus defines the observed state of a DecoBuild. + properties: + completionTime: + description: CompletionTime is when the build job finished (succeeded + or failed). + format: date-time + type: string + conditions: + description: Conditions represent the latest observations of the build + state. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + jobName: + description: JobName is the K8s Job created for this build. + type: string + phase: + description: Phase is the current lifecycle phase. + type: string + startTime: + description: StartTime is when the build job was created. + format: date-time + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index f71ed0f..615115a 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -2,6 +2,7 @@ # since it depends on service name and namespace that are out of this kustomize package. # It should be run by config/default resources: +- bases/deco.sites_decobuilds.yaml - bases/deco.sites_decofiles.yaml # +kubebuilder:scaffold:crdkustomizeresource diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 2ace323..94b6f3a 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -43,9 +43,20 @@ rules: - get - list - watch +- apiGroups: + - batch + resources: + - jobs + verbs: + - create + - delete + - get + - list + - watch - apiGroups: - deco.sites resources: + - decobuilds - decofiles verbs: - create @@ -58,12 +69,14 @@ rules: - apiGroups: - deco.sites resources: + - decobuilds/finalizers - decofiles/finalizers verbs: - update - apiGroups: - deco.sites resources: + - decobuilds/status - decofiles/status verbs: - get diff --git a/hack/helm-generator/main.go b/hack/helm-generator/main.go index 2622bfd..d09a181 100644 --- a/hack/helm-generator/main.go +++ b/hack/helm-generator/main.go @@ -188,7 +188,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")) }} + envBlock := ` {{- if or (and .Values.github (or .Values.github.token .Values.github.existingSecret)) (and .Values.valkey (get .Values.valkey "sentinelUrls")) .Values.cfworkers.existingSecret }} env: {{- if and .Values.github .Values.github.existingSecret }} - name: GITHUB_TOKEN @@ -218,6 +218,32 @@ func addEnvVarsToDeployment(templatesDir string) error { {{- end }} {{- end }} {{- end }} + {{- with .Values.cfworkers }} + {{- if .existingSecret }} + - name: CLOUDFLARE_API_WORKERS_TOKEN + valueFrom: + secretKeyRef: + name: {{ .existingSecret | quote }} + key: cf-api-token + - name: CLOUDFLARE_ACCOUNT_ID + valueFrom: + secretKeyRef: + name: {{ .existingSecret | quote }} + key: cf-account-id + - name: S3_ACCESS_KEY_ID + valueFrom: + secretKeyRef: + name: {{ .existingSecret | quote }} + key: s3-access-key-id + - name: S3_SECRET_ACCESS_KEY + valueFrom: + secretKeyRef: + name: {{ .existingSecret | quote }} + key: s3-secret-access-key + {{- end }} + - name: S3_REGION + value: {{ .s3Region | default "sa-east-1" | quote }} + {{- end }} {{- end }}` re := regexp.MustCompile(`(?m)( image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}")`) From a5c968f1f084e289d4d063b2e6062fcf9e2bccf2 Mon Sep 17 00:00:00 2001 From: igoramf Date: Mon, 4 May 2026 06:46:07 -0300 Subject: [PATCH 05/42] feat: replace DecoBuild CRD with Deco CR for cfworkers builds and previews Replaces the per-build DecoBuild CRD with a site-scoped Deco CR that owns both production and preview builds. The operator reconciles spec.build.source for production deploys and spec.previews.active[] for concurrent PR previews, fixing the concurrent PR overwrite bug. Co-Authored-By: Claude Sonnet 4.6 --- api/v1alpha1/deco_types.go | 189 ++++++++++++ api/v1alpha1/decobuild_types.go | 116 ------- api/v1alpha1/zz_generated.deepcopy.go | 185 +++++++++-- .../clusterrole-operator-manager-role.yaml | 6 +- ...ourcedefinition-decobuilds.deco.sites.yaml | 186 ------------ ...omresourcedefinition-decos.deco.sites.yaml | 129 ++++++++ cmd/main.go | 5 +- config/crd/bases/deco.sites_decobuilds.yaml | 172 +++++------ config/crd/bases/deco.sites_decos.yaml | 189 ++++++++++++ config/crd/kustomization.yaml | 2 +- config/rbac/role.yaml | 6 +- internal/build/job.go | 77 +++-- internal/build/s3presign.go | 2 +- internal/controller/build_controller.go | 157 ---------- internal/controller/deco_controller.go | 286 ++++++++++++++++++ 15 files changed, 1083 insertions(+), 624 deletions(-) create mode 100644 api/v1alpha1/deco_types.go delete mode 100644 chart/templates/customresourcedefinition-decobuilds.deco.sites.yaml create mode 100644 chart/templates/customresourcedefinition-decos.deco.sites.yaml create mode 100644 config/crd/bases/deco.sites_decos.yaml delete mode 100644 internal/controller/build_controller.go create mode 100644 internal/controller/deco_controller.go diff --git a/api/v1alpha1/deco_types.go b/api/v1alpha1/deco_types.go new file mode 100644 index 0000000..123c6da --- /dev/null +++ b/api/v1alpha1/deco_types.go @@ -0,0 +1,189 @@ +package v1alpha1 + +import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + +// DecoSpec defines the desired state of a Deco workload. +type DecoSpec struct { + // Type is the workload type. site | server | admin | preview + // +optional + Type string `json:"type,omitempty"` + + // Site is the site/repository name. + // +kubebuilder:validation:Required + Site string `json:"site"` + + // Org is the GitHub organization or owner. + // +kubebuilder:validation:Required + Org string `json:"org"` + + // Framework is the site framework. deno | tanstack | next | remix | static + // +optional + Framework string `json:"framework,omitempty"` + + // Build describes the production build pipeline. + // +optional + Build *DecoSpecBuild `json:"build,omitempty"` + + // Serving describes the runtime serving configuration. + // +optional + Serving *DecoSpecServing `json:"serving,omitempty"` + + // Previews configures preview builds for this site. + // Admin adds entries to Previews.Active on PR open and removes on PR close. + // +optional + Previews *DecoPreviewPolicy `json:"previews,omitempty"` +} + +// DecoSpecBuild describes the build pipeline for a workload. +type DecoSpecBuild struct { + // Type is the build mechanism. Currently only k8s-job is supported. + // +optional + Type string `json:"type,omitempty"` + + // Source identifies the code revision to build. + // Repository and owner come from spec.site and spec.org. + Source DecoSpecBuildSource `json:"source"` + + // Builder overrides the builder image (repository:tag). + // +optional + Builder string `json:"builder,omitempty"` +} + +// DecoSpecBuildSource identifies the code revision to build. +type DecoSpecBuildSource struct { + // CommitSha is the git commit SHA to build. + // Updating this field triggers a new build. + // +kubebuilder:validation:Required + CommitSha string `json:"commitSha"` + + // Production indicates whether this is a production deploy. + // +optional + Production bool `json:"production,omitempty"` + + // BranchRef is the branch name for preview aliases (non-production only). + // +optional + BranchRef string `json:"branchRef,omitempty"` +} + +// DecoSpecServing describes the runtime serving configuration. +type DecoSpecServing struct { + // Type is the serving runtime. Drives both serving and build job selection. + // Supported: cloudflare-worker | knative | deployment + // +kubebuilder:validation:Required + Type string `json:"type"` +} + +// DecoPreviewPolicy configures the preview system for this site. +type DecoPreviewPolicy struct { + // Type is the preview runtime. cloudflare-preview | statefulset | sandbox + // +kubebuilder:validation:Required + Type string `json:"type"` + + // MaxActive is the maximum number of concurrent previews the operator will build. + // Operator processes only the most recent MaxActive entries in Active. + // +optional + MaxActive int32 `json:"maxActive,omitempty"` + + // TTL is the duration after which completed previews are eligible for cleanup (e.g. "48h"). + // +optional + TTL string `json:"ttl,omitempty"` + + // Active is the list of preview builds currently requested. + // Admin adds entries on PR open, removes on PR close. + // +optional + Active []DecoPreviewRequest `json:"active,omitempty"` +} + +// DecoPreviewRequest identifies a single preview build request. +type DecoPreviewRequest struct { + // CommitSha is the git commit SHA to build. + // +kubebuilder:validation:Required + CommitSha string `json:"commitSha"` + + // BranchRef is the branch or PR ref (used as the wrangler preview alias). + // +kubebuilder:validation:Required + BranchRef string `json:"branchRef"` + + // PrId is the pull request ID, for tracking. + // +optional + PrId string `json:"prId,omitempty"` +} + +// DecoStatus defines the observed state of a Deco workload. +type DecoStatus struct { + // Build tracks the current production build lifecycle. + // +optional + Build *DecoStatusBuild `json:"build,omitempty"` + + // Previews tracks the build status of each active preview. + // +optional + Previews []DecoPreviewStatus `json:"previews,omitempty"` +} + +// DecoStatusBuild tracks the production build lifecycle. +type DecoStatusBuild struct { + // Phase is the current build phase: Running | Succeeded | Failed + // +optional + Phase string `json:"phase,omitempty"` + + // CommitSha is the commit currently being built (or last attempted). + // +optional + CommitSha string `json:"commitSha,omitempty"` + + // LastBuiltCommit is the commit SHA of the last successful build. + // +optional + LastBuiltCommit string `json:"lastBuiltCommit,omitempty"` + + // JobName is the K8s Job name for the current build. + // +optional + JobName string `json:"jobName,omitempty"` + + // StartTime is when the current build started. + // +optional + StartTime *metav1.Time `json:"startTime,omitempty"` + + // CompletionTime is when the current build finished. + // +optional + CompletionTime *metav1.Time `json:"completionTime,omitempty"` +} + +// DecoPreviewStatus tracks the build status of a single preview. +type DecoPreviewStatus struct { + CommitSha string `json:"commitSha"` + BranchRef string `json:"branchRef"` + PrId string `json:"prId,omitempty"` + JobName string `json:"jobName,omitempty"` + Phase string `json:"phase"` + StartTime *metav1.Time `json:"startTime,omitempty"` + CompletionTime *metav1.Time `json:"completionTime,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="Site",type=string,JSONPath=`.spec.site` +// +kubebuilder:printcolumn:name="Serving",type=string,JSONPath=`.spec.serving.type` +// +kubebuilder:printcolumn:name="Commit",type=string,JSONPath=`.spec.build.source.commitSha` +// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.build.phase` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` + +// Deco is the Schema for the decos API. +type Deco struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec DecoSpec `json:"spec,omitempty"` + Status DecoStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// DecoList contains a list of Deco. +type DecoList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []Deco `json:"items"` +} + +func init() { + SchemeBuilder.Register(&Deco{}, &DecoList{}) +} diff --git a/api/v1alpha1/decobuild_types.go b/api/v1alpha1/decobuild_types.go index fdea16e..1c26788 100644 --- a/api/v1alpha1/decobuild_types.go +++ b/api/v1alpha1/decobuild_types.go @@ -1,117 +1 @@ package v1alpha1 - -import ( - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -// DecoBuildPhase is the lifecycle phase of a DecoBuild. -type DecoBuildPhase string - -const ( - DecoBuildPhasePending DecoBuildPhase = "Pending" - DecoBuildPhaseRunning DecoBuildPhase = "Running" - DecoBuildPhaseSucceeded DecoBuildPhase = "Succeeded" - DecoBuildPhaseFailed DecoBuildPhase = "Failed" -) - -// DecoBuildSpec defines the desired state of a Cloudflare Workers build. -type DecoBuildSpec struct { - // Site is the deco site name (used as the Cloudflare Worker name by default). - // +kubebuilder:validation:Required - Site string `json:"site"` - - // Owner is the GitHub repository owner/org. - // +kubebuilder:validation:Required - Owner string `json:"owner"` - - // Repo is the GitHub repository name. - // +kubebuilder:validation:Required - Repo string `json:"repo"` - - // CommitSha is the git commit SHA to build. - // +kubebuilder:validation:Required - CommitSha string `json:"commitSha"` - - // Production indicates whether this is a production deploy. - // When false, a wrangler preview alias is created instead. - // +optional - Production bool `json:"production,omitempty"` - - // BranchRef is the branch name used as the preview alias message (non-production only). - // +optional - BranchRef string `json:"branchRef,omitempty"` - - // WorkerName overrides the Cloudflare Worker name. Defaults to site name. - // +optional - WorkerName string `json:"workerName,omitempty"` - - // EntryPoint is the worker entry file path. Defaults to src/worker-entry.ts. - // +optional - EntryPoint string `json:"entryPoint,omitempty"` - - // CompatDate is the Cloudflare compatibility date. Defaults to 2025-04-01. - // +optional - CompatDate string `json:"compatDate,omitempty"` - - // GithubToken is a GitHub token for cloning private repositories. - // Prefer GithubTokenSecret for production; this field is for convenience. - // +optional - GithubToken string `json:"githubToken,omitempty"` - - // GithubTokenSecret is the name of a K8s Secret (in this namespace) containing - // a "token" key with a GitHub token. Takes precedence over GithubToken. - // +optional - GithubTokenSecret string `json:"githubTokenSecret,omitempty"` -} - -// DecoBuildStatus defines the observed state of a DecoBuild. -type DecoBuildStatus struct { - // Phase is the current lifecycle phase. - // +optional - Phase DecoBuildPhase `json:"phase,omitempty"` - - // JobName is the K8s Job created for this build. - // +optional - JobName string `json:"jobName,omitempty"` - - // StartTime is when the build job was created. - // +optional - StartTime *metav1.Time `json:"startTime,omitempty"` - - // CompletionTime is when the build job finished (succeeded or failed). - // +optional - CompletionTime *metav1.Time `json:"completionTime,omitempty"` - - // Conditions represent the latest observations of the build state. - // +optional - Conditions []metav1.Condition `json:"conditions,omitempty"` -} - -// +kubebuilder:object:root=true -// +kubebuilder:subresource:status -// +kubebuilder:printcolumn:name="Site",type=string,JSONPath=`.spec.site` -// +kubebuilder:printcolumn:name="Commit",type=string,JSONPath=`.spec.commitSha` -// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase` -// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` - -// DecoBuild is the Schema for the decobuilds API. -type DecoBuild struct { - metav1.TypeMeta `json:",inline"` - metav1.ObjectMeta `json:"metadata,omitempty"` - - Spec DecoBuildSpec `json:"spec,omitempty"` - Status DecoBuildStatus `json:"status,omitempty"` -} - -// +kubebuilder:object:root=true - -// DecoBuildList contains a list of DecoBuild. -type DecoBuildList struct { - metav1.TypeMeta `json:",inline"` - metav1.ListMeta `json:"metadata,omitempty"` - Items []DecoBuild `json:"items"` -} - -func init() { - SchemeBuilder.Register(&DecoBuild{}, &DecoBuildList{}) -} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 3615d51..86cbc71 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -26,26 +26,26 @@ import ( ) // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *DecoBuild) DeepCopyInto(out *DecoBuild) { +func (in *Deco) DeepCopyInto(out *Deco) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - out.Spec = in.Spec + in.Spec.DeepCopyInto(&out.Spec) in.Status.DeepCopyInto(&out.Status) } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DecoBuild. -func (in *DecoBuild) DeepCopy() *DecoBuild { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Deco. +func (in *Deco) DeepCopy() *Deco { if in == nil { return nil } - out := new(DecoBuild) + out := new(Deco) in.DeepCopyInto(out) return out } // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *DecoBuild) DeepCopyObject() runtime.Object { +func (in *Deco) DeepCopyObject() runtime.Object { if c := in.DeepCopy(); c != nil { return c } @@ -53,31 +53,31 @@ func (in *DecoBuild) DeepCopyObject() runtime.Object { } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *DecoBuildList) DeepCopyInto(out *DecoBuildList) { +func (in *DecoList) DeepCopyInto(out *DecoList) { *out = *in out.TypeMeta = in.TypeMeta in.ListMeta.DeepCopyInto(&out.ListMeta) if in.Items != nil { in, out := &in.Items, &out.Items - *out = make([]DecoBuild, len(*in)) + *out = make([]Deco, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) } } } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DecoBuildList. -func (in *DecoBuildList) DeepCopy() *DecoBuildList { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DecoList. +func (in *DecoList) DeepCopy() *DecoList { if in == nil { return nil } - out := new(DecoBuildList) + out := new(DecoList) in.DeepCopyInto(out) return out } // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *DecoBuildList) DeepCopyObject() runtime.Object { +func (in *DecoList) DeepCopyObject() runtime.Object { if c := in.DeepCopy(); c != nil { return c } @@ -85,22 +85,42 @@ func (in *DecoBuildList) DeepCopyObject() runtime.Object { } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *DecoBuildSpec) DeepCopyInto(out *DecoBuildSpec) { +func (in *DecoPreviewPolicy) DeepCopyInto(out *DecoPreviewPolicy) { + *out = *in + if in.Active != nil { + in, out := &in.Active, &out.Active + *out = make([]DecoPreviewRequest, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DecoPreviewPolicy. +func (in *DecoPreviewPolicy) DeepCopy() *DecoPreviewPolicy { + if in == nil { + return nil + } + out := new(DecoPreviewPolicy) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DecoPreviewRequest) DeepCopyInto(out *DecoPreviewRequest) { *out = *in } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DecoBuildSpec. -func (in *DecoBuildSpec) DeepCopy() *DecoBuildSpec { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DecoPreviewRequest. +func (in *DecoPreviewRequest) DeepCopy() *DecoPreviewRequest { if in == nil { return nil } - out := new(DecoBuildSpec) + out := new(DecoPreviewRequest) in.DeepCopyInto(out) return out } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *DecoBuildStatus) DeepCopyInto(out *DecoBuildStatus) { +func (in *DecoPreviewStatus) DeepCopyInto(out *DecoPreviewStatus) { *out = *in if in.StartTime != nil { in, out := &in.StartTime, &out.StartTime @@ -110,21 +130,140 @@ func (in *DecoBuildStatus) DeepCopyInto(out *DecoBuildStatus) { in, out := &in.CompletionTime, &out.CompletionTime *out = (*in).DeepCopy() } - if in.Conditions != nil { - in, out := &in.Conditions, &out.Conditions - *out = make([]v1.Condition, len(*in)) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DecoPreviewStatus. +func (in *DecoPreviewStatus) DeepCopy() *DecoPreviewStatus { + if in == nil { + return nil + } + out := new(DecoPreviewStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DecoSpec) DeepCopyInto(out *DecoSpec) { + *out = *in + if in.Build != nil { + in, out := &in.Build, &out.Build + *out = new(DecoSpecBuild) + (*in).DeepCopyInto(*out) + } + if in.Serving != nil { + in, out := &in.Serving, &out.Serving + *out = new(DecoSpecServing) + **out = **in + } + if in.Previews != nil { + in, out := &in.Previews, &out.Previews + *out = new(DecoPreviewPolicy) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DecoSpec. +func (in *DecoSpec) DeepCopy() *DecoSpec { + if in == nil { + return nil + } + out := new(DecoSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DecoSpecBuild) DeepCopyInto(out *DecoSpecBuild) { + *out = *in + out.Source = in.Source +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DecoSpecBuild. +func (in *DecoSpecBuild) DeepCopy() *DecoSpecBuild { + if in == nil { + return nil + } + out := new(DecoSpecBuild) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DecoSpecBuildSource) DeepCopyInto(out *DecoSpecBuildSource) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DecoSpecBuildSource. +func (in *DecoSpecBuildSource) DeepCopy() *DecoSpecBuildSource { + if in == nil { + return nil + } + out := new(DecoSpecBuildSource) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DecoSpecServing) DeepCopyInto(out *DecoSpecServing) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DecoSpecServing. +func (in *DecoSpecServing) DeepCopy() *DecoSpecServing { + if in == nil { + return nil + } + out := new(DecoSpecServing) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DecoStatus) DeepCopyInto(out *DecoStatus) { + *out = *in + if in.Build != nil { + in, out := &in.Build, &out.Build + *out = new(DecoStatusBuild) + (*in).DeepCopyInto(*out) + } + if in.Previews != nil { + in, out := &in.Previews, &out.Previews + *out = make([]DecoPreviewStatus, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) } } } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DecoBuildStatus. -func (in *DecoBuildStatus) DeepCopy() *DecoBuildStatus { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DecoStatus. +func (in *DecoStatus) DeepCopy() *DecoStatus { + if in == nil { + return nil + } + out := new(DecoStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DecoStatusBuild) DeepCopyInto(out *DecoStatusBuild) { + *out = *in + if in.StartTime != nil { + in, out := &in.StartTime, &out.StartTime + *out = (*in).DeepCopy() + } + if in.CompletionTime != nil { + in, out := &in.CompletionTime, &out.CompletionTime + *out = (*in).DeepCopy() + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DecoStatusBuild. +func (in *DecoStatusBuild) DeepCopy() *DecoStatusBuild { if in == nil { return nil } - out := new(DecoBuildStatus) + out := new(DecoStatusBuild) in.DeepCopyInto(out) return out } diff --git a/chart/templates/clusterrole-operator-manager-role.yaml b/chart/templates/clusterrole-operator-manager-role.yaml index 1ff0b2d..79c3477 100644 --- a/chart/templates/clusterrole-operator-manager-role.yaml +++ b/chart/templates/clusterrole-operator-manager-role.yaml @@ -55,8 +55,8 @@ rules: - apiGroups: - deco.sites resources: - - decobuilds - decofiles + - decos verbs: - create - delete @@ -68,15 +68,15 @@ rules: - apiGroups: - deco.sites resources: - - decobuilds/finalizers - decofiles/finalizers + - decos/finalizers verbs: - update - apiGroups: - deco.sites resources: - - decobuilds/status - decofiles/status + - decos/status verbs: - get - patch diff --git a/chart/templates/customresourcedefinition-decobuilds.deco.sites.yaml b/chart/templates/customresourcedefinition-decobuilds.deco.sites.yaml deleted file mode 100644 index b064984..0000000 --- a/chart/templates/customresourcedefinition-decobuilds.deco.sites.yaml +++ /dev/null @@ -1,186 +0,0 @@ -apiVersion: apiextensions.k8s.io/v1 -kind: CustomResourceDefinition -metadata: - annotations: - controller-gen.kubebuilder.io/version: v0.18.0 - name: decobuilds.deco.sites -spec: - group: deco.sites - names: - kind: DecoBuild - listKind: DecoBuildList - plural: decobuilds - singular: decobuild - scope: Namespaced - versions: - - additionalPrinterColumns: - - jsonPath: .spec.site - name: Site - type: string - - jsonPath: .spec.commitSha - name: Commit - type: string - - jsonPath: .status.phase - name: Phase - type: string - - jsonPath: .metadata.creationTimestamp - name: Age - type: date - name: v1alpha1 - schema: - openAPIV3Schema: - description: DecoBuild is the Schema for the decobuilds API. - properties: - apiVersion: - description: |- - APIVersion defines the versioned schema of this representation of an object. - Servers should convert recognized schemas to the latest internal value, and - may reject unrecognized values. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources - type: string - kind: - description: |- - Kind is a string value representing the REST resource this object represents. - Servers may infer this from the endpoint the client submits requests to. - Cannot be updated. - In CamelCase. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds - type: string - metadata: - type: object - spec: - description: DecoBuildSpec defines the desired state of a Cloudflare Workers - build. - properties: - branchRef: - description: BranchRef is the branch name used as the preview alias - message (non-production only). - type: string - commitSha: - description: CommitSha is the git commit SHA to build. - type: string - compatDate: - description: CompatDate is the Cloudflare compatibility date. Defaults - to 2025-04-01. - type: string - entryPoint: - description: EntryPoint is the worker entry file path. Defaults to - src/worker-entry.ts. - type: string - githubToken: - description: |- - GithubToken is a GitHub token for cloning private repositories. - Prefer GithubTokenSecret for production; this field is for convenience. - type: string - githubTokenSecret: - description: |- - GithubTokenSecret is the name of a K8s Secret (in this namespace) containing - a "token" key with a GitHub token. Takes precedence over GithubToken. - type: string - owner: - description: Owner is the GitHub repository owner/org. - type: string - production: - description: |- - Production indicates whether this is a production deploy. - When false, a wrangler preview alias is created instead. - type: boolean - repo: - description: Repo is the GitHub repository name. - type: string - site: - description: Site is the deco site name (used as the Cloudflare Worker - name by default). - type: string - workerName: - description: WorkerName overrides the Cloudflare Worker name. Defaults - to site name. - type: string - required: - - commitSha - - owner - - repo - - site - type: object - status: - description: DecoBuildStatus defines the observed state of a DecoBuild. - properties: - completionTime: - description: CompletionTime is when the build job finished (succeeded - or failed). - format: date-time - type: string - conditions: - description: Conditions represent the latest observations of the build - state. - items: - description: Condition contains details for one aspect of the current - state of this API Resource. - properties: - lastTransitionTime: - description: |- - lastTransitionTime is the last time the condition transitioned from one status to another. - This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. - format: date-time - type: string - message: - description: |- - message is a human readable message indicating details about the transition. - This may be an empty string. - maxLength: 32768 - type: string - observedGeneration: - description: |- - observedGeneration represents the .metadata.generation that the condition was set based upon. - For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date - with respect to the current state of the instance. - format: int64 - minimum: 0 - type: integer - reason: - description: |- - reason contains a programmatic identifier indicating the reason for the condition's last transition. - Producers of specific condition types may define expected values and meanings for this field, - and whether the values are considered a guaranteed API. - The value should be a CamelCase string. - This field may not be empty. - maxLength: 1024 - minLength: 1 - pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ - type: string - status: - description: status of the condition, one of True, False, Unknown. - enum: - - "True" - - "False" - - Unknown - type: string - type: - description: type of condition in CamelCase or in foo.example.com/CamelCase. - maxLength: 316 - pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ - type: string - required: - - lastTransitionTime - - message - - reason - - status - - type - type: object - type: array - jobName: - description: JobName is the K8s Job created for this build. - type: string - phase: - description: Phase is the current lifecycle phase. - type: string - startTime: - description: StartTime is when the build job was created. - format: date-time - type: string - type: object - type: object - served: true - storage: true - subresources: - status: {} \ No newline at end of file diff --git a/chart/templates/customresourcedefinition-decos.deco.sites.yaml b/chart/templates/customresourcedefinition-decos.deco.sites.yaml new file mode 100644 index 0000000..1ff51b8 --- /dev/null +++ b/chart/templates/customresourcedefinition-decos.deco.sites.yaml @@ -0,0 +1,129 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.18.0 + name: decos.deco.sites +spec: + group: deco.sites + names: + kind: Deco + listKind: DecoList + plural: decos + singular: deco + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.site + name: Site + type: string + - jsonPath: .spec.serving.type + name: Serving + type: string + - jsonPath: .spec.build.source.commitSha + name: Commit + type: string + - jsonPath: .status.build.phase + name: Phase + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Deco is the Schema for the decos API. + properties: + apiVersion: + type: string + kind: + type: string + metadata: + type: object + spec: + description: DecoSpec defines the desired state of a Deco workload. + properties: + type: + description: Type is the workload type. site | server | admin | preview + type: string + site: + description: Site is the site/repository name. + type: string + org: + description: Org is the GitHub organization or owner. + type: string + framework: + description: Framework is the site framework. deno | tanstack | next | remix | static + type: string + build: + description: Build describes the build pipeline. + properties: + type: + description: Type is the build mechanism. Currently only k8s-job is supported. + type: string + builder: + description: Builder overrides the builder image (repository:tag). + type: string + source: + description: Source identifies the code revision to build. + properties: + commitSha: + description: CommitSha is the git commit SHA to build. Updating this field triggers a new build. + type: string + production: + description: Production indicates whether this is a production deploy. + type: boolean + branchRef: + description: BranchRef is the branch name for preview aliases (non-production only). + type: string + required: + - commitSha + type: object + required: + - source + type: object + serving: + description: Serving describes the runtime serving configuration. Also drives build job selection. + properties: + type: + description: 'Type is the serving runtime. Supported: cloudflare-worker | knative | deployment' + type: string + required: + - type + type: object + required: + - site + - org + type: object + status: + description: DecoStatus defines the observed state of a Deco workload. + properties: + build: + description: Build tracks the current build lifecycle. + properties: + phase: + description: 'Phase is the current build phase: Running | Succeeded | Failed' + type: string + commitSha: + description: CommitSha is the commit currently being built (or last attempted). + type: string + lastBuiltCommit: + description: LastBuiltCommit is the commit SHA of the last successful build. + type: string + jobName: + description: JobName is the K8s Job name for the current build. + type: string + startTime: + format: date-time + type: string + completionTime: + format: date-time + type: string + type: object + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/cmd/main.go b/cmd/main.go index 83c3412..ece4b1c 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -344,9 +344,10 @@ func main() { os.Exit(1) } } - if err := (&controller.BuildReconciler{ + if err := (&controller.DecoReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), + GithubToken: os.Getenv("GITHUB_TOKEN"), CfApiToken: cfApiToken, CfAccountId: cfAccountId, S3Config: build.S3Config{ @@ -355,7 +356,7 @@ func main() { SecretAccessKey: s3SecretAccessKey, }, }).SetupWithManager(mgr); err != nil { - setupLog.Error(err, "unable to create controller", "controller", "DecoBuild") + setupLog.Error(err, "unable to create controller", "controller", "Deco") os.Exit(1) } // +kubebuilder:scaffold:builder diff --git a/config/crd/bases/deco.sites_decobuilds.yaml b/config/crd/bases/deco.sites_decobuilds.yaml index fe74bcf..ebba1aa 100644 --- a/config/crd/bases/deco.sites_decobuilds.yaml +++ b/config/crd/bases/deco.sites_decobuilds.yaml @@ -15,10 +15,13 @@ spec: scope: Namespaced versions: - additionalPrinterColumns: - - jsonPath: .spec.site - name: Site + - jsonPath: .spec.source.repo + name: Repo type: string - - jsonPath: .spec.commitSha + - jsonPath: .spec.target.type + name: Target + type: string + - jsonPath: .spec.source.commitSha name: Commit type: string - jsonPath: .status.phase @@ -33,131 +36,121 @@ spec: description: DecoBuild is the Schema for the decobuilds API. properties: apiVersion: - description: |- - APIVersion defines the versioned schema of this representation of an object. - Servers should convert recognized schemas to the latest internal value, and - may reject unrecognized values. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources type: string kind: - description: |- - Kind is a string value representing the REST resource this object represents. - Servers may infer this from the endpoint the client submits requests to. - Cannot be updated. - In CamelCase. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds type: string metadata: type: object spec: - description: DecoBuildSpec defines the desired state of a Cloudflare Workers - build. + description: DecoBuildSpec defines the desired state of a build. properties: - branchRef: - description: BranchRef is the branch name used as the preview alias - message (non-production only). - type: string - commitSha: - description: CommitSha is the git commit SHA to build. - type: string - compatDate: - description: CompatDate is the Cloudflare compatibility date. Defaults - to 2025-04-01. - type: string - entryPoint: - description: EntryPoint is the worker entry file path. Defaults to - src/worker-entry.ts. - type: string - githubToken: - description: |- - GithubToken is a GitHub token for cloning private repositories. - Prefer GithubTokenSecret for production; this field is for convenience. - type: string - githubTokenSecret: - description: |- - GithubTokenSecret is the name of a K8s Secret (in this namespace) containing - a "token" key with a GitHub token. Takes precedence over GithubToken. - type: string - owner: - description: Owner is the GitHub repository owner/org. - type: string - production: - description: |- - Production indicates whether this is a production deploy. - When false, a wrangler preview alias is created instead. - type: boolean - repo: - description: Repo is the GitHub repository name. - type: string - site: - description: Site is the deco site name (used as the Cloudflare Worker - name by default). - type: string - workerName: - description: WorkerName overrides the Cloudflare Worker name. Defaults - to site name. - type: string + build: + description: Build describes the build mechanism. Defaults to k8s-job when omitted. + properties: + type: + description: Type is the build mechanism. Currently only k8s-job is supported. + type: string + type: object + source: + description: Source describes the code to build. + properties: + owner: + description: Owner is the GitHub repository owner/org. + type: string + repo: + description: Repo is the GitHub repository name. + type: string + commitSha: + description: CommitSha is the git commit SHA to build. + type: string + production: + description: Production indicates whether this is a production deploy. + type: boolean + branchRef: + description: BranchRef is the branch name used as preview alias (non-production only). + type: string + required: + - owner + - repo + - commitSha + type: object + builder: + description: Builder overrides the builder image. + properties: + image: + description: Image is the full image reference (repository:tag). + type: string + required: + - image + type: object + artifact: + description: Artifact describes where the build output is stored after a successful build. + properties: + bucket: + description: Bucket is the S3 bucket name. + type: string + key: + description: Key is the S3 object key. Supports {repo} and {commitSha} placeholders. + type: string + required: + - bucket + - key + type: object + target: + description: Target describes the deploy target platform. + properties: + type: + description: 'Type is the target runtime platform. Supported: cloudflare-worker' + type: string + cloudflare-worker: + description: CloudflareWorker contains settings specific to Cloudflare Workers deployments. + properties: + entryPoint: + description: EntryPoint is the worker entry file path. Defaults to src/worker-entry.ts. + type: string + compatDate: + description: CompatDate is the Cloudflare compatibility date. Defaults to 2025-04-01. + type: string + type: object + required: + - type + type: object required: - - commitSha - - owner - - repo - - site + - source + - target type: object status: description: DecoBuildStatus defines the observed state of a DecoBuild. properties: completionTime: - description: CompletionTime is when the build job finished (succeeded - or failed). format: date-time type: string conditions: - description: Conditions represent the latest observations of the build - state. items: - description: Condition contains details for one aspect of the current - state of this API Resource. + description: Condition contains details for one aspect of the current state of this API Resource. properties: lastTransitionTime: - description: |- - lastTransitionTime is the last time the condition transitioned from one status to another. - This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. format: date-time type: string message: - description: |- - message is a human readable message indicating details about the transition. - This may be an empty string. maxLength: 32768 type: string observedGeneration: - description: |- - observedGeneration represents the .metadata.generation that the condition was set based upon. - For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date - with respect to the current state of the instance. format: int64 minimum: 0 type: integer reason: - description: |- - reason contains a programmatic identifier indicating the reason for the condition's last transition. - Producers of specific condition types may define expected values and meanings for this field, - and whether the values are considered a guaranteed API. - The value should be a CamelCase string. - This field may not be empty. maxLength: 1024 minLength: 1 pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ type: string status: - description: status of the condition, one of True, False, Unknown. enum: - "True" - "False" - Unknown type: string type: - description: type of condition in CamelCase or in foo.example.com/CamelCase. maxLength: 316 pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ type: string @@ -170,13 +163,10 @@ spec: type: object type: array jobName: - description: JobName is the K8s Job created for this build. type: string phase: - description: Phase is the current lifecycle phase. type: string startTime: - description: StartTime is when the build job was created. format: date-time type: string type: object diff --git a/config/crd/bases/deco.sites_decos.yaml b/config/crd/bases/deco.sites_decos.yaml new file mode 100644 index 0000000..27d0775 --- /dev/null +++ b/config/crd/bases/deco.sites_decos.yaml @@ -0,0 +1,189 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.18.0 + name: decos.deco.sites +spec: + group: deco.sites + names: + kind: Deco + listKind: DecoList + plural: decos + singular: deco + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.site + name: Site + type: string + - jsonPath: .spec.serving.type + name: Serving + type: string + - jsonPath: .spec.build.source.commitSha + name: Commit + type: string + - jsonPath: .status.build.phase + name: Phase + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Deco is the Schema for the decos API. + properties: + apiVersion: + type: string + kind: + type: string + metadata: + type: object + spec: + description: DecoSpec defines the desired state of a Deco workload. + properties: + type: + description: Type is the workload type. site | server | admin | preview + type: string + site: + description: Site is the site/repository name. + type: string + org: + description: Org is the GitHub organization or owner. + type: string + framework: + description: Framework is the site framework. deno | tanstack | next | remix | static + type: string + build: + description: Build describes the build pipeline. + properties: + type: + description: Type is the build mechanism. Currently only k8s-job is supported. + type: string + builder: + description: Builder overrides the builder image (repository:tag). + type: string + source: + description: Source identifies the code revision to build. + properties: + commitSha: + description: CommitSha is the git commit SHA to build. Updating this field triggers a new build. + type: string + production: + description: Production indicates whether this is a production deploy. + type: boolean + branchRef: + description: BranchRef is the branch name for preview aliases (non-production only). + type: string + required: + - commitSha + type: object + required: + - source + type: object + serving: + description: Serving describes the runtime serving configuration. Also drives build job selection. + properties: + type: + description: 'Type is the serving runtime. Supported: cloudflare-worker | knative | deployment' + type: string + required: + - type + type: object + previews: + description: Previews configures preview builds for this site. + properties: + type: + description: Preview runtime. cloudflare-preview | statefulset | sandbox + type: string + maxActive: + description: Maximum number of concurrent previews the operator will build. + format: int32 + type: integer + ttl: + description: Duration after which completed previews are eligible for cleanup (e.g. "48h"). + type: string + active: + description: List of preview builds currently requested. + items: + properties: + commitSha: + description: Git commit SHA to build. + type: string + branchRef: + description: Branch or PR ref used as the wrangler preview alias. + type: string + prId: + description: Pull request ID for tracking. + type: string + required: + - commitSha + - branchRef + type: object + type: array + required: + - type + type: object + required: + - site + - org + type: object + status: + description: DecoStatus defines the observed state of a Deco workload. + properties: + build: + description: Build tracks the current build lifecycle. + properties: + phase: + description: 'Phase is the current build phase: Running | Succeeded | Failed' + type: string + commitSha: + description: CommitSha is the commit currently being built (or last attempted). + type: string + lastBuiltCommit: + description: LastBuiltCommit is the commit SHA of the last successful build. + type: string + jobName: + description: JobName is the K8s Job name for the current build. + type: string + startTime: + format: date-time + type: string + completionTime: + format: date-time + type: string + type: object + previews: + description: Previews tracks the build status of each active preview. + items: + properties: + commitSha: + type: string + branchRef: + type: string + prId: + type: string + jobName: + type: string + phase: + type: string + startTime: + format: date-time + type: string + completionTime: + format: date-time + type: string + required: + - commitSha + - branchRef + - phase + type: object + type: array + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 615115a..3e19e85 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -2,7 +2,7 @@ # since it depends on service name and namespace that are out of this kustomize package. # It should be run by config/default resources: -- bases/deco.sites_decobuilds.yaml +- bases/deco.sites_decos.yaml - bases/deco.sites_decofiles.yaml # +kubebuilder:scaffold:crdkustomizeresource diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 94b6f3a..2439d35 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -56,8 +56,8 @@ rules: - apiGroups: - deco.sites resources: - - decobuilds - decofiles + - decos verbs: - create - delete @@ -69,15 +69,15 @@ rules: - apiGroups: - deco.sites resources: - - decobuilds/finalizers - decofiles/finalizers + - decos/finalizers verbs: - update - apiGroups: - deco.sites resources: - - decobuilds/status - decofiles/status + - decos/status verbs: - get - patch diff --git a/internal/build/job.go b/internal/build/job.go index 66d0cd6..aa5aa24 100644 --- a/internal/build/job.go +++ b/internal/build/job.go @@ -1,5 +1,4 @@ // Package build contains helpers for creating Cloudflare Workers build Jobs. -// It is the Go equivalent of hosting/cfworkers/build.ts in the admin. package build import ( @@ -15,22 +14,19 @@ import ( ) const ( - BuilderImage = "ghcr.io/decocms/infra_applications/cfworkers-builder:v1.0.0" - DefaultEntryPoint = "src/worker-entry.ts" - DefaultCompatDate = "2025-04-01" + BuilderImage = "ghcr.io/decocms/infra_applications/cfworkers-builder:latest" LogsBucket = "deco-sites-build-logs" CacheBucket = "deco-cfworkers-deployments" ttlSecondsAfterFinished = int32(24 * 60 * 60) // 24h ) -// JobName returns a deterministic job name from a commit SHA. -// Mirrors generateJobName() in the admin's build.ts. -func JobName(commitSha string) string { - h := sha256.Sum256([]byte("build-" + commitSha)) - return fmt.Sprintf("build-%x", h[:6]) +// JobName returns a deterministic job name: sha256("build-{commitSha}-{site}"), first 4 bytes as hex. +func JobName(commitSha, site string) string { + h := sha256.Sum256([]byte("build-" + commitSha + "-" + site)) + return fmt.Sprintf("build-%x", h[:4]) } -// PresignedURLs are the three S3 presigned URLs the build job needs. +// PresignedURLs are the S3 presigned URLs the build job needs. type PresignedURLs struct { LogsUpload string CacheDownload string @@ -39,53 +35,53 @@ type PresignedURLs struct { // JobOpts are the inputs for NewJob. type JobOpts struct { - Build *decositesv1alpha1.DecoBuild + Deco *decositesv1alpha1.Deco JobName string GithubToken string CfApiToken string CfAccountId string PresignedURLs PresignedURLs + // SourceOverride replaces spec.build.source when set (used for preview builds). + SourceOverride *decositesv1alpha1.DecoSpecBuildSource } // NewJob builds the batchv1.Job spec for a cfworkers build. -// This is the Go equivalent of buildJobOf() in the admin's build.ts. func NewJob(opts JobOpts) *batchv1.Job { - spec := opts.Build.Spec - - workerName := spec.WorkerName - if workerName == "" { - workerName = spec.Site - } - entryPoint := spec.EntryPoint - if entryPoint == "" { - entryPoint = DefaultEntryPoint - } - compatDate := spec.CompatDate - if compatDate == "" { - compatDate = DefaultCompatDate + spec := opts.Deco.Spec + var src decositesv1alpha1.DecoSpecBuildSource + if opts.SourceOverride != nil { + src = *opts.SourceOverride + } else if spec.Build != nil { + src = spec.Build.Source } + + owner := spec.Org + repo := spec.Site + isProduction := "false" - if spec.Production { + if src.Production { isProduction = "true" } + builderImage := BuilderImage + if spec.Build != nil && spec.Build.Builder != "" { + builderImage = spec.Build.Builder + } + env := []corev1.EnvVar{ - {Name: "GIT_REPO", Value: fmt.Sprintf("https://github.com/%s/%s", spec.Owner, spec.Repo)}, - {Name: "COMMIT_SHA", Value: spec.CommitSha}, - {Name: "DECO_SITE_NAME", Value: spec.Site}, + {Name: "GIT_REPO", Value: fmt.Sprintf("https://github.com/%s/%s", owner, repo)}, + {Name: "COMMIT_SHA", Value: src.CommitSha}, + {Name: "DECO_SITE_NAME", Value: repo}, {Name: "BUILD_NAME", Value: opts.JobName}, {Name: "IS_PRODUCTION", Value: isProduction}, - {Name: "WORKER_NAME", Value: workerName}, {Name: "CF_ACCOUNT_ID", Value: opts.CfAccountId}, {Name: "CLOUDFLARE_API_TOKEN", Value: opts.CfApiToken}, - {Name: "ENTRY_POINT", Value: entryPoint}, - {Name: "COMPAT_DATE", Value: compatDate}, {Name: "LOGS_UPLOAD_URL", Value: opts.PresignedURLs.LogsUpload}, {Name: "CACHE_DOWNLOAD_URL", Value: opts.PresignedURLs.CacheDownload}, {Name: "CACHE_UPLOAD_URL", Value: opts.PresignedURLs.CacheUpload}, } - if spec.BranchRef != "" { - env = append(env, corev1.EnvVar{Name: "BRANCH_REF", Value: spec.BranchRef}) + if src.BranchRef != "" { + env = append(env, corev1.EnvVar{Name: "BRANCH_REF", Value: src.BranchRef}) } if opts.GithubToken != "" { env = append(env, corev1.EnvVar{Name: "GITHUB_TOKEN", Value: opts.GithubToken}) @@ -97,12 +93,11 @@ func NewJob(opts JobOpts) *batchv1.Job { return &batchv1.Job{ ObjectMeta: metav1.ObjectMeta{ Name: opts.JobName, - Namespace: opts.Build.Namespace, + Namespace: opts.Deco.Namespace, Labels: map[string]string{ - "app.deco/site": spec.Site, - "app.deco/owner": spec.Owner, - "app.deco/repo": spec.Repo, - "app.deco/platform": "cfworkers", + "app.deco/site": repo, + "app.deco/org": owner, + "app.deco/serving": spec.Serving.Type, }, }, Spec: batchv1.JobSpec{ @@ -114,11 +109,11 @@ func NewJob(opts JobOpts) *batchv1.Job { Containers: []corev1.Container{ { Name: "builder", - Image: BuilderImage, + Image: builderImage, Env: env, Resources: corev1.ResourceRequirements{ Requests: corev1.ResourceList{ - corev1.ResourceMemory: resource.MustParse("4Gi"), + corev1.ResourceMemory: resource.MustParse("1Gi"), corev1.ResourceCPU: resource.MustParse("500m"), corev1.ResourceEphemeralStorage: resource.MustParse("2Gi"), }, diff --git a/internal/build/s3presign.go b/internal/build/s3presign.go index 6cedd2b..122c92d 100644 --- a/internal/build/s3presign.go +++ b/internal/build/s3presign.go @@ -20,7 +20,7 @@ type S3Config struct { SecretAccessKey string } -// GeneratePresignedURLs generates the three presigned URLs the build job needs. +// GeneratePresignedURLs generates all presigned URLs the build job needs. // Mirrors generatePresignedUrls() in the admin's build.ts. func GeneratePresignedURLs(ctx context.Context, cfg S3Config, site, jobName string) (PresignedURLs, error) { awsCfg, err := config.LoadDefaultConfig(ctx, diff --git a/internal/controller/build_controller.go b/internal/controller/build_controller.go deleted file mode 100644 index f2bf3ab..0000000 --- a/internal/controller/build_controller.go +++ /dev/null @@ -1,157 +0,0 @@ -package controller - -import ( - "context" - "fmt" - - batchv1 "k8s.io/api/batch/v1" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/types" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller" - "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" - logf "sigs.k8s.io/controller-runtime/pkg/log" - - decositesv1alpha1 "github.com/deco-sites/decofile-operator/api/v1alpha1" - "github.com/deco-sites/decofile-operator/internal/build" -) - -// BuildReconciler reconciles DecoBuild objects. -// It creates a K8s Job for each build and tracks the Job's outcome back to the DecoBuild status. -type BuildReconciler struct { - client.Client - Scheme *runtime.Scheme - CfApiToken string - CfAccountId string - S3Config build.S3Config -} - -// +kubebuilder:rbac:groups=deco.sites,resources=decobuilds,verbs=get;list;watch;create;update;patch;delete -// +kubebuilder:rbac:groups=deco.sites,resources=decobuilds/status,verbs=get;update;patch -// +kubebuilder:rbac:groups=deco.sites,resources=decobuilds/finalizers,verbs=update -// +kubebuilder:rbac:groups=batch,resources=jobs,verbs=get;list;watch;create;delete - -func (r *BuildReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - log := logf.FromContext(ctx) - - decoBuild := &decositesv1alpha1.DecoBuild{} - if err := r.Get(ctx, req.NamespacedName, decoBuild); err != nil { - if errors.IsNotFound(err) { - return ctrl.Result{}, nil - } - return ctrl.Result{}, err - } - - // Nothing to do for terminal phases. - if decoBuild.Status.Phase == decositesv1alpha1.DecoBuildPhaseSucceeded || - decoBuild.Status.Phase == decositesv1alpha1.DecoBuildPhaseFailed { - return ctrl.Result{}, nil - } - - jobName := build.JobName(decoBuild.Spec.CommitSha) - - existingJob := &batchv1.Job{} - err := r.Get(ctx, types.NamespacedName{Name: jobName, Namespace: decoBuild.Namespace}, existingJob) - if errors.IsNotFound(err) { - log.Info("Creating build job", "job", jobName, "site", decoBuild.Spec.Site) - return r.createJob(ctx, decoBuild, jobName) - } - if err != nil { - return ctrl.Result{}, err - } - - return r.syncStatus(ctx, decoBuild, existingJob) -} - -func (r *BuildReconciler) createJob(ctx context.Context, decoBuild *decositesv1alpha1.DecoBuild, jobName string) (ctrl.Result, error) { - githubToken, err := r.resolveGithubToken(ctx, decoBuild) - if err != nil { - return ctrl.Result{}, fmt.Errorf("resolving github token: %w", err) - } - - presignedURLs, err := build.GeneratePresignedURLs(ctx, r.S3Config, decoBuild.Spec.Site, jobName) - if err != nil { - return ctrl.Result{}, fmt.Errorf("generating presigned URLs: %w", err) - } - - job := build.NewJob(build.JobOpts{ - Build: decoBuild, - JobName: jobName, - GithubToken: githubToken, - CfApiToken: r.CfApiToken, - CfAccountId: r.CfAccountId, - PresignedURLs: presignedURLs, - }) - - if err := controllerutil.SetControllerReference(decoBuild, job, r.Scheme); err != nil { - return ctrl.Result{}, fmt.Errorf("setting owner reference: %w", err) - } - - if err := r.Create(ctx, job); err != nil && !errors.IsAlreadyExists(err) { - return ctrl.Result{}, fmt.Errorf("creating build job: %w", err) - } - - now := metav1.Now() - decoBuild.Status.Phase = decositesv1alpha1.DecoBuildPhaseRunning - decoBuild.Status.JobName = jobName - decoBuild.Status.StartTime = &now - return ctrl.Result{}, r.Status().Update(ctx, decoBuild) -} - -// syncStatus maps the K8s Job conditions to the DecoBuild phase. -// Mirrors buildStatusOf() in the admin's build.ts. -func (r *BuildReconciler) syncStatus(ctx context.Context, decoBuild *decositesv1alpha1.DecoBuild, job *batchv1.Job) (ctrl.Result, error) { - phase := jobPhase(job) - if phase == decoBuild.Status.Phase { - return ctrl.Result{}, nil - } - - decoBuild.Status.Phase = phase - if phase == decositesv1alpha1.DecoBuildPhaseSucceeded || phase == decositesv1alpha1.DecoBuildPhaseFailed { - now := metav1.Now() - decoBuild.Status.CompletionTime = &now - } - return ctrl.Result{}, r.Status().Update(ctx, decoBuild) -} - -// jobPhase maps batchv1.Job conditions to a DecoBuildPhase. -func jobPhase(job *batchv1.Job) decositesv1alpha1.DecoBuildPhase { - for _, c := range job.Status.Conditions { - if c.Status != corev1.ConditionTrue { - continue - } - switch c.Type { - case batchv1.JobComplete, "SuccessCriteriaMet": - return decositesv1alpha1.DecoBuildPhaseSucceeded - case batchv1.JobFailed: - return decositesv1alpha1.DecoBuildPhaseFailed - } - } - return decositesv1alpha1.DecoBuildPhaseRunning -} - -// resolveGithubToken returns the GitHub token from spec or from the referenced Secret. -// GithubTokenSecret takes precedence over the inline GithubToken field. -func (r *BuildReconciler) resolveGithubToken(ctx context.Context, decoBuild *decositesv1alpha1.DecoBuild) (string, error) { - if secretName := decoBuild.Spec.GithubTokenSecret; secretName != "" { - secret := &corev1.Secret{} - if err := r.Get(ctx, types.NamespacedName{Name: secretName, Namespace: decoBuild.Namespace}, secret); err != nil { - return "", err - } - return string(secret.Data["token"]), nil - } - return decoBuild.Spec.GithubToken, nil -} - -func (r *BuildReconciler) SetupWithManager(mgr ctrl.Manager) error { - return ctrl.NewControllerManagedBy(mgr). - For(&decositesv1alpha1.DecoBuild{}). - Owns(&batchv1.Job{}). - WithOptions(controller.Options{MaxConcurrentReconciles: 4}). - Named("decobuild"). - Complete(r) -} diff --git a/internal/controller/deco_controller.go b/internal/controller/deco_controller.go new file mode 100644 index 0000000..3551fc0 --- /dev/null +++ b/internal/controller/deco_controller.go @@ -0,0 +1,286 @@ +package controller + +import ( + "context" + "fmt" + + "github.com/go-logr/logr" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + logf "sigs.k8s.io/controller-runtime/pkg/log" + + decositesv1alpha1 "github.com/deco-sites/decofile-operator/api/v1alpha1" + "github.com/deco-sites/decofile-operator/internal/build" +) + +// DecoReconciler reconciles Deco objects. +type DecoReconciler struct { + client.Client + Scheme *runtime.Scheme + GithubToken string + CfApiToken string + CfAccountId string + S3Config build.S3Config +} + +// +kubebuilder:rbac:groups=deco.sites,resources=decos,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=deco.sites,resources=decos/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=deco.sites,resources=decos/finalizers,verbs=update +// +kubebuilder:rbac:groups=batch,resources=jobs,verbs=get;list;watch;create;delete + +func (r *DecoReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := logf.FromContext(ctx) + + deco := &decositesv1alpha1.Deco{} + if err := r.Get(ctx, req.NamespacedName, deco); err != nil { + if errors.IsNotFound(err) { + return ctrl.Result{}, nil + } + return ctrl.Result{}, err + } + + patch := deco.DeepCopy() + statusChanged := false + + if deco.Spec.Build != nil && deco.Spec.Build.Source.CommitSha != "" { + changed, err := r.reconcileProductionBuild(ctx, log, deco, patch) + if err != nil { + return ctrl.Result{}, err + } + statusChanged = statusChanged || changed + } + + if deco.Spec.Previews != nil && len(deco.Spec.Previews.Active) > 0 { + changed, err := r.reconcilePreviewBuilds(ctx, log, deco, patch) + if err != nil { + return ctrl.Result{}, err + } + statusChanged = statusChanged || changed + } else if len(deco.Status.Previews) > 0 { + patch.Status.Previews = nil + statusChanged = true + } + + if statusChanged { + return ctrl.Result{}, r.Status().Update(ctx, patch) + } + return ctrl.Result{}, nil +} + +func (r *DecoReconciler) reconcileProductionBuild(ctx context.Context, log logr.Logger, deco, patch *decositesv1alpha1.Deco) (bool, error) { + commitSha := deco.Spec.Build.Source.CommitSha + + if deco.Status.Build != nil && deco.Status.Build.LastBuiltCommit == commitSha { + return false, nil + } + + jobName := build.JobName(commitSha, deco.Spec.Site) + + existingJob := &batchv1.Job{} + err := r.Get(ctx, types.NamespacedName{Name: jobName, Namespace: deco.Namespace}, existingJob) + if errors.IsNotFound(err) { + if deco.Spec.Serving == nil { + return false, fmt.Errorf("spec.serving is required") + } + log.Info("Creating production build job", "job", jobName, "site", deco.Spec.Site) + if err := r.createJob(ctx, deco, jobName, deco.Spec.Build.Source); err != nil { + return false, err + } + now := metav1.Now() + if patch.Status.Build == nil { + patch.Status.Build = &decositesv1alpha1.DecoStatusBuild{} + } + patch.Status.Build.Phase = "Running" + patch.Status.Build.CommitSha = commitSha + patch.Status.Build.JobName = jobName + patch.Status.Build.StartTime = &now + return true, nil + } + if err != nil { + return false, err + } + + phase := buildPhaseFromJob(existingJob) + currentPhase := "" + if deco.Status.Build != nil { + currentPhase = deco.Status.Build.Phase + } + if currentPhase == phase { + return false, nil + } + + if patch.Status.Build == nil { + patch.Status.Build = &decositesv1alpha1.DecoStatusBuild{} + } + patch.Status.Build.Phase = phase + if phase == "Succeeded" || phase == "Failed" { + now := metav1.Now() + patch.Status.Build.CompletionTime = &now + } + if phase == "Succeeded" { + patch.Status.Build.LastBuiltCommit = commitSha + } + return true, nil +} + +func (r *DecoReconciler) reconcilePreviewBuilds(ctx context.Context, log logr.Logger, deco, patch *decositesv1alpha1.Deco) (bool, error) { + policy := deco.Spec.Previews + active := policy.Active + + if policy.MaxActive > 0 && int32(len(active)) > policy.MaxActive { + active = active[len(active)-int(policy.MaxActive):] + } + + existingByCommit := map[string]decositesv1alpha1.DecoPreviewStatus{} + for _, s := range deco.Status.Previews { + existingByCommit[s.CommitSha] = s + } + + activeCommits := map[string]bool{} + for _, p := range active { + activeCommits[p.CommitSha] = true + } + + var newStatuses []decositesv1alpha1.DecoPreviewStatus + changed := false + + for _, preview := range active { + jobName := build.JobName(preview.CommitSha, deco.Spec.Site) + + if s, ok := existingByCommit[preview.CommitSha]; ok && s.Phase == "Succeeded" { + newStatuses = append(newStatuses, s) + continue + } + + existingJob := &batchv1.Job{} + err := r.Get(ctx, types.NamespacedName{Name: jobName, Namespace: deco.Namespace}, existingJob) + if errors.IsNotFound(err) { + if deco.Spec.Serving == nil { + return false, fmt.Errorf("spec.serving is required") + } + log.Info("Creating preview build job", "job", jobName, "site", deco.Spec.Site, "branchRef", preview.BranchRef) + source := decositesv1alpha1.DecoSpecBuildSource{ + CommitSha: preview.CommitSha, + Production: false, + BranchRef: preview.BranchRef, + } + if err := r.createJob(ctx, deco, jobName, source); err != nil { + return false, err + } + now := metav1.Now() + newStatuses = append(newStatuses, decositesv1alpha1.DecoPreviewStatus{ + CommitSha: preview.CommitSha, + BranchRef: preview.BranchRef, + PrId: preview.PrId, + JobName: jobName, + Phase: "Running", + StartTime: &now, + }) + changed = true + continue + } + if err != nil { + return false, err + } + + phase := buildPhaseFromJob(existingJob) + s := decositesv1alpha1.DecoPreviewStatus{ + CommitSha: preview.CommitSha, + BranchRef: preview.BranchRef, + PrId: preview.PrId, + JobName: jobName, + Phase: phase, + } + if existing, ok := existingByCommit[preview.CommitSha]; ok { + s.StartTime = existing.StartTime + if (phase == "Succeeded" || phase == "Failed") && existing.CompletionTime == nil { + now := metav1.Now() + s.CompletionTime = &now + } else { + s.CompletionTime = existing.CompletionTime + } + if existing.Phase != phase { + changed = true + } + } else { + changed = true + } + newStatuses = append(newStatuses, s) + } + + // Detect removed previews + for _, s := range deco.Status.Previews { + if !activeCommits[s.CommitSha] { + changed = true + break + } + } + + if changed { + patch.Status.Previews = newStatuses + } + return changed, nil +} + +// createJob creates a K8s Job for either a production or preview build. +func (r *DecoReconciler) createJob(ctx context.Context, deco *decositesv1alpha1.Deco, jobName string, source decositesv1alpha1.DecoSpecBuildSource) error { + if deco.Spec.Serving.Type != "cloudflare-worker" { + return fmt.Errorf("unknown serving type %q", deco.Spec.Serving.Type) + } + + presignedURLs, err := build.GeneratePresignedURLs(ctx, r.S3Config, deco.Spec.Site, jobName) + if err != nil { + return fmt.Errorf("generating presigned URLs: %w", err) + } + + job := build.NewJob(build.JobOpts{ + Deco: deco, + JobName: jobName, + GithubToken: r.GithubToken, + CfApiToken: r.CfApiToken, + CfAccountId: r.CfAccountId, + PresignedURLs: presignedURLs, + SourceOverride: &source, + }) + + if err := controllerutil.SetControllerReference(deco, job, r.Scheme); err != nil { + return fmt.Errorf("setting owner reference: %w", err) + } + + if err := r.Create(ctx, job); err != nil && !errors.IsAlreadyExists(err) { + return fmt.Errorf("creating build job: %w", err) + } + return nil +} + +func buildPhaseFromJob(job *batchv1.Job) string { + for _, c := range job.Status.Conditions { + if c.Status != corev1.ConditionTrue { + continue + } + switch c.Type { + case batchv1.JobComplete, "SuccessCriteriaMet": + return "Succeeded" + case batchv1.JobFailed: + return "Failed" + } + } + return "Running" +} + +func (r *DecoReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&decositesv1alpha1.Deco{}). + Owns(&batchv1.Job{}). + WithOptions(controller.Options{MaxConcurrentReconciles: 4}). + Named("deco-build"). + Complete(r) +} From 978c5410350b54325b98af927c4660a75ba2e000 Mon Sep 17 00:00:00 2001 From: igoramf Date: Mon, 4 May 2026 08:13:09 -0300 Subject: [PATCH 06/42] fix: also set WORKER_NAME env var for backwards compat with existing builder images Co-Authored-By: Claude Sonnet 4.6 --- internal/build/job.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/build/job.go b/internal/build/job.go index aa5aa24..7e0f0fc 100644 --- a/internal/build/job.go +++ b/internal/build/job.go @@ -72,6 +72,7 @@ func NewJob(opts JobOpts) *batchv1.Job { {Name: "GIT_REPO", Value: fmt.Sprintf("https://github.com/%s/%s", owner, repo)}, {Name: "COMMIT_SHA", Value: src.CommitSha}, {Name: "DECO_SITE_NAME", Value: repo}, + {Name: "WORKER_NAME", Value: repo}, {Name: "BUILD_NAME", Value: opts.JobName}, {Name: "IS_PRODUCTION", Value: isProduction}, {Name: "CF_ACCOUNT_ID", Value: opts.CfAccountId}, From fdf6a672057ca949278d938712d56dfb3f8cb2eb Mon Sep 17 00:00:00 2001 From: igoramf Date: Mon, 4 May 2026 08:14:04 -0300 Subject: [PATCH 07/42] revert: remove WORKER_NAME backwards compat, use DECO_SITE_NAME only Co-Authored-By: Claude Sonnet 4.6 --- internal/build/job.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/build/job.go b/internal/build/job.go index 7e0f0fc..aa5aa24 100644 --- a/internal/build/job.go +++ b/internal/build/job.go @@ -72,7 +72,6 @@ func NewJob(opts JobOpts) *batchv1.Job { {Name: "GIT_REPO", Value: fmt.Sprintf("https://github.com/%s/%s", owner, repo)}, {Name: "COMMIT_SHA", Value: src.CommitSha}, {Name: "DECO_SITE_NAME", Value: repo}, - {Name: "WORKER_NAME", Value: repo}, {Name: "BUILD_NAME", Value: opts.JobName}, {Name: "IS_PRODUCTION", Value: isProduction}, {Name: "CF_ACCOUNT_ID", Value: opts.CfAccountId}, From 78ce233f34142bf8db91ed538c4e8a2ce82ef513 Mon Sep 17 00:00:00 2001 From: igoramf Date: Mon, 4 May 2026 09:29:07 -0300 Subject: [PATCH 08/42] =?UTF-8?q?chore:=20remove=20DecoBuild=20CRD=20?= =?UTF-8?q?=E2=80=94=20using=20Deco=20CR=20directly=20for=20cfworkers=20bu?= =?UTF-8?q?ilds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- api/v1alpha1/decobuild_types.go | 1 - chart/values.yaml | 1 - config/crd/bases/deco.sites_decobuilds.yaml | 177 -------------------- 3 files changed, 179 deletions(-) delete mode 100644 api/v1alpha1/decobuild_types.go delete mode 100644 config/crd/bases/deco.sites_decobuilds.yaml diff --git a/api/v1alpha1/decobuild_types.go b/api/v1alpha1/decobuild_types.go deleted file mode 100644 index 1c26788..0000000 --- a/api/v1alpha1/decobuild_types.go +++ /dev/null @@ -1 +0,0 @@ -package v1alpha1 diff --git a/chart/values.yaml b/chart/values.yaml index 022b9f7..5e7d307 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -96,7 +96,6 @@ podAnnotations: {} podLabels: {} # Cloudflare Workers build support -# When existingSecret is set, the operator can create DecoBuild jobs for cfworkers sites. cfworkers: existingSecret: "" # Secret with cf-api-token, cf-account-id, s3-access-key-id, s3-secret-access-key s3Region: "sa-east-1" diff --git a/config/crd/bases/deco.sites_decobuilds.yaml b/config/crd/bases/deco.sites_decobuilds.yaml deleted file mode 100644 index ebba1aa..0000000 --- a/config/crd/bases/deco.sites_decobuilds.yaml +++ /dev/null @@ -1,177 +0,0 @@ ---- -apiVersion: apiextensions.k8s.io/v1 -kind: CustomResourceDefinition -metadata: - annotations: - controller-gen.kubebuilder.io/version: v0.18.0 - name: decobuilds.deco.sites -spec: - group: deco.sites - names: - kind: DecoBuild - listKind: DecoBuildList - plural: decobuilds - singular: decobuild - scope: Namespaced - versions: - - additionalPrinterColumns: - - jsonPath: .spec.source.repo - name: Repo - type: string - - jsonPath: .spec.target.type - name: Target - type: string - - jsonPath: .spec.source.commitSha - name: Commit - type: string - - jsonPath: .status.phase - name: Phase - type: string - - jsonPath: .metadata.creationTimestamp - name: Age - type: date - name: v1alpha1 - schema: - openAPIV3Schema: - description: DecoBuild is the Schema for the decobuilds API. - properties: - apiVersion: - type: string - kind: - type: string - metadata: - type: object - spec: - description: DecoBuildSpec defines the desired state of a build. - properties: - build: - description: Build describes the build mechanism. Defaults to k8s-job when omitted. - properties: - type: - description: Type is the build mechanism. Currently only k8s-job is supported. - type: string - type: object - source: - description: Source describes the code to build. - properties: - owner: - description: Owner is the GitHub repository owner/org. - type: string - repo: - description: Repo is the GitHub repository name. - type: string - commitSha: - description: CommitSha is the git commit SHA to build. - type: string - production: - description: Production indicates whether this is a production deploy. - type: boolean - branchRef: - description: BranchRef is the branch name used as preview alias (non-production only). - type: string - required: - - owner - - repo - - commitSha - type: object - builder: - description: Builder overrides the builder image. - properties: - image: - description: Image is the full image reference (repository:tag). - type: string - required: - - image - type: object - artifact: - description: Artifact describes where the build output is stored after a successful build. - properties: - bucket: - description: Bucket is the S3 bucket name. - type: string - key: - description: Key is the S3 object key. Supports {repo} and {commitSha} placeholders. - type: string - required: - - bucket - - key - type: object - target: - description: Target describes the deploy target platform. - properties: - type: - description: 'Type is the target runtime platform. Supported: cloudflare-worker' - type: string - cloudflare-worker: - description: CloudflareWorker contains settings specific to Cloudflare Workers deployments. - properties: - entryPoint: - description: EntryPoint is the worker entry file path. Defaults to src/worker-entry.ts. - type: string - compatDate: - description: CompatDate is the Cloudflare compatibility date. Defaults to 2025-04-01. - type: string - type: object - required: - - type - type: object - required: - - source - - target - type: object - status: - description: DecoBuildStatus defines the observed state of a DecoBuild. - properties: - completionTime: - format: date-time - type: string - conditions: - items: - description: Condition contains details for one aspect of the current state of this API Resource. - properties: - lastTransitionTime: - format: date-time - type: string - message: - maxLength: 32768 - type: string - observedGeneration: - format: int64 - minimum: 0 - type: integer - reason: - maxLength: 1024 - minLength: 1 - pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ - type: string - status: - enum: - - "True" - - "False" - - Unknown - type: string - type: - maxLength: 316 - pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ - type: string - required: - - lastTransitionTime - - message - - reason - - status - - type - type: object - type: array - jobName: - type: string - phase: - type: string - startTime: - format: date-time - type: string - type: object - type: object - served: true - storage: true - subresources: - status: {} From 62f71a346d12a7d42d3783f874978fe3e20d5d0e Mon Sep 17 00:00:00 2001 From: igoramf Date: Mon, 4 May 2026 09:41:46 -0300 Subject: [PATCH 09/42] refactor(operator): decouple reconciler from cfworkers via JobFactory The DecoReconciler no longer holds cfworkers-specific credentials (CfApiToken, CfAccountId, S3Config). A JobFactory function type is injected at startup, keeping the reconciler platform-agnostic. Future serving types just need a new factory wired in main.go. Co-Authored-By: Claude Sonnet 4.6 --- cmd/main.go | 35 ++++++++++++++++++-------- internal/build/job.go | 2 +- internal/controller/deco_controller.go | 30 ++++++---------------- 3 files changed, 34 insertions(+), 33 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index ece4b1c..2c91696 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -30,6 +30,7 @@ import ( // to ensure that exec-entrypoint and run can make use of them. _ "k8s.io/client-go/plugin/pkg/client/auth" + batchv1 "k8s.io/api/batch/v1" "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" @@ -344,17 +345,31 @@ func main() { os.Exit(1) } } + githubToken := os.Getenv("GITHUB_TOKEN") + s3Cfg := build.S3Config{ + Region: s3Region, + AccessKeyID: s3AccessKeyID, + SecretAccessKey: s3SecretAccessKey, + } + cfWorkersFactory := controller.JobFactory(func(ctx context.Context, deco *decositesv1alpha1.Deco, jobName string, source decositesv1alpha1.DecoSpecBuildSource) (*batchv1.Job, error) { + presignedURLs, err := build.GeneratePresignedURLs(ctx, s3Cfg, deco.Spec.Site, jobName) + if err != nil { + return nil, fmt.Errorf("generating presigned URLs: %w", err) + } + return build.NewJob(build.JobOpts{ + Deco: deco, + JobName: jobName, + GithubToken: githubToken, + CfApiToken: cfApiToken, + CfAccountId: cfAccountId, + PresignedURLs: presignedURLs, + SourceOverride: &source, + }), nil + }) if err := (&controller.DecoReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - GithubToken: os.Getenv("GITHUB_TOKEN"), - CfApiToken: cfApiToken, - CfAccountId: cfAccountId, - S3Config: build.S3Config{ - Region: s3Region, - AccessKeyID: s3AccessKeyID, - SecretAccessKey: s3SecretAccessKey, - }, + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + JobFactory: cfWorkersFactory, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "Deco") os.Exit(1) diff --git a/internal/build/job.go b/internal/build/job.go index aa5aa24..fff1618 100644 --- a/internal/build/job.go +++ b/internal/build/job.go @@ -1,4 +1,4 @@ -// Package build contains helpers for creating Cloudflare Workers build Jobs. +// Package build contains helpers for creating cfworkers build Jobs. package build import ( diff --git a/internal/controller/deco_controller.go b/internal/controller/deco_controller.go index 3551fc0..7bab533 100644 --- a/internal/controller/deco_controller.go +++ b/internal/controller/deco_controller.go @@ -18,17 +18,17 @@ import ( logf "sigs.k8s.io/controller-runtime/pkg/log" decositesv1alpha1 "github.com/deco-sites/decofile-operator/api/v1alpha1" - "github.com/deco-sites/decofile-operator/internal/build" ) +// JobFactory builds a *batchv1.Job for a given Deco and build source. +// The controller sets the owner reference and creates the job in Kubernetes. +type JobFactory func(ctx context.Context, deco *decositesv1alpha1.Deco, jobName string, source decositesv1alpha1.DecoSpecBuildSource) (*batchv1.Job, error) + // DecoReconciler reconciles Deco objects. type DecoReconciler struct { client.Client - Scheme *runtime.Scheme - GithubToken string - CfApiToken string - CfAccountId string - S3Config build.S3Config + Scheme *runtime.Scheme + JobFactory JobFactory } // +kubebuilder:rbac:groups=deco.sites,resources=decos,verbs=get;list;watch;create;update;patch;delete @@ -232,25 +232,11 @@ func (r *DecoReconciler) reconcilePreviewBuilds(ctx context.Context, log logr.Lo // createJob creates a K8s Job for either a production or preview build. func (r *DecoReconciler) createJob(ctx context.Context, deco *decositesv1alpha1.Deco, jobName string, source decositesv1alpha1.DecoSpecBuildSource) error { - if deco.Spec.Serving.Type != "cloudflare-worker" { - return fmt.Errorf("unknown serving type %q", deco.Spec.Serving.Type) - } - - presignedURLs, err := build.GeneratePresignedURLs(ctx, r.S3Config, deco.Spec.Site, jobName) + job, err := r.JobFactory(ctx, deco, jobName, source) if err != nil { - return fmt.Errorf("generating presigned URLs: %w", err) + return fmt.Errorf("building job spec: %w", err) } - job := build.NewJob(build.JobOpts{ - Deco: deco, - JobName: jobName, - GithubToken: r.GithubToken, - CfApiToken: r.CfApiToken, - CfAccountId: r.CfAccountId, - PresignedURLs: presignedURLs, - SourceOverride: &source, - }) - if err := controllerutil.SetControllerReference(deco, job, r.Scheme); err != nil { return fmt.Errorf("setting owner reference: %w", err) } From f071d3eff312fcd23b976a57c05ee79caa5bb677 Mon Sep 17 00:00:00 2001 From: igoramf Date: Mon, 4 May 2026 09:49:30 -0300 Subject: [PATCH 10/42] refactor(operator): move platform-specific constants into JobFactory BuilderImage, TTLSeconds, LogsBucket and CacheBucket are now fields on JobOpts/S3Config instead of hardcoded constants. The cfworkers factory in main.go owns these values. spec.build.builder in the CR still takes precedence over the platform default for BuilderImage. Co-Authored-By: Claude Sonnet 4.6 --- cmd/main.go | 4 ++++ internal/build/job.go | 16 +++++++--------- internal/build/s3presign.go | 10 ++++++---- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index 2c91696..be1998b 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -350,6 +350,8 @@ func main() { Region: s3Region, AccessKeyID: s3AccessKeyID, SecretAccessKey: s3SecretAccessKey, + LogsBucket: "deco-sites-build-logs", + CacheBucket: "deco-cfworkers-deployments", } cfWorkersFactory := controller.JobFactory(func(ctx context.Context, deco *decositesv1alpha1.Deco, jobName string, source decositesv1alpha1.DecoSpecBuildSource) (*batchv1.Job, error) { presignedURLs, err := build.GeneratePresignedURLs(ctx, s3Cfg, deco.Spec.Site, jobName) @@ -364,6 +366,8 @@ func main() { CfAccountId: cfAccountId, PresignedURLs: presignedURLs, SourceOverride: &source, + BuilderImage: "ghcr.io/decocms/infra_applications/cfworkers-builder:latest", + TTLSeconds: 24 * 60 * 60, }), nil }) if err := (&controller.DecoReconciler{ diff --git a/internal/build/job.go b/internal/build/job.go index fff1618..c335604 100644 --- a/internal/build/job.go +++ b/internal/build/job.go @@ -13,13 +13,6 @@ import ( decositesv1alpha1 "github.com/deco-sites/decofile-operator/api/v1alpha1" ) -const ( - BuilderImage = "ghcr.io/decocms/infra_applications/cfworkers-builder:latest" - LogsBucket = "deco-sites-build-logs" - CacheBucket = "deco-cfworkers-deployments" - ttlSecondsAfterFinished = int32(24 * 60 * 60) // 24h -) - // JobName returns a deterministic job name: sha256("build-{commitSha}-{site}"), first 4 bytes as hex. func JobName(commitSha, site string) string { h := sha256.Sum256([]byte("build-" + commitSha + "-" + site)) @@ -43,6 +36,10 @@ type JobOpts struct { PresignedURLs PresignedURLs // SourceOverride replaces spec.build.source when set (used for preview builds). SourceOverride *decositesv1alpha1.DecoSpecBuildSource + // BuilderImage is the platform default. spec.build.builder in the CR takes precedence when set. + BuilderImage string + // TTLSeconds controls how long the Job is kept after completion. + TTLSeconds int32 } // NewJob builds the batchv1.Job spec for a cfworkers build. @@ -63,7 +60,8 @@ func NewJob(opts JobOpts) *batchv1.Job { isProduction = "true" } - builderImage := BuilderImage + // CR takes precedence over the platform default. + builderImage := opts.BuilderImage if spec.Build != nil && spec.Build.Builder != "" { builderImage = spec.Build.Builder } @@ -88,7 +86,7 @@ func NewJob(opts JobOpts) *batchv1.Job { } backoffLimit := int32(0) - ttl := ttlSecondsAfterFinished + ttl := opts.TTLSeconds return &batchv1.Job{ ObjectMeta: metav1.ObjectMeta{ diff --git a/internal/build/s3presign.go b/internal/build/s3presign.go index 122c92d..d432fd6 100644 --- a/internal/build/s3presign.go +++ b/internal/build/s3presign.go @@ -13,11 +13,13 @@ import ( const presignExpiry = time.Hour -// S3Config holds the AWS credentials used to generate presigned URLs. +// S3Config holds the AWS credentials and bucket names used to generate presigned URLs. type S3Config struct { Region string AccessKeyID string SecretAccessKey string + LogsBucket string // bucket for build logs + CacheBucket string // bucket for npm cache } // GeneratePresignedURLs generates all presigned URLs the build job needs. @@ -36,7 +38,7 @@ func GeneratePresignedURLs(ctx context.Context, cfg S3Config, site, jobName stri presigner := s3.NewPresignClient(s3.NewFromConfig(awsCfg)) logsUpload, err := presigner.PresignPutObject(ctx, &s3.PutObjectInput{ - Bucket: aws.String(LogsBucket), + Bucket: aws.String(cfg.LogsBucket), Key: aws.String(fmt.Sprintf("%s/%s.log", site, jobName)), }, s3.WithPresignExpires(presignExpiry)) if err != nil { @@ -46,7 +48,7 @@ func GeneratePresignedURLs(ctx context.Context, cfg S3Config, site, jobName stri cacheKey := fmt.Sprintf("%s/npm-cache.tar.zst", site) cacheDownload, err := presigner.PresignGetObject(ctx, &s3.GetObjectInput{ - Bucket: aws.String(CacheBucket), + Bucket: aws.String(cfg.CacheBucket), Key: aws.String(cacheKey), }, s3.WithPresignExpires(presignExpiry)) if err != nil { @@ -54,7 +56,7 @@ func GeneratePresignedURLs(ctx context.Context, cfg S3Config, site, jobName stri } cacheUpload, err := presigner.PresignPutObject(ctx, &s3.PutObjectInput{ - Bucket: aws.String(CacheBucket), + Bucket: aws.String(cfg.CacheBucket), Key: aws.String(cacheKey), }, s3.WithPresignExpires(presignExpiry)) if err != nil { From 36d2818599d136c62ef1a35f2983779da64d99e7 Mon Sep 17 00:00:00 2001 From: igoramf Date: Mon, 4 May 2026 09:52:29 -0300 Subject: [PATCH 11/42] refactor(operator): support multiple serving types via factory registry Replace single JobFactory field with Factories map[string]JobFactory keyed by spec.serving.type. createJob looks up the factory by type and returns an error for unknown types. A new platform just registers its factory in main.go. Co-Authored-By: Claude Sonnet 4.6 --- cmd/main.go | 8 +++++--- internal/controller/deco_controller.go | 12 +++++++++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index be1998b..a1e0c03 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -371,9 +371,11 @@ func main() { }), nil }) if err := (&controller.DecoReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - JobFactory: cfWorkersFactory, + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Factories: map[string]controller.JobFactory{ + "cloudflare-worker": cfWorkersFactory, + }, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "Deco") os.Exit(1) diff --git a/internal/controller/deco_controller.go b/internal/controller/deco_controller.go index 7bab533..14c9ef6 100644 --- a/internal/controller/deco_controller.go +++ b/internal/controller/deco_controller.go @@ -27,8 +27,8 @@ type JobFactory func(ctx context.Context, deco *decositesv1alpha1.Deco, jobName // DecoReconciler reconciles Deco objects. type DecoReconciler struct { client.Client - Scheme *runtime.Scheme - JobFactory JobFactory + Scheme *runtime.Scheme + Factories map[string]JobFactory // keyed by spec.serving.type } // +kubebuilder:rbac:groups=deco.sites,resources=decos,verbs=get;list;watch;create;update;patch;delete @@ -232,7 +232,13 @@ func (r *DecoReconciler) reconcilePreviewBuilds(ctx context.Context, log logr.Lo // createJob creates a K8s Job for either a production or preview build. func (r *DecoReconciler) createJob(ctx context.Context, deco *decositesv1alpha1.Deco, jobName string, source decositesv1alpha1.DecoSpecBuildSource) error { - job, err := r.JobFactory(ctx, deco, jobName, source) + servingType := deco.Spec.Serving.Type + factory, ok := r.Factories[servingType] + if !ok { + return fmt.Errorf("no job factory registered for serving type %q", servingType) + } + + job, err := factory(ctx, deco, jobName, source) if err != nil { return fmt.Errorf("building job spec: %w", err) } From 4c1c38aff49cc42ee4b859136566458d73362def Mon Sep 17 00:00:00 2001 From: igoramf Date: Mon, 4 May 2026 09:56:15 -0300 Subject: [PATCH 12/42] =?UTF-8?q?rename:=20JobOpts=20=E2=86=92=20CfWorkers?= =?UTF-8?q?JobOpts,=20NewJob=20=E2=86=92=20NewCfWorkersJob?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes the cfworkers-specific types explicit in the build package so future platforms can add their own types alongside without ambiguity. Co-Authored-By: Claude Sonnet 4.6 --- cmd/main.go | 2 +- internal/build/job.go | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index a1e0c03..f879f1b 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -358,7 +358,7 @@ func main() { if err != nil { return nil, fmt.Errorf("generating presigned URLs: %w", err) } - return build.NewJob(build.JobOpts{ + return build.NewCfWorkersJob(build.CfWorkersJobOpts{ Deco: deco, JobName: jobName, GithubToken: githubToken, diff --git a/internal/build/job.go b/internal/build/job.go index c335604..a774e49 100644 --- a/internal/build/job.go +++ b/internal/build/job.go @@ -26,8 +26,8 @@ type PresignedURLs struct { CacheUpload string } -// JobOpts are the inputs for NewJob. -type JobOpts struct { +// CfWorkersJobOpts are the inputs for NewJob. +type CfWorkersJobOpts struct { Deco *decositesv1alpha1.Deco JobName string GithubToken string @@ -42,8 +42,8 @@ type JobOpts struct { TTLSeconds int32 } -// NewJob builds the batchv1.Job spec for a cfworkers build. -func NewJob(opts JobOpts) *batchv1.Job { +// NewCfWorkersJob builds the batchv1.Job spec for a cfworkers build. +func NewCfWorkersJob(opts CfWorkersJobOpts) *batchv1.Job { spec := opts.Deco.Spec var src decositesv1alpha1.DecoSpecBuildSource if opts.SourceOverride != nil { From d2eaaef4ee02514a282ba5a6e632f87fcf674bfc Mon Sep 17 00:00:00 2001 From: igoramf Date: Mon, 4 May 2026 10:02:54 -0300 Subject: [PATCH 13/42] refactor(operator): introduce build.Registry for platform dispatch Move serving-type dispatch out of the controller into build.Registry. The controller now holds a *build.Registry and calls registry.NewJob() without knowing about platforms. New platforms register via registry.Register() in main.go. Co-Authored-By: Claude Sonnet 4.6 --- cmd/main.go | 13 +++++----- internal/build/registry.go | 34 ++++++++++++++++++++++++++ internal/controller/deco_controller.go | 17 +++---------- 3 files changed, 45 insertions(+), 19 deletions(-) create mode 100644 internal/build/registry.go diff --git a/cmd/main.go b/cmd/main.go index f879f1b..64fccce 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -353,7 +353,7 @@ func main() { LogsBucket: "deco-sites-build-logs", CacheBucket: "deco-cfworkers-deployments", } - cfWorkersFactory := controller.JobFactory(func(ctx context.Context, deco *decositesv1alpha1.Deco, jobName string, source decositesv1alpha1.DecoSpecBuildSource) (*batchv1.Job, error) { + cfWorkersFactory := build.Factory(func(ctx context.Context, deco *decositesv1alpha1.Deco, jobName string, source decositesv1alpha1.DecoSpecBuildSource) (*batchv1.Job, error) { presignedURLs, err := build.GeneratePresignedURLs(ctx, s3Cfg, deco.Spec.Site, jobName) if err != nil { return nil, fmt.Errorf("generating presigned URLs: %w", err) @@ -370,12 +370,13 @@ func main() { TTLSeconds: 24 * 60 * 60, }), nil }) + registry := build.NewRegistry() + registry.Register("cloudflare-worker", cfWorkersFactory) + if err := (&controller.DecoReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - Factories: map[string]controller.JobFactory{ - "cloudflare-worker": cfWorkersFactory, - }, + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Registry: registry, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "Deco") os.Exit(1) diff --git a/internal/build/registry.go b/internal/build/registry.go new file mode 100644 index 0000000..2c725ca --- /dev/null +++ b/internal/build/registry.go @@ -0,0 +1,34 @@ +package build + +import ( + "context" + "fmt" + + batchv1 "k8s.io/api/batch/v1" + + decositesv1alpha1 "github.com/deco-sites/decofile-operator/api/v1alpha1" +) + +// Factory builds a *batchv1.Job for a given Deco and build source. +type Factory func(ctx context.Context, deco *decositesv1alpha1.Deco, jobName string, source decositesv1alpha1.DecoSpecBuildSource) (*batchv1.Job, error) + +// Registry maps serving types to their job factories. +type Registry struct { + platforms map[string]Factory +} + +func NewRegistry() *Registry { + return &Registry{platforms: map[string]Factory{}} +} + +func (r *Registry) Register(servingType string, f Factory) { + r.platforms[servingType] = f +} + +func (r *Registry) NewJob(ctx context.Context, deco *decositesv1alpha1.Deco, jobName string, source decositesv1alpha1.DecoSpecBuildSource) (*batchv1.Job, error) { + f, ok := r.platforms[deco.Spec.Serving.Type] + if !ok { + return nil, fmt.Errorf("no factory registered for serving type %q", deco.Spec.Serving.Type) + } + return f(ctx, deco, jobName, source) +} diff --git a/internal/controller/deco_controller.go b/internal/controller/deco_controller.go index 14c9ef6..0c74add 100644 --- a/internal/controller/deco_controller.go +++ b/internal/controller/deco_controller.go @@ -18,17 +18,14 @@ import ( logf "sigs.k8s.io/controller-runtime/pkg/log" decositesv1alpha1 "github.com/deco-sites/decofile-operator/api/v1alpha1" + "github.com/deco-sites/decofile-operator/internal/build" ) -// JobFactory builds a *batchv1.Job for a given Deco and build source. -// The controller sets the owner reference and creates the job in Kubernetes. -type JobFactory func(ctx context.Context, deco *decositesv1alpha1.Deco, jobName string, source decositesv1alpha1.DecoSpecBuildSource) (*batchv1.Job, error) - // DecoReconciler reconciles Deco objects. type DecoReconciler struct { client.Client - Scheme *runtime.Scheme - Factories map[string]JobFactory // keyed by spec.serving.type + Scheme *runtime.Scheme + Registry *build.Registry } // +kubebuilder:rbac:groups=deco.sites,resources=decos,verbs=get;list;watch;create;update;patch;delete @@ -232,13 +229,7 @@ func (r *DecoReconciler) reconcilePreviewBuilds(ctx context.Context, log logr.Lo // createJob creates a K8s Job for either a production or preview build. func (r *DecoReconciler) createJob(ctx context.Context, deco *decositesv1alpha1.Deco, jobName string, source decositesv1alpha1.DecoSpecBuildSource) error { - servingType := deco.Spec.Serving.Type - factory, ok := r.Factories[servingType] - if !ok { - return fmt.Errorf("no job factory registered for serving type %q", servingType) - } - - job, err := factory(ctx, deco, jobName, source) + job, err := r.Registry.NewJob(ctx, deco, jobName, source) if err != nil { return fmt.Errorf("building job spec: %w", err) } From 32131fa0dd44d68f3a9dc11a75f939c03d30a5e5 Mon Sep 17 00:00:00 2001 From: igoramf Date: Mon, 4 May 2026 10:36:02 -0300 Subject: [PATCH 14/42] =?UTF-8?q?rename:=20build/job.go=20=E2=86=92=20buil?= =?UTF-8?q?d/cfworkers.go?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clarifies that this file is cfworkers-specific. Generic build helpers (registry, s3presign) stay at the package level; platform-specific implementations get their own file. Co-Authored-By: Claude Sonnet 4.6 --- internal/build/{job.go => cfworkers.go} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename internal/build/{job.go => cfworkers.go} (100%) diff --git a/internal/build/job.go b/internal/build/cfworkers.go similarity index 100% rename from internal/build/job.go rename to internal/build/cfworkers.go From e145449989f5f95f7201ae103e7741496a78fcd3 Mon Sep 17 00:00:00 2001 From: igoramf Date: Mon, 4 May 2026 12:49:05 -0300 Subject: [PATCH 15/42] chore: bump version to 0.2.7 Co-Authored-By: Claude Sonnet 4.6 --- chart/Chart.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/chart/Chart.yaml b/chart/Chart.yaml index b7fa246..2f160c7 100644 --- a/chart/Chart.yaml +++ b/chart/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: deco-operator description: Kubernetes operator for Deco CMS that manages Decofile resources and automatically injects configuration into Knative Services type: application -version: 0.3.0 -appVersion: "0.3.0" +version: 0.2.7 +appVersion: "0.2.7" keywords: - operator - kubernetes From 8a53c1f4fcbcbc3546a82da1bbf88bd199837959 Mon Sep 17 00:00:00 2001 From: igoramf Date: Mon, 4 May 2026 12:53:28 -0300 Subject: [PATCH 16/42] chore: revert version to 0.2.6 Co-Authored-By: Claude Sonnet 4.6 --- chart/Chart.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/chart/Chart.yaml b/chart/Chart.yaml index 2f160c7..1fec1d8 100644 --- a/chart/Chart.yaml +++ b/chart/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: deco-operator description: Kubernetes operator for Deco CMS that manages Decofile resources and automatically injects configuration into Knative Services type: application -version: 0.2.7 -appVersion: "0.2.7" +version: 0.2.6 +appVersion: "0.2.6" keywords: - operator - kubernetes From a6d5f775cf3f3860b8548ff249b87a15708cacc3 Mon Sep 17 00:00:00 2001 From: igoramf Date: Mon, 4 May 2026 12:59:11 -0300 Subject: [PATCH 17/42] chore: move s3Region from values to secret Co-Authored-By: Claude Sonnet 4.6 --- .../templates/deployment-operator-controller-manager.yaml | 7 +++++-- chart/values.yaml | 3 +-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/chart/templates/deployment-operator-controller-manager.yaml b/chart/templates/deployment-operator-controller-manager.yaml index 00b7841..6bcbd6e 100644 --- a/chart/templates/deployment-operator-controller-manager.yaml +++ b/chart/templates/deployment-operator-controller-manager.yaml @@ -83,9 +83,12 @@ spec: secretKeyRef: name: {{ .existingSecret | quote }} key: s3-secret-access-key - {{- end }} - name: S3_REGION - value: {{ .s3Region | default "sa-east-1" | quote }} + valueFrom: + secretKeyRef: + name: {{ .existingSecret | quote }} + key: s3-region + {{- end }} {{- end }} {{- end }} livenessProbe: diff --git a/chart/values.yaml b/chart/values.yaml index 5e7d307..d0e48c8 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -97,8 +97,7 @@ podLabels: {} # Cloudflare Workers build support cfworkers: - existingSecret: "" # Secret with cf-api-token, cf-account-id, s3-access-key-id, s3-secret-access-key - s3Region: "sa-east-1" + existingSecret: "" # Secret with cf-api-token, cf-account-id, s3-access-key-id, s3-secret-access-key, s3-region # Name overrides nameOverride: "" From 9b658c08e3480f7ef3972c513a8022d863d95ab5 Mon Sep 17 00:00:00 2001 From: igoramf Date: Mon, 4 May 2026 14:09:29 -0300 Subject: [PATCH 18/42] feat: add cfworkers build duration and count metrics Co-Authored-By: Claude Sonnet 4.6 --- internal/controller/deco_controller.go | 8 ++++++++ internal/controller/metrics.go | 25 +++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/internal/controller/deco_controller.go b/internal/controller/deco_controller.go index 0c74add..ad4e6d1 100644 --- a/internal/controller/deco_controller.go +++ b/internal/controller/deco_controller.go @@ -121,6 +121,10 @@ func (r *DecoReconciler) reconcileProductionBuild(ctx context.Context, log logr. if phase == "Succeeded" || phase == "Failed" { now := metav1.Now() patch.Status.Build.CompletionTime = &now + if deco.Status.Build != nil && deco.Status.Build.StartTime != nil { + duration := now.Sub(deco.Status.Build.StartTime.Time).Seconds() + RecordBuild(deco.Spec.Site, phase, "production", duration) + } } if phase == "Succeeded" { patch.Status.Build.LastBuiltCommit = commitSha @@ -201,6 +205,10 @@ func (r *DecoReconciler) reconcilePreviewBuilds(ctx context.Context, log logr.Lo if (phase == "Succeeded" || phase == "Failed") && existing.CompletionTime == nil { now := metav1.Now() s.CompletionTime = &now + if existing.StartTime != nil { + duration := now.Sub(existing.StartTime.Time).Seconds() + RecordBuild(deco.Spec.Site, phase, "preview", duration) + } } else { s.CompletionTime = existing.CompletionTime } diff --git a/internal/controller/metrics.go b/internal/controller/metrics.go index d0fdc95..bf045ed 100644 --- a/internal/controller/metrics.go +++ b/internal/controller/metrics.go @@ -22,6 +22,23 @@ import ( ) var ( + // cfworkersBuildDuration tracks how long each build took (seconds), labelled by site, status, and build type. + cfworkersBuildDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: "deco_operator", + Subsystem: "cfworkers", + Name: "build_duration_seconds", + Help: "Duration of cfworkers build jobs in seconds.", + Buckets: []float64{30, 60, 120, 180, 300, 600, 900}, + }, []string{"site", "status", "type"}) // type: production | preview + + // cfworkersBuildTotal counts completed builds by site, status, and type. + cfworkersBuildTotal = prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: "deco_operator", + Subsystem: "cfworkers", + Name: "builds_total", + Help: "Total number of cfworkers builds completed.", + }, []string{"site", "status", "type"}) // type: production | preview + // valkeyACLProvisioned counts successful ACL user + Secret provisioning operations. valkeyACLProvisioned = prometheus.NewCounter(prometheus.CounterOpts{ Namespace: "deco_operator", @@ -78,8 +95,16 @@ func RecordSentinelFailover() { valkeySentinelFailovers.Inc() } +// RecordBuild emits duration and count metrics when a build job completes. +func RecordBuild(site, status, buildType string, durationSeconds float64) { + cfworkersBuildDuration.WithLabelValues(site, status, buildType).Observe(durationSeconds) + cfworkersBuildTotal.WithLabelValues(site, status, buildType).Inc() +} + func init() { metrics.Registry.MustRegister( + cfworkersBuildDuration, + cfworkersBuildTotal, valkeyACLProvisioned, valkeyACLDeleted, valkeyACLErrors, From 1ca83c3fb3cd4294c8118c143316fb7b1172d0fb Mon Sep 17 00:00:00 2001 From: igoramf Date: Mon, 4 May 2026 15:17:54 -0300 Subject: [PATCH 19/42] fix: address CI lint failures and move S3_REGION to secretKeyRef - Fix goconst: extract Succeeded/Failed as constants - Fix prealloc: pre-allocate newStatuses slice - Fix lll: break long factory func signature in cmd/main.go - Fix helm-generator to emit S3_REGION as secretKeyRef (key: s3-region) - Regenerate CRD template (alphabetical field ordering from controller-gen) Co-Authored-By: Claude Sonnet 4.6 --- ...omresourcedefinition-decos.deco.sites.yaml | 139 +++++++++++++----- cmd/main.go | 7 +- hack/helm-generator/main.go | 7 +- internal/controller/deco_controller.go | 19 ++- 4 files changed, 129 insertions(+), 43 deletions(-) diff --git a/chart/templates/customresourcedefinition-decos.deco.sites.yaml b/chart/templates/customresourcedefinition-decos.deco.sites.yaml index 1ff51b8..996278a 100644 --- a/chart/templates/customresourcedefinition-decos.deco.sites.yaml +++ b/chart/templates/customresourcedefinition-decos.deco.sites.yaml @@ -1,4 +1,3 @@ ---- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: @@ -44,54 +43,99 @@ spec: spec: description: DecoSpec defines the desired state of a Deco workload. properties: - type: - description: Type is the workload type. site | server | admin | preview - type: string - site: - description: Site is the site/repository name. - type: string - org: - description: Org is the GitHub organization or owner. - type: string - framework: - description: Framework is the site framework. deno | tanstack | next | remix | static - type: string build: description: Build describes the build pipeline. properties: - type: - description: Type is the build mechanism. Currently only k8s-job is supported. - type: string builder: description: Builder overrides the builder image (repository:tag). type: string source: description: Source identifies the code revision to build. properties: + branchRef: + description: BranchRef is the branch name for preview aliases + (non-production only). + type: string commitSha: - description: CommitSha is the git commit SHA to build. Updating this field triggers a new build. + description: CommitSha is the git commit SHA to build. Updating + this field triggers a new build. type: string production: - description: Production indicates whether this is a production deploy. + description: Production indicates whether this is a production + deploy. type: boolean - branchRef: - description: BranchRef is the branch name for preview aliases (non-production only). - type: string required: - commitSha type: object + type: + description: Type is the build mechanism. Currently only k8s-job + is supported. + type: string required: - source type: object + framework: + description: Framework is the site framework. deno | tanstack | next + | remix | static + type: string + org: + description: Org is the GitHub organization or owner. + type: string + previews: + description: Previews configures preview builds for this site. + properties: + active: + description: List of preview builds currently requested. + items: + properties: + branchRef: + description: Branch or PR ref used as the wrangler preview + alias. + type: string + commitSha: + description: Git commit SHA to build. + type: string + prId: + description: Pull request ID for tracking. + type: string + required: + - commitSha + - branchRef + type: object + type: array + maxActive: + description: Maximum number of concurrent previews the operator + will build. + format: int32 + type: integer + ttl: + description: Duration after which completed previews are eligible + for cleanup (e.g. "48h"). + type: string + type: + description: Preview runtime. cloudflare-preview | statefulset + | sandbox + type: string + required: + - type + type: object serving: - description: Serving describes the runtime serving configuration. Also drives build job selection. + description: Serving describes the runtime serving configuration. + Also drives build job selection. properties: type: - description: 'Type is the serving runtime. Supported: cloudflare-worker | knative | deployment' + description: 'Type is the serving runtime. Supported: cloudflare-worker + | knative | deployment' type: string required: - type type: object + site: + description: Site is the site/repository name. + type: string + type: + description: Type is the workload type. site | server | admin | preview + type: string required: - site - org @@ -102,28 +146,57 @@ spec: build: description: Build tracks the current build lifecycle. properties: - phase: - description: 'Phase is the current build phase: Running | Succeeded | Failed' - type: string commitSha: - description: CommitSha is the commit currently being built (or last attempted). + description: CommitSha is the commit currently being built (or + last attempted). type: string - lastBuiltCommit: - description: LastBuiltCommit is the commit SHA of the last successful build. + completionTime: + format: date-time type: string jobName: description: JobName is the K8s Job name for the current build. type: string - startTime: - format: date-time + lastBuiltCommit: + description: LastBuiltCommit is the commit SHA of the last successful + build. type: string - completionTime: + phase: + description: 'Phase is the current build phase: Running | Succeeded + | Failed' + type: string + startTime: format: date-time type: string type: object + previews: + description: Previews tracks the build status of each active preview. + items: + properties: + branchRef: + type: string + commitSha: + type: string + completionTime: + format: date-time + type: string + jobName: + type: string + phase: + type: string + prId: + type: string + startTime: + format: date-time + type: string + required: + - commitSha + - branchRef + - phase + type: object + type: array type: object type: object served: true storage: true subresources: - status: {} + status: {} \ No newline at end of file diff --git a/cmd/main.go b/cmd/main.go index 64fccce..081f249 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -353,7 +353,12 @@ func main() { LogsBucket: "deco-sites-build-logs", CacheBucket: "deco-cfworkers-deployments", } - cfWorkersFactory := build.Factory(func(ctx context.Context, deco *decositesv1alpha1.Deco, jobName string, source decositesv1alpha1.DecoSpecBuildSource) (*batchv1.Job, error) { + cfWorkersFactory := build.Factory(func( + ctx context.Context, + deco *decositesv1alpha1.Deco, + jobName string, + source decositesv1alpha1.DecoSpecBuildSource, + ) (*batchv1.Job, error) { presignedURLs, err := build.GeneratePresignedURLs(ctx, s3Cfg, deco.Spec.Site, jobName) if err != nil { return nil, fmt.Errorf("generating presigned URLs: %w", err) diff --git a/hack/helm-generator/main.go b/hack/helm-generator/main.go index d09a181..c5f863d 100644 --- a/hack/helm-generator/main.go +++ b/hack/helm-generator/main.go @@ -240,9 +240,12 @@ func addEnvVarsToDeployment(templatesDir string) error { secretKeyRef: name: {{ .existingSecret | quote }} key: s3-secret-access-key - {{- end }} - name: S3_REGION - value: {{ .s3Region | default "sa-east-1" | quote }} + valueFrom: + secretKeyRef: + name: {{ .existingSecret | quote }} + key: s3-region + {{- end }} {{- end }} {{- end }}` diff --git a/internal/controller/deco_controller.go b/internal/controller/deco_controller.go index ad4e6d1..cbcdca6 100644 --- a/internal/controller/deco_controller.go +++ b/internal/controller/deco_controller.go @@ -21,6 +21,11 @@ import ( "github.com/deco-sites/decofile-operator/internal/build" ) +const ( + phaseSucceeded = "Succeeded" + phaseFailed = "Failed" +) + // DecoReconciler reconciles Deco objects. type DecoReconciler struct { client.Client @@ -118,7 +123,7 @@ func (r *DecoReconciler) reconcileProductionBuild(ctx context.Context, log logr. patch.Status.Build = &decositesv1alpha1.DecoStatusBuild{} } patch.Status.Build.Phase = phase - if phase == "Succeeded" || phase == "Failed" { + if phase == phaseSucceeded || phase == phaseFailed { now := metav1.Now() patch.Status.Build.CompletionTime = &now if deco.Status.Build != nil && deco.Status.Build.StartTime != nil { @@ -126,7 +131,7 @@ func (r *DecoReconciler) reconcileProductionBuild(ctx context.Context, log logr. RecordBuild(deco.Spec.Site, phase, "production", duration) } } - if phase == "Succeeded" { + if phase == phaseSucceeded { patch.Status.Build.LastBuiltCommit = commitSha } return true, nil @@ -150,13 +155,13 @@ func (r *DecoReconciler) reconcilePreviewBuilds(ctx context.Context, log logr.Lo activeCommits[p.CommitSha] = true } - var newStatuses []decositesv1alpha1.DecoPreviewStatus + newStatuses := make([]decositesv1alpha1.DecoPreviewStatus, 0, len(active)) changed := false for _, preview := range active { jobName := build.JobName(preview.CommitSha, deco.Spec.Site) - if s, ok := existingByCommit[preview.CommitSha]; ok && s.Phase == "Succeeded" { + if s, ok := existingByCommit[preview.CommitSha]; ok && s.Phase == phaseSucceeded { newStatuses = append(newStatuses, s) continue } @@ -202,7 +207,7 @@ func (r *DecoReconciler) reconcilePreviewBuilds(ctx context.Context, log logr.Lo } if existing, ok := existingByCommit[preview.CommitSha]; ok { s.StartTime = existing.StartTime - if (phase == "Succeeded" || phase == "Failed") && existing.CompletionTime == nil { + if (phase == phaseSucceeded || phase == phaseFailed) && existing.CompletionTime == nil { now := metav1.Now() s.CompletionTime = &now if existing.StartTime != nil { @@ -259,9 +264,9 @@ func buildPhaseFromJob(job *batchv1.Job) string { } switch c.Type { case batchv1.JobComplete, "SuccessCriteriaMet": - return "Succeeded" + return phaseSucceeded case batchv1.JobFailed: - return "Failed" + return phaseFailed } } return "Running" From a390197da91b9ca394f131ebf643a08fa4dfb030 Mon Sep 17 00:00:00 2001 From: igoramf Date: Mon, 4 May 2026 15:25:39 -0300 Subject: [PATCH 20/42] fix: pin CRD plural to 'decos' via +kubebuilder:resource:path=decos Without this annotation controller-gen auto-pluralises 'Deco' as 'decoes', creating a second deco.sites_decoes.yaml alongside the committed deco.sites_decos.yaml. envtest loads every yaml in config/crd/bases/ so both CRDs were registered, causing the BeforeSuite context deadline timeout. Also regenerates deco.sites_decos.yaml from current types (updated schema, alphabetical field ordering, adds previews/branchRef fields). Co-Authored-By: Claude Sonnet 4.6 --- api/v1alpha1/deco_types.go | 1 + config/crd/bases/deco.sites_decos.yaml | 171 +++++++++++++++---------- 2 files changed, 106 insertions(+), 66 deletions(-) diff --git a/api/v1alpha1/deco_types.go b/api/v1alpha1/deco_types.go index 123c6da..3603386 100644 --- a/api/v1alpha1/deco_types.go +++ b/api/v1alpha1/deco_types.go @@ -160,6 +160,7 @@ type DecoPreviewStatus struct { // +kubebuilder:object:root=true // +kubebuilder:subresource:status +// +kubebuilder:resource:path=decos // +kubebuilder:printcolumn:name="Site",type=string,JSONPath=`.spec.site` // +kubebuilder:printcolumn:name="Serving",type=string,JSONPath=`.spec.serving.type` // +kubebuilder:printcolumn:name="Commit",type=string,JSONPath=`.spec.build.source.commitSha` diff --git a/config/crd/bases/deco.sites_decos.yaml b/config/crd/bases/deco.sites_decos.yaml index 27d0775..b9dc120 100644 --- a/config/crd/bases/deco.sites_decos.yaml +++ b/config/crd/bases/deco.sites_decos.yaml @@ -36,148 +36,187 @@ spec: description: Deco is the Schema for the decos API. properties: apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources type: string kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds type: string metadata: type: object spec: description: DecoSpec defines the desired state of a Deco workload. properties: - type: - description: Type is the workload type. site | server | admin | preview - type: string - site: - description: Site is the site/repository name. - type: string - org: - description: Org is the GitHub organization or owner. - type: string - framework: - description: Framework is the site framework. deno | tanstack | next | remix | static - type: string build: - description: Build describes the build pipeline. + description: Build describes the production build pipeline. properties: - type: - description: Type is the build mechanism. Currently only k8s-job is supported. - type: string builder: description: Builder overrides the builder image (repository:tag). type: string source: - description: Source identifies the code revision to build. + description: |- + Source identifies the code revision to build. + Repository and owner come from spec.site and spec.org. properties: + branchRef: + description: BranchRef is the branch name for preview aliases + (non-production only). + type: string commitSha: - description: CommitSha is the git commit SHA to build. Updating this field triggers a new build. + description: |- + CommitSha is the git commit SHA to build. + Updating this field triggers a new build. type: string production: - description: Production indicates whether this is a production deploy. + description: Production indicates whether this is a production + deploy. type: boolean - branchRef: - description: BranchRef is the branch name for preview aliases (non-production only). - type: string required: - commitSha type: object - required: - - source - type: object - serving: - description: Serving describes the runtime serving configuration. Also drives build job selection. - properties: type: - description: 'Type is the serving runtime. Supported: cloudflare-worker | knative | deployment' + description: Type is the build mechanism. Currently only k8s-job + is supported. type: string required: - - type + - source type: object + framework: + description: Framework is the site framework. deno | tanstack | next + | remix | static + type: string + org: + description: Org is the GitHub organization or owner. + type: string previews: - description: Previews configures preview builds for this site. + description: |- + Previews configures preview builds for this site. + Admin adds entries to Previews.Active on PR open and removes on PR close. properties: - type: - description: Preview runtime. cloudflare-preview | statefulset | sandbox - type: string - maxActive: - description: Maximum number of concurrent previews the operator will build. - format: int32 - type: integer - ttl: - description: Duration after which completed previews are eligible for cleanup (e.g. "48h"). - type: string active: - description: List of preview builds currently requested. + description: |- + Active is the list of preview builds currently requested. + Admin adds entries on PR open, removes on PR close. items: + description: DecoPreviewRequest identifies a single preview + build request. properties: - commitSha: - description: Git commit SHA to build. - type: string branchRef: - description: Branch or PR ref used as the wrangler preview alias. + description: BranchRef is the branch or PR ref (used as + the wrangler preview alias). + type: string + commitSha: + description: CommitSha is the git commit SHA to build. type: string prId: - description: Pull request ID for tracking. + description: PrId is the pull request ID, for tracking. type: string required: - - commitSha - branchRef + - commitSha type: object type: array + maxActive: + description: |- + MaxActive is the maximum number of concurrent previews the operator will build. + Operator processes only the most recent MaxActive entries in Active. + format: int32 + type: integer + ttl: + description: TTL is the duration after which completed previews + are eligible for cleanup (e.g. "48h"). + type: string + type: + description: Type is the preview runtime. cloudflare-preview | + statefulset | sandbox + type: string + required: + - type + type: object + serving: + description: Serving describes the runtime serving configuration. + properties: + type: + description: |- + Type is the serving runtime. Drives both serving and build job selection. + Supported: cloudflare-worker | knative | deployment + type: string required: - type type: object + site: + description: Site is the site/repository name. + type: string + type: + description: Type is the workload type. site | server | admin | preview + type: string required: - - site - org + - site type: object status: description: DecoStatus defines the observed state of a Deco workload. properties: build: - description: Build tracks the current build lifecycle. + description: Build tracks the current production build lifecycle. properties: - phase: - description: 'Phase is the current build phase: Running | Succeeded | Failed' - type: string commitSha: - description: CommitSha is the commit currently being built (or last attempted). + description: CommitSha is the commit currently being built (or + last attempted). type: string - lastBuiltCommit: - description: LastBuiltCommit is the commit SHA of the last successful build. + completionTime: + description: CompletionTime is when the current build finished. + format: date-time type: string jobName: description: JobName is the K8s Job name for the current build. type: string - startTime: - format: date-time + lastBuiltCommit: + description: LastBuiltCommit is the commit SHA of the last successful + build. type: string - completionTime: + phase: + description: 'Phase is the current build phase: Running | Succeeded + | Failed' + type: string + startTime: + description: StartTime is when the current build started. format: date-time type: string type: object previews: description: Previews tracks the build status of each active preview. items: + description: DecoPreviewStatus tracks the build status of a single + preview. properties: - commitSha: - type: string branchRef: type: string - prId: + commitSha: + type: string + completionTime: + format: date-time type: string jobName: type: string phase: type: string - startTime: - format: date-time + prId: type: string - completionTime: + startTime: format: date-time type: string required: - - commitSha - branchRef + - commitSha - phase type: object type: array From 35288f82bc78fc5f16c213048ef606632b92355d Mon Sep 17 00:00:00 2001 From: igoramf Date: Mon, 4 May 2026 15:29:08 -0300 Subject: [PATCH 21/42] chore: regenerate helm chart CRD template from updated deco.sites_decos.yaml Co-Authored-By: Claude Sonnet 4.6 --- ...omresourcedefinition-decos.deco.sites.yaml | 71 +++++++++++++------ 1 file changed, 48 insertions(+), 23 deletions(-) diff --git a/chart/templates/customresourcedefinition-decos.deco.sites.yaml b/chart/templates/customresourcedefinition-decos.deco.sites.yaml index 996278a..8902a2f 100644 --- a/chart/templates/customresourcedefinition-decos.deco.sites.yaml +++ b/chart/templates/customresourcedefinition-decos.deco.sites.yaml @@ -35,8 +35,19 @@ spec: description: Deco is the Schema for the decos API. properties: apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources type: string kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds type: string metadata: type: object @@ -44,21 +55,24 @@ spec: description: DecoSpec defines the desired state of a Deco workload. properties: build: - description: Build describes the build pipeline. + description: Build describes the production build pipeline. properties: builder: description: Builder overrides the builder image (repository:tag). type: string source: - description: Source identifies the code revision to build. + description: |- + Source identifies the code revision to build. + Repository and owner come from spec.site and spec.org. properties: branchRef: description: BranchRef is the branch name for preview aliases (non-production only). type: string commitSha: - description: CommitSha is the git commit SHA to build. Updating - this field triggers a new build. + description: |- + CommitSha is the git commit SHA to build. + Updating this field triggers a new build. type: string production: description: Production indicates whether this is a production @@ -82,50 +96,57 @@ spec: description: Org is the GitHub organization or owner. type: string previews: - description: Previews configures preview builds for this site. + description: |- + Previews configures preview builds for this site. + Admin adds entries to Previews.Active on PR open and removes on PR close. properties: active: - description: List of preview builds currently requested. + description: |- + Active is the list of preview builds currently requested. + Admin adds entries on PR open, removes on PR close. items: + description: DecoPreviewRequest identifies a single preview + build request. properties: branchRef: - description: Branch or PR ref used as the wrangler preview - alias. + description: BranchRef is the branch or PR ref (used as + the wrangler preview alias). type: string commitSha: - description: Git commit SHA to build. + description: CommitSha is the git commit SHA to build. type: string prId: - description: Pull request ID for tracking. + description: PrId is the pull request ID, for tracking. type: string required: - - commitSha - branchRef + - commitSha type: object type: array maxActive: - description: Maximum number of concurrent previews the operator - will build. + description: |- + MaxActive is the maximum number of concurrent previews the operator will build. + Operator processes only the most recent MaxActive entries in Active. format: int32 type: integer ttl: - description: Duration after which completed previews are eligible - for cleanup (e.g. "48h"). + description: TTL is the duration after which completed previews + are eligible for cleanup (e.g. "48h"). type: string type: - description: Preview runtime. cloudflare-preview | statefulset - | sandbox + description: Type is the preview runtime. cloudflare-preview | + statefulset | sandbox type: string required: - type type: object serving: description: Serving describes the runtime serving configuration. - Also drives build job selection. properties: type: - description: 'Type is the serving runtime. Supported: cloudflare-worker - | knative | deployment' + description: |- + Type is the serving runtime. Drives both serving and build job selection. + Supported: cloudflare-worker | knative | deployment type: string required: - type @@ -137,20 +158,21 @@ spec: description: Type is the workload type. site | server | admin | preview type: string required: - - site - org + - site type: object status: description: DecoStatus defines the observed state of a Deco workload. properties: build: - description: Build tracks the current build lifecycle. + description: Build tracks the current production build lifecycle. properties: commitSha: description: CommitSha is the commit currently being built (or last attempted). type: string completionTime: + description: CompletionTime is when the current build finished. format: date-time type: string jobName: @@ -165,12 +187,15 @@ spec: | Failed' type: string startTime: + description: StartTime is when the current build started. format: date-time type: string type: object previews: description: Previews tracks the build status of each active preview. items: + description: DecoPreviewStatus tracks the build status of a single + preview. properties: branchRef: type: string @@ -189,8 +214,8 @@ spec: format: date-time type: string required: - - commitSha - branchRef + - commitSha - phase type: object type: array From 0f5c11b3f0aa61d1d8babeeba8755ec16ca2e0fa Mon Sep 17 00:00:00 2001 From: igoramf Date: Wed, 6 May 2026 10:51:36 -0300 Subject: [PATCH 22/42] refactor(build): replace Factory func type with Builder interface --- internal/build/registry.go | 26 +++++++++------ internal/build/registry_test.go | 57 +++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 10 deletions(-) create mode 100644 internal/build/registry_test.go diff --git a/internal/build/registry.go b/internal/build/registry.go index 2c725ca..528a2cd 100644 --- a/internal/build/registry.go +++ b/internal/build/registry.go @@ -2,6 +2,7 @@ package build import ( "context" + "errors" "fmt" batchv1 "k8s.io/api/batch/v1" @@ -9,26 +10,31 @@ import ( decositesv1alpha1 "github.com/deco-sites/decofile-operator/api/v1alpha1" ) -// Factory builds a *batchv1.Job for a given Deco and build source. -type Factory func(ctx context.Context, deco *decositesv1alpha1.Deco, jobName string, source decositesv1alpha1.DecoSpecBuildSource) (*batchv1.Job, error) +var errNoFactory = errors.New("no builder registered for serving type") -// Registry maps serving types to their job factories. +// Builder creates a K8s Job for a given Deco workload and build source. +type Builder interface { + NewJob(ctx context.Context, deco *decositesv1alpha1.Deco, jobName string, source decositesv1alpha1.DecoSpecBuildSource) (*batchv1.Job, error) +} + +// Registry dispatches to the correct Builder by spec.serving.type. +// Registry itself satisfies Builder. type Registry struct { - platforms map[string]Factory + platforms map[string]Builder } func NewRegistry() *Registry { - return &Registry{platforms: map[string]Factory{}} + return &Registry{platforms: map[string]Builder{}} } -func (r *Registry) Register(servingType string, f Factory) { - r.platforms[servingType] = f +func (r *Registry) Register(servingType string, b Builder) { + r.platforms[servingType] = b } func (r *Registry) NewJob(ctx context.Context, deco *decositesv1alpha1.Deco, jobName string, source decositesv1alpha1.DecoSpecBuildSource) (*batchv1.Job, error) { - f, ok := r.platforms[deco.Spec.Serving.Type] + b, ok := r.platforms[deco.Spec.Serving.Type] if !ok { - return nil, fmt.Errorf("no factory registered for serving type %q", deco.Spec.Serving.Type) + return nil, fmt.Errorf("%w %q", errNoFactory, deco.Spec.Serving.Type) } - return f(ctx, deco, jobName, source) + return b.NewJob(ctx, deco, jobName, source) } diff --git a/internal/build/registry_test.go b/internal/build/registry_test.go new file mode 100644 index 0000000..92ee5d8 --- /dev/null +++ b/internal/build/registry_test.go @@ -0,0 +1,57 @@ +package build + +import ( + "context" + "errors" + "testing" + + batchv1 "k8s.io/api/batch/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + decositesv1alpha1 "github.com/deco-sites/decofile-operator/api/v1alpha1" +) + +// Compile-time: Registry must satisfy Builder. +var _ Builder = (*Registry)(nil) + +type stubBuilder struct{ job *batchv1.Job } + +func (s *stubBuilder) NewJob(_ context.Context, _ *decositesv1alpha1.Deco, _ string, _ decositesv1alpha1.DecoSpecBuildSource) (*batchv1.Job, error) { + return s.job, nil +} + +func testDeco(servingType string) *decositesv1alpha1.Deco { + return &decositesv1alpha1.Deco{ + ObjectMeta: metav1.ObjectMeta{Name: "site", Namespace: "default"}, + Spec: decositesv1alpha1.DecoSpec{ + Site: "site", + Org: "org", + Serving: &decositesv1alpha1.DecoSpecServing{Type: servingType}, + }, + } +} + +func TestRegistry_DispatchesToRegisteredBuilder(t *testing.T) { + want := &batchv1.Job{ObjectMeta: metav1.ObjectMeta{Name: "build-abc"}} + r := NewRegistry() + r.Register("cloudflare-worker", &stubBuilder{job: want}) + + got, err := r.NewJob(context.Background(), testDeco("cloudflare-worker"), "build-abc", decositesv1alpha1.DecoSpecBuildSource{CommitSha: "abc"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != want { + t.Errorf("expected job %p, got %p", want, got) + } +} + +func TestRegistry_ErrorsOnUnknownServingType(t *testing.T) { + r := NewRegistry() + _, err := r.NewJob(context.Background(), testDeco("unknown"), "job", decositesv1alpha1.DecoSpecBuildSource{}) + if err == nil { + t.Fatal("expected error for unregistered serving type") + } + if !errors.Is(err, errNoFactory) { + t.Errorf("expected errNoFactory, got %v", err) + } +} From aebeb07e978f26a789f269ed26437210aad8603d Mon Sep 17 00:00:00 2001 From: igoramf Date: Wed, 6 May 2026 10:52:39 -0300 Subject: [PATCH 23/42] feat(build): add CfWorkersConfig, cfWorkersBuilder, NewCloudflareFactory --- internal/build/cfworkers.go | 65 +++++++++++++ internal/build/cfworkers_test.go | 152 +++++++++++++++++++++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 internal/build/cfworkers_test.go diff --git a/internal/build/cfworkers.go b/internal/build/cfworkers.go index a774e49..5b250e3 100644 --- a/internal/build/cfworkers.go +++ b/internal/build/cfworkers.go @@ -2,8 +2,10 @@ package build import ( + "context" "crypto/sha256" "fmt" + "os" batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" @@ -127,3 +129,66 @@ func NewCfWorkersJob(opts CfWorkersJobOpts) *batchv1.Job { }, } } + +// CfWorkersConfig holds all configuration the Cloudflare Workers builder needs. +type CfWorkersConfig struct { + CfApiToken string + CfAccountId string + GithubToken string + BuilderImage string + TTLSeconds int32 + S3 S3Config +} + +// CfWorkersConfigFromEnv reads CfWorkersConfig from standard environment variables. +func CfWorkersConfigFromEnv() CfWorkersConfig { + return CfWorkersConfig{ + CfApiToken: os.Getenv("CLOUDFLARE_API_WORKERS_TOKEN"), + CfAccountId: os.Getenv("CLOUDFLARE_ACCOUNT_ID"), + GithubToken: os.Getenv("GITHUB_TOKEN"), + BuilderImage: envOrDefault("CFWORKERS_BUILDER_IMAGE", "ghcr.io/decocms/infra_applications/cfworkers-builder:latest"), + TTLSeconds: 24 * 60 * 60, + S3: S3Config{ + Region: envOrDefault("S3_REGION", "sa-east-1"), + AccessKeyID: os.Getenv("S3_ACCESS_KEY_ID"), + SecretAccessKey: os.Getenv("S3_SECRET_ACCESS_KEY"), + LogsBucket: envOrDefault("S3_LOGS_BUCKET", "deco-sites-build-logs"), + CacheBucket: envOrDefault("S3_CACHE_BUCKET", "deco-cfworkers-deployments"), + }, + } +} + +type cfWorkersBuilder struct { + cfg CfWorkersConfig + presignFn func(ctx context.Context, cfg S3Config, site, jobName string) (PresignedURLs, error) +} + +// NewCloudflareFactory returns a Builder for spec.serving.type = "cloudflare-worker". +func NewCloudflareFactory(cfg CfWorkersConfig) Builder { + return &cfWorkersBuilder{cfg: cfg, presignFn: GeneratePresignedURLs} +} + +func (b *cfWorkersBuilder) NewJob(ctx context.Context, deco *decositesv1alpha1.Deco, jobName string, source decositesv1alpha1.DecoSpecBuildSource) (*batchv1.Job, error) { + urls, err := b.presignFn(ctx, b.cfg.S3, deco.Spec.Site, jobName) + if err != nil { + return nil, fmt.Errorf("generating presigned URLs: %w", err) + } + return NewCfWorkersJob(CfWorkersJobOpts{ + Deco: deco, + JobName: jobName, + GithubToken: b.cfg.GithubToken, + CfApiToken: b.cfg.CfApiToken, + CfAccountId: b.cfg.CfAccountId, + PresignedURLs: urls, + SourceOverride: &source, + BuilderImage: b.cfg.BuilderImage, + TTLSeconds: b.cfg.TTLSeconds, + }), nil +} + +func envOrDefault(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} diff --git a/internal/build/cfworkers_test.go b/internal/build/cfworkers_test.go new file mode 100644 index 0000000..9b34011 --- /dev/null +++ b/internal/build/cfworkers_test.go @@ -0,0 +1,152 @@ +package build + +import ( + "context" + "fmt" + "os" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + decositesv1alpha1 "github.com/deco-sites/decofile-operator/api/v1alpha1" +) + +// Compile-time: cfWorkersBuilder must satisfy Builder. +var _ Builder = (*cfWorkersBuilder)(nil) + +func TestCfWorkersConfigFromEnv(t *testing.T) { + t.Setenv("CLOUDFLARE_API_WORKERS_TOKEN", "cf-token") + t.Setenv("CLOUDFLARE_ACCOUNT_ID", "cf-account") + t.Setenv("GITHUB_TOKEN", "gh-token") + t.Setenv("S3_REGION", "us-east-1") + t.Setenv("S3_ACCESS_KEY_ID", "key-id") + t.Setenv("S3_SECRET_ACCESS_KEY", "secret") + + cfg := CfWorkersConfigFromEnv() + + if cfg.CfApiToken != "cf-token" { + t.Errorf("CfApiToken: want %q, got %q", "cf-token", cfg.CfApiToken) + } + if cfg.CfAccountId != "cf-account" { + t.Errorf("CfAccountId: want %q, got %q", "cf-account", cfg.CfAccountId) + } + if cfg.GithubToken != "gh-token" { + t.Errorf("GithubToken: want %q, got %q", "gh-token", cfg.GithubToken) + } + if cfg.S3.Region != "us-east-1" { + t.Errorf("S3.Region: want %q, got %q", "us-east-1", cfg.S3.Region) + } + if cfg.S3.AccessKeyID != "key-id" { + t.Errorf("S3.AccessKeyID: want %q, got %q", "key-id", cfg.S3.AccessKeyID) + } +} + +func TestCfWorkersConfigFromEnv_Defaults(t *testing.T) { + os.Unsetenv("S3_REGION") + os.Unsetenv("CFWORKERS_BUILDER_IMAGE") + os.Unsetenv("S3_LOGS_BUCKET") + os.Unsetenv("S3_CACHE_BUCKET") + + cfg := CfWorkersConfigFromEnv() + + if cfg.S3.Region != "sa-east-1" { + t.Errorf("default S3.Region: want sa-east-1, got %q", cfg.S3.Region) + } + if cfg.S3.LogsBucket != "deco-sites-build-logs" { + t.Errorf("default S3.LogsBucket: want deco-sites-build-logs, got %q", cfg.S3.LogsBucket) + } + if cfg.S3.CacheBucket != "deco-cfworkers-deployments" { + t.Errorf("default S3.CacheBucket: want deco-cfworkers-deployments, got %q", cfg.S3.CacheBucket) + } + if cfg.TTLSeconds != 24*60*60 { + t.Errorf("default TTLSeconds: want 86400, got %d", cfg.TTLSeconds) + } +} + +func TestCloudflareBuilder_NewJob_BuildsValidJob(t *testing.T) { + cfg := CfWorkersConfig{ + CfApiToken: "cf-token", + CfAccountId: "cf-account", + GithubToken: "gh-token", + BuilderImage: "ghcr.io/test:v1", + TTLSeconds: 3600, + S3: S3Config{Region: "sa-east-1", LogsBucket: "logs", CacheBucket: "cache"}, + } + + stubPresign := func(_ context.Context, _ S3Config, _, _ string) (PresignedURLs, error) { + return PresignedURLs{LogsUpload: "s3://logs/job.log", CacheDownload: "s3://cache/dl", CacheUpload: "s3://cache/ul"}, nil + } + + b := &cfWorkersBuilder{cfg: cfg, presignFn: stubPresign} + + d := &decositesv1alpha1.Deco{ + ObjectMeta: metav1.ObjectMeta{Name: "mysite", Namespace: "default"}, + Spec: decositesv1alpha1.DecoSpec{ + Site: "mysite", + Org: "myorg", + Serving: &decositesv1alpha1.DecoSpecServing{Type: "cloudflare-worker"}, + }, + } + source := decositesv1alpha1.DecoSpecBuildSource{CommitSha: "abc123", Production: true} + + job, err := b.NewJob(context.Background(), d, "build-abc", source) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if job.Name != "build-abc" { + t.Errorf("job name: want build-abc, got %q", job.Name) + } + if job.Namespace != "default" { + t.Errorf("job namespace: want default, got %q", job.Namespace) + } + if len(job.Spec.Template.Spec.Containers) != 1 { + t.Fatalf("expected 1 container, got %d", len(job.Spec.Template.Spec.Containers)) + } + c := job.Spec.Template.Spec.Containers[0] + if c.Image != "ghcr.io/test:v1" { + t.Errorf("container image: want ghcr.io/test:v1, got %q", c.Image) + } + envMap := make(map[string]string, len(c.Env)) + for _, e := range c.Env { + envMap[e.Name] = e.Value + } + checks := map[string]string{ + "COMMIT_SHA": "abc123", + "DECO_SITE_NAME": "mysite", + "CF_ACCOUNT_ID": "cf-account", + "CLOUDFLARE_API_TOKEN": "cf-token", + "GITHUB_TOKEN": "gh-token", + "IS_PRODUCTION": "true", + } + for k, want := range checks { + if got := envMap[k]; got != want { + t.Errorf("env %s: want %q, got %q", k, want, got) + } + } +} + +func TestCloudflareBuilder_NewJob_PropagatesPresignError(t *testing.T) { + cfg := CfWorkersConfig{BuilderImage: "img:v1", TTLSeconds: 60} + b := &cfWorkersBuilder{ + cfg: cfg, + presignFn: func(_ context.Context, _ S3Config, _, _ string) (PresignedURLs, error) { + return PresignedURLs{}, fmt.Errorf("s3 unavailable") + }, + } + d := &decositesv1alpha1.Deco{ + ObjectMeta: metav1.ObjectMeta{Name: "s", Namespace: "ns"}, + Spec: decositesv1alpha1.DecoSpec{ + Site: "s", + Org: "o", + Serving: &decositesv1alpha1.DecoSpecServing{Type: "cloudflare-worker"}, + }, + } + _, err := b.NewJob(context.Background(), d, "job", decositesv1alpha1.DecoSpecBuildSource{CommitSha: "sha"}) + if err == nil { + t.Fatal("expected error from presign failure") + } +} + +func TestNewCloudflareFactory_SatisfiesBuilder(t *testing.T) { + var _ Builder = NewCloudflareFactory(CfWorkersConfig{}) +} From 2794a10a147292fa22cbb2fc57fabb691b0b2c66 Mon Sep 17 00:00:00 2001 From: igoramf Date: Wed, 6 May 2026 10:53:57 -0300 Subject: [PATCH 24/42] refactor(controller): hold build.Builder interface; update default bucket names --- internal/build/cfworkers.go | 4 ++-- internal/build/cfworkers_test.go | 8 ++++---- internal/controller/deco_controller.go | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/internal/build/cfworkers.go b/internal/build/cfworkers.go index 5b250e3..3c2bc73 100644 --- a/internal/build/cfworkers.go +++ b/internal/build/cfworkers.go @@ -152,8 +152,8 @@ func CfWorkersConfigFromEnv() CfWorkersConfig { Region: envOrDefault("S3_REGION", "sa-east-1"), AccessKeyID: os.Getenv("S3_ACCESS_KEY_ID"), SecretAccessKey: os.Getenv("S3_SECRET_ACCESS_KEY"), - LogsBucket: envOrDefault("S3_LOGS_BUCKET", "deco-sites-build-logs"), - CacheBucket: envOrDefault("S3_CACHE_BUCKET", "deco-cfworkers-deployments"), + LogsBucket: envOrDefault("S3_LOGS_BUCKET", "new-deco-sites-build-logs"), + CacheBucket: envOrDefault("S3_CACHE_BUCKET", "new-deco-cfworkers-deployments"), }, } } diff --git a/internal/build/cfworkers_test.go b/internal/build/cfworkers_test.go index 9b34011..543bd36 100644 --- a/internal/build/cfworkers_test.go +++ b/internal/build/cfworkers_test.go @@ -52,11 +52,11 @@ func TestCfWorkersConfigFromEnv_Defaults(t *testing.T) { if cfg.S3.Region != "sa-east-1" { t.Errorf("default S3.Region: want sa-east-1, got %q", cfg.S3.Region) } - if cfg.S3.LogsBucket != "deco-sites-build-logs" { - t.Errorf("default S3.LogsBucket: want deco-sites-build-logs, got %q", cfg.S3.LogsBucket) + if cfg.S3.LogsBucket != "new-deco-sites-build-logs" { + t.Errorf("default S3.LogsBucket: want new-deco-sites-build-logs, got %q", cfg.S3.LogsBucket) } - if cfg.S3.CacheBucket != "deco-cfworkers-deployments" { - t.Errorf("default S3.CacheBucket: want deco-cfworkers-deployments, got %q", cfg.S3.CacheBucket) + if cfg.S3.CacheBucket != "new-deco-cfworkers-deployments" { + t.Errorf("default S3.CacheBucket: want new-deco-cfworkers-deployments, got %q", cfg.S3.CacheBucket) } if cfg.TTLSeconds != 24*60*60 { t.Errorf("default TTLSeconds: want 86400, got %d", cfg.TTLSeconds) diff --git a/internal/controller/deco_controller.go b/internal/controller/deco_controller.go index cbcdca6..7d7e8d3 100644 --- a/internal/controller/deco_controller.go +++ b/internal/controller/deco_controller.go @@ -29,8 +29,8 @@ const ( // DecoReconciler reconciles Deco objects. type DecoReconciler struct { client.Client - Scheme *runtime.Scheme - Registry *build.Registry + Scheme *runtime.Scheme + Builder build.Builder } // +kubebuilder:rbac:groups=deco.sites,resources=decos,verbs=get;list;watch;create;update;patch;delete @@ -242,7 +242,7 @@ func (r *DecoReconciler) reconcilePreviewBuilds(ctx context.Context, log logr.Lo // createJob creates a K8s Job for either a production or preview build. func (r *DecoReconciler) createJob(ctx context.Context, deco *decositesv1alpha1.Deco, jobName string, source decositesv1alpha1.DecoSpecBuildSource) error { - job, err := r.Registry.NewJob(ctx, deco, jobName, source) + job, err := r.Builder.NewJob(ctx, deco, jobName, source) if err != nil { return fmt.Errorf("building job spec: %w", err) } From e75a3ce441407631079b51402ed43f00d4ad2f3c Mon Sep 17 00:00:00 2001 From: igoramf Date: Wed, 6 May 2026 10:56:26 -0300 Subject: [PATCH 25/42] =?UTF-8?q?refactor(main):=20remove=20CF/S3=20specif?= =?UTF-8?q?ics=20=E2=80=94=20wire=20via=20build.NewCloudflareFactory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/main.go | 54 ++++------------------------------------------------- 1 file changed, 4 insertions(+), 50 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index 081f249..e4c2f44 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -30,7 +30,6 @@ import ( // to ensure that exec-entrypoint and run can make use of them. _ "k8s.io/client-go/plugin/pkg/client/auth" - batchv1 "k8s.io/api/batch/v1" "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" @@ -115,21 +114,6 @@ func main() { os.Getenv("VALKEY_WATCH_FAILOVER") != "false", "Subscribe to Sentinel +switch-master events and trigger immediate ACL resync on failover. "+ "Enabled by default when VALKEY_SENTINEL_URLS is set. Set VALKEY_WATCH_FAILOVER=false to disable.") - var cfApiToken string - var cfAccountId string - var s3Region string - var s3AccessKeyID string - var s3SecretAccessKey string - flag.StringVar(&cfApiToken, "cf-api-token", os.Getenv("CLOUDFLARE_API_WORKERS_TOKEN"), - "Cloudflare Workers API token for build deployments.") - flag.StringVar(&cfAccountId, "cf-account-id", os.Getenv("CLOUDFLARE_ACCOUNT_ID"), - "Cloudflare account ID for build deployments.") - flag.StringVar(&s3Region, "s3-region", getEnvOrDefault("S3_REGION", "sa-east-1"), - "AWS S3 region for build logs and npm cache.") - flag.StringVar(&s3AccessKeyID, "s3-access-key-id", os.Getenv("S3_ACCESS_KEY_ID"), - "AWS access key ID for S3 presigned URL generation.") - flag.StringVar(&s3SecretAccessKey, "s3-secret-access-key", os.Getenv("S3_SECRET_ACCESS_KEY"), - "AWS secret access key for S3 presigned URL generation.") opts := zap.Options{ Development: false, } @@ -345,43 +329,13 @@ func main() { os.Exit(1) } } - githubToken := os.Getenv("GITHUB_TOKEN") - s3Cfg := build.S3Config{ - Region: s3Region, - AccessKeyID: s3AccessKeyID, - SecretAccessKey: s3SecretAccessKey, - LogsBucket: "deco-sites-build-logs", - CacheBucket: "deco-cfworkers-deployments", - } - cfWorkersFactory := build.Factory(func( - ctx context.Context, - deco *decositesv1alpha1.Deco, - jobName string, - source decositesv1alpha1.DecoSpecBuildSource, - ) (*batchv1.Job, error) { - presignedURLs, err := build.GeneratePresignedURLs(ctx, s3Cfg, deco.Spec.Site, jobName) - if err != nil { - return nil, fmt.Errorf("generating presigned URLs: %w", err) - } - return build.NewCfWorkersJob(build.CfWorkersJobOpts{ - Deco: deco, - JobName: jobName, - GithubToken: githubToken, - CfApiToken: cfApiToken, - CfAccountId: cfAccountId, - PresignedURLs: presignedURLs, - SourceOverride: &source, - BuilderImage: "ghcr.io/decocms/infra_applications/cfworkers-builder:latest", - TTLSeconds: 24 * 60 * 60, - }), nil - }) registry := build.NewRegistry() - registry.Register("cloudflare-worker", cfWorkersFactory) + registry.Register("cloudflare-worker", build.NewCloudflareFactory(build.CfWorkersConfigFromEnv())) if err := (&controller.DecoReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - Registry: registry, + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Builder: registry, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "Deco") os.Exit(1) From 9064d112b19c5e1def1b84c2394900c9e5d9a4a1 Mon Sep 17 00:00:00 2001 From: igoramf Date: Wed, 6 May 2026 11:08:36 -0300 Subject: [PATCH 26/42] refactor(build): unexport internal types NewCfWorkersJob, CfWorkersJobOpts, PresignedURLs, GeneratePresignedURLs --- internal/build/cfworkers.go | 28 ++++++++++++++-------------- internal/build/cfworkers_test.go | 8 ++++---- internal/build/s3presign.go | 14 +++++++------- 3 files changed, 25 insertions(+), 25 deletions(-) diff --git a/internal/build/cfworkers.go b/internal/build/cfworkers.go index 3c2bc73..7a390d6 100644 --- a/internal/build/cfworkers.go +++ b/internal/build/cfworkers.go @@ -21,21 +21,21 @@ func JobName(commitSha, site string) string { return fmt.Sprintf("build-%x", h[:4]) } -// PresignedURLs are the S3 presigned URLs the build job needs. -type PresignedURLs struct { +// presignedURLs are the S3 presigned URLs the build job needs. +type presignedURLs struct { LogsUpload string CacheDownload string CacheUpload string } -// CfWorkersJobOpts are the inputs for NewJob. -type CfWorkersJobOpts struct { +// cfWorkersJobOpts are the inputs for NewJob. +type cfWorkersJobOpts struct { Deco *decositesv1alpha1.Deco JobName string GithubToken string CfApiToken string CfAccountId string - PresignedURLs PresignedURLs + presignedURLs presignedURLs // SourceOverride replaces spec.build.source when set (used for preview builds). SourceOverride *decositesv1alpha1.DecoSpecBuildSource // BuilderImage is the platform default. spec.build.builder in the CR takes precedence when set. @@ -44,8 +44,8 @@ type CfWorkersJobOpts struct { TTLSeconds int32 } -// NewCfWorkersJob builds the batchv1.Job spec for a cfworkers build. -func NewCfWorkersJob(opts CfWorkersJobOpts) *batchv1.Job { +// newCfWorkersJob builds the batchv1.Job spec for a cfworkers build. +func newCfWorkersJob(opts cfWorkersJobOpts) *batchv1.Job { spec := opts.Deco.Spec var src decositesv1alpha1.DecoSpecBuildSource if opts.SourceOverride != nil { @@ -76,9 +76,9 @@ func NewCfWorkersJob(opts CfWorkersJobOpts) *batchv1.Job { {Name: "IS_PRODUCTION", Value: isProduction}, {Name: "CF_ACCOUNT_ID", Value: opts.CfAccountId}, {Name: "CLOUDFLARE_API_TOKEN", Value: opts.CfApiToken}, - {Name: "LOGS_UPLOAD_URL", Value: opts.PresignedURLs.LogsUpload}, - {Name: "CACHE_DOWNLOAD_URL", Value: opts.PresignedURLs.CacheDownload}, - {Name: "CACHE_UPLOAD_URL", Value: opts.PresignedURLs.CacheUpload}, + {Name: "LOGS_UPLOAD_URL", Value: opts.presignedURLs.LogsUpload}, + {Name: "CACHE_DOWNLOAD_URL", Value: opts.presignedURLs.CacheDownload}, + {Name: "CACHE_UPLOAD_URL", Value: opts.presignedURLs.CacheUpload}, } if src.BranchRef != "" { env = append(env, corev1.EnvVar{Name: "BRANCH_REF", Value: src.BranchRef}) @@ -160,12 +160,12 @@ func CfWorkersConfigFromEnv() CfWorkersConfig { type cfWorkersBuilder struct { cfg CfWorkersConfig - presignFn func(ctx context.Context, cfg S3Config, site, jobName string) (PresignedURLs, error) + presignFn func(ctx context.Context, cfg S3Config, site, jobName string) (presignedURLs, error) } // NewCloudflareFactory returns a Builder for spec.serving.type = "cloudflare-worker". func NewCloudflareFactory(cfg CfWorkersConfig) Builder { - return &cfWorkersBuilder{cfg: cfg, presignFn: GeneratePresignedURLs} + return &cfWorkersBuilder{cfg: cfg, presignFn: generatePresignedURLs} } func (b *cfWorkersBuilder) NewJob(ctx context.Context, deco *decositesv1alpha1.Deco, jobName string, source decositesv1alpha1.DecoSpecBuildSource) (*batchv1.Job, error) { @@ -173,13 +173,13 @@ func (b *cfWorkersBuilder) NewJob(ctx context.Context, deco *decositesv1alpha1.D if err != nil { return nil, fmt.Errorf("generating presigned URLs: %w", err) } - return NewCfWorkersJob(CfWorkersJobOpts{ + return newCfWorkersJob(cfWorkersJobOpts{ Deco: deco, JobName: jobName, GithubToken: b.cfg.GithubToken, CfApiToken: b.cfg.CfApiToken, CfAccountId: b.cfg.CfAccountId, - PresignedURLs: urls, + presignedURLs: urls, SourceOverride: &source, BuilderImage: b.cfg.BuilderImage, TTLSeconds: b.cfg.TTLSeconds, diff --git a/internal/build/cfworkers_test.go b/internal/build/cfworkers_test.go index 543bd36..b867651 100644 --- a/internal/build/cfworkers_test.go +++ b/internal/build/cfworkers_test.go @@ -73,8 +73,8 @@ func TestCloudflareBuilder_NewJob_BuildsValidJob(t *testing.T) { S3: S3Config{Region: "sa-east-1", LogsBucket: "logs", CacheBucket: "cache"}, } - stubPresign := func(_ context.Context, _ S3Config, _, _ string) (PresignedURLs, error) { - return PresignedURLs{LogsUpload: "s3://logs/job.log", CacheDownload: "s3://cache/dl", CacheUpload: "s3://cache/ul"}, nil + stubPresign := func(_ context.Context, _ S3Config, _, _ string) (presignedURLs, error) { + return presignedURLs{LogsUpload: "s3://logs/job.log", CacheDownload: "s3://cache/dl", CacheUpload: "s3://cache/ul"}, nil } b := &cfWorkersBuilder{cfg: cfg, presignFn: stubPresign} @@ -129,8 +129,8 @@ func TestCloudflareBuilder_NewJob_PropagatesPresignError(t *testing.T) { cfg := CfWorkersConfig{BuilderImage: "img:v1", TTLSeconds: 60} b := &cfWorkersBuilder{ cfg: cfg, - presignFn: func(_ context.Context, _ S3Config, _, _ string) (PresignedURLs, error) { - return PresignedURLs{}, fmt.Errorf("s3 unavailable") + presignFn: func(_ context.Context, _ S3Config, _, _ string) (presignedURLs, error) { + return presignedURLs{}, fmt.Errorf("s3 unavailable") }, } d := &decositesv1alpha1.Deco{ diff --git a/internal/build/s3presign.go b/internal/build/s3presign.go index d432fd6..9f8deee 100644 --- a/internal/build/s3presign.go +++ b/internal/build/s3presign.go @@ -22,9 +22,9 @@ type S3Config struct { CacheBucket string // bucket for npm cache } -// GeneratePresignedURLs generates all presigned URLs the build job needs. +// GeneratepresignedURLs generates all presigned URLs the build job needs. // Mirrors generatePresignedUrls() in the admin's build.ts. -func GeneratePresignedURLs(ctx context.Context, cfg S3Config, site, jobName string) (PresignedURLs, error) { +func generatePresignedURLs(ctx context.Context, cfg S3Config, site, jobName string) (presignedURLs, error) { awsCfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(cfg.Region), config.WithCredentialsProvider( @@ -32,7 +32,7 @@ func GeneratePresignedURLs(ctx context.Context, cfg S3Config, site, jobName stri ), ) if err != nil { - return PresignedURLs{}, fmt.Errorf("loading aws config: %w", err) + return presignedURLs{}, fmt.Errorf("loading aws config: %w", err) } presigner := s3.NewPresignClient(s3.NewFromConfig(awsCfg)) @@ -42,7 +42,7 @@ func GeneratePresignedURLs(ctx context.Context, cfg S3Config, site, jobName stri Key: aws.String(fmt.Sprintf("%s/%s.log", site, jobName)), }, s3.WithPresignExpires(presignExpiry)) if err != nil { - return PresignedURLs{}, fmt.Errorf("presigning logs upload: %w", err) + return presignedURLs{}, fmt.Errorf("presigning logs upload: %w", err) } cacheKey := fmt.Sprintf("%s/npm-cache.tar.zst", site) @@ -52,7 +52,7 @@ func GeneratePresignedURLs(ctx context.Context, cfg S3Config, site, jobName stri Key: aws.String(cacheKey), }, s3.WithPresignExpires(presignExpiry)) if err != nil { - return PresignedURLs{}, fmt.Errorf("presigning cache download: %w", err) + return presignedURLs{}, fmt.Errorf("presigning cache download: %w", err) } cacheUpload, err := presigner.PresignPutObject(ctx, &s3.PutObjectInput{ @@ -60,10 +60,10 @@ func GeneratePresignedURLs(ctx context.Context, cfg S3Config, site, jobName stri Key: aws.String(cacheKey), }, s3.WithPresignExpires(presignExpiry)) if err != nil { - return PresignedURLs{}, fmt.Errorf("presigning cache upload: %w", err) + return presignedURLs{}, fmt.Errorf("presigning cache upload: %w", err) } - return PresignedURLs{ + return presignedURLs{ LogsUpload: logsUpload.URL, CacheDownload: cacheDownload.URL, CacheUpload: cacheUpload.URL, From 0cdc4f4d72481188186d1c36be6f3dc9d56c7597 Mon Sep 17 00:00:00 2001 From: igoramf Date: Wed, 6 May 2026 11:11:03 -0300 Subject: [PATCH 27/42] =?UTF-8?q?refactor(build):=20remove=20defaults=20te?= =?UTF-8?q?st=20=E2=80=94=20buckets=20are=20env-configurable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/build/cfworkers_test.go | 23 +---------------------- 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/internal/build/cfworkers_test.go b/internal/build/cfworkers_test.go index b867651..46936ff 100644 --- a/internal/build/cfworkers_test.go +++ b/internal/build/cfworkers_test.go @@ -3,7 +3,6 @@ package build import ( "context" "fmt" - "os" "testing" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -14,6 +13,7 @@ import ( // Compile-time: cfWorkersBuilder must satisfy Builder. var _ Builder = (*cfWorkersBuilder)(nil) + func TestCfWorkersConfigFromEnv(t *testing.T) { t.Setenv("CLOUDFLARE_API_WORKERS_TOKEN", "cf-token") t.Setenv("CLOUDFLARE_ACCOUNT_ID", "cf-account") @@ -41,27 +41,6 @@ func TestCfWorkersConfigFromEnv(t *testing.T) { } } -func TestCfWorkersConfigFromEnv_Defaults(t *testing.T) { - os.Unsetenv("S3_REGION") - os.Unsetenv("CFWORKERS_BUILDER_IMAGE") - os.Unsetenv("S3_LOGS_BUCKET") - os.Unsetenv("S3_CACHE_BUCKET") - - cfg := CfWorkersConfigFromEnv() - - if cfg.S3.Region != "sa-east-1" { - t.Errorf("default S3.Region: want sa-east-1, got %q", cfg.S3.Region) - } - if cfg.S3.LogsBucket != "new-deco-sites-build-logs" { - t.Errorf("default S3.LogsBucket: want new-deco-sites-build-logs, got %q", cfg.S3.LogsBucket) - } - if cfg.S3.CacheBucket != "new-deco-cfworkers-deployments" { - t.Errorf("default S3.CacheBucket: want new-deco-cfworkers-deployments, got %q", cfg.S3.CacheBucket) - } - if cfg.TTLSeconds != 24*60*60 { - t.Errorf("default TTLSeconds: want 86400, got %d", cfg.TTLSeconds) - } -} func TestCloudflareBuilder_NewJob_BuildsValidJob(t *testing.T) { cfg := CfWorkersConfig{ From f109761f08b361ae1df067bd879ed0629563f4e3 Mon Sep 17 00:00:00 2001 From: igoramf Date: Wed, 6 May 2026 11:12:23 -0300 Subject: [PATCH 28/42] =?UTF-8?q?chore:=20remove=20cfworkers=5Ftest.go=20?= =?UTF-8?q?=E2=80=94=20follow-up=20PR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/build/cfworkers_test.go | 131 ------------------------------- 1 file changed, 131 deletions(-) delete mode 100644 internal/build/cfworkers_test.go diff --git a/internal/build/cfworkers_test.go b/internal/build/cfworkers_test.go deleted file mode 100644 index 46936ff..0000000 --- a/internal/build/cfworkers_test.go +++ /dev/null @@ -1,131 +0,0 @@ -package build - -import ( - "context" - "fmt" - "testing" - - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - - decositesv1alpha1 "github.com/deco-sites/decofile-operator/api/v1alpha1" -) - -// Compile-time: cfWorkersBuilder must satisfy Builder. -var _ Builder = (*cfWorkersBuilder)(nil) - - -func TestCfWorkersConfigFromEnv(t *testing.T) { - t.Setenv("CLOUDFLARE_API_WORKERS_TOKEN", "cf-token") - t.Setenv("CLOUDFLARE_ACCOUNT_ID", "cf-account") - t.Setenv("GITHUB_TOKEN", "gh-token") - t.Setenv("S3_REGION", "us-east-1") - t.Setenv("S3_ACCESS_KEY_ID", "key-id") - t.Setenv("S3_SECRET_ACCESS_KEY", "secret") - - cfg := CfWorkersConfigFromEnv() - - if cfg.CfApiToken != "cf-token" { - t.Errorf("CfApiToken: want %q, got %q", "cf-token", cfg.CfApiToken) - } - if cfg.CfAccountId != "cf-account" { - t.Errorf("CfAccountId: want %q, got %q", "cf-account", cfg.CfAccountId) - } - if cfg.GithubToken != "gh-token" { - t.Errorf("GithubToken: want %q, got %q", "gh-token", cfg.GithubToken) - } - if cfg.S3.Region != "us-east-1" { - t.Errorf("S3.Region: want %q, got %q", "us-east-1", cfg.S3.Region) - } - if cfg.S3.AccessKeyID != "key-id" { - t.Errorf("S3.AccessKeyID: want %q, got %q", "key-id", cfg.S3.AccessKeyID) - } -} - - -func TestCloudflareBuilder_NewJob_BuildsValidJob(t *testing.T) { - cfg := CfWorkersConfig{ - CfApiToken: "cf-token", - CfAccountId: "cf-account", - GithubToken: "gh-token", - BuilderImage: "ghcr.io/test:v1", - TTLSeconds: 3600, - S3: S3Config{Region: "sa-east-1", LogsBucket: "logs", CacheBucket: "cache"}, - } - - stubPresign := func(_ context.Context, _ S3Config, _, _ string) (presignedURLs, error) { - return presignedURLs{LogsUpload: "s3://logs/job.log", CacheDownload: "s3://cache/dl", CacheUpload: "s3://cache/ul"}, nil - } - - b := &cfWorkersBuilder{cfg: cfg, presignFn: stubPresign} - - d := &decositesv1alpha1.Deco{ - ObjectMeta: metav1.ObjectMeta{Name: "mysite", Namespace: "default"}, - Spec: decositesv1alpha1.DecoSpec{ - Site: "mysite", - Org: "myorg", - Serving: &decositesv1alpha1.DecoSpecServing{Type: "cloudflare-worker"}, - }, - } - source := decositesv1alpha1.DecoSpecBuildSource{CommitSha: "abc123", Production: true} - - job, err := b.NewJob(context.Background(), d, "build-abc", source) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if job.Name != "build-abc" { - t.Errorf("job name: want build-abc, got %q", job.Name) - } - if job.Namespace != "default" { - t.Errorf("job namespace: want default, got %q", job.Namespace) - } - if len(job.Spec.Template.Spec.Containers) != 1 { - t.Fatalf("expected 1 container, got %d", len(job.Spec.Template.Spec.Containers)) - } - c := job.Spec.Template.Spec.Containers[0] - if c.Image != "ghcr.io/test:v1" { - t.Errorf("container image: want ghcr.io/test:v1, got %q", c.Image) - } - envMap := make(map[string]string, len(c.Env)) - for _, e := range c.Env { - envMap[e.Name] = e.Value - } - checks := map[string]string{ - "COMMIT_SHA": "abc123", - "DECO_SITE_NAME": "mysite", - "CF_ACCOUNT_ID": "cf-account", - "CLOUDFLARE_API_TOKEN": "cf-token", - "GITHUB_TOKEN": "gh-token", - "IS_PRODUCTION": "true", - } - for k, want := range checks { - if got := envMap[k]; got != want { - t.Errorf("env %s: want %q, got %q", k, want, got) - } - } -} - -func TestCloudflareBuilder_NewJob_PropagatesPresignError(t *testing.T) { - cfg := CfWorkersConfig{BuilderImage: "img:v1", TTLSeconds: 60} - b := &cfWorkersBuilder{ - cfg: cfg, - presignFn: func(_ context.Context, _ S3Config, _, _ string) (presignedURLs, error) { - return presignedURLs{}, fmt.Errorf("s3 unavailable") - }, - } - d := &decositesv1alpha1.Deco{ - ObjectMeta: metav1.ObjectMeta{Name: "s", Namespace: "ns"}, - Spec: decositesv1alpha1.DecoSpec{ - Site: "s", - Org: "o", - Serving: &decositesv1alpha1.DecoSpecServing{Type: "cloudflare-worker"}, - }, - } - _, err := b.NewJob(context.Background(), d, "job", decositesv1alpha1.DecoSpecBuildSource{CommitSha: "sha"}) - if err == nil { - t.Fatal("expected error from presign failure") - } -} - -func TestNewCloudflareFactory_SatisfiesBuilder(t *testing.T) { - var _ Builder = NewCloudflareFactory(CfWorkersConfig{}) -} From c5c0429f403709106ba0b58ea76d2648abb4be42 Mon Sep 17 00:00:00 2001 From: igoramf Date: Wed, 6 May 2026 11:24:31 -0300 Subject: [PATCH 29/42] =?UTF-8?q?refactor(build):=20remove=20hardcoded=20d?= =?UTF-8?q?efault=20bucket/image=20names=20=E2=80=94=20repo=20is=20public?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/build/cfworkers.go | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/internal/build/cfworkers.go b/internal/build/cfworkers.go index 7a390d6..e6e29b8 100644 --- a/internal/build/cfworkers.go +++ b/internal/build/cfworkers.go @@ -146,14 +146,14 @@ func CfWorkersConfigFromEnv() CfWorkersConfig { CfApiToken: os.Getenv("CLOUDFLARE_API_WORKERS_TOKEN"), CfAccountId: os.Getenv("CLOUDFLARE_ACCOUNT_ID"), GithubToken: os.Getenv("GITHUB_TOKEN"), - BuilderImage: envOrDefault("CFWORKERS_BUILDER_IMAGE", "ghcr.io/decocms/infra_applications/cfworkers-builder:latest"), + BuilderImage: os.Getenv("CFWORKERS_BUILDER_IMAGE"), TTLSeconds: 24 * 60 * 60, S3: S3Config{ - Region: envOrDefault("S3_REGION", "sa-east-1"), + Region: os.Getenv("S3_REGION"), AccessKeyID: os.Getenv("S3_ACCESS_KEY_ID"), SecretAccessKey: os.Getenv("S3_SECRET_ACCESS_KEY"), - LogsBucket: envOrDefault("S3_LOGS_BUCKET", "new-deco-sites-build-logs"), - CacheBucket: envOrDefault("S3_CACHE_BUCKET", "new-deco-cfworkers-deployments"), + LogsBucket: os.Getenv("S3_LOGS_BUCKET"), + CacheBucket: os.Getenv("S3_CACHE_BUCKET"), }, } } @@ -185,10 +185,3 @@ func (b *cfWorkersBuilder) NewJob(ctx context.Context, deco *decositesv1alpha1.D TTLSeconds: b.cfg.TTLSeconds, }), nil } - -func envOrDefault(key, def string) string { - if v := os.Getenv(key); v != "" { - return v - } - return def -} From 628ecb610ef149bdc14722bdbe7ee5bee7546543 Mon Sep 17 00:00:00 2001 From: igoramf Date: Wed, 6 May 2026 11:27:45 -0300 Subject: [PATCH 30/42] feat(helm): move S3_REGION to values; add builderImage, s3LogsBucket, s3CacheBucket as plain values --- ...eployment-operator-controller-manager.yaml | 21 +++++++++--- chart/values.yaml | 6 +++- hack/helm-generator/main.go | 21 +++++++++--- values-local.yaml | 34 +++++++++++++++++++ 4 files changed, 71 insertions(+), 11 deletions(-) create mode 100644 values-local.yaml diff --git a/chart/templates/deployment-operator-controller-manager.yaml b/chart/templates/deployment-operator-controller-manager.yaml index 6bcbd6e..72ff80d 100644 --- a/chart/templates/deployment-operator-controller-manager.yaml +++ b/chart/templates/deployment-operator-controller-manager.yaml @@ -31,7 +31,7 @@ spec: command: - /manager image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" - {{- if or (and .Values.github (or .Values.github.token .Values.github.existingSecret)) (and .Values.valkey (get .Values.valkey "sentinelUrls")) .Values.cfworkers.existingSecret }} + {{- 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.s3Region .Values.cfworkers.s3LogsBucket .Values.cfworkers.s3CacheBucket }} env: {{- if and .Values.github .Values.github.existingSecret }} - name: GITHUB_TOKEN @@ -83,11 +83,22 @@ spec: secretKeyRef: name: {{ .existingSecret | quote }} key: s3-secret-access-key + {{- end }} + {{- if .s3Region }} - name: S3_REGION - valueFrom: - secretKeyRef: - name: {{ .existingSecret | quote }} - key: s3-region + value: {{ .s3Region | quote }} + {{- end }} + {{- if .s3LogsBucket }} + - name: S3_LOGS_BUCKET + value: {{ .s3LogsBucket | quote }} + {{- end }} + {{- if .s3CacheBucket }} + - name: S3_CACHE_BUCKET + value: {{ .s3CacheBucket | quote }} + {{- end }} + {{- if .builderImage }} + - name: CFWORKERS_BUILDER_IMAGE + value: {{ .builderImage | quote }} {{- end }} {{- end }} {{- end }} diff --git a/chart/values.yaml b/chart/values.yaml index d0e48c8..9dfcc69 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -97,7 +97,11 @@ podLabels: {} # Cloudflare Workers build support cfworkers: - existingSecret: "" # Secret with cf-api-token, cf-account-id, s3-access-key-id, s3-secret-access-key, s3-region + existingSecret: "" # Secret with cf-api-token, cf-account-id, s3-access-key-id, s3-secret-access-key + s3Region: "" # AWS region for S3 presigned URLs (e.g. us-east-1) + s3LogsBucket: "" # S3 bucket for build logs + s3CacheBucket: "" # S3 bucket for npm cache + builderImage: "" # Builder container image (repository:tag) # Name overrides nameOverride: "" diff --git a/hack/helm-generator/main.go b/hack/helm-generator/main.go index c5f863d..9be3986 100644 --- a/hack/helm-generator/main.go +++ b/hack/helm-generator/main.go @@ -188,7 +188,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 }} + 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.s3Region .Values.cfworkers.s3LogsBucket .Values.cfworkers.s3CacheBucket }} env: {{- if and .Values.github .Values.github.existingSecret }} - name: GITHUB_TOKEN @@ -240,11 +240,22 @@ func addEnvVarsToDeployment(templatesDir string) error { secretKeyRef: name: {{ .existingSecret | quote }} key: s3-secret-access-key + {{- end }} + {{- if .s3Region }} - name: S3_REGION - valueFrom: - secretKeyRef: - name: {{ .existingSecret | quote }} - key: s3-region + value: {{ .s3Region | quote }} + {{- end }} + {{- if .s3LogsBucket }} + - name: S3_LOGS_BUCKET + value: {{ .s3LogsBucket | quote }} + {{- end }} + {{- if .s3CacheBucket }} + - name: S3_CACHE_BUCKET + value: {{ .s3CacheBucket | quote }} + {{- end }} + {{- if .builderImage }} + - name: CFWORKERS_BUILDER_IMAGE + value: {{ .builderImage | quote }} {{- end }} {{- end }} {{- end }}` diff --git a/values-local.yaml b/values-local.yaml new file mode 100644 index 0000000..66724fc --- /dev/null +++ b/values-local.yaml @@ -0,0 +1,34 @@ +image: + repository: operator + tag: local + pullPolicy: Never + +replicaCount: 1 +certManager: + enabled: true +webhook: + enabled: false +leaderElection: + enabled: false + +github: + existingSecret: deco-operator-github-token + existingSecretKey: token + +valkey: + sentinelUrls: "" + +cfworkers: + existingSecret: deco-operator-cfworkers + s3Region: sa-east-1 + s3LogsBucket: "" # set via S3_LOGS_BUCKET env or here + s3CacheBucket: "" # set via S3_CACHE_BUCKET env or here + builderImage: "" # set via CFWORKERS_BUILDER_IMAGE env or here + +resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi From 288d89aa1c002fdf4adc9cadb37357f58f0d340b Mon Sep 17 00:00:00 2001 From: igoramf Date: Wed, 6 May 2026 11:34:19 -0300 Subject: [PATCH 31/42] =?UTF-8?q?refactor(helm):=20split=20S3=20into=20ded?= =?UTF-8?q?icated=20s3.existingSecret=20=E2=80=94=20shared=20across=20buil?= =?UTF-8?q?d=20platforms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + ...eployment-operator-controller-manager.yaml | 26 ++++++++------ chart/values.yaml | 12 ++++--- hack/helm-generator/main.go | 26 ++++++++------ values-local.yaml | 34 ------------------- 5 files changed, 39 insertions(+), 60 deletions(-) delete mode 100644 values-local.yaml diff --git a/.gitignore b/.gitignore index 9753218..770db48 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ go.work *.swp *.swo *~ +values-local.yaml diff --git a/chart/templates/deployment-operator-controller-manager.yaml b/chart/templates/deployment-operator-controller-manager.yaml index 72ff80d..606a54d 100644 --- a/chart/templates/deployment-operator-controller-manager.yaml +++ b/chart/templates/deployment-operator-controller-manager.yaml @@ -31,7 +31,7 @@ spec: command: - /manager image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" - {{- 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.s3Region .Values.cfworkers.s3LogsBucket .Values.cfworkers.s3CacheBucket }} + {{- 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.s3.existingSecret .Values.s3.region .Values.s3.logsBucket .Values.s3.cacheBucket }} env: {{- if and .Values.github .Values.github.existingSecret }} - name: GITHUB_TOKEN @@ -73,6 +73,14 @@ spec: secretKeyRef: name: {{ .existingSecret | quote }} key: cf-account-id + {{- end }} + {{- if .builderImage }} + - name: CFWORKERS_BUILDER_IMAGE + value: {{ .builderImage | quote }} + {{- end }} + {{- end }} + {{- with .Values.s3 }} + {{- if .existingSecret }} - name: S3_ACCESS_KEY_ID valueFrom: secretKeyRef: @@ -84,21 +92,17 @@ spec: name: {{ .existingSecret | quote }} key: s3-secret-access-key {{- end }} - {{- if .s3Region }} + {{- if .region }} - name: S3_REGION - value: {{ .s3Region | quote }} + value: {{ .region | quote }} {{- end }} - {{- if .s3LogsBucket }} + {{- if .logsBucket }} - name: S3_LOGS_BUCKET - value: {{ .s3LogsBucket | quote }} + value: {{ .logsBucket | quote }} {{- end }} - {{- if .s3CacheBucket }} + {{- if .cacheBucket }} - name: S3_CACHE_BUCKET - value: {{ .s3CacheBucket | quote }} - {{- end }} - {{- if .builderImage }} - - name: CFWORKERS_BUILDER_IMAGE - value: {{ .builderImage | quote }} + value: {{ .cacheBucket | quote }} {{- end }} {{- end }} {{- end }} diff --git a/chart/values.yaml b/chart/values.yaml index 9dfcc69..27861cf 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -97,12 +97,16 @@ podLabels: {} # Cloudflare Workers build support cfworkers: - existingSecret: "" # Secret with cf-api-token, cf-account-id, s3-access-key-id, s3-secret-access-key - s3Region: "" # AWS region for S3 presigned URLs (e.g. us-east-1) - s3LogsBucket: "" # S3 bucket for build logs - s3CacheBucket: "" # S3 bucket for npm cache + existingSecret: "" # Secret with cf-api-token, cf-account-id builderImage: "" # Builder container image (repository:tag) +# S3 credentials and config — shared across all build platforms +s3: + existingSecret: "" # Secret with s3-access-key-id, s3-secret-access-key + region: "" # AWS region (e.g. us-east-1) + logsBucket: "" # S3 bucket for build logs + cacheBucket: "" # S3 bucket for npm cache + # Name overrides nameOverride: "" fullnameOverride: "" diff --git a/hack/helm-generator/main.go b/hack/helm-generator/main.go index 9be3986..7511c24 100644 --- a/hack/helm-generator/main.go +++ b/hack/helm-generator/main.go @@ -188,7 +188,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.s3Region .Values.cfworkers.s3LogsBucket .Values.cfworkers.s3CacheBucket }} + 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.s3.existingSecret .Values.s3.region .Values.s3.logsBucket .Values.s3.cacheBucket }} env: {{- if and .Values.github .Values.github.existingSecret }} - name: GITHUB_TOKEN @@ -230,6 +230,14 @@ func addEnvVarsToDeployment(templatesDir string) error { secretKeyRef: name: {{ .existingSecret | quote }} key: cf-account-id + {{- end }} + {{- if .builderImage }} + - name: CFWORKERS_BUILDER_IMAGE + value: {{ .builderImage | quote }} + {{- end }} + {{- end }} + {{- with .Values.s3 }} + {{- if .existingSecret }} - name: S3_ACCESS_KEY_ID valueFrom: secretKeyRef: @@ -241,21 +249,17 @@ func addEnvVarsToDeployment(templatesDir string) error { name: {{ .existingSecret | quote }} key: s3-secret-access-key {{- end }} - {{- if .s3Region }} + {{- if .region }} - name: S3_REGION - value: {{ .s3Region | quote }} + value: {{ .region | quote }} {{- end }} - {{- if .s3LogsBucket }} + {{- if .logsBucket }} - name: S3_LOGS_BUCKET - value: {{ .s3LogsBucket | quote }} + value: {{ .logsBucket | quote }} {{- end }} - {{- if .s3CacheBucket }} + {{- if .cacheBucket }} - name: S3_CACHE_BUCKET - value: {{ .s3CacheBucket | quote }} - {{- end }} - {{- if .builderImage }} - - name: CFWORKERS_BUILDER_IMAGE - value: {{ .builderImage | quote }} + value: {{ .cacheBucket | quote }} {{- end }} {{- end }} {{- end }}` diff --git a/values-local.yaml b/values-local.yaml deleted file mode 100644 index 66724fc..0000000 --- a/values-local.yaml +++ /dev/null @@ -1,34 +0,0 @@ -image: - repository: operator - tag: local - pullPolicy: Never - -replicaCount: 1 -certManager: - enabled: true -webhook: - enabled: false -leaderElection: - enabled: false - -github: - existingSecret: deco-operator-github-token - existingSecretKey: token - -valkey: - sentinelUrls: "" - -cfworkers: - existingSecret: deco-operator-cfworkers - s3Region: sa-east-1 - s3LogsBucket: "" # set via S3_LOGS_BUCKET env or here - s3CacheBucket: "" # set via S3_CACHE_BUCKET env or here - builderImage: "" # set via CFWORKERS_BUILDER_IMAGE env or here - -resources: - requests: - cpu: 100m - memory: 128Mi - limits: - cpu: 500m - memory: 512Mi From 1d66d7e26f74da16975107b1dcbdda6afcdc042d Mon Sep 17 00:00:00 2001 From: igoramf Date: Wed, 6 May 2026 12:13:10 -0300 Subject: [PATCH 32/42] refactor(build): rename Registry to BuilderRegistry for clarity --- cmd/main.go | 2 +- internal/build/registry.go | 14 +++++++------- internal/build/registry_test.go | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index e4c2f44..5661e13 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -329,7 +329,7 @@ func main() { os.Exit(1) } } - registry := build.NewRegistry() + registry := build.NewBuilderRegistry() registry.Register("cloudflare-worker", build.NewCloudflareFactory(build.CfWorkersConfigFromEnv())) if err := (&controller.DecoReconciler{ diff --git a/internal/build/registry.go b/internal/build/registry.go index 528a2cd..d3f19f4 100644 --- a/internal/build/registry.go +++ b/internal/build/registry.go @@ -17,21 +17,21 @@ type Builder interface { NewJob(ctx context.Context, deco *decositesv1alpha1.Deco, jobName string, source decositesv1alpha1.DecoSpecBuildSource) (*batchv1.Job, error) } -// Registry dispatches to the correct Builder by spec.serving.type. -// Registry itself satisfies Builder. -type Registry struct { +// BuilderRegistry dispatches to the correct Builder by spec.serving.type. +// BuilderRegistry itself satisfies Builder. +type BuilderRegistry struct { platforms map[string]Builder } -func NewRegistry() *Registry { - return &Registry{platforms: map[string]Builder{}} +func NewBuilderRegistry() *BuilderRegistry { + return &BuilderRegistry{platforms: map[string]Builder{}} } -func (r *Registry) Register(servingType string, b Builder) { +func (r *BuilderRegistry) Register(servingType string, b Builder) { r.platforms[servingType] = b } -func (r *Registry) NewJob(ctx context.Context, deco *decositesv1alpha1.Deco, jobName string, source decositesv1alpha1.DecoSpecBuildSource) (*batchv1.Job, error) { +func (r *BuilderRegistry) NewJob(ctx context.Context, deco *decositesv1alpha1.Deco, jobName string, source decositesv1alpha1.DecoSpecBuildSource) (*batchv1.Job, error) { b, ok := r.platforms[deco.Spec.Serving.Type] if !ok { return nil, fmt.Errorf("%w %q", errNoFactory, deco.Spec.Serving.Type) diff --git a/internal/build/registry_test.go b/internal/build/registry_test.go index 92ee5d8..14928e4 100644 --- a/internal/build/registry_test.go +++ b/internal/build/registry_test.go @@ -33,7 +33,7 @@ func testDeco(servingType string) *decositesv1alpha1.Deco { func TestRegistry_DispatchesToRegisteredBuilder(t *testing.T) { want := &batchv1.Job{ObjectMeta: metav1.ObjectMeta{Name: "build-abc"}} - r := NewRegistry() + r := NewBuilderRegistry() r.Register("cloudflare-worker", &stubBuilder{job: want}) got, err := r.NewJob(context.Background(), testDeco("cloudflare-worker"), "build-abc", decositesv1alpha1.DecoSpecBuildSource{CommitSha: "abc"}) @@ -46,7 +46,7 @@ func TestRegistry_DispatchesToRegisteredBuilder(t *testing.T) { } func TestRegistry_ErrorsOnUnknownServingType(t *testing.T) { - r := NewRegistry() + r := NewBuilderRegistry() _, err := r.NewJob(context.Background(), testDeco("unknown"), "job", decositesv1alpha1.DecoSpecBuildSource{}) if err == nil { t.Fatal("expected error for unregistered serving type") From 81bdc4a358403a5afd82aa0e5ade100b31640b17 Mon Sep 17 00:00:00 2001 From: igoramf Date: Wed, 6 May 2026 12:19:26 -0300 Subject: [PATCH 33/42] fix(build): update compile-time check to BuilderRegistry after rename --- internal/build/registry_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/build/registry_test.go b/internal/build/registry_test.go index 14928e4..4268a59 100644 --- a/internal/build/registry_test.go +++ b/internal/build/registry_test.go @@ -11,8 +11,8 @@ import ( decositesv1alpha1 "github.com/deco-sites/decofile-operator/api/v1alpha1" ) -// Compile-time: Registry must satisfy Builder. -var _ Builder = (*Registry)(nil) +// Compile-time: BuilderRegistry must satisfy Builder. +var _ Builder = (*BuilderRegistry)(nil) type stubBuilder struct{ job *batchv1.Job } From 441f5a0731dd32f55becd8a8f3b074b45fbd5a6a Mon Sep 17 00:00:00 2001 From: igoramf Date: Wed, 6 May 2026 18:15:22 -0300 Subject: [PATCH 34/42] feat(crd): add envs and secrets fields to DecoSpecBuild for custom Job env injection --- api/v1alpha1/deco_types.go | 29 ++++++++++++ api/v1alpha1/zz_generated.deepcopy.go | 47 +++++++++++++++++++ ...sourcedefinition-decofiles.deco.sites.yaml | 2 +- ...omresourcedefinition-decos.deco.sites.yaml | 39 ++++++++++++++- config/crd/bases/deco.sites_decofiles.yaml | 2 +- config/crd/bases/deco.sites_decos.yaml | 39 ++++++++++++++- internal/build/cfworkers.go | 33 +++++++++++-- 7 files changed, 184 insertions(+), 7 deletions(-) diff --git a/api/v1alpha1/deco_types.go b/api/v1alpha1/deco_types.go index 3603386..388c6f7 100644 --- a/api/v1alpha1/deco_types.go +++ b/api/v1alpha1/deco_types.go @@ -34,6 +34,26 @@ type DecoSpec struct { Previews *DecoPreviewPolicy `json:"previews,omitempty"` } +// DecoEnvVar is a plain environment variable injected into the build Job. +type DecoEnvVar struct { + // Name is the environment variable name. + // +kubebuilder:validation:Required + Name string `json:"name"` + // Value is the literal value. + // +optional + Value string `json:"value,omitempty"` +} + +// DecoSecretRef mounts all keys of a K8s Secret as environment variables in the build Job. +type DecoSecretRef struct { + // Name is the name of the K8s Secret in the same namespace as the Job. + // +kubebuilder:validation:Required + Name string `json:"name"` + // Optional specifies whether the Secret must exist. Defaults to false. + // +optional + Optional *bool `json:"optional,omitempty"` +} + // DecoSpecBuild describes the build pipeline for a workload. type DecoSpecBuild struct { // Type is the build mechanism. Currently only k8s-job is supported. @@ -47,6 +67,15 @@ type DecoSpecBuild struct { // Builder overrides the builder image (repository:tag). // +optional Builder string `json:"builder,omitempty"` + + // Envs are additional plain environment variables injected into the build Job. + // +optional + Envs []DecoEnvVar `json:"envs,omitempty"` + + // Secrets are K8s Secrets whose keys are mounted as environment variables in the build Job. + // The secrets must exist in the same namespace as the Job (the site namespace). + // +optional + Secrets []DecoSecretRef `json:"secrets,omitempty"` } // DecoSpecBuildSource identifies the code revision to build. diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 86cbc71..732a105 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -172,10 +172,57 @@ func (in *DecoSpec) DeepCopy() *DecoSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DecoEnvVar) DeepCopyInto(out *DecoEnvVar) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DecoEnvVar. +func (in *DecoEnvVar) DeepCopy() *DecoEnvVar { + if in == nil { + return nil + } + out := new(DecoEnvVar) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DecoSecretRef) DeepCopyInto(out *DecoSecretRef) { + *out = *in + if in.Optional != nil { + in, out := &in.Optional, &out.Optional + *out = new(bool) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DecoSecretRef. +func (in *DecoSecretRef) DeepCopy() *DecoSecretRef { + if in == nil { + return nil + } + out := new(DecoSecretRef) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *DecoSpecBuild) DeepCopyInto(out *DecoSpecBuild) { *out = *in out.Source = in.Source + if in.Envs != nil { + in, out := &in.Envs, &out.Envs + *out = make([]DecoEnvVar, len(*in)) + copy(*out, *in) + } + if in.Secrets != nil { + in, out := &in.Secrets, &out.Secrets + *out = make([]DecoSecretRef, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DecoSpecBuild. diff --git a/chart/templates/customresourcedefinition-decofiles.deco.sites.yaml b/chart/templates/customresourcedefinition-decofiles.deco.sites.yaml index 1e6f7d1..7d17a2d 100644 --- a/chart/templates/customresourcedefinition-decofiles.deco.sites.yaml +++ b/chart/templates/customresourcedefinition-decofiles.deco.sites.yaml @@ -2,7 +2,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.18.0 + controller-gen.kubebuilder.io/version: v0.21.0 name: decofiles.deco.sites spec: group: deco.sites diff --git a/chart/templates/customresourcedefinition-decos.deco.sites.yaml b/chart/templates/customresourcedefinition-decos.deco.sites.yaml index 8902a2f..ece20b9 100644 --- a/chart/templates/customresourcedefinition-decos.deco.sites.yaml +++ b/chart/templates/customresourcedefinition-decos.deco.sites.yaml @@ -2,7 +2,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.18.0 + controller-gen.kubebuilder.io/version: v0.21.0 name: decos.deco.sites spec: group: deco.sites @@ -60,6 +60,43 @@ spec: builder: description: Builder overrides the builder image (repository:tag). type: string + envs: + description: Envs are additional plain environment variables injected + into the build Job. + items: + description: DecoEnvVar is a plain environment variable injected + into the build Job. + properties: + name: + description: Name is the environment variable name. + type: string + value: + description: Value is the literal value. + type: string + required: + - name + type: object + type: array + secrets: + description: |- + Secrets are K8s Secrets whose keys are mounted as environment variables in the build Job. + The secrets must exist in the same namespace as the Job (the site namespace). + items: + description: DecoSecretRef mounts all keys of a K8s Secret as + environment variables in the build Job. + properties: + name: + description: Name is the name of the K8s Secret in the same + namespace as the Job. + type: string + optional: + description: Optional specifies whether the Secret must + exist. Defaults to false. + type: boolean + required: + - name + type: object + type: array source: description: |- Source identifies the code revision to build. diff --git a/config/crd/bases/deco.sites_decofiles.yaml b/config/crd/bases/deco.sites_decofiles.yaml index 4ae0913..26d52ea 100644 --- a/config/crd/bases/deco.sites_decofiles.yaml +++ b/config/crd/bases/deco.sites_decofiles.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.18.0 + controller-gen.kubebuilder.io/version: v0.21.0 name: decofiles.deco.sites spec: group: deco.sites diff --git a/config/crd/bases/deco.sites_decos.yaml b/config/crd/bases/deco.sites_decos.yaml index b9dc120..d673001 100644 --- a/config/crd/bases/deco.sites_decos.yaml +++ b/config/crd/bases/deco.sites_decos.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.18.0 + controller-gen.kubebuilder.io/version: v0.21.0 name: decos.deco.sites spec: group: deco.sites @@ -61,6 +61,43 @@ spec: builder: description: Builder overrides the builder image (repository:tag). type: string + envs: + description: Envs are additional plain environment variables injected + into the build Job. + items: + description: DecoEnvVar is a plain environment variable injected + into the build Job. + properties: + name: + description: Name is the environment variable name. + type: string + value: + description: Value is the literal value. + type: string + required: + - name + type: object + type: array + secrets: + description: |- + Secrets are K8s Secrets whose keys are mounted as environment variables in the build Job. + The secrets must exist in the same namespace as the Job (the site namespace). + items: + description: DecoSecretRef mounts all keys of a K8s Secret as + environment variables in the build Job. + properties: + name: + description: Name is the name of the K8s Secret in the same + namespace as the Job. + type: string + optional: + description: Optional specifies whether the Secret must + exist. Defaults to false. + type: boolean + required: + - name + type: object + type: array source: description: |- Source identifies the code revision to build. diff --git a/internal/build/cfworkers.go b/internal/build/cfworkers.go index e6e29b8..935852f 100644 --- a/internal/build/cfworkers.go +++ b/internal/build/cfworkers.go @@ -42,6 +42,10 @@ type cfWorkersJobOpts struct { BuilderImage string // TTLSeconds controls how long the Job is kept after completion. TTLSeconds int32 + // ExtraEnvs are additional plain env vars from spec.build.envs. + ExtraEnvs []decositesv1alpha1.DecoEnvVar + // ExtraSecrets are K8s Secrets to mount as env vars from spec.build.secrets. + ExtraSecrets []decositesv1alpha1.DecoSecretRef } // newCfWorkersJob builds the batchv1.Job spec for a cfworkers build. @@ -86,6 +90,19 @@ func newCfWorkersJob(opts cfWorkersJobOpts) *batchv1.Job { if opts.GithubToken != "" { env = append(env, corev1.EnvVar{Name: "GITHUB_TOKEN", Value: opts.GithubToken}) } + for _, e := range opts.ExtraEnvs { + env = append(env, corev1.EnvVar{Name: e.Name, Value: e.Value}) + } + + var envFrom []corev1.EnvFromSource + for _, s := range opts.ExtraSecrets { + envFrom = append(envFrom, corev1.EnvFromSource{ + SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: s.Name}, + Optional: s.Optional, + }, + }) + } backoffLimit := int32(0) ttl := opts.TTLSeconds @@ -108,9 +125,10 @@ func newCfWorkersJob(opts cfWorkersJobOpts) *batchv1.Job { RestartPolicy: corev1.RestartPolicyNever, Containers: []corev1.Container{ { - Name: "builder", - Image: builderImage, - Env: env, + Name: "builder", + Image: builderImage, + Env: env, + EnvFrom: envFrom, Resources: corev1.ResourceRequirements{ Requests: corev1.ResourceList{ corev1.ResourceMemory: resource.MustParse("1Gi"), @@ -173,6 +191,13 @@ func (b *cfWorkersBuilder) NewJob(ctx context.Context, deco *decositesv1alpha1.D if err != nil { return nil, fmt.Errorf("generating presigned URLs: %w", err) } + var extraEnvs []decositesv1alpha1.DecoEnvVar + var extraSecrets []decositesv1alpha1.DecoSecretRef + if deco.Spec.Build != nil { + extraEnvs = deco.Spec.Build.Envs + extraSecrets = deco.Spec.Build.Secrets + } + return newCfWorkersJob(cfWorkersJobOpts{ Deco: deco, JobName: jobName, @@ -183,5 +208,7 @@ func (b *cfWorkersBuilder) NewJob(ctx context.Context, deco *decositesv1alpha1.D SourceOverride: &source, BuilderImage: b.cfg.BuilderImage, TTLSeconds: b.cfg.TTLSeconds, + ExtraEnvs: extraEnvs, + ExtraSecrets: extraSecrets, }), nil } From f0754a237dcd7e06f21f6c0934e3867a1001eb06 Mon Sep 17 00:00:00 2001 From: igoramf Date: Wed, 6 May 2026 18:41:36 -0300 Subject: [PATCH 35/42] refactor(build): read envs/secrets directly from Deco spec in newCfWorkersJob --- internal/build/cfworkers.go | 35 +++++++++++++---------------------- 1 file changed, 13 insertions(+), 22 deletions(-) diff --git a/internal/build/cfworkers.go b/internal/build/cfworkers.go index 935852f..81131b7 100644 --- a/internal/build/cfworkers.go +++ b/internal/build/cfworkers.go @@ -42,10 +42,6 @@ type cfWorkersJobOpts struct { BuilderImage string // TTLSeconds controls how long the Job is kept after completion. TTLSeconds int32 - // ExtraEnvs are additional plain env vars from spec.build.envs. - ExtraEnvs []decositesv1alpha1.DecoEnvVar - // ExtraSecrets are K8s Secrets to mount as env vars from spec.build.secrets. - ExtraSecrets []decositesv1alpha1.DecoSecretRef } // newCfWorkersJob builds the batchv1.Job spec for a cfworkers build. @@ -90,18 +86,22 @@ func newCfWorkersJob(opts cfWorkersJobOpts) *batchv1.Job { if opts.GithubToken != "" { env = append(env, corev1.EnvVar{Name: "GITHUB_TOKEN", Value: opts.GithubToken}) } - for _, e := range opts.ExtraEnvs { - env = append(env, corev1.EnvVar{Name: e.Name, Value: e.Value}) + if opts.Deco.Spec.Build != nil { + for _, e := range opts.Deco.Spec.Build.Envs { + env = append(env, corev1.EnvVar{Name: e.Name, Value: e.Value}) + } } var envFrom []corev1.EnvFromSource - for _, s := range opts.ExtraSecrets { - envFrom = append(envFrom, corev1.EnvFromSource{ - SecretRef: &corev1.SecretEnvSource{ - LocalObjectReference: corev1.LocalObjectReference{Name: s.Name}, - Optional: s.Optional, - }, - }) + if opts.Deco.Spec.Build != nil { + for _, s := range opts.Deco.Spec.Build.Secrets { + envFrom = append(envFrom, corev1.EnvFromSource{ + SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: s.Name}, + Optional: s.Optional, + }, + }) + } } backoffLimit := int32(0) @@ -191,13 +191,6 @@ func (b *cfWorkersBuilder) NewJob(ctx context.Context, deco *decositesv1alpha1.D if err != nil { return nil, fmt.Errorf("generating presigned URLs: %w", err) } - var extraEnvs []decositesv1alpha1.DecoEnvVar - var extraSecrets []decositesv1alpha1.DecoSecretRef - if deco.Spec.Build != nil { - extraEnvs = deco.Spec.Build.Envs - extraSecrets = deco.Spec.Build.Secrets - } - return newCfWorkersJob(cfWorkersJobOpts{ Deco: deco, JobName: jobName, @@ -208,7 +201,5 @@ func (b *cfWorkersBuilder) NewJob(ctx context.Context, deco *decositesv1alpha1.D SourceOverride: &source, BuilderImage: b.cfg.BuilderImage, TTLSeconds: b.cfg.TTLSeconds, - ExtraEnvs: extraEnvs, - ExtraSecrets: extraSecrets, }), nil } From 4e97e156f9e03789b8ec4364d26ba5719a22bfb1 Mon Sep 17 00:00:00 2001 From: igoramf Date: Wed, 6 May 2026 18:43:49 -0300 Subject: [PATCH 36/42] fix(build): prealloc envFrom, make TTLSecondsAfterFinished configurable via CRD (default 10m) --- api/v1alpha1/deco_types.go | 6 ++++++ api/v1alpha1/zz_generated.deepcopy.go | 5 +++++ .../customresourcedefinition-decos.deco.sites.yaml | 7 +++++++ config/crd/bases/deco.sites_decos.yaml | 7 +++++++ internal/build/cfworkers.go | 8 ++++++-- 5 files changed, 31 insertions(+), 2 deletions(-) diff --git a/api/v1alpha1/deco_types.go b/api/v1alpha1/deco_types.go index 388c6f7..d9865a7 100644 --- a/api/v1alpha1/deco_types.go +++ b/api/v1alpha1/deco_types.go @@ -76,6 +76,12 @@ type DecoSpecBuild struct { // The secrets must exist in the same namespace as the Job (the site namespace). // +optional Secrets []DecoSecretRef `json:"secrets,omitempty"` + + // TTLSecondsAfterFinished controls how long the Job is kept after completion. + // Defaults to 600 (10 minutes) when not set. + // +optional + // +kubebuilder:validation:Minimum=0 + TTLSecondsAfterFinished *int32 `json:"ttlSecondsAfterFinished,omitempty"` } // DecoSpecBuildSource identifies the code revision to build. diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 732a105..a0d6a79 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -223,6 +223,11 @@ func (in *DecoSpecBuild) DeepCopyInto(out *DecoSpecBuild) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.TTLSecondsAfterFinished != nil { + in, out := &in.TTLSecondsAfterFinished, &out.TTLSecondsAfterFinished + *out = new(int32) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DecoSpecBuild. diff --git a/chart/templates/customresourcedefinition-decos.deco.sites.yaml b/chart/templates/customresourcedefinition-decos.deco.sites.yaml index ece20b9..ce9e19d 100644 --- a/chart/templates/customresourcedefinition-decos.deco.sites.yaml +++ b/chart/templates/customresourcedefinition-decos.deco.sites.yaml @@ -118,6 +118,13 @@ spec: required: - commitSha type: object + ttlSecondsAfterFinished: + description: |- + TTLSecondsAfterFinished controls how long the Job is kept after completion. + Defaults to 600 (10 minutes) when not set. + format: int32 + minimum: 0 + type: integer type: description: Type is the build mechanism. Currently only k8s-job is supported. diff --git a/config/crd/bases/deco.sites_decos.yaml b/config/crd/bases/deco.sites_decos.yaml index d673001..14079c0 100644 --- a/config/crd/bases/deco.sites_decos.yaml +++ b/config/crd/bases/deco.sites_decos.yaml @@ -119,6 +119,13 @@ spec: required: - commitSha type: object + ttlSecondsAfterFinished: + description: |- + TTLSecondsAfterFinished controls how long the Job is kept after completion. + Defaults to 600 (10 minutes) when not set. + format: int32 + minimum: 0 + type: integer type: description: Type is the build mechanism. Currently only k8s-job is supported. diff --git a/internal/build/cfworkers.go b/internal/build/cfworkers.go index 81131b7..736fa8a 100644 --- a/internal/build/cfworkers.go +++ b/internal/build/cfworkers.go @@ -93,7 +93,8 @@ func newCfWorkersJob(opts cfWorkersJobOpts) *batchv1.Job { } var envFrom []corev1.EnvFromSource - if opts.Deco.Spec.Build != nil { + if opts.Deco.Spec.Build != nil && len(opts.Deco.Spec.Build.Secrets) > 0 { + envFrom = make([]corev1.EnvFromSource, 0, len(opts.Deco.Spec.Build.Secrets)) for _, s := range opts.Deco.Spec.Build.Secrets { envFrom = append(envFrom, corev1.EnvFromSource{ SecretRef: &corev1.SecretEnvSource{ @@ -106,6 +107,9 @@ func newCfWorkersJob(opts cfWorkersJobOpts) *batchv1.Job { backoffLimit := int32(0) ttl := opts.TTLSeconds + if spec.Build != nil && spec.Build.TTLSecondsAfterFinished != nil { + ttl = *spec.Build.TTLSecondsAfterFinished + } return &batchv1.Job{ ObjectMeta: metav1.ObjectMeta{ @@ -165,7 +169,7 @@ func CfWorkersConfigFromEnv() CfWorkersConfig { CfAccountId: os.Getenv("CLOUDFLARE_ACCOUNT_ID"), GithubToken: os.Getenv("GITHUB_TOKEN"), BuilderImage: os.Getenv("CFWORKERS_BUILDER_IMAGE"), - TTLSeconds: 24 * 60 * 60, + TTLSeconds: 10 * 60, S3: S3Config{ Region: os.Getenv("S3_REGION"), AccessKeyID: os.Getenv("S3_ACCESS_KEY_ID"), From 16e579ec77f2f2dd51d809d6cd8cd856f61c7c45 Mon Sep 17 00:00:00 2001 From: igoramf Date: Wed, 6 May 2026 23:45:22 -0300 Subject: [PATCH 37/42] =?UTF-8?q?fix(build):=20fix=20prealloc=20lint=20?= =?UTF-8?q?=E2=80=94=20use=20make+index=20instead=20of=20make+append=20for?= =?UTF-8?q?=20envFrom?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/build/cfworkers.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/internal/build/cfworkers.go b/internal/build/cfworkers.go index 736fa8a..4159d49 100644 --- a/internal/build/cfworkers.go +++ b/internal/build/cfworkers.go @@ -93,15 +93,16 @@ func newCfWorkersJob(opts cfWorkersJobOpts) *batchv1.Job { } var envFrom []corev1.EnvFromSource - if opts.Deco.Spec.Build != nil && len(opts.Deco.Spec.Build.Secrets) > 0 { - envFrom = make([]corev1.EnvFromSource, 0, len(opts.Deco.Spec.Build.Secrets)) - for _, s := range opts.Deco.Spec.Build.Secrets { - envFrom = append(envFrom, corev1.EnvFromSource{ + if opts.Deco.Spec.Build != nil { + secrets := opts.Deco.Spec.Build.Secrets + envFrom = make([]corev1.EnvFromSource, len(secrets)) + for i, s := range secrets { + envFrom[i] = corev1.EnvFromSource{ SecretRef: &corev1.SecretEnvSource{ LocalObjectReference: corev1.LocalObjectReference{Name: s.Name}, Optional: s.Optional, }, - }) + } } } From 32b1896781772707f51cc46600c80d34f7da6557 Mon Sep 17 00:00:00 2001 From: igoramf Date: Thu, 7 May 2026 09:49:18 -0300 Subject: [PATCH 38/42] =?UTF-8?q?refactor(s3):=20rename=20cacheBucket=20to?= =?UTF-8?q?=20artifactsBucket=20=E2=80=94=20build=20artifacts=20not=20just?= =?UTF-8?q?=20npm=20cache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../templates/deployment-operator-controller-manager.yaml | 8 ++++---- chart/values.yaml | 2 +- hack/helm-generator/main.go | 8 ++++---- internal/build/cfworkers.go | 2 +- internal/build/s3presign.go | 6 +++--- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/chart/templates/deployment-operator-controller-manager.yaml b/chart/templates/deployment-operator-controller-manager.yaml index 606a54d..6250eb7 100644 --- a/chart/templates/deployment-operator-controller-manager.yaml +++ b/chart/templates/deployment-operator-controller-manager.yaml @@ -31,7 +31,7 @@ spec: command: - /manager image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" - {{- 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.s3.existingSecret .Values.s3.region .Values.s3.logsBucket .Values.s3.cacheBucket }} + {{- 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.s3.existingSecret .Values.s3.region .Values.s3.logsBucket .Values.s3.artifactsBucket }} env: {{- if and .Values.github .Values.github.existingSecret }} - name: GITHUB_TOKEN @@ -100,9 +100,9 @@ spec: - name: S3_LOGS_BUCKET value: {{ .logsBucket | quote }} {{- end }} - {{- if .cacheBucket }} - - name: S3_CACHE_BUCKET - value: {{ .cacheBucket | quote }} + {{- if .artifactsBucket }} + - name: S3_ARTIFACTS_BUCKET + value: {{ .artifactsBucket | quote }} {{- end }} {{- end }} {{- end }} diff --git a/chart/values.yaml b/chart/values.yaml index 27861cf..fe995ce 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -105,7 +105,7 @@ s3: existingSecret: "" # Secret with s3-access-key-id, s3-secret-access-key region: "" # AWS region (e.g. us-east-1) logsBucket: "" # S3 bucket for build logs - cacheBucket: "" # S3 bucket for npm cache + artifactsBucket: "" # S3 bucket for build artifacts # Name overrides nameOverride: "" diff --git a/hack/helm-generator/main.go b/hack/helm-generator/main.go index 7511c24..6d69728 100644 --- a/hack/helm-generator/main.go +++ b/hack/helm-generator/main.go @@ -188,7 +188,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.s3.existingSecret .Values.s3.region .Values.s3.logsBucket .Values.s3.cacheBucket }} + 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.s3.existingSecret .Values.s3.region .Values.s3.logsBucket .Values.s3.artifactsBucket }} env: {{- if and .Values.github .Values.github.existingSecret }} - name: GITHUB_TOKEN @@ -257,9 +257,9 @@ func addEnvVarsToDeployment(templatesDir string) error { - name: S3_LOGS_BUCKET value: {{ .logsBucket | quote }} {{- end }} - {{- if .cacheBucket }} - - name: S3_CACHE_BUCKET - value: {{ .cacheBucket | quote }} + {{- if .artifactsBucket }} + - name: S3_ARTIFACTS_BUCKET + value: {{ .artifactsBucket | quote }} {{- end }} {{- end }} {{- end }}` diff --git a/internal/build/cfworkers.go b/internal/build/cfworkers.go index 4159d49..636a8b4 100644 --- a/internal/build/cfworkers.go +++ b/internal/build/cfworkers.go @@ -176,7 +176,7 @@ func CfWorkersConfigFromEnv() CfWorkersConfig { AccessKeyID: os.Getenv("S3_ACCESS_KEY_ID"), SecretAccessKey: os.Getenv("S3_SECRET_ACCESS_KEY"), LogsBucket: os.Getenv("S3_LOGS_BUCKET"), - CacheBucket: os.Getenv("S3_CACHE_BUCKET"), + ArtifactsBucket: os.Getenv("S3_ARTIFACTS_BUCKET"), }, } } diff --git a/internal/build/s3presign.go b/internal/build/s3presign.go index 9f8deee..4d83c8d 100644 --- a/internal/build/s3presign.go +++ b/internal/build/s3presign.go @@ -19,7 +19,7 @@ type S3Config struct { AccessKeyID string SecretAccessKey string LogsBucket string // bucket for build logs - CacheBucket string // bucket for npm cache + ArtifactsBucket string // bucket for build artifacts } // GeneratepresignedURLs generates all presigned URLs the build job needs. @@ -48,7 +48,7 @@ func generatePresignedURLs(ctx context.Context, cfg S3Config, site, jobName stri cacheKey := fmt.Sprintf("%s/npm-cache.tar.zst", site) cacheDownload, err := presigner.PresignGetObject(ctx, &s3.GetObjectInput{ - Bucket: aws.String(cfg.CacheBucket), + Bucket: aws.String(cfg.ArtifactsBucket), Key: aws.String(cacheKey), }, s3.WithPresignExpires(presignExpiry)) if err != nil { @@ -56,7 +56,7 @@ func generatePresignedURLs(ctx context.Context, cfg S3Config, site, jobName stri } cacheUpload, err := presigner.PresignPutObject(ctx, &s3.PutObjectInput{ - Bucket: aws.String(cfg.CacheBucket), + Bucket: aws.String(cfg.ArtifactsBucket), Key: aws.String(cacheKey), }, s3.WithPresignExpires(presignExpiry)) if err != nil { From 7ba57fbb95f8dfb67542b9bfbaa5a46565307cd9 Mon Sep 17 00:00:00 2001 From: igoramf Date: Thu, 7 May 2026 09:55:47 -0300 Subject: [PATCH 39/42] chore: remove values-local.yaml from .gitignore --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index 770db48..9753218 100644 --- a/.gitignore +++ b/.gitignore @@ -28,4 +28,3 @@ go.work *.swp *.swo *~ -values-local.yaml From 01b900d20b2ebc220131e4e2c484e862d6bed3ca Mon Sep 17 00:00:00 2001 From: igoramf Date: Thu, 7 May 2026 10:00:34 -0300 Subject: [PATCH 40/42] refactor: move artifactsBucket from s3 to cfworkers in chart values artifactsBucket is platform-specific (Cloudflare Workers), not shared S3 infrastructure, so it belongs under the cfworkers section. Co-Authored-By: Claude Sonnet 4.6 --- .../deployment-operator-controller-manager.yaml | 10 +++++----- chart/values.yaml | 2 +- hack/helm-generator/main.go | 10 +++++----- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/chart/templates/deployment-operator-controller-manager.yaml b/chart/templates/deployment-operator-controller-manager.yaml index 6250eb7..72eeadf 100644 --- a/chart/templates/deployment-operator-controller-manager.yaml +++ b/chart/templates/deployment-operator-controller-manager.yaml @@ -31,7 +31,7 @@ spec: command: - /manager image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" - {{- 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.s3.existingSecret .Values.s3.region .Values.s3.logsBucket .Values.s3.artifactsBucket }} + {{- 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.existingSecret .Values.s3.region .Values.s3.logsBucket }} env: {{- if and .Values.github .Values.github.existingSecret }} - name: GITHUB_TOKEN @@ -78,6 +78,10 @@ spec: - name: CFWORKERS_BUILDER_IMAGE value: {{ .builderImage | quote }} {{- end }} + {{- if .artifactsBucket }} + - name: S3_ARTIFACTS_BUCKET + value: {{ .artifactsBucket | quote }} + {{- end }} {{- end }} {{- with .Values.s3 }} {{- if .existingSecret }} @@ -100,10 +104,6 @@ spec: - name: S3_LOGS_BUCKET value: {{ .logsBucket | quote }} {{- end }} - {{- if .artifactsBucket }} - - name: S3_ARTIFACTS_BUCKET - value: {{ .artifactsBucket | quote }} - {{- end }} {{- end }} {{- end }} livenessProbe: diff --git a/chart/values.yaml b/chart/values.yaml index fe995ce..54f5f05 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -99,13 +99,13 @@ podLabels: {} cfworkers: existingSecret: "" # Secret with cf-api-token, cf-account-id builderImage: "" # Builder container image (repository:tag) + artifactsBucket: "" # S3 bucket for build artifacts (cfworkers-specific) # S3 credentials and config — shared across all build platforms s3: existingSecret: "" # Secret with s3-access-key-id, s3-secret-access-key region: "" # AWS region (e.g. us-east-1) logsBucket: "" # S3 bucket for build logs - artifactsBucket: "" # S3 bucket for build artifacts # Name overrides nameOverride: "" diff --git a/hack/helm-generator/main.go b/hack/helm-generator/main.go index 6d69728..7fa0078 100644 --- a/hack/helm-generator/main.go +++ b/hack/helm-generator/main.go @@ -188,7 +188,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.s3.existingSecret .Values.s3.region .Values.s3.logsBucket .Values.s3.artifactsBucket }} + 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.existingSecret .Values.s3.region .Values.s3.logsBucket }} env: {{- if and .Values.github .Values.github.existingSecret }} - name: GITHUB_TOKEN @@ -235,6 +235,10 @@ func addEnvVarsToDeployment(templatesDir string) error { - name: CFWORKERS_BUILDER_IMAGE value: {{ .builderImage | quote }} {{- end }} + {{- if .artifactsBucket }} + - name: S3_ARTIFACTS_BUCKET + value: {{ .artifactsBucket | quote }} + {{- end }} {{- end }} {{- with .Values.s3 }} {{- if .existingSecret }} @@ -257,10 +261,6 @@ func addEnvVarsToDeployment(templatesDir string) error { - name: S3_LOGS_BUCKET value: {{ .logsBucket | quote }} {{- end }} - {{- if .artifactsBucket }} - - name: S3_ARTIFACTS_BUCKET - value: {{ .artifactsBucket | quote }} - {{- end }} {{- end }} {{- end }}` From 01f1cd3c4c2a7ef920c4903f5fed555e83930388 Mon Sep 17 00:00:00 2001 From: igoramf Date: Thu, 7 May 2026 10:11:21 -0300 Subject: [PATCH 41/42] fix: use Status().Patch() to avoid optimistic concurrency conflicts on status update Co-Authored-By: Claude Sonnet 4.6 --- internal/controller/deco_controller.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/controller/deco_controller.go b/internal/controller/deco_controller.go index 7d7e8d3..4a9a200 100644 --- a/internal/controller/deco_controller.go +++ b/internal/controller/deco_controller.go @@ -49,6 +49,7 @@ func (r *DecoReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl. return ctrl.Result{}, err } + base := deco.DeepCopy() patch := deco.DeepCopy() statusChanged := false @@ -72,7 +73,7 @@ func (r *DecoReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl. } if statusChanged { - return ctrl.Result{}, r.Status().Update(ctx, patch) + return ctrl.Result{}, r.Status().Patch(ctx, patch, client.MergeFrom(base)) } return ctrl.Result{}, nil } From c0506feaba3863337bf10247e0ad0fa7dcd2da35 Mon Sep 17 00:00:00 2001 From: igoramf Date: Thu, 7 May 2026 11:17:53 -0300 Subject: [PATCH 42/42] fix: resolve helm-verify and lint CI failures - Regenerate CRDs with controller-gen v0.18.0 to match Makefile pin - Fix gofmt: remove extra alignment spaces in cfworkers.go - Add phaseRunning constant; replace all "Running" literals in deco_controller - Add condTypePodsNotified constant in decofile_controller - Add metricsNamespace/metricsSubsystemValkey constants in metrics.go - Add valkeyReservedDefault constant in namespace_controller - Exclude test files from goconst in .golangci.yml Co-Authored-By: Claude Sonnet 4.6 --- .golangci.yml | 5 +++ ...sourcedefinition-decofiles.deco.sites.yaml | 2 +- ...omresourcedefinition-decos.deco.sites.yaml | 2 +- config/crd/bases/deco.sites_decofiles.yaml | 2 +- config/crd/bases/deco.sites_decos.yaml | 2 +- internal/build/cfworkers.go | 2 +- internal/controller/deco_controller.go | 5 +-- internal/controller/decofile_controller.go | 10 +++--- internal/controller/metrics.go | 33 +++++++++++-------- internal/controller/namespace_controller.go | 13 ++++---- 10 files changed, 45 insertions(+), 31 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index e5b21b0..3a85d5a 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -22,6 +22,8 @@ linters: - unparam - unused settings: + goconst: + min-occurrences: 3 revive: rules: - name: comment-spacings @@ -36,6 +38,9 @@ linters: - dupl - lll path: internal/* + - linters: + - goconst + path: _test\.go$ paths: - third_party$ - builtin$ diff --git a/chart/templates/customresourcedefinition-decofiles.deco.sites.yaml b/chart/templates/customresourcedefinition-decofiles.deco.sites.yaml index 7d17a2d..1e6f7d1 100644 --- a/chart/templates/customresourcedefinition-decofiles.deco.sites.yaml +++ b/chart/templates/customresourcedefinition-decofiles.deco.sites.yaml @@ -2,7 +2,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.21.0 + controller-gen.kubebuilder.io/version: v0.18.0 name: decofiles.deco.sites spec: group: deco.sites diff --git a/chart/templates/customresourcedefinition-decos.deco.sites.yaml b/chart/templates/customresourcedefinition-decos.deco.sites.yaml index ce9e19d..3bb2657 100644 --- a/chart/templates/customresourcedefinition-decos.deco.sites.yaml +++ b/chart/templates/customresourcedefinition-decos.deco.sites.yaml @@ -2,7 +2,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.21.0 + controller-gen.kubebuilder.io/version: v0.18.0 name: decos.deco.sites spec: group: deco.sites diff --git a/config/crd/bases/deco.sites_decofiles.yaml b/config/crd/bases/deco.sites_decofiles.yaml index 26d52ea..4ae0913 100644 --- a/config/crd/bases/deco.sites_decofiles.yaml +++ b/config/crd/bases/deco.sites_decofiles.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.21.0 + controller-gen.kubebuilder.io/version: v0.18.0 name: decofiles.deco.sites spec: group: deco.sites diff --git a/config/crd/bases/deco.sites_decos.yaml b/config/crd/bases/deco.sites_decos.yaml index 14079c0..c84c3f4 100644 --- a/config/crd/bases/deco.sites_decos.yaml +++ b/config/crd/bases/deco.sites_decos.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.21.0 + controller-gen.kubebuilder.io/version: v0.18.0 name: decos.deco.sites spec: group: deco.sites diff --git a/internal/build/cfworkers.go b/internal/build/cfworkers.go index 636a8b4..db89b94 100644 --- a/internal/build/cfworkers.go +++ b/internal/build/cfworkers.go @@ -176,7 +176,7 @@ func CfWorkersConfigFromEnv() CfWorkersConfig { AccessKeyID: os.Getenv("S3_ACCESS_KEY_ID"), SecretAccessKey: os.Getenv("S3_SECRET_ACCESS_KEY"), LogsBucket: os.Getenv("S3_LOGS_BUCKET"), - ArtifactsBucket: os.Getenv("S3_ARTIFACTS_BUCKET"), + ArtifactsBucket: os.Getenv("S3_ARTIFACTS_BUCKET"), }, } } diff --git a/internal/controller/deco_controller.go b/internal/controller/deco_controller.go index 4a9a200..9c89ebf 100644 --- a/internal/controller/deco_controller.go +++ b/internal/controller/deco_controller.go @@ -22,6 +22,7 @@ import ( ) const ( + phaseRunning = "Running" phaseSucceeded = "Succeeded" phaseFailed = "Failed" ) @@ -101,7 +102,7 @@ func (r *DecoReconciler) reconcileProductionBuild(ctx context.Context, log logr. if patch.Status.Build == nil { patch.Status.Build = &decositesv1alpha1.DecoStatusBuild{} } - patch.Status.Build.Phase = "Running" + patch.Status.Build.Phase = phaseRunning patch.Status.Build.CommitSha = commitSha patch.Status.Build.JobName = jobName patch.Status.Build.StartTime = &now @@ -270,7 +271,7 @@ func buildPhaseFromJob(job *batchv1.Job) string { return phaseFailed } } - return "Running" + return phaseRunning } func (r *DecoReconciler) SetupWithManager(mgr ctrl.Manager) error { diff --git a/internal/controller/decofile_controller.go b/internal/controller/decofile_controller.go index 1262d05..5a54d61 100644 --- a/internal/controller/decofile_controller.go +++ b/internal/controller/decofile_controller.go @@ -36,6 +36,8 @@ import ( decositesv1alpha1 "github.com/deco-sites/decofile-operator/api/v1alpha1" ) +const condTypePodsNotified = "PodsNotified" + // DecofileReconciler reconciles a Decofile object type DecofileReconciler struct { client.Client @@ -93,7 +95,7 @@ func (r *DecofileReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c // Check if notification is in progress or failed hasIncompleteNotification := false for _, cond := range decofile.Status.Conditions { - if cond.Type == "PodsNotified" && + if cond.Type == condTypePodsNotified && (cond.Status == metav1.ConditionUnknown || cond.Status == metav1.ConditionFalse) { hasIncompleteNotification = true break @@ -249,7 +251,7 @@ func (r *DecofileReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c } inProgressCondition := metav1.Condition{ - Type: "PodsNotified", + Type: condTypePodsNotified, Status: metav1.ConditionUnknown, Reason: "NotificationInProgress", Message: fmt.Sprintf("Notifying pods for %s", updateIdentifier), @@ -326,7 +328,7 @@ func (r *DecofileReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c if podsNotified { podsNotifiedCondition = metav1.Condition{ - Type: "PodsNotified", + Type: condTypePodsNotified, Status: metav1.ConditionTrue, Reason: "NotificationSucceeded", Message: fmt.Sprintf("Successfully notified all pods for %s", updateIdentifier), @@ -334,7 +336,7 @@ func (r *DecofileReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c } } else { podsNotifiedCondition = metav1.Condition{ - Type: "PodsNotified", + Type: condTypePodsNotified, Status: metav1.ConditionFalse, Reason: "NotificationFailed", Message: fmt.Sprintf("Failed to notify pods for %s: %s", updateIdentifier, notificationError), diff --git a/internal/controller/metrics.go b/internal/controller/metrics.go index bf045ed..f9c31a2 100644 --- a/internal/controller/metrics.go +++ b/internal/controller/metrics.go @@ -21,10 +21,15 @@ import ( "sigs.k8s.io/controller-runtime/pkg/metrics" ) +const ( + metricsNamespace = "deco_operator" + metricsSubsystemValkey = "valkey" +) + var ( // cfworkersBuildDuration tracks how long each build took (seconds), labelled by site, status, and build type. cfworkersBuildDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{ - Namespace: "deco_operator", + Namespace: metricsNamespace, Subsystem: "cfworkers", Name: "build_duration_seconds", Help: "Duration of cfworkers build jobs in seconds.", @@ -33,7 +38,7 @@ var ( // cfworkersBuildTotal counts completed builds by site, status, and type. cfworkersBuildTotal = prometheus.NewCounterVec(prometheus.CounterOpts{ - Namespace: "deco_operator", + Namespace: metricsNamespace, Subsystem: "cfworkers", Name: "builds_total", Help: "Total number of cfworkers builds completed.", @@ -41,40 +46,40 @@ var ( // valkeyACLProvisioned counts successful ACL user + Secret provisioning operations. valkeyACLProvisioned = prometheus.NewCounter(prometheus.CounterOpts{ - Namespace: "deco_operator", - Subsystem: "valkey", + Namespace: metricsNamespace, + Subsystem: metricsSubsystemValkey, Name: "acl_provisioned_total", Help: "Total number of Valkey ACL users provisioned (new or re-provisioned).", }) // valkeyACLDeleted counts ACL user deletions triggered by namespace removal. valkeyACLDeleted = prometheus.NewCounter(prometheus.CounterOpts{ - Namespace: "deco_operator", - Subsystem: "valkey", + Namespace: metricsNamespace, + Subsystem: metricsSubsystemValkey, Name: "acl_deleted_total", Help: "Total number of Valkey ACL users deleted on namespace removal.", }) // valkeyACLErrors counts failures when interacting with Valkey. valkeyACLErrors = prometheus.NewCounterVec(prometheus.CounterOpts{ - Namespace: "deco_operator", - Subsystem: "valkey", + Namespace: metricsNamespace, + Subsystem: metricsSubsystemValkey, Name: "acl_errors_total", Help: "Total number of Valkey ACL operation errors by operation type.", }, []string{"operation"}) // operation: upsert | delete | check // valkeyACLSelfHealed counts how many times an ACL was re-created after Valkey restart. valkeyACLSelfHealed = prometheus.NewCounter(prometheus.CounterOpts{ - Namespace: "deco_operator", - Subsystem: "valkey", + Namespace: metricsNamespace, + Subsystem: metricsSubsystemValkey, Name: "acl_self_healed_total", Help: "Total number of Valkey ACL users re-provisioned after being lost (e.g. Valkey restart).", }) // valkeyTenantsProvisioned tracks the current number of provisioned tenants (gauge). valkeyTenantsProvisioned = prometheus.NewGauge(prometheus.GaugeOpts{ - Namespace: "deco_operator", - Subsystem: "valkey", + Namespace: metricsNamespace, + Subsystem: metricsSubsystemValkey, Name: "tenants_provisioned", Help: "Current number of site namespaces with a provisioned Valkey ACL user.", }) @@ -82,8 +87,8 @@ var ( // valkeySentinelFailovers counts Sentinel +switch-master events received. // Each event triggers an immediate full ACL resync to all nodes. valkeySentinelFailovers = prometheus.NewCounter(prometheus.CounterOpts{ - Namespace: "deco_operator", - Subsystem: "valkey", + Namespace: metricsNamespace, + Subsystem: metricsSubsystemValkey, Name: "sentinel_failovers_total", Help: "Total number of Sentinel master failovers detected via +switch-master pub/sub.", }) diff --git a/internal/controller/namespace_controller.go b/internal/controller/namespace_controller.go index 0c92014..48a52af 100644 --- a/internal/controller/namespace_controller.go +++ b/internal/controller/namespace_controller.go @@ -41,10 +41,11 @@ import ( ) const ( - valkeyACLAnnotation = "deco.sites/valkey-acl" - valkeyACLFinalizer = "deco.sites/valkey-acl" - valkeySecretName = "valkey-acl" - siteNamespacePrefix = "sites-" + valkeyACLAnnotation = "deco.sites/valkey-acl" + valkeyACLFinalizer = "deco.sites/valkey-acl" + valkeySecretName = "valkey-acl" + siteNamespacePrefix = "sites-" + valkeyReservedDefault = "default" ) // +kubebuilder:rbac:groups="",resources=namespaces,verbs=get;list;watch;update;patch @@ -357,8 +358,8 @@ func (r *NamespaceReconciler) createSecret(ctx context.Context, namespace, usern // unauthenticated connections and health probes. "redis-root" is reserved for // the dedicated operator admin user used when auth.enabled: true. var reservedValkeyUsernames = map[string]bool{ - "default": true, - "redis-root": true, + valkeyReservedDefault: true, + "redis-root": true, } // siteNameFromNamespace derives the Valkey ACL username from the K8s namespace name.