kdeploy 1.3.1 → 1.3.4

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.
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'thor'
4
4
  require 'json'
5
+ require 'yaml'
5
6
  require 'pastel'
6
7
  require 'tty-table'
7
8
  require 'tty-box'
@@ -53,17 +54,20 @@ module Kdeploy
53
54
  method_option :retry_delay, type: :numeric, desc: 'Retry delay seconds (default: 1)'
54
55
  method_option :retry_on_nonzero, type: :boolean, desc: 'Retry commands on nonzero exit status (default: false)'
55
56
  method_option :timeout, type: :numeric, desc: 'Per-host execution timeout seconds (default: none)'
57
+ method_option :step_timeout, type: :numeric, desc: 'Per-step execution timeout seconds (default: none)'
58
+ method_option :retry_policy, type: :string, desc: 'Retry policy JSON to override config file'
59
+ method_option :retry_policy_file, type: :string, desc: 'Retry policy JSON file to override config file'
56
60
  def execute(task_file, task_name = nil)
57
- load_config_file
58
- show_banner_once
59
61
  @task_file_dir = File.dirname(File.expand_path(task_file))
62
+ Configuration.load_for_execute(task_dir: @task_file_dir)
63
+ show_banner_once
60
64
  load_task_file(task_file)
61
65
 
62
66
  tasks_to_run = determine_tasks(task_name)
63
67
  all_results = execute_tasks(tasks_to_run)
64
68
 
65
- # Exit non-zero if any executed host failed.
66
- exit 1 if any_failed?(all_results)
69
+ # Exit non-zero if any executed host failed or a task could not run.
70
+ exit 1 if any_failed?(all_results) || @task_errors
67
71
  rescue StandardError => e
68
72
  puts Kdeploy::Banner.show_error(e.message)
69
73
  exit 1
@@ -72,12 +76,13 @@ module Kdeploy
72
76
  private
73
77
 
74
78
  def load_config_file
75
- Configuration.load_from_file
79
+ # ponytail: kept for compatibility; prefer Configuration.load_for_execute
80
+ Configuration.load_for_execute(task_dir: @task_file_dir)
76
81
  end
77
82
 
78
83
  def load_task_file(file)
79
84
  validate_task_file(file)
80
- # instance_eval 并传递顶层 binding,兼容 heredoc
85
+ # Use module_eval with top-level binding to keep heredoc compatible
81
86
  self.class.module_eval(File.read(file), file)
82
87
  rescue StandardError => e
83
88
  raise FileNotFoundError, file if e.message.include?('not found')
@@ -103,6 +108,8 @@ module Kdeploy
103
108
  return hosts unless limit
104
109
 
105
110
  host_names = limit.split(',').map(&:strip)
111
+ unknown = host_names.reject { |name| hosts.key?(name) }
112
+ warn "Warning: --limit ignored unknown host(s): #{unknown.join(', ')}" if unknown.any?
106
113
  hosts.slice(*host_names)
107
114
  end
108
115
 
@@ -145,7 +152,7 @@ module Kdeploy
145
152
  end
146
153
 
147
154
  def print_host_result(host, result, formatter)
148
- if %i[success changed].include?(result[:status])
155
+ if %i[success].include?(result[:status])
149
156
  print_success_result(host, result, formatter)
150
157
  else
151
158
  print_failure_result(host, result, formatter)
@@ -221,19 +228,31 @@ module Kdeploy
221
228
  end
222
229
 
223
230
  def determine_tasks(task_name)
224
- task_name ? [task_name.to_sym] : self.class.kdeploy_tasks.keys
231
+ if task_name
232
+ sym = task_name.to_sym
233
+ raise TaskNotFoundError, task_name unless self.class.kdeploy_tasks.key?(sym)
234
+
235
+ [sym]
236
+ else
237
+ self.class.kdeploy_tasks.keys
238
+ end
225
239
  end
226
240
 
227
241
  def execute_tasks(tasks_to_run)
228
242
  all_results = {}
