Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/on-pr.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/on-push-main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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:: \
Expand Down
41 changes: 40 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ 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:**
- One resource, one API — ArgoCD, GitHub repo, and Applications all wired automatically
- 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
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions apis/gitopsstacks/definition.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions examples/gitopsstacks/webhook.yaml
Original file line number Diff line number Diff line change
@@ -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
32 changes: 32 additions & 0 deletions functions/render/000-state-init.yaml.gotmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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" }}
Expand Down Expand Up @@ -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 }}
Expand Down Expand Up @@ -180,6 +199,7 @@
{{- $state := dict
"name" $name
"clusterName" $clusterName
"resourceNamespace" $resourceNamespace
"namespace" $namespace
"managementPolicies" $managementPolicies
"nonDeletingManagementPolicies" $nonDeletingManagementPolicies
Expand Down Expand Up @@ -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)
Expand Down
29 changes: 28 additions & 1 deletion functions/render/010-state-status.yaml.gotmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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 }}
Expand Down
11 changes: 11 additions & 0 deletions functions/render/200-helm-release-argocd.yaml.gotmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading