kdeploy 1.2.41 → 1.3.3

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.
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'timeout'
4
+
5
+ module Kdeploy
6
+ # Executes a single command and records execution time
7
+ class CommandExecutor
8
+ def initialize(executor, output, debug: false, retries: 0, retry_delay: 1, retry_on_nonzero: false,
9
+ step_timeout: nil, retry_policy: nil)
10
+ @executor = executor
11
+ @output = output
12
+ @debug = debug
13
+ @retries = retries.to_i
14
+ @retry_delay = retry_delay.to_f
15
+ @retry_on_nonzero = retry_on_nonzero
16
+ @step_timeout = step_timeout&.to_f
17
+ @retry_policy = retry_policy
18
+ end
19
+
20
+ def execute_run(command, _host_name)
21
+ cmd = command[:command]
22
+ use_sudo = command[:sudo]
23
+
24
+ result, duration = measure_time do
25
+ with_retries(step_type: :run) do
26
+ with_timeout { @executor.execute(cmd, use_sudo: use_sudo) }
27
+ end
28
+ end
29
+
30
+ { command: cmd, output: result, duration: duration, type: :run }
31
+ end
32
+
33
+ def execute_upload(command, _host_name)
34
+ _result, duration = measure_time do
35
+ with_retries(step_type: :upload) do
36
+ with_timeout { @executor.upload(command[:source], command[:destination]) }
37
+ end
38
+ end
39
+ {
40
+ command: "upload: #{command[:source]} -> #{command[:destination]}",
41
+ duration: duration,
42
+ type: :upload
43
+ }
44
+ end
45
+
46
+ def execute_upload_template(command, _host_name)
47
+ _result, duration = measure_time do
48
+ with_retries(step_type: :upload_template) do
49
+ with_timeout do
50
+ @executor.upload_template(command[:source], command[:destination], command[:variables])
51
+ end
52
+ end
53
+ end
54
+ {
55
+ command: "upload_template: #{command[:source]} -> #{command[:destination]}",
56
+ duration: duration,
57
+ type: :upload_template
58
+ }
59
+ end
60
+
61
+ def execute_sync(command, _host_name)
62
+ source = command[:source]
63
+ destination = command[:destination]
64
+ fast = command.key?(:fast) ? command[:fast] : Configuration.default_sync_fast
65
+ parallel = command.key?(:parallel) ? command[:parallel] : Configuration.default_sync_parallel
66
+
67
+ result, duration = measure_time do
68
+ with_retries(step_type: :sync) do
69
+ with_timeout do
70
+ @executor.sync_directory(
71
+ source,
72
+ destination,
73
+ ignore: command[:ignore] || [],
74
+ exclude: command[:exclude] || [],
75
+ delete: command[:delete] || false,
76
+ fast: fast,
77
+ parallel: parallel
78
+ )
79
+ end
80
+ end
81
+ end
82
+
83
+ build_sync_result(source, destination, result, duration)
84
+ end
85
+
86
+ private
87
+
88
+ def build_sync_result(source, destination, result, duration)
89
+ {
90
+ command: "sync: #{source} -> #{destination}",
91
+ duration: duration,
92
+ type: :sync,
93
+ result: result,
94
+ uploaded: result[:uploaded],
95
+ deleted: result[:deleted],
96
+ total: result[:total]
97
+ }
98
+ end
99
+
100
+ def measure_time
101
+ start_time = Time.now
102
+ result = yield
103
+ duration = Time.now - start_time
104
+ [result, duration]
105
+ end
106
+
107
+ def with_retries(step_type: nil)
108
+ attempts = 0
109
+ max_retries = retries_for(step_type)
110
+ exit_codes = retry_exit_codes_for(step_type)
111
+ begin
112
+ attempts += 1
113
+ yield
114
+ rescue SSHError, SCPError, TemplateError => e
115
+ raise if e.is_a?(SSHError) && e.exit_status && !retry_on_exit_status?(e.exit_status, exit_codes)
116
+ raise if attempts > (max_retries + 1)
117
+
118
+ sleep(@retry_delay) if @retry_delay.positive?
119
+ retry
120
+ end
121
+ end
122
+
123
+ def with_timeout(&block)
124
+ return yield unless @step_timeout&.positive?
125
+
126
+ Timeout.timeout(@step_timeout, &block)
127
+ rescue Timeout::Error
128
+ raise StepTimeoutError, "exceeded #{@step_timeout}s"
129
+ end
130
+
131
+ def retries_for(step_type)
132
+ return @retries unless @retry_policy.is_a?(Hash) && step_type
133
+
134
+ policy = @retry_policy[step_type.to_s] || @retry_policy[step_type.to_sym]
135
+ return @retries unless policy.is_a?(Hash)
136
+
137
+ policy.fetch('retries', policy.fetch(:retries, @retries)).to_i
138
+ end
139
+
140
+ def retry_exit_codes_for(step_type)
141
+ return nil unless @retry_policy.is_a?(Hash) && step_type
142
+
143
+ policy = @retry_policy[step_type.to_s] || @retry_policy[step_type.to_sym]
144
+ return nil unless policy.is_a?(Hash)
145
+
146
+ policy['retry_on_exit_codes'] || policy[:retry_on_exit_codes]
147
+ end
148
+
149
+ def retry_on_exit_status?(exit_status, exit_codes)
150
+ return @retry_on_nonzero if exit_codes.nil?
151
+
152
+ Array(exit_codes).map(&:to_i).include?(exit_status.to_i)
153
+ end
154
+ end
155
+ end
@@ -5,6 +5,8 @@ require 'net/scp'
5
5
  require 'pathname'
