kdeploy 1.2.29 → 1.2.33

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: acbdbfe3b19c57b25f4de9b50dfe12f2bb63866bcd56fc81ef6e1906f136f75c
4
- data.tar.gz: d6f9f8efc10329a9ff2f1ea9d7828a9d1000d53cc1b7413d776471a75ef42b82
3
+ metadata.gz: ba776f38e5a34ec7e6dac606aac7202391fdab2e4869aec08f526b3228bc8deb
4
+ data.tar.gz: aaa348b87ad83b9c8c1ad3291a2e45395594b922c1594fa82d54cd2a9b7c6d30
5
5
  SHA512:
6
- metadata.gz: 869e6f7e77bbda0e645384063eac441ee81ed4629f5bb1102240b455eda6bde5a3fb4e6b2afdcb249dfc2cb78819fec490ab12ca75e694ffabcfdfc05d9ade9e
7
- data.tar.gz: 8a2c3c6c1ad3f8b9e2287c6d1cc942ef858b346c7652f8d475d74304ed62b81a4dde0f251af466339c10ea8a1decc2e69b19a80e4d9e98a37623d940b8bc74e3
6
+ metadata.gz: 7d731fd05fddc4b03c0aaef558e5200b6584772159262405d80cf807eae5f096689201e60aad944f0dd51b2a5e0db7d1d883476ec945cd3984a19383c840830c
7
+ data.tar.gz: 6ce986d5b3a60685ed51340298a0d0fbe22d25c2ee7c7cf584b3870d23174d004597e5135c7bb52a2f2f1d1006806c552d49ddcdb92080808348a580ec865947
data/AGENTS.md ADDED
@@ -0,0 +1,18 @@
1
+ <!-- OPENSPEC:START -->
2
+ # OpenSpec Instructions
3
+
4
+ These instructions are for AI assistants working in this project.
5
+
6
+ Always open `@/openspec/AGENTS.md` when the request:
7
+ - Mentions planning or proposals (words like proposal, spec, change, plan)
8
+ - Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work
9
+ - Sounds ambiguous and you need the authoritative spec before coding
10
+
11
+ Use `@/openspec/AGENTS.md` to learn:
12
+ - How to create and apply change proposals
13
+ - Spec format and conventions
14
+ - Project structure and guidelines
15
+
16
+ Keep this managed block so 'openspec update' can refresh the instructions.
17
+
18
+ <!-- OPENSPEC:END -->
data/README.md CHANGED
@@ -201,6 +201,11 @@ kdeploy execute deploy.rb deploy_web
201
201
  - `--limit HOSTS`: 限制执行到特定主机(逗号分隔)
202
202
  - `--parallel NUM`: 并行执行数量(默认: 10)
203
203
  - `--dry-run`: 预览模式 - 显示将要执行的操作而不实际执行
204
+ - `--debug`: 调试模式 - 显示 `run` 命令的 stdout/stderr 详细输出(便于排查问题)
205
+ - `--no-banner`: 不输出 Banner(更适合脚本/CI 场景)
206
+ - `--format FORMAT`: 输出格式(`text`|`json`,默认 `text`)
207
+ - `--retries N`: 网络相关操作重试次数(默认 `0`)
208
+ - `--retry-delay SECONDS`: 每次重试间隔秒数(默认 `1`)
204
209
 
205
210
  **示例:**
206
211
  ```bash
@@ -213,6 +218,15 @@ kdeploy execute deploy.rb deploy_web --limit web01,web02
213
218
  # 使用自定义并行数量
214
219
  kdeploy execute deploy.rb deploy_web --parallel 5
215
220
 
221
+ # 输出详细调试信息(stdout/stderr)
222
+ kdeploy execute deploy.rb deploy_web --debug
223
+
224
+ # 机器可读 JSON 输出(便于集成)
225
+ kdeploy execute deploy.rb deploy_web --format json --no-banner
226
+
227
+ # 重试网络抖动导致的失败
228
+ kdeploy execute deploy.rb deploy_web --retries 3 --retry-delay 1
229
+
216
230
  # 组合选项
217
231
  kdeploy execute deploy.rb deploy_web --limit web01 --parallel 3 --dry-run
218
232
  ```
