kdep 0.1.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.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/exe/kdep +3 -0
  4. data/lib/kdep/cli.rb +72 -0
  5. data/lib/kdep/cluster_health.rb +44 -0
  6. data/lib/kdep/commands/apply.rb +147 -0
  7. data/lib/kdep/commands/build.rb +103 -0
  8. data/lib/kdep/commands/bump.rb +190 -0
  9. data/lib/kdep/commands/diff.rb +133 -0
  10. data/lib/kdep/commands/eject.rb +97 -0
  11. data/lib/kdep/commands/init.rb +99 -0
  12. data/lib/kdep/commands/log.rb +145 -0
  13. data/lib/kdep/commands/push.rb +94 -0
  14. data/lib/kdep/commands/render.rb +130 -0
  15. data/lib/kdep/commands/restart.rb +109 -0
  16. data/lib/kdep/commands/scale.rb +136 -0
  17. data/lib/kdep/commands/secrets.rb +223 -0
  18. data/lib/kdep/commands/sh.rb +117 -0
  19. data/lib/kdep/commands/status.rb +187 -0
  20. data/lib/kdep/config.rb +69 -0
  21. data/lib/kdep/context_guard.rb +23 -0
  22. data/lib/kdep/dashboard/health_panel.rb +53 -0
  23. data/lib/kdep/dashboard/layout.rb +40 -0
  24. data/lib/kdep/dashboard/log_panel.rb +54 -0
  25. data/lib/kdep/dashboard/panel.rb +135 -0
  26. data/lib/kdep/dashboard/resources_panel.rb +91 -0
  27. data/lib/kdep/dashboard/rollout_panel.rb +75 -0
  28. data/lib/kdep/dashboard/screen.rb +29 -0
  29. data/lib/kdep/dashboard.rb +258 -0
  30. data/lib/kdep/defaults.rb +21 -0
  31. data/lib/kdep/discovery.rb +29 -0
  32. data/lib/kdep/docker.rb +29 -0
  33. data/lib/kdep/kubectl.rb +41 -0
  34. data/lib/kdep/preset.rb +33 -0
  35. data/lib/kdep/registry.rb +94 -0
  36. data/lib/kdep/renderer.rb +53 -0
  37. data/lib/kdep/template_context.rb +22 -0
  38. data/lib/kdep/ui.rb +48 -0
  39. data/lib/kdep/validator.rb +73 -0
  40. data/lib/kdep/version.rb +3 -0
  41. data/lib/kdep/version_tagger.rb +27 -0
  42. data/lib/kdep/writer.rb +26 -0
  43. data/lib/kdep.rb +49 -0
  44. data/templates/init/app.yml.erb +49 -0
  45. data/templates/init/gitignore +3 -0
  46. data/templates/init/secrets.yml +7 -0
  47. data/templates/presets/cronjob +4 -0
  48. data/templates/presets/job +4 -0
  49. data/templates/presets/web +7 -0
  50. data/templates/presets/worker +4 -0
  51. data/templates/resources/configmap.yml.erb +7 -0
  52. data/templates/resources/cronjob.yml.erb +42 -0
  53. data/templates/resources/deployment.yml.erb +71 -0
  54. data/templates/resources/ingress.yml.erb +47 -0
  55. data/templates/resources/job.yml.erb +35 -0
  56. data/templates/resources/secret.yml.erb +15 -0
  57. data/templates/resources/service.yml.erb +13 -0
  58. metadata +142 -0
