kdep 0.3.5 → 0.4.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: 2f2546356a99516a639dc29b0d25a6494898c351a2dd40ca58a3510da93aa49d
4
- data.tar.gz: ea992fe366d4d6875b1edf6d976d2c8ece688854d76dfd12ab87031c85ffda6d
3
+ metadata.gz: '09613eb122fcc0b4963a04b0d2d982dbd3b4a6d7c3325aebebdfad3875eb6d69'
4
+ data.tar.gz: 1d844ed77f42b6b50fdbdeaf3aece71e78658223571f827d5c31a9713361c4f5
5
5
  SHA512:
6
- metadata.gz: 122d63cdabc4fda2ff9f7f52f546dc5288d26209ec9f7025ee93cbdd0109e4de094e37af1b5073266b6b5a5d939ca15fcd65004dc79c5eda286709bc4fb059d3
7
- data.tar.gz: 2aee7b30512d9102f86bf1150b6ee5054f741497bc4009784dbaf38f2d2f222ae6b8c3a2249090c3a9dad5a31fcdf013c8c753e7f07dd761051ccc189e0450f8
6
+ metadata.gz: 15743ca9ed3ced89956e3b83535629d127f0c7b51378b54653d3dc4c62b4c799f63e5c28fdf760ac1c327173656741a69a8efe10b8821c4a4536e9ba06c8b11e
7
+ data.tar.gz: edfc8dae8a811f72fc12563f5832c253b4637e6da02bfe134b1aed19ed0ce2e830697d8631b74e18cb63d6e811b9941e5598ff915e33ac58928cb2304cf19863
@@ -0,0 +1,52 @@
1
+ require "open3"
2
+
3
+ module Kdep
4
+ # Runs declarative preflight validators from app.yml's `check:` list.
5
+ # Each entry is `cmd: <shell-string>` plus optional `inputs: [paths]`. Paths
6
+ # are resolved via PathResolver. The full invocation is `sh -c "<cmd>
7
+ # <resolved_inputs...>"`. Any non-zero exit halts the pipeline.
8
+ class CheckRunner
9
+ class Error < StandardError; end
10
+
11
+ # Injectable runner so tests can assert argv without shelling out.
12
+ class << self
13
+ attr_accessor :runner
14
+ end
15
+ self.runner = ->(cmd) { Open3.capture3("sh", "-c", cmd) }
16
+
17
+ def initialize(checks:, path_resolver:, ui:)
18
+ @checks = checks || []
19
+ @resolver = path_resolver
20
+ @ui = ui
21
+ end
22
+
23
+ def run!
24
+ return true if @checks.empty?
25
+
26
+ @checks.each do |entry|
27
+ cmd = entry["cmd"].to_s
28
+ raise Error, "check entry missing 'cmd'" if cmd.empty?
29
+ inputs = Array(entry["inputs"]).map { |p| @resolver.resolve(p, warn_on_fallback: true) }
30
+ # Shell-quote each input so paths with spaces survive.
31
+ quoted_inputs = inputs.compact.map { |p| shell_quote(p) }
32
+ full_cmd = ([cmd] + quoted_inputs).join(" ")
33
+
34
+ @ui.info("check: #{full_cmd}")
35
+ stdout, stderr, status = self.class.runner.call(full_cmd)
36
+ unless status.success?
37
+ @ui.error("check failed: #{full_cmd}")
38
+ @ui.error(stderr.strip) unless stderr.strip.empty?
39
+ @ui.error(stdout.strip) unless stdout.strip.empty?
40
+ raise Error, "preflight check failed: #{full_cmd}"
41
+ end
42
+ end
43
+ true
44
+ end
45
+
46
+ private
47
+
48
+ def shell_quote(str)
49
+ "'#{str.to_s.gsub("'", %q('\\\\''))}'"
50
+ end
51
+ end
52
+ end
data/lib/kdep/cli.rb CHANGED
@@ -21,6 +21,7 @@ module Kdep
21
21
  "helm-install" => Commands::HelmInstall,
22
22
  "dashboard" => Commands::Dashboard,
23
23
  "doctor" => Commands::Doctor,
24
+ "check" => Commands::Check,
24
25
  }.freeze
25
26
 
26
27
  def initialize(argv)
@@ -14,6 +14,7 @@ module Kdep
14
14
  opts.on("--no-dashboard", "Skip TUI dashboard after deploy")
15
15
  opts.on("--platform=PLATFORM", "Target platform (e.g., linux/amd64)")
16
16
  opts.on("--init-state", "Seed state.yml at 0.0 when missing (non-interactive)")
17
+ opts.on("--no-check", "Skip preflight check: validators")
17
18
  end
18
19
  end
19
20
 
@@ -44,7 +45,43 @@ module Kdep
44
45
 
45
46
  config = Kdep::Config.new(deploy_dir, env).load
46
47
 
47
- # Step 2: Context guard (bump touches the cluster)
48
+ # Feature G: preflight validators. Runs for every preset, halts on
49
+ # first failure. --no-check skips.
50
+ unless @command_options[:"no-check"]
51
+ run_preflight_checks(config, deploy_dir, kdep_dir)
52
+ end
53
+
54
+ # Feature E: dispatch on preset before the docker-heavy app pipeline.
55
+ # Helm and custom presets don't build/push images — they route to
56
+ # preset-specific pipelines that keep the rest of bump's guarantees
57
+ # (context guard, rollout tracking, etc.).
58
+ preset = config["preset"]
59
+ if preset == "helm"
60
+ run_helm_pipeline(deploy_dir, env)
61
+ return
62
+ elsif preset == "custom"
63
+ run_custom_pipeline(deploy_dir, config, kdep_dir)
64
+ return
65
+ end
66
+
67
+ # Step 2: Local config sanity — read state.yml BEFORE touching the
68
+ # cluster. A missing/corrupt state.yml is a local config error and
69
+ # should fail fast without waking kubectl.
70
+ begin
71
+ current_tag = Kdep::State.tag(deploy_dir) || handle_missing_state(deploy_dir)
72
+ rescue Kdep::State::Error => e
73
+ @ui.error(e.message)
74
+ exit 1
75
+ end
76
+ # to_fix.md #3 — warn if someone has gitignored state.yml
77
+ require "kdep/doctor/check_state_gitignored"
78
+ gitignore_result = Kdep::Doctor::CheckStateGitignored.new(deploy_dir).run
79
+ if gitignore_result.severity == :error
80
+ @ui.warn(gitignore_result.message)
81
+ @ui.warn(gitignore_result.hint) if gitignore_result.hint
82
+ end
83
+
84
+ # Step 3: Context guard (bump touches the cluster)
48
85
  begin
