kdep 0.4.7 → 0.4.8

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: 661f22197841e0bcfd4ff7089399ab62efbd428b5aec5b8bae914f5fd80d63a4
4
- data.tar.gz: 2671d06e5e5e71b3ba1842d5d58e172674c819a20ecce554f47d80a0d58798c1
3
+ metadata.gz: 1ed0d8de8dfb58cc372615340a068aecb3f8092aecafdbc6d1c6480c14477d57
4
+ data.tar.gz: 8dde50b29b917321144fcca1d0cb83cff8b66a702442c5bf19375ae527f4cf6e
5
5
  SHA512:
6
- metadata.gz: 02bb2c68ba2fc5a08746214c2b163d7fa8d51fb220853e2facd17b3af86c62cd36c1b9e5f95e77e5ee18cc345b4ad97a8874af8764f87f49e4f22aa3c8b78651
7
- data.tar.gz: f9e32dff02c9061d0f00aea72980d1e8fe1687a29f68e48faa72e11eee4f13758e32e0ee6fcede71357bf44778d2ad045537d06eec6ab4c7512a84d5074da8c2
6
+ metadata.gz: 3669fafcc6315fcdb7af39bb0b31cb7314a953b3185988523af63e1d9ab1fb60a458ba141dc22e3735e00de5df8a821b862b657aaae68d36e8326e0a0f304f2b
7
+ data.tar.gz: 7cbb7445b6909b46e74f47245c88df5fa8d7d93617a949b14059aef3f50c2981941ed129d70f013dede5e2789ad363e55d8768698129059c412c03d631851b81
@@ -48,6 +48,7 @@ module Kdep
48
48
  # Feature G: preflight validators. Runs for every preset, halts on
49
49
  # first failure. --no-check skips.
50
50
  unless @command_options[:"no-check"]
51
+ check_duplicate_yaml_keys(deploy_dir)
51
52
  run_preflight_checks(config, deploy_dir, kdep_dir)
52
53
  end
53
54
 
@@ -270,6 +271,20 @@ module Kdep
270
271
  end
271
272
  end
272
273
 
274
+ # Built-in preflight, unlike the user-declared check: list. The error
275
+ # this catches is made by whoever doesn't know the trap exists, so an
276
+ # opt-in guard would only protect the people who already know. Runs
277
+ # before docker and before the cluster; --no-check is the escape hatch.
278
+ def check_duplicate_yaml_keys(deploy_dir)
279
+ require "kdep/yaml_duplicate_keys"
280
+ dups = Kdep::YamlDuplicateKeys.in_dir(deploy_dir)
281
+ return if dups.empty?
282
+
283
+ dups.each { |dup| @ui.error(dup.to_s) }
284
+ @ui.error("Merge the repeated blocks into one, or re-run with --no-check to bypass.")
285
+ exit 1
286
+ end
287
+
273
288
  def run_preflight_checks(config, deploy_dir, kdep_dir)
274
289
  checks = config["check"]
275
290
  return if checks.nil? || checks.empty?
@@ -35,6 +35,10 @@ module Kdep
35
35
  deploy_dir = resolve_deploy_dir(kdep_dir, deploy_name, discovery)
36
36
  exit 1 unless deploy_dir
37
37
 
38
+ # Built-in guard, ahead of the user-declared list: a duplicate key is
39
+ # resolved silently by the parser, so nothing downstream can report it.
40
+ check_duplicate_yaml_keys(deploy_dir)
41
+
38
42
  config = Kdep::Config.new(deploy_dir, env).load
39
43
  checks = config["check"]
40
44
  if checks.nil? || checks.empty?
@@ -58,6 +62,16 @@ module Kdep
58
62
 
59
63
  private
60
64
 
65
+ def check_duplicate_yaml_keys(deploy_dir)
66
+ require "kdep/yaml_duplicate_keys"
67
+ dups = Kdep::YamlDuplicateKeys.in_dir(deploy_dir)
68
+ return if dups.empty?
69
+
70
+ dups.each { |dup| @ui.error(dup.to_s) }
71
+ @ui.error("Merge the repeated blocks into one.")
72
+ exit 1
73
+ end
74
+
61
75
  def resolve_deploy_dir(kdep_dir, deploy_name, discovery)
62
76
  if deploy_name
63
77
  path = File.join(kdep_dir, deploy_name)
@@ -3,16 +3,16 @@ require "optparse"
3
3
  module Kdep
4
4
  module Commands
5
5
  class Restart
6
- RESTARTABLE_PRESETS = %w[web worker].freeze
7
- NON_RESTARTABLE_PRESETS = %w[job cronjob].freeze
6
+ WORKLOAD_KINDS = %w[deployment statefulset daemonset].freeze
7
+ NON_RESTARTABLE_KINDS = %w[job cronjob].freeze
8
8
 
9
9
  def self.option_parser
10
10
  OptionParser.new do |opts|
11
11
  opts.banner = "Usage: kdep restart [deploy] [--type TYPE]"
12
12
  opts.separator ""
13
- opts.separator "Triggers a rolling restart of a Deployment or StatefulSet."
13
+ opts.separator "Triggers a rolling restart of a Deployment, StatefulSet or DaemonSet."
14
14
  opts.separator ""
15
- opts.on("--type TYPE", "Resource type: deployment, statefulset (overrides preset)") do |_|
15
+ opts.on("--type TYPE", "Resource type: deployment, statefulset, daemonset (overrides preset)") do |_|
16
16
  # parsed via into: hash
17
17
  end
18
18
  end
@@ -58,30 +58,58 @@ module Kdep
58
58
  preset = config["preset"]
59
59
 
60
60
  # Determine resource type
61
- resource_type = determine_resource_type(preset)
61
+ resource_type = determine_resource_type(preset, deploy_dir)
62
62
 
63
63
  # Execute rollout restart
64
- Kdep::Kubectl.run("rollout", "restart", "#{resource_type}/#{app_name}", "-n", namespace)
64
+ begin
65
+ Kdep::Kubectl.run("rollout", "restart", "#{resource_type}/#{app_name}", "-n", namespace)
66
+ rescue Kdep::Kubectl::Error => e
67
+ @ui.error(e.message)
68
+ @ui.info("Hint: use --type <deployment|statefulset|daemonset> if the workload kind differs from the preset")
69
+ exit 1
70
+ end
65
71
  @ui.info("Restarted #{resource_type}/#{app_name} in #{namespace}")
66
72
  end
67
73
 
68
74
  private
69
75
 
70
- def determine_resource_type(preset)
76
+ # The preset file lists the resources kdep applies, so the workload kind
77
+ # is declared there -- deriving it from the preset *name* got every
78
+ # non-Deployment target wrong (daemonset, statefulset, custom). Reading
79
+ # the list also handles `custom`, whose resources live in the deploy dir.
80
+ def determine_resource_type(preset, deploy_dir)
71
81
  if @command_options[:type]
72
82
  return @command_options[:type]
73
83
  end
74
84
 
75
- resource_type_for_preset(preset)
85
+ resource_type_for_preset(preset, deploy_dir)
76
86
  end
77
87
 
78
- def resource_type_for_preset(preset)
79
- if NON_RESTARTABLE_PRESETS.include?(preset)
80
- @ui.error("#{preset} workloads cannot be restarted (use --type to override)")
88
+ def resource_type_for_preset(preset, deploy_dir)
89
+ resources = preset_resources(preset, deploy_dir)
90
+
91
+ if (kind = resources.find { |r| NON_RESTARTABLE_KINDS.include?(r) })
92
+ @ui.error("#{kind} workloads cannot be restarted (use --type to override)")
81
93
  exit 1
82
94
  end
83
95
 
84
- "deployment"
96
+ found = resources & WORKLOAD_KINDS
97
+
98
+ if found.size > 1
99
+ @ui.error("Multiple workloads in preset '#{preset}': #{found.join(', ')}. Use --type to pick one.")
100
+ exit 1
101
+ end
102
+
103
+ # helm charts declare no workload of their own, and a missing preset is
104
+ # already reported by render/apply -- keep the old guess rather than
105
+ # failing a command that used to work.
106
+ found.first || "deployment"
107
+ end
108
+
109
+ def preset_resources(preset, deploy_dir)
110
+ Kdep::Preset.new(preset, deploy_dir).resources
111
+ rescue StandardError
112
+ []
85
113
  end
86
114
 
87
115
  def resolve_deploy_dir(kdep_dir, deploy_name, discovery)
@@ -0,0 +1,27 @@
1
+ require "kdep/yaml_duplicate_keys"
2
+
3
+ module Kdep
4
+ module Doctor
5
+ # A duplicate key is not catchable by review -- in a 50-line file with the
6
+ # two blocks 30 lines apart, they are invisible. It's machine work, and
7
+ # kdep is the one place in the flow that sees every deploy's YAML.
8
+ class CheckYamlDupKeys < Check
9
+ ID = "yaml_dup_keys".freeze
10
+
11
+ def run
12
+ dups = Kdep::YamlDuplicateKeys.in_dir(deploy_dir)
13
+ return Result.ok(ID) if dups.empty?
14
+
15
+ Result.new(
16
+ id: ID,
17
+ severity: :error,
18
+ message: dups.map(&:to_s).join("; "),
19
+ # Deliberately not wired into --fix: merging two blocks means
20
+ # deciding which value survives when the inner keys conflict, and a
21
+ # wrong automatic merge is worse than the bug -- it looks reviewed.
22
+ hint: "Merge the repeated blocks into one by hand."
23
+ )
24
+ end
25
+ end
26
+ end
27
+ end
data/lib/kdep/doctor.rb CHANGED
@@ -5,6 +5,7 @@ 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_yaml_dup_keys"
8
9
 
9
10
  module Kdep
10
11
  module Doctor
@@ -14,6 +15,7 @@ module Kdep
14
15
  CheckStateTagFormat,
15
16
  CheckStateGitignored,
16
17
  CheckGitignoreCanonical,
18
+ CheckYamlDupKeys,
17
19
  ].freeze
18
20
  end
19
21
  end
data/lib/kdep/state.rb CHANGED
@@ -27,15 +27,47 @@ module Kdep
27
27
  loaded && loaded["tag"]
28
28
  end
29
29
 
30
+ # Rewrites only the `tag:` line, keeping everything else byte-for-byte.
31
+ # The header says "do not edit by hand", but state.yml is the one place a
32
+ # warning sits next to the data it guards -- e.g. a target that shares an
33
+ # image repo with another and must never be re-seeded with --init-state.
34
+ # Rebuilding the file from scratch silently ate those comments on every
35
+ # bump, and a YAML round-trip would too (the parser doesn't carry them).
30
36
  def self.write(deploy_dir, tag:)
31
37
  path = File.join(deploy_dir, FILENAME)
32
38
  tmp = "#{path}.tmp"
33
- content = "#{HEADER}tag: \"#{tag}\"\n"
39
+
40
+ content = if File.exist?(path)
41
+ with_header(replace_tag_line(File.read(path), tag))
42
+ else
43
+ "#{HEADER}tag: \"#{tag}\"\n"
44
+ end
45
+
34
46
  File.write(tmp, content)
35
47
  File.rename(tmp, path)
36
48
  path
37
49
  end
38
50
 
51
+ # Appends when the key is absent (state hand-edited into a corrupt shape)
52
+ # rather than discarding what was there -- `load` already raises on that,
53
+ # so the right move here is to not destroy the evidence.
54
+ def self.replace_tag_line(current, tag)
55
+ line = "tag: \"#{tag}\""
56
+ return current.sub(/^tag:.*$/, line) if current.match?(/^tag:.*$/)
57
+
58
+ "#{current.chomp}\n#{line}\n"
59
+ end
60
+ private_class_method :replace_tag_line
61
+
62
+ # A state.yml committed by hand (bump suggests exactly that when none
63
+ # exists) has no banner; give it one instead of leaving it unmarked.
64
+ def self.with_header(content)
65
+ return content if content.include?(HEADER.lines.first.chomp)
66
+
67
+ "#{HEADER}#{content}"
68
+ end
69
+ private_class_method :with_header
70
+
39
71
  def self.exists?(deploy_dir)
40
72
  File.exist?(File.join(deploy_dir, FILENAME))
41
73
  end
data/lib/kdep/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Kdep
2
- VERSION = "0.4.7"
2
+ VERSION = "0.4.8"
3
3
  end