data/README_EN.md CHANGED
@@ -200,6 +200,11 @@ kdeploy execute deploy.rb deploy_web
200
200
  - `--limit HOSTS`: Limit execution to specific hosts (comma-separated)
201
201
  - `--parallel NUM`: Number of parallel executions (default: 10)
202
202
  - `--dry-run`: Preview mode - show what would be done without executing
203
+ - `--debug`: Debug mode - show detailed stdout/stderr output for `run` steps
204
+ - `--no-banner`: Do not print banner (automation-friendly)
205
+ - `--format FORMAT`: Output format (`text`|`json`, default `text`)
206
+ - `--retries N`: Retry count for network operations (default `0`)
207
+ - `--retry-delay SECONDS`: Delay between retries in seconds (default `1`)
203
208
 
204
209
  **Examples:**
205
210
  ```bash
@@ -212,6 +217,15 @@ kdeploy execute deploy.rb deploy_web --limit web01,web02
212
217
  # Use custom parallel count
213
218
  kdeploy execute deploy.rb deploy_web --parallel 5
214
219
 
220
+ # Show detailed stdout/stderr output
221
+ kdeploy execute deploy.rb deploy_web --debug
222
+
223
+ # Machine-readable JSON output
224
+ kdeploy execute deploy.rb deploy_web --format json --no-banner
225
+
226
+ # Retry transient network failures
227
+ kdeploy execute deploy.rb deploy_web --retries 3 --retry-delay 1
228
+
215
229
  # Combine options
216
230
  kdeploy execute deploy.rb deploy_web --limit web01 --parallel 3 --dry-run
217
231
  ```
data/exe/kdeploy CHANGED
@@ -1,19 +1,5 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
- # 自动修复常见 gem 扩展
5
- # %w[debug rbs].zip(%w[1.7.1 2.8.2]).each do |gem_name, version|
6
- # require gem_name
7
- # rescue LoadError
8
- # warn "[Kdeploy] 自动修复 #{gem_name}-#{version} ..."
9
- # system("gem pristine #{gem_name} --version #{version}")
10
- # begin
11
- # require gem_name
12
- # rescue LoadError
13
- # warn "[Kdeploy] 依然无法加载 #{gem_name}-#{version},请手动修复。"
14
- # end
15
- # end
16
-
17
4
  require 'kdeploy'
18
-
19
5
  Kdeploy::CLI.start(ARGV)
data/lib/kdeploy/cli.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'thor'
4
+ require 'json'
4
5
  require 'pastel'
5
6
  require 'tty-table'
6
7
  require 'tty-box'
@@ -43,9 +44,13 @@ module Kdeploy
43
44
 
44
45
  desc 'execute TASK_FILE [TASK]', 'Execute deployment tasks from file'
45
46
  method_option :limit, type: :string, desc: 'Limit to specific hosts (comma-separated)'
46
- method_option :parallel, type: :numeric, default: 10, desc: 'Number of parallel executions'
47
+ method_option :parallel, type: :numeric, desc: 'Number of parallel executions'
47
48
  method_option :dry_run, type: :boolean, desc: 'Show what would be done'
48
49
  method_option :debug, type: :boolean, desc: 'Show detailed command output (stdout/stderr)'
50
+ method_option :no_banner, type: :boolean, desc: 'Do not print banner'
51
+ method_option :format, type: :string, default: 'text', desc: 'Output format (text|json)'
52
+ method_option :retries, type: :numeric, desc: 'Retry count for network operations (default: 0)'
53
+ method_option :retry_delay, type: :numeric, desc: 'Retry delay seconds (default: 1)'
49
54
  def execute(task_file, task_name = nil)
50
55
  load_config_file
51
56
  show_banner_once
@@ -53,7 +58,10 @@ module Kdeploy
53
58
  load_task_file(task_file)
54
59
 
