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/api/v1alpha1/deco_types.go b/api/v1alpha1/deco_types.go new file mode 100644 index 0000000..d9865a7 --- /dev/null +++ b/api/v1alpha1/deco_types.go @@ -0,0 +1,225 @@ +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"` +} + +// 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. + // +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"` + + // 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"` + + // 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. +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: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` +// +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/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 8f1e9be..a0d6a79 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -25,6 +25,301 @@ 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 *Deco) DeepCopyInto(out *Deco) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// 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(Deco) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Deco) 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 *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([]Deco, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// 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(DecoList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DecoList) 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 *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 DecoPreviewRequest. +func (in *DecoPreviewRequest) DeepCopy() *DecoPreviewRequest { + if in == nil { + return nil + } + 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 *DecoPreviewStatus) DeepCopyInto(out *DecoPreviewStatus) { + *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 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 *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]) + } + } + 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. +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 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(DecoStatusBuild) + 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..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.0 -appVersion: "0.2.0" +version: 0.2.6 +appVersion: "0.2.6" keywords: - operator - kubernetes diff --git a/chart/templates/clusterrole-operator-manager-role.yaml b/chart/templates/clusterrole-operator-manager-role.yaml index 5d32d08..79c3477 100644 --- a/chart/templates/clusterrole-operator-manager-role.yaml +++ b/chart/templates/clusterrole-operator-manager-role.yaml @@ -42,10 +42,21 @@ rules: - get - list - watch +- apiGroups: + - batch + resources: + - jobs + verbs: + - create + - delete + - get + - list + - watch - apiGroups: - deco.sites resources: - decofiles + - decos verbs: - create - delete @@ -58,12 +69,14 @@ rules: - deco.sites resources: - decofiles/finalizers + - decos/finalizers verbs: - update - apiGroups: - deco.sites resources: - decofiles/status + - decos/status verbs: - get - patch diff --git a/chart/templates/customresourcedefinition-decos.deco.sites.yaml b/chart/templates/customresourcedefinition-decos.deco.sites.yaml new file mode 100644 index 0000000..3bb2657 --- /dev/null +++ b/chart/templates/customresourcedefinition-decos.deco.sites.yaml @@ -0,0 +1,271 @@ +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: + 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: + build: + description: Build describes the production build pipeline. + properties: + 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. + 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. + type: string + production: + description: Production indicates whether this is a production + deploy. + type: boolean + 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. + 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. + Admin adds entries to Previews.Active on PR open and removes on PR close. + properties: + active: + 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: 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: PrId is the pull request ID, for tracking. + type: string + required: + - 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: + - org + - site + type: object + status: + description: DecoStatus defines the observed state of a Deco workload. + properties: + build: + 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: + description: JobName is the K8s Job name for the current build. + type: string + lastBuiltCommit: + description: LastBuiltCommit is the commit SHA of the last successful + build. + type: string + 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: + 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: + - branchRef + - commitSha + - phase + type: object + type: array + type: object + type: object + served: true + storage: true + subresources: + 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 859c1a7..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")) }} + {{- 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 @@ -61,6 +61,50 @@ 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 + {{- end }} + {{- if .builderImage }} + - name: CFWORKERS_BUILDER_IMAGE + value: {{ .builderImage | quote }} + {{- end }} + {{- if .artifactsBucket }} + - name: S3_ARTIFACTS_BUCKET + value: {{ .artifactsBucket | quote }} + {{- end }} + {{- end }} + {{- with .Values.s3 }} + {{- if .existingSecret }} + - 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 }} + {{- if .region }} + - name: S3_REGION + value: {{ .region | quote }} + {{- end }} + {{- if .logsBucket }} + - name: S3_LOGS_BUCKET + value: {{ .logsBucket | quote }} + {{- end }} + {{- end }} {{- end }} livenessProbe: httpGet: diff --git a/chart/values.yaml b/chart/values.yaml index 361dd04..54f5f05 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -95,6 +95,18 @@ podAnnotations: {} # Pod labels podLabels: {} +# Cloudflare Workers build support +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 + # Name overrides nameOverride: "" fullnameOverride: "" diff --git a/cmd/main.go b/cmd/main.go index c689cca..5661e13 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" @@ -328,6 +329,17 @@ func main() { os.Exit(1) } } + registry := build.NewBuilderRegistry() + registry.Register("cloudflare-worker", build.NewCloudflareFactory(build.CfWorkersConfigFromEnv())) + + if err := (&controller.DecoReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Builder: registry, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "Deco") + os.Exit(1) + } // +kubebuilder:scaffold:builder if metricsCertWatcher != nil { diff --git a/config/crd/bases/deco.sites_decos.yaml b/config/crd/bases/deco.sites_decos.yaml new file mode 100644 index 0000000..c84c3f4 --- /dev/null +++ b/config/crd/bases/deco.sites_decos.yaml @@ -0,0 +1,272 @@ +--- +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: + 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: + build: + description: Build describes the production build pipeline. + properties: + 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. + 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. + type: string + production: + description: Production indicates whether this is a production + deploy. + type: boolean + 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. + 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. + Admin adds entries to Previews.Active on PR open and removes on PR close. + properties: + active: + 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: 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: PrId is the pull request ID, for tracking. + type: string + required: + - 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: + - org + - site + type: object + status: + description: DecoStatus defines the observed state of a Deco workload. + properties: + build: + 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: + description: JobName is the K8s Job name for the current build. + type: string + lastBuiltCommit: + description: LastBuiltCommit is the commit SHA of the last successful + build. + type: string + 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: + 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: + - branchRef + - commitSha + - 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 f71ed0f..3e19e85 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_decos.yaml - bases/deco.sites_decofiles.yaml # +kubebuilder:scaffold:crdkustomizeresource diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 2ace323..2439d35 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -43,10 +43,21 @@ rules: - get - list - watch +- apiGroups: + - batch + resources: + - jobs + verbs: + - create + - delete + - get + - list + - watch - apiGroups: - deco.sites resources: - decofiles + - decos verbs: - create - delete @@ -59,12 +70,14 @@ rules: - deco.sites resources: - decofiles/finalizers + - decos/finalizers verbs: - update - apiGroups: - deco.sites resources: - decofiles/status + - decos/status verbs: - get - patch diff --git a/go.mod b/go.mod index 7f73982..4dbf225 100644 --- a/go.mod +++ b/go.mod @@ -4,9 +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 @@ -17,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 @@ -53,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= diff --git a/hack/helm-generator/main.go b/hack/helm-generator/main.go index 2622bfd..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")) }} + 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 @@ -218,6 +218,50 @@ 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 + {{- end }} + {{- if .builderImage }} + - name: CFWORKERS_BUILDER_IMAGE + value: {{ .builderImage | quote }} + {{- end }} + {{- if .artifactsBucket }} + - name: S3_ARTIFACTS_BUCKET + value: {{ .artifactsBucket | quote }} + {{- end }} + {{- end }} + {{- with .Values.s3 }} + {{- if .existingSecret }} + - 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 }} + {{- if .region }} + - name: S3_REGION + value: {{ .region | quote }} + {{- end }} + {{- if .logsBucket }} + - name: S3_LOGS_BUCKET + value: {{ .logsBucket | quote }} + {{- end }} + {{- end }} {{- end }}` re := regexp.MustCompile(`(?m)( image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}")`) diff --git a/internal/build/cfworkers.go b/internal/build/cfworkers.go new file mode 100644 index 0000000..db89b94 --- /dev/null +++ b/internal/build/cfworkers.go @@ -0,0 +1,210 @@ +// Package build contains helpers for creating cfworkers build Jobs. +package build + +import ( + "context" + "crypto/sha256" + "fmt" + "os" + + 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" +) + +// 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 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 { + 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 + // 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 +} + +// 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 { + src = *opts.SourceOverride + } else if spec.Build != nil { + src = spec.Build.Source + } + + owner := spec.Org + repo := spec.Site + + isProduction := "false" + if src.Production { + isProduction = "true" + } + + // CR takes precedence over the platform default. + builderImage := opts.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", 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: "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}, + } + 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}) + } + 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 + 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, + }, + } + } + } + + backoffLimit := int32(0) + ttl := opts.TTLSeconds + if spec.Build != nil && spec.Build.TTLSecondsAfterFinished != nil { + ttl = *spec.Build.TTLSecondsAfterFinished + } + + return &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: opts.JobName, + Namespace: opts.Deco.Namespace, + Labels: map[string]string{ + "app.deco/site": repo, + "app.deco/org": owner, + "app.deco/serving": spec.Serving.Type, + }, + }, + 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, + EnvFrom: envFrom, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceMemory: resource.MustParse("1Gi"), + corev1.ResourceCPU: resource.MustParse("500m"), + corev1.ResourceEphemeralStorage: resource.MustParse("2Gi"), + }, + Limits: corev1.ResourceList{ + corev1.ResourceMemory: resource.MustParse("4Gi"), + corev1.ResourceEphemeralStorage: resource.MustParse("3Gi"), + }, + }, + }, + }, + }, + }, + }, + } +} + +// 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: os.Getenv("CFWORKERS_BUILDER_IMAGE"), + TTLSeconds: 10 * 60, + S3: S3Config{ + Region: os.Getenv("S3_REGION"), + 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"), + }, + } +} + +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 +} diff --git a/internal/build/registry.go b/internal/build/registry.go new file mode 100644 index 0000000..d3f19f4 --- /dev/null +++ b/internal/build/registry.go @@ -0,0 +1,40 @@ +package build + +import ( + "context" + "errors" + "fmt" + + batchv1 "k8s.io/api/batch/v1" + + decositesv1alpha1 "github.com/deco-sites/decofile-operator/api/v1alpha1" +) + +var errNoFactory = errors.New("no builder registered for serving type") + +// 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) +} + +// BuilderRegistry dispatches to the correct Builder by spec.serving.type. +// BuilderRegistry itself satisfies Builder. +type BuilderRegistry struct { + platforms map[string]Builder +} + +func NewBuilderRegistry() *BuilderRegistry { + return &BuilderRegistry{platforms: map[string]Builder{}} +} + +func (r *BuilderRegistry) Register(servingType string, b Builder) { + r.platforms[servingType] = b +} + +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) + } + 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..4268a59 --- /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: BuilderRegistry must satisfy Builder. +var _ Builder = (*BuilderRegistry)(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 := NewBuilderRegistry() + 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 := NewBuilderRegistry() + _, 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) + } +} diff --git a/internal/build/s3presign.go b/internal/build/s3presign.go new file mode 100644 index 0000000..4d83c8d --- /dev/null +++ b/internal/build/s3presign.go @@ -0,0 +1,71 @@ +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 and bucket names used to generate presigned URLs. +type S3Config struct { + Region string + AccessKeyID string + SecretAccessKey string + LogsBucket string // bucket for build logs + ArtifactsBucket string // bucket for build artifacts +} + +// 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, + 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(cfg.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(cfg.ArtifactsBucket), + 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(cfg.ArtifactsBucket), + 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/deco_controller.go b/internal/controller/deco_controller.go new file mode 100644 index 0000000..9c89ebf --- /dev/null +++ b/internal/controller/deco_controller.go @@ -0,0 +1,284 @@ +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" +) + +const ( + phaseRunning = "Running" + phaseSucceeded = "Succeeded" + phaseFailed = "Failed" +) + +// DecoReconciler reconciles Deco objects. +type DecoReconciler struct { + client.Client + Scheme *runtime.Scheme + Builder build.Builder +} + +// +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 + } + + base := deco.DeepCopy() + 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().Patch(ctx, patch, client.MergeFrom(base)) + } + 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 = phaseRunning + 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 == phaseSucceeded || phase == phaseFailed { + 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 == phaseSucceeded { + 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 + } + + 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 == phaseSucceeded { + 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 == phaseSucceeded || phase == phaseFailed) && 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 + } + 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 { + job, err := r.Builder.NewJob(ctx, deco, jobName, source) + if err != nil { + return fmt.Errorf("building job spec: %w", err) + } + + 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 phaseSucceeded + case batchv1.JobFailed: + return phaseFailed + } + } + return phaseRunning +} + +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) +} 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 d0fdc95..f9c31a2 100644 --- a/internal/controller/metrics.go +++ b/internal/controller/metrics.go @@ -21,43 +21,65 @@ 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: metricsNamespace, + 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: metricsNamespace, + 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", - 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.", }) @@ -65,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.", }) @@ -78,8 +100,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, 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.