kdep 0.3.3 → 0.3.5

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: 95e9b9f35fb62ccb1d6e7e3c375e0a68e629b7bc81f7959c57d14eb33af19e5d
4
- data.tar.gz: 5f5e8d59539c690e57d110fd109ea4c38d34081747f62f08e60b9397991faae4
3
+ metadata.gz: 2f2546356a99516a639dc29b0d25a6494898c351a2dd40ca58a3510da93aa49d
4
+ data.tar.gz: ea992fe366d4d6875b1edf6d976d2c8ece688854d76dfd12ab87031c85ffda6d
5
5
  SHA512:
6
- metadata.gz: 7a208e0fdc9c932ddb5a1c68cc5d15401e95fd18fb598293045cb0beeef43c74d38a3300224f961d30dba477702279af0caa0e86116beafa8ea14d678783adb3
7
- data.tar.gz: 3046797ac1769ac6c238a1fd8de10be4fac0bab2492557bb0a27447470d5cac6edf222779dd59195d9be0d1253935fbdffbb3e728e97ea3352e08883c3cef5a6
6
+ metadata.gz: 122d63cdabc4fda2ff9f7f52f546dc5288d26209ec9f7025ee93cbdd0109e4de094e37af1b5073266b6b5a5d939ca15fcd65004dc79c5eda286709bc4fb059d3
7
+ data.tar.gz: 2aee7b30512d9102f86bf1150b6ee5054f741497bc4009784dbaf38f2d2f222ae6b8c3a2249090c3a9dad5a31fcdf013c8c753e7f07dd761051ccc189e0450f8
data/lib/kdep/cli.rb CHANGED
@@ -20,6 +20,7 @@ module Kdep
20
20
  "migrate" => Commands::Migrate,
21
21
  "helm-install" => Commands::HelmInstall,
22
22
  "dashboard" => Commands::Dashboard,
23
+ "doctor" => Commands::Doctor,
23
24
  }.freeze
24
25
 
25
26
  def initialize(argv)
@@ -13,6 +13,7 @@ module Kdep
13
13
  opts.on("--dry-run", "Run full pipeline but skip kubectl apply")
14
14
  opts.on("--no-dashboard", "Skip TUI dashboard after deploy")
15
15
  opts.on("--platform=PLATFORM", "Target platform (e.g., linux/amd64)")
16
+ opts.on("--init-state", "Seed state.yml at 0.0 when missing (non-interactive)")
16
17
  end
17
18
  end
18
19
 
@@ -64,12 +65,18 @@ module Kdep
64
65
 
65
66
  begin
66
67
  # Step 3: Read current tag from state.yml, increment minor
67
- state_path = File.join(deploy_dir, "state.yml")
68
- if File.exist?(state_path)
69
- state = YAML.safe_load(File.read(state_path)) || {}
70
- current_tag = state["tag"] || "0.0"
71
- else
72
- current_tag = "0.0"
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
73
80
  end
74
81
  parsed = Kdep::VersionTagger.parse(current_tag)
75
82
  next_version = parsed ? "#{parsed[0]}.#{parsed[1] + 1}" : "0.1"
@@ -113,12 +120,7 @@ module Kdep
113
120
 
114
121
  resources.each_with_index do |resource_name, idx|
115
122
  index = idx + 1
116
- begin
117
- content = renderer.render_resource(resource_name)
118
- rescue => e
119
- @ui.error("#{resource_name}: #{e.message}")
120
- next
121
- end
123
+ content = renderer.render_resource(resource_name)
122
124
 
123
125
  unless content.nil? || content.strip.empty?
124
126
  result = validator.validate(content, resource_name)
@@ -148,7 +150,6 @@ module Kdep
148
150
  else
149
151
  # Apply
150
152
  namespace = config["namespace"]
151
- errors = []
152
153
  applied = 0
153
154
 
154
155
  rendered_files.each do |file_path|
@@ -157,30 +158,25 @@ module Kdep
157
158
  @ui.info("applied: #{File.basename(file_path)}")
158
159
  applied += 1
159
160
  rescue Kdep::Kubectl::Error => e
160
- errors << { "file" => File.basename(file_path), "error" => e.message }
161
161
  @ui.error("#{File.basename(file_path)}: #{e.message}")
162
+ exit 1
162
163
  end
163
164
  end
164
165
 
165
- if errors.empty?
166
- @ui.info("#{applied} files applied, 0 errors")
167
-
168
- # Save new tag to state.yml
169
- File.write(File.join(deploy_dir, "state.yml"), YAML.dump({"tag" => next_version}))
170
-
171
- # Launch TUI dashboard for live monitoring
172
- unless @command_options[:"no-dashboard"]
173
- dashboard = Kdep::Dashboard.new(
174
- "name" => config["name"],
175
- "namespace" => namespace,
176
- "registry" => config["registry"],
177
- "image" => image
178
- )
179
- dashboard.run
180
- end
181
- else
182
- @ui.info("#{applied} files applied, #{errors.length} errors")
183
- exit 1
166
+ @ui.info("#{applied} files applied, 0 errors")
167
+
168
+ # Save new tag to state.yml
169
+ Kdep::State.write(deploy_dir, tag: next_version)
170
+
171
+ # Launch TUI dashboard for live monitoring
172
+ unless @command_options[:"no-dashboard"]
173
+ dashboard = Kdep::Dashboard.new(
174
+ "name" => config["name"],
175
+ "namespace" => namespace,
176
+ "registry" => config["registry"],
177
+ "image" => image
178
+ )
179
+ dashboard.run
184
180
  end
185
181
  end
186
182
  end
@@ -196,6 +192,41 @@ module Kdep
196
192
 
197
193
  private
198
194
 
195
+ def handle_missing_state(deploy_dir)
196
+ if @command_options[:"init-state"]
197
+ seed_initial_state(deploy_dir, reason: :flag)
198
+ elsif $stdin.tty?
199
+ prompt_for_init(deploy_dir)
200
+ else
201
+ @ui.error(
202
+ "state.yml not found in #{deploy_dir}.\n" \
203
+ "Re-run with --init-state to seed at \"#{Kdep::State::INITIAL_TAG}\" " \
204
+ "(first deploy will be tag \"0.1\"), or commit a state.yml manually."
205
+ )
206
+ exit 1
207
+ end
208
+ end
209
+
210
+ def prompt_for_init(deploy_dir)
211
+ @ui.warn("No state.yml found in #{deploy_dir}.")
212
+ @ui.warn("If prod is already running a higher tag, this will regress it.")
213
+ print %Q(Seed state.yml at "#{Kdep::State::INITIAL_TAG}" and proceed? [y/N] )
214
+ answer = $stdin.gets&.strip&.downcase
215
+ unless %w[y yes].include?(answer)
216
+ @ui.info("Aborted.")
217
+ exit 0
218
+ end
219
+ seed_initial_state(deploy_dir, reason: :prompt)
220
+ end
221
+
222
+ def seed_initial_state(deploy_dir, reason:)
223
+ tag = Kdep::State::INITIAL_TAG
224
+ Kdep::State.write(deploy_dir, tag: tag)
225
+ source = reason == :flag ? "--init-state flag" : "user confirmation"
226
+ @ui.info("Seeded state.yml at #{tag} (#{source}). First deploy will be tag 0.1.")
227
+ tag
228
+ end
229
+
199
230
  def resolve_deploy_dir(kdep_dir, deploy_name, discovery)
200
231
  if deploy_name
201
232
  deploy_dir = File.join(kdep_dir, deploy_name)
@@ -48,37 +48,43 @@ module Kdep
48
48
  exit 1
49
49
  end
50
50
 
51
- # Load preset resources and render
52
- preset = Kdep::Preset.new(config["preset"], deploy_dir)
53
- resources = preset.resources
54
-
55
- repo_root = File.dirname(kdep_dir)
56
- output_dir = File.join(deploy_dir, ".rendered")
57
-
58
- writer = Kdep::Writer.new(output_dir)
59
- writer.clean
60
- renderer = Kdep::Renderer.new(config, deploy_dir)
51
+ # Inject tag from state.yml if present (to_fix.md #1)
52
+ if (state_tag = Kdep::State.tag(deploy_dir))
53
+ config["tag"] = state_tag
54
+ else
55
+ @ui.warn("No state.yml in #{deploy_dir} -- diff will compare preset default tag against live cluster.")
56
+ end
61
57
 