55
60
  tasks_to_run = determine_tasks(task_name)
56
- execute_tasks(tasks_to_run)
61
+ all_results = execute_tasks(tasks_to_run)
62
+
63
+ # Exit non-zero if any executed host failed.
64
+ exit 1 if any_failed?(all_results)
57
65
  rescue StandardError => e
58
66
  puts Kdeploy::Banner.show_error(e.message)
59
67
  exit 1
@@ -189,6 +197,7 @@ module Kdeploy
189
197
  def show_banner_once
190
198
  @banner_printed ||= false
191
199
  return if @banner_printed
200
+ return if options[:no_banner]
192
201
 
193
202
  puts Kdeploy::Banner.show
194
203
  @banner_printed = true
@@ -208,7 +217,9 @@ module Kdeploy
208
217
  end
209
218
 
210
219
  # Show combined summary at the end for all tasks
211
- print_all_tasks_summary(all_results) unless all_results.empty?
220
+ print_all_tasks_summary(all_results) unless all_results.empty? || options[:format] == 'json'
221
+
222
+ all_results
212
223
  end
213
224
 
214
225
  def execute_single_task(task)
@@ -221,7 +232,11 @@ module Kdeploy
221
232
  end
222
233
 
223
234
  if options[:dry_run]
224
- print_dry_run(hosts, task)
235
+ if options[:format] == 'json'
236
+ print_dry_run_json(hosts, task)
237
+ else
238
+ print_dry_run(hosts, task)
239
+ end
225
240
  return nil
226
241
  end
227
242
 
@@ -232,20 +247,34 @@ module Kdeploy
232
247
  output = ConsoleOutput.new
233
248
  parallel_count = options[:parallel] || Configuration.default_parallel
234
249
  debug_mode = options[:debug] || false
250
+ retries = options[:retries].nil? ? Configuration.default_retries : options[:retries]
251
+ retry_delay = options[:retry_delay].nil? ? Configuration.default_retry_delay : options[:retry_delay]
235
252
  base_dir = @task_file_dir
236
253
  runner = Runner.new(
237
254
  hosts, self.class.kdeploy_tasks,
238
255
  parallel: parallel_count,
239
256
  output: output,
240
257
  debug: debug_mode,
241
- base_dir: base_dir
258
+ base_dir: base_dir,
259
+ retries: retries,
260
+ retry_delay: retry_delay
242
261
  )
243
262
  results = runner.run(task)
244
- # Don't show summary here - it will be shown at the end for all tasks
245
- print_results(results, task, show_summary: false, debug: debug_mode)
263
+ if options[:format] == 'json'
264
+ print_results_json(task, results)
265
+ else
266
+ # Don't show summary here - it will be shown at the end for all tasks
267
+ print_results(results, task, show_summary: false, debug: debug_mode)
268
+ end
246
269
  results
247
270
  end
248
271
 
272
+ def any_failed?(all_results)
273
+ all_results.values.any? do |task_results|
274
+ task_results.values.any? { |result| result[:status] == :failed }
275
+ end
276
+ end
277
+
249
278
  def print_all_tasks_summary(all_results)
250
279
  debug_mode = options[:debug] || false
251
280
  formatter = OutputFormatter.new(debug: debug_mode)
@@ -272,6 +301,76 @@ module Kdeploy
272
301
  end
273
302
  end
274
303
 