49
86
  Kdep::ContextGuard.new(config["context"]).validate!
50
87
  rescue Kdep::Kubectl::Error => e
@@ -64,20 +101,7 @@ module Kdep
64
101
  repo_root = File.dirname(kdep_dir)
65
102
 
66
103
  begin
67
- # Step 3: Read current tag from state.yml, increment minor
68
- begin
69
- current_tag = Kdep::State.tag(deploy_dir) || handle_missing_state(deploy_dir)
70
- rescue Kdep::State::Error => e
71
- @ui.error(e.message)
72
- exit 1
73
- end
74
- # to_fix.md #3 — warn if someone has gitignored state.yml
75
- require "kdep/doctor/check_state_gitignored"
76
- gitignore_result = Kdep::Doctor::CheckStateGitignored.new(deploy_dir).run
77
- if gitignore_result.severity == :error
78
- @ui.warn(gitignore_result.message)
79
- @ui.warn(gitignore_result.hint) if gitignore_result.hint
80
- end
104
+ # Step 4: Increment minor version.
81
105
  parsed = Kdep::VersionTagger.parse(current_tag)
82
106
  next_version = parsed ? "#{parsed[0]}.#{parsed[1] + 1}" : "0.1"
83
107
  @ui.info("#{current_tag} -> #{next_version}")
@@ -192,6 +216,68 @@ module Kdep
192
216
 
193
217
  private
194
218
 
219
+ # Feature E: helm-preset bump = helm-install pipeline. Preflight
220
+ # already ran; context guard runs inside HelmInstall; rollout flush
221
+ # and configmap overlays are internal to it.
222
+ def run_helm_pipeline(deploy_dir, env)
223
+ deploy_arg = File.basename(deploy_dir)
224
+ helm_install = Kdep::Commands::HelmInstall.new(
225
+ global_options: @global_options,
226
+ command_options: { :"dry-run" => @command_options[:"dry-run"] },
227
+ args: env ? [deploy_arg, env] : [deploy_arg],
228
+ )
229
+ helm_install.execute
230
+ end
231
+
232
+ # Feature E: custom-preset bump = render + apply, no docker, no state.
233
+ def run_custom_pipeline(deploy_dir, config, kdep_dir)
234
+ # Context guard for safety.
235
+ begin
236
+ Kdep::ContextGuard.new(config["context"]).validate!
237
+ rescue Kdep::Kubectl::Error => e
238
+ @ui.error(e.message)
239
+ exit 1
240
+ end
241
+
242
+ namespace = config["namespace"]
243
+ repo_root = File.dirname(kdep_dir)
244
+ output_dir = File.join(deploy_dir, ".rendered")
245
+
246
+ writer = Kdep::Writer.new(output_dir)
247
+ writer.clean
248
+ renderer = Kdep::Renderer.new(config, deploy_dir)
249
+ preset_resources = Kdep::Preset.new("custom", deploy_dir).resources
250
+
251
+ preset_resources.each_with_index do |resource, idx|
252
+ content = renderer.render_resource(resource)
253
+ path = writer.write(resource, content, idx + 1)
254
+ @ui.file_written(path.sub(repo_root + "/", "")) if path
255
+ end
256
+
257
+ unless @command_options[:"dry-run"]
258
+ Dir.glob(File.join(output_dir, "*.yml")).sort.each do |file_path|
259
+ Kdep::Kubectl.apply(file_path, namespace: namespace)
260
+ @ui.info("applied: #{File.basename(file_path)}")
261
+ end
262
+ end
263
+ end
264
+
265
+ def run_preflight_checks(config, deploy_dir, kdep_dir)
266
+ checks = config["check"]
267
+ return if checks.nil? || checks.empty?
268
+
269
+ resolver = Kdep::PathResolver.new(
270
+ deploy_dir: deploy_dir,
271
+ kdep_dir: kdep_dir,
272
+ paths_from: config["paths_from"],
273
+ )
274
+ begin
275
+ Kdep::CheckRunner.new(checks: checks, path_resolver: resolver, ui: @ui).run!
276
+ rescue Kdep::CheckRunner::Error
277
+ exit 1
278
+ end
279
+ end
280
+
195
281
  def handle_missing_state(deploy_dir)
196
282
  if @command_options[:"init-state"]
197
283
  seed_initial_state(deploy_dir, reason: :flag)
