diff --git a/api/v1alpha1/deco_types.go b/api/v1alpha1/deco_types.go index 74a403c..1f01418 100644 --- a/api/v1alpha1/deco_types.go +++ b/api/v1alpha1/deco_types.go @@ -35,6 +35,33 @@ type DecoSpec struct { // Admin adds entries to Previews.Active on PR open and removes on PR close. // +optional Previews *DecoPreviewPolicy `json:"previews,omitempty"` + + // FastDeploy enables and configures git-driven, KV-first content fast-deploy + // for this site. When Enabled, content-only pushes (touching only + // .deco/blocks/**) sync to Cloudflare KV via a Decofile CR instead of a full + // build/deploy. This is the per-site config the webhook's DeploymentTarget + // reads to decide whether and how to fast-deploy. + // +optional + FastDeploy *DecoFastDeploy `json:"fastDeploy,omitempty"` +} + +// DecoFastDeploy configures KV-first content fast-deploy for a site. +// +kubebuilder:validation:XValidation:rule="!self.enabled || (has(self.kvNamespaceId) && size(self.kvNamespaceId) > 0)",message="kvNamespaceId is required when fastDeploy is enabled" +type DecoFastDeploy struct { + // Enabled gates the fast-deploy content path. When false, content pushes take + // the normal build/deploy path. + // +optional + Enabled bool `json:"enabled,omitempty"` + + // KVNamespaceID is the Cloudflare KV namespace id for this site (one per site). + // Required when enabled (enforced via the XValidation rule on this struct). + // +optional + KVNamespaceID string `json:"kvNamespaceId,omitempty"` + + // SiteOrigin is the deployed site origin used for cache purge after a sync + // (e.g. https://www.example.com). Optional. + // +optional + SiteOrigin string `json:"siteOrigin,omitempty"` } // DecoEnvVar is a plain environment variable injected into the build Job. diff --git a/api/v1alpha1/decofile_types.go b/api/v1alpha1/decofile_types.go index 446b9dd..5105523 100644 --- a/api/v1alpha1/decofile_types.go +++ b/api/v1alpha1/decofile_types.go @@ -24,7 +24,24 @@ import ( // EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! // NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. +// Decofile source kinds (DecofileSpec.Source). +const ( + SourceInline = "inline" + SourceGitHub = "github" +) + +// Decofile delivery targets (DecofileSpec.Target) — selects the FastDeployment +// strategy that reconciles the CR. +const ( + // TargetConfigMap writes a ConfigMap + notifies Knative pods (default). + TargetConfigMap = "configmap" + // TargetTanstackKV runs a Job that pushes the decofile to Cloudflare KV. + TargetTanstackKV = "tanstack-kv" +) + // DecofileSpec defines the desired state of Decofile. +// +kubebuilder:validation:XValidation:rule="self.target != 'tanstack-kv' || has(self.tanstackKV)",message="spec.tanstackKV is required when target is tanstack-kv" +// +kubebuilder:validation:XValidation:rule="self.target != 'tanstack-kv' || self.source == 'github'",message="source must be 'github' when target is tanstack-kv" type DecofileSpec struct { // Source specifies where to get the configuration data // +kubebuilder:validation:Required @@ -43,6 +60,33 @@ type DecofileSpec struct { // Pods are queried using the app.deco/deploymentId label // +optional DeploymentId string `json:"deploymentId,omitempty"` + + // Target selects how this Decofile is delivered (the FastDeployment strategy). + // "configmap" (default) writes a ConfigMap and notifies Knative pods. + // "tanstack-kv" runs a self-cleaning Job that pushes the decofile to Cloudflare + // KV — the fast-deploy content path for TanStack/Workers sites. + // +kubebuilder:validation:Enum=configmap;tanstack-kv + // +kubebuilder:default=configmap + // +optional + Target string `json:"target,omitempty"` + + // TanstackKV configures the tanstack-kv target. Required when target=tanstack-kv. + // The repo/commit to sync come from spec.github (source=github). + // +optional + TanstackKV *TanstackKVTarget `json:"tanstackKV,omitempty"` +} + +// TanstackKVTarget configures Cloudflare KV fast-deploy for a TanStack/Workers site. +type TanstackKVTarget struct { + // KVNamespaceID is the Cloudflare KV namespace id for this site (one per site). + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + KVNamespaceID string `json:"kvNamespaceId"` + + // SiteOrigin is the deployed site origin used to POST /_cache/purge after the + // sync (e.g. https://www.example.com). Optional — purge is skipped when empty. + // +optional + SiteOrigin string `json:"siteOrigin,omitempty"` } // InlineSource contains direct JSON configuration data @@ -98,6 +142,10 @@ type DecofileStatus struct { // GitHubCommit stores the commit SHA if using GitHub source // +optional GitHubCommit string `json:"githubCommit,omitempty"` + + // JobName is the K8s Job name for the current tanstack-kv sync (target=tanstack-kv). + // +optional + JobName string `json:"jobName,omitempty"` } // +kubebuilder:object:root=true diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index aa9b034..843496f 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -68,6 +68,21 @@ func (in *DecoEnvVar) DeepCopy() *DecoEnvVar { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DecoFastDeploy) DeepCopyInto(out *DecoFastDeploy) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DecoFastDeploy. +func (in *DecoFastDeploy) DeepCopy() *DecoFastDeploy { + if in == nil { + return nil + } + out := new(DecoFastDeploy) + in.DeepCopyInto(out) + return out +} + // 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 @@ -297,6 +312,11 @@ func (in *DecoSpec) DeepCopyInto(out *DecoSpec) { *out = new(DecoPreviewPolicy) (*in).DeepCopyInto(*out) } + if in.FastDeploy != nil { + in, out := &in.FastDeploy, &out.FastDeploy + *out = new(DecoFastDeploy) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DecoSpec. @@ -508,6 +528,11 @@ func (in *DecofileSpec) DeepCopyInto(out *DecofileSpec) { *out = new(GitHubSource) **out = **in } + if in.TanstackKV != nil { + in, out := &in.TanstackKV, &out.TanstackKV + *out = new(TanstackKVTarget) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DecofileSpec. @@ -579,3 +604,18 @@ func (in *InlineSource) DeepCopy() *InlineSource { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TanstackKVTarget) DeepCopyInto(out *TanstackKVTarget) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TanstackKVTarget. +func (in *TanstackKVTarget) DeepCopy() *TanstackKVTarget { + if in == nil { + return nil + } + out := new(TanstackKVTarget) + in.DeepCopyInto(out) + return out +} diff --git a/chart/Chart.yaml b/chart/Chart.yaml index 179721c..8c08e11 100644 --- a/chart/Chart.yaml +++ b/chart/Chart.yaml @@ -2,7 +2,7 @@ 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.6 +version: 0.3.0 appVersion: "0.2.6" keywords: - operator diff --git a/chart/templates/clusterrole-operator-manager-role.yaml b/chart/templates/clusterrole-operator-manager-role.yaml index e723350..c01f3ba 100644 --- a/chart/templates/clusterrole-operator-manager-role.yaml +++ b/chart/templates/clusterrole-operator-manager-role.yaml @@ -61,6 +61,8 @@ rules: - delete - get - list + - patch + - update - watch - apiGroups: - cert-manager.io diff --git a/chart/templates/customresourcedefinition-decofiles.deco.sites.yaml b/chart/templates/customresourcedefinition-decofiles.deco.sites.yaml index 1e6f7d1..002db38 100644 --- a/chart/templates/customresourcedefinition-decofiles.deco.sites.yaml +++ b/chart/templates/customresourcedefinition-decofiles.deco.sites.yaml @@ -89,9 +89,43 @@ spec: - inline - github type: string + tanstackKV: + description: |- + TanstackKV configures the tanstack-kv target. Required when target=tanstack-kv. + The repo/commit to sync come from spec.github (source=github). + properties: + kvNamespaceId: + description: KVNamespaceID is the Cloudflare KV namespace id for + this site (one per site). + minLength: 1 + type: string + siteOrigin: + description: |- + SiteOrigin is the deployed site origin used to POST /_cache/purge after the + sync (e.g. https://www.example.com). Optional — purge is skipped when empty. + type: string + required: + - kvNamespaceId + type: object + target: + default: configmap + description: |- + Target selects how this Decofile is delivered (the FastDeployment strategy). + "configmap" (default) writes a ConfigMap and notifies Knative pods. + "tanstack-kv" runs a self-cleaning Job that pushes the decofile to Cloudflare + KV — the fast-deploy content path for TanStack/Workers sites. + enum: + - configmap + - tanstack-kv + type: string required: - source type: object + x-kubernetes-validations: + - message: spec.tanstackKV is required when target is tanstack-kv + rule: self.target != 'tanstack-kv' || has(self.tanstackKV) + - message: source must be 'github' when target is tanstack-kv + rule: self.target != 'tanstack-kv' || self.source == 'github' status: description: DecofileStatus defines the observed state of Decofile. properties: @@ -160,6 +194,10 @@ spec: githubCommit: description: GitHubCommit stores the commit SHA if using GitHub source type: string + jobName: + description: JobName is the K8s Job name for the current tanstack-kv + sync (target=tanstack-kv). + type: string lastUpdated: description: LastUpdated is the timestamp of the last update format: date-time diff --git a/chart/templates/customresourcedefinition-decos.deco.sites.yaml b/chart/templates/customresourcedefinition-decos.deco.sites.yaml index e6e2265..f74c702 100644 --- a/chart/templates/customresourcedefinition-decos.deco.sites.yaml +++ b/chart/templates/customresourcedefinition-decos.deco.sites.yaml @@ -179,6 +179,34 @@ spec: required: - source type: object + fastDeploy: + description: |- + FastDeploy enables and configures git-driven, KV-first content fast-deploy + for this site. When Enabled, content-only pushes (touching only + .deco/blocks/**) sync to Cloudflare KV via a Decofile CR instead of a full + build/deploy. This is the per-site config the webhook's DeploymentTarget + reads to decide whether and how to fast-deploy. + properties: + enabled: + description: |- + Enabled gates the fast-deploy content path. When false, content pushes take + the normal build/deploy path. + type: boolean + kvNamespaceId: + description: |- + KVNamespaceID is the Cloudflare KV namespace id for this site (one per site). + Required when enabled (enforced via the XValidation rule on this struct). + type: string + siteOrigin: + description: |- + SiteOrigin is the deployed site origin used for cache purge after a sync + (e.g. https://www.example.com). Optional. + type: string + type: object + x-kubernetes-validations: + - message: kvNamespaceId is required when fastDeploy is enabled + rule: '!self.enabled || (has(self.kvNamespaceId) && size(self.kvNamespaceId) + > 0)' framework: description: Framework is the site framework. deno | tanstack | next | remix | static diff --git a/chart/templates/deployment-operator-controller-manager.yaml b/chart/templates/deployment-operator-controller-manager.yaml index d1e5e9d..c483a74 100644 --- a/chart/templates/deployment-operator-controller-manager.yaml +++ b/chart/templates/deployment-operator-controller-manager.yaml @@ -47,7 +47,7 @@ spec: command: - /manager image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" - {{- if or (and .Values.github (or .Values.github.token .Values.github.existingSecret)) .Values.operatorApi.existingSecret (and .Values.valkey (get .Values.valkey "sentinelUrls")) .Values.cfworkers.existingSecret .Values.cfworkers.builderImage .Values.cfworkers.artifactsBucket .Values.s3.region .Values.s3.logsBucket .Values.s3.stateBucket .Values.build.serviceAccount .Values.build.roleArn .Values.build.nodeSelector .Values.build.tolerations }} + {{- if or (and .Values.github (or .Values.github.token .Values.github.existingSecret)) .Values.operatorApi.existingSecret (and .Values.valkey (get .Values.valkey "sentinelUrls")) .Values.cfworkers.existingSecret .Values.cfworkers.builderImage .Values.cfworkers.artifactsBucket .Values.s3.region .Values.s3.logsBucket .Values.s3.stateBucket .Values.build.serviceAccount .Values.build.roleArn .Values.build.nodeSelector .Values.build.tolerations (and .Values.fastDeploy .Values.fastDeploy.syncerImage) }} env: {{- if and .Values.github .Values.github.existingSecret }} - name: GITHUB_TOKEN @@ -131,6 +131,10 @@ spec: value: {{ .tolerations | toJson | quote }} {{- end }} {{- end }} + {{- if and .Values.fastDeploy .Values.fastDeploy.syncerImage }} + - name: DECOFILE_SYNCER_IMAGE + value: {{ .Values.fastDeploy.syncerImage | quote }} + {{- end }} {{- if .Values.operatorApi.existingSecret }} {{- if .Values.operatorApi.addr }} - name: OPERATOR_API_ADDR @@ -138,12 +142,19 @@ spec: {{- end }} {{- end }} {{- end }} - {{- if .Values.operatorApi.existingSecret }} + {{- if or .Values.secretEnv.existingSecret .Values.operatorApi.existingSecret }} envFrom: + {{- if .Values.secretEnv.existingSecret }} + - secretRef: + name: {{ .Values.secretEnv.existingSecret | quote }} + optional: true + {{- end }} + {{- if .Values.operatorApi.existingSecret }} - secretRef: name: {{ .Values.operatorApi.existingSecret | quote }} optional: true {{- end }} + {{- end }} livenessProbe: httpGet: path: /healthz diff --git a/chart/values.yaml b/chart/values.yaml index e83e90f..22663be 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -123,6 +123,24 @@ build: nodeSelector: {} # Default nodeSelector for build pods (overridable per-site via spec.build.nodeSelector) tolerations: [] # Default tolerations for build pods (overridable per-site via spec.build.tolerations) +# ── Unified secret env ─────────────────────────────────────────────────────── +# A single Secret whose KEYS ARE THE ENV VAR NAMES (e.g. CLOUDFLARE_API_WORKERS_TOKEN, +# CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_KV_API_TOKEN, GITHUB_TOKEN, GITHUB_APP_ID, +# GITHUB_APP_PRIVATE_KEY, GITHUB_WEBHOOK_SECRET, DECO_PURGE_TOKEN). Injected +# wholesale via envFrom, so adding a new secret env is just a new key on the +# backing ExternalSecret — no chart change. Preferred over the per-feature +# existingSecret knobs above; those still work when set (and win, being explicit env). +secretEnv: + existingSecret: "" + +# ── Fast deploy (KV-first content) ─────────────────────────────────────────── +# Git-webhook → Decofile CR → self-cleaning KV-sync Job. The webhook/KV/GitHub-App +# secrets come from secretEnv above (GITHUB_WEBHOOK_SECRET enables the endpoint; +# the operator-api controller must also be enabled). Only the non-secret syncer +# image lives here. +fastDeploy: + syncerImage: "" # decofile-syncer image (repository:tag) → DECOFILE_SYNCER_IMAGE + # ── Operator Management API ────────────────────────────────────────────────── # General HTTP API for managing operator resources (DecoRedirects, etc.). # Enabled when username+password (or existingSecret) are set. diff --git a/cmd/main.go b/cmd/main.go index bd1e99c..5fa10db 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -50,6 +50,8 @@ import ( "github.com/deco-sites/decofile-operator/internal/api" "github.com/deco-sites/decofile-operator/internal/build" "github.com/deco-sites/decofile-operator/internal/controller" + "github.com/deco-sites/decofile-operator/internal/deploy" + "github.com/deco-sites/decofile-operator/internal/githubapp" "github.com/deco-sites/decofile-operator/internal/valkey" webhookv1 "github.com/deco-sites/decofile-operator/internal/webhook/v1" // +kubebuilder:scaffold:imports @@ -336,10 +338,30 @@ func main() { if enabled(controller.DecofileControllerName) { httpClient := controller.NewHTTPClient() + // Fast-deploy: pluggable FastDeployment strategies for non-configmap + // Decofile targets. tanstack-kv runs a self-cleaning Job that pushes the + // decofile to Cloudflare KV (config from env; inert unless a Decofile + // selects the target). + fastDeployRegistry := deploy.NewDeploymentRegistry() + tkvCfg := deploy.TanstackKVConfigFromEnv() + // Private-repo clones use a GitHub App installation token (short-lived, + // repo-scoped) — same mechanism as admin. Optional: falls back to a static + // GITHUB_TOKEN / public-repo clone when unset. + if ghApp, gerr := githubapp.NewFromEnv(); gerr != nil { + setupLog.Error(gerr, "GitHub App disabled: invalid GITHUB_APP_* config; private-repo fast-deploy will fail") + } else if ghApp != nil { + tkvCfg.GitHubApp = ghApp + setupLog.Info("GitHub App configured for fast-deploy private-repo clones") + } + fastDeployRegistry.Register( + decositesv1alpha1.TargetTanstackKV, + deploy.NewTanstackKV(tkvCfg), + ) if err = (&controller.DecofileReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), HTTPClient: httpClient, + FastDeploy: fastDeployRegistry, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "Decofile") os.Exit(1) @@ -404,13 +426,25 @@ func main() { if enabled(api.ControllerName) { apiUser := os.Getenv("OPERATOR_API_USER") apiPass := os.Getenv("OPERATOR_API_PASSWORD") - if apiUser != "" && apiPass != "" { + + // Git webhook → fast-deploy. The cloudflare-workers DeploymentTarget turns a + // content-only push into a Decofile CR. Authenticated by HMAC signature + // (GITHUB_WEBHOOK_SECRET), independent of the basic-auth redirects API. + targetRegistry := deploy.NewTargetRegistry() + targetRegistry.Register(deploy.ServingCloudflareWorker, deploy.NewCloudflareWorkersTarget()) + webhookHandlers := api.NewWebhookHandlers( + mgr.GetClient(), os.Getenv("GITHUB_WEBHOOK_SECRET"), targetRegistry, + ) + + // Start the HTTP server when EITHER the redirects API (basic-auth creds) or + // the webhook (secret) is configured. + if (apiUser != "" && apiPass != "") || webhookHandlers.Enabled() { h := api.NewHandlers(mgr.GetClient(), redirectNamespace) - if err = mgr.Add(api.NewServer(operatorAPIAddr, apiUser, apiPass, h)); err != nil { + if err = mgr.Add(api.NewServer(operatorAPIAddr, apiUser, apiPass, h, webhookHandlers)); err != nil { setupLog.Error(err, "unable to add operator API server") os.Exit(1) } - setupLog.Info("Operator API enabled", "addr", operatorAPIAddr) + setupLog.Info("Operator API enabled", "addr", operatorAPIAddr, "webhook", webhookHandlers.Enabled()) } } diff --git a/config/crd/bases/deco.sites_decofiles.yaml b/config/crd/bases/deco.sites_decofiles.yaml index 4ae0913..997a9fd 100644 --- a/config/crd/bases/deco.sites_decofiles.yaml +++ b/config/crd/bases/deco.sites_decofiles.yaml @@ -90,9 +90,43 @@ spec: - inline - github type: string + tanstackKV: + description: |- + TanstackKV configures the tanstack-kv target. Required when target=tanstack-kv. + The repo/commit to sync come from spec.github (source=github). + properties: + kvNamespaceId: + description: KVNamespaceID is the Cloudflare KV namespace id for + this site (one per site). + minLength: 1 + type: string + siteOrigin: + description: |- + SiteOrigin is the deployed site origin used to POST /_cache/purge after the + sync (e.g. https://www.example.com). Optional — purge is skipped when empty. + type: string + required: + - kvNamespaceId + type: object + target: + default: configmap + description: |- + Target selects how this Decofile is delivered (the FastDeployment strategy). + "configmap" (default) writes a ConfigMap and notifies Knative pods. + "tanstack-kv" runs a self-cleaning Job that pushes the decofile to Cloudflare + KV — the fast-deploy content path for TanStack/Workers sites. + enum: + - configmap + - tanstack-kv + type: string required: - source type: object + x-kubernetes-validations: + - message: spec.tanstackKV is required when target is tanstack-kv + rule: self.target != 'tanstack-kv' || has(self.tanstackKV) + - message: source must be 'github' when target is tanstack-kv + rule: self.target != 'tanstack-kv' || self.source == 'github' status: description: DecofileStatus defines the observed state of Decofile. properties: @@ -161,6 +195,10 @@ spec: githubCommit: description: GitHubCommit stores the commit SHA if using GitHub source type: string + jobName: + description: JobName is the K8s Job name for the current tanstack-kv + sync (target=tanstack-kv). + type: string lastUpdated: description: LastUpdated is the timestamp of the last update format: date-time diff --git a/config/crd/bases/deco.sites_decos.yaml b/config/crd/bases/deco.sites_decos.yaml index d2b912e..99eb1c0 100644 --- a/config/crd/bases/deco.sites_decos.yaml +++ b/config/crd/bases/deco.sites_decos.yaml @@ -180,6 +180,34 @@ spec: required: - source type: object + fastDeploy: + description: |- + FastDeploy enables and configures git-driven, KV-first content fast-deploy + for this site. When Enabled, content-only pushes (touching only + .deco/blocks/**) sync to Cloudflare KV via a Decofile CR instead of a full + build/deploy. This is the per-site config the webhook's DeploymentTarget + reads to decide whether and how to fast-deploy. + properties: + enabled: + description: |- + Enabled gates the fast-deploy content path. When false, content pushes take + the normal build/deploy path. + type: boolean + kvNamespaceId: + description: |- + KVNamespaceID is the Cloudflare KV namespace id for this site (one per site). + Required when enabled (enforced via the XValidation rule on this struct). + type: string + siteOrigin: + description: |- + SiteOrigin is the deployed site origin used for cache purge after a sync + (e.g. https://www.example.com). Optional. + type: string + type: object + x-kubernetes-validations: + - message: kvNamespaceId is required when fastDeploy is enabled + rule: '!self.enabled || (has(self.kvNamespaceId) && size(self.kvNamespaceId) + > 0)' framework: description: Framework is the site framework. deno | tanstack | next | remix | static diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 2fa3d19..9c8c108 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -62,6 +62,8 @@ rules: - delete - get - list + - patch + - update - watch - apiGroups: - cert-manager.io diff --git a/docs/fast-deploy-webhook.md b/docs/fast-deploy-webhook.md new file mode 100644 index 0000000..bdfc8de --- /dev/null +++ b/docs/fast-deploy-webhook.md @@ -0,0 +1,90 @@ +# Fast-Deploy Webhook & Content Sync + +The operator turns a content-only git push into a Cloudflare KV content update +("fast deploy"), with **no** code redeploy. Flow: + +``` +git push (main, .deco/blocks/** only) + → GitHub webhook → operator POST /webhooks/github (HMAC-verified) + → DeploymentTarget (cloudflare-workers) resolves the repo's Deco CR, + creates/updates a Decofile CR (target: tanstack-kv) + → DecofileReconciler dispatches to the tanstack-kv FastDeployment + → self-cleaning Job (decofile-syncer image): clone repo@commit + + `deco-sync-blocks-to-kv --write --all` → Cloudflare KV +``` + +## 1. Operator deployment env + +Set on the operator Deployment (provisioning): + +| Env | Required | Purpose | +|-----|----------|---------| +| `GITHUB_WEBHOOK_SECRET` | yes (enables the webhook) | HMAC secret you generate; the SAME value goes in the GitHub webhook's "Secret" field (see §3) | +| `DECOFILE_SYNCER_IMAGE` | yes | `ghcr.io/decocms/infra_applications/decofile-syncer:` | +| `CLOUDFLARE_ACCOUNT_ID` | yes | account id for KV writes (`CF_ACCOUNT_ID` in the Job), e.g. `c95fc4cec7fc52453228d9db170c372c` | +| `CLOUDFLARE_KV_API_TOKEN` | yes | CF API token with **Workers KV Storage: Edit** (`CF_API_TOKEN` in the Job) | +| `GITHUB_APP_ID` + `GITHUB_APP_PRIVATE_KEY` | for private repos (preferred) | GitHub App creds; the operator mints a short-lived, repo-scoped installation token per sync (same as admin). Private key PEM (PKCS#1 or PKCS#8); literal `\n` is unescaped | +| `GITHUB_TOKEN` | private repos (fallback) | static token used only when no GitHub App is configured | +| `DECO_PURGE_TOKEN` | optional | bearer token for the site's `/_cache/purge` | +| `BUILD_SERVICE_ACCOUNT` | optional | ServiceAccount for the sync Job pod | +| `BUILD_NODE_SELECTOR` / `BUILD_TOLERATIONS` | optional | JSON; pins sync pods to a node pool | +| `OPERATOR_API_ADDR` | optional | API/webhook listen addr (default `:9090`) | + +The HTTP server starts when either the redirects API (`OPERATOR_API_USER`/ +`OPERATOR_API_PASSWORD`) **or** the webhook (`GITHUB_WEBHOOK_SECRET`) is configured. +The webhook authenticates by signature and is independent of the basic-auth API. + +**Private repos** use the same mechanism as admin (`utils/loaders/github/tokens.ts`): the +operator signs an RS256 App JWT, looks up the repo's installation, and exchanges it for an +access token scoped to that one repo — injected into the sync Job as `GITHUB_TOKEN`. This is +preferred over a static `GITHUB_TOKEN` (short-lived, least-privilege). Public repos need +neither. + +## 2. Per-site config — the `Deco` CR + +Add `spec.fastDeploy` to the site's `Deco` CR (source of truth the webhook reads): + +```yaml +apiVersion: deco.sites/v1alpha1 +kind: Deco +spec: + site: my-store # repo name + org: deco-sites # repo owner + serving: + type: cloudflare-worker + fastDeploy: + enabled: true + kvNamespaceId: # one per site + siteOrigin: https://www.my-store.com # optional, for cache purge +``` + +Content pushes are fast-deployed only when `serving.type=cloudflare-worker` **and** +`fastDeploy.enabled=true`. Otherwise the push takes the normal build/deploy path. + +## 3. GitHub webhook (org-level or per repo) + +Add the webhook once at the **org** level (`https://github.com/organizations//settings/hooks`) +— it fires for every repo; the operator ignores pushes with no matching fast-deploy `Deco` CR +or non-content changes — or per repo (Settings → Webhooks). Either way: + +- **Payload URL:** `https:///webhooks/github` +- **Content type:** `application/json` +- **Secret:** **you invent this value** (e.g. `openssl rand -hex 32`) and paste it into the + webhook's "Secret" field. GitHub then HMAC-signs each delivery with it (`X-Hub-Signature-256`), + and the operator verifies against `GITHUB_WEBHOOK_SECRET` (identical value). You do NOT define + the payload — GitHub does; you only pick the event and set this shared secret. (This is exactly + what admin does — `actions/github/webhooks/broker.ts`.) +- **Events:** "Just the push event" + +The operator processes only pushes to the repo's default branch whose changed files +are all under `.deco/blocks/**`. A `ping` event (sent on setup) returns `200`. + +## 4. Verify + +1. Edit a `.deco/blocks/*.json`, push to `main`. +2. `kubectl get decofile -n ` → a `fastdeploy-` CR; a + `decofile-sync-*` Job runs and completes; the CR's `Synced` condition flips True. +3. Hit the live site — the change is visible within ~10s (the worker polls KV's + `index:revision`), no redeploy. Confirm exactly one `decofile refreshed` log + (no poll loop → the Job's revision matches the runtime's). +4. The Job/pod is GC'd by `ttlSecondsAfterFinished`. diff --git a/hack/helm-generator/main.go b/hack/helm-generator/main.go index c322040..1f1d973 100644 --- a/hack/helm-generator/main.go +++ b/hack/helm-generator/main.go @@ -224,7 +224,7 @@ func addEnvVarsToDeployment(templatesDir string) error { contentStr := string(content) // Find the image line and add env vars after it - envBlock := ` {{- if or (and .Values.github (or .Values.github.token .Values.github.existingSecret)) (and .Values.valkey (get .Values.valkey "sentinelUrls")) .Values.cfworkers.existingSecret .Values.cfworkers.builderImage .Values.cfworkers.artifactsBucket .Values.s3.region .Values.s3.logsBucket .Values.s3.stateBucket .Values.build.serviceAccount .Values.build.roleArn .Values.build.nodeSelector .Values.build.tolerations }} + envBlock := ` {{- if or (and .Values.github (or .Values.github.token .Values.github.existingSecret)) (and .Values.valkey (get .Values.valkey "sentinelUrls")) .Values.cfworkers.existingSecret .Values.cfworkers.builderImage .Values.cfworkers.artifactsBucket .Values.s3.region .Values.s3.logsBucket .Values.s3.stateBucket .Values.build.serviceAccount .Values.build.roleArn .Values.build.nodeSelector .Values.build.tolerations (and .Values.fastDeploy .Values.fastDeploy.syncerImage) }} env: {{- if and .Values.github .Values.github.existingSecret }} - name: GITHUB_TOKEN @@ -308,6 +308,10 @@ func addEnvVarsToDeployment(templatesDir string) error { value: {{ .tolerations | toJson | quote }} {{- end }} {{- end }} + {{- if and .Values.fastDeploy .Values.fastDeploy.syncerImage }} + - name: DECOFILE_SYNCER_IMAGE + value: {{ .Values.fastDeploy.syncerImage | quote }} + {{- end }} {{- end }}` imageLine := ` image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"` @@ -494,11 +498,18 @@ func addOperatorAPIEnvVars(templatesDir string) error { {{- end }} {{- end }}` - envFrom := ` {{- if .Values.operatorApi.existingSecret }} + envFrom := ` {{- if or .Values.secretEnv.existingSecret .Values.operatorApi.existingSecret }} envFrom: + {{- if .Values.secretEnv.existingSecret }} + - secretRef: + name: {{ .Values.secretEnv.existingSecret | quote }} + optional: true + {{- end }} + {{- if .Values.operatorApi.existingSecret }} - secretRef: name: {{ .Values.operatorApi.existingSecret | quote }} optional: true + {{- end }} {{- end }}` // envVars injects inside the env: block; envFrom injects after env: closes, before livenessProbe diff --git a/internal/api/server.go b/internal/api/server.go index 05ba842..b80d2e3 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -17,16 +17,27 @@ type Server struct { addr string } -func NewServer(addr, user, pass string, h *Handlers) *Server { - mux := http.NewServeMux() - mux.HandleFunc("GET /redirects", h.list) - mux.HandleFunc("POST /redirects", h.create) - mux.HandleFunc("GET /redirects/{domain}", h.get) - mux.HandleFunc("DELETE /redirects/{domain}", h.delete) - return &Server{ - addr: addr, - handler: basicAuth(user, pass, mux), +func NewServer(addr, user, pass string, h *Handlers, wh *WebhookHandlers) *Server { + // Redirects API — guarded by basic auth. + redirects := http.NewServeMux() + redirects.HandleFunc("GET /redirects", h.list) + redirects.HandleFunc("POST /redirects", h.create) + redirects.HandleFunc("GET /redirects/{domain}", h.get) + redirects.HandleFunc("DELETE /redirects/{domain}", h.delete) + + root := http.NewServeMux() + // The git webhook authenticates via its HMAC signature, NOT basic auth, so + // it is mounted on the root mux outside the basic-auth wrapper. + if wh.Enabled() { + root.HandleFunc("POST /webhooks/github", wh.handleGitHub) + } + // The redirects API is mounted only when basic-auth creds are configured, so + // a webhook-only deployment never exposes it unauthenticated. + if user != "" && pass != "" { + root.Handle("/", basicAuth(user, pass, redirects)) } + + return &Server{addr: addr, handler: root} } func (s *Server) Start(ctx context.Context) error { diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 129e302..59460c6 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -21,7 +21,7 @@ func TestBasicAuth_Unauthorized(t *testing.T) { _ = clientgoscheme.AddToScheme(scheme) _ = decositesv1alpha1.AddToScheme(scheme) h := api.NewHandlers(fake.NewClientBuilder().WithScheme(scheme).Build(), "deco-redirect-system") - srv := api.NewServer(":0", "user", "pass", h) + srv := api.NewServer(":0", "user", "pass", h, nil) _ = srv // server registered // Build handler directly to test without starting the TCP listener @@ -40,7 +40,7 @@ func TestCreate_HappyPath(t *testing.T) { _ = decositesv1alpha1.AddToScheme(scheme) fc := fake.NewClientBuilder().WithScheme(scheme).Build() h := api.NewHandlers(fc, "deco-redirect-system") - srv := api.NewServer(":0", "user", "pass", h) + srv := api.NewServer(":0", "user", "pass", h, nil) body, _ := json.Marshal(map[string]string{"from": "example.com", "to": "https://www.example.com"}) req := httptest.NewRequest(http.MethodPost, "/redirects", bytes.NewReader(body)) @@ -64,7 +64,7 @@ func TestCreate_NormalizesToScheme(t *testing.T) { _ = decositesv1alpha1.AddToScheme(scheme) fc := fake.NewClientBuilder().WithScheme(scheme).Build() h := api.NewHandlers(fc, "deco-redirect-system") - srv := api.NewServer(":0", "user", "pass", h) + srv := api.NewServer(":0", "user", "pass", h, nil) body, _ := json.Marshal(map[string]string{"from": "example.com", "to": "www.example.com"}) req := httptest.NewRequest(http.MethodPost, "/redirects", bytes.NewReader(body)) @@ -91,7 +91,7 @@ func TestDelete_HappyPath(t *testing.T) { Spec: decositesv1alpha1.DecoRedirectSpec{From: "example.com", To: "https://www.example.com"}, }).Build() h := api.NewHandlers(fc, "deco-redirect-system") - srv := api.NewServer(":0", "user", "pass", h) + srv := api.NewServer(":0", "user", "pass", h, nil) req := httptest.NewRequest(http.MethodDelete, "/redirects/example.com", nil) req.SetBasicAuth("user", "pass") @@ -111,7 +111,7 @@ func TestGet_HappyPath(t *testing.T) { Spec: decositesv1alpha1.DecoRedirectSpec{From: "example.com", To: "https://www.example.com"}, }).Build() h := api.NewHandlers(fc, "deco-redirect-system") - srv := api.NewServer(":0", "user", "pass", h) + srv := api.NewServer(":0", "user", "pass", h, nil) req := httptest.NewRequest(http.MethodGet, "/redirects/example.com", nil) req.SetBasicAuth("user", "pass") @@ -136,7 +136,7 @@ func TestGet_NotFound(t *testing.T) { _ = decositesv1alpha1.AddToScheme(scheme) fc := fake.NewClientBuilder().WithScheme(scheme).Build() h := api.NewHandlers(fc, "deco-redirect-system") - srv := api.NewServer(":0", "user", "pass", h) + srv := api.NewServer(":0", "user", "pass", h, nil) req := httptest.NewRequest(http.MethodGet, "/redirects/notfound.com", nil) req.SetBasicAuth("user", "pass") @@ -156,7 +156,7 @@ func TestList_HappyPath(t *testing.T) { Spec: decositesv1alpha1.DecoRedirectSpec{From: "example.com", To: "https://www.example.com"}, }).Build() h := api.NewHandlers(fc, "deco-redirect-system") - srv := api.NewServer(":0", "user", "pass", h) + srv := api.NewServer(":0", "user", "pass", h, nil) req := httptest.NewRequest(http.MethodGet, "/redirects", nil) req.SetBasicAuth("user", "pass") @@ -180,7 +180,7 @@ func TestCreate_WithRedirectCode(t *testing.T) { _ = decositesv1alpha1.AddToScheme(scheme) fc := fake.NewClientBuilder().WithScheme(scheme).Build() h := api.NewHandlers(fc, "deco-redirect-system") - srv := api.NewServer(":0", "user", "pass", h) + srv := api.NewServer(":0", "user", "pass", h, nil) code := 301 body, _ := json.Marshal(map[string]interface{}{"from": "example.com", "to": "https://www.example.com", "redirectCode": code}) @@ -216,7 +216,7 @@ func TestGet_IncludesRedirectCode(t *testing.T) { }, }).Build() h := api.NewHandlers(fc, "deco-redirect-system") - srv := api.NewServer(":0", "user", "pass", h) + srv := api.NewServer(":0", "user", "pass", h, nil) req := httptest.NewRequest(http.MethodGet, "/redirects/example.com", nil) req.SetBasicAuth("user", "pass") diff --git a/internal/api/webhook.go b/internal/api/webhook.go new file mode 100644 index 0000000..e6d2bab --- /dev/null +++ b/internal/api/webhook.go @@ -0,0 +1,230 @@ +package api + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "sigs.k8s.io/controller-runtime/pkg/client" + logf "sigs.k8s.io/controller-runtime/pkg/log" + + decositesv1alpha1 "github.com/deco-sites/decofile-operator/api/v1alpha1" + "github.com/deco-sites/decofile-operator/internal/deploy" +) + +// maxWebhookBody caps the GitHub payload we read (GitHub push payloads are well +// under this; the cap prevents a malicious unbounded body). +const maxWebhookBody = 8 << 20 // 8 MiB + +// WebhookHandlers serves the git webhook that drives fast-deploy. It verifies +// the HMAC signature, resolves the site's Deco CR, and asks the DeploymentTarget +// registry to plan desired-state CRs, which it then applies. +type WebhookHandlers struct { + client client.Client + secret string + targets *deploy.TargetRegistry +} + +func NewWebhookHandlers(c client.Client, secret string, targets *deploy.TargetRegistry) *WebhookHandlers { + return &WebhookHandlers{client: c, secret: secret, targets: targets} +} + +// Enabled reports whether the webhook is fully configured (secret + targets). +func (h *WebhookHandlers) Enabled() bool { + return h != nil && h.secret != "" && h.targets != nil +} + +type githubPushPayload struct { + Ref string `json:"ref"` + After string `json:"after"` + Repository struct { + Name string `json:"name"` + DefaultBranch string `json:"default_branch"` + Owner struct { + Login string `json:"login"` + Name string `json:"name"` + } `json:"owner"` + } `json:"repository"` + Commits []struct { + Added []string `json:"added"` + Modified []string `json:"modified"` + Removed []string `json:"removed"` + } `json:"commits"` +} + +// handleGitHub is the POST /webhooks/github handler. Auth is the HMAC signature, +// NOT basic auth, so it is mounted outside the basic-auth wrapper. +func (h *WebhookHandlers) handleGitHub(w http.ResponseWriter, r *http.Request) { + log := logf.FromContext(r.Context()) + + body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBody)) + if err != nil { + http.Error(w, "failed to read body", http.StatusBadRequest) + return + } + + if !verifySignature(h.secret, body, r.Header.Get("X-Hub-Signature-256")) { + http.Error(w, "invalid signature", http.StatusUnauthorized) + return + } + + // Acknowledge non-push events (e.g. the "ping" GitHub sends on setup) so the + // webhook shows green in the GitHub UI. + if event := r.Header.Get("X-GitHub-Event"); event != "push" { + w.WriteHeader(http.StatusOK) + _, _ = fmt.Fprintf(w, "ignored event %q", event) + return + } + + var payload githubPushPayload + if err := json.Unmarshal(body, &payload); err != nil { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return + } + + // Only act on pushes to the repo's default branch. + defaultBranch := payload.Repository.DefaultBranch + if defaultBranch == "" { + defaultBranch = "main" + } + if payload.Ref != "refs/heads/"+defaultBranch { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ignored non-default-branch push")) + return + } + + owner := payload.Repository.Owner.Login + if owner == "" { + owner = payload.Repository.Owner.Name + } + push := deploy.PushEvent{ + Owner: owner, + Repo: payload.Repository.Name, + Commit: payload.After, + ChangedFiles: changedFiles(payload), + } + + // Resolve the site's Deco CR (source of truth for serving type + KV config). + deco, err := h.resolveDeco(r.Context(), push.Owner, push.Repo) + if err != nil { + log.Error(err, "failed to resolve Deco for repo", "owner", push.Owner, "repo", push.Repo) + http.Error(w, "failed to resolve site", http.StatusInternalServerError) + return + } + if deco == nil { + // No site registered for this repo → nothing to do. + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("no matching Deco for repo")) + return + } + + objs, err := h.targets.Plan(r.Context(), push, deploy.SiteConfig{Deco: deco}) + if err != nil { + log.Error(err, "deployment target Plan failed") + http.Error(w, "plan failed", http.StatusInternalServerError) + return + } + if len(objs) == 0 { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("nothing to deploy")) + return + } + + for _, obj := range objs { + if err := h.apply(r.Context(), obj); err != nil { + log.Error(err, "failed to apply planned object", "name", obj.GetName()) + http.Error(w, "apply failed", http.StatusInternalServerError) + return + } + } + + log.Info("fast-deploy triggered", "owner", push.Owner, "repo", push.Repo, "commit", push.Commit, "objects", len(objs)) + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write([]byte("fast-deploy triggered")) +} + +// resolveDeco finds the Deco CR for owner/repo by matching spec.org + spec.site. +// Returns nil (not an error) when no site is registered for the repo. +func (h *WebhookHandlers) resolveDeco(ctx context.Context, owner, repo string) (*decositesv1alpha1.Deco, error) { + list := &decositesv1alpha1.DecoList{} + if err := h.client.List(ctx, list); err != nil { + return nil, err + } + for i := range list.Items { + d := &list.Items[i] + if strings.EqualFold(d.Spec.Org, owner) && strings.EqualFold(d.Spec.Site, repo) { + return d, nil + } + } + return nil, nil +} + +// apply creates the planned object, or updates its spec/labels when it already +// exists. The Decofile name is deterministic per site, so repeated pushes +// converge on one CR. +func (h *WebhookHandlers) apply(ctx context.Context, obj client.Object) error { + df, ok := obj.(*decositesv1alpha1.Decofile) + if !ok { + return fmt.Errorf("unsupported planned object type %T", obj) + } + existing := &decositesv1alpha1.Decofile{} + err := h.client.Get(ctx, client.ObjectKeyFromObject(df), existing) + if apierrors.IsNotFound(err) { + return h.client.Create(ctx, df) + } + if err != nil { + return err + } + existing.Spec = df.Spec + existing.Labels = df.Labels + return h.client.Update(ctx, existing) +} + +// changedFiles is the union of added/modified/removed paths across all commits +// in the push. +func changedFiles(p githubPushPayload) []string { + seen := map[string]struct{}{} + var out []string + add := func(paths []string) { + for _, f := range paths { + if _, ok := seen[f]; ok { + continue + } + seen[f] = struct{}{} + out = append(out, f) + } + } + for _, c := range p.Commits { + add(c.Added) + add(c.Modified) + add(c.Removed) + } + return out +} + +// verifySignature checks the GitHub HMAC-SHA256 signature header +// ("sha256=") against the raw body using the shared secret. +func verifySignature(secret string, body []byte, header string) bool { + if secret == "" || header == "" { + return false + } + const prefix = "sha256=" + if !strings.HasPrefix(header, prefix) { + return false + } + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(body) + want := mac.Sum(nil) + got, err := hex.DecodeString(strings.TrimPrefix(header, prefix)) + if err != nil { + return false + } + return hmac.Equal(got, want) +} diff --git a/internal/build/cfworkers.go b/internal/build/cfworkers.go index 217799d..aa04d4f 100644 --- a/internal/build/cfworkers.go +++ b/internal/build/cfworkers.go @@ -4,7 +4,6 @@ package build import ( "context" "crypto/sha256" - "encoding/json" "fmt" "os" @@ -14,6 +13,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" decositesv1alpha1 "github.com/deco-sites/decofile-operator/api/v1alpha1" + "github.com/deco-sites/decofile-operator/internal/envparse" ) // JobName returns a deterministic job name: sha256("build-{commitSha}-{site}"), first 4 bytes as hex. @@ -202,8 +202,8 @@ func CfWorkersConfigFromEnv() CfWorkersConfig { BuilderImage: os.Getenv("CFWORKERS_BUILDER_IMAGE"), BuilderServiceAccount: os.Getenv("BUILD_SERVICE_ACCOUNT"), TTLSeconds: 10 * 60, - NodeSelector: parseNodeSelector(os.Getenv("BUILD_NODE_SELECTOR")), - Tolerations: parseTolerations(os.Getenv("BUILD_TOLERATIONS")), + NodeSelector: envparse.NodeSelector(os.Getenv("BUILD_NODE_SELECTOR")), + Tolerations: envparse.Tolerations(os.Getenv("BUILD_TOLERATIONS")), S3: S3Config{ Region: os.Getenv("S3_REGION"), LogsBucket: os.Getenv("S3_LOGS_BUCKET"), @@ -213,35 +213,6 @@ func CfWorkersConfigFromEnv() CfWorkersConfig { } } -// parseNodeSelector parses a JSON object string into a map. -// Returns nil on empty input or parse error. -func parseNodeSelector(s string) map[string]string { - if s == "" { - return nil - } - m := map[string]string{} - if err := json.Unmarshal([]byte(s), &m); err != nil { - return nil - } - if len(m) == 0 { - return nil - } - return m -} - -// parseTolerations unmarshals a JSON array of Toleration objects. -// Returns nil on empty input or parse error. -func parseTolerations(s string) []corev1.Toleration { - if s == "" { - return nil - } - var t []corev1.Toleration - if err := json.Unmarshal([]byte(s), &t); err != nil { - return nil - } - return t -} - type cfWorkersBuilder struct { cfg CfWorkersConfig } diff --git a/internal/controller/decofile_controller.go b/internal/controller/decofile_controller.go index ae73cb3..80456ae 100644 --- a/internal/controller/decofile_controller.go +++ b/internal/controller/decofile_controller.go @@ -23,6 +23,7 @@ import ( "net/http" "time" + 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" @@ -42,6 +43,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" decositesv1alpha1 "github.com/deco-sites/decofile-operator/api/v1alpha1" + "github.com/deco-sites/decofile-operator/internal/deploy" ) const ( @@ -58,6 +60,9 @@ type DecofileReconciler struct { // HTTPClient is a shared HTTP client for pod notifications. // It should be created once and reused to prevent connection leaks. HTTPClient *http.Client + // FastDeploy dispatches non-configmap Decofile targets (e.g. tanstack-kv) to + // a pluggable FastDeployment strategy. Nil = only the default ConfigMap path. + FastDeploy *deploy.DeploymentRegistry } // +kubebuilder:rbac:groups=deco.sites,resources=decofiles,verbs=get;list;watch;create;update;patch;delete @@ -67,6 +72,7 @@ type DecofileReconciler struct { // +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch // +kubebuilder:rbac:groups="",resources=pods,verbs=get;list;watch // +kubebuilder:rbac:groups=serving.knative.dev,resources=revisions,verbs=get;list;watch +// +kubebuilder:rbac:groups=batch,resources=jobs,verbs=get;list;watch;create;update;patch;delete // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. @@ -94,6 +100,20 @@ func (r *DecofileReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c log.V(1).Info("Fetched Decofile", "duration", time.Since(fetchStart)) + // Pluggable delivery: non-configmap targets (e.g. tanstack-kv KV sync) are + // driven by a FastDeployment strategy that owns its own child resources + + // status. The default/empty target falls through to the ConfigMap + Knative + // path below. + if r.FastDeploy != nil && decofile.Spec.Target != "" && + decofile.Spec.Target != decositesv1alpha1.TargetConfigMap { + if impl, ok := r.FastDeploy.For(decofile.Spec.Target); ok { + return impl.Reconcile(ctx, r.Client, r.Scheme, decofile) + } + log.Info("No FastDeployment registered for Decofile target; ignoring", + "target", decofile.Spec.Target) + return ctrl.Result{}, nil + } + // Sync Revision ownerReferences so deletion cascades when the Knative // Revision referencing this Decofile is garbage-collected. // Non-fatal: failures are logged but don't block reconcile. @@ -542,6 +562,7 @@ func (r *DecofileReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&decositesv1alpha1.Decofile{}). Owns(&corev1.ConfigMap{}). + Owns(&batchv1.Job{}). Watches( &servingv1.Revision{}, handler.EnqueueRequestsFromMapFunc(r.mapRevisionToDecofile), diff --git a/internal/deploy/cloudflare_workers.go b/internal/deploy/cloudflare_workers.go new file mode 100644 index 0000000..cf453cd --- /dev/null +++ b/internal/deploy/cloudflare_workers.go @@ -0,0 +1,66 @@ +package deploy + +import ( + "context" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + decositesv1alpha1 "github.com/deco-sites/decofile-operator/api/v1alpha1" +) + +// ServingCloudflareWorker is the deco.spec.serving.type this target handles. +const ServingCloudflareWorker = "cloudflare-worker" + +// cloudflareWorkersTarget is the DeploymentTarget for Cloudflare Workers / TanStack +// sites. On a content-only push to a fast-deploy-enabled site it emits a Decofile +// CR with target=tanstack-kv; the FastDeployment side turns that into a KV-sync Job. +type cloudflareWorkersTarget struct{} + +// NewCloudflareWorkersTarget returns the DeploymentTarget for +// serving.type = "cloudflare-worker". +func NewCloudflareWorkersTarget() DeploymentTarget { + return &cloudflareWorkersTarget{} +} + +func (t *cloudflareWorkersTarget) Plan(_ context.Context, push PushEvent, site SiteConfig) ([]client.Object, error) { + deco := site.Deco + if deco == nil || deco.Spec.FastDeploy == nil || !deco.Spec.FastDeploy.Enabled { + // Fast-deploy not configured/enabled for this site → nothing to do. + return nil, nil + } + if !isContentOnly(push.ChangedFiles) { + // Code (or mixed) change → normal build/deploy path owns it. + return nil, nil + } + + fd := deco.Spec.FastDeploy + df := &decositesv1alpha1.Decofile{ + ObjectMeta: metav1.ObjectMeta{ + Name: decofileName(deco.Spec.Site), + Namespace: deco.Namespace, + Labels: map[string]string{ + "app.deco/site": deco.Spec.Site, + "app.deco/org": deco.Spec.Org, + "app.deco/serving": ServingCloudflareWorker, + }, + }, + Spec: decositesv1alpha1.DecofileSpec{ + Source: decositesv1alpha1.SourceGitHub, + GitHub: &decositesv1alpha1.GitHubSource{ + Org: push.Owner, + Repo: push.Repo, + Commit: push.Commit, + // Trailing slash so prefix-based extraction can't match sibling + // dirs like `.deco/blocks-old`. + Path: ".deco/blocks/", + }, + Target: decositesv1alpha1.TargetTanstackKV, + TanstackKV: &decositesv1alpha1.TanstackKVTarget{ + KVNamespaceID: fd.KVNamespaceID, + SiteOrigin: fd.SiteOrigin, + }, + }, + } + return []client.Object{df}, nil +} diff --git a/internal/deploy/fastdeployment.go b/internal/deploy/fastdeployment.go new file mode 100644 index 0000000..3f33242 --- /dev/null +++ b/internal/deploy/fastdeployment.go @@ -0,0 +1,51 @@ +package deploy + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + decositesv1alpha1 "github.com/deco-sites/decofile-operator/api/v1alpha1" +) + +// FastDeployment is the watcher-side seam: given a Decofile CR, drive it to its +// deployed effect (create child resources, report status). One implementation +// per Decofile target. The first impl is tanstack-kv (KV sync via a Job); the +// existing ConfigMap/Knative path can be extracted into a "configmap" impl +// behind this same interface later. +type FastDeployment interface { + // Reconcile drives the Decofile toward its target state. It owns any child + // resources it creates (via owner references) and updates the Decofile's + // status. Returning a non-zero Result requeues (e.g. while a Job runs). + Reconcile(ctx context.Context, c client.Client, scheme *runtime.Scheme, df *decositesv1alpha1.Decofile) (ctrl.Result, error) +} + +// DeploymentRegistry dispatches to a FastDeployment by the Decofile's target. +type DeploymentRegistry struct { + impls map[string]FastDeployment +} + +func NewDeploymentRegistry() *DeploymentRegistry { + return &DeploymentRegistry{impls: map[string]FastDeployment{}} +} + +func (r *DeploymentRegistry) Register(target string, d FastDeployment) { + r.impls[target] = d +} + +// For returns the FastDeployment for a Decofile target. ok=false means no +// custom strategy is registered (the caller falls back to the default +// ConfigMap path). +func (r *DeploymentRegistry) For(target string) (FastDeployment, bool) { + d, ok := r.impls[target] + return d, ok +} + +// errMisconfigured is returned when a Decofile selects a target but omits the +// config that target needs. +func errMisconfigured(target, reason string) error { + return fmt.Errorf("decofile target %q misconfigured: %s", target, reason) +} diff --git a/internal/deploy/tanstack_kv.go b/internal/deploy/tanstack_kv.go new file mode 100644 index 0000000..2385dd5 --- /dev/null +++ b/internal/deploy/tanstack_kv.go @@ -0,0 +1,263 @@ +package deploy + +import ( + "context" + "crypto/sha256" + "fmt" + "os" + "time" + + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + apimeta "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "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/envparse" + "github.com/deco-sites/decofile-operator/internal/githubapp" +) + +// condSynced is the Decofile condition the tanstack-kv strategy manages. +const condSynced = "Synced" + +// requeueWhileRunning is how often the reconciler re-checks an in-flight sync Job. +const requeueWhileRunning = 10 * time.Second + +// TanstackKVConfig holds the cluster-level configuration the KV-sync Job needs. +// Per-site values (KV namespace id, site origin, repo, commit) come from the +// Decofile CR; these are the operator-global defaults. +type TanstackKVConfig struct { + // SyncerImage is the decofile-syncer image (repository:tag). + SyncerImage string + // ServiceAccount is the K8s ServiceAccount the sync pod runs as. + ServiceAccount string + // GithubToken is a static fallback for cloning private site repos, used only + // when GitHubApp is nil (no GitHub App configured). + GithubToken string + // GitHubApp, when set, mints a short-lived repo-scoped installation token per + // sync (preferred over GithubToken) — same mechanism admin uses. + GitHubApp *githubapp.App + // CfAccountId / CfApiToken authenticate the Cloudflare KV REST writes. + CfAccountId string + CfApiToken string + // PurgeToken is the bearer token for the site's POST /_cache/purge (optional). + PurgeToken string + // TTLSeconds controls how long the finished Job is kept before GC. + TTLSeconds int32 + NodeSelector map[string]string + Tolerations []corev1.Toleration +} + +// TanstackKVConfigFromEnv reads TanstackKVConfig from environment variables. +func TanstackKVConfigFromEnv() TanstackKVConfig { + return TanstackKVConfig{ + SyncerImage: os.Getenv("DECOFILE_SYNCER_IMAGE"), + ServiceAccount: os.Getenv("BUILD_SERVICE_ACCOUNT"), + GithubToken: os.Getenv("GITHUB_TOKEN"), + CfAccountId: os.Getenv("CLOUDFLARE_ACCOUNT_ID"), + CfApiToken: os.Getenv("CLOUDFLARE_KV_API_TOKEN"), + PurgeToken: os.Getenv("DECO_PURGE_TOKEN"), + TTLSeconds: 5 * 60, + NodeSelector: envparse.NodeSelector(os.Getenv("BUILD_NODE_SELECTOR")), + Tolerations: envparse.Tolerations(os.Getenv("BUILD_TOLERATIONS")), + } +} + +type tanstackKV struct { + cfg TanstackKVConfig +} + +// NewTanstackKV returns the FastDeployment for Decofile target = "tanstack-kv". +func NewTanstackKV(cfg TanstackKVConfig) FastDeployment { + return &tanstackKV{cfg: cfg} +} + +// SyncJobName is the deterministic Job name for a (commit, site) sync. Uses a +// 64-bit hash slice so distinct commits don't collide onto the same Job name +// (which would make a new commit falsely read as already-synced). +func SyncJobName(commit, site string) string { + h := sha256.Sum256([]byte("decofile-sync-" + commit + "-" + site)) + return fmt.Sprintf("decofile-sync-%x", h[:8]) +} + +func (d *tanstackKV) Reconcile(ctx context.Context, c client.Client, scheme *runtime.Scheme, df *decositesv1alpha1.Decofile) (ctrl.Result, error) { + log := logf.FromContext(ctx) + + if df.Spec.GitHub == nil || df.Spec.GitHub.Commit == "" { + return ctrl.Result{}, errMisconfigured(decositesv1alpha1.TargetTanstackKV, "spec.github with a commit is required") + } + if df.Spec.TanstackKV == nil || df.Spec.TanstackKV.KVNamespaceID == "" { + return ctrl.Result{}, errMisconfigured(decositesv1alpha1.TargetTanstackKV, "spec.tanstackKV.kvNamespaceId is required") + } + + commit := df.Spec.GitHub.Commit + site := df.Spec.GitHub.Repo + jobName := SyncJobName(commit, site) + + // Already synced this commit → nothing to do (idempotent on re-delivered events). + if df.Status.GitHubCommit == commit && + apimeta.IsStatusConditionTrue(df.Status.Conditions, condSynced) { + return ctrl.Result{}, nil + } + + job := &batchv1.Job{} + err := c.Get(ctx, client.ObjectKey{Name: jobName, Namespace: df.Namespace}, job) + switch { + case apierrors.IsNotFound(err): + githubToken, terr := d.resolveGitHubToken(ctx, df.Spec.GitHub.Org, df.Spec.GitHub.Repo) + if terr != nil { + return ctrl.Result{}, fmt.Errorf("resolve GitHub token: %w", terr) + } + job = d.buildJob(df, jobName, site, githubToken) + if err := controllerutil.SetControllerReference(df, job, scheme); err != nil { + return ctrl.Result{}, fmt.Errorf("set owner ref on sync Job: %w", err) + } + if err := c.Create(ctx, job); err != nil { + if apierrors.IsAlreadyExists(err) { + return ctrl.Result{RequeueAfter: requeueWhileRunning}, nil + } + return ctrl.Result{}, fmt.Errorf("create sync Job: %w", err) + } + log.Info("Created decofile KV-sync Job", "job", jobName, "commit", commit, "kvNamespace", df.Spec.TanstackKV.KVNamespaceID) + d.setStatus(df, jobName, commit, metav1.ConditionUnknown, "Syncing", "KV sync Job created") + if err := c.Status().Update(ctx, df); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{RequeueAfter: requeueWhileRunning}, nil + case err != nil: + return ctrl.Result{}, fmt.Errorf("get sync Job: %w", err) + } + + // Job exists — check terminal state. + switch { + case isJobComplete(job): + log.Info("decofile KV-sync Job completed", "job", jobName, "commit", commit) + d.setStatus(df, jobName, commit, metav1.ConditionTrue, "SyncSucceeded", "decofile pushed to Cloudflare KV") + return ctrl.Result{}, c.Status().Update(ctx, df) + case isJobFailed(job): + log.Info("decofile KV-sync Job failed", "job", jobName, "commit", commit) + d.setStatus(df, jobName, commit, metav1.ConditionFalse, "SyncFailed", "KV-sync Job failed; see Job logs") + // Don't requeue forever — a new commit yields a new Job name. The failed + // Job is GC'd by TTLSecondsAfterFinished. + return ctrl.Result{}, c.Status().Update(ctx, df) + default: + // Still running. + return ctrl.Result{RequeueAfter: requeueWhileRunning}, nil + } +} + +func (d *tanstackKV) setStatus(df *decositesv1alpha1.Decofile, jobName, commit string, status metav1.ConditionStatus, reason, msg string) { + df.Status.JobName = jobName + df.Status.SourceType = decositesv1alpha1.SourceGitHub + df.Status.LastUpdated = metav1.Now() + if status == metav1.ConditionTrue { + df.Status.GitHubCommit = commit + } + apimeta.SetStatusCondition(&df.Status.Conditions, metav1.Condition{ + Type: condSynced, + Status: status, + Reason: reason, + Message: msg, + }) +} + +// resolveGitHubToken mints a short-lived, repo-scoped installation token via the +// GitHub App when configured (preferred), falling back to the static token +// (which may be empty for public repos). +func (d *tanstackKV) resolveGitHubToken(ctx context.Context, owner, repo string) (string, error) { + if d.cfg.GitHubApp != nil { + return d.cfg.GitHubApp.InstallationToken(ctx, owner, repo) + } + return d.cfg.GithubToken, nil +} + +func (d *tanstackKV) buildJob(df *decositesv1alpha1.Decofile, jobName, site, githubToken string) *batchv1.Job { + gh := df.Spec.GitHub + env := []corev1.EnvVar{ + {Name: "GIT_REPO", Value: fmt.Sprintf("https://github.com/%s/%s", gh.Org, gh.Repo)}, + {Name: "COMMIT_SHA", Value: gh.Commit}, + {Name: "DECO_SITE_NAME", Value: site}, + {Name: "BUILD_NAME", Value: jobName}, + {Name: "CF_ACCOUNT_ID", Value: d.cfg.CfAccountId}, + {Name: "CF_API_TOKEN", Value: d.cfg.CfApiToken}, + {Name: "CF_KV_NAMESPACE_ID", Value: df.Spec.TanstackKV.KVNamespaceID}, + } + if githubToken != "" { + env = append(env, corev1.EnvVar{Name: "GITHUB_TOKEN", Value: githubToken}) + } + if origin := df.Spec.TanstackKV.SiteOrigin; origin != "" { + env = append(env, corev1.EnvVar{Name: "PURGE_URL", Value: origin}) + if d.cfg.PurgeToken != "" { + env = append(env, corev1.EnvVar{Name: "PURGE_TOKEN", Value: d.cfg.PurgeToken}) + } + } + + backoffLimit := int32(2) + ttl := d.cfg.TTLSeconds + + return &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: jobName, + Namespace: df.Namespace, + Labels: map[string]string{ + "app.deco/site": site, + "app.deco/target": decositesv1alpha1.TargetTanstackKV, + }, + }, + Spec: batchv1.JobSpec{ + BackoffLimit: &backoffLimit, + TTLSecondsAfterFinished: &ttl, + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyNever, + ServiceAccountName: d.cfg.ServiceAccount, + NodeSelector: d.cfg.NodeSelector, + Tolerations: d.cfg.Tolerations, + Containers: []corev1.Container{ + { + Name: "syncer", + Image: d.cfg.SyncerImage, + Env: env, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceMemory: resource.MustParse("256Mi"), + corev1.ResourceCPU: resource.MustParse("250m"), + corev1.ResourceEphemeralStorage: resource.MustParse("1Gi"), + }, + Limits: corev1.ResourceList{ + corev1.ResourceMemory: resource.MustParse("1Gi"), + corev1.ResourceEphemeralStorage: resource.MustParse("2Gi"), + }, + }, + }, + }, + }, + }, + }, + } +} + +func isJobComplete(j *batchv1.Job) bool { + for _, c := range j.Status.Conditions { + if c.Type == batchv1.JobComplete && c.Status == corev1.ConditionTrue { + return true + } + } + return false +} + +func isJobFailed(j *batchv1.Job) bool { + for _, c := range j.Status.Conditions { + if c.Type == batchv1.JobFailed && c.Status == corev1.ConditionTrue { + return true + } + } + return false +} diff --git a/internal/deploy/target.go b/internal/deploy/target.go new file mode 100644 index 0000000..d97c969 --- /dev/null +++ b/internal/deploy/target.go @@ -0,0 +1,124 @@ +// Package deploy holds the two pluggable seams of the git-driven fast-deploy +// flow: +// +// DeploymentTarget — maps a git push to desired-state CR(s) (webhook side) +// FastDeployment — drives a Decofile CR to its effect (e.g. KV sync) (watcher side) +// +// Each is a small interface with a name-keyed registry so new deploy targets +// and execution strategies plug in without touching the webhook or reconciler. +// The first (and currently only) implementations are cloudflare-workers +// (DeploymentTarget) and tanstack-kv (FastDeployment). +package deploy + +import ( + "context" + "strings" + + "sigs.k8s.io/controller-runtime/pkg/client" + + decositesv1alpha1 "github.com/deco-sites/decofile-operator/api/v1alpha1" +) + +// blocksPrefix is the repo-relative directory whose changes count as a +// content-only push (a fast-deploy candidate). +const blocksPrefix = ".deco/blocks/" + +// PushEvent is the normalized git push the webhook hands to a DeploymentTarget. +type PushEvent struct { + Owner string + Repo string + Commit string + ChangedFiles []string +} + +// SiteConfig is the resolved per-site configuration for a push. The Deco CR is +// the source of truth (site, serving type, and fast-deploy/KV settings). +type SiteConfig struct { + Deco *decositesv1alpha1.Deco +} + +// DeploymentTarget maps a push (+ resolved site config) to the desired-state +// objects to create/apply. It returns an empty slice when there is nothing to +// deploy (e.g. a code-only change, or fast-deploy disabled for the site). +type DeploymentTarget interface { + Plan(ctx context.Context, push PushEvent, site SiteConfig) ([]client.Object, error) +} + +// TargetRegistry dispatches to a DeploymentTarget by the site's serving type +// (deco.spec.serving.type, e.g. "cloudflare-worker"). It is itself a +// DeploymentTarget. +type TargetRegistry struct { + targets map[string]DeploymentTarget +} + +func NewTargetRegistry() *TargetRegistry { + return &TargetRegistry{targets: map[string]DeploymentTarget{}} +} + +func (r *TargetRegistry) Register(servingType string, t DeploymentTarget) { + r.targets[servingType] = t +} + +func (r *TargetRegistry) Plan(ctx context.Context, push PushEvent, site SiteConfig) ([]client.Object, error) { + serving := "" + if site.Deco != nil && site.Deco.Spec.Serving != nil { + serving = site.Deco.Spec.Serving.Type + } + t, ok := r.targets[serving] + if !ok { + // No target registered for this serving type → nothing to do. Code-only + // or unsupported sites fall through to the normal build/deploy path. + return nil, nil + } + return t.Plan(ctx, push, site) +} + +// isContentOnly reports whether every changed file is under .deco/blocks/ (a +// content-only push). An empty file list is treated as not content-only — we +// can't prove it's content, so we don't fast-deploy it. +func isContentOnly(files []string) bool { + if len(files) == 0 { + return false + } + for _, f := range files { + if !strings.HasPrefix(f, blocksPrefix) { + return false + } + } + return true +} + +// maxNameLen is the Kubernetes object-name ceiling (DNS-1123 label). +const maxNameLen = 63 + +// decofileName is the deterministic Decofile CR name for a site's fast-deploy +// content. Stable per site so repeated pushes update the same CR. The site is +// sanitized to a DNS-1123 label and the whole name capped at 63 chars so any +// repo/site naming variant yields a valid object name. +func decofileName(site string) string { + name := "fastdeploy-" + sanitizeDNS1123(site) + if len(name) > maxNameLen { + name = name[:maxNameLen] + } + return strings.Trim(name, "-") +} + +// sanitizeDNS1123 lowercases the input and replaces any char outside +// [a-z0-9-] with '-', trimming leading/trailing dashes. Returns "site" if the +// result is empty. +func sanitizeDNS1123(s string) string { + var b strings.Builder + for _, r := range strings.ToLower(s) { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-': + b.WriteRune(r) + default: + b.WriteRune('-') + } + } + out := strings.Trim(b.String(), "-") + if out == "" { + return "site" + } + return out +} diff --git a/internal/envparse/envparse.go b/internal/envparse/envparse.go new file mode 100644 index 0000000..d8c1829 --- /dev/null +++ b/internal/envparse/envparse.go @@ -0,0 +1,36 @@ +// Package envparse holds small helpers for parsing JSON-encoded pod-scheduling +// config from environment variables, shared by the build and deploy Job +// builders so their behavior can't drift. +package envparse + +import ( + "encoding/json" + + corev1 "k8s.io/api/core/v1" +) + +// NodeSelector parses a JSON object string into a nodeSelector map. Returns nil +// on empty input or parse error. +func NodeSelector(s string) map[string]string { + if s == "" { + return nil + } + m := map[string]string{} + if err := json.Unmarshal([]byte(s), &m); err != nil || len(m) == 0 { + return nil + } + return m +} + +// Tolerations parses a JSON array of Toleration objects. Returns nil on empty +// input or parse error. +func Tolerations(s string) []corev1.Toleration { + if s == "" { + return nil + } + var t []corev1.Toleration + if err := json.Unmarshal([]byte(s), &t); err != nil { + return nil + } + return t +} diff --git a/internal/githubapp/githubapp.go b/internal/githubapp/githubapp.go new file mode 100644 index 0000000..3dbb91c --- /dev/null +++ b/internal/githubapp/githubapp.go @@ -0,0 +1,176 @@ +// Package githubapp mints short-lived, repo-scoped GitHub App installation +// access tokens — the same mechanism admin uses to clone private repos +// (utils/loaders/github/tokens.ts): sign an RS256 JWT with the App private key, +// look up the repo's installation, then exchange for an installation token +// scoped to that single repo. +// +// The token is injected into the fast-deploy sync Job as GITHUB_TOKEN, which +// clones via https://x-access-token:$GITHUB_TOKEN@github.com//. +package githubapp + +import ( + "bytes" + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" +) + +const githubAPI = "https://api.github.com" + +// App holds the GitHub App credentials used to mint installation tokens. +type App struct { + appID string + key *rsa.PrivateKey + http *http.Client +} + +// NewFromEnv builds an App from GITHUB_APP_ID + GITHUB_APP_PRIVATE_KEY. +// Returns (nil, nil) when unset — the App is simply disabled (callers then fall +// back to a static token / public-repo clone). Returns an error only when the +// vars are SET but invalid, so a misconfiguration surfaces instead of silently +// disabling private-repo access. +func NewFromEnv() (*App, error) { + appID := strings.TrimSpace(os.Getenv("GITHUB_APP_ID")) + rawKey := os.Getenv("GITHUB_APP_PRIVATE_KEY") + if appID == "" && strings.TrimSpace(rawKey) == "" { + return nil, nil // not configured → disabled + } + if appID == "" || strings.TrimSpace(rawKey) == "" { + return nil, fmt.Errorf("both GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY must be set") + } + // Env vars often carry the PEM with literal "\n"; restore real newlines + // (matches admin's tokens.ts). + key, err := parsePrivateKey(strings.ReplaceAll(rawKey, `\n`, "\n")) + if err != nil { + return nil, fmt.Errorf("parse GITHUB_APP_PRIVATE_KEY: %w", err) + } + return &App{ + appID: appID, + key: key, + http: &http.Client{Timeout: 15 * time.Second}, + }, nil +} + +// parsePrivateKey accepts a PEM-encoded RSA key in either PKCS#1 +// ("RSA PRIVATE KEY", GitHub's default) or PKCS#8 ("PRIVATE KEY") form. +func parsePrivateKey(pemStr string) (*rsa.PrivateKey, error) { + block, _ := pem.Decode([]byte(pemStr)) + if block == nil { + return nil, fmt.Errorf("no PEM block found") + } + if k, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil { + return k, nil + } + k8, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("key is neither PKCS#1 nor PKCS#8: %w", err) + } + rsaKey, ok := k8.(*rsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("private key is not RSA (got %T)", k8) + } + return rsaKey, nil +} + +// jwt builds an RS256 App JWT (iss=appID). GitHub allows exp up to 10 min and +// recommends backdating iat 60s to tolerate clock drift. +func (a *App) jwt() (string, error) { + now := time.Now() + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`)) + claims := base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf( + `{"iat":%d,"exp":%d,"iss":%q}`, + now.Add(-60*time.Second).Unix(), + now.Add(9*time.Minute).Unix(), + a.appID, + ))) + signingInput := header + "." + claims + digest := sha256.Sum256([]byte(signingInput)) + sig, err := rsa.SignPKCS1v15(rand.Reader, a.key, crypto.SHA256, digest[:]) + if err != nil { + return "", fmt.Errorf("sign JWT: %w", err) + } + return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig), nil +} + +// InstallationToken returns a short-lived access token scoped to owner/repo +// only. Mirrors admin's getRepoTokenFromAppInstallation. +func (a *App) InstallationToken(ctx context.Context, owner, repo string) (string, error) { + jwt, err := a.jwt() + if err != nil { + return "", err + } + installID, err := a.installationID(ctx, jwt, owner, repo) + if err != nil { + return "", err + } + return a.accessToken(ctx, jwt, installID, repo) +} + +func (a *App) installationID(ctx context.Context, jwt, owner, repo string) (int64, error) { + url := fmt.Sprintf("%s/repos/%s/%s/installation", githubAPI, owner, repo) + var out struct { + ID int64 `json:"id"` + } + if err := a.do(ctx, http.MethodGet, url, jwt, nil, &out); err != nil { + return 0, fmt.Errorf("lookup installation for %s/%s: %w", owner, repo, err) + } + if out.ID == 0 { + return 0, fmt.Errorf("no GitHub App installation found for %s/%s", owner, repo) + } + return out.ID, nil +} + +func (a *App) accessToken(ctx context.Context, jwt string, installID int64, repo string) (string, error) { + url := fmt.Sprintf("%s/app/installations/%d/access_tokens", githubAPI, installID) + // Scope the token to just this repo (least privilege), matching admin. + body, _ := json.Marshal(map[string]any{"repositories": []string{repo}}) + var out struct { + Token string `json:"token"` + } + if err := a.do(ctx, http.MethodPost, url, jwt, body, &out); err != nil { + return "", fmt.Errorf("mint installation access token: %w", err) + } + if out.Token == "" { + return "", fmt.Errorf("empty installation access token from GitHub") + } + return out.Token, nil +} + +func (a *App) do(ctx context.Context, method, url, jwt string, body []byte, out any) error { + var rdr io.Reader + if body != nil { + rdr = bytes.NewReader(body) + } + req, err := http.NewRequestWithContext(ctx, method, url, rdr) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+jwt) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := a.http.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + data, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("github %s %s: %d %s", method, url, resp.StatusCode, strings.TrimSpace(string(data))) + } + return json.Unmarshal(data, out) +}