kubernetes_template_rendering 0.6.2 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7c71eb502c7b52355ee6d9040a8401a6e7937477b4ffc81b725951187340e325
4
- data.tar.gz: 07e31b80f5c7c2e0d95860d10a4a73390dd919eeed86000867e151faad9f7113
3
+ metadata.gz: 1b1538a338a89bf830d6fa47ab6c1b08b756399fa2e923abc34fef7ba796470d
4
+ data.tar.gz: 5b8d03d43e2cac5ab1b0b6060006e83885adcf1f682d576a00c9eac155d4e74c
5
5
  SHA512:
6
- metadata.gz: 3113be1c65d2e3f356e82b0b3388f9a090fab3945dcc9c54720146f12f9f95d8c95e7dd7fae7af0c00687aa6e557cf84c7fc6de5754f16c7e831fe101b283770
7
- data.tar.gz: 64b29afd0de7680e0a8c4aa57010268bc3e1e0bb6ec502c11d91e1ac518d633371d8b1b8f7fa368cb71b73f5e89754e42fecc36cbdd56e698077064269316e0a
6
+ metadata.gz: 5805d53bd6d47c2dfaa86b282d98661fba87de5a7958364c04f8f2e7dda30b396fec6f4db036e81e682dcfc778e53e109c6358f898377c8daab84593400c4425
7
+ data.tar.gz: e630935cca259aa07afc4929aab4a8cd42e7c90c6168e64bffdcc36d9696c736ce82ba31a301093230620144ba00e31ab609c59c06115cb3972b7b44cbbea9c9
data/CHANGELOG.md CHANGED
@@ -4,6 +4,19 @@ 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
+
11
+ ## [0.7.0] - 2026-07-23
12
+ ### Added
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.
14
+ - Added `--variable-override-json '<json object>'` (repeatable), deep-merged with all other override flags in command-line order (later flags win). Use it for values containing commas or whole structures.
15
+ - Overrides are echoed once to stdout at render start (`Variable overrides (deep-merged after definitions.yaml): {...}`). Rendered files still carry no override comment (see 0.3.0).
16
+
17
+ ### Changed
18
+ - `Template#initialize` merges `variable_overrides` with `deep_merge` instead of `merge`. Behavior-identical for all previously-valid inputs (legacy override values are always strings, so no hash-vs-hash merge could occur).
19
+
7
20
  ## [0.6.2] - 2026-07-15
8
21
  ### Fixed
9
22
  - Fixed the `--reconcile` sweep root for entries whose `subdirectory:` nests more than one level deep (e.g. `subdirectory: exclude-argocd/auth`). The sweep root is now the entry's canonical base — `<region>/<cluster_type>/<color>` for non-SPP entries and `<region>/<cluster_type>/<color>/spp/SPP-PLACEHOLDER` for SPP entries — regardless of `subdirectory:` depth, rather than the immediate parent of the rendered output directory. Previously a nested `subdirectory:` pushed the sweep root one level too deep, so when a `subdirectory:` was renamed or nested deeper the files left at the old shallower path were siblings of the sweep root and were never swept.