@@ -0,0 +1,84 @@
1
+ require "optparse"
2
+
3
+ module Kdep
4
+ module Commands
5
+ # `kdep check <deploy>` — runs preflight validators declared in app.yml's
6
+ # check: list. Exits 0 on success, non-zero on first failing validator.
7
+ class Check
8
+ def self.option_parser
9
+ OptionParser.new do |opts|
10
+ opts.banner = "Usage: kdep check [deploy] [env]"
11
+ opts.separator ""
12
+ opts.separator "Runs preflight validators (promtool, amtool, etc.) declared in app.yml's"
13
+ opts.separator "check: list. Invoked automatically by kdep bump unless --no-check is set."
14
+ end
15
+ end
16
+
17
+ def initialize(global_options:, command_options:, args:)
18
+ @global_options = global_options
19
+ @command_options = command_options
20
+ @args = args
21
+ @ui = Kdep::UI.new(color: false)
22
+ end
23
+
24
+ def execute
25
+ deploy_name = @args[0]
26
+ env = @args[1]
27
+
28
+ discovery = Kdep::Discovery.new
29
+ kdep_dir = discovery.find_kdep_dir
30
+ unless kdep_dir
31
+ @ui.error("No kdep/ directory found")
32
+ exit 1
33
+ end
34
+
35
+ deploy_dir = resolve_deploy_dir(kdep_dir, deploy_name, discovery)
36
+ exit 1 unless deploy_dir
37
+
38
+ config = Kdep::Config.new(deploy_dir, env).load
39
+ checks = config["check"]
40
+ if checks.nil? || checks.empty?
41
+ @ui.info("no check: entries declared for this deploy")
42
+ return
43
+ end
44
+
45
+ resolver = Kdep::PathResolver.new(
46
+ deploy_dir: deploy_dir,
47
+ kdep_dir: kdep_dir,
48
+ paths_from: config["paths_from"],
49
+ )
50
+
51
+ begin
52
+ Kdep::CheckRunner.new(checks: checks, path_resolver: resolver, ui: @ui).run!
53
+ @ui.success("all #{checks.size} checks passed")
54
+ rescue Kdep::CheckRunner::Error
55
+ exit 1
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ def resolve_deploy_dir(kdep_dir, deploy_name, discovery)
62
+ if deploy_name
63
+ path = File.join(kdep_dir, deploy_name)
64
+ unless File.directory?(path)
65
+ @ui.error("Deploy target not found: #{deploy_name}")
66
+ return nil
67
+ end
68
+ path
69
+ else
70
+ deploys = discovery.find_deploys
71
+ if deploys.length == 1
72
+ File.join(kdep_dir, deploys[0])
73
+ elsif deploys.length > 1
74
+ @ui.error("Multiple deploys found, specify one: #{deploys.join(', ')}")
75
+ nil
76
+ else
77
+ @ui.error("No deploy targets found in kdep/")
78
+ nil
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -46,6 +46,18 @@ module Kdep
46
46
  # Load config
47
47
  config = Kdep::Config.new(@deploy_dir, env).load
48
48
  @namespace = config["namespace"]
49
+ @kdep_dir = kdep_dir
50
+ @resolver = Kdep::PathResolver.new(
51
+ deploy_dir: @deploy_dir,
52
+ kdep_dir: @kdep_dir,
53
+ paths_from: config["paths_from"],
54
+ )
55
+ @tracker = Kdep::RolloutTracker.new(
56
+ rollout_on: config["rollout_on"],
57
+ namespace: @namespace,
58
+ ui: @ui,
59
+ dry_run: @dry_run,
60
+ )
49
61
 
50
62
  unless config["preset"] == "helm"
51
63
  @ui.error("#{deploy_name} is not a helm preset (preset: #{config["preset"]})")
@@ -88,10 +100,43 @@ module Kdep
88
100
  install_chart(chart_conf)
89
101
  end
90
102
 
103
+ # Feature A: overlay rich config into helm-owned configmaps.
104
+ apply_configmap_overlays(config)
105
+
91
106
  # Render and apply additional k8s resources (ingress, secret, etc.)
92
107
  apply_extra_resources(config, kdep_dir)
108
+
109
+ # Feature F: flush rollout-restarts after all apply phases complete.
110
+ ok = @tracker.flush!
111
+ exit 1 if @tracker.errors? && !ok
112
+ end
113
+
114
+ private
115
+
116
+ def apply_configmap_overlays(config)
117
+ overlays = config["configmap_overlays"] || []
118
+ return if overlays.empty?
119
+
120
+ overlays.each do |overlay_hash|
121
+ overlay = Kdep::ConfigmapOverlay.new(
122
+ overlay_hash,
123
+ namespace: @namespace,
124
+ path_resolver: @resolver,
125
+ ui: @ui,
126
+ dry_run: @dry_run,
127
+ )
128
+ begin
129
+ result = overlay.apply!
130
+ @tracker.record_applied("configmap", overlay_hash["name"]) if result&.applied
131
+ rescue Kdep::ConfigmapOverlay::Error => e
132
+ @ui.error(e.message)
133
+ exit 1
134
+ end
135
+ end
93
136
  end
94
137
 
138
+ public
139
+
95
140
  private
96
141
 
97
142
  # Build a normalized list of chart configs.
@@ -108,16 +153,22 @@ module Kdep
108
153
  {
109
154
  release: c["release"] || config["name"],
110
155
  chart: c["chart"],
156
+ version: c["version"],
111
157
  values: c["values"],
112
158
  sets: c["sets"] || {},
113
159
  }
114
160
  end
115
161
  elsif config["chart"]
162
+ sets = config["sets"] || config["helm_sets"] || {}
163
+ if config.key?("helm_sets") && !config.key?("sets")
164
+ @ui.warn("helm_sets: is deprecated; use sets: (will be removed in kdep 0.5.x)")
165
+ end
116
166
  [{
117
167
  release: config["release"] || config["name"],
118
168
  chart: config["chart"],
169
+ version: config["version"],
119
170
  values: nil, # auto-detect values.yml in deploy dir
120
- sets: config["helm_sets"] || {},
171
+ sets: sets,
121
172
  }]
122
173
  else
123
174
  []
@@ -137,7 +188,9 @@ module Kdep
137
188
  sets[key] = interpolate(value.to_s, @secrets)
138
189
  end
139
190
 
140
- @ui.info("helm upgrade --install #{release} #{chart} -n #{@namespace}")
191
+ version = chart_conf[:version]
192
+ version_suffix = version ? " --version #{version}" : ""
193
+ @ui.info("helm upgrade --install #{release} #{chart} -n #{@namespace}#{version_suffix}")
141
194
  @ui.info(" values: #{values_file}") if values_file
142
195
  sets.each { |k, v| @ui.info(" --set #{k}=#{mask(v)}") }