@@ -0,0 +1,72 @@
1
+ require "psych"
2
+
3
+ module Kdep
4
+ # Finds keys declared twice in the same mapping, at any depth.
5
+ #
6
+ # YAML resolves a repeated key by keeping the LAST one and discarding the
7
+ # earlier block ENTIRELY, in silence. Nothing in the pipeline warns: helm
8
+ # template, helm upgrade and kdep all receive the hash already collapsed by
9
+ # the parser. In repleadfy/llm-api2#1 a redis values.yaml declared `metrics:`
10
+ # twice; the first block -- the one redirecting the exporter registry to
11
+ # bitnamilegacy, after Bitnami closed the free repo -- was dropped at parse
12
+ # time, and the wrong image ran in production for 73 days, surviving only on
13
+ # a node's image cache.
14
+ #
15
+ # This walks the Psych AST rather than calling Psych.load, because load has
16
+ # already applied last-one-wins and destroyed the evidence. The AST also
17
+ # gives real line numbers on both sides, works at any depth, and knows that
18
+ # `key: value` text inside a block scalar is data, not a key -- which a grep
19
+ # over the raw file cannot.
20
+ module YamlDuplicateKeys
21
+ Duplicate = Struct.new(:file, :key_path, :first_line, :repeat_line) do
22
+ def to_s
23
+ "#{file}:#{repeat_line} duplicate key '#{key_path}' (first declared at line " \
24
+ "#{first_line}) -- YAML keeps the last block and discards the earlier one"
25
+ end
26
+ end
27
+
28
+ def self.in_file(path)
29
+ ast = Psych.parse_stream(File.read(path))
30
+ scan(ast).map do |dup|
31
+ Duplicate.new(File.basename(path), dup[:path], dup[:first], dup[:repeat])
32
+ end
33
+ rescue Psych::SyntaxError
34
+ # A file that doesn't parse is a different problem, reported elsewhere
35
+ # with better context (Config#load, helm). Silently collapsing is the
36
+ # failure this scanner exists for; don't turn it into a crash here.
37
+ []
38
+ end
39
+
40
+ # Top-level only: everything kdep reads as input lives in the deploy dir
41
+ # root (app.yml, app.<env>.yml, secrets.yml, values.yaml), while nested
42
+ # dirs hold rendered output and templates.
43
+ def self.in_dir(dir)
44
+ Dir.glob(File.join(dir, "*.{yml,yaml}")).sort.flat_map { |f| in_file(f) }
45
+ end
46
+
47
+ def self.scan(node, path = [])
48
+ found = []
49
+
50
+ case node
51
+ when Psych::Nodes::Mapping
52
+ seen = {}
53
+ node.children.each_slice(2) do |key, value|
54
+ name = key.respond_to?(:value) ? key.value : key.to_s
55
+ if seen.key?(name)
56
+ found << {
57
+ :path => (path + [name]).join("."),
58
+ :first => seen[name],
59
+ :repeat => key.start_line + 1,
60
+ }
61
+ end
62
+ seen[name] = key.start_line + 1
63
+ found.concat(scan(value, path + [name]))
64
+ end
65
+ when Psych::Nodes::Sequence, Psych::Nodes::Document, Psych::Nodes::Stream
66
+ node.children.each { |child| found.concat(scan(child, path)) }
67
+ end
68
+
69
+ found
70
+ end
71
+ end
72
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kdep
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.7
4
+ version: 0.4.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Leadfy
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-07-26 00:00:00.000000000 Z
11
+ date: 2026-08-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: envspec
@@ -125,6 +125,7 @@ files:
125
125
  - lib/kdep/doctor/check_state_parseable.rb
126
126
  - lib/kdep/doctor/check_state_present.rb
127
127
  - lib/kdep/doctor/check_state_tag_format.rb
128
+ - lib/kdep/doctor/check_yaml_dup_keys.rb
128
129
  - lib/kdep/doctor/result.rb
129
130
  - lib/kdep/helm.rb
130
131
  - lib/kdep/kubectl.rb
@@ -144,6 +145,7 @@ files:
144
145
  - lib/kdep/version_tagger.rb
145
146
  - lib/kdep/writer.rb
146
147
  - lib/kdep/yaml_compat.rb
148
+ - lib/kdep/yaml_duplicate_keys.rb
147
149
  - templates/init/app.yml.erb
148
150
  - templates/init/env.spec
149
151
  - templates/init/gitignore