6
6
  require 'find'
7
7
  require 'shellwords'
8
+ require 'tempfile'
9
+ require 'concurrent'
8
10
  require_relative 'file_filter'
9
11
 
10
12
  module Kdeploy
@@ -16,7 +18,7 @@ module Kdeploy
16
18
  @ip = host_config[:ip]
17
19
  @password = host_config[:password]
18
20
  @key = host_config[:key]
19
- @port = host_config[:port] # 新增端口支持
21
+ @port = host_config[:port] # Added custom port support
20
22
  @use_sudo = host_config[:use_sudo] || false
21
23
  @sudo_password = host_config[:sudo_password]
22
24
  @base_dir = host_config[:base_dir] # Base directory for resolving relative paths
@@ -114,7 +116,8 @@ module Kdeploy
114
116
  raise TemplateError.new("Template upload failed: #{e.message}", e)
115
117
  end
116
118
 
117
- def sync_directory(source, destination, ignore: [], exclude: [], delete: false, use_sudo: nil)
119
+ def sync_directory(source, destination, ignore: [], exclude: [], delete: false, fast: nil, parallel: nil,
120
+ use_sudo: nil)
118
121
  use_sudo = @use_sudo if use_sudo.nil?
119
122
 
120
123
  # Resolve relative paths relative to base_dir
@@ -127,24 +130,29 @@ module Kdeploy
127
130
  all_patterns = ignore + exclude
128
131
  filter = FileFilter.new(ignore_patterns: all_patterns)
129
132
 
133
+ if fast
134
+ rsync_result = sync_with_rsync(
135
+ resolved_source,
136
+ destination,
137
+ ignore: ignore,
138
+ exclude: exclude,
139
+ delete: delete,
140
+ use_sudo: use_sudo
141
+ )
142
+ return rsync_result if rsync_result
143
+ end
144
+
130
145
  # Collect files to sync
131
146
  files_to_sync = collect_files_to_sync(resolved_source, filter)
132
147
 
133
148
  # Upload files