143
196
 
@@ -146,6 +199,7 @@ module Kdep
146
199
  release: release,
147
200
  chart: chart,
148
201
  namespace: @namespace,
202
+ version: version,
149
203
  values_file: values_file,
150
204
  sets: sets,
151
205
  dry_run: @dry_run
@@ -160,13 +214,14 @@ module Kdep
160
214
 
161
215
  def resolve_values_file(explicit_name)
162
216
  if explicit_name
163
- path = File.join(@deploy_dir, explicit_name)
164
- return path if File.exist?(path)
217
+ path = @resolver.resolve(explicit_name, warn_on_fallback: true)
218
+ return path if path && File.exist?(path)
165
219
  @ui.warn("Values file not found: #{explicit_name}")
166
220
  return nil
167
221
  end
168
222
 
169
- # Auto-detect values.yml or values.yaml
223
+ # Auto-detect values.yml or values.yaml in the deploy dir (not affected
224
+ # by paths_from: repo_root, since auto-detect is a deploy-dir contract).
170
225
  %w[values.yml values.yaml].each do |name|
171
226
  path = File.join(@deploy_dir, name)
172
227
  return path if File.exist?(path)
@@ -190,8 +245,35 @@ module Kdep
190
245
  files_written = 0
191
246
  render_errors = []
192
247
 
193
- resources.each_with_index do |resource_name, idx|
194
- index = idx + 1
248
+ next_index = 1
249
+ resources.each do |resource_name|
250
+ # Feature B: multi-ingress replaces the single built-in ingress when
251
+ # config["ingresses"] is set.
252
+ if resource_name == "ingress" && config["ingresses"]
253
+ begin
254
+ entries = renderer.render_helm_ingresses
255
+ rescue => e
256
+ render_errors << "ingress: #{e.message}"
257
+ next
258
+ end
259
+ entries.each do |suffix, content|
260
+ unless content.nil? || content.strip.empty?
261
+ result = validator.validate(content, "ingress")
262
+ unless result["valid"]
263
+ result["errors"].each { |err| render_errors << "ingress[#{suffix}]: #{err}" }
264
+ end
265
+ end
266
+ path = writer.write_suffixed("ingress", suffix, content, next_index)
267
+ if path
268
+ @ui.file_written(path.sub(repo_root + "/", ""))
269
+ files_written += 1
270
+ next_index += 1
271
+ end
272
+ end
273
+ next
274
+ end
275
+
276
+ index = next_index
195
277
  begin
196
278
  content = renderer.render_resource(resource_name)
197
279
  rescue => e
@@ -212,6 +294,7 @@ module Kdep
212
294
  if path
213
295
  @ui.file_written(path.sub(repo_root + "/", ""))
214
296
  files_written += 1
297
+ next_index += 1
215
298
  end
216
299
  end
217
300
 
data/lib/kdep/config.rb CHANGED
@@ -64,7 +64,31 @@ module Kdep
64
64
  result["image"] = result["name"]
65
65
  end
66
66
 
67
+ desugar_configmap_overlay_rollout!(result)
68
+
67
69
  result
68
70
  end
71
+
72
+ # Feature F sugar: `configmap_overlays[].rollout` becomes an entry in
73
+ # `rollout_on` with source: configmap/<overlay-name>. If an explicit
74
+ # rollout_on entry already exists for that source, merge targets (union,
75
+ # deduped). Canonical internal form is always `rollout_on`.
76
+ def desugar_configmap_overlay_rollout!(config)
77
+ overlays = config["configmap_overlays"]
78
+ return unless overlays.is_a?(Array)
79
+
80
+ overlays.each do |overlay|
81
+ next unless overlay.is_a?(Hash) && overlay["rollout"]
82
+ src = "configmap/#{overlay["name"]}"
83
+ target = overlay["rollout"]
84
+ config["rollout_on"] ||= []
85
+ existing = config["rollout_on"].find { |r| r.is_a?(Hash) && r["source"] == src }
86
+ if existing
87
+ existing["targets"] = (Array(existing["targets"]) + [target]).uniq
88
+ else
89
+ config["rollout_on"] << { "source" => src, "targets" => [target] }
90
+ end
91
+ end
92
+ end
69
93
  end
70
94
  end
