From 50d746d03f05206638963705e392be9d3e57ea89 Mon Sep 17 00:00:00 2001 From: Oleg Lukasonok Date: Wed, 29 Jul 2026 23:48:32 +0300 Subject: [PATCH] Add gitops-argo-cd agent skill Consolidates the five cogarchhubgitops-* source skills into one skill with scope routing: ArgoCD Applications, Copier templates, Helm values, environment answer files, and the end-to-end resource-addition walkthrough. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 51 +++- SKILL.md | 252 +++++++++++++++++ .../argocd/application.yaml.jinja | 37 +++ .../copier-variables.yaml | 46 +++ .../resources/values.yaml.jinja | 48 ++++ references/adding-a-resource.md | 195 +++++++++++++ references/argocd-applications.md | 143 ++++++++++ references/cogarchhubgitops-reference.md | 161 +++++++++++ references/copier-templates.md | 205 ++++++++++++++ references/environments.md | 265 ++++++++++++++++++ references/helm-values.md | 213 ++++++++++++++ 11 files changed, 1615 insertions(+), 1 deletion(-) create mode 100644 SKILL.md create mode 100644 assets/example-cluster-resource/argocd/application.yaml.jinja create mode 100644 assets/example-cluster-resource/copier-variables.yaml create mode 100644 assets/example-cluster-resource/resources/values.yaml.jinja create mode 100644 references/adding-a-resource.md create mode 100644 references/argocd-applications.md create mode 100644 references/cogarchhubgitops-reference.md create mode 100644 references/copier-templates.md create mode 100644 references/environments.md create mode 100644 references/helm-values.md diff --git a/README.md b/README.md index ca379f1..6bd7233 100644 --- a/README.md +++ b/README.md @@ -1 +1,50 @@ -# gitops-argo-cd \ No newline at end of file +# gitops-argo-cd + +Agent Skill for GitOps repositories where **ArgoCD** syncs cluster state from a +**Copier**-templated Git repo — app-of-apps `Application` manifests under `cluster/argocd/`, +Helm `values.yaml.jinja` templates under `cluster/resources/`, Jinja2 conditional directory +names used as feature flags, and per-environment `copier-answers.yaml` files. + +The rules are repo-agnostic; `references/cogarchhubgitops-reference.md` records +CogArchHubGitOps as the concrete reference implementation (bands in use, flag names, global +settings, ordering dependencies). + +## Layout + +```text +gitops-argo-cd/ +├── SKILL.md # Entry point — scope routing + shared rules +├── references/ +│ ├── argocd-applications.md # Application manifests, sync policy, troubleshooting +│ ├── copier-templates.md # copier.yaml, .jinja authoring, render() macro +│ ├── helm-values.md # values.yaml.jinja, images, global block, probes +│ ├── environments.md # copier-answers.yaml, flags, drift, copier update +│ ├── adding-a-resource.md # End-to-end walkthrough across all three layers +│ └── cogarchhubgitops-reference.md # The CogArchHubGitOps instance +└── assets/ + └── example-cluster-resource/ # Starting templates for a new resource + ├── argocd/application.yaml.jinja + ├── resources/values.yaml.jinja + └── copier-variables.yaml +``` + +## The three-layer contract + +Every managed resource spans three layers that share one feature flag and one conditional +directory expression: + +```text +copier.yaml → declares the flag + variables +cluster/argocd/{{ 'NNN-name' if flag }}/ → ArgoCD Application +cluster/resources/{{ 'NNN-name' if flag }}/ → Helm values +``` + +## Deploy + +```bash +cd ../skill-manager/scripts +task deploy -- --skill-dir="$(cd ../../gitops-argo-cd && pwd)" +``` + +Built with the `skill-manager` skill, following the +[agentskills.io specification](https://agentskills.io/specification). diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..83f3d82 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,252 @@ +--- +name: gitops-argo-cd +description: > + Create, review, refactor, debug, and maintain GitOps repositories where ArgoCD syncs + cluster state from a Copier-templated Git repo — app-of-apps ArgoCD Application manifests + under cluster/argocd/, Helm values.yaml.jinja templates under cluster/resources/, Jinja2 + conditional directory names used as feature flags, copier.yaml variable schemas, and + per-environment copier-answers.yaml files. Use when adding a new operator, database, or + workload to a cluster; editing sync policies, prune/self-heal, finalizers, or destination + namespaces; wiring container image address/tag through Copier variables into Helm values; + enabling or disabling a feature flag for one environment; comparing environment drift or + running copier update to re-render a deployed environment; or debugging an app stuck + OutOfSync, a template that fails to render, or blank lines that break rendered YAML — even + when the user only names their repository (for example CogArchHubGitOps) instead of saying + "GitOps" or "ArgoCD". +license: Proprietary +metadata: + author: workspace-skills-code-agent + version: "1.0" + spec: agentskills.io/specification + reference-implementation: CogArchHubGitOps +compatibility: Designed for Cline, Claude Code, GitHub Copilot, OpenAI Codex, and other compatible agent environments +--- + +# ArgoCD GitOps on a Copier Template Repository + +Work rules for GitOps repositories that combine three layers: **Copier** renders the +repository from per-environment answer files, **ArgoCD** reads the rendered output and syncs +it, and **Helm** consumes the rendered `values.yaml` at sync time. + +The rules here are repo-agnostic. `references/cogarchhubgitops-reference.md` records a +complete, concrete instance of every pattern (bands in use, flag names, global settings) — +read it when working in that repository, or as a worked example when standing up a new one. + +## Scope routing + +Pick the reference file that matches the change; do not load all of them. + +| The change touches | Read | +|---|---| +| An `application.yaml.jinja` — sync policy, finalizers, destination, source path | `references/argocd-applications.md` | +| `copier.yaml`, any `.jinja` syntax, conditional directory names, the `render()` macro | `references/copier-templates.md` | +| A `values.yaml.jinja` — image wiring, `global` block, env vars, probes, replicas | `references/helm-values.md` | +| A `/copier-answers.yaml` — feature flags, image tags, drift, `copier update` | `references/environments.md` | +| Adding a whole new resource across all three layers at once | `references/adding-a-resource.md` | +| CogArchHubGitOps specifically — bands in use, flag names, global settings | `references/cogarchhubgitops-reference.md` | + +## 1. Canonical repository shape + +```text +/ +├── copier.yaml # variable schema + defaults +├── cluster/ +│ ├── argocd/ +│ │ ├── application.yaml.jinja # root app-of-apps (000-root) +│ │ └── {{ 'NNN-name' if }}/ +│ │ └── application.yaml.jinja # one child Application per resource +│ └── resources/ +│ └── {{ 'NNN-name' if }}/ +│ └── values.yaml.jinja # Helm values for that Application +└── / + └── copier-answers.yaml # per-environment answers +``` + +Every file under `cluster/` is a Copier template. There are no raw `.yaml` files there — a +manifest without the `.jinja` extension will be copied through unrendered and will reach +ArgoCD with literal `{{ … }}` in it. + +## 2. The three-layer contract + +Every managed resource spans three layers that must stay in sync: + +```text +Layer 1 — copier.yaml + declares the feature flag + every configuration variable +Layer 2 — cluster/argocd/{{ 'NNN-name' if flag }}/application.yaml.jinja + tells ArgoCD where the chart and values live +Layer 3 — cluster/resources/{{ 'NNN-name' if flag }}/values.yaml.jinja + supplies the rendered values.yaml to Helm at sync time +``` + +All three share the **same feature flag variable** and the **same conditional directory +expression**. Breaking that symmetry is the single most common failure in this repo shape: +if the two directory expressions differ, one side renders and the other is omitted, and +ArgoCD fails with a path-not-found error. + +**Mirroring rule** — every `cluster/argocd//application.yaml.jinja` must have a +`cluster/resources//` sibling containing at least a `values.yaml.jinja` (for Helm +apps) or raw manifests. Same expression, character for character. + +## 3. App-of-apps bootstrap + +`cluster/argocd/application.yaml.jinja` is the **root application**, conventionally named +`000-root`. It watches `cluster/argocd/` recursively inside the rendered repository. When +ArgoCD reconciles the root, it discovers every `application.yaml` beneath it and creates +those child `Application` resources automatically. Adding a child app therefore requires no +change to the root — only a new conditional directory. + +## 4. Numeric band convention + +Every child directory and its ArgoCD `Application` name carry a three-digit prefix: + +| Band | Domain | Examples | +|---|---|---| +| 000 | Root app-of-apps | `000-root` | +| 200–299 | Operators | ESO, CNPG, Redis operator | +| 300–399 | Cluster configuration | ClusterSecretStore, namespaces | +| 400–499 | Databases | PostgreSQL cluster, Redis cluster | +| 700–799 | Advantage / MCP workloads | MCP servers, AI agents | +| 800–899 | Platform applications | notification, integration, agent-service | + +Sub-groups inside a band take the next free number in that band — e.g. `210-eso-operator-group`, +`213-eso-subscription`, `215-eso-configuration`. Choose the band **before** writing any file +and scan existing directories to confirm the number is free. Never reuse a number across +domains. + +## 5. Conditional directories are the feature-flag mechanism + +Directory names inside `cluster/` are Jinja2 expressions: + +```text +{{ '210-eso-operator-group' if operators.eso_is_enabled }} +``` + +Copier evaluates the expression at render time. Truthy → the directory renders under that +name. Falsy → **the entire directory tree is omitted**, so neither the ArgoCD app nor its +resources are generated. + +This is why manifests in this repo contain almost no Jinja2 logic. Do not use `{% if %}` +blocks to conditionally include whole YAML sections — gate the directory instead. Templates +substitute values; directories decide what exists. + +## 6. Non-negotiable rules + +These apply to every change regardless of which layer it touches: + +- **Never hardcode `repoURL` or `targetRevision`** — always `{{ gitops.repo_url }}` and + `{{ gitops.branch }}`. +- **Never hardcode image address or tag** in a values template — environments must deploy + different versions of the same template. +- **Every `values.yaml.jinja` uses the `{% macro render() %}` blank-line-stripping footer.** + YAML is whitespace-sensitive and unexpanded conditionals leave blank lines that break the + parse. The pattern is mandatory, not stylistic — see `references/copier-templates.md`. +- **Use `.get()` chains with defaults** for any nested variable that may be absent in some + environment: `{{ applications.get('svc', {}).get('replicas', 1) }}`. Direct dot access on + an optional key raises `UndefinedError` and fails the render for every environment. +- **New feature flags default to `false`** in `copier.yaml` — resources are opt-in per + environment. +- **No secrets in Git.** Not in `.jinja` templates, not in `values.yaml.jinja`, not in + `copier-answers.yaml`. Use placeholders and inject at runtime via ExternalSecret / + ClusterSecretStore. +- **Feature flags are YAML booleans** (`true`/`false`) in answer files. Helm string-booleans + (`is_internal`, `maintenance`) are quoted strings (`"true"`/`"false"`). These are different + things — see `references/environments.md`. + +## 7. Adding a new resource — the short path + +Full walkthrough with copy-paste templates: `references/adding-a-resource.md`. +Starting templates: `assets/example-cluster-resource/`. + +1. Pick the band, the next free number, the kebab-case name, and the flag variable name. +2. Declare the flag (`type: bool`, `default: false`) and its config variables in `copier.yaml`. +3. Create `cluster/argocd/{{ 'NNN-name' if }}/application.yaml.jinja`. +4. Create `cluster/resources/{{ 'NNN-name' if }}/values.yaml.jinja` — identical expression. +5. Enable the flag and supply image/config values in the answer file of each target environment. +6. Render with the flag off **and** on, and validate both (section 8). + +## 8. Validation + +Never commit a template change without rendering it. A broken template blocks every +environment that renders from the repo, not just the one you were working on. + +```bash +# Render with defaults — new resource must NOT appear (flag defaults to false) +copier copy . /tmp/gitops-off --overwrite --defaults +ls /tmp/gitops-off/cluster/argocd/ | grep "" # expect no output + +# Render with the resource enabled +copier copy . /tmp/gitops-on --overwrite --defaults \ + --data '._is_enabled=true' \ + --data '..image.address=myregistry/myimage' \ + --data '..image.tag=1.0.0' +ls /tmp/gitops-on/cluster/argocd/ | grep "" # expect the directory + +# Render a real environment +copier copy . /tmp/gitops-env --overwrite --answers-file /copier-answers.yaml + +# No .jinja may leak into rendered output +find /tmp/gitops-env -name "*.jinja" | wc -l # must be 0 + +# Every rendered YAML must parse +find /tmp/gitops-env -name "*.yaml" -exec python3 -c \ + "import yaml, sys; yaml.safe_load(open(sys.argv[1]))" {} \; + +# Every Application's source.path must exist under the rendered tree +for app in $(find /tmp/gitops-env/cluster/argocd -name "application.yaml" -exec \ + python3 -c "import yaml,sys; print(yaml.safe_load(open(sys.argv[1]))['spec']['source']['path'])" {} \;); do + [ -d "/tmp/gitops-env/$app" ] || echo "MISSING resources dir: $app" +done +``` + +Jinja2 syntax check without rendering (fast feedback while editing): + +```bash +python3 -c " +from jinja2 import Environment +import pathlib +env = Environment() +for f in pathlib.Path('cluster').rglob('*.jinja'): + env.parse(f.read_text()); print('OK:', f) +" +``` + +Helm-lint the rendered values against the chart when a chart path is available: + +```bash +helm lint -f /tmp/gitops-on/cluster/resources//values.yaml +``` + +## 9. Consistency checklist + +Before committing any resource addition or rename: + +- [ ] The three-digit number is unique and in the correct band. +- [ ] The flag is declared in `copier.yaml` with `type: bool` and `default: false`. +- [ ] The ArgoCD `metadata.name` equals the directory name exactly. +- [ ] `spec.source.path` equals the `cluster/resources/` directory name exactly. +- [ ] The conditional expressions in `cluster/argocd/` and `cluster/resources/` are identical. +- [ ] `repoURL` and `targetRevision` use Jinja2 variables. +- [ ] `resources-finalizer.argocd.argoproj.io` is present in `metadata.finalizers`. +- [ ] `values.yaml.jinja` carries the `render()` macro and stripping footer. +- [ ] All optional variable references use `.get()` with a default. +- [ ] No secrets in any new or edited file. +- [ ] At least one environment's answer file enables and fully configures the resource. +- [ ] Rendered flag-off and flag-on, both validated. + +## 10. Top mistakes to avoid + +- Creating the ArgoCD app without the mirrored `cluster/resources/` directory — ArgoCD errors + with path-not-found. +- Using different conditional expressions on the two sides — one renders, the other vanishes. +- Writing a raw `application.yaml` instead of `application.yaml.jinja`. +- Omitting the `resources-finalizer.argocd.argoproj.io` finalizer — deleting the app leaves + orphaned cluster resources behind. +- Disabling `selfHeal` or `prune` without documenting why — it silently defeats the GitOps + contract. +- Enabling a flag in an answer file without supplying the required image/config variables — + the rendered values file has empty fields and Helm fails at sync. +- Editing `_src_path` or `_commit` by hand in an answer file — both are Copier metadata and + wrong values break `copier update`. +- Skipping the render-and-diff step after editing an answer file — drift between answers and + rendered output produces unexplained cluster state. diff --git a/assets/example-cluster-resource/argocd/application.yaml.jinja b/assets/example-cluster-resource/argocd/application.yaml.jinja new file mode 100644 index 0000000..abcb7f8 --- /dev/null +++ b/assets/example-cluster-resource/argocd/application.yaml.jinja @@ -0,0 +1,37 @@ +{# --------------------------------------------------------------------------- + Template for cluster/argocd/{{ '-' if ._is_enabled }}/application.yaml.jinja + + Replace: + - three-digit band number + kebab-case descriptor (e.g. 210-eso-operator-group) + namespace the workload deploys into + copier.yaml domain object (operators, databases, applications, …) + this resource's key inside that domain + + The parent directory name must be the Jinja2 conditional expression, and it must be + character-identical to the one used for the cluster/resources/ mirror. + --------------------------------------------------------------------------- #} +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: - + namespace: openshift-gitops + finalizers: + - resources-finalizer.argocd.argoproj.io +spec: + project: default + source: + repoURL: {{ gitops.repo_url }} + targetRevision: {{ gitops.branch }} + path: cluster/resources/- + helm: + valueFiles: + - values.yaml + destination: + server: https://kubernetes.default.svc + namespace: + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true diff --git a/assets/example-cluster-resource/copier-variables.yaml b/assets/example-cluster-resource/copier-variables.yaml new file mode 100644 index 0000000..44bfe9a --- /dev/null +++ b/assets/example-cluster-resource/copier-variables.yaml @@ -0,0 +1,46 @@ +# --------------------------------------------------------------------------- +# Snippet to merge into copier.yaml when adding a new cluster resource. +# +# Replace: +# existing domain object (operators, configuration, databases, +# advantage_mcp, applications) — never add a top-level variable +# this resource's key inside that domain +# - the directory / ArgoCD Application name +# +# The flag name used here must be the one appearing in BOTH conditional directory +# expressions (cluster/argocd/ and cluster/resources/). +# --------------------------------------------------------------------------- + +: + _is_enabled: + type: bool + default: false + help: "Enable -" + + : + image: + address: + type: str + default: "" + help: "Container image for " + tag: + type: str + default: "" + help: "Container image tag for " + replicas: + type: str + default: "1" + help: "Replica count for " + +# --------------------------------------------------------------------------- +# Matching snippet for /copier-answers.yaml in each environment that should +# run this resource. Feature flags are YAML booleans; Helm string-booleans such as +# is_internal / maintenance are quoted strings — see references/environments.md §4. +# --------------------------------------------------------------------------- +# +# : +# _is_enabled: true +# : +# image: +# address: / +# tag: diff --git a/assets/example-cluster-resource/resources/values.yaml.jinja b/assets/example-cluster-resource/resources/values.yaml.jinja new file mode 100644 index 0000000..fb11b5a --- /dev/null +++ b/assets/example-cluster-resource/resources/values.yaml.jinja @@ -0,0 +1,48 @@ +{# --------------------------------------------------------------------------- + Template for cluster/resources/{{ '-' if ._is_enabled }}/values.yaml.jinja + + Standalone-chart shape. For services sharing one release via a `global` block, + use the shared-chart shape in references/helm-values.md §4 instead. + + The macro wrapper and the four-line stripping footer are mandatory — they remove + blank lines left by unexpanded Jinja2 constructs, which would otherwise break the + rendered YAML. + --------------------------------------------------------------------------- #} +{% macro render() %} +# values for - +replicaCount: {{ .get('', {}).get('replicas', 1) }} + +image: + repository: {{ .get('', {}).get('image', {}).get('address', '') }} + tag: {{ .get('', {}).get('image', {}).get('tag', '') }} + pullPolicy: Always + +imagePullSecrets: + - name: "storage-images" + +service: + type: ClusterIP + port: 80 + +livenessProbe: + httpGet: + path: /health + port: + periodSeconds: 60 + +readinessProbe: + httpGet: + path: /health + port: + periodSeconds: 60 + +env: + targetPort: + : {{ .get('', {}).get('env', {}).get('', '') }} +{% endmacro %} +{%- set result = render() -%} +{%- for line in result.split('\n') -%} +{% if line | trim | length != 0 -%} +{{ line }} +{% endif -%} +{%- endfor %} diff --git a/references/adding-a-resource.md b/references/adding-a-resource.md new file mode 100644 index 0000000..bc24aff --- /dev/null +++ b/references/adding-a-resource.md @@ -0,0 +1,195 @@ +# Adding a Cluster Resource End-to-End + +The integration walkthrough: one atomic change touching all three layers — Copier schema, +ArgoCD Application, Helm values — plus the environment answer file. + +Read this when onboarding a brand-new operator, database, MCP component, or workload. For an +isolated edit to one layer, read that layer's reference instead +(`argocd-applications.md`, `copier-templates.md`, `helm-values.md`, `environments.md`). + +Starting templates: `assets/example-cluster-resource/`. + +--- + +## Step 1 — Choose number, name, and flag + +```text +Band: from the table in SKILL.md §4 +Number: next available in that band +Name: - +Flag: ._is_enabled (type: bool, default: false) +``` + +Confirm the number is free before writing anything: + +```bash +ls cluster/argocd/ cluster/resources/ | grep -o '[0-9]\{3\}' | sort -u +``` + +The directory names are Jinja2 expressions, so the numbers are still visible in a plain +listing — no rendering needed for this check. + +--- + +## Step 2 — Declare variables in `copier.yaml` + +```yaml +: + _is_enabled: + type: bool + default: false + help: "Enable -" + + : + image: + address: + type: str + default: "" + help: "Container image for " + tag: + type: str + default: "" + help: "Container image tag for " + # additional variables as the resource requires +``` + +The flag defaults to `false` so existing environments are unaffected until they opt in. + +--- + +## Step 3 — Create the ArgoCD Application + +`cluster/argocd/{{ '-' if ._is_enabled }}/application.yaml.jinja` + +```yaml +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: - + namespace: openshift-gitops + finalizers: + - resources-finalizer.argocd.argoproj.io +spec: + project: default + source: + repoURL: {{ gitops.repo_url }} + targetRevision: {{ gitops.branch }} + path: cluster/resources/- + helm: + valueFiles: + - values.yaml + destination: + server: https://kubernetes.default.svc + namespace: + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true +``` + +--- + +## Step 4 — Create the Helm values template + +`cluster/resources/{{ '-' if ._is_enabled }}/values.yaml.jinja` + +The conditional expression must be **character-identical** to step 3. + +```jinja +{% macro render() %} +# values for - +replicaCount: 1 + +image: + repository: {{ .get('', {}).get('image', {}).get('address', '') }} + tag: {{ .get('', {}).get('image', {}).get('tag', '') }} + pullPolicy: Always + +# ... remaining values +{% endmacro %} +{%- set result = render() -%} +{%- for line in result.split('\n') -%} +{% if line | trim | length != 0 -%} +{{ line }} +{% endif -%} +{%- endfor %} +``` + +--- + +## Step 5 — Update environment answer files + +For each environment that should run this resource: + +```yaml +: + _is_enabled: true + : + image: + address: / + tag: +``` + +Enable it in one environment first — typically the lowest — and promote after it syncs +cleanly. Enabling everywhere in the same commit means a template bug reaches production in +the same sync cycle. + +--- + +## Step 6 — Validate both states + +```bash +# Flag off (defaults): the directories must NOT appear +copier copy . /tmp/gitops-render-off --overwrite --defaults +ls /tmp/gitops-render-off/cluster/argocd/ | grep "" # expect no output + +# Flag on: the directories must appear +copier copy . /tmp/gitops-render-on --overwrite --defaults \ + --data '._is_enabled=true' \ + --data '..image.address=myregistry/myimage' \ + --data '..image.tag=1.0.0' +ls /tmp/gitops-render-on/cluster/argocd/ | grep "" + +# Both rendered files must parse +python3 -c "import yaml; yaml.safe_load(open('/tmp/gitops-render-on/cluster/argocd/-/application.yaml'))" +python3 -c "import yaml; yaml.safe_load(open('/tmp/gitops-render-on/cluster/resources/-/values.yaml'))" + +# Helm lint, when a chart path is available +helm lint -f /tmp/gitops-render-on/cluster/resources/-/values.yaml +``` + +Testing the **off** state matters as much as the on state: a malformed conditional +expression can render the directory unconditionally, silently deploying the new resource to +every environment on the next sync. + +--- + +## Step 7 — Consistency checklist + +- [ ] The three-digit number is unique and in the correct band. +- [ ] The flag is declared in `copier.yaml` with `type: bool` and `default: false`. +- [ ] The ArgoCD `metadata.name` matches the directory name exactly. +- [ ] `spec.source.path` matches the `cluster/resources/` directory name exactly. +- [ ] The conditional expressions in `cluster/argocd/` and `cluster/resources/` are identical. +- [ ] `repoURL` and `targetRevision` use Jinja2 variables, not literals. +- [ ] `resources-finalizer.argocd.argoproj.io` is present in `metadata.finalizers`. +- [ ] `values.yaml.jinja` uses the `render()` macro and stripping footer. +- [ ] All variable references use `.get()` with a type-appropriate default. +- [ ] No secrets or credentials in any new file. +- [ ] At least one environment's answer file enables and fully configures the resource. +- [ ] Rendered and validated with the flag both off and on. + +--- + +## Mistakes to avoid + +- Creating the ArgoCD Application without the matching `cluster/resources/` directory — + ArgoCD fails with path-not-found. +- Different conditional expressions on the two sides — one renders, the other is omitted. +- Referencing a variable that was never declared in `copier.yaml` — the render fails for + every environment, not just yours. +- Hardcoding image address or tag in `values.yaml.jinja`. +- Setting the new flag `default: true` — new resources are opt-in. +- Committing without rendering; a broken template blocks every environment. diff --git a/references/argocd-applications.md b/references/argocd-applications.md new file mode 100644 index 0000000..8a7f372 --- /dev/null +++ b/references/argocd-applications.md @@ -0,0 +1,143 @@ +# ArgoCD Application Manifests + +Everything under `cluster/argocd/`: the root app-of-apps, numbered child applications, sync +policies, finalizers, and destinations. + +Read this when creating a child `Application`, changing sync behaviour, moving a destination +namespace, or debugging an app that will not sync. + +--- + +## 1. Template anatomy + +Every `cluster/argocd/**/application.yaml.jinja` follows this shape: + +```yaml +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: + namespace: openshift-gitops + finalizers: + - resources-finalizer.argocd.argoproj.io +spec: + project: default + source: + repoURL: {{ gitops.repo_url }} + targetRevision: {{ gitops.branch }} + path: cluster/resources/ + helm: + valueFiles: + - values.yaml + destination: + server: https://kubernetes.default.svc + namespace: + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true +``` + +| Field | Rule | +|---|---| +| `metadata.name` | Exactly equal to the parent directory name (`210-eso-operator-group`) | +| `metadata.namespace` | Always the ArgoCD install namespace — `openshift-gitops` on OpenShift GitOps | +| `metadata.finalizers` | Must include `resources-finalizer.argocd.argoproj.io` | +| `spec.project` | `default` unless the cluster defines scoped AppProjects | +| `spec.source.repoURL` | Always `{{ gitops.repo_url }}` — never a literal URL | +| `spec.source.targetRevision` | Always `{{ gitops.branch }}` — never a literal branch | +| `spec.source.path` | Exactly the mirrored `cluster/resources/` path | +| `spec.source.helm.valueFiles` | Usually `values.yaml`, the rendered `values.yaml.jinja` | +| `spec.destination.server` | `https://kubernetes.default.svc` for in-cluster | +| `spec.destination.namespace` | The workload's target namespace | + +Note the asymmetry in `spec.source.path`: the **path value** is the plain rendered directory +name (`cluster/resources/210-eso-operator-group`), while the **directory on disk** is the +Jinja2 conditional expression. Copier renders the expression away, so the two agree in the +output — but only if the literal you type in `path` matches what the expression evaluates to. + +--- + +## 2. Sync policy rules + +- `automated.prune: true` — removes resources no longer present in Git. Required; without it + a disabled feature flag leaves its resources running in the cluster forever. +- `automated.selfHeal: true` — reverts manual cluster changes. Required; disabling it means + `kubectl edit` silently wins over Git, which defeats the whole contract. +- Any exception to either must be justified in a YAML comment directly above `syncPolicy`. +- `syncOptions: [CreateNamespace=true]` — set for any app whose destination namespace is not + guaranteed to pre-exist. Harmless when the namespace already exists. +- Retry settings are optional; add `syncPolicy.retry` only for apps with known flaky + dependencies, and document the reason. + +--- + +## 3. Naming rules + +- Three-digit prefix from the correct band (see `SKILL.md` §4), next free number. +- Descriptive part in lowercase kebab-case: `210-eso-operator-group`. +- `metadata.name` == directory name == the value inside the conditional expression. +- Wrap the directory in `{{ 'NNN-name' if }}` — no exceptions; an + unconditional directory renders in every environment whether or not it is wanted. + +--- + +## 4. Adding a child application + +1. Choose the band and the next free number in it. +2. Create the conditional directory: + + ```text + cluster/argocd/{{ '' if }}/application.yaml.jinja + ``` + +3. Create the mirror: + + ```text + cluster/resources/{{ '' if }}/values.yaml.jinja + ``` + +4. Write the manifest from the anatomy in section 1. +5. Declare `` in `copier.yaml` with `type: bool`, `default: false`. +6. Add the resource's configuration variables alongside the flag. +7. Render and validate (`SKILL.md` §8). + +No edit to the root `application.yaml.jinja` is needed — the app-of-apps discovers the new +child automatically. + +--- + +## 5. Troubleshooting + +| Symptom | Likely cause | +|---|---| +| App missing entirely from ArgoCD | Feature flag false in that environment's answer file, or the conditional directory expression is malformed and evaluated falsy | +| `path does not exist` / ComparisonError | `spec.source.path` does not match the rendered `cluster/resources/` directory, or the mirror directory's conditional expression differs from the argocd one | +| Literal `{{ gitops.repo_url }}` in the live manifest | File was committed as `application.yaml` instead of `application.yaml.jinja`, so Copier copied it through unrendered | +| Permanently `OutOfSync` after a manual cluster edit | `selfHeal` disabled — the cluster and Git disagree and nothing reconciles them | +| Resources survive after deleting the app | Missing `resources-finalizer.argocd.argoproj.io` | +| Resources vanish after a rename | Renaming a directory prunes the old app; expected — confirm the new app synced before assuming breakage | +| Sync fails on namespace not found | Missing `CreateNamespace=true` in `syncOptions` | + +Inspect the live state: + +```bash +argocd app list +argocd app get +argocd app diff +kubectl -n openshift-gitops get applications.argoproj.io +``` + +--- + +## 6. Mistakes to avoid + +- Hardcoding `repoURL` or `targetRevision` instead of the Jinja2 variables. +- Omitting the finalizer. +- Creating the argocd side without the mirrored resources side. +- Reusing a numeric prefix across domains (a `3xx` number for a database app). +- Writing `application.yaml` without the `.jinja` extension. +- Disabling `selfHeal` or `prune` silently. +- Letting `metadata.name` drift from the directory name after a rename. diff --git a/references/cogarchhubgitops-reference.md b/references/cogarchhubgitops-reference.md new file mode 100644 index 0000000..ba828db --- /dev/null +++ b/references/cogarchhubgitops-reference.md @@ -0,0 +1,161 @@ +# CogArchHubGitOps — Reference Implementation + +The concrete instance of every pattern in this skill. Read when working in +`CogArchHubGitOps/` specifically, or as a worked example when standing up a new GitOps +repository from the generic rules. + +The generic rules live in `SKILL.md` and the other reference files; this file records only +what is specific to this repository. + +--- + +## 1. Repositories + +```text +CogArchHubGitOps/ ← target repository (the Copier template + answer files) +CogArchHubGitOps-code-agent/ ← code-agent repository (skills and agent tooling) +``` + +Work described by this skill lands in the **target** repository. Deployed environments are +rendered copies of it. + +--- + +## 2. Layout + +```text +CogArchHubGitOps/ +├── copier.yaml +├── cluster/ +│ ├── argocd/ +│ │ ├── application.yaml.jinja # 000-root app-of-apps +│ │ ├── {{ '200-operators' if … }}/ +│ │ ├── {{ '300-configuration' if … }}/ +│ │ ├── {{ '400-databases' if … }}/ +│ │ ├── {{ '700-advantage-mcp' if … }}/ +│ │ └── {{ '800-applications' if … }}/ +│ └── resources/ +│ └── / +└── / + └── copier-answers.yaml +``` + +`cluster/argocd/application.yaml.jinja` is the root app named `000-root`. It watches +`cluster/argocd/` recursively and creates every child Application it finds. + +--- + +## 3. Bands in use + +| Band | Domain | Concrete contents | +|---|---|---| +| 000 | Root | `000-root` app-of-apps | +| 200–299 | Operators | External Secrets Operator (ESO), CloudNativePG (CNPG), Redis | +| 300–399 | Cluster configuration | ClusterSecretStore and related config | +| 400–499 | Databases | PostgreSQL clusters, Redis clusters | +| 700–799 | Advantage / MCP | MCP servers, AI agent workloads | +| 800–899 | Platform applications | notification, integration, agent-service | + +ESO occupies a contiguous sub-group inside the 2xx band — operator group, subscription, and +configuration each take their own number (`210`, `213`, `215`), so a related set stays +readable in a sorted directory listing. Follow the same grouping habit for new operators. + +--- + +## 4. Feature flags + +| Variable | Gates | +|---|---| +| `operators.is_enabled` | The whole `200-operators` band | +| `operators.eso_is_enabled` | ESO operator group, subscription, configuration | +| `operators.cnpg_is_enabled` | CNPG operator group and subscription | +| `operators.redis_is_enabled` | Redis operator group and subscription | +| `configuration.is_enabled` | The whole `300-configuration` band | +| `configuration.cluster_secret_store_is_enabled` | ClusterSecretStore | +| `databases.is_enabled` | The whole `400-databases` band | +| `advantage_mcp.is_enabled` | The whole `700-advantage-mcp` band | +| `applications.is_enabled` | The whole `800-applications` band | + +Sub-flags are named `_is_enabled` and live flat under the domain object — `operators.eso_is_enabled`, +not `operators.eso.is_enabled`. Keep new flags consistent with that shape. + +--- + +## 5. ArgoCD conventions + +- ArgoCD runs in the `openshift-gitops` namespace (OpenShift GitOps operator), so every + `Application` sets `metadata.namespace: openshift-gitops`. +- `spec.project` is `default`. +- `spec.destination.server` is `https://kubernetes.default.svc` (in-cluster). +- `prune: true` and `selfHeal: true` are on for all apps. + +--- + +## 6. Application global settings + +`applications.global` in each answer file provides the settings shared by all 8xx services: + +```yaml +applications: + global: + is_internal: "false" + enable_instana: "false" + ca_secret_name: ca-secret + ca_env: + maintenance: "false" + host: + ca_host: + suffix_url: + node_selector_app: + mongo: + host: + port: "27017" + user: + redis: + host: + port: "6379" + max_retries: "3" + neo4j: + url: + user: +``` + +The datastore blocks (`mongo`, `redis`, `neo4j`) carry connection coordinates only. Credentials +come from ESO-managed secrets at runtime, which is why `ca_secret_name` and the +ClusterSecretStore in the 3xx band are prerequisites for the 8xx band to work at all. + +`is_internal`, `enable_instana`, and `maintenance` are **quoted strings**, not YAML booleans — +they reach the Helm chart as strings. Feature flags in the same file are real booleans. See +`environments.md` §4. + +--- + +## 7. Private registry + +Workload images pull from a private registry using the pre-existing secret `storage-images`: + +```yaml +imagePullSecrets: + - name: "storage-images" +``` + +The secret is expected to exist in the target namespace already; it is not created by these +templates. A new namespace therefore needs the secret provisioned before its first sync, or +pods stall on `ImagePullBackOff`. + +--- + +## 8. Ordering dependencies + +The bands are numbered so a full bootstrap converges in ascending order: + +1. **2xx operators** install the CRDs everything else depends on (ESO, CNPG, Redis). +2. **3xx configuration** creates the ClusterSecretStore that supplies secrets. +3. **4xx databases** create instances using the operators' CRDs. +4. **7xx / 8xx workloads** consume the databases and secrets. + +ArgoCD does not enforce this ordering — the app-of-apps creates all children at once and each +syncs independently. The numbering is a convention that makes the intended order legible, and +failing apps in a fresh cluster usually resolve themselves once the band they depend on has +finished syncing. Do not add sync-waves to force ordering without discussing it first; retry +plus eventual consistency is the assumed model here. diff --git a/references/copier-templates.md b/references/copier-templates.md new file mode 100644 index 0000000..2e1f8b7 --- /dev/null +++ b/references/copier-templates.md @@ -0,0 +1,205 @@ +# Copier Templating Layer + +The `copier.yaml` variable schema, `.jinja` template authoring, conditional directory names, +and the `render()` macro. Read this when adding a variable, editing any `.jinja` file, or +debugging a rendering failure. + +--- + +## 1. How Copier renders this repo + +The repository **is** a Copier template. `copier copy` (or `copier update`) against an answer +file causes Copier to: + +1. Read every variable definition from `copier.yaml`. +2. Substitute values from `copier-answers.yaml` into every `*.jinja` file. +3. Evaluate Jinja2 expressions used as **directory names** — a falsy result omits the whole + subtree from the output. +4. Write rendered files without the `.jinja` extension to the destination. + +The rendered output is what ArgoCD reads from Git. The template source is what you edit. +These are different trees; never confuse a rendering bug with a cluster bug. + +--- + +## 2. Conditional directory names + +```text +{{ '210-eso-operator-group' if operators.eso_is_enabled }} +``` + +- Flag `true` → directory renders as `210-eso-operator-group/`. +- Flag `false` → the directory **and everything inside it** is omitted. + +This is the feature-flag mechanism for the entire repository. Consequences: + +- No `{% if %}` blocks are needed inside manifests, and none should be added. +- The expression must evaluate to exactly the intended name string when truthy, and to a + falsy value when not. `{{ '210-x' if flag else '' }}` and `{{ '210-x' if flag }}` both work; + keep the shorter form for consistency. +- The variable named in the expression must be the one declared in `copier.yaml` — a typo + produces `UndefinedError` or, worse, a silently falsy value that drops the resource. +- `cluster/argocd/` and `cluster/resources/` must use **identical** expressions. Renaming a + flag means editing both. + +--- + +## 3. The `render()` macro pattern + +Every `values.yaml.jinja` wraps its content in a macro and strips blank lines: + +```jinja +{% macro render() %} +key: {{ variable }} +other_key: {{ other_variable }} +{% endmacro %} +{%- set result = render() -%} +{%- for line in result.split('\n') -%} +{% if line | trim | length != 0 -%} +{{ line }} +{% endif -%} +{%- endfor %} +``` + +Mandatory for all `values.yaml.jinja` files. Unexpanded Jinja2 constructs leave blank lines +in the output; in YAML those can break block structure and produce a parse error at sync +time — long after the commit that caused it. + +Do not try to solve the same problem with Jinja2 whitespace control (`{%-`/`-%}`) inside the +macro body. The footer loop is the agreed mechanism; mixing approaches makes the output +depend on which one wins. + +--- + +## 4. Variable schema (`copier.yaml`) + +Each variable declares: + +- `type` — `str`, `bool`, `dict`, `yaml` +- `default` — used when no answer file supplies a value +- `help` — shown during interactive prompting + +Naming conventions: + +| Pattern | Purpose | +|---|---| +| `.is_enabled` | Top-level flag for a whole domain (`operators.is_enabled`) | +| `._is_enabled` | Sub-feature flag (`operators.eso_is_enabled`) | +| `..image.address` | Container image repository path | +| `..image.tag` | Container image tag | +| `..env.` | Application environment variable | +| `gitops.repo_url` | Git URL used in ArgoCD sources | +| `gitops.branch` | Git branch used in ArgoCD sources | + +Rules: + +- Declare every variable before any template references it — an undeclared reference fails + the render for every environment. +- Give every variable a sensible `default` so `--defaults` rendering works without an answer + file. +- Feature flags are `type: bool`, never `str`. +- New flags default to `false` — resources are opt-in. +- Group variables under a domain object (`operators:`, `applications:`). Never add isolated + top-level variables. + +--- + +## 5. Accessing variables in templates + +```jinja +{# Direct — raises UndefinedError if the key is missing #} +{{ operators.eso_is_enabled }} + +{# Safe with default — preferred for any optional nested key #} +{{ applications.get('integration', {}).get('replicas', 1) }} +``` + +Use the `.get()` chain for anything that might be absent in some environment. A direct +reference to an optional key is a latent failure: it renders fine in the environment you +tested and breaks the one you did not. + +Defaults inside `.get()` should match the field's type — `''` for strings, a number for +numeric fields. + +--- + +## 6. Adding a new variable + +1. Open `copier.yaml`. +2. Add it under the correct domain key: + + ```yaml + : + _is_enabled: + type: bool + default: false + help: "Enable -" + + : + image: + address: + type: str + default: "" + help: "Container image for " + tag: + type: str + default: "" + help: "Container image tag for " + ``` + +3. Reference it from the appropriate `.jinja` template. +4. Add its environment-specific value to each relevant `copier-answers.yaml`. +5. Render and validate. + +--- + +## 7. Authoring rules + +- Every new `.jinja` values file uses the `render()` macro pattern. +- Keep Jinja2 logic minimal — directories handle feature flags, templates handle substitution. +- No credentials, tokens, or secrets inside any template. +- Answer files stay YAML; never `.json` or `.env`. +- `_src_path` and `_commit` in answer files are Copier metadata — never hand-edited. + +--- + +## 8. Debugging a render failure + +```bash +# Parse every template without rendering — fastest way to find a syntax error +python3 -c " +from jinja2 import Environment +import pathlib +env = Environment() +for f in pathlib.Path('cluster').rglob('*.jinja'): + env.parse(f.read_text()); print('OK:', f) +" + +# Full render with defaults +copier copy . /tmp/gitops-render-defaults --overwrite --defaults + +# Full render with a specific environment +copier copy . /tmp/gitops-render-env --overwrite --answers-file /copier-answers.yaml + +# No .jinja may survive into the output +find /tmp/gitops-render-env -name "*.jinja" && echo "ERROR: .jinja files in output" +``` + +| Error | Cause | +|---|---| +| `UndefinedError: '' is undefined` | Variable not declared in `copier.yaml`, or a direct reference to an optional nested key — switch to `.get()` | +| Directory missing from output | Flag false, or a typo in the variable name inside the conditional expression | +| `.jinja` file in the output | File is inside a directory Copier excluded, or the extension is misspelled | +| Rendered YAML fails to parse | Missing `render()` stripping footer leaving blank lines | +| `copier update` refuses to run | `_src_path` or `_commit` was hand-edited | + +--- + +## 9. Mistakes to avoid + +- Skipping the `render()` blank-line-stripping wrapper. +- Using string `"true"`/`"false"` for `bool` feature flags. +- Adding isolated top-level variables instead of grouping under a domain. +- Renaming a flag on only one of `cluster/argocd/` and `cluster/resources/`. +- Direct `{{ variable }}` access for optional nested keys instead of `.get()`. +- Committing an answer file containing real secrets. diff --git a/references/environments.md b/references/environments.md new file mode 100644 index 0000000..246b9d7 --- /dev/null +++ b/references/environments.md @@ -0,0 +1,265 @@ +# Environment Configuration + +Per-environment `copier-answers.yaml` files: feature flags, image tags, global application +settings, drift between environments, and re-rendering with `copier update`. + +Read this when enabling or disabling a resource for one environment, onboarding a new +environment, auditing what is switched on, or investigating why two environments differ. + +--- + +## 1. How environments work + +Each deployed environment is a rendered copy of the template repository. The rendering is +driven by an answer file supplying environment-specific values for the variables declared in +`copier.yaml`: + +```text +Template repo + + /copier-answers.yaml + ───────────────────────────── + → rendered cluster state (what ArgoCD reads) +``` + +The rendered state normally lives on a separate branch or repository per environment. The +answer files themselves live at `/copier-answers.yaml` in the template repo. + +--- + +## 2. Answer file structure + +Copier maintains two metadata fields automatically; everything else is user-defined: + +```yaml +_src_path: ../ # relative path to the template source +_commit: # template commit used for the last render + +gitops: + repo_url: https://github.com/org/repo + branch: main + +operators: + is_enabled: true + eso_is_enabled: true + cnpg_is_enabled: false + redis_is_enabled: false + +configuration: + is_enabled: true + cluster_secret_store_is_enabled: true + +applications: + is_enabled: true + global: + is_internal: "false" + ca_env: production + host: myapp.example.com +``` + +Keys must match `copier.yaml` exactly. Copier **silently ignores unknown keys** — a typo does +not error, it just leaves the variable at its default, which is the most common cause of a +"why is this environment misconfigured" investigation. + +--- + +## 3. Variable categories + +### Feature flags + +Flags follow `.is_enabled` and `._is_enabled`: + +| Variable | Effect when true | +|---|---| +| `operators.is_enabled` | Renders the `200-operators` band | +| `operators.eso_is_enabled` | Renders ESO operator group, subscription, configuration | +| `operators.cnpg_is_enabled` | Renders CNPG operator group and subscription | +| `operators.redis_is_enabled` | Renders Redis operator group and subscription | +| `configuration.is_enabled` | Renders the `300-configuration` band | +| `configuration.cluster_secret_store_is_enabled` | Renders ClusterSecretStore | +| `databases.is_enabled` | Renders the `400-databases` band | +| `advantage_mcp.is_enabled` | Renders the `700-advantage-mcp` band | +| `applications.is_enabled` | Renders the `800-applications` band | + +**Parent flags gate whole bands.** With `operators.is_enabled: false`, every sub-flag under +`operators` is irrelevant — the entire `200-operators` directory is omitted regardless of +what the sub-flags say. When a resource unexpectedly fails to render, check the parent flag +before the specific one. + +### Image variables + +```yaml +: + : + image: + address: / + tag: +``` + +Every enabled workload needs non-empty values for both. + +### Global application settings + +`applications.global` carries settings shared by all services in the release: + +```yaml +applications: + global: + is_internal: "true" # string bool + enable_instana: "false" # string bool + ca_secret_name: ca-secret + ca_env: # dev | staging | production + maintenance: "false" # string bool + host: + ca_host: + suffix_url: + node_selector_app: + mongo: + host: + port: "27017" + user: + redis: + host: + port: "6379" + max_retries: "3" + neo4j: + url: + user: +``` + +--- + +## 4. Booleans: two different kinds + +This trips people up constantly: + +| Kind | Written as | Why | +|---|---|---| +| Copier feature flag (`*_is_enabled`) | YAML boolean `true` / `false` | Evaluated as a Jinja2 conditional deciding whether a directory renders | +| Helm string-bool (`is_internal`, `maintenance`, `enable_instana`) | Quoted string `"true"` / `"false"` | The values templates emit them as strings and the chart reads them as strings | + +A feature flag written as `"false"` is a **non-empty string** — truthy in Jinja2 — so the +resource renders when you meant to disable it. This fails silently and deploys something. + +--- + +## 5. Rules + +### Answer files + +- `_src_path` and `_commit` are Copier-managed; never edit them by hand. +- Keep answer files minimal — only override what differs from the `copier.yaml` default. +- Never commit real secrets. Use placeholders and inject at runtime via ExternalSecret. + +### Multi-environment + +- One answer file per environment; never share a file across environments. +- When adding a variable to `copier.yaml`, update every answer file needing a non-default + value — the rest correctly fall back to the default. + +### Enabling a resource + +1. Set the flag to `true`. +2. Supply **all** required image and configuration variables for that resource. +3. Re-render the environment. +4. Review the rendered diff before committing. + +Skipping step 2 renders a values file with empty fields; Helm then fails at sync with an +error that names the chart, not the answer file, which makes it slow to trace back. + +### Disabling a resource + +1. Set the flag to `false`. +2. Re-render. +3. ArgoCD prunes the Application and its cluster resources on the next sync, because + `prune: true` is set. This is destructive and immediate — confirm the resource is really + meant to leave the cluster, not just be paused. + +--- + +## 6. Workflows + +### New environment + +```bash +mkdir + +# Option A — interactive prompts +copier copy . /tmp/gitops- --answers-file /copier-answers.yaml + +# Option B — start from the nearest existing environment +cp /copier-answers.yaml /copier-answers.yaml +# then edit for environment-specific values +``` + +Option B is faster but carries over every override from the source environment; walk the +whole file rather than only the fields you came to change. + +### Re-render after a template change + +```bash +copier update /path/to/rendered- \ + --answers-file /copier-answers.yaml \ + --overwrite + +git -C /path/to/rendered- diff +``` + +### Compare two environments + +```bash +copier copy . /tmp/env-a --overwrite --answers-file env-a/copier-answers.yaml +copier copy . /tmp/env-b --overwrite --answers-file env-b/copier-answers.yaml +diff -rq /tmp/env-a/cluster /tmp/env-b/cluster +``` + +### Audit which resources are enabled + +```bash +python3 -c " +import yaml, sys +a = yaml.safe_load(open(sys.argv[1])) +def walk(d, prefix=''): + for k, v in (d.items() if isinstance(d, dict) else []): + if k.endswith('_is_enabled') or k == 'is_enabled': + print(f'{prefix}{k}: {v}') + elif isinstance(v, dict): + walk(v, prefix + k + '.') +walk(a) +" /copier-answers.yaml +``` + +--- + +## 7. Validation + +```bash +# Render the environment +copier copy . /tmp/gitops-validate --overwrite --answers-file /copier-answers.yaml + +# No .jinja may leak through +find /tmp/gitops-validate -name "*.jinja" | wc -l # must be 0 + +# Every rendered YAML parses +find /tmp/gitops-validate -name "*.yaml" -exec \ + python3 -c "import yaml, sys; yaml.safe_load(open(sys.argv[1])); print('OK:', sys.argv[1])" {} \; + +# Every Application has its resources directory +for app in $(find /tmp/gitops-validate/cluster/argocd -name "application.yaml" -exec \ + python3 -c "import yaml,sys; print(yaml.safe_load(open(sys.argv[1]))['spec']['source']['path'])" {} \;); do + [ -d "/tmp/gitops-validate/$app" ] || echo "MISSING resources dir: $app" +done +``` + +--- + +## 8. Mistakes to avoid + +- Editing `_src_path` or `_commit` — breaks `copier update`. +- Enabling a flag without supplying the resource's image and configuration variables. +- Writing a feature flag as `"true"` instead of `true` — truthy either way, so it renders + when disabled was intended. +- Editing an answer file without re-rendering and reviewing the diff. +- Setting `applications.global` addresses (host, mongo, redis, neo4j) without confirming the + environment's real infrastructure — wrong values misconfigure every service silently. +- Putting environment secrets in the answer file rather than an external secret manager. +- Assuming a sub-flag controls a resource when its parent band flag is `false`. diff --git a/references/helm-values.md b/references/helm-values.md new file mode 100644 index 0000000..9a1fbff --- /dev/null +++ b/references/helm-values.md @@ -0,0 +1,213 @@ +# Helm Values Templates + +Everything under `cluster/resources/**/values.yaml.jinja`: image wiring, the `global` block, +per-service sections, environment variables, probes, and replica counts. + +Read this when creating or editing a values template, or when rendered `values.yaml` produces +invalid YAML or unexpected Helm behaviour. + +--- + +## 1. Dual-layer rendering + +Every `values.yaml.jinja` is rendered **twice**: + +1. **By Copier** — substitutes `{{ variable }}` from the answer file, producing `values.yaml`. +2. **By Helm** — ArgoCD passes that `values.yaml` to `helm template` at sync time. + +The file must therefore be valid Jinja2 *and* produce valid Helm values afterwards. A +mistake in layer 1 fails the render; a mistake in layer 2 fails the sync, potentially hours +later. Validate both (section 6). + +--- + +## 2. Two categories of values file + +| Category | Shape | Used for | +|---|---|---| +| Operator / config resources | Simple YAML, minimal templating | CRD instances — OperatorGroup, Subscription, ClusterSecretStore | +| Application workloads | Full Helm values with `global` + per-service sections | Platform services, MCP servers, notification, integration | + +Both use the same `render()` macro pattern. Do not mix the `global` shape and the standalone +shape inside one file — pick the one matching the chart and follow it consistently. + +--- + +## 3. Minimal structure (every file) + +```jinja +{% macro render() %} +# --- values content here --- +key: {{ domain.get('sub', {}).get('field', '') }} +{% endmacro %} +{%- set result = render() -%} +{%- for line in result.split('\n') -%} +{% if line | trim | length != 0 -%} +{{ line }} +{% endif -%} +{%- endfor %} +``` + +--- + +## 4. Shared-chart workload structure + +Services that share one Helm release read cluster-wide settings from a `global` block: + +```yaml +global: + is_internal: "{{ applications.get('global', {}).get('is_internal', '') }}" + ca_env: {{ applications.get('global', {}).get('ca_env', '') }} + ca_secret_name: {{ applications.get('global', {}).get('ca_secret_name', '') }} + host: {{ applications.get('global', {}).get('host', '') }} + ca_host: {{ applications.get('global', {}).get('ca_host', '') }} + suffix_url: {{ applications.get('global', {}).get('suffix_url', '') }} + maintenance: "{{ applications.get('global', {}).get('maintenance', 'false') }}" + node_selector_app: "{{ applications.get('global', {}).get('node_selector_app', '') }}" + mongo: + host: {{ applications.get('global', {}).get('mongo', {}).get('host', '') }} + port: {{ applications.get('global', {}).get('mongo', {}).get('port', '') }} + user: {{ applications.get('global', {}).get('mongo', {}).get('user', '') }} + +network: + routes: [] + +: + enabled: true + image: + name: {{ applications.get('', {}).get('image', {}).get('address', '') }} + tag: {{ applications.get('', {}).get('image', {}).get('tag', '') }} + replicas: {{ applications.get('', {}).get('replicas', 1) }} +``` + +`global` goes at the top of the file. All `global` sub-keys use the established names — do +not introduce synonyms (`hostname` for `host`, `env` for `ca_env`); the chart templates read +the exact keys. + +--- + +## 5. Standalone workload structure + +Charts that do not share a `global` block use the standard Helm shape: + +```yaml +replicaCount: 1 + +image: + repository: {{ domain.get('service', {}).get('image', {}).get('address', '') }} + tag: {{ domain.get('service', {}).get('image', {}).get('tag', '') }} + pullPolicy: Always + +imagePullSecrets: + - name: "storage-images" + +service: + type: ClusterIP + port: 80 + +livenessProbe: + httpGet: + path: /health + port: + periodSeconds: 60 + +readinessProbe: + httpGet: + path: /health + port: + periodSeconds: 60 + +env: + targetPort: + : {{ domain.get('service', {}).get('env', {}).get('', '') }} +``` + +--- + +## 6. Design rules + +### Images + +- Always wire address and tag from Copier variables; never hardcode image coordinates. +- Use `.get()` with an empty-string default for every image field. +- `pullPolicy: Always` for mutable tags (`latest`, environment-specific). `IfNotPresent` + only for immutable digest-pinned images. +- Private registries need `imagePullSecrets` referencing the pre-existing secret — commonly + `storage-images`. + +### Booleans + +Booleans passed into `global` (`is_internal`, `maintenance`, `enable_instana`) are **quoted +strings** because the chart templates treat them as strings. This is deliberate and differs +from Copier feature flags, which are real YAML booleans. Keep the two straight: flags gate +rendering, these gate chart behaviour. + +### Environment variables + +- Map every env var from a Copier variable via `.get()`; never hardcode. +- Group them under an `env:` key in the service's section. +- Document *which* env vars are required, never their values — secrets come from an external + secret store, not from a values file. + +### Probes + +- Every application workload configures both `livenessProbe` and `readinessProbe`. +- Prefer `httpGet` on `/health` where the app exposes it. +- `periodSeconds: 60` is the default; change only when startup or health cadence demands it. + +### Replicas + +- Default to `1` via the Copier variable: `replicas: {{ domain.get('svc', {}).get('replicas', 1) }}`. +- Never hardcode — environments must scale independently. + +### Blank-line stripping + +- Wrap the full body in `{% macro render() %}` … `{% endmacro %}`. +- Always append the four-line stripping footer. +- Do not rely on `-` whitespace control inside the macro body for the same purpose. + +--- + +## 7. Adding a new values file + +1. Create it at `cluster/resources/{{ '' if }}/values.yaml.jinja` — the + expression identical to the `cluster/argocd/` side. +2. Open with `{% macro render() %}`. +3. Write the values body, sourcing every variable through a `.get()` chain. +4. Close with `{% endmacro %}` plus the stripping footer. +5. Confirm every referenced variable is declared in `copier.yaml`. +6. Render and validate. + +A starting template is in `assets/example-cluster-resource/resources/values.yaml.jinja`. + +--- + +## 8. Validation + +```bash +# Render (Jinja2 layer) +copier copy . /tmp/gitops-render --overwrite --defaults + +# Parse the rendered values (YAML layer) +python3 -c "import yaml; yaml.safe_load(open('/tmp/gitops-render/cluster/resources//values.yaml'))" + +# Lint against the chart (Helm layer) +helm lint -f /tmp/gitops-render/cluster/resources//values.yaml + +# See what Helm will actually produce +helm template \ + -f /tmp/gitops-render/cluster/resources//values.yaml +``` + +--- + +## 9. Mistakes to avoid + +- Omitting the `render()` wrapper — blank lines from unexpanded blocks produce invalid YAML. +- Hardcoding image tags, so every environment deploys the same version. +- Putting secrets in a values file instead of an ExternalSecret / ClusterSecretStore. +- Quoting numerics (replicas, ports) unless the chart explicitly expects a string. +- Mixing the `global` pattern and the standalone pattern in one file. +- Forgetting `imagePullSecrets` for a private-registry image — pods stall on `ImagePullBackOff`. +- Renaming a `global` sub-key to a synonym the chart does not read — the value silently + becomes empty rather than erroring.