304
+ def print_results_json(task_name, results)
305
+ payload = {
306
+ task: task_name.to_s,
307
+ results: results.transform_values { |r| serialize_host_result(r) }
308
+ }
309
+ puts JSON.generate(payload)
310
+ end
311
+
312
+ def print_dry_run_json(hosts, task_name)
313
+ tasks = self.class.kdeploy_tasks
314
+ commands = tasks[task_name][:block].call
315
+
316
+ payload = {
317
+ task: task_name.to_s,
318
+ dry_run: true,
319
+ planned: hosts.transform_values do |_config|
320
+ commands.map { |cmd| serialize_planned_step(cmd) }
321
+ end
322
+ }
323
+
324
+ puts JSON.generate(payload)
325
+ end
326
+
327
+ def serialize_host_result(result)
328
+ {
329
+ status: result[:status].to_s,
330
+ error: result[:error],
331
+ steps: Array(result[:output]).map { |step| serialize_step(step) }
332
+ }
333
+ end
334
+
335
+ def serialize_step(step)
336
+ out = {
337
+ type: step[:type].to_s,
338
+ command: step[:command],
339
+ duration: step[:duration]
340
+ }
341
+
342
+ out[:result] = step[:result] if step[:type] == :sync
343
+
344
+ if options[:debug] && step[:type] == :run && step[:output].is_a?(Hash)
345
+ out[:stdout] = step[:output][:stdout]
346
+ out[:stderr] = step[:output][:stderr]
347
+ end
348
+
349
+ out
350
+ end
351
+
352
+ def serialize_planned_step(cmd)
353
+ case cmd[:type]
354
+ when :run
355
+ { type: 'run', command: cmd[:command], sudo: cmd[:sudo] }
356
+ when :upload
357
+ { type: 'upload', source: cmd[:source], destination: cmd[:destination] }
358
+ when :upload_template
359
+ { type: 'upload_template', source: cmd[:source], destination: cmd[:destination], variables: cmd[:variables] }
360
+ when :sync
361
+ {
362
+ type: 'sync',
363
+ source: cmd[:source],
364
+ destination: cmd[:destination],
365
+ ignore: cmd[:ignore] || [],
366
+ exclude: cmd[:exclude] || [],
367
+ delete: cmd[:delete] || false
368
+ }
369
+ else
370
+ { type: cmd[:type].to_s }
371
+ end
372
+ end
373
+
275
374
  def extract_error_message(result)
276
375
  return result[:error] if result[:error]
277
376
 
@@ -3,10 +3,12 @@
3
3
  module Kdeploy
4
4
  # Executes a single command and records execution time
5
5
  class CommandExecutor
6
- def initialize(executor, output, debug: false)
6
+ def initialize(executor, output, debug: false, retries: 0, retry_delay: 1)
7
7
  @executor = executor
8
8
  @output = output
9
9
  @debug = debug
10
+ @retries = retries.to_i
11
+ @retry_delay = retry_delay.to_f
10
12
  end
11
13
 
12
14
  def execute_run(command, host_name)
@@ -18,21 +20,19 @@ module Kdeploy
18
20
  pastel = @output.respond_to?(:pastel) ? @output.pastel : Pastel.new
19
21
 
20
22
  result, duration = measure_time do
21
- @executor.execute(cmd, use_sudo: use_sudo)
23
+ with_retries { @executor.execute(cmd, use_sudo: use_sudo) }
22
24
  end
23
25
 
24
26
  # Show execution time if command took more than 1 second
25
27
  @output.write_line(pastel.dim(" [completed in #{format('%.2f', duration)}s]")) if duration > 1.0
26
28
 
27
- # Show command output only in debug mode
28
- show_command_output(result) if @debug
29
29
  { command: cmd, output: result, duration: duration, type: :run }
30
30
  end
31
31
 
32
32
  def execute_upload(command, host_name)
33
33
  show_command_header(host_name, :upload, "#{command[:source]} -> #{command[:destination]}")
34
34
  _result, duration = measure_time do
35
- @executor.upload(command[:source], command[:destination])
35
+ with_retries { @executor.upload(command[:source], command[:destination]) }
36
36
  end