@@ -0,0 +1,110 @@
1
+ require "yaml"
2
+ require "json"
3
+
4
+ module Kdep
5
+ # Overlays rich configmap data from external files onto a helm-owned
6
+ # configmap. Runs after `helm upgrade --install` so the chart creates the
7
+ # configmap first; this module rewrites its data keys without touching
8
+ # helm-set labels or annotations.
9
+ #
10
+ # Two merge strategies:
11
+ # replace — fetch live configmap, replace `data:` with only the listed
12
+ # keys, `kubectl apply` the full manifest back.
13
+ # patch — emit a JSON patch that adds/updates only the listed keys,
14
+ # leaving any other data helm wrote intact.
15
+ #
16
+ # First-deploy safety: if the configmap does not yet exist (chart hasn't
17
+ # run yet), the overlay is skipped with a warning; a re-run picks it up.
18
+ class ConfigmapOverlay
19
+ class Error < StandardError; end
20
+
21
+ Result = Struct.new(:configmap_name, :applied, :skipped_reason,
22
+ keyword_init: true)
23
+
24
+ def initialize(overlay, namespace:, path_resolver:, ui:, dry_run: false)
25
+ @overlay = overlay
26
+ @namespace = namespace
27
+ @resolver = path_resolver
28
+ @ui = ui
29
+ @dry_run = dry_run
30
+ end
31
+
32
+ def name
33
+ @overlay["name"]
34
+ end
35
+
36
+ def merge_strategy
37
+ (@overlay["merge"] || "replace").to_s
38
+ end
39
+
40
+ def apply!
41
+ case merge_strategy
42
+ when "replace" then apply_replace!
43
+ when "patch" then apply_patch!
44
+ else
45
+ raise Error, "configmap_overlay #{name}: unknown merge strategy '#{merge_strategy}' (expected replace|patch)"
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def data_payload
52
+ payload = {}
53
+ @overlay["data_files"].each do |key, path|
54
+ resolved = @resolver.resolve(path, warn_on_fallback: true)
55
+ raise Error, "configmap_overlay #{name}: source file not found: #{path}" unless resolved && File.exist?(resolved)
56
+ content = File.read(resolved)
57
+ raise Error, "configmap_overlay #{name}: source file is empty: #{path}" if content.empty?
58
+ payload[key] = content
59
+ end
60
+ payload
61
+ end
62
+
63
+ def apply_replace!
64
+ live_yaml = Kdep::Kubectl.get("configmap", name, namespace: @namespace)
65
+ if live_yaml.nil? || live_yaml.strip.empty?
66
+ @ui.warn("configmap_overlay #{name}: not yet created (first deploy); skipping")
67
+ return Result.new(configmap_name: name, applied: false, skipped_reason: "not_yet_created")
68
+ end
69
+
70
+ live = Kdep::YAMLCompat.safe_load(live_yaml)
71
+ raise Error, "configmap_overlay #{name}: unexpected live configmap shape" unless live.is_a?(Hash)
72
+ live["data"] = data_payload
73
+ # Strip server-side-managed fields that confuse kubectl apply.
74
+ if live["metadata"].is_a?(Hash)
75
+ %w[resourceVersion uid creationTimestamp managedFields selfLink generation].each { |k| live["metadata"].delete(k) }
76
+ end
77
+
78
+ require "tempfile"
79
+ tmp = Tempfile.new(["#{name}-overlay", ".yml"])
80
+ tmp.write(YAML.dump(live))
81
+ tmp.close
82
+
83
+ if @dry_run
84
+ @ui.info("[dry-run] would apply configmap_overlay #{name}")
85
+ else
86
+ Kdep::Kubectl.apply(tmp.path, namespace: @namespace)
87
+ @ui.info("configmap_overlay #{name}: applied (replace)")
88
+ end
89
+ Result.new(configmap_name: name, applied: true, skipped_reason: nil)
90
+ ensure
91
+ tmp&.unlink
92
+ end
93
+
94
+ def apply_patch!
95
+ ops = data_payload.map { |k, v| { "op" => "add", "path" => "/data/#{escape_patch_key(k)}", "value" => v } }
96
+ if @dry_run
97
+ @ui.info("[dry-run] would patch configmap_overlay #{name} (#{ops.size} keys)")
98
+ else
99
+ Kdep::Kubectl.patch("configmap", name, namespace: @namespace, type: "json", patch: ops.to_json)
100
+ @ui.info("configmap_overlay #{name}: patched (#{ops.size} keys)")
101
+ end
102
+ Result.new(configmap_name: name, applied: true, skipped_reason: nil)
103
+ end
104
+
105
+ # RFC 6901 JSON-pointer escaping for `/` and `~` in key names.
106
+ def escape_patch_key(key)
107
+ key.to_s.gsub("~", "~0").gsub("/", "~1")
108
+ end
109
+ end
110
+ end
@@ -8,12 +8,14 @@ module Kdep
8
8
 
9
9
  def run
10
10
  state_path = File.join(deploy_dir, Kdep::State::FILENAME)
11
- _, status = Open3.capture2("git", "check-ignore", "-q", state_path)
11
+ # capture3 swallows stderr so "fatal: not a git repository" noise
12
+ # never reaches the user when the caller runs outside a repo.
13
+ _, _, status = Open3.capture3("git", "check-ignore", "-q", state_path)
12
14
  if status.success?
13
15
  Result.new(
14
16
  id: ID, severity: :error,
15
17
  message: "#{state_path} is gitignored",
16
- hint: "Remove 'state.yml' from .gitignore and 'git add' the file so fresh clones see the last-deployed tag."
18
+ hint: "Remove 'state.yml' from any .gitignore (local or ancestor) and 'git add' the file so fresh clones see the last-deployed tag."
17
19
  )
18
20
  else
19
21
  Result.ok(ID)
data/lib/kdep/doctor.rb CHANGED
@@ -5,7 +5,6 @@ require "kdep/doctor/check_state_parseable"
5
5
  require "kdep/doctor/check_state_tag_format"
6
6
  require "kdep/doctor/check_state_gitignored"
7
7
  require "kdep/doctor/check_gitignore_canonical"
8
- require "kdep/doctor/check_ancestor_ignores_state"
9
8
 
10
9
  module Kdep
11
10
  module Doctor
@@ -15,7 +14,6 @@ module Kdep
15
14
  CheckStateTagFormat,
16
15
  CheckStateGitignored,
17
16
  CheckGitignoreCanonical,
18
- CheckAncestorIgnoresState,
19
17
  ].freeze
20
18
  end
21
19
  end
data/lib/kdep/helm.rb CHANGED
@@ -4,17 +4,24 @@ module Kdep
4
4
  module Helm
5
5
  class Error < StandardError; end
6
6
 
7
+ class << self
8
+ attr_accessor :runner
9
+ end
10
+ # Default: shell out to the real helm binary.
11
+ self.runner = ->(*args) { Open3.capture3("helm", *args) }
12
+
7
13
  def self.run(*args)
8
- stdout, stderr, status = Open3.capture3("helm", *args)
14
+ stdout, stderr, status = runner.call(*args)
9
15
  unless status.success?
10
16
  raise Error, "helm #{args.first} failed: #{stderr.strip}"
11
17
  end
12
18
  stdout
13
19
  end
14
20
 
15
- # helm upgrade --install <release> <chart> -n <namespace> [-f values.yml] [--set k=v ...]
16
- def self.upgrade_install(release:, chart:, namespace:, values_file: nil, sets: {}, dry_run: false)
21
+ # helm upgrade --install <release> <chart> -n <namespace> [--version <v>] [-f values.yml] [--set k=v ...]
22
+ def self.upgrade_install(release:, chart:, namespace:, version: nil, values_file: nil, sets: {}, dry_run: false)
17
23
  args = ["upgrade", "--install", release, chart, "-n", namespace]