62
- resources.each_with_index do |resource_name, idx|
63
- index = idx + 1
64
- begin
65
- content = renderer.render_resource(resource_name)
66
- rescue => e
67
- @ui.error("#{resource_name}: #{e.message}")
68
- next
58
+ # Render into a temp dir so we never clobber .rendered/ (owned by bump/render)
59
+ require "tmpdir"
60
+ Dir.mktmpdir("kdep-diff-") do |tmpdir|
61
+ preset = Kdep::Preset.new(config["preset"], deploy_dir)
62
+ writer = Kdep::Writer.new(tmpdir)
63
+ renderer = Kdep::Renderer.new(config, deploy_dir)
64
+
65
+ preset.resources.each_with_index do |resource_name, idx|
66
+ begin
67
+ content = renderer.render_resource(resource_name)
68
+ rescue => e
69
+ @ui.error("#{resource_name}: #{e.message}")
70
+ next
71
+ end
72
+ writer.write(resource_name, content, idx + 1)
69
73
  end
70
- writer.write(resource_name, content, index)
74
+
75
+ rendered_files = Dir.glob(File.join(tmpdir, "*.yml")).sort
76
+ run_diff(rendered_files)
71
77
  end
78
+ end
72
79
 
73
- # Get sorted rendered files
74
- rendered_files = Dir.glob(File.join(output_dir, "*.yml")).sort
80
+ private
75
81
 
82
+ def run_diff(rendered_files)
76
83
  if rendered_files.empty?
77
84
  @ui.info("No rendered files to diff")
78
85
  return
79
86
  end
80
87
 
81
- # Diff each file against live cluster state
82
88
  has_diffs = false
83
89
  errors = []
84
90
 
@@ -101,8 +107,6 @@ module Kdep
101
107
  end
102
108
  end
103
109
 
104
- private
105
-
106
110
  def resolve_deploy_dir(kdep_dir, deploy_name, discovery)
107
111
  if deploy_name
108
112
  deploy_dir = File.join(kdep_dir, deploy_name)
@@ -0,0 +1,99 @@
1
+ require "optparse"
2
+ require "json"
3
+ require "fileutils"
4
+
5
+ module Kdep
6
+ module Commands
7
+ class Doctor
8
+ def self.option_parser
9
+ OptionParser.new do |opts|
10
+ opts.banner = "Usage: kdep doctor [deploy]"
11
+ opts.separator ""
12
+ opts.separator "Runs health checks against the kdep/ directory."
13
+ opts.separator "With a deploy name, checks only that deploy; otherwise, checks all."
14
+ opts.separator ""
15
+ opts.on("--fix", "Auto-fix safe issues (missing .gitignore only)")
16
+ opts.on("--only=CHECKS", "Comma-separated check IDs to run")
17
+ opts.on("--format=FORMAT", "Output format: text (default) or json")
18
+ end
19
+ end
20
+
21
+ def initialize(global_options:, command_options:, args:)
22
+ @global_options = global_options
23
+ @command_options = command_options
24
+ @args = args
25
+ @ui = Kdep::UI.new(color: false)
26
+ end
27
+
28
+ def execute
29
+ deploy_name = @args[0]
30
+ discovery = Kdep::Discovery.new
31
+ kdep_dir = discovery.find_kdep_dir
32
+
33
+ unless kdep_dir
34
+ @ui.error("No kdep/ directory found")
35
+ exit 1
36
+ end
37
+
38
+ deploys = deploy_name ? [deploy_name] : discovery.find_deploys
39
+ only = (@command_options[:only] || "").split(",").map(&:strip).reject(&:empty?)
40
+ format = @command_options[:format] || "text"
41
+
42
+ all_results = []
43
+ deploys.each do |name|
44
+ deploy_dir = File.join(kdep_dir, name)
45
+ unless File.directory?(deploy_dir)
46
+ @ui.error("Deploy not found: #{name}")
47
+ exit 1
48
+ end
49
+ Kdep::Doctor::CHECKS.each do |check_class|
50
+ id = check_class.const_get(:ID)
51
+ next if !only.empty? && !only.include?(id)
52
+ result = check_class.new(deploy_dir).run
53
+ all_results << { deploy: name, result: result }
54
+ apply_fix(check_class, deploy_dir, result) if @command_options[:fix]
55
+ end
56
+ end
57
+
58
+ if format == "json"
59
+ puts JSON.pretty_generate(all_results.map { |r| r[:result].to_h.merge(deploy: r[:deploy]) })
60
+ else
61
+ render_text(all_results)
62
+ end
63
+
64
+ exit 1 if all_results.any? { |r| r[:result].severity == :error }
65
+ end
66
+
67
+ private
68
+
69
+ def render_text(entries)
70
+ entries.each do |entry|
71
+ r = entry[:result]
72
+ prefix = case r.severity
73
+ when :ok then "OK "
74
+ when :info then "INFO "
75
+ when :warn then "WARN "
76
+ when :error then "ERR "
77
+ else r.severity.to_s.upcase.ljust(5)
78
+ end
79
+ line = "#{prefix} [#{entry[:deploy]}] #{r.id}"
80
+ line += ": #{r.message}" if r.message
81
+ puts line
82
+ puts " -> #{r.hint}" if r.hint
83
+ end
84
+ end
85
+
86
+ def apply_fix(check_class, deploy_dir, result)
87
+ return unless result.severity != :ok
88
+ return unless check_class == Kdep::Doctor::CheckGitignoreCanonical
89
+ path = File.join(deploy_dir, ".gitignore")
90
+ return if File.exist?(path)
91
+ FileUtils.cp(
92
+ File.join(Kdep.templates_dir, "init", "gitignore"),
93
+ path
94
+ )
95
+ @ui.file_written(path)
96
+ end
97
+ end
98
+ end
99
+ end
@@ -57,6 +57,28 @@ module Kdep
57
57
  File.write(File.join(target_dir, "app.yml"), YAML.dump(kdep_config))