37
37
  {
38
38
  command: "upload: #{command[:source]} -> #{command[:destination]}",
@@ -44,7 +44,9 @@ module Kdeploy
44
44
  def execute_upload_template(command, host_name)
45
45
  show_command_header(host_name, :upload_template, "#{command[:source]} -> #{command[:destination]}")
46
46
  _result, duration = measure_time do
47
- @executor.upload_template(command[:source], command[:destination], command[:variables])
47
+ with_retries do
48
+ @executor.upload_template(command[:source], command[:destination], command[:variables])
49
+ end
48
50
  end
49
51
  {
50
52
  command: "upload_template: #{command[:source]} -> #{command[:destination]}",
@@ -60,13 +62,15 @@ module Kdeploy
60
62
  show_command_header(host_name, :sync, description)
61
63
 
62
64
  result, duration = measure_time do
63
- @executor.sync_directory(
64
- source,
65
- destination,
66
- ignore: command[:ignore] || [],
67
- exclude: command[:exclude] || [],
68
- delete: command[:delete] || false
69
- )
65
+ with_retries do
66
+ @executor.sync_directory(
67
+ source,
68
+ destination,
69
+ ignore: command[:ignore] || [],
70
+ exclude: command[:exclude] || [],
71
+ delete: command[:delete] || false
72
+ )
73
+ end
70
74
  end
71
75
 
72
76
  build_sync_result(source, destination, result, duration)
@@ -99,27 +103,16 @@ module Kdeploy
99
103
  [result, duration]
100
104
  end
101
105
 
102
- def show_command_output(output)
103
- return unless output.is_a?(Hash)
106
+ def with_retries
107
+ attempts = 0
108
+ begin
109
+ attempts += 1
110
+ yield
111
+ rescue SSHError, SCPError, TemplateError
112
+ raise if attempts > (@retries + 1)
104
113
 
105
- pastel = @output.respond_to?(:pastel) ? @output.pastel : Pastel.new
106
- show_stdout(output[:stdout])
107
- show_stderr(output[:stderr], pastel)
108
- end
109
-
110
- def show_stdout(stdout)
111
- return unless stdout && !stdout.empty?
112
-
113
- stdout.each_line do |line|
114
- @output.write_line(" #{line.rstrip}") unless line.strip.empty?
115
- end
116
- end
117
-
118
- def show_stderr(stderr, pastel)
119
- return unless stderr && !stderr.empty?
120
-
121
- stderr.each_line do |line|
122
- @output.write_line(pastel.green(" #{line.rstrip}")) unless line.strip.empty?
114
+ sleep(@retry_delay) if @retry_delay.positive?
115
+ retry
123
116
  end
124
117
  end
125
118
 
@@ -8,17 +8,23 @@ module Kdeploy
8
8
  DEFAULT_PARALLEL = 10
9
9
  DEFAULT_SSH_TIMEOUT = 30
10
10
  DEFAULT_VERIFY_HOST_KEY = :never
11
+ DEFAULT_RETRIES = 0
12
+ DEFAULT_RETRY_DELAY = 1
11
13
  CONFIG_FILE_NAME = '.kdeploy.yml'
12
14
 
13
15
  class << self
14
16
  attr_accessor :default_parallel,
15
17
  :default_ssh_timeout,
16
- :default_verify_host_key
18
+ :default_verify_host_key,
19
+ :default_retries,
20
+ :default_retry_delay
17
21
 
18
22
  def reset
19
23
  @default_parallel = DEFAULT_PARALLEL
20
24
  @default_ssh_timeout = DEFAULT_SSH_TIMEOUT
21
25
  @default_verify_host_key = DEFAULT_VERIFY_HOST_KEY
26
+ @default_retries = DEFAULT_RETRIES
27
+ @default_retry_delay = DEFAULT_RETRY_DELAY
22
28
  end
23
29
 
24
30
  def load_from_file(config_path = nil)
@@ -56,6 +62,8 @@ module Kdeploy
56
62
  @default_parallel = config['parallel'] if config.key?('parallel')
57
63
  @default_ssh_timeout = config['ssh_timeout'] if config.key?('ssh_timeout')
58
64
  @default_verify_host_key = parse_verify_host_key(config['verify_host_key']) if config.key?('verify_host_key')
65
+ @default_retries = config['retries'] if config.key?('retries')
66
+ @default_retry_delay = config['retry_delay'] if config.key?('retry_delay')
59
67
  end
60
68
 
61
69
  def parse_verify_host_key(value)
data/lib/kdeploy/dsl.rb CHANGED
@@ -5,12 +5,33 @@ require 'set'
5
5
  module Kdeploy
6
6
  # Domain-specific language for defining hosts, roles, and tasks
7
7
  module DSL
8
+ def self.included(base)
9
+ # Support `include Kdeploy::DSL` by promoting the DSL methods to class methods.
10
+ # This keeps tests and external integrations simpler while preserving the
11
+ # primary usage pattern (CLI uses `extend DSL`).
12
+ base.extend(self)
13
+ end
14
+
8
15
  def self.extended(base)
9
16
  base.instance_variable_set(:@kdeploy_hosts, {})
10
17
  base.instance_variable_set(:@kdeploy_tasks, {})
11
18
  base.instance_variable_set(:@kdeploy_roles, {})
12
19
  end
13
20
 
21
+ # Stable read accessors for tests/integrations.
22
+ # Keep these as aliases so internal storage can evolve without breaking callers.
23
+ def hosts
24
+ kdeploy_hosts
25
+ end
26
+
27
+ def tasks
28
+ kdeploy_tasks
29
+ end
30
+
31
+ def roles
32
+ kdeploy_roles
33
+ end
34
+
14
35
  def kdeploy_hosts
15
36
  @kdeploy_hosts ||= {}
16
37
  end
@@ -47,8 +47,14 @@ module Kdeploy
47
47
  .gsub('[!', '[^') # [^...] negation
48
48
  .gsub('[', '[') # Character class
49
49
 
50
- # Anchor to start if pattern doesn't start with **
51
- regex_str = "^#{regex_str}" unless pattern.start_with?('**')
50
+ # gitignore semantics: patterns without a slash match in any directory.
51
+ # e.g. "*.log" should match "a.log" and "logs/a.log".
52
+ if !pattern.include?('/') && !pattern.start_with?('**')
53
+ regex_str = "(?:^|.*/)#{regex_str}"
54
+ elsif !pattern.start_with?('**')
55
+ regex_str = "^#{regex_str}"
56
+ end
57
+
52
58
  # Match end of string or directory separator
53
59
  regex_str = "#{regex_str}(/|$)" unless pattern.end_with?('*') || pattern.end_with?('**')
54
60
 
@@ -64,8 +70,13 @@ module Kdeploy
64
70
  def relative_path_for(file_path, base_path)
65
71
  return file_path.to_s unless base_path
66
72
 
73
+ file = Pathname.new(file_path.to_s)
74
+
75
+ # If caller already passed a relative path (common in sync traversal),
76
+ # keep it as-is to avoid Pathname#relative_path_from prefix errors.
77
+ return file_path.to_s unless file.absolute?
78
+
67
79
  base = Pathname.new(base_path)
68
- file = Pathname.new(file_path)
69
80
  file.relative_path_from(base).to_s
70
81
  end
71
82
  end
@@ -27,8 +27,13 @@ module Kdeploy
27
27
  <<~COMMANDS
28
28
  #{@pastel.bright_yellow('🚀')} #{@pastel.bright_white('execute TASK_FILE [TASK]')} Execute deployment tasks from file
29
29
  #{@pastel.dim(' --limit HOSTS')} Limit to specific hosts (comma-separated)
30
- #{@pastel.dim(' --parallel NUM')} Number of parallel executions (default: 5)
30
+ #{@pastel.dim(' --parallel NUM')} Number of parallel executions (default: 10; overridden by .kdeploy.yml)
31
31
  #{@pastel.dim(' --dry-run')} Show what would be done without executing
32
+ #{@pastel.dim(' --debug')} Show detailed command output (stdout/stderr)
33
+ #{@pastel.dim(' --no-banner')} Do not print banner (automation-friendly)
34
+ #{@pastel.dim(' --format FORMAT')} Output format (text|json)
35
+ #{@pastel.dim(' --retries N')} Retry count for network operations (default: 0; overridden by .kdeploy.yml)
36
+ #{@pastel.dim(' --retry-delay SECONDS')} Retry delay seconds (default: 1; overridden by .kdeploy.yml)
32
37
 
33
38
  #{@pastel.bright_yellow('🆕')} #{@pastel.bright_white('init [DIR]')} Initialize new deployment project
34
39
  #{@pastel.bright_yellow('ℹ️')} #{@pastel.bright_white('version')} Show version information
@@ -54,6 +59,12 @@ module Kdeploy
54
59
 
55
60
  #{@pastel.dim('# Preview deployment')}
56
61
  #{@pastel.bright_cyan('kdeploy execute deploy.rb deploy_web --dry-run')}
62
+
63
+ #{@pastel.dim('# Machine-readable output')}
64
+ #{@pastel.bright_cyan('kdeploy execute deploy.rb deploy_web --format json --no-banner')}
65
+
66
+ #{@pastel.dim('# Retry transient network failures')}
67
+ #{@pastel.bright_cyan('kdeploy execute deploy.rb deploy_web --retries 3 --retry-delay 1')}
57
68
  EXAMPLES
58
69
  end
59
70
 
@@ -6,21 +6,23 @@ module Kdeploy
6
6
  # Concurrent task runner for executing tasks across multiple hosts
7
7
  class Runner
8
8
  def initialize(hosts, tasks, parallel: Configuration.default_parallel, output: ConsoleOutput.new,
9
- debug: false, base_dir: nil)
9
+ debug: false, base_dir: nil, retries: Configuration.default_retries,
10
+ retry_delay: Configuration.default_retry_delay)
10
11
  @hosts = hosts
11
12
  @tasks = tasks
12
13
  @parallel = parallel
13
14
  @output = output
14
15
  @debug = debug
15
16
  @base_dir = base_dir
17
+ @retries = retries
18
+ @retry_delay = retry_delay
16
19
  @pool = Concurrent::FixedThreadPool.new(@parallel)
17
20
  @results = Concurrent::Hash.new
18
21
  end
19
22
 
20
23
  def run(task_name)
21
24
  task = find_task(task_name)
22
- results = execute_concurrent_tasks(task)
23
- results
25
+ execute_concurrent_tasks(task, task_name)
24
26
  ensure
25
27
  @pool.shutdown
26
28
  end
@@ -33,8 +35,8 @@ module Kdeploy
33
35
  task
34
36
  end
35
37
 
36
- def execute_concurrent_tasks(task)
37
- futures = create_task_futures(task)
38
+ def execute_concurrent_tasks(task, task_name)
39
+ futures = create_task_futures(task, task_name)
38
40
 
39
41
  # If no hosts, return empty results immediately
40
42
  return @results if futures.empty?
@@ -97,47 +99,53 @@ module Kdeploy
97
99
  @results
98
100
  end
99
101
 
100
- def create_task_futures(task)
102
+ def create_task_futures(task, task_name)
101
103
  # Store host names in order to match with futures
102
104
  @host_names = @hosts.keys
103
105
  @hosts.map do |name, config|
104
106
  Concurrent::Future.execute(executor: @pool) do
105
- execute_task_for_host(name, config, task)
107
+ execute_task_for_host(name, config, task, task_name)
106
108
  end
107
109
  end
108
110
  end
109
111
 
110
112
  private
111
113
 
112
- def execute_task_for_host(name, config, task)
114
+ def execute_task_for_host(name, config, task, task_name)
113
115
  # Add base_dir to config for path resolution
114
116
  config_with_base_dir = config.merge(base_dir: @base_dir)
115
117
  executor = Executor.new(config_with_base_dir)
116
- command_executor = CommandExecutor.new(executor, @output, debug: @debug)
118
+ command_executor = CommandExecutor.new(
119
+ executor,
120
+ @output,
121
+ debug: @debug,
122
+ retries: @retries,
123
+ retry_delay: @retry_delay
124
+ )
117
125
  result = { status: :success, output: [] }
118
126
 
119
127
  begin
120
- execute_grouped_commands(task, command_executor, name, result)
128
+ execute_grouped_commands(task, command_executor, name, result, task_name)
121
129
  rescue StandardError => e
122
130
  # Ensure result is always set, even on error
123
131
  # Don't re-raise, as it would cause future.value to fail
124
- result = { status: :failed, error: e.message, output: [] }
132
+ result = { status: :failed, error: "#{e.class}: #{e.message}", output: [] }
125
133
  end
126
134
 
127
135
  # Return the result so it can be collected from the future
128
136
  [name, result]
129
137
  end
130
138
 
131
- def execute_grouped_commands(task, command_executor, name, result)
139
+ def execute_grouped_commands(task, command_executor, name, result, task_name)
132
140
  commands = task[:block].call
133
141
  grouped_commands = CommandGrouper.group(commands)
134
142
 
135
143
  grouped_commands.each_value do |command_group|
136
- execute_command_group(command_group, command_executor, name, result)
144
+ execute_command_group(command_group, command_executor, name, result, task_name)
137
145
  end
138
146
  end
139
147
 
140
- def execute_command_group(command_group, command_executor, name, result)
148
+ def execute_command_group(command_group, command_executor, name, result, task_name)
141
149
  first_cmd = command_group.first
142
150
  task_desc = CommandGrouper.task_description(first_cmd)
143
151
  show_task_header(task_desc)
@@ -149,7 +157,7 @@ module Kdeploy
149
157
  @output.write_line(pastel.dim(" [Step #{index + 1}/#{command_group.length}]"))
150
158
  end
151
159
 
152
- step_result = execute_command(command_executor, command, name)
160
+ step_result = execute_command_with_context(command_executor, command, name, task_name)
153
161
  result[:output] << step_result
154
162
  end
155
163
  end
@@ -169,6 +177,29 @@ module Kdeploy
169
177
  end
170
178
  end
171
179
 
180
+ def execute_command_with_context(command_executor, command, host_name, task_name)
181
+ execute_command(command_executor, command, host_name)
182
+ rescue StandardError => e
183
+ step = step_description(command)
184
+ raise StandardError, "task=#{task_name} host=#{host_name} step=#{step} error=#{e.class}: #{e.message}"
185
+ end
186
+
187
+ def step_description(command)
188
+ case command[:type]
189
+ when :run
190
+ first = command[:command].to_s.lines.first&.strip
191
+ "run: #{first}"
192
+ when :upload
193
+ "upload: #{command[:source]} -> #{command[:destination]}"
194
+ when :upload_template
195
+ "upload_template: #{command[:source]} -> #{command[:destination]}"
196
+ when :sync
197
+ "sync: #{command[:source]} -> #{command[:destination]}"
198
+ else
199
+ command[:type].to_s
200
+ end
201
+ end
202
+
172
203
  def show_task_header(task_desc)
173
204
  # Don't show command header during execution - it will be shown in results
174
205
  # This reduces noise during execution
@@ -2,5 +2,5 @@
2
2
 
3
3
  # Kdeploy module for version management
4
4
  module Kdeploy
5
- VERSION = '1.2.29' unless const_defined?(:VERSION)
5
+ VERSION = '1.2.33' unless const_defined?(:VERSION)
6
6
  end
data/r.md ADDED
File without changes
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kdeploy
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.29
4
+ version: 1.2.33
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kk
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-11-21 00:00:00.000000000 Z
11
+ date: 2026-01-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bcrypt_pbkdf
@@ -160,6 +160,7 @@ extensions:
160
160
  - ext/mkrf_conf.rb
161
161
  extra_rdoc_files: []
162
162
  files:
163
+ - AGENTS.md
163
164
  - README.md
164
165
  - README_EN.md
165
166
  - exe/kdeploy
@@ -184,6 +185,7 @@ files:
184
185
  - lib/kdeploy/runner.rb
185
186
  - lib/kdeploy/template.rb
186
187
  - lib/kdeploy/version.rb
188
+ - r.md
187
189
  homepage: https://github.com/kevin197011/kdeploy
188
190
  licenses:
189
191
  - MIT