24
+ args += ["--version", version] if version
18
25
  args += ["-f", values_file] if values_file
19
26
  sets.each do |key, value|
20
27
  args += ["--set", "#{key}=#{value}"]
data/lib/kdep/kubectl.rb CHANGED
@@ -5,8 +5,14 @@ module Kdep
5
5
  module Kubectl
6
6
  class Error < StandardError; end
7
7
 
8
+ class << self
9
+ attr_accessor :runner
10
+ end
11
+ # Default: shell out to the real kubectl binary.
12
+ self.runner = ->(*args) { Open3.capture3("kubectl", *args) }
13
+
8
14
  def self.run(*args)
9
- stdout, stderr, status = Open3.capture3("kubectl", *args)
15
+ stdout, stderr, status = runner.call(*args)
10
16
  unless status.success?
11
17
  raise Error, "kubectl #{args.first} failed: #{stderr.strip}"
12
18
  end
@@ -14,8 +20,7 @@ module Kdep
14
20
  end
15
21
 
16
22
  def self.run_json(*args)
17
- output = run(*args, "-o", "json")
18
- JSON.parse(output)
23
+ JSON.parse(run(*args, "-o", "json"))
19
24
  end
20
25
 
21
26
  def self.current_context
@@ -27,15 +32,30 @@ module Kdep
27
32
  end
28
33
 
29
34
  def self.diff(file_path)
30
- stdout, stderr, status = Open3.capture3("kubectl", "diff", "-f", file_path)
35
+ stdout, stderr, status = runner.call("diff", "-f", file_path)
31
36
  case status.exitstatus
32
- when 0
33
- nil
34
- when 1
35
- stdout
36
- else
37
- raise Error, "kubectl diff failed (exit #{status.exitstatus}): #{stderr.strip}"
37
+ when 0 then nil # no diff
38
+ when 1 then stdout # diffs present on stdout
39
+ else raise Error, "kubectl diff failed (exit #{status.exitstatus}): #{stderr.strip}"
38
40
  end
39
41
  end
42
+
43
+ # Declarative rollout-restart helper used by Kdep::RolloutTracker (Feature F).
44
+ def self.rollout_restart(kind, name, namespace:)
45
+ run("rollout", "restart", "#{kind}/#{name}", "-n", namespace)
46
+ end
47
+
48
+ # JSON-patch helper used by Kdep::ConfigmapOverlay (Feature A) with merge: patch.
49
+ def self.patch(kind, name, namespace:, type:, patch:)
50
+ run("patch", kind, name, "-n", namespace, "--type", type, "-p", patch)
51
+ end
52
+
53
+ # Live-fetch helper. Returns nil when the resource is absent (NotFound).
54
+ # Caller distinguishes "not yet created" from real errors.
55
+ def self.get(kind, name, namespace:, output: "yaml")
56
+ run("get", kind, name, "-n", namespace, "-o", output)
57
+ rescue Error
58
+ nil
59
+ end
40
60
  end
41
61
  end
@@ -261,14 +261,20 @@ module Kdep
261
261
  # Extract the image tag from the live deploy (e.g., "0.44" from
262
262
  # "ghcr.io/org/app:0.44"). Returns nil if the tag is "latest",
263
263
  # empty, or absent. On multi-container deployments, returns the
264
- # first usable tag found.
264
+ # first usable tag found. Uses rpartition so registry URLs with
265
+ # explicit ports (host:5000/org/app:0.44) aren't misparsed.
265
266
  def extracted_tag
266
267
  deployed_images.each do |img|
267
- parts = img.to_s.split(":", 2)
268
- next if parts.length < 2
269
- tag = parts[1]
270
- next if tag.nil? || tag.empty? || tag == "latest"
271
- return tag
268
+ before, sep, after = img.to_s.rpartition(":")
269
+ # rpartition returns ["", "", img] when no ":" exists
270
+ next if sep.empty?
271
+ # A host:port with no tag (e.g. "host:5000/img") has sep but the
272
+ # "tag" part contains a "/" — not a real tag.
273
+ next if after.empty? || after.include?("/")
274
+ next if after == "latest"
275
+ # before will include any host:port prefix intact
276
+ _ = before
277
+ return after
272
278
  end
273
279
  nil
274
280
  end
@@ -0,0 +1,61 @@
1
+ require "pathname"
2
+
3
+ module Kdep
4
+ # Resolves user-supplied relative file paths from app.yml against either
5
+ # the deploy dir (default) or the repo root that contains kdep/.
6
+ # Mode is selected by the top-level `paths_from:` field.
7
+ class PathResolver
8
+ def initialize(deploy_dir:, kdep_dir:, paths_from:)
9
+ @deploy_dir = deploy_dir
10
+ @repo_root = File.dirname(kdep_dir)
11
+ @mode = paths_from.to_s == "repo_root" ? :repo_root : :target
12
+ end
13
+
14
+ # Returns the absolute filesystem path for a relative input. If the
15
+ # resolved path does not exist at the configured origin but DOES exist at
16
+ # the other origin, resolves at the other and optionally warns — this
17
+ # catches the common "forgot paths_from: repo_root" mistake.
18
+ def resolve(path, warn_on_fallback: false)
19
+ return nil if path.nil?
20
+ return path if Pathname.new(path).absolute?
21
+
22
+ primary = File.expand_path(path, primary_base)
23
+ return primary if File.exist?(primary)
24
+
25
+ fallback = File.expand_path(path, fallback_base)
26
+ if File.exist?(fallback)
27
+ if warn_on_fallback
28
+ warn "path '#{path}' not found under #{@mode}; falling back to #{other_mode}. " \
29
+ "Set paths_from: #{other_mode} or fix the path."
30
+ end
31
+ return fallback
32
+ end
33
+
34
+ primary
35
+ end
36
+
37
+ # Checks either origin. Used by validators that don't care WHICH origin
38
+ # holds the file, only that it exists somewhere sensible.
39
+ def exists?(path)
40
+ return false if path.nil?
41
+ return File.exist?(path) if Pathname.new(path).absolute?
42
+
43
+ File.exist?(File.expand_path(path, primary_base)) ||
44
+ File.exist?(File.expand_path(path, fallback_base))
45
+ end
46
+
47
+ private
48
+
49
+ def primary_base
50
+ @mode == :repo_root ? @repo_root : @deploy_dir
51
+ end
52
+
53
+ def fallback_base
54
+ @mode == :repo_root ? @deploy_dir : @repo_root
55
+ end
56
+
57
+ def other_mode
58
+ @mode == :repo_root ? :target : :repo_root
59
+ end
60
+ end
61
+ end
data/lib/kdep/renderer.rb CHANGED
@@ -28,6 +28,28 @@ module Kdep
28
28
  erb.result(context.get_binding)