134
- uploaded_count = 0
135
- source_path = Pathname.new(resolved_source)
136
- files_to_sync.each do |file_path|
137
- relative_path = Pathname.new(file_path).relative_path_from(source_path).to_s
138
- remote_path = File.join(destination, relative_path).gsub(%r{/+}, '/')
139
-
140
- # Ensure remote directory exists
141
- remote_dir = File.dirname(remote_path)
142
- ensure_remote_directory(remote_dir, use_sudo: use_sudo)
143
-
144
- # Upload file
145
- upload(file_path, remote_path, use_sudo: use_sudo)
146
- uploaded_count += 1
147
- end
149
+ uploaded_count = upload_files(
150
+ files_to_sync,
151
+ resolved_source,
152
+ destination,
153
+ parallel: parallel,
154
+ use_sudo: use_sudo
155
+ )
148
156
 
149
157
  # Delete extra files if requested
150
158
  deleted_count = 0
@@ -196,6 +204,80 @@ module Kdeploy
196
204
  end
197
205
  end
198
206
 
207
+ def sync_with_rsync(source, destination, ignore:, exclude:, delete:, use_sudo:)
208
+ return nil unless system('command -v rsync >/dev/null 2>&1')
209
+
210
+ exclude_file = build_rsync_excludes(ignore + exclude)
211
+
212
+ unless remote_rsync_available?
213
+ File.delete(exclude_file) if exclude_file && File.exist?(exclude_file)
214
+ return nil
215
+ end
216
+
217
+ begin
218
+ rsync_cmd = build_rsync_command(source, destination, exclude_file, delete: delete, use_sudo: use_sudo)
219
+ return nil unless system(rsync_cmd)
220
+
221
+ {
222
+ uploaded: 0,
223
+ deleted: 0,
224
+ total: 0,
225
+ fast_path: 'rsync'
226
+ }
227
+ ensure
228
+ File.delete(exclude_file) if exclude_file && File.exist?(exclude_file)
229
+ end
230
+ rescue StandardError
231
+ nil
232
+ end
233
+
234
+ def build_rsync_excludes(patterns)
235
+ patterns = Array(patterns).compact
236
+ return nil if patterns.empty?
237
+
238
+ file = Tempfile.new('kdeploy_rsync_excludes')
239
+ patterns.each { |pattern| file.write("#{pattern}\n") }
240
+ file.close
241
+ file.path
242
+ end
243
+
244
+ def build_rsync_command(source, destination, exclude_file, delete:, use_sudo:)
245
+ delete_flag = delete ? '--delete' : ''
246
+ exclude_flag = exclude_file ? "--exclude-from='#{exclude_file}'" : ''
247
+ ssh_cmd = build_rsync_ssh_command
248
+ sudo_flag = if use_sudo || requires_sudo?(destination)
249
+ '--rsync-path="sudo rsync"'
250
+ else
251
+ ''
252
+ end
253
+ parts = [
254
+ 'rsync -az',
255
+ delete_flag,
256
+ exclude_flag,
257
+ sudo_flag,
258
+ "-e \"#{ssh_cmd}\"",
259
+ "#{Shellwords.escape(source)}/",
260
+ "#{@user}@#{@ip}:#{Shellwords.escape(destination)}/"
261
+ ].reject(&:empty?)
262
+ parts.join(' ')
263
+ end
264
+
265
+ def build_rsync_ssh_command
266
+ ssh = ['ssh']
267
+ ssh << "-p #{@port}" if @port
268
+ ssh << "-i #{Shellwords.escape(@key)}" if @key
269
+ ssh.join(' ')
270
+ end
271
+
272
+ def remote_rsync_available?
273
+ Net::SSH.start(@ip, @user, ssh_options) do |ssh|
274
+ output = ssh.exec!('command -v rsync 2>/dev/null || true')
275
+ return !output.to_s.strip.empty?
276
+ end
277
+ rescue StandardError
278
+ false
279
+ end
280
+
199
281
  def requires_sudo?(path)
200
282
  # Check if path is in system directories that typically require sudo
201
283
  system_dirs = %w[/etc /usr /var /opt /sbin /bin /lib /lib64 /root]
