feat: initial ArgoCD GitOps self-management setup
Validate manifests / validate (push) Failing after 4s

- Base references upstream ArgoCD v3.3.6 install.yaml
- Production overlay applies all cluster customizations:
  - KSOPS CMP sidecar on argocd-repo-server
  - Custom ConfigMaps (argocd-cm, argocd-cmd-params-cm, argocd-rbac-cm, argocd-notifications-cm)
  - Redis migrated to shared-redis.data:6379
  - Traefik IngressRoute for argocd.olb42.com
  - SOPS-encrypted secrets (age key, redis auth, gitea tokens, repo-creds)
- Bootstrap Application with prune:false for safe self-management
This commit is contained in:
2026-04-19 23:36:09 +01:00
commit 40eb3b842c
17 changed files with 531 additions and 0 deletions
+117
View File
@@ -0,0 +1,117 @@
name: Validate manifests
on:
push:
pull_request:
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install tools
run: |
curl -sL https://github.com/yannh/kubeconform/releases/latest/download/kubeconform-linux-amd64.tar.gz \
| tar xz -C /usr/local/bin
# Traefik IngressRoute is a CRD, so kubeconform needs an extra schema source.
mkdir -p .ci-schemas/traefik.io
curl -fsSL https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/traefik.io/ingressroute_v1alpha1.json \
-o .ci-schemas/traefik.io/ingressroute_v1alpha1.json
cp .ci-schemas/traefik.io/ingressroute_v1alpha1.json .ci-schemas/traefik.io/IngressRoute_v1alpha1.json
cp .ci-schemas/traefik.io/ingressroute_v1alpha1.json .ci-schemas/traefik.io/IngressRoute.json
cp .ci-schemas/traefik.io/ingressroute_v1alpha1.json .ci-schemas/traefik.io/ingressroute.json
# ArgoCD Application is also a CRD.
mkdir -p .ci-schemas/argoproj.io
curl -fsSL https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/argoproj.io/application_v1alpha1.json \
-o .ci-schemas/argoproj.io/application_v1alpha1.json
cp .ci-schemas/argoproj.io/application_v1alpha1.json .ci-schemas/argoproj.io/Application_v1alpha1.json
cp .ci-schemas/argoproj.io/application_v1alpha1.json .ci-schemas/argoproj.io/Application.json
cp .ci-schemas/argoproj.io/application_v1alpha1.json .ci-schemas/argoproj.io/application.json
- name: kubeconform - raw YAML
run: |
bootstrap_enabled=false
if [ -f bootstrap/config.yaml ] && grep -Eq '^[[:space:]]*enabled:[[:space:]]*true[[:space:]]*$' bootstrap/config.yaml; then
bootstrap_enabled=true
fi
mapfile -t manifests < <(
find . -type f -name '*.yaml' \
! -path './.gitea/*' \
! -name '.sops.yaml' \
! -name '*.secret.yaml' \
! -name 'kustomization.yaml' \
! \( -name 'secret*.yaml' \) \
! \( -path '*/config/*' -prune \) \
! -path './bootstrap/config.yaml' \
| sort
)
if [ "$bootstrap_enabled" != "true" ]; then
filtered=()
for manifest in "${manifests[@]}"; do
[ "$manifest" = "./bootstrap/application.yaml" ] && continue
filtered+=("$manifest")
done
manifests=("${filtered[@]}")
fi
if [ "${#manifests[@]}" -eq 0 ]; then
echo "No manifests found"
exit 0
fi
printf '%s\n' "${manifests[@]}" \
| xargs kubeconform \
-strict \
-kubernetes-version 1.35.0 \
-schema-location default \
-schema-location '.ci-schemas/{{.Group}}/{{.ResourceKind}}{{.KindSuffix}}.json' \
-schema-location '.ci-schemas/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json' \
-summary
- name: SOPS - check no secret files committed unencrypted
run: |
fail=0
while IFS= read -r file; do
if ! grep -q 'sops:' "$file"; then
echo "ERROR: $file appears to be unencrypted"
fail=1
fi
done < <(
find . -type f \( -name '*.secret.yaml' -o -name '*.enc.yaml' \) | sort
)
exit "$fail"
- name: Apply bootstrap Application
env:
KUBECONFIG_B64: ${{ secrets.KUBECONFIG_B64 }}
run: |
if [ "${GITHUB_EVENT_NAME:-}" != "push" ] || [ "${GITHUB_REF:-}" != "refs/heads/main" ]; then
echo "Skipping bootstrap apply: only push events on main may apply"
exit 0
fi
if [ ! -f bootstrap/config.yaml ] || ! grep -Eq '^[[:space:]]*enabled:[[:space:]]*true[[:space:]]*$' bootstrap/config.yaml; then
echo "Skipping bootstrap apply: bootstrap/config.yaml is not enabled"
exit 0
fi
if [ -z "${KUBECONFIG_B64:-}" ]; then
echo "Warning: KUBECONFIG_B64 secret not set — skipping bootstrap apply"
exit 0
fi
curl -fsSL https://dl.k8s.io/release/$(curl -fsSL https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl \
-o /usr/local/bin/kubectl
chmod +x /usr/local/bin/kubectl
mkdir -p "${HOME}/.kube"
printf '%s' "$KUBECONFIG_B64" | base64 -d > "${HOME}/.kube/config"
kubectl apply -f bootstrap/application.yaml
+9
View File
@@ -0,0 +1,9 @@
.DS_Store
.idea/
.vscode/
*.swp
*.swo
# Local plaintext twins for sync-managed secrets. Keep these untracked.
manifest/overlays/**/secret.yaml
manifest/components/**/secret.yaml
+5
View File
@@ -0,0 +1,5 @@
creation_rules:
- path_regex: manifest/(overlays|components)/.*/.*\.enc\.yaml$
age: age1s0gzgh8c00tgnkgxrqyz2nu0k56xvc9ev3jdenkmu90egux9xfmsxvvj43
- path_regex: manifest/(overlays|components)/.*/secret\.secret\.yaml$
age: age1s0gzgh8c00tgnkgxrqyz2nu0k56xvc9ev3jdenkmu90egux9xfmsxvvj43
+29
View File
@@ -0,0 +1,29 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: argocd-production
namespace: argocd
labels:
app.kubernetes.io/managed-by: argocd
app.kubernetes.io/name: argocd
spec:
project: default
source:
repoURL: http://gitea-ha-http.apps:3000/olb42/argocd.git
targetRevision: main
path: manifest/overlays/production
plugin:
name: ksops
destination:
server: https://kubernetes.default.svc
namespace: argocd
syncPolicy:
automated:
# prune: false — ArgoCD self-managing itself; never auto-prune to avoid
# accidentally removing ArgoCD components and losing cluster management.
prune: false
selfHeal: true
syncOptions:
- CreateNamespace=false
- ApplyOutOfSyncOnly=true
- ServerSideApply=true
+3
View File
@@ -0,0 +1,3 @@
enabled: true
target_namespace: argocd
apply_from_branch: main
+7
View File
@@ -0,0 +1,7 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: argocd
resources:
- https://raw.githubusercontent.com/argoproj/argo-cd/v3.3.6/manifests/install.yaml
@@ -0,0 +1,95 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: argocd-cm
namespace: argocd
data:
accounts.homepage: apiKey
accounts.mcpserver: apiKey
exec.enabled: "true"
kustomize.buildOptions: --enable-helm
resource.customizations.ignoreResourceUpdates.ConfigMap: |
jqPathExpressions:
- '.metadata.annotations."cluster-autoscaler.kubernetes.io/last-updated"'
- '.metadata.annotations."control-plane.alpha.kubernetes.io/leader"'
resource.customizations.ignoreResourceUpdates.Endpoints: |
jsonPointers:
- /metadata
- /subsets
resource.customizations.ignoreResourceUpdates.all: |
jsonPointers:
- /status
resource.customizations.ignoreResourceUpdates.apps_ReplicaSet: |
jqPathExpressions:
- '.metadata.annotations."deployment.kubernetes.io/desired-replicas"'
- '.metadata.annotations."deployment.kubernetes.io/max-replicas"'
- '.metadata.annotations."rollout.argoproj.io/desired-replicas"'
resource.customizations.ignoreResourceUpdates.argoproj.io_Application: |
jqPathExpressions:
- '.metadata.annotations."notified.notifications.argoproj.io"'
- '.metadata.annotations."argocd.argoproj.io/refresh"'
- '.metadata.annotations."argocd.argoproj.io/hydrate"'
- '.operation'
resource.customizations.ignoreResourceUpdates.argoproj.io_Rollout: |
jqPathExpressions:
- '.metadata.annotations."notified.notifications.argoproj.io"'
resource.customizations.ignoreResourceUpdates.autoscaling_HorizontalPodAutoscaler: |
jqPathExpressions:
- '.metadata.annotations."autoscaling.alpha.kubernetes.io/behavior"'
- '.metadata.annotations."autoscaling.alpha.kubernetes.io/conditions"'
- '.metadata.annotations."autoscaling.alpha.kubernetes.io/metrics"'
- '.metadata.annotations."autoscaling.alpha.kubernetes.io/current-metrics"'
resource.customizations.ignoreResourceUpdates.discovery.k8s.io_EndpointSlice: |
jsonPointers:
- /metadata
- /endpoints
- /ports
resource.exclusions: |
- apiGroups:
- ''
- discovery.k8s.io
kinds:
- Endpoints
- EndpointSlice
- apiGroups:
- coordination.k8s.io
kinds:
- Lease
- apiGroups:
- authentication.k8s.io
- authorization.k8s.io
kinds:
- SelfSubjectReview
- TokenReview
- LocalSubjectAccessReview
- SelfSubjectAccessReview
- SelfSubjectRulesReview
- SubjectAccessReview
- apiGroups:
- certificates.k8s.io
kinds:
- CertificateSigningRequest
- apiGroups:
- cert-manager.io
kinds:
- CertificateRequest
- apiGroups:
- cilium.io
kinds:
- CiliumIdentity
- CiliumEndpoint
- CiliumEndpointSlice
- apiGroups:
- kyverno.io
- reports.kyverno.io
- wgpolicyk8s.io
kinds:
- PolicyReport
- ClusterPolicyReport
- EphemeralReport
- ClusterEphemeralReport
- AdmissionReport
- ClusterAdmissionReport
- BackgroundScanReport
- ClusterBackgroundScanReport
- UpdateRequest
@@ -0,0 +1,8 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: argocd-cmd-params-cm
namespace: argocd
data:
redis.server: shared-redis.data:6379
server.insecure: "true"
@@ -0,0 +1,26 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: argocd-cmp-cm
namespace: argocd
data:
ksops.yaml: |
apiVersion: argoproj.io/v1alpha1
kind: ConfigManagementPlugin
metadata:
name: ksops
spec:
generate:
command: ["sh", "-c"]
args:
- |
if [ -n "$ARGOCD_ENV_BRANCH_SLUG" ]; then
sed -i "s/BRANCH_SLUG_PLACEHOLDER/$ARGOCD_ENV_BRANCH_SLUG/g" kustomization.yaml
fi
if [ -n "$ARGOCD_ENV_IMAGE_TAG" ]; then
kustomize edit set image "ngorse/swarm-dns-watcher:$ARGOCD_ENV_IMAGE_TAG"
fi
kustomize build --enable-alpha-plugins --enable-exec .
discover:
find:
glob: "**/kustomization.yaml"
@@ -0,0 +1,27 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: argocd-notifications-cm
namespace: argocd
data:
service.webhook.gitea: |
url: https://git.olb42.com
headers:
- name: Authorization
value: token $GITEA_TOKEN
- name: Content-Type
value: application/json
template.gitea-create-issue: |
webhook:
gitea:
method: POST
path: /api/v1/repos/olb42/k8s-manifests/issues
body: |
{
"title": "Deploy failed: {{.app.metadata.name}}",
"body": "## Deployment Failure\n\n**App:** `{{.app.metadata.name}}`\n**Namespace:** `{{.app.spec.destination.namespace}}`\n**Sync status:** `{{.app.status.sync.status}}`\n**Health:** `{{.app.status.health.status}}`\n\n### Conditions\n```\n{{range .app.status.conditions}}{{.message}}\n{{end}}```\n\n[View in ArgoCD](https://argocd.olb42.com/applications/{{.app.metadata.name}})",
"labels": [1]
}
trigger.on-deploy-failed: |
- when: app.status.health.status == 'Degraded' || app.status.sync.status == 'Unknown'
send: [gitea-create-issue]
@@ -0,0 +1,12 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: argocd-rbac-cm
namespace: argocd
data:
policy.csv: |
p, role:app-manager, applications, *, */*, allow
p, role:app-viewer, applications, get, */*, allow
g, mcpserver, role:app-manager
g, homepage, role:app-viewer
policy.default: role:readonly
@@ -0,0 +1,36 @@
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: argocd-server
namespace: argocd
annotations:
gethomepage.dev/app: argocd-server
gethomepage.dev/description: CI Service
gethomepage.dev/enabled: "true"
gethomepage.dev/href: https://argocd.olb42.com
gethomepage.dev/icon: argo-cd
gethomepage.dev/name: Argo-CD
gethomepage.dev/namespace: argocd
gethomepage.dev/widget.key: '{{HOMEPAGE_VAR_ARGOCD_TOKEN}}'
gethomepage.dev/widget.type: argocd
gethomepage.dev/widget.url: http://argocd-server.argocd
spec:
entryPoints:
- websecure
routes:
- kind: Rule
match: Host(`argocd.olb42.com`)
priority: 10
services:
- name: argocd-server
port: 80
scheme: http
- kind: Rule
match: Host(`argocd.olb42.com`) && Header(`Content-Type`, `application/grpc`)
priority: 11
services:
- name: argocd-server
port: 80
scheme: h2c
tls:
secretName: olb42-com-tls
@@ -0,0 +1,23 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: ksops-cmp-config
namespace: argocd
data:
plugin.yaml: |
apiVersion: argoproj.io/v1alpha1
kind: ConfigManagementPlugin
metadata:
name: ksops
spec:
version: v1.0
discover:
find:
glob: "**/ksops-generator.yaml"
generate:
command:
- kustomize
- build
- --enable-alpha-plugins
- --enable-exec
- .
@@ -0,0 +1,10 @@
apiVersion: viaduct.ai/v1
kind: ksops
metadata:
name: argocd-secret-generator
annotations:
config.kubernetes.io/function: |
exec:
path: ksops
files:
- ./secret.enc.yaml
@@ -0,0 +1,22 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: argocd
resources:
- ../../base
- argocd-cmp-cm.yaml
- ksops-cmp-config.yaml
- ingressroute.yaml
patches:
- path: argocd-cm-patch.yaml
- path: argocd-cmd-params-cm-patch.yaml
- path: argocd-rbac-cm-patch.yaml
- path: argocd-notifications-cm-patch.yaml
- path: repo-server-patch.yaml
# KSOPS decrypts secret.enc.yaml at render time.
# The presence of ksops-generator.yaml activates the KSOPS CMP plugin.
generators:
- ksops-generator.yaml
@@ -0,0 +1,61 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: argocd-repo-server
namespace: argocd
spec:
template:
spec:
initContainers:
- name: copyutil
image: quay.io/argoproj/argocd:v3.3.6
command:
- sh
- -c
args:
- /bin/cp --update=none /usr/local/bin/argocd /var/run/argocd/argocd && /bin/ln -s /var/run/argocd/argocd /var/run/argocd/argocd-cmp-server
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: true
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
volumeMounts:
- mountPath: /var/run/argocd
name: var-files
containers:
- name: ksops
image: viaductoss/ksops:v4.3.2
command:
- /var/run/argocd/argocd-cmp-server
env:
- name: SOPS_AGE_KEY_FILE
value: /age/key.txt
securityContext:
runAsNonRoot: true
runAsUser: 999
volumeMounts:
- mountPath: /var/run/argocd
name: var-files
- mountPath: /home/argocd/cmp-server/plugins
name: plugins
- mountPath: /home/argocd/cmp-server/config/plugin.yaml
name: argocd-cmp-cm
subPath: ksops.yaml
- mountPath: /age
name: age-key
readOnly: true
- mountPath: /tmp
name: cmp-tmp
volumes:
- name: argocd-cmp-cm
configMap:
name: argocd-cmp-cm
- name: age-key
secret:
secretName: argocd-age-key
- name: cmp-tmp
emptyDir: {}
@@ -0,0 +1,41 @@
apiVersion: ENC[AES256_GCM,data:nAs=,iv:DjVwuYqCpa9e9TY59qPUqG2yjrGOykfDUI/bH00cYC8=,tag:AgFF0Bb3YXZeUKb2BmA9Pg==,type:str]
kind: ENC[AES256_GCM,data:oBkqTrVH,iv:/M+hMx4ioTgtqjySJ0SvIv2W7huDyH1jAFCRXfHl4MQ=,tag:Qmq8JHdpdbpTA1WRrt8kuA==,type:str]
metadata:
name: ENC[AES256_GCM,data:MhdiC/arAhFbp2yzt4o=,iv:8pmcBcIj+iiVymud92ZIbMgEt9XCN7EFEYnL1ocwkWQ=,tag:T7/Dr24TtqACE7J171U/Rg==,type:str]
namespace: ENC[AES256_GCM,data:60vOnJKT,iv:t9Fapbuu9Ya311uzqZ86YjwEAR76PpOjxMDdGgfbxpA=,tag:6ERx/f670/SNOMikO+q/4w==,type:str]
type: ENC[AES256_GCM,data:i+s8ejKv,iv:LOqBlClSNxZY/RA50V0LNWD8a2XsHOI7bZSastpQ2E8=,tag:hlJ3TVaK6c4j/GSAK4EbqA==,type:str]
stringData:
key.txt: ENC[AES256_GCM,data:BYd7VuC7Tpu+fBfT2f+UwdmbzQnxDbAcapvNyoQR4qmcV/t4y7gjiD5Og5Tcpw3WhLLA3JBTuea7R/FqLwsHxqM1ZFsO17dUSZw=,iv:rodtfLRQUTyyt9P+7bhP6i/BEgZe5fePPDSYC1VHlq0=,tag:WGNFEStQo6CInbvNFbhrVw==,type:str]
sops:
age:
- recipient: age1s0gzgh8c00tgnkgxrqyz2nu0k56xvc9ev3jdenkmu90egux9xfmsxvvj43
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBqTVFMS3hqVFpFYkJPYkdl
QTU5ZTNtL3NhbWhFRUt4N1ZmOHNUT2Z6SnhjCmFGYTdsVm9lWWJBMURQdExkQ3pD
eFZvYTdzaS9Eb0dURGNXaERVSm1jSUEKLS0tIGpJSjJqU2NDZXBNSmIxRWg5Nk1G
aFNPeGdCUUxuUHJPZWdkWU1QWW8yN2MKo5P8YpmEx1K6n2KmJ5ZinI8tHTt79U+x
QIPHIOsL0vyRRDwDrhxagd1MX4OEOZl590GvT6IiNY0lN4gCdjoNow==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-04-19T22:34:54Z"
mac: ENC[AES256_GCM,data:5Ya2agLt9jHqylYh72cN9SNWpinNkSnbfAoUYXtPI+lp69qxYIq3SelE8vulWcDoOZIDxEepttVpIo+pkyQbxKwMms/iHLyEC1EIFg9MVIaP5KIdrr93p/aExMkFyPcDLiqC0XcCMuBhNTaZvQ5IE+wDs3JthUIa5F1ZL+3eTt4=,iv:iQLvWgAegOMYas/nxD3k3lXZ7wOjMGXYq/fuiin1+zU=,tag:F/YLP8oDDlGMisz0XLCJDA==,type:str]
unencrypted_suffix: _unencrypted
version: 3.12.2
---
apiVersion: ENC[AES256_GCM,data:THQ=,iv:kX/yYct8NmlujHHZ2Ui29wcGsjHojnHvYzx24i6CGxA=,tag:XtQInMnByeiit852ZAom7Q==,type:str]
kind: ENC[AES256_GCM,data:4NDGjFRb,iv:ImM1RSLM7Sa0J/RNPOeSmBUdDbxg32yIImurjgAIocM=,tag:5HLeek7XNSHDZ9ZlZsbahQ==,type:str]
metadata:
name: ENC[AES256_GCM,data:EotC086GrXiwxc2a,iv:xC5j6+Wokph7O5few7Kkzxwg8PW9eCE56QynyJftdUE=,tag:MvHqWXnwj3cVoie3VbTf/Q==,type:str]
namespace: ENC[AES256_GCM,data:Vo+Jc74Q,iv:Xfl7xjj8obV62L5U7IcK9XhY81aOvjWpQVu4XveMU/E=,tag:FDL3yxeO9DtVvHJY6LpWGA==,type:str]
type: ENC[AES256_GCM,data:U6aSqKrG,iv:iDBCN4GNTF01+c1sURCRDMXyY+WZ8IYYEYCQx3AX2As=,tag:QzwWCRXZWzncLo668Ei9RQ==,type:str]
stringData:
auth: ENC[AES256_GCM,data:IoYCciqcZIahHzyqIfs9dPtOU9//DU8ISSf/w5R2Og==,iv:1xqmXww4P+C/auZ6IjTISChTfhDEC76fYPi7beNl7Pc=,tag:c5ze7wDGfCxp2xiACbRcQg==,type:str]
sops:
age:
- recipient: age1s0gzgh8c00tgnkgxrqyz2nu0k56xvc9ev3jdenkmu90egux9xfmsxvvj43
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBqTVFMS3hqVFpFYkJPYkdl
QTU5ZTNtL3NhbWhFRUt4N1ZmOHNUT2Z6SnhjCmFGYTdsVm9lWWJBMURQdExkQ3pD
eFZvYTdzaS9Eb0dURGNXaERVSm1jSUEKLS0tIGpJSjJqU2NDZXBNSmIxRWg5Nk1G
... (145 lines truncated)