243
+ @task_errors = false
244
+
245
+ if tasks_to_run.empty?
246
+ puts Kdeploy::Banner.show_error('No tasks defined in task file')
247
+ @task_errors = true
248
+ return all_results
249
+ end
229
250
 
230
251
  tasks_to_run.each do |task|
231
252
  task_results = execute_single_task(task)
232
- # Collect results for final summary
233
253
  all_results[task] = task_results if task_results
234
254
 
235
- # Stop executing remaining tasks once any host failed for this task.
236
- break if task_failed?(task_results)
255
+ break if task_failed?(task_results) || @task_errors
237
256
  end
238
257
 
239
258
  # Show combined summary at the end for all tasks
@@ -248,6 +267,8 @@ module Kdeploy
248
267
 
249
268
  if hosts.empty?
250
269
  puts Kdeploy::Banner.show_error("No hosts found for task: #{task}")
270
+ puts 'This usually means no hosts matched the task configuration or --limit filter.'
271
+ @task_errors = true
251
272
  return nil
252
273
  end
253
274
 
@@ -272,6 +293,8 @@ module Kdeploy
272
293
  retry_on_nonzero =
273
294
  options[:retry_on_nonzero].nil? ? Configuration.default_retry_on_nonzero : options[:retry_on_nonzero]
274
295
  host_timeout = options[:timeout].nil? ? Configuration.default_host_timeout : options[:timeout]
296
+ step_timeout = options[:step_timeout].nil? ? Configuration.default_step_timeout : options[:step_timeout]
297
+ retry_policy = resolve_retry_policy
275
298
  base_dir = @task_file_dir
276
299
  runner = Runner.new(
277
300
  hosts, self.class.kdeploy_tasks,
@@ -282,7 +305,9 @@ module Kdeploy
282
305
  retries: retries,
283
306
  retry_delay: retry_delay,
284
307
  retry_on_nonzero: retry_on_nonzero,
285
- host_timeout: host_timeout
308
+ host_timeout: host_timeout,
309
+ step_timeout: step_timeout,
310
+ retry_policy: retry_policy
286
311
  )
287
312
  results = runner.run(task)
288
313
  if options[:format] == 'json'
@@ -315,10 +340,9 @@ module Kdeploy
315
340
  all_hosts = {}
316
341
  all_results.each do |task_name, task_results|
317
342
  task_results.each do |host, result|
318
- all_hosts[host] ||= { ok: 0, changed: 0, failed: 0, tasks: [] }
343
+ all_hosts[host] ||= { ok: 0, failed: 0, tasks: [] }
319
344
  counts = formatter.calculate_summary_counts(result)
320
345
  all_hosts[host][:ok] += counts[:ok]
321
- all_hosts[host][:changed] += counts[:changed]
322
346
  all_hosts[host][:failed] += counts[:failed]
323
347
  all_hosts[host][:tasks] << task_name
324
348
  end
@@ -370,15 +394,13 @@ module Kdeploy
370
394
  duration: step[:duration]
371
395
  }
372
396
 
373
- out[:exit_status] = step[:output][:exit_status] if step[:output].is_a?(Hash) && step[:output].key?(:exit_status)
374
-
375
- out[:result] = step[:result] if step[:type] == :sync
376
-
377
- if options[:debug] && step[:type] == :run && step[:output].is_a?(Hash)
397
+ if step[:output].is_a?(Hash)
378
398
  out[:stdout] = step[:output][:stdout]
379
399
  out[:stderr] = step[:output][:stderr]
400
+ out[:exit_status] = step[:output][:exit_status]
380
401
  end
381
402
 
403
+ out[:result] = step[:result] if step[:type] == :sync
382
404
  out
383
405
  end
384
406
 
@@ -397,7 +419,9 @@ module Kdeploy
397
419
  destination: cmd[:destination],
398
420
  ignore: cmd[:ignore] || [],
399
421
  exclude: cmd[:exclude] || [],