29
29
  end
30
30
 
31
+ # Feature B: render N Ingress resources from config["ingresses"].
32
+ # Returns [[suffix, yaml], ...] so the caller can emit multiple files.
33
+ def render_helm_ingresses
34
+ entries = @config["ingresses"] || []
35
+ return [] if entries.empty?
36
+
37
+ template_path = File.join(Kdep.templates_dir, "resources", "helm_ingress.yml.erb")
38
+ raise "helm_ingress template not found at #{template_path}" unless File.exist?(template_path)
39
+ template_content = File.read(template_path)
40
+ erb = if RUBY_VERSION >= "2.6"
41
+ ERB.new(template_content, trim_mode: "-")
42
+ else
43
+ ERB.new(template_content, nil, "-")
44
+ end
45
+
46
+ entries.map do |ingress|
47
+ context = TemplateContext.new(@config)
48
+ context.instance_variable_set(:@ingress, ingress)
49
+ [ingress["name"], erb.result(context.get_binding)]
50
+ end
51
+ end
52
+
31
53
  private
32
54
 
33
55
  def resolve_template(resource_name)
@@ -0,0 +1,62 @@
1
+ require "set"
2
+
3
+ module Kdep
4
+ # Declarative rollout-restart triggers. Phases that apply resources register
5
+ # sources; at end of run the tracker looks up matching rollout_on rules and
6
+ # restarts the unique set of targets once.
7
+ #
8
+ # sources are "kind/name" (configmap, secret).
9
+ # targets are "kind/name" (deployment, statefulset, daemonset).
10
+ class RolloutTracker
11
+ class Error < StandardError; end
12
+
13
+ def initialize(rollout_on:, namespace:, ui:, dry_run: false)
14
+ @rules = rollout_on || []
15
+ @namespace = namespace
16
+ @ui = ui
17
+ @dry_run = dry_run
18
+ @applied = Set.new
19
+ @errors = []
20
+ end
21
+
22
+ def record_applied(kind, name)
23
+ @applied << "#{kind.to_s.downcase}/#{name}"
24
+ end
25
+
26
+ def flush!
27
+ targets = Set.new
28
+ @rules.each do |rule|
29
+ src = normalize(rule["source"])
30
+ next unless @applied.include?(src)
31
+ Array(rule["targets"]).each { |t| targets << normalize(t) }
32
+ end
33
+
34
+ targets.sort.each do |target|
35
+ kind, name = target.split("/", 2)
36
+ if @dry_run
37
+ @ui.info("[dry-run] would rollout-restart #{kind}/#{name}")
38
+ next
39
+ end
40
+ begin
41
+ Kdep::Kubectl.rollout_restart(kind, name, namespace: @namespace)
42
+ @ui.info("rollout-restarted #{kind}/#{name}")
43
+ rescue Kdep::Kubectl::Error => e
44
+ @errors << "#{kind}/#{name}: #{e.message}"
45
+ @ui.error("rollout-restart failed for #{kind}/#{name}: #{e.message}")
46
+ end
47
+ end
48
+
49
+ @errors.empty?
50
+ end
51
+
52
+ def errors?
53
+ !@errors.empty?
54
+ end
55
+
56
+ private
57
+
58
+ def normalize(s)
59
+ s.to_s.downcase
60
+ end
61
+ end
62
+ end
data/lib/kdep/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Kdep
2
- VERSION = "0.3.5"
2
+ VERSION = "0.4.0"
3
3
  end
data/lib/kdep/writer.rb CHANGED
@@ -22,5 +22,17 @@ module Kdep
22
22
  File.write(path, content)
23
23
  path
24
24
  end
25
+
26
+ # Used by Feature B (multi-ingress): one logical resource "ingress"
27
+ # emits N files named NN-ingress-<suffix>.yml so kubectl apply diffs
28
+ # them independently.
29
+ def write_suffixed(base_name, suffix, content, index)
30
+ return nil if content.nil? || content.strip.empty?
31
+ FileUtils.mkdir_p(@output_dir)
32
+ filename = format("%02d-%s-%s.yml", index, base_name, suffix)
33
+ path = File.join(@output_dir, filename)
34
+ File.write(path, content)
35
+ path
36
+ end
25
37
  end
26
38
  end
data/lib/kdep.rb CHANGED
@@ -18,6 +18,10 @@ require "kdep/helm"
18
18
  require "kdep/registry"
19
19
  require "kdep/version_tagger"
20
20
  require "kdep/state"
21
+ require "kdep/path_resolver"
22
+ require "kdep/configmap_overlay"
23
+ require "kdep/rollout_tracker"
24
+ require "kdep/check_runner"
21
25
  require "kdep/doctor"
22
26
  require "kdep/commands/render"
23
27
  require "kdep/commands/init"