@@ -229,16 +311,16 @@ module Kdeploy
229
311
  end
230
312
 
231
313
  def wrap_with_sudo(command)
232
- # 如果命令已经以 sudo 开头,不重复添加
314
+ # Do not add sudo again if command already starts with sudo
233
315
  return command if command.strip.start_with?('sudo')
234
316
 
235
- # 多行、shell 控制结构、或包含 && / || / ; 的复合命令需整段在 sudo 下执行
317
+ # Wrap multi-line/control/compound commands so sudo applies to the full block
236
318
  needs_wrap = command.include?("\n") ||
237
319
  command.match?(/\b(if|for|while|case|function)\b/) ||
238
320
  command.match?(/\s(&&|\|\||;)\s/)
239
321
 
240
322
  if needs_wrap
241
- # 转义命令中的单引号,然后用 bash -c 执行
323
+ # Escape single quotes in command, then execute via bash -c
242
324
  escaped_command = command.gsub("'", "'\"'\"'")
243
325
  if @sudo_password
244
326
  escaped_password = @sudo_password.gsub('\'', "'\"'\"'").gsub('$', '\\$').gsub('`', '\\`')
@@ -247,7 +329,7 @@ module Kdeploy
247
329
  "sudo bash -c '#{escaped_command}'"
248
330
  end
249
331
  elsif @sudo_password
250
- # 单行命令直接包装
332
+ # Wrap single-line command directly
251
333
  escaped_password = @sudo_password.gsub('\'', "'\"'\"'").gsub('$', '\\$').gsub('`', '\\`')
252
334
  "echo '#{escaped_password}' | sudo -S #{command}"
253
335
  else
@@ -271,6 +353,55 @@ module Kdeploy
271
353
  files
272
354
  end
273
355
 
356
+ def upload_files(files_to_sync, source_dir, destination, parallel:, use_sudo:)
357
+ count = Concurrent::AtomicFixnum.new(0)
358
+ source_path = Pathname.new(source_dir)
359
+ parallel = normalize_parallel(parallel)
360
+ return upload_files_sequential(files_to_sync, source_path, destination, use_sudo, count) if parallel <= 1
361
+
362
+ queue = Queue.new
363
+ files_to_sync.each { |path| queue << path }
364
+ workers = Array.new(parallel) do
365
+ Thread.new do
366
+ until queue.empty?
367
+ file_path = begin
368
+ queue.pop(true)
369
+ rescue StandardError
370
+ nil
371
+ end
372
+ next unless file_path
373
+
374
+ upload_single_file(file_path, source_path, destination, use_sudo)
375
+ count.increment
376
+ end
377
+ end
378
+ end
379
+ workers.each(&:join)
380
+ count.value
381
+ end
382
+
383
+ def upload_files_sequential(files_to_sync, source_path, destination, use_sudo, count)
384
+ files_to_sync.each do |file_path|
385
+ upload_single_file(file_path, source_path, destination, use_sudo)
386
+ count.increment
387
+ end
388
+ count.value
389
+ end
390
+
391
+ def upload_single_file(file_path, source_path, destination, use_sudo)
392
+ relative_path = Pathname.new(file_path).relative_path_from(source_path).to_s
393
+ remote_path = File.join(destination, relative_path).gsub(%r{/+}, '/')
394
+ remote_dir = File.dirname(remote_path)
395
+ ensure_remote_directory(remote_dir, use_sudo: use_sudo)
396
+ upload(file_path, remote_path, use_sudo: use_sudo)
397
+ end
398
+
399
+ def normalize_parallel(parallel)
400
+ value = parallel.nil? ? Configuration.default_sync_parallel : parallel
401
+ value = value.to_i
402
+ value.positive? ? value : 1
403
+ end
404
+
274
405
  def ensure_remote_directory(remote_dir, use_sudo: nil)
275
406
  use_sudo = @use_sudo if use_sudo.nil?