400
- delete: cmd[:delete] || false
422
+ delete: cmd[:delete] || false,
423
+ fast: cmd[:fast],
424
+ parallel: cmd[:parallel]
401
425
  }
402
426
  else
403
427
  { type: cmd[:type].to_s }
@@ -409,11 +433,61 @@ module Kdeploy
409
433
 
410
434
  if result[:output].is_a?(Array)
411
435
  result[:output].map do |o|
412
- o[:output][:stderr] if o[:output].is_a?(Hash)
436
+ next unless o.is_a?(Hash)
437
+
438
+ err = o[:output]
439
+ next unless err.is_a?(Hash)
440
+
441
+ pieces = []
442
+ pieces << "command=#{err[:command]}" if err[:command]
443
+ pieces << "exit_status=#{err[:exit_status]}" if err[:exit_status]
444
+ pieces << "stderr=#{err[:stderr]}" if err[:stderr]
445
+ pieces.join(' ')
413
446
  end.compact.join("\n")
414
447
  else
415
448
  result[:output].to_s
416
449
  end
417
450
  end
451
+
452
+ def parse_retry_policy(raw)
453
+ policy = JSON.parse(raw)
454
+ raise ArgumentError, 'retry_policy must be a JSON object' unless policy.is_a?(Hash)
455
+
456
+ policy
457
+ rescue JSON::ParserError => e
458
+ raise ArgumentError, "retry_policy JSON parse error: #{e.message}"
459
+ end
460
+
461
+ def resolve_retry_policy
462
+ if options[:retry_policy_file]
463
+ path = options[:retry_policy_file]
464
+ unless File.exist?(path)
465
+ raise ArgumentError,
466
+ "retry_policy file not found: #{path} (examples: retry_policy.example.json / retry_policy.example.yml)"
467
+ end
468
+
469
+ return parse_retry_policy_file(path)
470
+ end
471
+ return parse_retry_policy(options[:retry_policy]) if options[:retry_policy]
472
+
473
+ Configuration.default_retry_policy
474
+ end
475
+
476
+ def parse_retry_policy_file(path)
477
+ ext = File.extname(path).downcase
478
+ raw = File.read(path)
479
+ policy =
480
+ case ext
481
+ when '.yml', '.yaml'
482
+ YAML.safe_load(raw) || {}
483
+ else
484
+ JSON.parse(raw)
485
+ end
486
+ raise ArgumentError, 'retry_policy must be a JSON/YAML object' unless policy.is_a?(Hash)
487
+
488
+ policy
489
+ rescue JSON::ParserError, Psych::SyntaxError => e
490
+ raise ArgumentError, "retry_policy parse error: #{e.message}"
491
+ end
418
492
  end
419
493
  end
@@ -36,6 +36,9 @@ module Kdeploy
36
36
  #{@pastel.dim(' --retry-delay SECONDS')} Retry delay seconds (default: 1; overridden by .kdeploy.yml)
37
37
  #{@pastel.dim(' --retry-on-nonzero')} Retry commands on nonzero exit status (default: false; overridden by .kdeploy.yml)
38
38
  #{@pastel.dim(' --timeout SECONDS')} Per-host execution timeout seconds (default: none; overridden by .kdeploy.yml)
39
+ #{@pastel.dim(' --step-timeout SECONDS')} Per-step execution timeout seconds (default: none; overridden by .kdeploy.yml)
40
+ #{@pastel.dim(' --retry-policy JSON')} Retry policy JSON (overrides .kdeploy.yml)
41
+ #{@pastel.dim(' --retry-policy-file PATH')} Retry policy JSON file (overrides .kdeploy.yml)
39
42
 
40
43
  #{@pastel.bright_yellow('🆕')} #{@pastel.bright_white('init [DIR]')} Initialize new deployment project
41
44
  #{@pastel.bright_yellow('ℹ️')} #{@pastel.bright_white('version')} Show version information
@@ -12,6 +12,10 @@ module Kdeploy
12
12
  DEFAULT_RETRY_DELAY = 1
13
13
  DEFAULT_HOST_TIMEOUT = nil