58
58
  @ui.file_written("kdep/#{name}/app.yml")
59
59
 
60
+ # Copy canonical .gitignore so the migrated deploy stops
61
+ # at the same ignores as 'kdep init' uses (no state.yml!).
62
+ FileUtils.cp(
63
+ File.join(Kdep.templates_dir, "init", "gitignore"),
64
+ File.join(target_dir, ".gitignore")
65
+ )
66
+ @ui.file_written("kdep/#{name}/.gitignore")
67
+
68
+ # Seed state.yml from the live deploy's image tag so the
69
+ # first 'kdep bump' post-migration produces the NEXT tag,
70
+ # not 0.1 (which would regress prod).
71
+ if (tag = old.extracted_tag)
72
+ Kdep::State.write(target_dir, tag: tag)
73
+ @ui.file_written("kdep/#{name}/state.yml")
74
+ @ui.info("Seeded state.yml from live deploy tag: #{tag}")
75
+ unless Kdep::State.parseable_tag?(tag)
76
+ @ui.warn("Tag #{tag} is not kdep's major.minor format. Verify state.yml before bumping; next bump may produce 0.1.")
77
+ end
78
+ else
79
+ @ui.warn("Could not extract tag from old deploy (latest/missing). Next 'kdep bump' will prompt to seed state.")
80
+ end
81
+
60
82
  # Extract and write env vars from ConfigMap
61
83
  env = old.extract_env
62
84
  unless env.empty?
@@ -40,6 +40,12 @@ module Kdep
40
40
  # Load config
41
41
  config = Kdep::Config.new(deploy_dir, env).load
42
42
 
43
+ if (state_tag = Kdep::State.tag(deploy_dir))
44
+ config["tag"] = state_tag
45
+ else
46
+ @ui.warn("No state.yml in #{deploy_dir} -- rendering with preset default tag. Run 'kdep bump' to establish state.")
47
+ end
48
+
43
49
  # Load preset resources
44
50
  preset = Kdep::Preset.new(config["preset"], deploy_dir)
45
51
  resources = preset.resources
data/lib/kdep/docker.rb CHANGED
@@ -48,19 +48,31 @@ module Kdep
48
48
  "docker", "login", server, "-u", user, "--password-stdin",
49
49
  stdin_data: pass
50
50
  )
51
- if status.success?
52
- @logged_in[server] = true
53
- else
54
- $stderr.puts "warning: docker login to #{server} failed: #{stderr.strip}"
51
+ unless status.success?
52
+ raise Error, "docker login to #{server} failed: #{stderr.strip}"
55
53
  end
54
+ @logged_in[server] = true
56
55
  end
57
56
 
