kdep 0.1.3 → 0.2.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.
@@ -1,6 +1,7 @@
1
1
  require "optparse"
2
2
  require "fileutils"
3
3
  require "yaml"
4
+ require "tmpdir"
4
5
 
5
6
  module Kdep
6
7
  module Commands
@@ -9,8 +10,9 @@ module Kdep
9
10
  OptionParser.new do |opts|
10
11
  opts.banner = "Usage: kdep migrate [path]"
11
12
  opts.separator ""
12
- opts.separator "Converts old deploy files (.app-name + app.yml) to kdep format."
13
- opts.separator "Verifies byte-for-byte match between old materialized and new rendered output."
13
+ opts.separator "Converts old deploy directories to kdep format."
14
+ opts.separator "Supports both old pattern (.app-name + deploy/) and"
15
+ opts.separator "new pattern (deploy_web/app.yml + subdirectories)."
14
16
  end
15
17
  end
16
18
 
@@ -37,8 +39,8 @@ module Kdep
37
39
  # Parse old format
38
40
  old = Kdep::OldFormat.new(path)
39
41
  begin
40
- name = old.app_name
41
42
  kdep_config = old.to_kdep_config
43
+ name = kdep_config["name"] || old.app_name
42
44
  rescue => e
43
45
  @ui.error(e.message)
44
46
  exit 1
@@ -55,102 +57,205 @@ module Kdep
55
57
  File.write(File.join(target_dir, "app.yml"), YAML.dump(kdep_config))
56
58
  @ui.file_written("kdep/#{name}/app.yml")
57
59
 
58
- # Warn about secrets
59
- @ui.warn("Secrets must be manually migrated to kdep/#{name}/secrets.yml")
60
+ # Extract and write env vars from ConfigMap
61
+ env = old.extract_env
62
+ unless env.empty?
63
+ @ui.info("Extracted #{env.size} env vars from ConfigMap (already in envFrom refs)")
64
+ end
60
65
 
61
- # Render via existing pipeline
62
- config = Kdep::Config.new(target_dir).load
63
- preset = Kdep::Preset.new(config["preset"], target_dir)
64
- resources = preset.resources
66
+ # Extract and write secrets (decode base64 =>plain text for secrets.yml)
67
+ secrets = old.extract_secrets
68
+ if secrets.empty?
69
+ @ui.warn("No secrets found. Create kdep/#{name}/secrets.yml manually if needed.")
70
+ else
71
+ require "base64"
72
+ decoded = {}
73
+ secrets.each do |k, v|
74
+ decoded[k] = Base64.decode64(v) rescue v
75
+ end
76
+ secrets_path = File.join(target_dir, "secrets.yml")
77
+ File.write(secrets_path, YAML.dump(decoded))
78
+ @ui.file_written("kdep/#{name}/secrets.yml")
79
+ @ui.warn("#{secrets.size} secrets written. Review kdep/#{name}/secrets.yml — values are now plain text.")
80
+ end
65
81
 
66
- output_dir = File.join(Dir.pwd, ".rendered")
67
- writer = Kdep::Writer.new(output_dir)
68
- writer.clean
69
- renderer = Kdep::Renderer.new(config, target_dir)
82
+ # Generate custom resource templates (PVC, stringData Secrets)
83
+ custom_resources = old.extract_custom_resources
84
+ unless custom_resources.empty?
85
+ res_dir = File.join(target_dir, "resources")
86
+ FileUtils.mkdir_p(res_dir)
87
+ custom_resources.each do |cr|
88
+ kind = cr["kind"]
89
+ doc = cr["doc"]
90
+ res_name = doc.dig("metadata", "name") || kind.downcase
91
+ filename = "#{res_name.gsub(/[^a-zA-Z0-9_-]/, "-")}.yml.erb"
92
+ File.write(File.join(res_dir, filename), YAML.dump(doc))
93
+ @ui.file_written("kdep/#{name}/resources/#{filename}")
94
+ end
95
+ @ui.info("#{custom_resources.size} custom resource(s) generated as templates")
96
+ end
70
97
 
71
- rendered_files = []
72
- resources.each_with_index do |resource_name, idx|
73
- content = renderer.render_resource(resource_name)
74
- written_path = writer.write(resource_name, content, idx + 1)
75
- rendered_files << written_path if written_path
98
+ @ui.success("Migration complete: kdep/#{name}/")
99
+ @ui.info("")
100
+ @ui.info("Generated config:")
101
+ kdep_config.each do |k, v|
102
+ if v.is_a?(Hash) || v.is_a?(Array)
103
+ @ui.info(" #{k}: #{v.inspect}")
104
+ else
105
+ @ui.info(" #{k}: #{v}")
106
+ end
76
107
  end
77
108
 