14
14
  DEFAULT_RETRY_ON_NONZERO = false
15
+ DEFAULT_SYNC_FAST = false
16
+ DEFAULT_STEP_TIMEOUT = nil
17
+ DEFAULT_RETRY_POLICY = nil
18
+ DEFAULT_SYNC_PARALLEL = 1
15
19
  CONFIG_FILE_NAME = '.kdeploy.yml'
16
20
 
17
21
  class << self
@@ -21,7 +25,11 @@ module Kdeploy
21
25
  :default_retries,
22
26
  :default_retry_delay,
23
27
  :default_host_timeout,
24
- :default_retry_on_nonzero
28
+ :default_retry_on_nonzero,
29
+ :default_sync_fast,
30
+ :default_step_timeout,
31
+ :default_retry_policy,
32
+ :default_sync_parallel
25
33
 
26
34
  def reset
27
35
  @default_parallel = DEFAULT_PARALLEL
@@ -31,6 +39,10 @@ module Kdeploy
31
39
  @default_retry_delay = DEFAULT_RETRY_DELAY
32
40
  @default_host_timeout = DEFAULT_HOST_TIMEOUT
33
41
  @default_retry_on_nonzero = DEFAULT_RETRY_ON_NONZERO
42
+ @default_sync_fast = DEFAULT_SYNC_FAST
43
+ @default_step_timeout = DEFAULT_STEP_TIMEOUT
44
+ @default_retry_policy = DEFAULT_RETRY_POLICY
45
+ @default_sync_parallel = DEFAULT_SYNC_PARALLEL
34
46
  end
35
47
 
36
48
  def load_from_file(config_path = nil)
@@ -44,6 +56,22 @@ module Kdeploy
44
56
  nil
45
57
  end
46
58
 
59
+ # Load config for execute: CWD walk-up, then task file dir (overrides), then env vars.
60
+ def load_for_execute(task_dir: nil, cwd: Dir.pwd)
61
+ reset
62
+ load_from_file(find_config_file(cwd))
63
+ load_from_file(find_config_file(task_dir)) if task_dir
64
+ apply_env_overrides
65
+ end
66
+
67
+ def apply_env_overrides
68
+ parallel = positive_int_env('KDEPLOY_PARALLEL')
69
+ @default_parallel = parallel if parallel
70
+
71
+ ssh_timeout = positive_int_env('KDEPLOY_SSH_TIMEOUT')
72
+ @default_ssh_timeout = ssh_timeout if ssh_timeout
73
+ end
74
+
47
75
  def find_config_file(start_dir = Dir.pwd)
48
76
  current_dir = File.expand_path(start_dir)
49
77
 
@@ -72,6 +100,18 @@ module Kdeploy
72
100
  @default_retry_delay = config['retry_delay'] if config.key?('retry_delay')
73
101
  @default_host_timeout = config['host_timeout'] if config.key?('host_timeout')
74
102
  @default_retry_on_nonzero = config['retry_on_nonzero'] if config.key?('retry_on_nonzero')
103
+ @default_sync_fast = config['sync_fast'] if config.key?('sync_fast')
104
+ @default_step_timeout = config['step_timeout'] if config.key?('step_timeout')
105
+ @default_retry_policy = config['retry_policy'] if config.key?('retry_policy')
106
+ @default_sync_parallel = config['sync_parallel'] if config.key?('sync_parallel')
107
+ end
108
+
109
+ def positive_int_env(key)
110
+ raw = ENV.fetch(key, nil)
111
+ return nil if raw.nil? || raw.to_s.strip.empty?
112
+
113
+ value = raw.to_i
114
+ value.positive? ? value : nil
75
115
  end
76
116
 
77
117
  def parse_verify_host_key(value)
@@ -161,7 +161,7 @@ module Kdeploy
161
161
  }
162
162
  end
163
163
 
164
- def sync(source, destination, ignore: [], delete: false, exclude: [])
164
+ def sync(source, destination, ignore: [], delete: false, exclude: [], fast: nil, parallel: nil)
165
165
  @kdeploy_commands ||= []
