kubernetes_template_rendering 0.7.0 → 0.8.0
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 +4 -4
- data/CHANGELOG.md +4 -0
- data/docs/adrs/0003-nested-multi-file-output.md +165 -0
- data/lib/kubernetes_template_rendering/resource.rb +41 -2
- data/lib/kubernetes_template_rendering/version.rb +1 -1
- data/mkdocs.yml +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1b1538a338a89bf830d6fa47ab6c1b08b756399fa2e923abc34fef7ba796470d
|
|
4
|
+
data.tar.gz: 5b8d03d43e2cac5ab1b0b6060006e83885adcf1f682d576a00c9eac155d4e74c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5805d53bd6d47c2dfaa86b282d98661fba87de5a7958364c04f8f2e7dda30b396fec6f4db036e81e682dcfc778e53e109c6358f898377c8daab84593400c4425
|
|
7
|
+
data.tar.gz: e630935cca259aa07afc4929aab4a8cd42e7c90c6168e64bffdcc36d9696c736ce82ba31a301093230620144ba00e31ab609c59c06115cb3972b7b44cbbea9c9
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,10 @@ 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.8.0] - 2026-07-23
|
|
8
|
+
### Added
|
|
9
|
+
- MULTI_FILE_RENDER keys and `MULTI_FILE_RENDER_NAME` values may now contain `/` to render into nested output directories (e.g. key `pr-1/app/foo` writes `<output dir>/pr-1/app/foo.yaml`); intermediate directories are created automatically. Previously such keys crashed with `Errno::ENOENT`. Names that resolve outside the output directory raise `ArgumentError` before any write. See docs/adrs/0003-nested-multi-file-output.md.
|
|
10
|
+
|
|
7
11
|
## [0.7.0] - 2026-07-23
|
|
8
12
|
### Added
|
|
9
13
|
- `--variable-override` now supports dotted-path keys (`components.webServer.hpa.minReplicas:2`) that deep-merge into nested variables, with JSON value coercion (integers, floats, booleans, `null`, quoted strings; non-JSON values stay raw strings) and `\.` escaping for literal dots. Plain `KEY:VALUE` (no dot) behaves exactly as before: top-level key, raw string value.
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# Nested output directories from MULTI_FILE_RENDER keys containing `/`
|
|
2
|
+
|
|
3
|
+
* Status: accepted
|
|
4
|
+
* Deciders: James Ebentier, Kubernetes platform reviewers
|
|
5
|
+
* Date: 2026-07-23
|
|
6
|
+
|
|
7
|
+
Technical Story: [OCTO-919](https://invoca.atlassian.net/browse/OCTO-919) — nested multi-file output spike (ST-11)
|
|
8
|
+
|
|
9
|
+
## Context and Problem Statement
|
|
10
|
+
|
|
11
|
+
Ephemeral deploy scripts (`deploy_ephemeral.sh` in voice-agent, contact-center-agents, polaris) render a flat file set and then shuffle files into `pr-N/{app,mysql}/` directories before committing to the rendered repo. If `MULTI_FILE_RENDER` keys could contain `/` (e.g. `pr-12/app/polaris-svc`), the service-templates-jsonnet library (ST-05) could emit the final layout directly and ST-09's vendored scripts could drop the file shuffle.
|
|
12
|
+
|
|
13
|
+
Keys are producer-controlled (they come from the template author's Jsonnet). A `/` in a key **currently crashes the render** because `File.write` does not create intermediate directories.
|
|
14
|
+
|
|
15
|
+
**Key→filename producer** (`lib/kubernetes_template_rendering/jsonnet_template.rb:60-67`):
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
def render_json_doc(json_doc, default_file_name, file_name_to_yaml_hash)
|
|
19
|
+
if multi_file_jsonnet_doc?(json_doc)
|
|
20
|
+
render_multi_file_jsonnet!(json_doc, file_name_to_yaml_hash)
|
|
21
|
+
else
|
|
22
|
+
file_name = file_name_from_object(json_doc, default: default_file_name)
|
|
23
|
+
file_name_to_yaml_hash["#{file_name}.yaml"] = with_auto_generated_yaml_comment(json_doc.to_yaml)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Keys (and `MULTI_FILE_RENDER_NAME` values, resolved by `file_name_from_object`) pass through **untouched** into the returned hash — no Jsonnet-side change is needed for nested paths.
|
|
29
|
+
|
|
30
|
+
**File-writing site** (`lib/kubernetes_template_rendering/resource.rb:34-50`):
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
def write_template(args)
|
|
34
|
+
print_status
|
|
35
|
+
|
|
36
|
+
# If a Hash is returned, that means this is a multi-file template, meaning we need to iterate over the hash.
|
|
37
|
+
# Else a String is returned and we can write it directly to the output file.
|
|
38
|
+
rt = rendered_template(args)
|
|
39
|
+
|
|
40
|
+
if args.render_files?
|
|
41
|
+
if rt.is_a?(Hash)
|
|
42
|
+
rt.each do |filename, contents|
|
|
43
|
+
File.write(output_path(filename), contents)
|
|
44
|
+
end
|
|
45
|
+
else
|
|
46
|
+
File.write(output_path(@output_filename), rt)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
**Path join** (`lib/kubernetes_template_rendering/resource.rb:68-70`):
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
def output_path(filename)
|
|
56
|
+
File.join(@output_directory, filename)
|
|
57
|
+
end
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`File.write` on the joined path is where `Errno::ENOENT` fires for nested keys. This is the single write path for all multi-file output; the non-hash branch's `@output_filename` derives from the template basename and can never contain `/`.
|
|
61
|
+
|
|
62
|
+
### Executable ENOENT evidence
|
|
63
|
+
|
|
64
|
+
Before this change, `spec/kubernetes_template_rendering/resource_spec.rb` drove `Resource#render` with a hash key `pr-1/app/foo.yaml` and observed:
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
Errno::ENOENT: No such file or directory @ rb_sysopen - .../pr-1/app/foo.yaml
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
No working template can emit `/`-bearing keys today, so enabling nested keys is not a backward-compatibility break.
|
|
71
|
+
|
|
72
|
+
## Decision Drivers
|
|
73
|
+
|
|
74
|
+
* Drop the `pr-N/{app,mysql}` file-shuffle from ephemeral deploy scripts once producers can emit nested keys (ST-05 / ST-09).
|
|
75
|
+
* Keys are producer-controlled; the gem should not rewrite or reject `/` in keys beyond path-safety containment.
|
|
76
|
+
* Crash today implies zero live consumers of nested keys — safe to enable without a migration.
|
|
77
|
+
* Reconcile and prune must remain correct for nested subtrees (marker-based sweep posture from ADR-0001/0002).
|
|
78
|
+
|
|
79
|
+
## Considered Options
|
|
80
|
+
|
|
81
|
+
* **Option A — Enable nested keys at the write site with a containment guard.** Add `FileUtils.mkdir_p` before `File.write` and reject `..` escapes in `output_path`.
|
|
82
|
+
* **Option B — Keep flat output and the script shuffle.** Document the blocker; pin ENOENT regression spec; no `lib/` change.
|
|
83
|
+
* **Option C — Gate nested keys behind a new CLI flag.** Rejected: keys already crash today; a flag adds surface for no safety gain.
|
|
84
|
+
|
|
85
|
+
## Decision Outcome
|
|
86
|
+
|
|
87
|
+
Chosen option: **Option A (implement nested output)**, because all four "small" bar conditions are satisfied:
|
|
88
|
+
|
|
89
|
+
1. **Confined change:** Only `Resource#write_template`, `Resource#output_path`, and `require "fileutils"` in `resource.rb`, plus specs/fixtures. No changes to `cli.rb`, `cli_arguments.rb`, `jsonnet_template.rb`, `resource_set.rb`, `template_directory_renderer.rb`, or `reconciler.rb`.
|
|
90
|
+
2. **Suite stability:** Full existing spec suite passes with zero expectation changes to pre-existing examples.
|
|
91
|
+
3. **Containment guard:** Four lines in `output_path`, matching `template_directory_renderer.rb:118-124` posture.
|
|
92
|
+
4. **Interplay:** `--prune`, `--reconcile`, and SPP expansion require documentation only (see Interplay analysis).
|
|
93
|
+
|
|
94
|
+
### Positive Consequences
|
|
95
|
+
|
|
96
|
+
* ST-05 may emit `pr-<prId>/{app,mysql}/...` keys after gem release and ST-01's Gemfile pin bump from `0.6.2`.
|
|
97
|
+
* ST-09 scripts can drop the file shuffle once the fleet converges; they are written to tolerate both layouts until then.
|
|
98
|
+
* Flat-output consumers (e.g. `cleanup.sh` scanning `pr-*-application.yaml` at the color-folder root) are unaffected until a producer opts in.
|
|
99
|
+
|
|
100
|
+
### Negative Consequences
|
|
101
|
+
|
|
102
|
+
* A key `foo` and key `foo/bar` in one template can coexist as `foo.yaml` and `foo/` directory on POSIX — legal, no special handling.
|
|
103
|
+
* Nested keys are unusable by ST-05 until (a) this gem version is published to rubygems.org and (b) ST-01's `Gemfile` pin is deliberately bumped.
|
|
104
|
+
|
|
105
|
+
## Interplay analysis
|
|
106
|
+
|
|
107
|
+
### `--prune`
|
|
108
|
+
|
|
109
|
+
`ResourceSet#prune_directory` (`resource_set.rb:248-257`) runs `FileUtils.rm_rf(directory)` on the whole output directory before rendering. Nested subtrees are removed with the parent directory; **no code change required**.
|
|
110
|
+
|
|
111
|
+
### `--reconcile`
|
|
112
|
+
|
|
113
|
+
`Reconciler#collect_stale_files` walks with `Find.find(root)` (recursive — nested files are visited). `remove_empty_dirs` globs `File.join(root, "**", "*/")` deepest-first. Freshly written nested files (mtime > marker) survive; stale nested files from removed keys are deleted; emptied nested directories are removed. **No Reconciler change required.**
|
|
114
|
+
|
|
115
|
+
### SPP expansion
|
|
116
|
+
|
|
117
|
+
`PlaceholderExpander` already handles nested directories — fixture `spec/fixtures/placeholder_expander/source/SPP-PLACEHOLDER/nested-SPP-PLACEHOLDER/service.yaml` proves rendered trees may nest. **No change required.**
|
|
118
|
+
|
|
119
|
+
### DeployGroupedResource
|
|
120
|
+
|
|
121
|
+
`DeployGroupedResource#filename_for_deploy_group` builds flat `<basename>-<group>-deploy.yaml` output filenames. **Unaffected.**
|
|
122
|
+
|
|
123
|
+
### print_status cosmetics
|
|
124
|
+
|
|
125
|
+
`Resource#print_status` prints `File.basename(output_path(@output_filename))` — the template's default name, not per-key names. **Non-issue for hash-key nesting.**
|
|
126
|
+
|
|
127
|
+
## Path safety
|
|
128
|
+
|
|
129
|
+
`File.join` + `File.write` would follow `..` out of the output directory without a guard (`File.expand_path("dir/../evil", "/base")` → `/base/evil`). `output_path` applies two layers of containment before any write, then **returns the same lexically expanded path that was validated** (not the raw `File.join` result):
|
|
130
|
+
|
|
131
|
+
1. **Lexical check** — `File.expand_path` on the joined path must stay under the expanded output directory (catches `..` segments and absolute-looking keys after `File.join` neutralization).
|
|
132
|
+
2. **Filesystem check** — walk each existing path component from the output directory toward the target file's parent directory; reject if any component is a symlink, and verify the resolved parent directory (via `File.realpath` on existing segments) still lies under the real output directory. This closes a bypass where a committed symlink inside a `*-kubernetes` checkout could let a producer-controlled nested key write outside the render sandbox — `File.expand_path` alone never resolves symlinks.
|
|
133
|
+
|
|
134
|
+
**Return-value alignment:** an earlier implementation validated `expanded` but returned the raw `path` from `File.join`. That reopened an escape when a key contained both `..` and a symlink segment (e.g. `a/../b/foo.yaml` with `a` → outside): lexical expansion folds the key to `<output_dir>/b/foo.yaml` (passing all checks), but `File.write` on the unvalidated raw path resolves the symlink before applying `..`, landing outside the sandbox. The fix is to return `expanded` so the write target exactly matches what was validated. Redundant-but-safe segments (`a/./b`, `a//b`) normalize away via `File.expand_path` without changing the logical destination.
|
|
135
|
+
|
|
136
|
+
Absolute-looking keys (`/etc/passwd`) are neutralized by `File.join` semantics (`File.join("dir", "/etc/passwd")` → `"dir/etc/passwd"`, not an absolute path) and then subject to the same guards.
|
|
137
|
+
|
|
138
|
+
**Per-key atomicity:** for multi-file hashes, each key is validated immediately before its own write. A later key that fails containment does not roll back files already written for earlier keys in the same hash.
|
|
139
|
+
|
|
140
|
+
## Pros and Cons of the Options
|
|
141
|
+
|
|
142
|
+
### Option A — Enable nested keys at write site
|
|
143
|
+
|
|
144
|
+
* Good, because change is minimal (~10 lines in one file).
|
|
145
|
+
* Good, because Jsonnet producer already passes keys through unchanged.
|
|
146
|
+
* Good, because reconcile/prune/SPP already handle nested trees.
|
|
147
|
+
* Bad, because gem release + ST-01 pin bump are prerequisites before library adoption.
|
|
148
|
+
|
|
149
|
+
### Option B — Defer (flat output only)
|
|
150
|
+
|
|
151
|
+
* Good, because zero gem churn.
|
|
152
|
+
* Bad, because ephemeral scripts keep the file shuffle indefinitely.
|
|
153
|
+
* Bad, because nested keys remain a footgun (ENOENT) for any author who tries them.
|
|
154
|
+
|
|
155
|
+
### Option C — CLI flag
|
|
156
|
+
|
|
157
|
+
* Good, because explicit opt-in.
|
|
158
|
+
* Bad, because keys already crash — no producer uses nested keys today.
|
|
159
|
+
* Bad, because adds CLI surface and documentation burden for no safety gain over Option A.
|
|
160
|
+
|
|
161
|
+
## Links
|
|
162
|
+
|
|
163
|
+
* Depends on [OCTO-918](https://invoca.atlassian.net/browse/OCTO-918) (ST-10) for same-repo sequencing only.
|
|
164
|
+
* Consumed by ST-05 (library file-key derivation) and ST-09 (scripts quartet) after gem release.
|
|
165
|
+
* Related: [ADR-0001](0001-strict-rendering-paths-for-stale-resource-deletion.md), [ADR-0002](0002-spp-aware-reconcile-scopes-and-only-guard.md)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "fileutils"
|
|
3
4
|
require "pathname"
|
|
4
5
|
require_relative "color"
|
|
5
6
|
require_relative "erb_template"
|
|
@@ -41,7 +42,9 @@ module KubernetesTemplateRendering
|
|
|
41
42
|
if args.render_files?
|
|
42
43
|
if rt.is_a?(Hash)
|
|
43
44
|
rt.each do |filename, contents|
|
|
44
|
-
|
|
45
|
+
path = output_path(filename)
|
|
46
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
47
|
+
File.write(path, contents)
|
|
45
48
|
end
|
|
46
49
|
else
|
|
47
50
|
File.write(output_path(@output_filename), rt)
|
|
@@ -66,7 +69,43 @@ module KubernetesTemplateRendering
|
|
|
66
69
|
end
|
|
67
70
|
|
|
68
71
|
def output_path(filename)
|
|
69
|
-
File.join(@output_directory, filename)
|
|
72
|
+
path = File.join(@output_directory, filename)
|
|
73
|
+
base = File.expand_path(@output_directory)
|
|
74
|
+
expanded = File.expand_path(path)
|
|
75
|
+
|
|
76
|
+
unless expanded == base || expanded.start_with?(base + File::SEPARATOR)
|
|
77
|
+
raise ArgumentError, "output filename #{filename.inspect} escapes output directory #{@output_directory}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
validate_resolved_containment!(File.dirname(expanded), base, filename)
|
|
81
|
+
expanded
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def validate_resolved_containment!(target_dir, base, filename)
|
|
85
|
+
base_real = realpath_or_expand(base)
|
|
86
|
+
current = base_real
|
|
87
|
+
Pathname.new(target_dir).relative_path_from(Pathname.new(base)).each_filename do |part|
|
|
88
|
+
candidate = File.join(current, part)
|
|
89
|
+
if File.symlink?(candidate)
|
|
90
|
+
raise ArgumentError, "output filename #{filename.inspect} escapes output directory #{@output_directory}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
if File.exist?(candidate)
|
|
94
|
+
current = File.realpath(candidate)
|
|
95
|
+
else
|
|
96
|
+
break
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
return if current == base_real || current.start_with?(base_real + File::SEPARATOR)
|
|
101
|
+
|
|
102
|
+
raise ArgumentError, "output filename #{filename.inspect} escapes output directory #{@output_directory}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def realpath_or_expand(path)
|
|
106
|
+
File.realpath(path)
|
|
107
|
+
rescue Errno::ENOENT
|
|
108
|
+
File.expand_path(path)
|
|
70
109
|
end
|
|
71
110
|
|
|
72
111
|
def template_filename(template_path)
|
data/mkdocs.yml
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: kubernetes_template_rendering
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Octothorpe
|
|
@@ -87,6 +87,7 @@ files:
|
|
|
87
87
|
- docs/adrs/0000-template.md
|
|
88
88
|
- docs/adrs/0001-strict-rendering-paths-for-stale-resource-deletion.md
|
|
89
89
|
- docs/adrs/0002-spp-aware-reconcile-scopes-and-only-guard.md
|
|
90
|
+
- docs/adrs/0003-nested-multi-file-output.md
|
|
90
91
|
- exe/render_templates
|
|
91
92
|
- lib/kubernetes_template_rendering.rb
|
|
92
93
|
- lib/kubernetes_template_rendering/cli.rb
|