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..7b96b57 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,35 @@ 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. 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: + exposure: + enabled: true + hostname: argocd.example.com + repository: + org: hops-ops + webhook: + enabled: true + secretStoreName: default + secretPath: push/example-cluster/argo/github-webhook + secretKey: webhookSecret + maxPayloadSizeMB: 10 +``` + +This creates a GitHub `push` webhook for +`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 Enable ArgoCD-managed Crossplane resources for full platform automation. @@ -291,6 +324,12 @@ 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.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 | | `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 +371,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..f6dbb4f 100644 --- a/apis/gitopsstacks/definition.yaml +++ b/apis/gitopsstacks/definition.yaml @@ -261,6 +261,40 @@ 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 + 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 push and 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 + - 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: @@ -332,6 +366,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..c89b662 --- /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: 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 4f9a91a..9291984 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,24 @@ {{- if hasKey $repoSpec "deleteBranchOnMerge" }} {{- $repoDeleteBranchOnMerge = $repoSpec.deleteBranchOnMerge }} {{- end }} +{{- $webhookSpec := $repoSpec.webhook | default dict }} +{{- $webhookEnabled := false }} +{{- 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 }} # Template config (optional) {{- $repoTemplate := $repoSpec.template | default dict }} @@ -180,6 +199,7 @@ {{- $state := dict "name" $name "clusterName" $clusterName + "resourceNamespace" $resourceNamespace "namespace" $namespace "managementPolicies" $managementPolicies "nonDeletingManagementPolicies" $nonDeletingManagementPolicies @@ -251,6 +271,18 @@ "deleteBranchOnMerge" $repoDeleteBranchOnMerge "allowDelete" ($repoSpec.allowDelete | default false) "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) + "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..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" "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 }} @@ -69,6 +69,18 @@ "ready" (get $checkReady "external-secret-argocd-repo-creds") "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") + "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 +110,21 @@ {{- 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 + }} + {{- 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 }} {{- end }} diff --git a/functions/render/200-helm-release-argocd.yaml.gotmpl b/functions/render/200-helm-release-argocd.yaml.gotmpl index 91640c1..c4d5499 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.argocdSecretName)) + ) + }} + {{- $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..576feff --- /dev/null +++ b/functions/render/250-github-webhook.yaml.gotmpl @@ -0,0 +1,305 @@ +# code: language=yaml +# +# GitHub push webhook for immediate ArgoCD refreshes. +# +# 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 +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.providerSecretName }} + namespace: {{ $state.resourceNamespace }} + spec: + refreshInterval: 1h + secretStoreRef: + name: {{ $webhook.secretStoreName }} + kind: ClusterSecretStore + target: + name: {{ $webhook.providerSecretName }} + 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.argocdSecretName }} + namespace: {{ $state.argocd.namespace }} + spec: + refreshInterval: 1h + secretStoreRef: + name: {{ $webhook.secretStoreName }} + kind: ClusterSecretStore + target: + name: {{ $webhook.argocdSecretName }} + 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.providerSecretName }} + key: url + secretSecretRef: + name: {{ $webhook.providerSecretName }} + key: secret + providerConfigRef: + name: {{ $state.githubProviderConfigRef.name }} + 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 +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..66d2c6d 100644 --- a/tests/test-render/main.k +++ b/tests/test-render/main.k @@ -972,6 +972,186 @@ _items = [ ] } } + + # ========================================================================== + # Test 20: GitHub webhook generates once and uses distinct target Secrets + # ========================================================================== + 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" + namespace = "platform" + exposure = { + enabled = True + hostname = "argocd.example.com" + } + repository = { + org = "hops-ops" + name = "platform-gitops" + webhook = { + enabled = True + secretStoreName = "aws-secrets" + secretPath = "push/production/argo/github-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-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" + 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 = "push/production/argo/github-webhook" + property = "hook_secret" + } + }] + } + } + } + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "webhook-test-argocd-webhook-secret" + 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" + } + } + } + } + { + 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