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) <noreply@anthropic.com>
This commit is contained in:
2026-07-29 23:48:32 +03:00
co-authored by Claude Opus 5
parent 89a9cf9320
commit 50d746d03f
11 changed files with 1615 additions and 1 deletions
+49
View File
@@ -1 +1,50 @@
# 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).
+252
View File
@@ -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 `<env>/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
<repo>/
├── copier.yaml # variable schema + defaults
├── cluster/
│ ├── argocd/
│ │ ├── application.yaml.jinja # root app-of-apps (000-root)
│ │ └── {{ 'NNN-name' if <flag> }}/
│ │ └── application.yaml.jinja # one child Application per resource
│ └── resources/
│ └── {{ 'NNN-name' if <flag> }}/
│ └── values.yaml.jinja # Helm values for that Application
└── <env>/
└── 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/<dir>/application.yaml.jinja` must have a
`cluster/resources/<same-dir>/` 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 <flag> }}/application.yaml.jinja`.
4. Create `cluster/resources/{{ 'NNN-name' if <flag> }}/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 "<NNN>" # expect no output
# Render with the resource enabled
copier copy . /tmp/gitops-on --overwrite --defaults \
--data '<domain>.<descriptor>_is_enabled=true' \
--data '<domain>.<descriptor>.image.address=myregistry/myimage' \
--data '<domain>.<descriptor>.image.tag=1.0.0'
ls /tmp/gitops-on/cluster/argocd/ | grep "<NNN>" # expect the directory
# Render a real environment
copier copy . /tmp/gitops-env --overwrite --answers-file <env>/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 <chart-path> -f /tmp/gitops-on/cluster/resources/<NNN-name>/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.
@@ -0,0 +1,37 @@
{# ---------------------------------------------------------------------------
Template for cluster/argocd/{{ '<NNN>-<name>' if <domain>.<descriptor>_is_enabled }}/application.yaml.jinja
Replace:
<NNN>-<name> three-digit band number + kebab-case descriptor (e.g. 210-eso-operator-group)
<target-namespace> namespace the workload deploys into
<domain> copier.yaml domain object (operators, databases, applications, …)
<descriptor> 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: <NNN>-<name>
namespace: openshift-gitops
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
project: default
source:
repoURL: {{ gitops.repo_url }}
targetRevision: {{ gitops.branch }}
path: cluster/resources/<NNN>-<name>
helm:
valueFiles:
- values.yaml
destination:
server: https://kubernetes.default.svc
namespace: <target-namespace>
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
@@ -0,0 +1,46 @@
# ---------------------------------------------------------------------------
# Snippet to merge into copier.yaml when adding a new cluster resource.
#
# Replace:
# <domain> existing domain object (operators, configuration, databases,
# advantage_mcp, applications) — never add a top-level variable
# <descriptor> this resource's key inside that domain
# <NNN>-<name> 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/).
# ---------------------------------------------------------------------------
<domain>:
<descriptor>_is_enabled:
type: bool
default: false
help: "Enable <NNN>-<name>"
<descriptor>:
image:
address:
type: str
default: ""
help: "Container image for <name>"
tag:
type: str
default: ""
help: "Container image tag for <name>"
replicas:
type: str
default: "1"
help: "Replica count for <name>"
# ---------------------------------------------------------------------------
# Matching snippet for <env>/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.
# ---------------------------------------------------------------------------
#
# <domain>:
# <descriptor>_is_enabled: true
# <descriptor>:
# image:
# address: <registry>/<image>
# tag: <version>
@@ -0,0 +1,48 @@
{# ---------------------------------------------------------------------------
Template for cluster/resources/{{ '<NNN>-<name>' if <domain>.<descriptor>_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 <NNN>-<name>
replicaCount: {{ <domain>.get('<descriptor>', {}).get('replicas', 1) }}
image:
repository: {{ <domain>.get('<descriptor>', {}).get('image', {}).get('address', '') }}
tag: {{ <domain>.get('<descriptor>', {}).get('image', {}).get('tag', '') }}
pullPolicy: Always
imagePullSecrets:
- name: "storage-images"
service:
type: ClusterIP
port: 80
livenessProbe:
httpGet:
path: /health
port: <app-port>
periodSeconds: 60
readinessProbe:
httpGet:
path: /health
port: <app-port>
periodSeconds: 60
env:
targetPort: <app-port>
<env_var>: {{ <domain>.get('<descriptor>', {}).get('env', {}).get('<env_var>', '') }}
{% endmacro %}
{%- set result = render() -%}
{%- for line in result.split('\n') -%}
{% if line | trim | length != 0 -%}
{{ line }}
{% endif -%}
{%- endfor %}
+195
View File
@@ -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: <NNN>-<kebab-case-descriptor>
Flag: <domain>.<descriptor>_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
<domain>:
<descriptor>_is_enabled:
type: bool
default: false
help: "Enable <NNN>-<name>"
<descriptor>:
image:
address:
type: str
default: ""
help: "Container image for <name>"
tag:
type: str
default: ""
help: "Container image tag for <name>"
# 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/{{ '<NNN>-<name>' if <domain>.<descriptor>_is_enabled }}/application.yaml.jinja`
```yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: <NNN>-<name>
namespace: openshift-gitops
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
project: default
source:
repoURL: {{ gitops.repo_url }}
targetRevision: {{ gitops.branch }}
path: cluster/resources/<NNN>-<name>
helm:
valueFiles:
- values.yaml
destination:
server: https://kubernetes.default.svc
namespace: <target-namespace>
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
```
---
## Step 4 — Create the Helm values template
`cluster/resources/{{ '<NNN>-<name>' if <domain>.<descriptor>_is_enabled }}/values.yaml.jinja`
The conditional expression must be **character-identical** to step 3.
```jinja
{% macro render() %}
# values for <NNN>-<name>
replicaCount: 1
image:
repository: {{ <domain>.get('<descriptor>', {}).get('image', {}).get('address', '') }}
tag: {{ <domain>.get('<descriptor>', {}).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
<domain>:
<descriptor>_is_enabled: true
<descriptor>:
image:
address: <registry>/<image>
tag: <version>
```
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 "<NNN>" # expect no output
# Flag on: the directories must appear
copier copy . /tmp/gitops-render-on --overwrite --defaults \
--data '<domain>.<descriptor>_is_enabled=true' \
--data '<domain>.<descriptor>.image.address=myregistry/myimage' \
--data '<domain>.<descriptor>.image.tag=1.0.0'
ls /tmp/gitops-render-on/cluster/argocd/ | grep "<NNN>"
# Both rendered files must parse
python3 -c "import yaml; yaml.safe_load(open('/tmp/gitops-render-on/cluster/argocd/<NNN>-<name>/application.yaml'))"
python3 -c "import yaml; yaml.safe_load(open('/tmp/gitops-render-on/cluster/resources/<NNN>-<name>/values.yaml'))"
# Helm lint, when a chart path is available
helm lint <chart-path> -f /tmp/gitops-render-on/cluster/resources/<NNN>-<name>/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.
+143
View File
@@ -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: <NNN-name>
namespace: openshift-gitops
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
project: default
source:
repoURL: {{ gitops.repo_url }}
targetRevision: {{ gitops.branch }}
path: cluster/resources/<NNN-name>
helm:
valueFiles:
- values.yaml
destination:
server: https://kubernetes.default.svc
namespace: <target-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 <feature_flag> }}` — 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/{{ '<NNN-name>' if <feature_flag> }}/application.yaml.jinja
```
3. Create the mirror:
```text
cluster/resources/{{ '<NNN-name>' if <feature_flag> }}/values.yaml.jinja
```
4. Write the manifest from the anatomy in section 1.
5. Declare `<feature_flag>` 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 <NNN-name>
argocd app diff <NNN-name>
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.
+161
View File
@@ -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/
│ └── <mirrored conditional directories>/
└── <env>/
└── 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 `<sub>_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: <env-name>
maintenance: "false"
host: <public-hostname>
ca_host: <internal-hostname>
suffix_url: <path-suffix>
node_selector_app: <node-label>
mongo:
host: <mongodb-host>
port: "27017"
user: <mongodb-user>
redis:
host: <redis-host>
port: "6379"
max_retries: "3"
neo4j:
url: <neo4j-bolt-url>
user: <neo4j-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.
+205
View File
@@ -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 |
|---|---|
| `<domain>.is_enabled` | Top-level flag for a whole domain (`operators.is_enabled`) |
| `<domain>.<sub>_is_enabled` | Sub-feature flag (`operators.eso_is_enabled`) |
| `<domain>.<sub>.image.address` | Container image repository path |
| `<domain>.<sub>.image.tag` | Container image tag |
| `<domain>.<sub>.env.<var>` | 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
<domain>:
<descriptor>_is_enabled:
type: bool
default: false
help: "Enable <NNN>-<name>"
<descriptor>:
image:
address:
type: str
default: ""
help: "Container image for <name>"
tag:
type: str
default: ""
help: "Container image tag for <name>"
```
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 <env>/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: '<x>' 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.
+265
View File
@@ -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
+ <env>/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 `<env>/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: <git-commit-sha> # 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 `<domain>.is_enabled` and `<domain>.<sub>_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
<domain>:
<service>:
image:
address: <registry>/<image-name>
tag: <semver-or-sha>
```
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: <env-name> # dev | staging | production
maintenance: "false" # string bool
host: <public-hostname>
ca_host: <internal-hostname>
suffix_url: <path-suffix>
node_selector_app: <node-label>
mongo:
host: <mongodb-host>
port: "27017"
user: <mongodb-user>
redis:
host: <redis-host>
port: "6379"
max_retries: "3"
neo4j:
url: <neo4j-bolt-url>
user: <neo4j-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 <new-env>
# Option A — interactive prompts
copier copy . /tmp/gitops-<new-env> --answers-file <new-env>/copier-answers.yaml
# Option B — start from the nearest existing environment
cp <base-env>/copier-answers.yaml <new-env>/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-<env> \
--answers-file <env>/copier-answers.yaml \
--overwrite
git -C /path/to/rendered-<env> 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)
" <env>/copier-answers.yaml
```
---
## 7. Validation
```bash
# Render the environment
copier copy . /tmp/gitops-validate --overwrite --answers-file <env>/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`.
+213
View File
@@ -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: []
<service-name>:
enabled: true
image:
name: {{ applications.get('<service-name>', {}).get('image', {}).get('address', '') }}
tag: {{ applications.get('<service-name>', {}).get('image', {}).get('tag', '') }}
replicas: {{ applications.get('<service-name>', {}).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: <app-port>
periodSeconds: 60
readinessProbe:
httpGet:
path: /health
port: <app-port>
periodSeconds: 60
env:
targetPort: <app-port>
<env_var>: {{ domain.get('service', {}).get('env', {}).get('<env_var>', '') }}
```
---
## 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/{{ '<NNN-name>' if <flag> }}/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/<NNN-name>/values.yaml'))"
# Lint against the chart (Helm layer)
helm lint <path-to-chart> -f /tmp/gitops-render/cluster/resources/<NNN-name>/values.yaml
# See what Helm will actually produce
helm template <release> <path-to-chart> \
-f /tmp/gitops-render/cluster/resources/<NNN-name>/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.