58
57
  def self.read_credentials(server)
59
- # Try ~/.leadfycr (default for leadfycr.azurecr.io)
60
- cred_file = File.join(Dir.home, ".leadfycr")
61
- if File.exist?(cred_file)
62
- parts = File.read(cred_file).strip.split(":", 2)
63
- return parts if parts.length == 2
58
+ # Try ~/.github-pat + ~/.github-user (for ghcr.io)
59
+ if server == "ghcr.io" || server.end_with?(".ghcr.io")
60
+ pat_file = File.join(Dir.home, ".github-pat")
61
+ user_file = File.join(Dir.home, ".github-user")
62
+ if File.exist?(pat_file) && File.exist?(user_file)
63
+ user = File.read(user_file).strip
64
+ pat = File.read(pat_file).strip
65
+ return [user, pat] if !user.empty? && !pat.empty?
66
+ end
67
+ end
68
+
69
+ # Try ~/.leadfycr (for azurecr.io)
70
+ if server.end_with?(".azurecr.io")
71
+ cred_file = File.join(Dir.home, ".leadfycr")
72
+ if File.exist?(cred_file)
73
+ parts = File.read(cred_file).strip.split(":", 2)
74
+ return parts if parts.length == 2
75
+ end
64
76
  end
65
77
 
66
78
  # Try ~/.docker/config.json
