Merge pull request #1 from CTOTools-skills-code-agent/4.0.0.0/IIAA-XYZ-gitops-argo-cd-skill-001
IIAA-XYZ: add gitops-argo-cd agent skill
This commit is contained in:
@@ -1 +1,50 @@
|
|||||||
# gitops-argo-cd
|
# 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).
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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`.
|
||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user