78
- # Compare byte-for-byte against old materialized files
109
+ # Automatic post-migration verification
110
+ @ui.info("")
111
+ verify_migration(old, target_dir, kdep_config)
112
+
113
+ @ui.info("")
114
+ @ui.info("Next: review kdep/#{name}/app.yml, then `kdep render #{name}` to test")
115
+ end
116
+
117
+ private
118
+
119
+ def verify_migration(old, target_dir, kdep_config)
120
+ @ui.info("Verifying migration...")
121
+
79
122
  old_files = old.materialized_files
80
123
  if old_files.empty?
81
- @ui.warn("No materialized files found to verify against")
82
- @ui.success("Migration complete: kdep/#{name}/")
124
+ @ui.warn("No old manifests found to verify against")
83
125
  return
84
126
  end
85
127
 
86
- mismatches = compare_files(old_files, rendered_files)
128
+ old_by_kind = parse_yamls(old_files)
87
129
 
88
- if mismatches.empty?
89
- @ui.success("Migration verified: #{rendered_files.length} files match byte-for-byte")
90
- @ui.success("Migration complete: kdep/#{name}/")
91
- else
92
- @ui.error("Migration verification failed: #{mismatches.length} file(s) differ")
93
- mismatches.each do |m|
94
- @ui.error(" #{m[:file]}:")
95
- m[:diff].each { |line| @ui.info(" #{line}") }
130
+ # Render new manifests to a temp dir
131
+ config = Kdep::Config.new(target_dir).load
132
+ preset = Kdep::Preset.new(config["preset"], target_dir)
133
+
134
+ Dir.mktmpdir("kdep-verify") do |tmpdir|
135
+ output_dir = File.join(tmpdir, ".rendered")
136
+ writer = Kdep::Writer.new(output_dir)
137
+ writer.clean
138
+ renderer = Kdep::Renderer.new(config, target_dir)
139
+
140
+ rendered_files = []
141
+ preset.resources.each_with_index do |res, idx|
142
+ content = renderer.render_resource(res)
143
+ path = writer.write(res, content, idx + 1)
144
+ rendered_files << path if path
96
145
  end
97
- # Clean up failed migration
98
- FileUtils.rm_rf(target_dir)
99
- @ui.error("Migration aborted. Generated config removed.")
100
- exit 1
101
- end
102
- end
103
146
 
104
- private
147
+ new_by_kind = parse_yamls(rendered_files)
105
148
 
106
- def compare_files(old_files, new_files)
107
- mismatches = []
108
- # Match by filename (basename)
109
- old_by_name = {}
110
- old_files.each { |f| old_by_name[File.basename(f)] = f }
149
+ diffs = collect_diffs(old_by_kind, new_by_kind)
111
150
 
112
- new_by_name = {}
113
- new_files.each { |f| new_by_name[File.basename(f)] = f }
151
+ if diffs.empty?
152
+ @ui.success("Verification passed: all fields match")
153
+ else
154
+ field_count = diffs.count { |d| d.include?(" ~ ") || d.include?(" + ") || d.include?(" - ") }
155
+ @ui.info("")
156
+ @ui.info(colorize(:bold, "Verification diff (#{field_count} field diffs, old => new):"))
157
+ diffs.each { |d| puts d }
158
+ end
159
+ end
160
+ rescue => e
161
+ @ui.warn("Verification failed: #{e.message}")
162
+ end
114
163
 
115
- # Check all old files have matching new files
116
- old_by_name.each do |name, old_path|
117
- new_path = new_by_name[name]
118
- unless new_path
119
- mismatches << { file: name, diff: ["- File exists in old but not in new rendered output"] }
164
+ def collect_diffs(old_by_kind, new_by_kind)
165
+ diffs = []
166
+ old_by_kind.each do |kind, old_entry|
167
+ new_entry = new_by_kind[kind]
168
+ unless new_entry
169
+ diffs << colorize(:red, " #{kind}: MISSING in rendered")
120
170
  next
121
171
  end
172
+ kind_diffs = tree_diff(old_entry["doc"], new_entry["doc"])
173
+ unless kind_diffs.empty?
174
+ diffs << colorize(:bold, " #{kind}:")
175
+ diffs.concat(kind_diffs)
176
+ end
177
+ end
178
+ new_by_kind.each do |kind, _|
179
+ unless old_by_kind.keys.any? { |k| k.start_with?(kind) }
180
+ diffs << colorize(:yellow, " #{kind}: EXTRA in rendered")
181
+ end
182
+ end
183
+ diffs
184
+ end
122
185
 
123
- old_content = File.read(old_path)
124
- new_content = File.read(new_path)
186
+ def tree_diff(old_val, new_val, path = "")
187
+ old_val = normalize(old_val)
188
+ new_val = normalize(new_val)
189
+ diffs = []
125
190
 