@@ -0,0 +1,15 @@
1
+ module Kdep
2
+ module Doctor
3
+ class Check
4
+ attr_reader :deploy_dir
5
+
6
+ def initialize(deploy_dir)
7
+ @deploy_dir = deploy_dir
8
+ end
9
+
10
+ def run
11
+ raise NotImplementedError, "#{self.class}#run must be overridden"
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,26 @@
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
@@ -0,0 +1,30 @@
1
+ module Kdep
2
+ module Doctor
3
+ class CheckGitignoreCanonical < Check
4
+ ID = "gitignore_canonical".freeze
5
+
6
+ def run
7
+ path = File.join(deploy_dir, ".gitignore")
8
+ template_path = File.join(Kdep.templates_dir, "init", "gitignore")
9
+
10
+ unless File.exist?(path)
11
+ return Result.new(
12
+ id: ID, severity: :info,
13
+ message: "#{deploy_dir}/.gitignore missing",
14
+ hint: "Run 'kdep doctor --fix' to create it from the canonical template."
15
+ )
16
+ end
17
+
18
+ if File.read(path) == File.read(template_path)
19
+ Result.ok(ID)
20
+ else
21
+ Result.new(
22
+ id: ID, severity: :info,
23
+ message: "#{path} differs from canonical template",
24
+ hint: "Review differences; add new entries under the kdep-generated comment."
25
+ )
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,26 @@
1
+ require "open3"
2
+ require "kdep/state"
3
+
4
+ module Kdep
5
+ module Doctor
6
+ class CheckStateGitignored < Check
7
+ ID = "state_gitignored".freeze
8
+
9
+ def run
10
+ state_path = File.join(deploy_dir, Kdep::State::FILENAME)
11
+ _, status = Open3.capture2("git", "check-ignore", "-q", state_path)
12
+ if status.success?
13
+ Result.new(
14
+ id: ID, severity: :error,
15
+ 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."
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
@@ -0,0 +1,19 @@
1
+ require "kdep/state"
2
+
3
+ module Kdep
4
+ module Doctor
5
+ class CheckStateParseable < Check
6
+ ID = "state_parseable".freeze
7
+
8
+ def run
9
+ return Result.ok(ID) unless Kdep::State.exists?(deploy_dir)
10
+ begin
11
+ Kdep::State.load(deploy_dir)
12
+ Result.ok(ID)
13
+ rescue Kdep::State::Error => e
14
+ Result.new(id: ID, severity: :error, message: e.message)
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,21 @@
1
+ require "kdep/state"
2
+
3
+ module Kdep
4
+ module Doctor
5
+ class CheckStatePresent < Check
6
+ ID = "state_present".freeze
7
+
8
+ def run
9
+ if Kdep::State.exists?(deploy_dir)
10
+ Result.ok(ID)
11
+ else
12
+ Result.new(
13
+ id: ID, severity: :warn,
14
+ message: "state.yml missing in #{deploy_dir}",
15
+ hint: "Run 'kdep bump --init-state' to seed, or commit a state.yml manually."
16
+ )
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,20 @@
1
+ require "kdep/state"
2
+
3
+ module Kdep
4
+ module Doctor
5
+ class CheckStateTagFormat < Check
6
+ ID = "state_tag_format".freeze
7
+
8
+ def run
9
+ return Result.ok(ID) unless Kdep::State.exists?(deploy_dir)
10
+ tag = Kdep::State.tag(deploy_dir)
11
+ return Result.ok(ID) if Kdep::State.parseable_tag?(tag)
12
+ Result.new(
13
+ id: ID, severity: :warn,
14
+ message: "state.yml tag #{tag.inspect} is not kdep's major.minor format",
15
+ hint: "Next 'kdep bump' will fall back to 0.1 for this deploy."
16
+ )
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,26 @@
1
+ module Kdep
2
+ module Doctor
3
+ class Result
4
+ attr_reader :id, :severity, :message, :hint
5
+
6
+ def initialize(id:, severity:, message: nil, hint: nil)
7
+ @id = id
8
+ @severity = severity
9
+ @message = message
10
+ @hint = hint
11
+ end
12
+
13
+ def ok?
14
+ @severity == :ok
15
+ end
16
+
17
+ def self.ok(id)
18
+ new(id: id, severity: :ok)
19
+ end
20
+
21
+ def to_h
22
+ { id: id, severity: severity, message: message, hint: hint }
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,21 @@
1
+ require "kdep/doctor/result"
2
+ require "kdep/doctor/check"
3
+ require "kdep/doctor/check_state_present"
4
+ require "kdep/doctor/check_state_parseable"
5
+ require "kdep/doctor/check_state_tag_format"
6
+ require "kdep/doctor/check_state_gitignored"
7
+ require "kdep/doctor/check_gitignore_canonical"
8
+ require "kdep/doctor/check_ancestor_ignores_state"
9
+
10
+ module Kdep
11
+ module Doctor
12
+ CHECKS = [
13
+ CheckStatePresent,
14
+ CheckStateParseable,
15
+ CheckStateTagFormat,
16
+ CheckStateGitignored,
17
+ CheckGitignoreCanonical,
18
+ CheckAncestorIgnoresState,
19
+ ].freeze
20
+ end
21
+ end
@@ -258,8 +258,35 @@ module Kdep
258
258
  env
259
259
  end
260
260
 
261
+ # Extract the image tag from the live deploy (e.g., "0.44" from
262
+ # "ghcr.io/org/app:0.44"). Returns nil if the tag is "latest",
263
+ # empty, or absent. On multi-container deployments, returns the
264
+ # first usable tag found.
265
+ def extracted_tag
266
+ 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
272
+ end
273
+ nil
274
+ end
275
+
261
276
  private
262
277
 
278
+ def deployed_images
279
+ return @deployed_images if defined?(@deployed_images)
280
+ @deployed_images = []
281
+ each_manifest do |doc|
282
+ containers = doc.dig("spec", "template", "spec", "containers") || []
283
+ containers.each do |c|
284
+ @deployed_images << c["image"] if c["image"]
285
+ end
286
+ end
287
+ @deployed_images
288
+ end
289
+
263
290
  def extract_from_manifests(result)
264
291
  has_service = false
265
292
  has_ingress = false
data/lib/kdep/state.rb ADDED
@@ -0,0 +1,48 @@
1
+ require "yaml"
2
+
3
+ module Kdep
4
+ module State
5
+ class Error < StandardError; end
6
+
7
+ FILENAME = "state.yml".freeze
8
+ INITIAL_TAG = "0.0".freeze
9
+
10
+ HEADER = <<~HEADER
11
+ # Last tag deployed by kdep bump -- do not edit by hand.
12
+ # This file is safe to commit. See: https://github.com/repleadfy/kdep
13
+ HEADER
14
+
15
+ def self.load(deploy_dir)
16
+ path = File.join(deploy_dir, FILENAME)
17
+ return nil unless File.exist?(path)
18
+ data = YAML.safe_load(File.read(path)) || {}
19
+ unless data.is_a?(Hash) && data["tag"]
20
+ raise Error, "#{path}: missing required key 'tag'"
21
+ end
22
+ data
23
+ end
24
+
25
+ def self.tag(deploy_dir)
26
+ loaded = load(deploy_dir)
27
+ loaded && loaded["tag"]
28
+ end
29
+
30
+ def self.write(deploy_dir, tag:)
31
+ path = File.join(deploy_dir, FILENAME)
32
+ tmp = "#{path}.tmp"
33
+ content = "#{HEADER}tag: \"#{tag}\"\n"
34
+ File.write(tmp, content)
35
+ File.rename(tmp, path)
36
+ path
37
+ end
38
+
39
+ def self.exists?(deploy_dir)
40
+ File.exist?(File.join(deploy_dir, FILENAME))
41
+ end
42
+
43
+ def self.parseable_tag?(tag)
44
+ require "kdep/version_tagger"
45
+ !!Kdep::VersionTagger.parse(tag)
46
+ end
47
+ end
48
+ end
data/lib/kdep/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Kdep
2
- VERSION = "0.3.3"
2
+ VERSION = "0.3.5"
3
3
  end
data/lib/kdep.rb CHANGED
@@ -17,6 +17,8 @@ require "kdep/docker"
17
17
  require "kdep/helm"
18
18
  require "kdep/registry"
19
19
  require "kdep/version_tagger"
20
+ require "kdep/state"
21
+ require "kdep/doctor"
20
22
  require "kdep/commands/render"
21
23
  require "kdep/commands/init"
22
24
  require "kdep/commands/eject"
@@ -34,6 +36,7 @@ require "kdep/commands/scale"
34
36
  require "kdep/commands/migrate"
35
37
  require "kdep/commands/helm_install"
36
38
  require "kdep/commands/dashboard"
39
+ require "kdep/commands/doctor"
37
40
  require "kdep/dashboard/screen"
38
41
  require "kdep/dashboard/layout"
39
42
  require "kdep/dashboard/panel"
@@ -1,3 +1,4 @@
1
- # kdep generated files
1
+ # kdep-generated files (do NOT add state.yml -- it is tracked so
2
+ # deploys are reproducible across clones and CI runners).
2
3
  secrets.yml
3
4
  .rendered/
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.3.3
4
+ version: 0.3.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Leadfy
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-04-13 00:00:00.000000000 Z
11
+ date: 2026-04-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -70,6 +70,7 @@ files:
70
70
  - lib/kdep/commands/bump.rb
71
71
  - lib/kdep/commands/dashboard.rb
72
72
  - lib/kdep/commands/diff.rb
73
+ - lib/kdep/commands/doctor.rb
73
74
  - lib/kdep/commands/eject.rb
74
75
  - lib/kdep/commands/helm_install.rb
75
76
  - lib/kdep/commands/init.rb
@@ -95,12 +96,22 @@ files:
95
96
  - lib/kdep/defaults.rb
96
97
  - lib/kdep/discovery.rb
97
98
  - lib/kdep/docker.rb
99
+ - lib/kdep/doctor.rb
100
+ - lib/kdep/doctor/check.rb
101
+ - lib/kdep/doctor/check_ancestor_ignores_state.rb
102
+ - lib/kdep/doctor/check_gitignore_canonical.rb
103
+ - lib/kdep/doctor/check_state_gitignored.rb
104
+ - lib/kdep/doctor/check_state_parseable.rb
105
+ - lib/kdep/doctor/check_state_present.rb
106
+ - lib/kdep/doctor/check_state_tag_format.rb
107
+ - lib/kdep/doctor/result.rb
98
108
  - lib/kdep/helm.rb
99
109
  - lib/kdep/kubectl.rb
100
110
  - lib/kdep/old_format.rb
101
111
  - lib/kdep/preset.rb
102
112
  - lib/kdep/registry.rb
103
113
  - lib/kdep/renderer.rb
114
+ - lib/kdep/state.rb
104
115
  - lib/kdep/template_context.rb
105
116
  - lib/kdep/ui.rb
106
117
  - lib/kdep/update_check.rb
@@ -147,7 +158,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
147
158
  - !ruby/object:Gem::Version
148
159
  version: '0'
149
160
  requirements: []
150
- rubygems_version: 3.2.32
161
+ rubygems_version: 3.5.22
151
162
  signing_key:
152
163
  specification_version: 4
153
164
  summary: Kubernetes deployment CLI