From f0eb5480bfbcd5b6c0229c06b3ccda610be94e24 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Mon, 31 Aug 2026 17:16:56 -0500 Subject: [PATCH 1/3] feat: add Argo CD GitHub webhook --- .github/workflows/on-pr.yaml | 1 + .github/workflows/on-push-main.yaml | 1 + Makefile | 1 + README.md | 36 +++- apis/gitopsstacks/definition.yaml | 30 +++ examples/gitopsstacks/webhook.yaml | 23 ++ functions/render/000-state-init.yaml.gotmpl | 22 ++ functions/render/010-state-status.yaml.gotmpl | 18 +- .../200-helm-release-argocd.yaml.gotmpl | 11 + .../render/250-github-webhook.yaml.gotmpl | 197 ++++++++++++++++++ tests/test-render/main.k | 111 ++++++++++ 11 files changed, 449 insertions(+), 2 deletions(-) create mode 100644 examples/gitopsstacks/webhook.yaml create mode 100644 functions/render/250-github-webhook.yaml.gotmpl diff --git a/.github/workflows/on-pr.yaml b/.github/workflows/on-pr.yaml index 9753bcd..3d4314c 100644 --- a/.github/workflows/on-pr.yaml +++ b/.github/workflows/on-pr.yaml @@ -34,6 +34,7 @@ jobs: [ { "example": "examples/gitopsstacks/minimal.yaml" }, { "example": "examples/gitopsstacks/standard.yaml" }, + { "example": "examples/gitopsstacks/webhook.yaml" }, { "example": "examples/gitopsstacks/standard.yaml", "observed_resources": "examples/test/mocks/observed-resources/standard/steps/1/" }, { "example": "examples/gitopsstacks/standard.yaml", "observed_resources": "examples/test/mocks/observed-resources/standard/steps/2/" }, { "example": "examples/gitopsstacks/eso.yaml" }, diff --git a/.github/workflows/on-push-main.yaml b/.github/workflows/on-push-main.yaml index bc0a7de..1d93e62 100644 --- a/.github/workflows/on-push-main.yaml +++ b/.github/workflows/on-push-main.yaml @@ -29,6 +29,7 @@ jobs: [ { "example": "examples/gitopsstacks/minimal.yaml" }, { "example": "examples/gitopsstacks/standard.yaml" }, + { "example": "examples/gitopsstacks/webhook.yaml" }, { "example": "examples/gitopsstacks/standard.yaml", "observed_resources": "examples/test/mocks/observed-resources/standard/steps/1/" }, { "example": "examples/gitopsstacks/standard.yaml", "observed_resources": "examples/test/mocks/observed-resources/standard/steps/2/" }, { "example": "examples/gitopsstacks/eso.yaml" }, diff --git a/Makefile b/Makefile index 2c745bb..b5afe81 100644 --- a/Makefile +++ b/Makefile @@ -27,6 +27,7 @@ EXAMPLES := \ examples/gitopsstacks/minimal.yaml:: \ examples/gitopsstacks/standard.yaml:: \ examples/gitopsstacks/nodepool.yaml:: \ + examples/gitopsstacks/webhook.yaml:: \ examples/gitopsstacks/standard.yaml::examples/test/mocks/observed-resources/standard/steps/1/ \ examples/gitopsstacks/standard.yaml::examples/test/mocks/observed-resources/standard/steps/2/ \ examples/gitopsstacks/eso.yaml:: \ diff --git a/README.md b/README.md index 486c6b9..43d1045 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ A single Crossplane resource that provisions a complete GitOps foundation: ArgoC - ArgoCD Applications referencing wrong repo URLs or paths after copy-paste - Deleting ArgoCD before its Applications causes orphaned resources and finalizer deadlocks - GitHub repo creation is a manual, out-of-band process with inconsistent naming/settings +- Git pushes wait for ArgoCD's polling interval before applications refresh - No single source of truth for "what GitOps infrastructure does this cluster have?" **With GitOps Stack:** @@ -16,6 +17,7 @@ A single Crossplane resource that provisions a complete GitOps foundation: ArgoC - Repo URL derived from org + cluster name — rename the cluster and everything adjusts - Safe deletion ordering enforced via Usage resources (projects app deletes before ArgoCD) - GitHub repo created with consistent settings (topics, visibility, branch cleanup, templates) +- Optional signed GitHub webhook refreshes ArgoCD immediately after pushes - Optional Crossplane integration deploys configurations and provider configs via ArgoCD ## What Gets Deployed @@ -51,6 +53,8 @@ client Secret. |----------|------|---------| | ArgoCD | Helm Release (`argo-cd` v9.7.1) | Continuous delivery platform | | GitHub Repository | `repo.github.m.upbound.io` | GitOps source repository | +| GitHub Repository Webhook | `repo.github.m.upbound.io` | Sends signed push events to ArgoCD | +| Webhook ExternalSecrets | Kubernetes Objects | Projects one AWS Secrets Manager value to GitHub and ArgoCD | | Projects Application | Kubernetes Object (ArgoCD Application) | Syncs ArgoCD projects from the repo | | Deletion Usage | Usage | Ensures projects app deletes before ArgoCD | | Crossplane AppProject | Kubernetes Object (ArgoCD AppProject) | Scoped ArgoCD project for Crossplane | @@ -113,6 +117,31 @@ spec: When `template` is set, the repo is created from the template instead of auto-init. +#### Immediate refresh after GitHub pushes + +Enable the native ArgoCD webhook to replace its polling delay with push-driven +refreshes. The secret value remains in AWS Secrets Manager; the Helm Release +stores only a reference to the External Secrets-managed Kubernetes Secret. + +```yaml +spec: + exposure: + enabled: true + hostname: argocd.example.com + repository: + org: hops-ops + webhook: + enabled: true + secretStoreName: default + secretPath: github/argocd-webhook + secretKey: webhookSecret + maxPayloadSizeMB: 10 +``` + +This creates a GitHub `push` webhook for +`https://argocd.example.com/api/webhook`. SecretStack and a public ArgoCD +Gateway route are required. + ### Stage 3: Crossplane Integration Enable ArgoCD-managed Crossplane resources for full platform automation. @@ -291,6 +320,11 @@ The Usage ensures ArgoCD CRDs stay alive until all ArgoCD Application CRs are cl | `repository.template.repository` | string | no | — | Template repo name | | `repository.topics` | []string | no | `[]` | Repository topics | | `repository.deleteBranchOnMerge` | boolean | no | `true` | Auto-delete head branches on merge | +| `repository.webhook.enabled` | boolean | no | `false` | Create a signed GitHub push webhook for immediate ArgoCD refreshes | +| `repository.webhook.secretStoreName` | string | no | `externalSecrets.secretStoreName` or `default` | ClusterSecretStore containing the shared secret | +| `repository.webhook.secretPath` | string | when enabled | — | AWS Secrets Manager path containing the shared secret | +| `repository.webhook.secretKey` | string | no | `webhookSecret` | JSON property containing the shared secret | +| `repository.webhook.maxPayloadSizeMB` | integer | no | `10` | Maximum webhook payload accepted by ArgoCD | | `externalSecrets.githubToken.secretPath` | string | no | — | AWS Secrets Manager secret containing a GitHub token | | `externalSecrets.githubToken.tokenKey` | string | no | `token` | JSON key containing the token | | `externalSecrets.githubToken.username` | string | no | `x-access-token` | HTTPS username for ArgoCD repository credentials | @@ -332,7 +366,7 @@ global: make render # Render all examples make render:minimal # Render a single example make validate # Validate all rendered output -make test # Run KCL unit tests (12 tests) +make test # Run KCL unit tests make e2e # Run E2E tests (requires GitHub App credentials) make build # Build the Crossplane package make publish tag=v1 # Build and push to registry diff --git a/apis/gitopsstacks/definition.yaml b/apis/gitopsstacks/definition.yaml index 20044bb..2696ab6 100644 --- a/apis/gitopsstacks/definition.yaml +++ b/apis/gitopsstacks/definition.yaml @@ -261,6 +261,34 @@ spec: description: Allow Crossplane to delete the repository when the XR is deleted. Defaults to false for safety. type: boolean default: false + webhook: + description: GitHub webhook that immediately refreshes ArgoCD Applications when the repository changes. Requires SecretStack and public ArgoCD exposure. + type: object + properties: + enabled: + description: Create a push webhook targeting the exposed ArgoCD server. Defaults to false. + type: boolean + default: false + secretStoreName: + description: ClusterSecretStore used to read the webhook secret. Defaults to externalSecrets.secretStoreName, then default. + type: string + secretPath: + description: AWS Secrets Manager path containing the webhook secret. + type: string + minLength: 1 + secretKey: + description: JSON property containing the webhook secret. Defaults to webhookSecret. + type: string + default: webhookSecret + maxPayloadSizeMB: + description: Maximum webhook request payload accepted by ArgoCD. Defaults to 10 MiB. + type: integer + minimum: 1 + maximum: 50 + default: 10 + x-kubernetes-validations: + - rule: "!self.enabled || has(self.secretPath)" + message: spec.repository.webhook.secretPath is required when the webhook is enabled required: - org externalSecrets: @@ -332,6 +360,8 @@ spec: x-kubernetes-validations: - rule: "!has(self.auth) || !has(self.auth.oidc) || !self.auth.oidc.enabled || (has(self.exposure) && self.exposure.enabled)" message: spec.exposure.enabled must be true when native OIDC is enabled + - rule: "!has(self.repository) || !has(self.repository.webhook) || !self.repository.webhook.enabled || (has(self.exposure) && self.exposure.enabled)" + message: spec.exposure.enabled must be true when the GitHub webhook is enabled status: description: GitopsStackStatus defines the observed state. type: object diff --git a/examples/gitopsstacks/webhook.yaml b/examples/gitopsstacks/webhook.yaml new file mode 100644 index 0000000..910fe7e --- /dev/null +++ b/examples/gitopsstacks/webhook.yaml @@ -0,0 +1,23 @@ +apiVersion: hops.ops.com.ai/v1alpha1 +kind: GitopsStack +metadata: + name: gitops + namespace: example-env +spec: + clusterName: example-cluster + exposure: + enabled: true + hostname: argocd.example.com + gatewayRef: + name: platform + namespace: istio-ingress + sectionName: https + repository: + org: hops-ops + name: example-cluster-gitops + webhook: + enabled: true + secretStoreName: default + secretPath: github/argocd-webhook + secretKey: webhookSecret + maxPayloadSizeMB: 10 diff --git a/functions/render/000-state-init.yaml.gotmpl b/functions/render/000-state-init.yaml.gotmpl index 4f9a91a..b8555de 100644 --- a/functions/render/000-state-init.yaml.gotmpl +++ b/functions/render/000-state-init.yaml.gotmpl @@ -12,6 +12,7 @@ # ============================================================================== {{- $name := $metadata.name | default "gitops" }} {{- $clusterName := $spec.clusterName | default $name }} +{{- $resourceNamespace := $metadata.namespace | default "default" }} {{- $namespace := $spec.namespace | default "argocd" }} {{- $managementPolicies := list "*" }} {{- if hasKey $spec "managementPolicies" }} @@ -152,6 +153,17 @@ {{- if hasKey $repoSpec "deleteBranchOnMerge" }} {{- $repoDeleteBranchOnMerge = $repoSpec.deleteBranchOnMerge }} {{- end }} +{{- $webhookSpec := $repoSpec.webhook | default dict }} +{{- $webhookEnabled := false }} +{{- if hasKey $webhookSpec "enabled" }} + {{- $webhookEnabled = $webhookSpec.enabled }} +{{- end }} +{{- if and $webhookEnabled (eq ($webhookSpec.secretPath | default "") "") }} + {{- fail "spec.repository.webhook.secretPath is required when the webhook is enabled" }} +{{- end }} +{{- if and $webhookEnabled (not $exposureEnabled) }} + {{- fail "spec.exposure.enabled must be true when the GitHub webhook is enabled" }} +{{- end }} # Template config (optional) {{- $repoTemplate := $repoSpec.template | default dict }} @@ -180,6 +192,7 @@ {{- $state := dict "name" $name "clusterName" $clusterName + "resourceNamespace" $resourceNamespace "namespace" $namespace "managementPolicies" $managementPolicies "nonDeletingManagementPolicies" $nonDeletingManagementPolicies @@ -251,6 +264,15 @@ "deleteBranchOnMerge" $repoDeleteBranchOnMerge "allowDelete" ($repoSpec.allowDelete | default false) "url" $repoUrl + "webhook" (dict + "enabled" $webhookEnabled + "secretStoreName" ($webhookSpec.secretStoreName | default ($esoSpec.secretStoreName | default "default")) + "secretPath" ($webhookSpec.secretPath | default "") + "secretKey" ($webhookSpec.secretKey | default "webhookSecret") + "secretName" (printf "%s-github-webhook" $name) + "url" (printf "https://%s/api/webhook" $exposureHostname) + "maxPayloadSizeMB" ($webhookSpec.maxPayloadSizeMB | default 10) + ) ) "externalSecrets" (dict "enabled" ($esoSpec.enabled | default false) diff --git a/functions/render/010-state-status.yaml.gotmpl b/functions/render/010-state-status.yaml.gotmpl index d027525..8410e2e 100644 --- a/functions/render/010-state-status.yaml.gotmpl +++ b/functions/render/010-state-status.yaml.gotmpl @@ -11,7 +11,7 @@ {{- $checkReady := dict }} {{- $checkExists := dict }} -{{- range $key := list "nodepool-gitops" "helm-release-argocd" "github-repository" "argocd-apps" "crossplane-app" "external-secret-argocd-repo-creds" "zitadel-credentials" "zitadel-provider-config" "zitadel-project" "zitadel-oidc-client" "zitadel-client-secret" }} +{{- range $key := list "nodepool-gitops" "helm-release-argocd" "github-repository" "argocd-apps" "crossplane-app" "external-secret-argocd-repo-creds" "external-secret-github-webhook" "external-secret-argocd-webhook" "github-repository-webhook" "zitadel-credentials" "zitadel-provider-config" "zitadel-project" "zitadel-oidc-client" "zitadel-client-secret" }} {{- $entry := get $observed $key | default dict }} {{- $resource := $entry.resource | default dict }} {{- $status := $resource.status | default dict }} @@ -69,6 +69,14 @@ "ready" (get $checkReady "external-secret-argocd-repo-creds") "exists" (get $checkExists "external-secret-argocd-repo-creds") ) + "webhook" (dict + "secretReady" (get $checkReady "external-secret-github-webhook") + "secretExists" (get $checkExists "external-secret-github-webhook") + "argocdSecretReady" (get $checkReady "external-secret-argocd-webhook") + "argocdSecretExists" (get $checkExists "external-secret-argocd-webhook") + "ready" (get $checkReady "github-repository-webhook") + "exists" (get $checkExists "github-repository-webhook") + ) "appsApp" (dict "ready" (get $checkReady "argocd-apps") "exists" (get $checkExists "argocd-apps") @@ -98,6 +106,14 @@ {{- if $state.externalSecrets.enabled }} {{- $ready = and $ready $state.observed.repoCreds.ready }} {{- end }} +{{- if $state.repository.webhook.enabled }} + {{- $ready = and + $ready + $state.observed.webhook.secretReady + $state.observed.webhook.argocdSecretReady + $state.observed.webhook.ready + }} +{{- end }} {{- if $state.applications.apps.enabled }} {{- $ready = and $ready $state.observed.appsApp.ready }} {{- end }} diff --git a/functions/render/200-helm-release-argocd.yaml.gotmpl b/functions/render/200-helm-release-argocd.yaml.gotmpl index 91640c1..791d041 100644 --- a/functions/render/200-helm-release-argocd.yaml.gotmpl +++ b/functions/render/200-helm-release-argocd.yaml.gotmpl @@ -108,6 +108,17 @@ spec: {{- $effectiveValues = mergeOverwrite $effectiveValues $exposureValues }} {{- end }} + {{- $webhook := $state.repository.webhook }} + {{- if $webhook.enabled }} + {{- $webhookValues := dict + "configs" (dict + "cm" (dict "webhook.maxPayloadSizeMB" (printf "%d" $webhook.maxPayloadSizeMB)) + "secret" (dict "githubSecret" (printf "$%s:secret" $webhook.secretName)) + ) + }} + {{- $effectiveValues = mergeOverwrite $effectiveValues $webhookValues }} + {{- end }} + {{- $oidc := $state.auth.oidc }} {{- if and $oidc.enabled $state.observed.zitadel.clientId }} {{- $oidcConfig := dict diff --git a/functions/render/250-github-webhook.yaml.gotmpl b/functions/render/250-github-webhook.yaml.gotmpl new file mode 100644 index 0000000..23ef76a --- /dev/null +++ b/functions/render/250-github-webhook.yaml.gotmpl @@ -0,0 +1,197 @@ +# code: language=yaml +# +# GitHub push webhook for immediate ArgoCD refreshes. +# +# The shared secret stays in AWS Secrets Manager. External Secrets projects it +# into both the composite namespace (for provider-upjet-github) and the ArgoCD +# namespace. The Helm Release contains only ArgoCD's external-secret reference. +# + +{{- $repo := $state.repository }} +{{- $webhook := $repo.webhook }} + +{{- if $webhook.enabled }} +--- +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: Object +metadata: + name: {{ $state.name }}-github-webhook-secret + annotations: + {{ setResourceNameAnnotation "external-secret-github-webhook" }} + labels: {{ $state.labels | toJson }} +spec: + managementPolicies: {{ $state.managementPolicies | toJson }} + readiness: + policy: DeriveFromObject + forProvider: + manifest: + apiVersion: external-secrets.io/v1 + kind: ExternalSecret + metadata: + name: {{ $webhook.secretName }} + namespace: {{ $state.resourceNamespace }} + spec: + refreshInterval: 1h + secretStoreRef: + name: {{ $webhook.secretStoreName }} + kind: ClusterSecretStore + target: + name: {{ $webhook.secretName }} + creationPolicy: Owner + template: + type: Opaque + data: + url: {{ $webhook.url | quote }} + secret: {{ `"{{ .webhookSecret }}"` }} + data: + - secretKey: webhookSecret + remoteRef: + key: {{ $webhook.secretPath }} + property: {{ $webhook.secretKey }} + providerConfigRef: + name: {{ $state.kubernetesProviderConfigRef.name }} + kind: {{ $state.kubernetesProviderConfigRef.kind }} + +--- +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: Object +metadata: + name: {{ $state.name }}-argocd-webhook-secret + annotations: + {{ setResourceNameAnnotation "external-secret-argocd-webhook" }} + labels: {{ $state.labels | toJson }} +spec: + managementPolicies: {{ $state.managementPolicies | toJson }} + readiness: + policy: DeriveFromObject + forProvider: + manifest: + apiVersion: external-secrets.io/v1 + kind: ExternalSecret + metadata: + name: {{ $webhook.secretName }} + namespace: {{ $state.argocd.namespace }} + spec: + refreshInterval: 1h + secretStoreRef: + name: {{ $webhook.secretStoreName }} + kind: ClusterSecretStore + target: + name: {{ $webhook.secretName }} + creationPolicy: Owner + template: + metadata: + labels: + app.kubernetes.io/part-of: argocd + type: Opaque + data: + secret: {{ `"{{ .webhookSecret }}"` }} + data: + - secretKey: webhookSecret + remoteRef: + key: {{ $webhook.secretPath }} + property: {{ $webhook.secretKey }} + providerConfigRef: + name: {{ $state.kubernetesProviderConfigRef.name }} + kind: {{ $state.kubernetesProviderConfigRef.kind }} + +--- +apiVersion: repo.github.m.upbound.io/v1alpha1 +kind: RepositoryWebhook +metadata: + name: {{ $repo.name }} + annotations: + {{ setResourceNameAnnotation "github-repository-webhook" }} + labels: {{ $state.labels | toJson }} +spec: + managementPolicies: {{ $state.managementPolicies | toJson }} + forProvider: + active: true + events: + - push + repositoryRef: + name: {{ $repo.name }} + configuration: + - contentType: json + insecureSsl: false + urlSecretRef: + name: {{ $webhook.secretName }} + key: url + secretSecretRef: + name: {{ $webhook.secretName }} + key: secret + providerConfigRef: + name: {{ $state.githubProviderConfigRef.name }} + kind: {{ $state.githubProviderConfigRef.kind }} + +{{- $obs := $state.observed }} +{{- if and $obs.repository.ready $obs.webhook.ready }} +--- +apiVersion: protection.crossplane.io/v1beta1 +kind: Usage +metadata: + name: {{ $state.name }}-delete-webhook-before-repository + annotations: + {{ setResourceNameAnnotation "usage-webhook-repository" }} + labels: {{ $state.labels | toJson }} +spec: + replayDeletion: true + of: + apiVersion: repo.github.m.upbound.io/v1alpha1 + kind: Repository + resourceRef: + name: {{ $repo.name }} + by: + apiVersion: repo.github.m.upbound.io/v1alpha1 + kind: RepositoryWebhook + resourceRef: + name: {{ $repo.name }} +{{- end }} + +{{- if and $obs.webhook.secretReady $obs.webhook.ready }} +--- +apiVersion: protection.crossplane.io/v1beta1 +kind: Usage +metadata: + name: {{ $state.name }}-delete-webhook-before-webhook-secret + annotations: + {{ setResourceNameAnnotation "usage-webhook-secret" }} + labels: {{ $state.labels | toJson }} +spec: + replayDeletion: true + of: + apiVersion: kubernetes.m.crossplane.io/v1alpha1 + kind: Object + resourceRef: + name: {{ $state.name }}-github-webhook-secret + by: + apiVersion: repo.github.m.upbound.io/v1alpha1 + kind: RepositoryWebhook + resourceRef: + name: {{ $repo.name }} +{{- end }} + +{{- if and $obs.argocd.ready $obs.webhook.argocdSecretReady }} +--- +apiVersion: protection.crossplane.io/v1beta1 +kind: Usage +metadata: + name: {{ $state.name }}-delete-argocd-webhook-secret-before-argocd + annotations: + {{ setResourceNameAnnotation "usage-argocd-webhook-secret" }} + labels: {{ $state.labels | toJson }} +spec: + replayDeletion: true + of: + apiVersion: helm.m.crossplane.io/v1beta1 + kind: Release + resourceRef: + name: {{ $state.argocd.name }} + by: + apiVersion: kubernetes.m.crossplane.io/v1alpha1 + kind: Object + resourceRef: + name: {{ $state.name }}-argocd-webhook-secret +{{- end }} + +{{- end }} diff --git a/tests/test-render/main.k b/tests/test-render/main.k index c5fc420..56e4a8f 100644 --- a/tests/test-render/main.k +++ b/tests/test-render/main.k @@ -972,6 +972,117 @@ _items = [ ] } } + + # ========================================================================== + # Test 20: GitHub webhook maps one external secret to GitHub and ArgoCD + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "github-webhook-refreshes-argocd" + spec = { + compositionPath = "apis/gitopsstacks/composition.yaml" + xrdPath = "apis/gitopsstacks/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.GitopsStack { + metadata = { + name = "webhook-test" + namespace = "platform" + } + spec = { + clusterName = "test-cluster" + exposure = { + enabled = True + hostname = "argocd.example.com" + } + repository = { + org = "hops-ops" + name = "platform-gitops" + webhook = { + enabled = True + secretStoreName = "aws-secrets" + secretPath = "github/argocd-webhook" + secretKey = "hook_secret" + maxPayloadSizeMB = 8 + } + } + } + } + assertResources = [ + { + apiVersion = "helm.m.crossplane.io/v1beta1" + kind = "Release" + metadata.name = "argocd" + spec.forProvider.values.configs = { + cm = {"webhook.maxPayloadSizeMB" = "8"} + secret.githubSecret = "$webhook-test-github-webhook:secret" + } + } + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "webhook-test-github-webhook-secret" + spec.forProvider.manifest = { + apiVersion = "external-secrets.io/v1" + kind = "ExternalSecret" + metadata = { + name = "webhook-test-github-webhook" + namespace = "platform" + } + spec = { + secretStoreRef = { + name = "aws-secrets" + kind = "ClusterSecretStore" + } + target.template.data.url = "https://argocd.example.com/api/webhook" + data = [{ + secretKey = "webhookSecret" + remoteRef = { + key = "github/argocd-webhook" + property = "hook_secret" + } + }] + } + } + } + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "webhook-test-argocd-webhook-secret" + spec.forProvider.manifest.spec.target.template.metadata.labels = { + "app.kubernetes.io/part-of" = "argocd" + } + } + { + apiVersion = "repo.github.m.upbound.io/v1alpha1" + kind = "RepositoryWebhook" + metadata.name = "platform-gitops" + spec = { + forProvider = { + active = True + events = ["push"] + repositoryRef.name = "platform-gitops" + configuration = [{ + contentType = "json" + insecureSsl = False + urlSecretRef = { + name = "webhook-test-github-webhook" + key = "url" + } + secretSecretRef = { + name = "webhook-test-github-webhook" + key = "secret" + } + }] + } + providerConfigRef = { + name = "default" + kind = "ProviderConfig" + } + } + } + ] + } + } ] items = _items From 3ad33e2bade4287a9c33617660024a37ee9799b7 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Mon, 31 Aug 2026 17:39:57 -0500 Subject: [PATCH 2/3] fix: separate webhook target secrets --- functions/render/000-state-init.yaml.gotmpl | 3 ++- .../render/200-helm-release-argocd.yaml.gotmpl | 2 +- .../render/250-github-webhook.yaml.gotmpl | 12 ++++++------ tests/test-render/main.k | 18 ++++++++++++++---- 4 files changed, 23 insertions(+), 12 deletions(-) diff --git a/functions/render/000-state-init.yaml.gotmpl b/functions/render/000-state-init.yaml.gotmpl index b8555de..f20c20a 100644 --- a/functions/render/000-state-init.yaml.gotmpl +++ b/functions/render/000-state-init.yaml.gotmpl @@ -269,7 +269,8 @@ "secretStoreName" ($webhookSpec.secretStoreName | default ($esoSpec.secretStoreName | default "default")) "secretPath" ($webhookSpec.secretPath | default "") "secretKey" ($webhookSpec.secretKey | default "webhookSecret") - "secretName" (printf "%s-github-webhook" $name) + "providerSecretName" (printf "%s-github-webhook" $name) + "argocdSecretName" (printf "%s-argocd-github-webhook" $name) "url" (printf "https://%s/api/webhook" $exposureHostname) "maxPayloadSizeMB" ($webhookSpec.maxPayloadSizeMB | default 10) ) diff --git a/functions/render/200-helm-release-argocd.yaml.gotmpl b/functions/render/200-helm-release-argocd.yaml.gotmpl index 791d041..c4d5499 100644 --- a/functions/render/200-helm-release-argocd.yaml.gotmpl +++ b/functions/render/200-helm-release-argocd.yaml.gotmpl @@ -113,7 +113,7 @@ spec: {{- $webhookValues := dict "configs" (dict "cm" (dict "webhook.maxPayloadSizeMB" (printf "%d" $webhook.maxPayloadSizeMB)) - "secret" (dict "githubSecret" (printf "$%s:secret" $webhook.secretName)) + "secret" (dict "githubSecret" (printf "$%s:secret" $webhook.argocdSecretName)) ) }} {{- $effectiveValues = mergeOverwrite $effectiveValues $webhookValues }} diff --git a/functions/render/250-github-webhook.yaml.gotmpl b/functions/render/250-github-webhook.yaml.gotmpl index 23ef76a..5ed0a72 100644 --- a/functions/render/250-github-webhook.yaml.gotmpl +++ b/functions/render/250-github-webhook.yaml.gotmpl @@ -28,7 +28,7 @@ spec: apiVersion: external-secrets.io/v1 kind: ExternalSecret metadata: - name: {{ $webhook.secretName }} + name: {{ $webhook.providerSecretName }} namespace: {{ $state.resourceNamespace }} spec: refreshInterval: 1h @@ -36,7 +36,7 @@ spec: name: {{ $webhook.secretStoreName }} kind: ClusterSecretStore target: - name: {{ $webhook.secretName }} + name: {{ $webhook.providerSecretName }} creationPolicy: Owner template: type: Opaque @@ -69,7 +69,7 @@ spec: apiVersion: external-secrets.io/v1 kind: ExternalSecret metadata: - name: {{ $webhook.secretName }} + name: {{ $webhook.argocdSecretName }} namespace: {{ $state.argocd.namespace }} spec: refreshInterval: 1h @@ -77,7 +77,7 @@ spec: name: {{ $webhook.secretStoreName }} kind: ClusterSecretStore target: - name: {{ $webhook.secretName }} + name: {{ $webhook.argocdSecretName }} creationPolicy: Owner template: metadata: @@ -115,10 +115,10 @@ spec: - contentType: json insecureSsl: false urlSecretRef: - name: {{ $webhook.secretName }} + name: {{ $webhook.providerSecretName }} key: url secretSecretRef: - name: {{ $webhook.secretName }} + name: {{ $webhook.providerSecretName }} key: secret providerConfigRef: name: {{ $state.githubProviderConfigRef.name }} diff --git a/tests/test-render/main.k b/tests/test-render/main.k index 56e4a8f..f74bf56 100644 --- a/tests/test-render/main.k +++ b/tests/test-render/main.k @@ -974,7 +974,7 @@ _items = [ } # ========================================================================== - # Test 20: GitHub webhook maps one external secret to GitHub and ArgoCD + # Test 20: GitHub webhook uses distinct Secrets in a shared namespace # ========================================================================== metav1alpha1.CompositionTest { metadata.name = "github-webhook-refreshes-argocd" @@ -990,6 +990,7 @@ _items = [ } spec = { clusterName = "test-cluster" + namespace = "platform" exposure = { enabled = True hostname = "argocd.example.com" @@ -1014,7 +1015,7 @@ _items = [ metadata.name = "argocd" spec.forProvider.values.configs = { cm = {"webhook.maxPayloadSizeMB" = "8"} - secret.githubSecret = "$webhook-test-github-webhook:secret" + secret.githubSecret = "$webhook-test-argocd-github-webhook:secret" } } { @@ -1048,8 +1049,17 @@ _items = [ apiVersion = "kubernetes.m.crossplane.io/v1alpha1" kind = "Object" metadata.name = "webhook-test-argocd-webhook-secret" - spec.forProvider.manifest.spec.target.template.metadata.labels = { - "app.kubernetes.io/part-of" = "argocd" + spec.forProvider.manifest = { + metadata = { + name = "webhook-test-argocd-github-webhook" + namespace = "platform" + } + spec.target = { + name = "webhook-test-argocd-github-webhook" + template.metadata.labels = { + "app.kubernetes.io/part-of" = "argocd" + } + } } } { From b5f1747bc32dabf5572ae1506933534376760ef6 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Mon, 31 Aug 2026 18:27:41 -0500 Subject: [PATCH 3/3] feat: generate webhook secret declaratively --- README.md | 17 ++- apis/gitopsstacks/definition.yaml | 8 +- examples/gitopsstacks/webhook.yaml | 2 +- functions/render/000-state-init.yaml.gotmpl | 9 ++ functions/render/010-state-status.yaml.gotmpl | 13 +- .../render/250-github-webhook.yaml.gotmpl | 114 +++++++++++++++++- tests/test-render/main.k | 65 +++++++++- 7 files changed, 213 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 43d1045..7b96b57 100644 --- a/README.md +++ b/README.md @@ -120,8 +120,9 @@ When `template` is set, the repo is created from the template instead of auto-in #### Immediate refresh after GitHub pushes Enable the native ArgoCD webhook to replace its polling delay with push-driven -refreshes. The secret value remains in AWS Secrets Manager; the Helm Release -stores only a reference to the External Secrets-managed Kubernetes Secret. +refreshes. By default, External Secrets generates the shared value once and +pushes it to AWS Secrets Manager. The Helm Release stores only a reference to +the External Secrets-managed Kubernetes Secret. ```yaml spec: @@ -133,14 +134,17 @@ spec: webhook: enabled: true secretStoreName: default - secretPath: github/argocd-webhook + secretPath: push/example-cluster/argo/github-webhook secretKey: webhookSecret maxPayloadSizeMB: 10 ``` This creates a GitHub `push` webhook for -`https://argocd.example.com/api/webhook`. SecretStack and a public ArgoCD -Gateway route are required. +`https://argocd.example.com/api/webhook`. The generated secret uses +`updatePolicy: IfNotExists` and is retained when the stack is deleted, making +AWS Secrets Manager the durable source of truth. SecretStack, a path under its +`push/*` write boundary, and a public ArgoCD Gateway route are required. Set +`generate: false` to read a pre-existing secret from another path instead. ### Stage 3: Crossplane Integration @@ -321,7 +325,8 @@ The Usage ensures ArgoCD CRDs stay alive until all ArgoCD Application CRs are cl | `repository.topics` | []string | no | `[]` | Repository topics | | `repository.deleteBranchOnMerge` | boolean | no | `true` | Auto-delete head branches on merge | | `repository.webhook.enabled` | boolean | no | `false` | Create a signed GitHub push webhook for immediate ArgoCD refreshes | -| `repository.webhook.secretStoreName` | string | no | `externalSecrets.secretStoreName` or `default` | ClusterSecretStore containing the shared secret | +| `repository.webhook.generate` | boolean | no | `true` | Generate the shared secret once and push it to AWS Secrets Manager | +| `repository.webhook.secretStoreName` | string | no | `externalSecrets.secretStoreName` or `default` | ClusterSecretStore used to push and read the shared secret | | `repository.webhook.secretPath` | string | when enabled | — | AWS Secrets Manager path containing the shared secret | | `repository.webhook.secretKey` | string | no | `webhookSecret` | JSON property containing the shared secret | | `repository.webhook.maxPayloadSizeMB` | integer | no | `10` | Maximum webhook payload accepted by ArgoCD | diff --git a/apis/gitopsstacks/definition.yaml b/apis/gitopsstacks/definition.yaml index 2696ab6..f6dbb4f 100644 --- a/apis/gitopsstacks/definition.yaml +++ b/apis/gitopsstacks/definition.yaml @@ -269,8 +269,12 @@ spec: description: Create a push webhook targeting the exposed ArgoCD server. Defaults to false. type: boolean default: false + generate: + description: Generate the shared secret and push it to AWS Secrets Manager. Defaults to true. Set false to use a pre-existing secret. + type: boolean + default: true secretStoreName: - description: ClusterSecretStore used to read the webhook secret. Defaults to externalSecrets.secretStoreName, then default. + description: ClusterSecretStore used to push and read the webhook secret. Defaults to externalSecrets.secretStoreName, then default. type: string secretPath: description: AWS Secrets Manager path containing the webhook secret. @@ -289,6 +293,8 @@ spec: x-kubernetes-validations: - rule: "!self.enabled || has(self.secretPath)" message: spec.repository.webhook.secretPath is required when the webhook is enabled + - rule: "!self.enabled || !self.generate || !has(self.secretPath) || self.secretPath.startsWith('push/')" + message: spec.repository.webhook.secretPath must start with push/ when secret generation is enabled required: - org externalSecrets: diff --git a/examples/gitopsstacks/webhook.yaml b/examples/gitopsstacks/webhook.yaml index 910fe7e..c89b662 100644 --- a/examples/gitopsstacks/webhook.yaml +++ b/examples/gitopsstacks/webhook.yaml @@ -18,6 +18,6 @@ spec: webhook: enabled: true secretStoreName: default - secretPath: github/argocd-webhook + secretPath: push/example-cluster/argo/github-webhook secretKey: webhookSecret maxPayloadSizeMB: 10 diff --git a/functions/render/000-state-init.yaml.gotmpl b/functions/render/000-state-init.yaml.gotmpl index f20c20a..9291984 100644 --- a/functions/render/000-state-init.yaml.gotmpl +++ b/functions/render/000-state-init.yaml.gotmpl @@ -158,9 +158,16 @@ {{- if hasKey $webhookSpec "enabled" }} {{- $webhookEnabled = $webhookSpec.enabled }} {{- end }} +{{- $webhookGenerate := true }} +{{- if hasKey $webhookSpec "generate" }} + {{- $webhookGenerate = $webhookSpec.generate }} +{{- end }} {{- if and $webhookEnabled (eq ($webhookSpec.secretPath | default "") "") }} {{- fail "spec.repository.webhook.secretPath is required when the webhook is enabled" }} {{- end }} +{{- if and $webhookEnabled $webhookGenerate (not (hasPrefix "push/" ($webhookSpec.secretPath | default ""))) }} + {{- fail "spec.repository.webhook.secretPath must start with push/ when secret generation is enabled" }} +{{- end }} {{- if and $webhookEnabled (not $exposureEnabled) }} {{- fail "spec.exposure.enabled must be true when the GitHub webhook is enabled" }} {{- end }} @@ -266,9 +273,11 @@ "url" $repoUrl "webhook" (dict "enabled" $webhookEnabled + "generate" $webhookGenerate "secretStoreName" ($webhookSpec.secretStoreName | default ($esoSpec.secretStoreName | default "default")) "secretPath" ($webhookSpec.secretPath | default "") "secretKey" ($webhookSpec.secretKey | default "webhookSecret") + "generatorName" (printf "%s-github-webhook" $name) "providerSecretName" (printf "%s-github-webhook" $name) "argocdSecretName" (printf "%s-argocd-github-webhook" $name) "url" (printf "https://%s/api/webhook" $exposureHostname) diff --git a/functions/render/010-state-status.yaml.gotmpl b/functions/render/010-state-status.yaml.gotmpl index 8410e2e..fdf9ff9 100644 --- a/functions/render/010-state-status.yaml.gotmpl +++ b/functions/render/010-state-status.yaml.gotmpl @@ -11,7 +11,7 @@ {{- $checkReady := dict }} {{- $checkExists := dict }} -{{- range $key := list "nodepool-gitops" "helm-release-argocd" "github-repository" "argocd-apps" "crossplane-app" "external-secret-argocd-repo-creds" "external-secret-github-webhook" "external-secret-argocd-webhook" "github-repository-webhook" "zitadel-credentials" "zitadel-provider-config" "zitadel-project" "zitadel-oidc-client" "zitadel-client-secret" }} +{{- range $key := list "nodepool-gitops" "helm-release-argocd" "github-repository" "argocd-apps" "crossplane-app" "webhook-secret-generator" "webhook-secret-push" "external-secret-argocd-repo-creds" "external-secret-github-webhook" "external-secret-argocd-webhook" "github-repository-webhook" "zitadel-credentials" "zitadel-provider-config" "zitadel-project" "zitadel-oidc-client" "zitadel-client-secret" }} {{- $entry := get $observed $key | default dict }} {{- $resource := $entry.resource | default dict }} {{- $status := $resource.status | default dict }} @@ -70,6 +70,10 @@ "exists" (get $checkExists "external-secret-argocd-repo-creds") ) "webhook" (dict + "generatorReady" (get $checkReady "webhook-secret-generator") + "generatorExists" (get $checkExists "webhook-secret-generator") + "pushReady" (get $checkReady "webhook-secret-push") + "pushExists" (get $checkExists "webhook-secret-push") "secretReady" (get $checkReady "external-secret-github-webhook") "secretExists" (get $checkExists "external-secret-github-webhook") "argocdSecretReady" (get $checkReady "external-secret-argocd-webhook") @@ -113,6 +117,13 @@ $state.observed.webhook.argocdSecretReady $state.observed.webhook.ready }} + {{- if $state.repository.webhook.generate }} + {{- $ready = and + $ready + $state.observed.webhook.generatorReady + $state.observed.webhook.pushReady + }} + {{- end }} {{- end }} {{- if $state.applications.apps.enabled }} {{- $ready = and $ready $state.observed.appsApp.ready }} diff --git a/functions/render/250-github-webhook.yaml.gotmpl b/functions/render/250-github-webhook.yaml.gotmpl index 5ed0a72..576feff 100644 --- a/functions/render/250-github-webhook.yaml.gotmpl +++ b/functions/render/250-github-webhook.yaml.gotmpl @@ -2,15 +2,100 @@ # # GitHub push webhook for immediate ArgoCD refreshes. # -# The shared secret stays in AWS Secrets Manager. External Secrets projects it -# into both the composite namespace (for provider-upjet-github) and the ArgoCD -# namespace. The Helm Release contains only ArgoCD's external-secret reference. +# The shared secret stays in AWS Secrets Manager. By default, ESO generates it +# once and pushes it under the SecretStack-owned push/ prefix. External Secrets +# then projects it into both the composite namespace (for provider-upjet-github) +# and the ArgoCD namespace. The Helm Release contains only ArgoCD's +# external-secret reference. # {{- $repo := $state.repository }} {{- $webhook := $repo.webhook }} {{- if $webhook.enabled }} +{{- if $webhook.generate }} +--- +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: Object +metadata: + name: {{ $state.name }}-webhook-secret-generator + annotations: + {{ setResourceNameAnnotation "webhook-secret-generator" }} + labels: {{ $state.labels | toJson }} +spec: + managementPolicies: {{ $state.managementPolicies | toJson }} + readiness: + policy: SuccessfulCreate + forProvider: + manifest: + apiVersion: generators.external-secrets.io/v1alpha1 + kind: Password + metadata: + name: {{ $webhook.generatorName }} + namespace: {{ $state.resourceNamespace }} + labels: {{ $state.labels | toJson }} + spec: + length: 64 + digits: 16 + symbols: 0 + allowRepeat: true + encoding: raw + providerConfigRef: + name: {{ $state.kubernetesProviderConfigRef.name }} + kind: {{ $state.kubernetesProviderConfigRef.kind }} + +--- +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: Object +metadata: + name: {{ $state.name }}-webhook-secret-push + annotations: + {{ setResourceNameAnnotation "webhook-secret-push" }} + labels: {{ $state.labels | toJson }} +spec: + managementPolicies: {{ $state.managementPolicies | toJson }} + readiness: + policy: DeriveFromObject + forProvider: + manifest: + apiVersion: external-secrets.io/v1alpha1 + kind: PushSecret + metadata: + name: {{ $state.name }}-github-webhook + namespace: {{ $state.resourceNamespace }} + labels: {{ $state.labels | toJson }} + spec: + updatePolicy: IfNotExists + deletionPolicy: None + refreshInterval: 1h0m0s + secretStoreRefs: + - name: {{ $webhook.secretStoreName }} + kind: ClusterSecretStore + selector: + generatorRef: + apiVersion: generators.external-secrets.io/v1alpha1 + kind: Password + name: {{ $webhook.generatorName }} + data: + - match: + secretKey: password + remoteRef: + remoteKey: {{ $webhook.secretPath }} + property: {{ $webhook.secretKey }} + metadata: + apiVersion: kubernetes.external-secrets.io/v1alpha1 + kind: PushSecretMetadata + spec: + secretPushFormat: string + description: {{ printf "ArgoCD GitHub webhook secret for %s" $state.clusterName | quote }} + tags: + hops.ops.com.ai/managed: "true" + hops.ops.com.ai/gitopsstack: {{ $state.name | quote }} + providerConfigRef: + name: {{ $state.kubernetesProviderConfigRef.name }} + kind: {{ $state.kubernetesProviderConfigRef.kind }} +{{- end }} + --- apiVersion: kubernetes.m.crossplane.io/v1alpha1 kind: Object @@ -125,6 +210,29 @@ spec: kind: {{ $state.githubProviderConfigRef.kind }} {{- $obs := $state.observed }} +{{- if and $webhook.generate $obs.webhook.generatorReady $obs.webhook.pushReady }} +--- +apiVersion: protection.crossplane.io/v1beta1 +kind: Usage +metadata: + name: {{ $state.name }}-delete-webhook-push-before-generator + annotations: + {{ setResourceNameAnnotation "usage-webhook-push-generator" }} + labels: {{ $state.labels | toJson }} +spec: + replayDeletion: true + of: + apiVersion: kubernetes.m.crossplane.io/v1alpha1 + kind: Object + resourceRef: + name: {{ $state.name }}-webhook-secret-generator + by: + apiVersion: kubernetes.m.crossplane.io/v1alpha1 + kind: Object + resourceRef: + name: {{ $state.name }}-webhook-secret-push +{{- end }} + {{- if and $obs.repository.ready $obs.webhook.ready }} --- apiVersion: protection.crossplane.io/v1beta1 diff --git a/tests/test-render/main.k b/tests/test-render/main.k index f74bf56..66d2c6d 100644 --- a/tests/test-render/main.k +++ b/tests/test-render/main.k @@ -974,7 +974,7 @@ _items = [ } # ========================================================================== - # Test 20: GitHub webhook uses distinct Secrets in a shared namespace + # Test 20: GitHub webhook generates once and uses distinct target Secrets # ========================================================================== metav1alpha1.CompositionTest { metadata.name = "github-webhook-refreshes-argocd" @@ -1001,7 +1001,7 @@ _items = [ webhook = { enabled = True secretStoreName = "aws-secrets" - secretPath = "github/argocd-webhook" + secretPath = "push/production/argo/github-webhook" secretKey = "hook_secret" maxPayloadSizeMB = 8 } @@ -1018,6 +1018,65 @@ _items = [ secret.githubSecret = "$webhook-test-argocd-github-webhook:secret" } } + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "webhook-test-webhook-secret-generator" + spec = { + readiness.policy = "SuccessfulCreate" + forProvider.manifest = { + apiVersion = "generators.external-secrets.io/v1alpha1" + kind = "Password" + metadata = { + name = "webhook-test-github-webhook" + namespace = "platform" + } + spec = { + length = 64 + digits = 16 + symbols = 0 + allowRepeat = True + encoding = "raw" + } + } + } + } + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "webhook-test-webhook-secret-push" + spec.forProvider.manifest = { + apiVersion = "external-secrets.io/v1alpha1" + kind = "PushSecret" + metadata = { + name = "webhook-test-github-webhook" + namespace = "platform" + } + spec = { + updatePolicy = "IfNotExists" + deletionPolicy = "None" + secretStoreRefs = [{ + name = "aws-secrets" + kind = "ClusterSecretStore" + }] + selector.generatorRef = { + apiVersion = "generators.external-secrets.io/v1alpha1" + kind = "Password" + name = "webhook-test-github-webhook" + } + data = [{ + match = { + secretKey = "password" + remoteRef = { + remoteKey = "push/production/argo/github-webhook" + property = "hook_secret" + } + } + metadata.spec.secretPushFormat = "string" + }] + } + } + } { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" kind = "Object" @@ -1038,7 +1097,7 @@ _items = [ data = [{ secretKey = "webhookSecret" remoteRef = { - key = "github/argocd-webhook" + key = "push/production/argo/github-webhook" property = "hook_secret" } }]