data/README.md CHANGED
@@ -71,6 +71,43 @@ Useful when one `--cluster_type` matches multiple sibling entries (e.g. `staging
71
71
 
72
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
73
 
74
+ ### Variable overrides
75
+
76
+ Pass `--variable-override KEY:VALUE` (repeatable) to override values from `definitions.yaml` after all per-section variables and auto-injected region/cluster vars are resolved. Overrides always win.
77
+
78
+ **Legacy form (no dot in KEY):** sets a top-level key to the raw string value — exactly the pre-0.7.0 behavior. Values are never type-coerced (`deploySha:12345` stays the string `"12345"`; `enabled:true` stays `"true"`). The value is everything after the first colon (`image:registry:5000/app` → `"registry:5000/app"`). Arguments with no colon are silently ignored.
79
+
80
+ ```bash
81
+ gem exec -g kubernetes_template_rendering render_templates \
82
+ --rendered-directory path/to/resources \
83
+ --variable-override deploySha:abc123 \
84
+ deployment/templates
85
+ ```
86
+
87
+ **Dotted-path form (KEY contains `.`):** the key is split on unescaped dots into a nested path and deep-merged into variables. The value is JSON-coerced when it parses as JSON (`2` → integer, `true`/`false` → booleans, `null` → nil, `"2"` → string); otherwise the raw string is kept (`hello` → `"hello"`, `02` → `"02"`).
88
+
89
+ ```bash
90
+ gem exec -g kubernetes_template_rendering render_templates \
91
+ --rendered-directory path/to/resources \
92
+ --variable-override components.webServer.hpa.minReplicas:2 \
93
+ deployment/templates
94
+ ```
95
+
96
+ Escape literal dots in a segment with `\.` (e.g. `metadata.labels.app\.kubernetes\.io/name:foo`). Only `\.` is an escape sequence; all other backslashes — including trailing ones — are kept literally. Keys that would need a literal backslash immediately before a path-splitting dot must use `--variable-override-json` instead.
97
+
98
+ **`--variable-override-json` (repeatable):** deep-merges a JSON object. Use this for values containing commas or whole arrays/objects (the legacy Array acceptor comma-splits `KEY:VALUE` arguments).
99
+
100
+ ```bash
101
+ gem exec -g kubernetes_template_rendering render_templates \
102
+ --rendered-directory path/to/resources \
103
+ --variable-override-json '{"ephemeral":{"mysql":{"enabled":true}}}' \
104
+ deployment/templates
105
+ ```
106
+
107
+ **Precedence:** all override flags are folded into one hash in command-line order via deep merge (later flags win on conflict), then deep-merged last over the fully-resolved variables.
108
+
109
+ Overrides are echoed once to stdout at render start (`Variable overrides (deep-merged after definitions.yaml): {...}`). Rendered files carry no override comment (removed in [0.3.0](CHANGELOG.md#030---2026-06-24) to avoid content-free diffs on every deploy).
110
+
74
111
  ### Staging Partial Platforms
75
112
 
76
113
  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.
@@ -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,7 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+
3
5
  require_relative "template_directory_renderer"
4
6
  require_relative "cli_arguments"
7
+ require_relative "variable_override_parser"
5
8
 
6
9
  module KubernetesTemplateRendering
7
10
  class CLI
@@ -43,21 +46,30 @@ module KubernetesTemplateRendering
43
46
  args.only << name
44
47
  end
45
48
 
46
- op.on("--variable-override=KEY:VALUE", "override a variable value set within definitions.yaml", Array) do |overrides|
49
+ op.on("--variable-override=KEY:VALUE", "override a variable value set within definitions.yaml (KEY may be a dotted path, e.g. components.webServer.hpa.minReplicas:2; escape literal dots as \\.)", Array) do |overrides|
47
50
  args.variable_overrides ||= {} # Initialize as a Hash
48
51
  overrides.each do |override|
49
- key, value = override.split(":", 2)
50
- args.variable_overrides[key] = value if key && value
52
+ VariableOverrideParser.merge_override!(args.variable_overrides, override)
51
53
  end
52
54
  end
53
55
 
56
+ op.on("--variable-override-json=JSON", "deep-merge a JSON object of variable overrides (repeatable; later flags win)") do |json|
57
+ args.variable_overrides ||= {}
58
+ VariableOverrideParser.merge_json!(args.variable_overrides, json)
59
+ end
60
+
54
61
  op.on("-h", "--help") do
55
62
  puts op
56
63
  exit
57
64
  end
58
65
  end
59
66
 
60
- parser.parse!(options)
67
+ begin
68
+ parser.parse!(options)
69
+ rescue VariableOverrideParser::ParseError => ex
70
+ STDERR.puts(ex.message)
71
+ exit(1)
72
+ end
61
73
  args.template_directory = options.first
62
74
  args.spps = (args.spps || []).uniq
63
75
  args.only = (args.only || []).uniq
@@ -80,6 +92,10 @@ module KubernetesTemplateRendering
80
92
  exit(1)
81
93
  end
82
94
 
95
+ if args.variable_overrides&.any?
96
+ puts "Variable overrides (deep-merged after definitions.yaml): #{JSON.generate(args.variable_overrides)}"
97
+ end
98
+
83
99
  [renderer_from_args(args), args]
84
100
  end
85
101
 
@@ -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
- File.write(output_path(filename), contents)
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)
@@ -4,6 +4,7 @@ require "ostruct"
4
4
  require "open3"
5
5
  require "shellwords"
6
6
  require "yaml"
7
+ require "active_support/core_ext/hash/deep_merge"
7
8
 
8
9
  # This is a base class for all Templates. Derived classes must implement the render method.
9
10
  module KubernetesTemplateRendering
@@ -12,7 +13,7 @@ module KubernetesTemplateRendering
12
13
 
13
14
  def initialize(template_path, variables, source_repo: nil, variable_overrides: {})
14
15
  @template_path = template_path
15
- @variables = variables.merge(variable_overrides)
16
+ @variables = variables.deep_merge(variable_overrides)
16
17
  @source_repo = source_repo
17
18
  @variable_overrides = variable_overrides
18
19
  end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "active_support/core_ext/hash/deep_merge"
5
+
6
+ module KubernetesTemplateRendering
7
+ # Parses --variable-override / --variable-override-json values into (possibly nested)
8
+ # override fragments and deep-merges them, in command-line order, into one overrides hash.
9
+ #
10
+ # Key forms:
11
+ # * Legacy: KEY contains no "." -> top-level key, value kept as the raw string
12
+ # (exactly the pre-0.7.0 behavior; never type-coerced).
13
+ # * Dotted: KEY contains "." -> split on unescaped dots into a nested path ("\." is a
14
+ # literal dot within a segment). The value is JSON-coerced when it parses as JSON
15
+ # (2 -> Integer, true/false -> booleans, null -> nil, "2" -> String), else kept raw.
16
+ class VariableOverrideParser
17
+ class ParseError < StandardError; end
18
+
19
+ UNESCAPED_DOT = /(?<!\\)\./.freeze
20
+
21
+ class << self
22
+ # Deep-merges a KEY:VALUE override fragment into +overrides+.
23
+ #
24
+ # @param overrides [Hash] accumulated overrides hash (mutated in place)
25
+ # @param raw [String] raw KEY:VALUE argument
26
+ # @return [Hash] +overrides+ after merging
27
+ def merge_override!(overrides, raw)
28
+ key, value = raw.split(":", 2)
29
+ return overrides if key.nil? || value.nil? # preserve legacy silent-skip of colon-less args
30
+
31
+ overrides.deep_merge!(fragment_for(key, value))
32
+ end
33
+
34
+ # Deep-merges a JSON object string into +overrides+.
35
+ #
36
+ # @param overrides [Hash] accumulated overrides hash (mutated in place)
37
+ # @param json [String] JSON object string
38
+ # @return [Hash] +overrides+ after merging
39
+ # @raise [ParseError] when +json+ is invalid or not a JSON object
40
+ def merge_json!(overrides, json)
41
+ fragment =
42
+ begin
43
+ JSON.parse(json)
44
+ rescue JSON::ParserError => ex
45
+ raise ParseError, "--variable-override-json value is not valid JSON: #{ex.message}"
46
+ end
47
+ fragment.is_a?(Hash) or raise ParseError, "--variable-override-json value must be a JSON object, got #{fragment.class}: #{json.inspect}"
48
+ overrides.deep_merge!(fragment)
49
+ end
50
+
51
+ private
52
+
53
+ def fragment_for(key, value)
54
+ if key.include?(".")
55
+ nest(path_segments(key), coerce(value))
56
+ else
57
+ { key => value } # legacy: literal key, raw string value
58
+ end
59
+ end
60
+
61
+ def path_segments(key)
62
+ segments = key.split(UNESCAPED_DOT, -1)
63
+ segments.none?(&:empty?) or
64
+ raise ParseError, "--variable-override key #{key.inspect} has an empty path segment (leading, trailing, or doubled '.')"
65
+ segments.map { |segment| segment.gsub("\\.", ".") }
66
+ end
67
+
68
+ def nest(segments, value)
69
+ segments.reverse.reduce(value) { |acc, segment| { segment => acc } }
70
+ end
71
+
72
+ def coerce(value)
73
+ JSON.parse(value)
74
+ rescue JSON::ParserError
75
+ value
76
+ end
77
+ end
78
+ end
79
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module KubernetesTemplateRendering
4
- VERSION = "0.6.2"
4
+ VERSION = "0.8.0"
5
5
  end
data/mkdocs.yml CHANGED
@@ -4,6 +4,7 @@ site_description: "A light weight gem used to render Kubernetes manifest templat
4
4
  nav:
5
5
  - Introduction: README.md
6
6
  - Change Log: CHANGELOG.md
7
+ - Nested Output Decision: adrs/0003-nested-multi-file-output.md
7
8
 
8
9
  plugins:
9
10
  - techdocs-core
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.6.2
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Octothorpe
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-07-15 00:00:00.000000000 Z
11
+ date: 2026-07-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -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
@@ -101,6 +102,7 @@ files:
101
102
  - lib/kubernetes_template_rendering/resource_set.rb
102
103
  - lib/kubernetes_template_rendering/template.rb
103
104
  - lib/kubernetes_template_rendering/template_directory_renderer.rb
105
+ - lib/kubernetes_template_rendering/variable_override_parser.rb
104
106
  - lib/kubernetes_template_rendering/version.rb
105
107
  - mkdocs.yml
106
108
  - sig/kubernetes_template_rendering.rbs