126
- if old_content != new_content
127
- mismatches << { file: name, diff: generate_diff(old_content, new_content) }
191
+ if old_val.is_a?(Hash) && new_val.is_a?(Hash)
192
+ (old_val.keys + new_val.keys).uniq.sort.each do |k|
193
+ p = path.empty? ? k.to_s : "#{path}.#{k}"
194
+ if !old_val.key?(k)
195
+ diffs << colorize(:green, " + #{p}: #{fmt(new_val[k])}")
196
+ elsif !new_val.key?(k)
197
+ diffs << colorize(:red, " - #{p}: #{fmt(old_val[k])}")
198
+ else
199
+ diffs.concat(tree_diff(old_val[k], new_val[k], p))
200
+ end
201
+ end
202
+ elsif old_val.is_a?(Array) && new_val.is_a?(Array)
203
+ [old_val.size, new_val.size].max.times do |i|
204
+ p = "#{path}[#{i}]"
205
+ if i >= old_val.size
206
+ diffs << colorize(:green, " + #{p}: #{fmt(new_val[i])}")
207
+ elsif i >= new_val.size
208
+ diffs << colorize(:red, " - #{p}: #{fmt(old_val[i])}")
209
+ else
210
+ diffs.concat(tree_diff(old_val[i], new_val[i], p))
211
+ end
128
212
  end
213
+ elsif old_val != new_val
214
+ diffs << colorize(:yellow, " ~ #{p || path}: #{fmt(old_val)} => #{fmt(new_val)}")
129
215
  end
216
+ diffs
217
+ end
130
218
 
131
- # Check for new files not in old
132
- new_by_name.each do |name, _|
133
- unless old_by_name.key?(name)
134
- mismatches << { file: name, diff: ["+ File exists in new but not in old materialized output"] }
135
- end
219
+ def normalize(obj)
220
+ case obj
221
+ when Hash
222
+ obj.sort_by { |k, _| k.to_s }.map { |k, v| [k, normalize(v)] }.to_h
223
+ when Array
224
+ obj.map { |v| normalize(v) }
225
+ when String
226
+ # Normalize image tags — tags are managed by kdep bump
227
+ obj.match?(/:\d+[\.\d]*\z/) ? obj.sub(/:[^:]+\z/, ":TAG") : obj
228
+ else
229
+ obj
136
230
  end
231
+ end
137
232
 
138
- mismatches
233
+ def fmt(val)
234
+ case val
235
+ when Hash, Array then val.inspect[0, 80]
236
+ else val.inspect
237
+ end
139
238
  end
140
239
 
141
- def generate_diff(old_content, new_content)
142
- old_lines = old_content.lines.map(&:chomp)
143
- new_lines = new_content.lines.map(&:chomp)
144
- diffs = []
145
- max = [old_lines.length, new_lines.length].max
146
- max.times do |i|
147
- old_line = old_lines[i]
148
- new_line = new_lines[i]
149
- next if old_line == new_line
150
- diffs << "- #{old_line}" if old_line
151
- diffs << "+ #{new_line}" if new_line
240
+ def parse_yamls(files)
241
+ by_kind = {}
242
+ files.each do |f|
243
+ File.read(f).split(/^---\s*$/).each do |part|
244
+ next if part.strip.empty?
245
+ doc = YAML.safe_load(part) rescue next
246
+ next unless doc.is_a?(Hash) && doc["kind"]
247
+ kind = doc["kind"]
248
+ kind = "#{kind}_#{by_kind.keys.count { |k| k.start_with?(doc["kind"]) }}" if by_kind[kind]
249
+ by_kind[kind] = { "doc" => doc, "file" => f }
250
+ end
152
251
  end
153
- diffs
252
+ by_kind
253
+ end
254
+
255
+ def colorize(color, text)
256
+ return text unless $stdout.tty?
257
+ colors = { red: "\e[31m", green: "\e[32m", yellow: "\e[33m", bold: "\e[1m" }
258
+ "#{colors[color]}#{text}\e[0m"
154
259
  end
155
260
  end
156
261
  end
@@ -44,9 +44,9 @@ module Kdep
44
44
  preset = Kdep::Preset.new(config["preset"], deploy_dir)
45
45
  resources = preset.resources
46
46
 
47
- # Determine output directory (.rendered/ in repo root)
47
+ # Determine output directory (.rendered/ in deploy target dir)
48
48
  repo_root = File.dirname(kdep_dir)
49
- output_dir = File.join(repo_root, ".rendered")
49
+ output_dir = File.join(deploy_dir, ".rendered")
50
50
 
51
51
  # Set up writer and renderer
52
52
  writer = Kdep::Writer.new(output_dir)
data/lib/kdep/defaults.rb CHANGED
@@ -1,15 +1,19 @@
1
1
  module Kdep
2
2
  module Defaults