276
407
  return if remote_dir.nil? || remote_dir.empty? || remote_dir == '.' || remote_dir == '/'
@@ -261,6 +261,7 @@ module Kdeploy
261
261
  stats = []
262
262
  stats << @pastel.green("#{uploaded} uploaded") if uploaded.positive?
263
263
  stats << @pastel.yellow("#{deleted} deleted") if deleted.positive?
264
+ stats << @pastel.cyan("fast: #{result[:fast_path]}") if result[:fast_path]
264
265
  stats_str = stats.any? ? " (#{stats.join(', ')})" : " (#{total} files)"
265
266
 
266
267
  @pastel.dim(' ') + @pastel.cyan(display_path) + @pastel.dim(stats_str) + duration_str + " #{status_str}"
@@ -7,7 +7,12 @@ module Kdeploy
7
7
  class Runner
8
8
  def initialize(hosts, tasks, parallel: Configuration.default_parallel, output: ConsoleOutput.new,
9
9
  debug: false, base_dir: nil, retries: Configuration.default_retries,
10
- retry_delay: Configuration.default_retry_delay)
10
+ retry_delay: Configuration.default_retry_delay,
11
+ retry_on_nonzero: Configuration.default_retry_on_nonzero,
12
+ host_timeout: Configuration.default_host_timeout,
13
+ step_timeout: Configuration.default_step_timeout,
14
+ retry_policy: Configuration.default_retry_policy,
15
+ on_step: nil)
11
16
  @hosts = hosts
12
17
  @tasks = tasks
13
18
  @parallel = parallel
@@ -16,6 +21,11 @@ module Kdeploy
16
21
  @base_dir = base_dir
17
22
  @retries = retries
18
23
  @retry_delay = retry_delay
24
+ @retry_on_nonzero = retry_on_nonzero
25
+ @host_timeout = normalize_timeout(host_timeout)
26
+ @step_timeout = normalize_timeout(step_timeout)
27
+ @retry_policy = retry_policy
28
+ @on_step = on_step
19
29
  @pool = Concurrent::FixedThreadPool.new(@parallel)
20
30
  @results = Concurrent::Hash.new
21
31
  end
@@ -41,59 +51,48 @@ module Kdeploy
41
51
  # If no hosts, return empty results immediately
42
52
  return @results if futures.empty?
43
53
 
