kubernetes_template_rendering 0.2.5 → 0.6.0.prerelease.tstarck.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dd614cd6cfbb3820cce568dea5609c10e3b76a468345673a69214f0faf1a54a5
4
- data.tar.gz: f9978c3776825effd13efeec919a3a25128f2cecdb0ed28545d69433de2f47a4
3
+ metadata.gz: 1306190d836dbde84a594aee2b814d7db1238f0863f4fd8a9c0ccea2979a46b3
4
+ data.tar.gz: dbae4fa4255214b4029227ad9e3bc48f73de0ba248f03cba7d584bfbdebc0dd1
5
5
  SHA512:
6
- metadata.gz: dca99566a866d75db291d679f5b4f700e23e254b2a869d93868345cf74c70c5a929f9d26195f20bf3f8b4693ded530fa46868578c87c9549a7db41b71cdd046d
7
- data.tar.gz: 9db31d09d52f310b8f1794218732d98a5ab5dd603028d4030d53f87da00a4be2bdb393ece991cf22a1374856f13add18ed4e3adb578302e590d40f021b14a3a5
6
+ metadata.gz: 85fbba190072912153255c4e06af9c3562613318d6c7cc0f14bf4e9d7f574015d2741bf17a85d3080d4f89c3cfdaf818d7d86e7c07c84d8294f764a370938864
7
+ data.tar.gz: ff0c9c764b719631f4fa250d0fb948203d2b1d57016de81aee53ae2c5febabf9e2f2266ce6d703a8c01f356993d77d143f2edd9f2d13d12e492829eee3c31fed
data/CHANGELOG.md CHANGED
@@ -4,6 +4,30 @@ Inspired by [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
4
4
 
5
5
  Note: this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.6.0] - Unreleased