3
3
  BASE_DEFAULTS = {
4
- "port" => 8080,
5
4
  "replicas" => 1,
5
+ "namespace" => "default",
6
+ "platform" => "linux/amd64",
6
7
  }.freeze
7
8
 
8
9
  PRESET_OVERRIDES = {
9
- "web" => { "replicas" => 3 },
10
+ "web" => { "replicas" => 3, "port" => 8080 },
10
11
  "worker" => {},
11
12
  "job" => {},
12
13
  "cronjob" => {},
14
+ "statefulset" => {},
15
+ "statefulset_svc" => {},
16
+ "helm" => {},
13
17
  "custom" => {},
14
18
  }.freeze
15
19
 
data/lib/kdep/docker.rb CHANGED
@@ -4,11 +4,16 @@ module Kdep
4
4
  module Docker
5
5
  class Error < StandardError; end
6
6
 
7
- def self.build(tag:, context_dir:, dockerfile: nil, target: nil, platform: nil)
7
+ def self.build(tag:, context_dir:, dockerfile: nil, target: nil, platform: nil, build_args: nil)
8
+ ensure_login(tag)
9
+
8
10
  args = ["docker", "build", "-t", tag]
9
11
  args.push("--file", dockerfile) if dockerfile
10
12
  args.push("--target", target) if target
11
13
  args.push("--platform", platform) if platform
14
+ if build_args.is_a?(Hash)
15
+ build_args.each { |k, v| args.push("--build-arg", "#{k}=#{v}") }
16
+ end
12
17
  args.push(context_dir)
13
18
 
14
19
  stdout, stderr, status = Open3.capture3(*args)
@@ -19,11 +24,62 @@ module Kdep
19
24
  end
20
25
 
21
26
  def self.push(tag)
27
+ ensure_login(tag)
28
+
22
29
  stdout, stderr, status = Open3.capture3("docker", "push", tag)
23
30
  unless status.success?
24
31
  raise Error, "docker push failed: #{stderr.strip}"
25
32
  end
26
33
  stdout
27
34
  end
35
+
36
+ def self.ensure_login(tag)
37
+ server = tag.split("/").first
38
+ return unless server && server.include?(".")
39
+
40
+ @logged_in ||= {}
41
+ return if @logged_in[server]
42
+
43
+ user, pass = read_credentials(server)
44
+ return unless user && pass
45
+
46
+ _, stderr, status = Open3.capture3(
47
+ "docker", "login", server, "-u", user, "--password-stdin",
48
+ stdin_data: pass
49
+ )
50
+ if status.success?
51
+ @logged_in[server] = true
52
+ else
53
+ $stderr.puts "warning: docker login to #{server} failed: #{stderr.strip}"
54
+ end
55
+ end
56
+
57
+ def self.read_credentials(server)
58
+ # Try ~/.leadfycr (default for leadfycr.azurecr.io)
59
+ cred_file = File.join(Dir.home, ".leadfycr")
60
+ if File.exist?(cred_file)
61
+ parts = File.read(cred_file).strip.split(":", 2)
62
+ return parts if parts.length == 2
63
+ end
64
+
65
+ # Try ~/.docker/config.json
66
+ config_path = File.join(Dir.home, ".docker", "config.json")
67
+ if File.exist?(config_path)
68
+ begin
69
+ config = JSON.parse(File.read(config_path))
70
+ entry = (config["auths"] || {})[server]
71
+ if entry && entry["auth"]
72
+ decoded = Base64.decode64(entry["auth"])
73
+ parts = decoded.split(":", 2)
74
+ return parts if parts.length == 2
75
+ end
76
+ rescue StandardError
77
+ end
78
+ end
79
+
80
+ [nil, nil]
81
+ end
82
+
83
+ private_class_method :ensure_login, :read_credentials
28
84
  end
29
85
  end
data/lib/kdep/helm.rb ADDED
@@ -0,0 +1,34 @@
1
+ require "open3"
2
+
3
+ module Kdep
4
+ module Helm
5
+ class Error < StandardError; end
6
+
7
+ def self.run(*args)
8
+ stdout, stderr, status = Open3.capture3("helm", *args)
9
+ unless status.success?
10
+ raise Error, "helm #{args.first} failed: #{stderr.strip}"
11
+ end
12
+ stdout
13
+ end
14
+
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)
17
+ args = ["upgrade", "--install", release, chart, "-n", namespace]
18
+ args += ["-f", values_file] if values_file
19
+ sets.each do |key, value|
20
+ args += ["--set", "#{key}=#{value}"]
21
+ end
22
+ args << "--dry-run" if dry_run
23
+ run(*args)
24
+ end
25
+
26
+ def self.repo_add(name, url)
27
+ run("repo", "add", name, url, "--force-update")
28
+ end
29
+
30
+ def self.repo_update
31
+ run("repo", "update")
32
+ end
33
+ end
34
+ end