44
- # Collect results from futures
45
- futures.each_with_index do |future, index|
46
- host_name = @host_names[index] # Get host name from the stored list
47
- begin
48
- # Wait for future to complete and get its value
49
- # This ensures the future has finished executing
50
- future_result = future.value
51
-
52
- # Handle the result
53
- if future_result.nil?
54
- # Future returned nil - create a default result
55
- @results[host_name] = { status: :unknown, error: 'Future returned nil', output: [] }
56
- elsif future_result.is_a?(Array) && future_result.length == 2
57
- name, result = future_result
58
- # Store the result using the name from the future
59
- @results[name] = result
60
- else
61
- # Handle unexpected result format - create a default result
62
- @results[host_name] = {
63
- status: :unknown,
64
- error: "Unexpected result format: #{future_result.class}",
54
+ pending = futures.dup
55
+
56
+ until pending.empty?
57
+ progressed = false
58
+ now = Time.now
59
+
60
+ pending.dup.each do |future|
61
+ meta = @future_meta[future]
62
+ host_name = meta[:host_name]
63
+ started_at = meta[:started_at].get
64
+
65
+ if future.fulfilled? || future.rejected?
66
+ collect_future_result(future, host_name)
67
+ pending.delete(future)
68
+ progressed = true
69
+ elsif timeout_exceeded?(started_at, now)
70
+ @results[host_name] ||= {
71
+ status: :failed,
72
+ error: "execution timeout after #{@host_timeout}s",
65
73
  output: []
66
74
  }
75
+ pending.delete(future)
76
+ progressed = true
67
77
  end
68
-
69
- # Check if future raised an exception
70
- if future.rejected?
71
- error = begin
72
- future.reason
73
- rescue StandardError
74
- 'Unknown error'
75
- end
76
- @results[host_name] = { status: :failed, error: error, output: [] } unless @results.key?(host_name)
77
- end
78
- rescue StandardError => e
79
- # If future.value raises an exception, create an error result
80
- @results[host_name] = { status: :failed, error: "#{e.class}: #{e.message}", output: [] }
81
- ensure
82
- # Ensure we always have a result for this host
83
- @results[host_name] ||= { status: :unknown, error: 'No result collected', output: [] }
84
78
  end
79
+
80
+ sleep(0.05) unless progressed
85
81
  end
86
82
 
87
83
  @results
88
84
  end
89
85
 
90
86
  def create_task_futures(task, task_name)
91
- # Store host names in order to match with futures
92
- @host_names = @hosts.keys
87
+ @future_meta = {}
93
88
  @hosts.map do |name, config|
94
- Concurrent::Future.execute(executor: @pool) do
89
+ started_at = Concurrent::AtomicReference.new(nil)
90
+ future = Concurrent::Future.execute(executor: @pool) do
91
+ started_at.set(Time.now)
95
92
  execute_task_for_host(name, config, task, task_name)
96
93
  end
94
+ @future_meta[future] = { host_name: name, started_at: started_at }
95
+ future
97
96
  end
98
97
  end
99
98
 
@@ -108,7 +107,10 @@ module Kdeploy
108
107
  @output,
109
108
  debug: @debug,
110
109
  retries: @retries,
111
- retry_delay: @retry_delay
110
+ retry_delay: @retry_delay,
111
+ retry_on_nonzero: @retry_on_nonzero,
112
+ step_timeout: @step_timeout,
113
+ retry_policy: @retry_policy
112
114
  )
113
115
  result = { status: :success, output: [] }
114
116
 
@@ -130,10 +132,11 @@ module Kdeploy
130
132
  commands.each do |command|
131
133
  step_result = execute_command(command_executor, command, name)
132
134
  result[:output] << step_result
135
+ emit_step(name, step_result, result, task_name)
133
136
  rescue StandardError => e
134
137
  step = step_description(command)
135
138
  result[:status] = :failed
136
- result[:error] = "task=#{task_name} host=#{name} step=#{step} error=#{e.class}: #{e.message}"
139
+ result[:error] = build_step_error(task_name, name, step, e)
137
140
  result[:output] << {
138
141
  type: command[:type],
139
142
  command: step_command_string(command),
@@ -141,10 +144,58 @@ module Kdeploy
141
144
  error: "#{e.class}: #{e.message}",
142
145
  output: error_output_for_step(e)
143
146
  }
147
+ emit_step(name, result[:output].last, result, task_name)
144
148
  break
145
149
  end
146
150
  end
147
151
 
152
+ def collect_future_result(future, host_name)
153
+ return if @results.key?(host_name)
154
+
155
+ begin
156
+ future_result = future.value
157
+ if future_result.nil?
158
+ @results[host_name] = { status: :unknown, error: 'Future returned nil', output: [] }
159
+ elsif future_result.is_a?(Array) && future_result.length == 2
160
+ name, result = future_result
161
+ @results[name] = result
162
+ else
163
+ @results[host_name] = {
164
+ status: :unknown,
165
+ error: "Unexpected result format: #{future_result.class}",
166
+ output: []
167
+ }
168
+ end
169
+
170
+ if future.rejected?
171
+ error = begin
172
+ future.reason
173
+ rescue StandardError
174
+ 'Unknown error'
175
+ end
176
+ @results[host_name] ||= { status: :failed, error: error, output: [] }
177
+ end
178
+ rescue StandardError => e
179
+ @results[host_name] = { status: :failed, error: "#{e.class}: #{e.message}", output: [] }
180
+ ensure
181
+ @results[host_name] ||= { status: :unknown, error: 'No result collected', output: [] }
182
+ end
183
+ end
184
+
185
+ def timeout_exceeded?(started_at, now)
186
+ return false unless @host_timeout
187
+ return false if started_at.nil?
188
+
189
+ (now - started_at) > @host_timeout
190
+ end
191
+
192
+ def normalize_timeout(timeout)
193
+ return nil if timeout.nil?
194
+
195
+ timeout = timeout.to_f
196
+ timeout.positive? ? timeout : nil
197
+ end
198
+
148
199
  def error_output_for_step(error)