@@ -37,6 +41,7 @@ require "kdep/commands/migrate"
37
41
  require "kdep/commands/helm_install"
38
42
  require "kdep/commands/dashboard"
39
43
  require "kdep/commands/doctor"
44
+ require "kdep/commands/check"
40
45
  require "kdep/dashboard/screen"
41
46
  require "kdep/dashboard/layout"
42
47
  require "kdep/dashboard/panel"
@@ -0,0 +1,43 @@
1
+ <%
2
+ raise "helm_ingress template requires @ingress (single entry)" unless @ingress
3
+ ingress = @ingress
4
+ ingress_name = "ingress-#{ingress["name"]}"
5
+ default_annotations = {
6
+ "kubernetes.io/ingress.class" => "nginx",
7
+ "cert-manager.io/cluster-issuer" => "letsencrypt",
8
+ }
9
+ if ingress["auth_secret"] && !ingress["auth_secret"].to_s.empty?
10
+ default_annotations["nginx.ingress.kubernetes.io/auth-type"] = "basic"
11
+ default_annotations["nginx.ingress.kubernetes.io/auth-secret"] = ingress["auth_secret"]
12
+ default_annotations["nginx.ingress.kubernetes.io/auth-realm"] = "Auth required"
13
+ end
14
+ user_annotations = ingress["annotations"].is_a?(Hash) ? ingress["annotations"] : {}
15
+ annotations = default_annotations.merge(user_annotations)
16
+ path = ingress["path"] || "/"
17
+ path_type = ingress["path_type"] || "Prefix"
18
+ -%>
19
+ apiVersion: networking.k8s.io/v1
20
+ kind: Ingress
21
+ metadata:
22
+ name: <%= ingress_name %>
23
+ namespace: <%= namespace %>
24
+ annotations:
25
+ <% annotations.each do |k, v| -%>
26
+ <%= k %>: "<%= v %>"
27
+ <% end -%>
28
+ spec:
29
+ tls:
30
+ - hosts:
31
+ - <%= ingress["host"] %>
32
+ secretName: <%= ingress["tls_secret"] %>
33
+ rules:
34
+ - host: <%= ingress["host"] %>
35
+ http:
36
+ paths:
37
+ - path: <%= path %>
38
+ pathType: <%= path_type %>
39
+ backend:
40
+ service:
41
+ name: <%= ingress["service"] %>
42
+ port:
43
+ number: <%= ingress["port"] %>
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kdep
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.5
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Leadfy
@@ -63,11 +63,13 @@ files:
63
63
  - LICENSE.txt
64
64
  - exe/kdep
65
65
  - lib/kdep.rb
66
+ - lib/kdep/check_runner.rb
66
67
  - lib/kdep/cli.rb
67
68
  - lib/kdep/cluster_health.rb
68
69
  - lib/kdep/commands/apply.rb
69
70
  - lib/kdep/commands/build.rb
70
71
  - lib/kdep/commands/bump.rb
72
+ - lib/kdep/commands/check.rb
71
73
  - lib/kdep/commands/dashboard.rb
72
74
  - lib/kdep/commands/diff.rb
73
75
  - lib/kdep/commands/doctor.rb
@@ -84,6 +86,7 @@ files:
84
86
  - lib/kdep/commands/sh.rb
85
87
  - lib/kdep/commands/status.rb
86
88
  - lib/kdep/config.rb
89
+ - lib/kdep/configmap_overlay.rb
87
90
  - lib/kdep/context_guard.rb
88
91
  - lib/kdep/dashboard.rb
89
92
  - lib/kdep/dashboard/health_panel.rb
@@ -98,7 +101,6 @@ files:
98
101
  - lib/kdep/docker.rb
99
102
  - lib/kdep/doctor.rb
100
103
  - lib/kdep/doctor/check.rb
101
- - lib/kdep/doctor/check_ancestor_ignores_state.rb
102
104
  - lib/kdep/doctor/check_gitignore_canonical.rb
103
105
  - lib/kdep/doctor/check_state_gitignored.rb
104
106
  - lib/kdep/doctor/check_state_parseable.rb
@@ -108,9 +110,11 @@ files:
108
110
  - lib/kdep/helm.rb
109
111
  - lib/kdep/kubectl.rb
110
112
  - lib/kdep/old_format.rb
113
+ - lib/kdep/path_resolver.rb
111
114
  - lib/kdep/preset.rb
112
115
  - lib/kdep/registry.rb
113
116
  - lib/kdep/renderer.rb
117
+ - lib/kdep/rollout_tracker.rb
114
118
  - lib/kdep/state.rb
115
119
  - lib/kdep/template_context.rb
116
120
  - lib/kdep/ui.rb
@@ -134,6 +138,7 @@ files:
134
138
  - templates/resources/configmap.yml.erb
135
139
  - templates/resources/cronjob.yml.erb
136
140
  - templates/resources/deployment.yml.erb
141
+ - templates/resources/helm_ingress.yml.erb
137
142
  - templates/resources/ingress.yml.erb
138
143
  - templates/resources/job.yml.erb
139
144
  - templates/resources/secret.yml.erb
@@ -1,26 +0,0 @@
1
- require "open3"
2
- require "kdep/state"
3
-
4
- module Kdep
5
- module Doctor
6
- class CheckAncestorIgnoresState < Check
7
- ID = "ancestor_ignores_state".freeze
8
-
9
- def run
10
- state_path = File.join(deploy_dir, Kdep::State::FILENAME)
11
- _, status = Open3.capture2("git", "check-ignore", "-v", state_path)
12
- if status.success?
13
- Result.new(
14
- id: ID, severity: :error,
15
- message: "An ancestor .gitignore is ignoring #{state_path}",
16
- hint: "Run 'git check-ignore -v #{state_path}' to find which file, then remove the pattern."
17
- )
18
- else
19
- Result.ok(ID)
20
- end
21
- rescue Errno::ENOENT
22
- Result.ok(ID)
23
- end
24
- end
25
- end
26
- end