166
166
  @kdeploy_commands << {
167
167
  type: :sync,
@@ -169,7 +169,9 @@ module Kdeploy
169
169
  destination: destination,
170
170
  ignore: Array(ignore),
171
171
  exclude: Array(exclude),
172
- delete: delete
172
+ delete: delete,
173
+ fast: fast,
174
+ parallel: parallel
173
175
  }
174
176
  end
175
177
 
@@ -177,14 +179,14 @@ module Kdeploy
177
179
  # Chef-style resource DSL (compiles to run/upload/upload_template)
178
180
  # -------------------------------------------------------------------------
179
181
 
180
- # 安装系统包。默认 apt 平台;支持 platform: :yum
182
+ # Install a system package. Defaults to apt; supports platform: :yum.
181
183
  def package(name, version: nil, platform: :apt)
182
184
  @kdeploy_commands ||= []
183
185
  cmd = build_package_command(name, version, platform)
184
186
  @kdeploy_commands << { type: :run, command: cmd, sudo: true }
185
187
  end
186
188
 
187
- # 管理系统服务(systemd)。action 支持 :start, :stop, :restart, :reload, :enable, :disable
189
+ # Manage a systemd service. action supports :start, :stop, :restart, :reload, :enable, :disable.
188
190
  def service(name, action: :start)
189
191
  @kdeploy_commands ||= []
190
192
  actions = Array(action)
@@ -194,7 +196,7 @@ module Kdeploy
194
196
  end
195
197
  end
196
198
 
197
- # 部署 ERB 模板到远程路径。支持 block 或关键字参数。
199
+ # Deploy an ERB template to a remote path. Supports block and keyword args.
198
200
  def template(destination, source: nil, variables: nil, &block)
199
201
  @kdeploy_commands ||= []
200
202
  if block
@@ -211,13 +213,13 @@ module Kdeploy
211
213
  upload_template(src, destination, vars)
212
214
  end
213
215
 
214
- # 上传本地文件到远程路径。
216
+ # Upload a local file to a remote path.
215
217
  def file(destination, source:)
216
218
  @kdeploy_commands ||= []
217
219
  upload(source, destination)
218
220
  end
219
221
 
220
- # 确保远程目录存在。支持 mode 参数。
222
+ # Ensure a remote directory exists. Supports mode option.
221
223
  def directory(path, mode: nil)
222
224
  @kdeploy_commands ||= []
223
225
  cmd = "mkdir -p #{Shellwords.escape(path.to_s)}"
@@ -96,11 +96,11 @@ module Kdeploy
96
96
  end
97
97
 
98
98
  def create_config_files
99
- # 创建配置目录
99
+ # Create config directory
100
100
  config_dir = File.join(@target_dir, 'config')
101
101
  FileUtils.mkdir_p(config_dir)
102
102
 
103
- # 创建 Nginx ERB 模板
103
+ # Create Nginx ERB template
104
104
  File.write(File.join(config_dir, 'nginx.conf.erb'), <<~CONF)
105
105
  user nginx;
106
106
  worker_processes <%= worker_processes %>;
@@ -152,7 +152,7 @@ module Kdeploy
152
152
  }
153
153
  CONF
154
154
 
155
- # 创建静态配置文件示例
155
+ # Create static config file example
156
156
  File.write(File.join(config_dir, 'app.conf'), <<~CONF)
157
157
  location /api {
158
158
  proxy_pass http://localhost:3000;
@@ -52,6 +52,13 @@ module Kdeploy
52
52
  attr_reader :original_error
53
53
  end
54
54
 
55
+ # Raised when a step exceeds configured timeout
56
+ class StepTimeoutError < Error
57
+ def initialize(message)
58
+ super("Step timeout: #{message}")
59
+ end
60
+ end
61
+
55
62
  # Raised when configuration is invalid
56
63
  class ConfigurationError < Error
57
64
  def initialize(message)
@@ -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