149
200
  return nil unless error.is_a?(Kdeploy::SSHError)
150
201
 
@@ -156,6 +207,26 @@ module Kdeploy
156
207
  }
157
208
  end
158
209
 
210
+ def build_step_error(task_name, host_name, step, error)
211
+ base = "task=#{task_name} host=#{host_name} step=#{step} error=#{error.class}: #{error.message}"
212
+ return base unless error.is_a?(Kdeploy::SSHError)
213
+
214
+ if error.exit_status
215
+ "#{base} exit_status=#{error.exit_status} command=#{error.command}"
216
+ else
217
+ base
218
+ end
219
+ end
220
+
221
+ def emit_step(host_name, step_result, result, task_name)
222
+ return unless @on_step
223
+
224
+ @on_step.call(host_name, step_result, result, task_name)
225
+ rescue StandardError
226
+ # Best-effort only; never fail execution because of streaming hooks.
227
+ nil
228
+ end
229
+
159
230
  def execute_command(command_executor, command, host_name)
160
231
  case command[:type]
161
232
  when :run
@@ -2,12 +2,14 @@
2
2
 
3
3
  require 'erb'
4
4
  require 'tempfile'
5
+ require 'set'
5
6
 
6
7
  module Kdeploy
7
8
  # ERB template rendering and upload handler
8
9
  class Template
9
10
  def self.render(template_path, variables = {})
10
11
  template_content = File.read(template_path)
12
+ validate_template_variables(template_content, variables)
11
13
  context = create_template_context(variables)
12
14
  ERB.new(template_content).result(context.instance_eval { binding })
13
15
  end
@@ -24,17 +26,38 @@ module Kdeploy
24
26
  def self.render_and_upload(executor, template_path, destination, variables = {})
25
27
  rendered_content = render(template_path, variables)
26
28
 
27
- # 创建临时文件
29
+ # Create temporary file
28
30
  temp_file = Tempfile.new('kdeploy')
29
31
  begin
30
32
  temp_file.write(rendered_content)
31
33
  temp_file.close
32
34
 
33
- # 上传渲染后的文件
35
+ # Upload rendered file
34
36
  executor.upload(temp_file.path, destination)
35
37
  ensure
36
38
  temp_file.unlink
37
39
  end
38
40
  end
41
+
42
+ def self.validate_template_variables(template_content, variables)
43
+ required = extract_template_identifiers(template_content)
44
+ return if required.empty?
45
+
46
+ provided = variables.keys.to_set(&:to_s)
47
+ missing = required.reject { |name| provided.include?(name) }
48
+ return if missing.empty?
49
+
50
+ raise ArgumentError, "Missing template variables: #{missing.sort.join(', ')}"
51
+ end
52
+
53
+ def self.extract_template_identifiers(template_content)
54
+ identifiers = template_content.scan(/<%=\s*([a-zA-Z_]\w*)/).flatten
55
+ identifiers.uniq - ruby_keywords
56
+ end
57
+
58
+ def self.ruby_keywords
59
+ %w[alias and begin break case class def defined? do else elsif end ensure false for if in module next nil not
60
+ or redo rescue retry return self super then true undef unless until when while yield]
61
+ end
39
62
  end
40
63
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  # Kdeploy module for version management
4
4
  module Kdeploy
5
- VERSION = '1.2.41' unless const_defined?(:VERSION)
5
+ VERSION = '1.3.3' unless const_defined?(:VERSION)
6
6
  end