@@ -0,0 +1,75 @@
1
+ module Kdep
2
+ class Dashboard
3
+ class RolloutPanel < Panel
4
+ def update_from_deployment(deployment_json)
5
+ spec = deployment_json["spec"] || {}
6
+ status = deployment_json["status"] || {}
7
+
8
+ desired = spec["replicas"] || 1
9
+ updated = status["updatedReplicas"] || 0
10
+ available = status["availableReplicas"] || 0
11
+ ready = status["readyReplicas"] || 0
12
+
13
+ percent = desired > 0 ? (updated * 100 / desired) : 0
14
+
15
+ conditions = status["conditions"] || []
16
+ progressing = conditions.find { |c| c["type"] == "Progressing" }
17
+ failed = progressing && progressing["status"] == "False"
18
+ message = progressing ? progressing["message"] : nil
19
+
20
+ @rollout = {
21
+ desired: desired,
22
+ updated: updated,
23
+ available: available,
24
+ ready: ready,
25
+ percent: percent,
26
+ failed: failed,
27
+ message: message,
28
+ }
29
+
30
+ build_lines
31
+ end
32
+
33
+ private
34
+
35
+ def build_lines
36
+ return unless @rollout
37
+
38
+ content = []
39
+ percent = @rollout[:percent]
40
+ bar_width = [rect.width - 20, 10].max
41
+ bar = render_progress_bar(percent, bar_width)
42
+
43
+ if @rollout[:failed]
44
+ content << "\e[31mRollout: #{percent}% #{bar}\e[0m"
45
+ elsif percent >= 100
46
+ content << "\e[32mRollout: 100% #{bar}\e[0m"
47
+ content << "\e[32mRollout complete\e[0m"
48
+ else
49
+ content << "Rollout: #{percent}% #{bar}"
50
+ end
51
+
52
+ d = @rollout[:desired]
53
+ content << "Pods: #{@rollout[:updated]}/#{d} updated, #{@rollout[:available]}/#{d} available, #{@rollout[:ready]}/#{d} ready"
54
+
55
+ if @rollout[:failed] && @rollout[:message]
56
+ content << "\e[31m#{@rollout[:message]}\e[0m"
57
+ end
58
+
59
+ update(content)
60
+ end
61
+
62
+ def render_progress_bar(percent, width)
63
+ filled = (width * percent / 100.0).to_i
64
+ bar = "=" * [filled, 0].max
65
+ if filled < width && filled > 0
66
+ bar += ">"
67
+ space = " " * [width - filled - 1, 0].max
68
+ else
69
+ space = " " * [width - filled, 0].max
70
+ end
71
+ "[#{bar}#{space}] #{percent}%"
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,29 @@
1
+ module Kdep
2
+ class Dashboard
3
+ module Screen
4
+ ENTER_ALT = "\e[?1049h".freeze
5
+ EXIT_ALT = "\e[?1049l".freeze
6
+ HIDE_CURSOR = "\e[?25l".freeze
7
+ SHOW_CURSOR = "\e[?25h".freeze
8
+ CLEAR_SCREEN = "\e[2J".freeze
9
+ HOME = "\e[H".freeze
10
+ CLEAR_LINE_SEQ = "\e[2K".freeze
11
+
12
+ def self.enter_sequence
13
+ ENTER_ALT + HIDE_CURSOR + CLEAR_SCREEN + HOME
14
+ end
15
+
16
+ def self.exit_sequence
17
+ SHOW_CURSOR + EXIT_ALT
18
+ end
19
+
20
+ def self.move_to(row, col)
21
+ "\e[#{row};#{col}H"
22
+ end
23
+
24
+ def self.clear_line
25
+ CLEAR_LINE_SEQ
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,258 @@
1
+ require "io/console"
2
+ require "kdep/dashboard/screen"
3
+ require "kdep/dashboard/layout"
4
+ require "kdep/dashboard/panel"
5
+ require "kdep/dashboard/rollout_panel"
6
+ require "kdep/dashboard/log_panel"
7
+ require "kdep/dashboard/resources_panel"
8
+ require "kdep/dashboard/health_panel"
9
+ require "kdep/kubectl"
10
+
11
+ module Kdep
12
+ class Dashboard
13
+ PANEL_ORDER = [:rollout, :logs, :resources, :health].freeze
14
+ PANEL_NAMES = { rollout: "Rollout", logs: "Logs", resources: "Resources", health: "Health" }.freeze
15
+ REFRESH_INTERVAL = 0.25
16
+
17
+ def initialize(config)
18
+ @config = config
19
+ @name = config["name"]
20
+ @namespace = config["namespace"] || "default"
21
+ @deployment_name = config["deployment_name"] || @name
22
+ @panels = {}
23
+ @active_panel = :rollout
24
+ @quit = false
25
+ @running = false
26
+ @threads = []
27
+ @ios = []
28
+ @mutex = Mutex.new
29
+ end
30
+
31
+ def run
32
+ unless $stdout.tty?
33
+ puts "Dashboard skipped: not a TTY"
34
+ return
35
+ end
36
+
37
+ rows, cols = IO.console.winsize
38
+ return if init_panels(rows, cols).nil?
39
+
40
+ $stdout.print Screen.enter_sequence
41
+ $stdout.flush
42
+ @running = true
43
+ @quit = false
44
+
45
+ setup_signal_handlers
46
+ start_log_thread
47
+ start_rollout_thread
48
+ start_metrics_thread
49
+
50
+ begin
51
+ $stdin.raw do |raw_stdin|
52
+ until @quit
53
+ if IO.select([raw_stdin], nil, nil, 0)
54
+ key = raw_stdin.getc
55
+ handle_key(key) if key
56
+ end
57
+ break if @quit
58
+ render_all_panels($stdout, rows, cols)
59
+ sleep REFRESH_INTERVAL
60
+ end
61
+ end
62
+ ensure
63
+ stop_data_threads
64
+ $stdout.print Screen.exit_sequence
65
+ $stdout.flush
66
+ end
67
+ end
68
+
69
+ private
70
+
71
+ def init_panels(rows, cols)
72
+ layout = Layout.new(rows: rows, cols: cols)
73
+ rects = layout.panels
74
+ return nil if rects.nil?
75
+
76
+ @panels = {
77
+ rollout: RolloutPanel.new(title: "Rollout", rect: rects[:rollout]),
78
+ logs: LogPanel.new(title: "Logs", rect: rects[:logs]),
79
+ resources: ResourcesPanel.new(title: "Resources", rect: rects[:resources]),
80
+ health: HealthPanel.new(title: "Health", rect: rects[:health]),
81
+ }
82
+ @active_panel = :rollout
83
+ @panels
84
+ end
85
+
86
+ def handle_key(key)
87
+ case key
88
+ when "q"
89
+ @quit = true
90
+ when "\x03" # Ctrl+C
91
+ @quit = true
92
+ when "\t"
93
+ idx = PANEL_ORDER.index(@active_panel) || 0
94
+ @active_panel = PANEL_ORDER[(idx + 1) % PANEL_ORDER.length]
95
+ when "j"
96
+ @mutex.synchronize { @panels[@active_panel].scroll_down }
97
+ when "k"
98
+ @mutex.synchronize { @panels[@active_panel].scroll_up }
99
+ when "1"
100
+ @active_panel = :rollout
101
+ when "2"
102
+ @active_panel = :logs
103
+ when "3"
104
+ @active_panel = :resources
105
+ when "4"
106
+ @active_panel = :health
107
+ end
108
+ end
109
+
110
+ def setup_signal_handlers
111
+ Signal.trap("INT") { @quit = true }
112
+ Signal.trap("TERM") { @quit = true }
113
+ end
114
+
115
+ def render_all_panels(io, rows, cols)
116
+ buf = ""
117
+
118
+ # Header
119
+ header = "kdep dashboard -- #{@name} (ns: #{@namespace}) -- q:quit tab:switch j/k:scroll"
120
+ if header.length > cols
121
+ header = header[0, cols]
122
+ end
123
+ buf << Screen.move_to(1, 1) << Screen.clear_line << header
124
+
125
+ # Panels
126
+ @mutex.synchronize do
127
+ @panels.each do |name, panel|
128
+ rect = panel.rect
129
+ lines = panel.render
130
+
131
+ # Highlight active panel border
132
+ lines.each_with_index do |line, i|
133
+ buf << Screen.move_to(rect.top + i + 1, rect.left + 1) << line
134
+ end
135
+ end
136
+ end
137
+
138
+ # Status bar
139
+ active_name = PANEL_NAMES[@active_panel] || @active_panel.to_s
140
+ status = " Active: #{active_name} | Refreshing..."
141
+ if status.length > cols
142
+ status = status[0, cols]
143
+ end
144
+ buf << Screen.move_to(rows, 1) << Screen.clear_line << status
145
+
146
+ io.print buf
147
+ io.flush
148
+ end
149
+
150
+ # --- Data threads ---
151
+
152
+ def start_log_thread
153
+ t = Thread.new do
154
+ begin
155
+ cmd = ["kubectl", "logs", "-n", @namespace, "-l", "app=#{@name}",
156
+ "-f", "--tail=50", "--all-containers", "--prefix"]
157
+ io = IO.popen(cmd, "r", err: [:child, :out])
158
+ @mutex.synchronize { @ios << io }
159
+ while @running && (line = io.gets)
160
+ @mutex.synchronize { @panels[:logs].add_line(line.chomp) }
161
+ end
162
+ rescue => e
163
+ @mutex.synchronize { @panels[:logs].add_line("Log stream error: #{e.message}") } if @running
164
+ ensure
165
+ io.close if io && !io.closed?
166
+ end
167
+ end
168
+ @threads << t
169
+ end
170
+
171
+ def start_rollout_thread
172
+ t = Thread.new do
173
+ interval = 2
174
+ while @running
175
+ begin
176
+ data = Kdep::Kubectl.run_json("get", "deployment", @deployment_name, "-n", @namespace)
177
+ @mutex.synchronize { @panels[:rollout].update_from_deployment(data) }
178
+ interval = 2
179
+ rescue => e
180
+ @mutex.synchronize { @panels[:rollout].add_line("Error: #{e.message}") } if @running
181
+ interval = 5
182
+ end
183
+ sleep_interruptible(interval)
184
+ end
185
+ end
186
+ @threads << t
187
+ end
188
+
189
+ def start_metrics_thread
190
+ t = Thread.new do
191
+ while @running
192
+ begin
193
+ # Pod metrics
194
+ pod_output = Kdep::Kubectl.run("top", "pods", "-n", @namespace, "-l", "app=#{@name}", "--no-headers")
195
+ pod_metrics = parse_pod_metrics(pod_output)
196
+ @mutex.synchronize { @panels[:resources].update(pod_metrics) }
197
+ rescue => e
198
+ @mutex.synchronize { @panels[:resources].add_line("Error: #{e.message}") } if @running
199
+ end
200
+
201
+ begin
202
+ # Node metrics
203
+ node_output = Kdep::Kubectl.run("top", "nodes", "--no-headers")
204
+ node_data = parse_node_metrics(node_output)
205
+ @mutex.synchronize { @panels[:health].update(node_data) }
206
+ rescue => e
207
+ @mutex.synchronize { @panels[:health].add_line("Error: #{e.message}") } if @running
208
+ end
209
+
210
+ sleep_interruptible(5)
211
+ end
212
+ end
213
+ @threads << t
214
+ end
215
+
216
+ def sleep_interruptible(seconds)
217
+ elapsed = 0
218
+ while elapsed < seconds && @running
219
+ sleep 0.5
220
+ elapsed += 0.5
221
+ end
222
+ end
223
+
224
+ def stop_data_threads
225
+ @running = false
226
+
227
+ @threads.each do |t|
228
+ t.join(2)
229
+ t.kill if t.alive?
230
+ end
231
+
232
+ @ios.each do |io|
233
+ io.close unless io.closed?
234
+ rescue
235
+ # ignore close errors
236
+ end
237
+
238
+ @threads.clear
239
+ @ios.clear
240
+ end
241
+
242
+ def parse_pod_metrics(output)
243
+ output.lines.map do |line|
244
+ parts = line.strip.split(/\s+/)
245
+ next if parts.length < 3
246
+ { name: parts[0], cpu: parts[1], memory: parts[2] }
247
+ end.compact
248
+ end
249
+
250
+ def parse_node_metrics(output)
251
+ output.lines.map do |line|
252
+ parts = line.strip.split(/\s+/)
253
+ next if parts.length < 5
254
+ { name: parts[0], cpu_percent: parts[2].to_s.sub(/%$/, ""), mem_percent: parts[4].to_s.sub(/%$/, "") }
255
+ end.compact
256
+ end
257
+ end
258
+ end
@@ -0,0 +1,21 @@
1
+ module Kdep
2
+ module Defaults
3
+ BASE_DEFAULTS = {
4
+ "port" => 8080,
5
+ "replicas" => 1,
6
+ }.freeze
7
+
8
+ PRESET_OVERRIDES = {
9
+ "web" => { "replicas" => 3 },
10
+ "worker" => {},
11
+ "job" => {},
12
+ "cronjob" => {},
13
+ "custom" => {},
14
+ }.freeze
15
+
16
+ def self.for_preset(preset_name)
17
+ overrides = PRESET_OVERRIDES.fetch(preset_name, {})
18
+ BASE_DEFAULTS.merge(overrides)
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,29 @@
1
+ module Kdep
2
+ class Discovery
3
+ def initialize(start_dir = Dir.pwd)
4
+ @start_dir = start_dir
5
+ end
6
+
7
+ def find_kdep_dir
8
+ dir = @start_dir
9
+ loop do
10
+ candidate = File.join(dir, "kdep")
11
+ return candidate if File.directory?(candidate)
12
+ parent = File.dirname(dir)
13
+ break if parent == dir # reached filesystem root
14
+ dir = parent
15
+ end
16
+ nil
17
+ end
18
+
19
+ def find_deploys
20
+ kdep_dir = find_kdep_dir
21
+ return [] unless kdep_dir
22
+
23
+ Dir.entries(kdep_dir)
24
+ .select { |e| File.directory?(File.join(kdep_dir, e)) }
25
+ .reject { |e| e.start_with?(".") }
26
+ .sort
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,29 @@
1
+ require "open3"
2
+
3
+ module Kdep
4
+ module Docker
5
+ class Error < StandardError; end
6
+
7
+ def self.build(tag:, context_dir:, dockerfile: nil, target: nil, platform: nil)
8
+ args = ["docker", "build", "-t", tag]
9
+ args.push("--file", dockerfile) if dockerfile
10
+ args.push("--target", target) if target
11
+ args.push("--platform", platform) if platform
12
+ args.push(context_dir)
13
+
14
+ stdout, stderr, status = Open3.capture3(*args)
15
+ unless status.success?
16
+ raise Error, "docker build failed: #{stderr.strip}"
17
+ end
18
+ stdout
19
+ end
20
+
21
+ def self.push(tag)
22
+ stdout, stderr, status = Open3.capture3("docker", "push", tag)
23
+ unless status.success?
24
+ raise Error, "docker push failed: #{stderr.strip}"
25
+ end
26
+ stdout
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,41 @@
1
+ require "open3"
2
+ require "json"
3
+
4
+ module Kdep
5
+ module Kubectl
6
+ class Error < StandardError; end
7
+
8
+ def self.run(*args)
9
+ stdout, stderr, status = Open3.capture3("kubectl", *args)
10
+ unless status.success?
11
+ raise Error, "kubectl #{args.first} failed: #{stderr.strip}"
12
+ end
13
+ stdout
14
+ end
15
+
16
+ def self.run_json(*args)
17
+ output = run(*args, "-o", "json")
18
+ JSON.parse(output)
19
+ end
20
+
21
+ def self.current_context
22
+ run("config", "current-context").strip
23
+ end
24
+
25
+ def self.apply(file_path, namespace:)
26
+ run("apply", "-f", file_path, "-n", namespace)
27
+ end
28
+
29
+ def self.diff(file_path)
30
+ stdout, stderr, status = Open3.capture3("kubectl", "diff", "-f", file_path)
31
+ 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}"
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,33 @@
1
+ module Kdep
2
+ class Preset
3
+ BUILT_IN = %w[web worker job cronjob custom].freeze
4
+
5
+ def initialize(preset_name, deploy_dir)
6
+ @preset_name = preset_name
7
+ @deploy_dir = deploy_dir
8
+ end
9
+
10
+ def resources
11
+ path = resolve_preset_path
12
+ raise "Preset not found: #{@preset_name}" unless path
13
+
14
+ File.readlines(path).map(&:strip).reject do |line|
15
+ line.empty? || line.start_with?("#")
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def resolve_preset_path
22
+ # Check user custom preset first
23
+ user_path = File.join(@deploy_dir, "presets", @preset_name)
24
+ return user_path if File.exist?(user_path)
25
+
26
+ # Fall back to built-in
27
+ builtin_path = File.join(Kdep.templates_dir, "presets", @preset_name)
28
+ return builtin_path if File.exist?(builtin_path)
29
+
30
+ nil
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,94 @@
1
+ require "net/http"
2
+ require "json"
3
+ require "uri"
4
+ require "base64"
5
+
6
+ module Kdep
7
+ class Registry
8
+ class Error < StandardError; end
9
+
10
+ attr_reader :host, :path_prefix
11
+
12
+ def initialize(registry_url)
13
+ url = registry_url.chomp("/")
14
+ parts = url.split("/", 2)
15
+ @host = parts[0]
16
+ @path_prefix = parts[1]
17
+ @registry_url = url
18
+ end
19
+
20
+ def list_tags(image)
21
+ repository = "#{@path_prefix}/#{image}"
22
+ uri = URI("https://#{@host}/v2/#{repository}/tags/list")
23
+
24
+ req = Net::HTTP::Get.new(uri)
25
+ apply_auth(req, repository)
26
+
27
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
28
+ http.request(req)
29
+ end
30
+
31
+ return [] if response.code == "404"
32
+
33
+ unless response.code.start_with?("2")
34
+ raise Error, "Registry returned #{response.code}: #{response.body}"
35
+ end
36
+
37
+ data = JSON.parse(response.body)
38
+ data["tags"] || []
39
+ end
40
+
41
+ private
42
+
43
+ def apply_auth(request, repository)
44
+ if @host == "ghcr.io" || @host.end_with?(".ghcr.io")
45
+ apply_ghcr_auth(request, repository)
46
+ else
47
+ apply_docker_config_auth(request)
48
+ end
49
+ end
50
+
51
+ def apply_ghcr_auth(request, repository)
52
+ token = ENV["GHCR_TOKEN"]
53
+ if token && !token.empty?
54
+ request["Authorization"] = "Bearer #{token}"
55
+ return
56
+ end
57
+
58
+ begin
59
+ token_uri = URI("https://ghcr.io/token?scope=repository:#{repository}:pull")
60
+ token_response = Net::HTTP.start(token_uri.host, token_uri.port, use_ssl: true) do |http|
61
+ http.request(Net::HTTP::Get.new(token_uri))
62
+ end
63
+ if token_response.code.start_with?("2")
64
+ data = JSON.parse(token_response.body)
65
+ if data["token"]
66
+ request["Authorization"] = "Bearer #{data["token"]}"
67
+ end
68
+ end
69
+ rescue StandardError
70
+ # Gracefully skip if token endpoint fails
71
+ end
72
+ end
73
+
74
+ def apply_docker_config_auth(request)
75
+ path = docker_config_path
76
+ return unless File.exist?(path)
77
+
78
+ begin
79
+ config = JSON.parse(File.read(path))
80
+ auths = config["auths"] || {}
81
+ entry = auths[@host]
82
+ if entry && entry["auth"]
83
+ request["Authorization"] = "Basic #{entry["auth"]}"
84
+ end
85
+ rescue StandardError
86
+ # Gracefully skip if config parsing fails
87
+ end
88
+ end
89
+
90
+ def docker_config_path
91
+ File.join(Dir.home, ".docker", "config.json")
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,53 @@
1
+ require "erb"
2
+ require "yaml"
3
+
4
+ module Kdep
5
+ class Renderer
6
+ def initialize(config, deploy_dir)
7
+ @config = config
8
+ @deploy_dir = deploy_dir
9
+ end
10
+
11
+ def render_resource(resource_name)
12
+ template_path = resolve_template(resource_name)
13
+ raise "Template not found: #{resource_name}.yml.erb" unless template_path
14
+
15
+ template_content = File.read(template_path)
16
+ erb = if RUBY_VERSION >= "2.6"
17
+ ERB.new(template_content, trim_mode: "-")
18
+ else
19
+ ERB.new(template_content, nil, "-")
20
+ end
21
+
22
+ secrets = {}
23
+ if resource_name == "secret"
24
+ secrets = load_secrets
25
+ end
26
+
27
+ context = TemplateContext.new(@config, secrets)
28
+ erb.result(context.get_binding)
29
+ end
30
+
31
+ private
32
+
33
+ def resolve_template(resource_name)
34
+ # Check user override first
35
+ user_path = File.join(@deploy_dir, "resources", "#{resource_name}.yml.erb")
36
+ return user_path if File.exist?(user_path)
37
+
38
+ # Fall back to built-in
39
+ builtin_path = File.join(Kdep.templates_dir, "resources", "#{resource_name}.yml.erb")
40
+ return builtin_path if File.exist?(builtin_path)
41
+
42
+ nil
43
+ end
44
+
45
+ def load_secrets
46
+ secrets_path = File.join(@deploy_dir, "secrets.yml")
47
+ return {} unless File.exist?(secrets_path)
48
+
49
+ content = File.read(secrets_path)
50
+ YAML.safe_load(content) || {}
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,22 @@
1
+ module Kdep
2
+ class TemplateContext
3
+ def initialize(config, secrets = {})
4
+ @config = config
5
+ @secrets = secrets
6
+
7
+ # Expose top-level config keys as methods for cleaner templates
8
+ config.each do |key, value|
9
+ define_singleton_method(key.to_sym) { value } if key.is_a?(String)
10
+ end
11
+ end
12
+
13
+ def get_binding
14
+ binding
15
+ end
16
+
17
+ # Helper: render a value as YAML-safe string
18
+ def yaml_value(val)
19
+ val.is_a?(String) ? val : val.to_s
20
+ end
21
+ end
22
+ end