8
+ ### Added
9
+ - Added `--reconcile` flag: a bounded, marker-based sweep that replaces the destructive per-entry `rm -rf` of `--prune`. It touches a marker before rendering, then after rendering deletes only files older than the marker under each scope root (`<region>/<cluster_type>/<color>/`) and removes empty directories, correctly cleaning up directories of deleted/renamed entries. `spp/` subtrees are fenced out of the base sweep, paths resolving outside their scope prefix raise a hard error, and `--reconcile` combined with `--prune` is rejected.
10
+ - Made `--reconcile` `--spp`-aware: without `--spp` only the `SPP-PLACEHOLDER` subtree is swept; with `--spp NAME` the sweep covers `SPP-PLACEHOLDER` (always re-rendered, as the expansion source) plus each requested per-SPP subtree (substituting `SPP-PLACEHOLDER` into the sweep root), leaving unrequested SPP siblings intact. Rejected `--reconcile` combined with `--only`, which would delete un-rendered siblings under the shared base root. See ADR-0002.
11
+ - Added a `--reconcile` SPP layout guard: SPP entries (name contains `SPP-PLACEHOLDER`) must render under `<region>/<cluster_type>/<color>/spp/SPP-PLACEHOLDER/`, and non-SPP entries must not render under any `spp/` segment. A `directory:` override that still resolves to the canonical SPP prefix is allowed; anything else hard-errors before rendering. The guard runs only under `--reconcile`. See ADR-0002.
12
+
13
+ ## [0.5.0] - 2026-06-26
14
+ ### Added
15
+ - Added `--spp NAME` (repeatable) flag that expands rendered output of `SPP-PLACEHOLDER` entries into per-Staging-Partial-Platform sibling directories, substituting `SPP-PLACEHOLDER` and its `PLACEHOLDER` suffix in both paths and contents. Composes with the SPP-derived base path introduced in 0.4.0 (sibling per-SPP trees are created next to the literal `SPP-PLACEHOLDER` segment). Replaces the post-render `invocaops_docker/tools/spp-transform/spp-transform.rb` step inside the gem.
16
+ - Added `--only NAME` (repeatable) flag that filters rendering to specific top-level `definitions.yaml` entries by exact key match. Composes with `--cluster_type`/`--region`/`--color`/`--spp` (all filters are AND'd). Raises with a list of valid keys if any `--only` value matches no entry across the rendered template directories.
17
+
18
+ ## [0.4.0] - 2026-06-25
19
+ ### Added
20
+ - Added a `subdirectory:` option to `definitions.yaml`. It is mutually exclusive with `directory:` and sets the output path to the base path `%{plain_region}/%{type}/%{color}/<subdirectory>`. When neither `directory:` nor `subdirectory:` is given, output is rendered to the base path `%{plain_region}/%{type}/%{color}` (previously a missing `directory:` raised an error).
21
+ - Emit a deprecation warning on any use of `directory:` in `definitions.yaml`, suggesting to remove it (to render into the standard `%{plain_region}/%{type}/%{color}` layout) or switch to `subdirectory:`. `directory:` is the only way to produce a non-standard path, which is unsafe for the planned `--reconcile` stale-resource deletion. See ADR-0001.
22
+ - SPP definitions (those whose name contains the `SPP-PLACEHOLDER` token) now render under a derived base path `%{plain_region}/%{type}/%{color}/spp/SPP-PLACEHOLDER`, with `subdirectory:` composing on top. This keeps each SPP instance's output distinct and bounded under the `region/type/color` tree for `--reconcile`, and preserves the literal `SPP-PLACEHOLDER` token for downstream per-instance substitution. The `directory:` deprecation warning now points SPP definitions at the SPP base layout. See ADR-0001.
23
+
24
+ ## [0.3.0] - 2026-06-24
25
+ ### Fixed
26
+ - Ruby 4.0 compatibility: declare `ostruct` as a dependency (it is `require`d directly but was removed from Ruby's default gems in 4.0.0) and bump `activesupport` to `7.2.3.1` so its `logger` dependency is resolved (`logger` was likewise dropped from default gems).
27
+
28
+ ### Removed
29
+ - No longer emit the `# Variable overrides used:` comment in rendered files. Because overrides such as `deploySha` change on every build, this comment caused large, content-free diffs across every rendered file. `--variable-override` still applies the overrides to the rendered output; only the comment is removed.
30
+
7
31
  ## [0.2.5] - 2025-05-08
8
32
  ### Fixed
9
33
  - Updated `rexml` dependency to avoid security issues
data/README.md CHANGED
@@ -7,12 +7,14 @@ configuration for various deployment environments.
7
7
  ## Installation
8
8
 
9
9
  Install the gem and add to the application's Gemfile by executing:
10
- ```
10
+
11
+ ```bash
11
12
  bundle add kubernetes_template_rendering
12
13
  ```
13
14
 
14
15
  If bundler is not being used to manage dependencies, install the gem by executing:
15
- ```
16
+
17
+ ```bash
16
18
  gem install kubernetes_template_rendering
17
19
  ```
18
20
 
@@ -23,20 +25,66 @@ To use this gem you can either install it, and use the `render_kubernetes_templa
23
25
  `gem exec` to execute the command without first installing the gem.
24
26
 
25
27
  ### Example Usage
28
+
26
29
  ```bash
27
30
  gem exec -g kubernetes_template_rendering render_templates \
28
31
  --jsonnet-library-path deployment/vendor \
29
- --rendered_directory path/to/resources \
32
+ --rendered-directory path/to/resources \
30
33
  deployment/templates
31
34
  ```
32
35
 
33
36
  ### Options
34
37
 
35
38
  To see a full list of options and how to use them, run the following command:
39
+
36
40
  ```bash
37
41
  gem exec -g kubernetes_template_rendering render_templates --help
38
42
  ```
39
43
 
44
+ ### Cleaning up stale output: `--prune` vs `--reconcile`
45
+
46
+ Both flags remove output left over from templates/entries that no longer render, but they differ in how:
47
+
48
+ - `--prune` deletes each entry's output directory with `rm -rf` **before** rendering. It never removes the directories of fully deleted/renamed entries and can clobber sibling directories when one entry renders at a prefix root above another.
49
+ - `--reconcile` performs a safer, bounded sweep: it touches a marker, renders, then deletes only files older than the marker under each scope root `<region>/<cluster_type>/<color>/` (honoring `--cluster_type` / `--region` / `--color`), and finally removes any now-empty directories. This cleans up directories of deleted/renamed entries without clobbering freshly-rendered siblings, and two identical reconcile runs produce the same result.
50
+
51
+ Notes:
52
+
53
+ - `--reconcile` and `--prune` are mutually exclusive (passing both exits with an error). `--reconcile` and `--only` are likewise mutually exclusive, since a filtered render would leave un-rendered siblings looking stale under the shared base root.
54
+ - `spp/` subtrees are fenced out of the base sweep. Without `--spp`, only the `SPP-PLACEHOLDER` subtree is swept. With `--spp NAME`, reconcile sweeps `SPP-PLACEHOLDER` (always re-rendered, as the expansion source) plus each requested per-SPP subtree, leaving unrequested SPP siblings intact. Deleted-SPP cleanup for unrequested siblings remains a manual `git rm` in the teardown runbook. See ADR-0002.
55
+ - If any rendered entry resolves to a path outside its scope prefix (a full-path or relative `..` escape), reconcile hard-errors before deleting anything.
56
+ - Reconcile enforces the SPP layout: every SPP entry (name contains `SPP-PLACEHOLDER`) must render under `<region>/<cluster_type>/<color>/spp/SPP-PLACEHOLDER/`, and no non-SPP entry may render under an `spp/` segment. A `directory:` override that still resolves to that prefix is allowed; anything else hard-errors before rendering. (This guard runs only under `--reconcile`.) See ADR-0002.
57
+
58
+ ### Filtering to specific entries
59
+
60
+ Pass `--only NAME` (repeatable) to render only the `definitions.yaml` entries whose top-level key exactly matches `NAME`. Composes with `--cluster_type`/`--region`/`--color`/`--spp` — all filters are AND'd.
61
+
62
+ ```bash
63
+ gem exec -g kubernetes_template_rendering render_templates \
64
+ --rendered-directory path/to/resources \
65
+ --cluster_type staging \
66
+ --only staging.test \
67
+ deployment/templates
68
+ ```
69
+
70
+ Useful when one `--cluster_type` matches multiple sibling entries (e.g. `staging` and `staging.test` both match `--cluster_type staging` after the suffix-strip rule) and you want to render only one of them. Repeated `--only` values are deduped.
71
+
72
+ If an `--only` value matches no entry across any template directory, the gem raises with the list of valid keys so the caller can self-correct.
73
+
74
+ ### Staging Partial Platforms
75
+
76
+ Pass `--spp NAME` (repeatable) to expand any entry whose `definitions.yaml` name contains `SPP-PLACEHOLDER` into a per-SPP sibling output. Substitutes `SPP-PLACEHOLDER` with `NAME` and the `PLACEHOLDER` suffix with the suffix of `NAME` (everything after the last `-`), in both file paths and contents. Source mtimes are preserved.
77
+
78
+ ```bash
79
+ gem exec -g kubernetes_template_rendering render_templates \
80
+ --rendered-directory path/to/resources \
81
+ --spp staging-qa02a \
82
+ --spp staging-qa08a \
83
+ deployment/templates
84
+ ```
85
+
86
+ This is purely additive — the placeholder-bearing output tree is still produced, and per-SPP trees are written alongside it. Repeated `--spp` values are deduped.
87
+
40
88
  ## Development
41
89
 
42
90
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
@@ -45,4 +93,4 @@ To install this gem onto your local machine, run `bundle exec rake install`. To
45
93
 
46
94
  ## Contributing
47
95
 
48
- Bug reports and pull requests are welcome on GitHub at https://github.com/invoca/kubernetes_template_rendering.
96
+ Bug reports and pull requests are welcome on GitHub at <https://github.com/invoca/kubernetes_template_rendering>.
@@ -0,0 +1,98 @@
1
+ # Strict, derived output paths so stale rendered resources can be safely reconciled
2
+
3
+ * Status: accepted
4
+ * Deciders: Tristan Starck, Kubernetes platform reviewers (via PR #17)
5
+ * Date: 2026-06-25
6
+
7
+ Technical Story: OCTO-849 — add a `subdirectory` option to `definitions.yaml` / [PR #17](https://github.com/Invoca/kubernetes_template_rendering/pull/17)
8
+
9
+ ## Context and Problem Statement
10
+
11
+ The renderer writes Kubernetes manifests into a `rendered_directory`. We want it to remove *stale* resources — those that are no longer rendered — so the rendered tree always matches the current templates. The current `--prune` flag does this bluntly: it `rm_rf`s an output directory before re-rendering. That is destructive (it deletes everything, then re-creates whatever is still rendered) and is only as safe as the output path is correct — a wrong or tree-escaping path means deleting the wrong files.
12
+
13
+ We are moving to a `--reconcile` flag instead: after rendering the desired set of resources, reconcile compares the rendered output tree against that desired set and deletes only the entries that are no longer rendered. Reconciliation is only safe and correct if the renderer knows exactly which paths it owns and can enumerate the complete desired set within a bounded tree.
14
+
15
+ Today the output location of each resource set is a freeform `directory:` pattern in `definitions.yaml` that can point anywhere, including escaping the rendered tree via `..` (e.g. `../some-cluster/...`). When output paths are arbitrary, the set of directories the renderer owns is not well-defined, so reconcile cannot safely decide what is stale. How do we make rendering deterministic enough to reconcile reliably — deleting resources that are no longer rendered, and nothing else?
16
+
17
+ ## Decision Drivers
18
+
19
+ * **Safe reconciliation of stale resources** — the core motivation: `--reconcile` must delete only resources the renderer owns and no longer renders.
20
+ * **Deterministic, bounded output paths** — output must be predictable and rooted inside the rendered tree, never escaping it, so the owned set is enumerable.
21
+ * **Lower boilerplate** — the `%{plain_region}/%{type}/%{color}` prefix is repeated in nearly every definition.
22
+ * **Backward compatibility** — existing `definitions.yaml` files that use `directory:` must keep working.
23
+ * **Hard to misuse** — the common case should require the least configuration and offer the fewest footguns.
24
+
25
+ ## Considered Options
26
+
27
+ * **Option 1 — Strict, derived paths.** Output is always `%{plain_region}/%{type}/%{color}[/<subdirectory>]`. Introduce a `subdirectory:` field, make the prefix the default, and deprecate/restrict the freeform `directory:` field.
28
+ * **Option 2 — Keep freeform `directory:` only.** Status quo; document conventions but enforce nothing.
29
+ * **Option 3 — Freeform `directory:` plus a rendered-path manifest.** Keep arbitrary layouts but emit an out-of-band manifest of rendered paths that `--reconcile` consults.
30
+
31
+ ## Decision Outcome
32
+
33
+ Chosen option: **Option 1 — strict, derived output paths.**
34
+
35
+ Rendered output paths MUST be derived from `plain_region`, the kubernetes cluster type (`type`), and `color`. The output directory of a resource set is resolved from the mutually-exclusive `directory:` and `subdirectory:` fields:
36
+
37
+ | Config present | Resulting output-directory pattern |
38
+ |---|---|
39
+ | `directory` only | the `directory` value, verbatim (legacy / escape hatch) |
40
+ | `subdirectory` only | `%{plain_region}/%{type}/%{color}/<subdirectory>` |
41
+ | `subdirectory` only, SPP definition | `%{plain_region}/%{type}/%{color}/spp/SPP-PLACEHOLDER/<subdirectory>` |
42
+ | neither, SPP definition | `%{plain_region}/%{type}/%{color}/spp/SPP-PLACEHOLDER` |
43
+ | neither | `%{plain_region}/%{type}/%{color}` (base path) |
44
+ | both | error (`ArgumentError`) |
45
+
46
+ `subdirectory:` is a plain literal final segment appended to the base path — no `%{...}` interpolation is performed on the value itself. The resolved pattern still flows through the existing per-region/color `format(...)` step, so e.g. `subdirectory: my-app` with region `us-east-1`, cluster type `prod`, and color `orange` renders into `us-east-1/prod/orange/my-app`. When neither field is given, output goes to the base path (previously a missing `directory:` raised an error).
47
+
48
+ Definitions whose name contains the `SPP-PLACEHOLDER` token (the same token from
49
+ which the `staging` cluster type is derived) render under an additional
50
+ `spp/SPP-PLACEHOLDER` segment: their base path is
51
+ `%{plain_region}/%{type}/%{color}/spp/SPP-PLACEHOLDER`, and `subdirectory:` composes
52
+ on top of it. The `spp/SPP-PLACEHOLDER` segment is literal (not substituted by
53
+ `format`), so the token survives into the rendered tree for downstream per-instance
54
+ substitution while remaining bounded under the `region/type/color` tree. The
55
+ warn-only-then-reject rollout for non-conforming `directory:` applies equally to SPP
56
+ definitions.
57
+
58
+ Because the base-path and `subdirectory` outputs are deterministic and rooted under the `region/type/color` tree, the renderer owns a predictable, enumerable set of directories. That is the property that lets `--reconcile` compute the desired set, diff it against what is on disk, and delete only the stale entries — safely and without escaping the tree.
59
+
60
+ Additionally, the freeform `directory:` field is **deprecated**. It remains supported for backward compatibility, but it is the only way to produce a non-conforming (or tree-escaping) path, so new definitions should use `subdirectory:` or the base-path default. As a first step, the renderer now **warns on any use of `directory:`**, suggesting authors remove it (to render into the standard `%{plain_region}/%{type}/%{color}` layout) or switch to `subdirectory:`. A subsequent follow-up will upgrade this to hard validation/removal so that legacy usage cannot break reconcile safety. **(Rejection not yet enforced — currently warn-only.)**
61
+
62
+ ### Positive Consequences
63
+
64
+ * `--reconcile` can safely delete stale resources because the renderer's output directories are deterministic and bounded.
65
+ * Less boilerplate — the common `region/type/color` prefix is supplied automatically.
66
+ * The misconfiguration surface shrinks over time as `directory:` usage is retired.
67
+
68
+ ### Negative Consequences
69
+
70
+ * **Behavior change:** a missing `directory:` no longer raises; it now renders to the base path. The previous "missing `directory:`" error is gone.
71
+ * Use of `directory:` is currently only a warning, not an error, so until the follow-up hard validation lands the reconcile-safety guarantee is not yet airtight.
72
+ * Deprecating `directory:` requires migrating existing definitions over time.
73
+
74
+ ## Pros and Cons of the Options
75
+
76
+ ### Option 1 — Strict, derived paths (chosen)
77
+
78
+ * Good, because deterministic, bounded paths make `--reconcile` / stale-resource deletion safe.
79
+ * Good, because it removes the repeated `region/type/color` prefix boilerplate.
80
+ * Good, because it is backward compatible — `directory:` still works while we steer toward strict paths.
81
+ * Bad, because the full guarantee is deferred until `directory:` is validated/retired.
82
+
83
+ ### Option 2 — Keep freeform `directory:` only
84
+
85
+ * Good, because no migration is required and layouts stay maximally flexible.
86
+ * Bad, because output ownership is undefined, so reconcile cannot safely decide what is stale.
87
+ * Bad, because patterns can escape the rendered tree via `..`.
88
+
89
+ ### Option 3 — Freeform `directory:` plus a rendered-path manifest
90
+
91
+ * Good, because it decouples reconcile from path layout and could track arbitrary layouts.
92
+ * Bad, because it adds a stateful artifact to generate, store, and trust.
93
+ * Bad, because it is more moving parts than simply enforcing a deterministic layout.
94
+
95
+ ## Links
96
+
97
+ * Implemented by [PR #17](https://github.com/Invoca/kubernetes_template_rendering/pull/17) (OCTO-849) — `subdirectory:` field, base-path default, `ResourceSet` directory resolution.
98
+ * Supersedes the implementation spec previously at `docs/superpowers/specs/2026-06-25-subdirectory-option-design.md` (removed; its content is folded into this ADR).
@@ -0,0 +1,114 @@
1
+ # SPP-aware reconcile scopes, layout guard, and a --reconcile/--only guard
2
+
3
+ * Status: accepted
4
+ * Deciders: Tristan Starck, Kubernetes platform reviewers
5
+ * Date: 2026-06-29 (revised 2026-07-06)
6
+
7
+ Technical Story: OCTO-842 — add a `reconcile` flag to kubernetes_template_rendering / depends on [PR #14](https://github.com/Invoca/kubernetes_template_rendering/pull/14) (`--spp` + `PlaceholderExpander`)
8
+
9
+ ## Revision History
10
+
11
+ * **2026-06-29** — original decision: SPP-aware sweep scopes (placeholder substitution) and the `--reconcile`/`--only` hard-error guard.
12
+ * **2026-07-06** — revised the `--spp` sweep scope to *also* sweep `SPP-PLACEHOLDER` (see "`--spp` reconcile scope derivation" below), and folded in the SPP layout guard previously drafted as ADR-0003.
13
+
14
+ ## Context and Problem Statement
15
+
16
+ [ADR-0001](0001-strict-rendering-paths-for-stale-resource-deletion.md) established `--reconcile`: after rendering the desired resources, sweep each owned root and delete files older than a marker captured before rendering, then remove empty directories. The base case sweeps `<region>/<cluster_type>/<color>/` with an `spp/` fence so the shared base sweep never touches Staging Partial Platform (SPP) subtrees.
17
+
18
+ SPP definitions render under `<region>/<cluster_type>/<color>/spp/SPP-PLACEHOLDER/...`. The literal `SPP-PLACEHOLDER` token is preserved so it can be expanded per-instance downstream. PR #14 introduces the `--spp NAME` flag and `PlaceholderExpander`, which expands the `SPP-PLACEHOLDER` output into one concrete subtree per requested SPP (e.g. `spp/staging-qa02a/`). Crucially, `SPP-PLACEHOLDER` is *always* re-rendered on every run — it is the source `PlaceholderExpander` copies from — so its freshly rendered files are always newer than the reconcile marker.
19
+
20
+ Three questions follow for reconcile:
21
+
22
+ 1. When a deploy targets specific SPPs (`--spp staging-qa02a`), which subtrees may reconcile sweep? It must delete stale files inside the *requested* SPP and inside `SPP-PLACEHOLDER` (both re-rendered this run), and must never touch unrequested SPP siblings (their deleted-SPP cleanup stays a manual `git rm` per the teardown runbook).
23
+ 2. How should `--reconcile` interact with PR #14's `--only` (render a subset of `definitions.yaml` entries)?
24
+ 3. How do we guarantee SPP resources actually land at the canonical `spp/SPP-PLACEHOLDER/` layout the sweep scoping depends on?
25
+
26
+ ## Decision Drivers
27
+
28
+ * **Bounded, targeted sweeps** — `--spp` must scope reconcile to exactly the subtrees rendered this run.
29
+ * **No accidental cross-SPP deletion** — unrequested SPP siblings must be fenced out of the sweep.
30
+ * **Sweep the leftovers we own** — `SPP-PLACEHOLDER` is re-rendered every run, so its stale files (from deleted/renamed templates) must be collected, not left to accumulate.
31
+ * **No silent data loss from partial renders** — a flag that renders a subset must not let reconcile delete the un-rendered (and therefore stale-looking) siblings.
32
+ * **Enforce the layout the sweep assumes** — SPP resources must be where the fence-and-substitute scoping expects them.
33
+ * **Minimal coupling to PR #14** — OCTO-842 owns reconcile scoping; expansion is PR #14's. Avoid duplicating `PlaceholderExpander` and keep the merge trivial.
34
+
35
+ ## Considered Options
36
+
37
+ ### Flag interaction: `--reconcile` + `--only`
38
+
39
+ * **Option A — Hard error.** Reject `--reconcile --only` the same way `--reconcile --prune` is rejected.
40
+ * **Option B — Narrow the sweep to only the rendered entries' roots.** Sweep just the roots of the `--only` entries.
41
+ * **Option C — Allow it as-is.** Run the normal base sweep alongside a subset render.
42
+
43
+ ### `--spp` reconcile scope derivation
44
+
45
+ * **Option 1 — Sweep `SPP-PLACEHOLDER` plus one root per requested target.** For each SPP scope whose root contains `SPP-PLACEHOLDER`, sweep the placeholder root itself *and* emit one sweep root per requested target (`spp/staging-qa02a`, …); with no `--spp`, sweep `spp/SPP-PLACEHOLDER` as-is.
46
+ * **Option 1a (original, superseded) — Substitute only, excluding `SPP-PLACEHOLDER`.** Under `--spp`, sweep *only* the requested targets and leave `SPP-PLACEHOLDER` untouched.
47
+ * **Option 2 — Always sweep the whole `spp/` tree** and rely on mtimes alone.
48
+
49
+ ### SPP layout enforcement (folded from ADR-0003)
50
+
51
+ * **When:** (A) only under `--reconcile`, or (B) always.
52
+ * **What:** (1) both directions — SPP entries must be under `spp/SPP-PLACEHOLDER/`, non-SPP entries must not be under any `spp/` segment; or (2) forward only.
53
+ * **`directory:` escape hatch:** (X) allow if it still resolves to the canonical prefix; or (Y) reject outright for SPP entries.
54
+
55
+ ## Decision Outcome
56
+
57
+ **Flag interaction: Option A (hard error).** `--reconcile --only` exits with a mutually-exclusive error. `--only` renders a subset, but reconcile sweeps the shared `<region>/<cluster_type>/<color>` base root; the un-rendered siblings would be older than the marker and get deleted. Option C is unsafe (silent deletion of valid resources). Option B is plausible but adds per-entry scoping complexity for a workflow no one has asked for; SPP targeting is already served by `--spp`. We can revisit Option B if a real need appears.
58
+
59
+ **`--spp` scope derivation: Option 1 (sweep `SPP-PLACEHOLDER` plus requested targets).** `collect_reconcile_scopes` splits each entry's sweep root into base roots (non-SPP, swept with an `spp/` fence) and SPP roots. For an SPP root, `spp_reconcile_roots`:
60
+
61
+ * with no `--spp`, returns the `spp/SPP-PLACEHOLDER` root unchanged (only the placeholder subtree is swept; expanded instances are left alone);
62
+ * with `--spp X [Y…]`, returns the `spp/SPP-PLACEHOLDER` root **and** one root per requested target (`spp/X`, `spp/Y`), replacing the `SPP-PLACEHOLDER` segment for each target.
63
+
64
+ The original decision (Option 1a) excluded `SPP-PLACEHOLDER` under `--spp`. That was revised on 2026-07-06: because the pipeline re-renders `SPP-PLACEHOLDER` on every run before expanding it, its fresh files are always newer than the marker, so sweeping it is marker-safe and *necessary* — otherwise stale output from deleted/renamed templates accumulates in `SPP-PLACEHOLDER` indefinitely when a team only ever deploys with `--spp`. Unrequested SPP siblings are *not* re-rendered this run, so they remain excluded (sweeping them would delete valid current files). Each derived root is re-validated to stay within `rendered_directory`. Substitution reuses the existing `ResourceSet::SPP_PLACEHOLDER` constant, so reconcile does **not** depend on PR #14's `PlaceholderExpander`; the two constants are equal once #14 lands.
65
+
66
+ **SPP layout enforcement: only under `--reconcile` (A), both directions (1), validate shape (X).** The fence-and-substitute scoping above is only sound if SPP resources live at the canonical `<region>/<cluster_type>/<color>/spp/SPP-PLACEHOLDER/` layout. Nothing guaranteed this: SPP-ness is decided by the entry name containing `SPP-PLACEHOLDER`, and the deprecated `directory:` escape hatch can render anywhere while only printing a warning. So under `--reconcile`, `validate_spp_layout!` (run in `collect_reconcile_scopes`, before the marker and any writes) enforces:
67
+
68
+ * **SPP entry** — `output_directory` must equal the canonical `spp_base_root` (`<rendered>/<region>/<cluster_type>/<color>/spp/SPP-PLACEHOLDER`) or sit beneath it (`start_with?(base + File::SEPARATOR)`; the separator guards against `SPP-PLACEHOLDER-2`-style prefix collisions).
69
+ * **Non-SPP entry** — `output_directory` must not contain any `spp` path segment (reusing `within_spp_subtree?`).
70
+
71
+ A `directory:` override that still resolves to the canonical prefix is allowed; anything else raises `Reconciler::SppLayoutError` before rendering. Comparing against the fully-formatted canonical prefix (rather than counting path segments) stays correct even if a region value contains a slash. Because expansion derives its per-target subtrees from the already-validated `SPP-PLACEHOLDER` root, validating the pre-expansion path is sufficient — `--reconcile --spp` needs no extra check. Enforcing only under `--reconcile` (Option A) keeps plain renders, which never sweep, unaffected.
72
+
73
+ ### Flag / layout interaction summary
74
+
75
+ | Flags | Reconcile sweep |
76
+ | --- | --- |
77
+ | `--reconcile` (no `--spp`) | base roots (with `spp/` fence) + `spp/SPP-PLACEHOLDER/` |
78
+ | `--reconcile --spp X [Y…]` | base roots (fenced) + `spp/SPP-PLACEHOLDER/` + one root per requested `spp/X/`, `spp/Y/`; unrequested SPPs excluded |
79
+ | `--reconcile --prune` | hard error |
80
+ | `--reconcile --only …` | hard error |
81
+ | SPP entry rendering outside `spp/SPP-PLACEHOLDER/` (under `--reconcile`) | `SppLayoutError` before any writes |
82
+ | non-SPP entry rendering under any `spp/` segment (under `--reconcile`) | `SppLayoutError` before any writes |
83
+
84
+ ### Positive Consequences
85
+
86
+ * `--spp` deploys reconcile the targeted SPP subtree(s) **and** `SPP-PLACEHOLDER`, so stale template output no longer accumulates in the placeholder tree; unrequested SPPs are never swept.
87
+ * Reconcile is decoupled from expansion: it sweeps based on on-disk state and mtimes regardless of how files were produced, so it needs no `PlaceholderExpander` dependency.
88
+ * The fence-and-substitute scoping rests on an enforced layout invariant rather than a convention.
89
+ * The two destructive-combination footguns (`--prune`, `--only`) fail fast with clear messages.
90
+
91
+ ### Negative Consequences
92
+
93
+ * `--reconcile --only` is unavailable; subset-targeted reconcile must go through `--spp`.
94
+ * Deleted-SPP cleanup for *unrequested* siblings, and placeholder-flip cleanup, remain manual `git rm` (no GC mode, no `--prune-old-spps`), as scoped in the ticket.
95
+ * A `directory:`-based SPP layout that reviewers previously tolerated now hard-errors under `--reconcile` and must move onto the canonical prefix.
96
+
97
+ ## Implementation Notes
98
+
99
+ * `lib/kubernetes_template_rendering/cli.rb` — adds `--spp` / `--only` parsing (repeatable, de-duplicated), threads `spps:` into the renderer, and adds the `--reconcile --only` guard.
100
+ * `lib/kubernetes_template_rendering/cli_arguments.rb` — adds `:spps`, `:only`.
101
+ * `lib/kubernetes_template_rendering/resource_set.rb` — accepts `spps:`; `reconcile_scopes` carries `spp:` and the canonical `spp_base_root:` per scope.
102
+ * `lib/kubernetes_template_rendering/reconciler.rb` — adds `Reconciler::SppLayoutError`.
103
+ * `lib/kubernetes_template_rendering/template_directory_renderer.rb` — `collect_reconcile_scopes` calls `spp_reconcile_roots` (SPP-PLACEHOLDER + requested targets) and `validate_spp_layout!` (after the existing scope-escape pass).
104
+
105
+ ### Merge ordering with PR #14
106
+
107
+ PR #14 landed on `main` first and OCTO-842 was merged on top of it. The `--spp` / `--only` flag plumbing, `CLIArguments` fields, and renderer signature were intentionally mirrored from PR #14, so the merge de-duplicated cleanly (identical hunks). Two pieces are owned by PR #14 rather than this change: the `--only` *filtering* of rendered entries (this ADR only adds the `--reconcile` + `--only` guard) and `PlaceholderExpander` (this change reuses the existing `ResourceSet::SPP_PLACEHOLDER` constant for sweep-root substitution and depends on the expander only at runtime). Since `--spp` expansion now runs during render (`PlaceholderExpander` preserves source mtimes), a `--spp` reconcile is a true end-to-end flow: expanded per-SPP files land after the marker and survive, while stale files in `SPP-PLACEHOLDER` and the requested SPP subtree are swept.
108
+
109
+ ## Links
110
+
111
+ * Refines [ADR-0001](0001-strict-rendering-paths-for-stale-resource-deletion.md)
112
+ * Supersedes the standalone ADR-0003 draft (SPP layout guard), now folded into this record.
113
+ * Depends on [PR #14](https://github.com/Invoca/kubernetes_template_rendering/pull/14)
114
+ * Supersedes the implementation spec previously at `docs/superpowers/specs/2026-07-06-reconcile-spp-layout-guard-design.md` (removed; its content is folded into this ADR).
@@ -32,7 +32,16 @@ module KubernetesTemplateRendering
32
32
  op.on("--region=REGION", "set the specific region to render") { args.region = _1 }
33
33
  op.on("--color=COLOR", "set the specific color to render") { args.color = _1 }
34
34
  op.on("--[no-]prune", "enable/disable pruning of untouched resources") { args.prune = _1 }
35
+ op.on("--[no-]reconcile", "bounded post-render sweep of stale resources") { args.reconcile = _1 }
35
36
  op.on("--source-repo=SOURCE_REPO", "set the source repo for the rendered templates") { args.source_repo = _1 }
37
+ op.on("--spp=NAME", "Staging Partial Platform target name to expand SPP-PLACEHOLDER output for (repeatable)") do |name|
38
+ args.spps ||= []
39
+ args.spps << name
40
+ end
41
+ op.on("--only=NAME", "render only the definitions.yaml entry with this exact top-level key (repeatable)") do |name|
42
+ args.only ||= []
43
+ args.only << name
44
+ end
36
45
 
37
46
  op.on("--variable-override=KEY:VALUE", "override a variable value set within definitions.yaml", Array) do |overrides|
38
47
  args.variable_overrides ||= {} # Initialize as a Hash
@@ -50,12 +59,27 @@ module KubernetesTemplateRendering
50
59
 
51
60
  parser.parse!(options)
52
61
  args.template_directory = options.first
62
+ args.spps = (args.spps || []).uniq
63
+ args.only = (args.only || []).uniq
53
64
 
54
65
  unless args.valid?
55
66
  STDERR.puts(parser)
56
67
  exit(1)
57
68
  end
58
69
 
70
+ if args.reconcile? && args.prune?
71
+ STDERR.puts("--reconcile and --prune are mutually exclusive")
72
+ exit(1)
73
+ end
74
+
75
+ # --only renders a subset of entries, but reconcile sweeps the shared <region>/<type>/<color>
76
+ # base root; the un-rendered siblings would look stale and be deleted. SPP targeting uses --spp
77
+ # (which scopes reconcile to per-SPP subtrees), so --only + --reconcile is rejected outright.
78
+ if args.reconcile? && args.only.any?
79
+ STDERR.puts("--reconcile and --only are mutually exclusive")
80
+ exit(1)
81
+ end
82
+
59
83
  [renderer_from_args(args), args]
60
84
  end
61
85
 
@@ -69,7 +93,9 @@ module KubernetesTemplateRendering
69
93
  region: args.region,
70
94
  color: args.color,
71
95
  variable_overrides: args.variable_overrides,
72
- source_repo: args.source_repo
96
+ source_repo: args.source_repo,
97
+ spps: args.spps,
98
+ only: args.only
73
99
  )
74
100
  end
75
101
 
@@ -13,7 +13,10 @@ module KubernetesTemplateRendering
13
13
  :color,
14
14
  :variable_overrides,
15
15
  :prune,
16
- :source_repo
16
+ :source_repo,
17
+ :reconcile,
18
+ :spps,
19
+ :only
17
20
  ) do
18
21
  def valid?
19
22
  rendered_directory && template_directory
@@ -31,6 +34,10 @@ module KubernetesTemplateRendering
31
34
  !!prune
32
35
  end
33
36
 
37
+ def reconcile?
38
+ !!reconcile
39
+ end
40
+
34
41
  def render_files?
35
42
  true
36
43
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'pathname'
5
+
6
+ module KubernetesTemplateRendering
7
+ # Expands a rendered placeholder-bearing output tree into a per-target sibling tree by
8
+ # substituting the placeholder token in both file paths and file contents. Mirrors the
9
+ # semantics of invocaops_docker/tools/spp-transform/spp-transform.rb.
10
+ class PlaceholderExpander
11
+ class << self
12
+ def expand!(source_directory:, target_name:, placeholder_token:)
13
+ placeholder_suffix = placeholder_token.rpartition('-').last
14
+ target_suffix = target_name.rpartition('-').last
15
+
16
+ source_root = Pathname.new(source_directory)
17
+ dest_root = Pathname.new(source_directory.gsub(placeholder_token, target_name))
18
+
19
+ Pathname.glob(source_root + "**" + "*").each do |source_path|
20
+ next unless source_path.file?
21
+
22
+ relative = source_path.relative_path_from(source_root).to_s
23
+ relative_substituted = relative.gsub(placeholder_token, target_name)
24
+ dest_path = dest_root + relative_substituted
25
+ FileUtils.mkdir_p(dest_path.dirname)
26
+
27
+ mtime = source_path.mtime
28
+ contents = File.read(source_path)
29
+ contents = contents.gsub(placeholder_token, target_name)
30
+ contents = contents.gsub(placeholder_suffix, target_suffix)
31
+ File.write(dest_path, contents)
32
+ File.utime(mtime, mtime, dest_path)
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "find"
5
+ require "tempfile"
6
+ require_relative "color"
7
+
8
+ module KubernetesTemplateRendering
9
+ # Bounded, marker-based reconcile sweep replacing the destructive per-entry `rm -rf` prune.
10
+ #
11
+ # Lifecycle (driven by TemplateDirectoryRenderer):
12
+ # 1. Construct (touches a marker file; its mtime is the cutoff) BEFORE any rendering/forking.
13
+ # 2. Render writes files (mtime > marker); PlaceholderExpander copies preserve a just-rendered
14
+ # source mtime (also > marker).
15
+ # 3. After all rendering, `sweep!` each scope root: delete files strictly older than the marker
16
+ # (leftovers from deleted/renamed entries), then remove now-empty directories.
17
+ #
18
+ # `spp/` subtrees are fenced out of the base sweep; per the ticket, deleted-SPP cleanup is a
19
+ # manual `git rm`, never an automatic sweep.
20
+ class Reconciler
21
+ class OutOfScopeError < StandardError; end
22
+ class SppLayoutError < StandardError; end
23
+
24
+ attr_reader :marker_mtime
25
+
26
+ def initialize(rendered_directory)
27
+ FileUtils.mkdir_p(rendered_directory)
28
+ @marker = Tempfile.new(".reconcile-marker", rendered_directory)
29
+ @marker.close
30
+ @marker_mtime = File.mtime(@marker.path)
31
+ end
32
+
33
+ def sweep!(root:, fences: [])
34
+ return unless File.directory?(root)
35
+
36
+ real_root = File.realpath(root)
37
+ fence_set = fences.select { |fence| File.exist?(fence) }.map { |fence| File.realpath(fence) }
38
+
39
+ delete_stale_files(root, real_root, fence_set)
40
+ remove_empty_dirs(root, fence_set)
41
+ end
42
+
43
+ def finish!
44
+ @marker.unlink
45
+ end
46
+
47
+ private
48
+
49
+ # Two-phase for all-or-nothing safety: validate the whole tree while collecting,
50
+ # then delete only after the walk succeeds. A scope error mid-walk deletes nothing.
51
+ def delete_stale_files(root, real_root, fence_set)
52
+ collect_stale_files(root, real_root, fence_set).each do |path|
53
+ puts "Reconcile: removing stale file #{Color.magenta(path)}"
54
+ File.delete(path)
55
+ end
56
+ end
57
+
58
+ def collect_stale_files(root, real_root, fence_set)
59
+ stale_paths = []
60
+
61
+ Find.find(root) do |path|
62
+ if File.directory?(path)
63
+ # Skip fenced subtrees (e.g. spp/) entirely; validate every other directory.
64
+ if fenced?(path, fence_set)
65
+ Find.prune
66
+ else
67
+ ensure_in_scope!(path, root, real_root)
68
+ end
69
+ next
70
+ end
71
+
72
+ next unless File.file?(path)
73
+
74
+ ensure_in_scope!(path, root, real_root)
75
+ stale_paths << path if stale?(path)
76
+ end
77
+
78
+ stale_paths
79
+ end
80
+
81
+ def stale?(path)
82
+ File.mtime(path) < @marker_mtime
83
+ end
84
+
85
+ def remove_empty_dirs(root, fence_set)
86
+ Dir.glob(File.join(root, "**", "*/"))
87
+ .reject { |dir| fenced?(dir, fence_set) }
88
+ .sort
89
+ .reverse # deepest paths first so parents empty out after their children
90
+ .each do |dir|
91
+ next unless File.directory?(dir)
92
+
93
+ if Dir.empty?(dir)
94
+ puts "Reconcile: removing empty directory #{Color.magenta(dir)}"
95
+ Dir.rmdir(dir)
96
+ end
97
+ end
98
+ end
99
+
100
+ def ensure_in_scope!(path, root, real_root)
101
+ real = File.realpath(path)
102
+ return if real == real_root || real.start_with?(real_root + File::SEPARATOR)
103
+
104
+ raise OutOfScopeError, "reconcile: #{path} resolves outside sweep root #{root}"
105
+ end
106
+
107
+ def fenced?(path, fence_set)
108
+ return false if fence_set.empty?
109
+
110
+ resolved = File.realpath(path)
111
+ fence_set.any? { |fence| resolved == fence || resolved.start_with?(fence + File::SEPARATOR) }
112
+ end
113
+ end
114
+ end
@@ -2,10 +2,14 @@
2
2
 
3
3
  require_relative "resource"
4
4
  require_relative "deploy_grouped_resource"
5
+ require_relative "placeholder_expander"
5
6
 
6
7
  # This class points to the resources in a given template_directory for a given kubernetes_cluster_type like 'ops' or 'prod' or 'ci'.
7
8
  # `config` contains the definitions found in `definitions_path`.
8
- # The most important config is "directory" which is a pattern like "%{region}/%{type}/%{color}/staging-ops".
9
+ # The output directory is resolved from the mutually-exclusive "directory" and "subdirectory" config:
10
+ # "directory" is a full pattern like "%{plain_region}/%{type}/%{color}/staging-ops".
11
+ # "subdirectory" is a literal final segment appended to the base path -> "%{plain_region}/%{type}/%{color}/<subdirectory>".
12
+ # when neither is given, output goes to the base path "%{plain_region}/%{type}/%{color}".
9
13
  # The config "regions", "colors" are the sets of regions and colors to render for.
10
14
  # It renders into `rendered_directory`.
11
15
  module KubernetesTemplateRendering
@@ -13,14 +17,16 @@ module KubernetesTemplateRendering
13
17
  attr_reader :variables, :output_directory, :deploy_group_config, :omitted_resources,
14
18
  :template_directory, :target_output_directory, :regions, :colors,
15
19
  :definitions_path, :kubernetes_cluster_type, :variable_overrides,
16
- :source_repo
20
+ :source_repo, :spps
17
21
 
18
- def initialize(config:, template_directory:, rendered_directory:, definitions_path:, kubernetes_cluster_type:, variable_overrides: {}, source_repo: nil)
22
+ def initialize(config:, template_directory:, rendered_directory:, definitions_path:, kubernetes_cluster_type:,
23
+ spp: false, spps: [], variable_overrides: {}, source_repo: nil)
24
+ @spp = spp
19
25
  @variables = config["variables"] || {}
20
26
  @deploy_group_config = config["deploy_groups"]
21
27
  @omitted_resources = config["omitted_resources"]
22
28
  @template_directory = template_directory
23
- @target_output_directory = config["directory"] or raise ArgumentError, "missing 'directory:' in #{config.inspect}"
29
+ @target_output_directory = resolve_target_output_directory(config["directory"], config["subdirectory"])
24
30
  @regions = config["regions"] || []
25
31
  @colors = config["colors"] || []
26
32
  @rendered_directory = rendered_directory
@@ -29,6 +35,7 @@ module KubernetesTemplateRendering
29
35
  @variable_overrides = variable_overrides
30
36
  @source_repo = source_repo
31
37
  @resources = {}
38
+ @spps = Array(spps)
32
39
 
33
40
  if @kubernetes_cluster_type != "kube-platform"
34
41
  @target_output_directory.include?("%{plain_region}") or raise "#{@template_directory}: target_output_directory #{@target_output_directory} needs %{plain_region}"
@@ -65,6 +72,28 @@ module KubernetesTemplateRendering
65
72
  end
66
73
  end
67
74
 
75
+ # Bounded reconcile scope roots for this entry, one per region × color.
76
+ #
77
+ # The sweep root is the parent directory of the entry's rendered `output_directory`, derived
78
+ # from the actual `directory:` pattern rather than assuming a fixed layout. For a
79
+ # `.../<service>` pattern that parent is the `<region>/<cluster_type>/<color>` base; for a
80
+ # `<region>/<service>` pattern it is `<region>`. The parent is shared with sibling entries, so
81
+ # leftovers from a deleted/renamed entry under it are swept. The renderer validates each
82
+ # `base_root` stays within `rendered_directory` (out-of-prefix, full or relative, is a hard error).
83
+ #
84
+ # Each scope also carries `spp:` (whether this entry is an SPP definition) and `spp_base_root:`
85
+ # (the canonical `<region>/<cluster_type>/<color>/spp/SPP-PLACEHOLDER` prefix for that region ×
86
+ # color) so the renderer can enforce the SPP layout guard before rendering.
87
+ def reconcile_scopes
88
+ @regions.flat_map do |plain_region|
89
+ @colors.map do |c|
90
+ output_directory = File.join(@rendered_directory, format(@target_output_directory, plain_region: plain_region, color: c, type: @kubernetes_cluster_type))
91
+ spp_base_root = File.join(@rendered_directory, format(SPP_BASE_OUTPUT_DIRECTORY, plain_region: plain_region, color: c, type: @kubernetes_cluster_type))
92
+ { base_root: File.dirname(output_directory), output_directory: output_directory, spp: @spp, spp_base_root: spp_base_root }
93
+ end
94
+ end
95
+ end
96
+
68
97
  def render_create_directory(args, output_directory)
69
98
  prune_directory(output_directory) if args.prune?
70
99
  create_directory(output_directory)
@@ -80,11 +109,53 @@ module KubernetesTemplateRendering
80
109
  resources(output_directory).each do |resource|
81
110
  resource.render(args)
82
111
  end
112
+ expand_placeholders(args, output_directory)
83
113
  puts
84
114
  end
85
115
 
86
116
  private
87
117
 
118
+ # The output-directory pattern is resolved from the mutually-exclusive
119
+ # `directory:` and `subdirectory:` config fields:
120
+ # directory only -> the directory pattern, verbatim
121
+ # subdirectory only -> "%{plain_region}/%{type}/%{color}/<subdirectory>"
122
+ # neither -> base path; "%{plain_region}/%{type}/%{color}", or the SPP base
123
+ # "%{plain_region}/%{type}/%{color}/spp/SPP-PLACEHOLDER" for SPP definitions
124
+ # both -> ArgumentError
125
+ SPP_PLACEHOLDER = "SPP-PLACEHOLDER"
126
+ BASE_OUTPUT_DIRECTORY = File.join("%{plain_region}", "%{type}", "%{color}")
127
+ # SPP definitions render under an extra spp/SPP-PLACEHOLDER segment so each SPP
128
+ # instance has a distinct, bounded path and the literal token survives for
129
+ # downstream per-instance substitution.
130
+ SPP_BASE_OUTPUT_DIRECTORY = File.join(BASE_OUTPUT_DIRECTORY, "spp", SPP_PLACEHOLDER)
131
+
132
+ def resolve_target_output_directory(directory, subdirectory)
133
+ if directory && subdirectory
134
+ raise ArgumentError, "specify only one of 'directory:' or 'subdirectory:' in #{({ 'directory' => directory, 'subdirectory' => subdirectory }).inspect}"
135
+ elsif directory
136
+ warn_directory_deprecated(directory)
137
+ directory
138
+ elsif subdirectory
139
+ File.join(base_output_directory, subdirectory)
140
+ else
141
+ base_output_directory
142
+ end
143
+ end
144
+
145
+ # SPP definitions render under SPP_BASE_OUTPUT_DIRECTORY; everything else uses the standard base.
146
+ def base_output_directory
147
+ @spp ? SPP_BASE_OUTPUT_DIRECTORY : BASE_OUTPUT_DIRECTORY
148
+ end
149
+
150
+ # `directory:` is a deprecated legacy escape hatch. It is the only way to produce a
151
+ # path that is not derived from the standard "%{plain_region}/%{type}/%{color}" layout,
152
+ # which is unsafe for --reconcile stale-resource deletion (see ADR-0001). Warn on any use.
153
+ def warn_directory_deprecated(directory)
154
+ puts Color.brown("WARNING: #{@template_directory}: `directory:` is deprecated. " \
155
+ "Remove it to render into the standard #{base_output_directory} layout, " \
156
+ "or use `subdirectory:` instead. (got `directory: #{directory}`)")
157
+ end
158
+
88
159
  CLOUD_REGION_TO_PROVIDER_AND_DATACENTER = {
89
160
  # Note: The names below should match RegionDiscovery from process_settings-production.
90
161
  # https://github.com/Invoca/process_settings-production/blob/main/settings/region_discovery/production_regions.yml
@@ -182,6 +253,27 @@ module KubernetesTemplateRendering
182
253
  end
183
254
  end
184
255
 
256
+ # Two orthogonal gates:
257
+ # @spps.empty? - caller didn't pass --spp NAME; nothing to fan out to
258
+ # !@spp - entry name is not SPP-shaped, so output_directory does not contain
259
+ # SPP_PLACEHOLDER; gsub-based expansion would be a no-op that hits
260
+ # --prune as a data-loss footgun (per_spp_dir collides with source).
261
+ def expand_placeholders(args, output_directory)
262
+ return if @spps.empty?
263
+ return unless @spp
264
+
265
+ @spps.each do |spp_name|
266
+ per_spp_dir = output_directory.gsub(SPP_PLACEHOLDER, spp_name)
267
+ prune_directory(per_spp_dir) if args.prune?
268
+ puts "Expanding #{SPP_PLACEHOLDER} to #{Color.magenta(spp_name)}"
269
+ PlaceholderExpander.expand!(
270
+ source_directory: output_directory,
271
+ target_name: spp_name,
272
+ placeholder_token: SPP_PLACEHOLDER
273
+ )
274
+ end
275
+ end
276
+
185
277
  def resources(output_directory)
186
278
  @resources[output_directory] ||= standard_resources(output_directory) + grouped_resources(output_directory)
187
279
  end
@@ -25,11 +25,11 @@ module KubernetesTemplateRendering
25
25
 
26
26
  def with_auto_generated_yaml_comment(yaml_string)
27
27
  comment = <<~EOS
28
- # WARNING: DO NO EDIT THIS FILE!
28
+ # WARNING: DO NOT EDIT THIS FILE!
29
29
  # Any changes made here will be lost.
30
30
  # This file is autogenerated from #{full_template_path}
31
31
  EOS
32
- comment + variable_override_comment + yaml_string
32
+ comment + yaml_string
33
33
  end
34
34
 
35
35
  def full_template_path
@@ -40,14 +40,6 @@ module KubernetesTemplateRendering
40
40
  end
41
41
  end
42
42
 
43
- def variable_override_comment
44
- if variable_overrides&.any?
45
- <<~EOS
46
- # Variable overrides used: #{variable_overrides.to_json}
47
- EOS
48
- end || ""
49
- end
50
-
51
43
  class << self
52
44
  # @param [String] template_path: file path to template file that needs to be rendered.
53
45
  # @param [Hash]: variables that will be used in the template file to generate distict files.
@@ -3,8 +3,10 @@
3
3
  require 'active_support'
4
4
  require 'active_support/core_ext' # for deep_merge
5
5
  require 'invoca/utils'
6
+ require 'pathname'
6
7
  require 'yaml'
7
8
  require_relative 'resource_set'
9
+ require_relative 'reconciler'
8
10
  require 'set'
9
11
 
10
12
  # This class points to a collection of template directories to render, and a rendered_directory to render into.
@@ -12,10 +14,13 @@ require 'set'
12
14
  module KubernetesTemplateRendering
13
15
  class TemplateDirectoryRenderer
14
16
  DEFINITIONS_FILENAME = "definitions.yaml"
17
+ SPP_FENCE_DIRNAME = "spp"
15
18
 
16
- attr_reader :directories, :omitted_names, :rendered_directory, :cluster_type, :region, :color, :variable_overrides, :source_repo
19
+ attr_reader :directories, :omitted_names, :rendered_directory, :cluster_type, :region, :color,
20
+ :variable_overrides, :source_repo, :spps, :only
17
21
 
18
- def initialize(directories:, rendered_directory:, omitted_names: [], cluster_type: nil, region: nil, color: nil, variable_overrides: nil, source_repo: nil)
22
+ def initialize(directories:, rendered_directory:, omitted_names: [], cluster_type: nil, region: nil, color: nil,
23
+ variable_overrides: nil, source_repo: nil, spps: [], only: [])
19
24
  @directories = directories_with_definitions(Array(directories))
20
25
  @omitted_names = Array(omitted_names)
21
26
  @rendered_directory = rendered_directory
@@ -24,9 +29,16 @@ module KubernetesTemplateRendering
24
29
  @color = color
25
30
  @variable_overrides = variable_overrides || {}
26
31
  @source_repo = source_repo
32
+ @spps = Array(spps)
33
+ @only = Array(only)
27
34
  end
28
35
 
29
36
  def render(args)
37
+ if args.reconcile?
38
+ sweep_scopes = collect_reconcile_scopes # validates out-of-prefix before any writes
39
+ reconciler = Reconciler.new(@rendered_directory) # marker captured before rendering/forking
40
+ end
41
+
30
42
  child_pids = []
31
43
  failed_processes = [] # [[pid, status]]
32
44
 
@@ -58,10 +70,103 @@ module KubernetesTemplateRendering
58
70
  raise "Child process completed with non-zero status: #{failed_processes.inspect}"
59
71
  end
60
72
  end
73
+
74
+ reconcile_sweep(reconciler, sweep_scopes) if args.reconcile?
61
75
  end
62
76
 
63
77
  private
64
78
 
79
+ # Collects sweep roots across all rendered resource sets and validates that each stays within
80
+ # rendered_directory (out-of-prefix, full or relative `..`, is a hard error).
81
+ #
82
+ # Returns two categories:
83
+ # base_roots — non-SPP entries' shared parent (e.g. <region>/<cluster_type>/<color>/);
84
+ # swept with an spp/ fence so sibling SPP directories are never touched.
85
+ # spp_roots — the specific SPP directory/directories that were rendered (e.g. spp/<spp-name>/);
86
+ # swept without a fence since we are already inside exactly one SPP directory.
87
+ # Without --spp, only the SPP-PLACEHOLDER root is swept. With --spp, the sweep
88
+ # covers SPP-PLACEHOLDER (always re-rendered) plus each requested target; unrequested
89
+ # siblings are left intact (their deleted-SPP cleanup stays a manual git rm).
90
+ def collect_reconcile_scopes
91
+ scopes = resource_sets.values.flatten.flat_map(&:reconcile_scopes)
92
+ scopes.each do |scope|
93
+ validate_within_scope!(scope[:base_root], @rendered_directory)
94
+ validate_spp_layout!(scope)
95
+ end
96
+
97
+ base_roots = []
98
+ spp_roots = []
99
+ scopes.each do |scope|
100
+ if within_spp_subtree?(scope[:base_root])
101
+ spp_roots.concat(spp_reconcile_roots(scope))
102
+ else
103
+ base_roots << scope[:base_root]
104
+ end
105
+ end
106
+ spp_roots.each { |r| validate_within_scope!(r, @rendered_directory) }
107
+ { base_roots: base_roots.uniq, spp_roots: spp_roots.uniq }
108
+ end
109
+
110
+ # Raises unless `path` resolves within `scope_root` (full-path or relative `..` escape).
111
+ # Lexical check (expand_path, not realpath) so it validates planned roots before they exist;
112
+ # Reconciler applies a separate realpath-based guard against symlink escapes during the sweep.
113
+ def validate_within_scope!(path, scope_root)
114
+ resolved = File.expand_path(path)
115
+ root = File.expand_path(scope_root)
116
+ unless resolved == root || resolved.start_with?(root + File::SEPARATOR)
117
+ raise Reconciler::OutOfScopeError, "reconcile: path #{resolved} resolves outside scope prefix #{root}"
118
+ end
119
+ end
120
+
121
+ # Under --reconcile, SPP entries must render beneath the canonical
122
+ # <region>/<cluster_type>/<color>/spp/SPP-PLACEHOLDER/ prefix, and non-SPP entries must never
123
+ # render beneath any spp/ segment. A directory:-overridden path that still resolves to the
124
+ # canonical SPP prefix is allowed; anything else is a hard error before any writes.
125
+ def validate_spp_layout!(scope)
126
+ output = File.expand_path(scope[:output_directory])
127
+ if scope[:spp]
128
+ base = File.expand_path(scope[:spp_base_root])
129
+ unless output == base || output.start_with?(base + File::SEPARATOR)
130
+ raise Reconciler::SppLayoutError,
131
+ "reconcile: SPP entry renders to #{output}, outside the required SPP prefix #{base}"
132
+ end
133
+ elsif within_spp_subtree?(scope[:output_directory])
134
+ raise Reconciler::SppLayoutError,
135
+ "reconcile: non-SPP entry renders under an spp/ segment (#{output}); only SPP entries may render beneath spp/"
136
+ end
137
+ end
138
+
139
+ # Expands an SPP sweep root into the concrete roots to sweep for this run.
140
+ # Without --spp, the placeholder root (spp/SPP-PLACEHOLDER) is swept as-is. With --spp, the sweep
141
+ # covers the placeholder root *and* each requested target (spp/staging-qa02a, ...): SPP-PLACEHOLDER
142
+ # is always re-rendered (it is the expansion source) and each target is freshly expanded from it,
143
+ # so both are marker-safe. Unrequested SPP siblings are not re-rendered this run and stay excluded.
144
+ def spp_reconcile_roots(scope)
145
+ root = spp_sweep_root(scope)
146
+ return [root] if @spps.empty?
147
+ return [root] unless root.include?(ResourceSet::SPP_PLACEHOLDER)
148
+
149
+ [root] + @spps.map { |spp_name| root.gsub(ResourceSet::SPP_PLACEHOLDER, spp_name) }
150
+ end
151
+
152
+ def within_spp_subtree?(root)
153
+ Pathname.new(root).relative_path_from(Pathname.new(@rendered_directory)).each_filename.include?(SPP_FENCE_DIRNAME)
154
+ end
155
+
156
+ # When the directory pattern has no service subdirectory (e.g. `.../spp/SPP-PLACEHOLDER`),
157
+ # base_root lands at the `spp/` level itself — sweeping that would touch all SPP siblings.
158
+ # Use output_directory (= `spp/<spp-name>`) in that case instead.
159
+ def spp_sweep_root(scope)
160
+ File.basename(scope[:base_root]) == SPP_FENCE_DIRNAME ? scope[:output_directory] : scope[:base_root]
161
+ end
162
+
163
+ def reconcile_sweep(reconciler, scopes)
164
+ scopes[:base_roots].each { |r| reconciler.sweep!(root: r, fences: [File.join(r, SPP_FENCE_DIRNAME)]) }
165
+ scopes[:spp_roots].each { |r| reconciler.sweep!(root: r) }
166
+ ensure
167
+ reconciler.finish!
168
+ end
169
+
65
170
  def read_definitions(path)
66
171
  File.read(path)
67
172
  end
@@ -106,26 +211,48 @@ module KubernetesTemplateRendering
106
211
  end
107
212
 
108
213
  def resource_sets
109
- @resource_sets ||= @directories.each_with_object({}) do |dir, hash|
110
- definitions_path = definitions_path_for_dir(dir)
111
- config = load_config(definitions_path)
112
-
113
- config.map do |name, config|
114
- next if omitted_names.include?(name)
115
-
116
- kubernetes_cluster_type = name.sub('SPP-PLACEHOLDER', 'staging').sub(/\..*/, '') # prod.gcp => prod
117
-
118
- hash[name] ||= []
119
- hash[name] << ResourceSet.new(
120
- config: config,
121
- template_directory: dir,
122
- rendered_directory: @rendered_directory,
123
- kubernetes_cluster_type: kubernetes_cluster_type,
124
- definitions_path: definitions_path,
125
- variable_overrides: @variable_overrides,
126
- source_repo: @source_repo
127
- )
214
+ @resource_sets ||= begin
215
+ matched_only = Set.new
216
+ all_keys = Set.new
217
+ sets = @directories.each_with_object({}) do |dir, hash|
218
+ definitions_path = definitions_path_for_dir(dir)
219
+ config = load_config(definitions_path)
220
+
221
+ config.each_key { |name| all_keys << name }
222
+
223
+ config.map do |name, config|
224
+ next if omitted_names.include?(name)
225
+ next if @only.any? && !@only.include?(name)
226
+ matched_only << name if @only.include?(name)
227
+
228
+ kubernetes_cluster_type = name.sub(ResourceSet::SPP_PLACEHOLDER, 'staging').sub(/\..*/, '') # prod.gcp => prod
229
+ spp = name.include?(ResourceSet::SPP_PLACEHOLDER)
230
+
231
+ hash[name] ||= []
232
+ hash[name] << ResourceSet.new(
233
+ config: config,
234
+ template_directory: dir,
235
+ rendered_directory: @rendered_directory,
236
+ kubernetes_cluster_type: kubernetes_cluster_type,
237
+ spp: spp,
238
+ spps: @spps,
239
+ definitions_path: definitions_path,
240
+ variable_overrides: @variable_overrides,
241
+ source_repo: @source_repo
242
+ )
243
+ end
128
244
  end
245
+
246
+ if @only.any?
247
+ unmatched = @only - matched_only.to_a
248
+ if unmatched.any?
249
+ raise ArgumentError,
250
+ "--only values not found in any definitions.yaml entry: #{unmatched.inspect}. " \
251
+ "Valid keys: #{all_keys.to_a.sort.inspect}"
252
+ end
253
+ end
254
+
255
+ sets
129
256
  end
130
257
  end
131
258
 
@@ -173,7 +300,7 @@ module KubernetesTemplateRendering
173
300
  end
174
301
 
175
302
  expand_config(config).each_with_object({}) do |(name, data), hash|
176
- if !cluster_type || cluster_type == name.sub('SPP-PLACEHOLDER', 'staging').sub(/\..*/, '') # prod.gcp => prod
303
+ if !cluster_type || cluster_type == name.sub(ResourceSet::SPP_PLACEHOLDER, 'staging').sub(/\..*/, '') # prod.gcp => prod
177
304
  cluster_type_config = OpenStruct.new(data)
178
305
 
179
306
  cluster_type_config.regions = cluster_type_config.regions & [region] if region
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module KubernetesTemplateRendering
4
- VERSION = "0.2.5"
4
+ VERSION = "0.6.0.prerelease.tstarck.1"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kubernetes_template_rendering
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.5
4
+ version: 0.6.0.prerelease.tstarck.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Octothorpe
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-05-08 00:00:00.000000000 Z
11
+ date: 2026-07-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -52,6 +52,20 @@ dependencies:
52
52
  - - ">="
53
53
  - !ruby/object:Gem::Version
54
54
  version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: ostruct
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
55
69
  description: Tool for rendering ERB and Jsonnet templates
56
70
  email:
57
71
  - octothorpe@invoca.com
@@ -71,6 +85,8 @@ files:
71
85
  - docs/CHANGELOG.md
72
86
  - docs/README.md
73
87
  - docs/adrs/0000-template.md
88
+ - docs/adrs/0001-strict-rendering-paths-for-stale-resource-deletion.md
89
+ - docs/adrs/0002-spp-aware-reconcile-scopes-and-only-guard.md
74
90
  - exe/render_templates
75
91
  - lib/kubernetes_template_rendering.rb
76
92
  - lib/kubernetes_template_rendering/cli.rb
@@ -79,6 +95,8 @@ files:
79
95
  - lib/kubernetes_template_rendering/deploy_grouped_resource.rb
80
96
  - lib/kubernetes_template_rendering/erb_template.rb
81
97
  - lib/kubernetes_template_rendering/jsonnet_template.rb
98
+ - lib/kubernetes_template_rendering/placeholder_expander.rb
99
+ - lib/kubernetes_template_rendering/reconciler.rb
82
100
  - lib/kubernetes_template_rendering/resource.rb
83
101
  - lib/kubernetes_template_rendering/resource_set.rb
84
102
  - lib/kubernetes_template_rendering/template.rb