kdeploy 1.2.30 → 1.2.38

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.
@@ -3,36 +3,28 @@
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
- def execute_run(command, host_name)
14
+ def execute_run(command, _host_name)
13
15
  cmd = command[:command]
14
16
  use_sudo = command[:sudo]
15
- show_command_header(host_name, :run, cmd)
16
-
17
- # Show progress indicator for long-running commands
18
- pastel = @output.respond_to?(:pastel) ? @output.pastel : Pastel.new
19
17
 
20
18
  result, duration = measure_time do
21
- @executor.execute(cmd, use_sudo: use_sudo)
19
+ with_retries { @executor.execute(cmd, use_sudo: use_sudo) }
22
20
  end
23
21
 
24
- # Show execution time if command took more than 1 second
25
- @output.write_line(pastel.dim(" [completed in #{format('%.2f', duration)}s]")) if duration > 1.0
26
-
27
- # Show command output only in debug mode
28
- show_command_output(result) if @debug
29
22
  { command: cmd, output: result, duration: duration, type: :run }
30
23
  end
31
24
 
32
- def execute_upload(command, host_name)
33
- show_command_header(host_name, :upload, "#{command[:source]} -> #{command[:destination]}")
25
+ def execute_upload(command, _host_name)
34
26
  _result, duration = measure_time do
35
- @executor.upload(command[:source], command[:destination])
27
+ with_retries { @executor.upload(command[:source], command[:destination]) }
36
28
  end
37
29
  {
38
30
  command: "upload: #{command[:source]} -> #{command[:destination]}",
@@ -41,10 +33,11 @@ module Kdeploy
41
33
  }
42
34
  end
43
35
 
44
- def execute_upload_template(command, host_name)
45
- show_command_header(host_name, :upload_template, "#{command[:source]} -> #{command[:destination]}")
36
+ def execute_upload_template(command, _host_name)
46
37
  _result, duration = measure_time do
47
- @executor.upload_template(command[:source], command[:destination], command[:variables])
38
+ with_retries do
39
+ @executor.upload_template(command[:source], command[:destination], command[:variables])
40
+ end
48
41
  end
49
42
  {
50
43
  command: "upload_template: #{command[:source]} -> #{command[:destination]}",
@@ -53,20 +46,20 @@ module Kdeploy
53
46
  }
54
47
  end
55
48
 
56
- def execute_sync(command, host_name)
49
+ def execute_sync(command, _host_name)
57
50
  source = command[:source]
58
51
  destination = command[:destination]
59
- description = build_sync_description(source, destination, command[:delete])
60
- show_command_header(host_name, :sync, description)
61
52
 
62
53
  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
- )
54
+ with_retries do
55
+ @executor.sync_directory(
56
+ source,
57
+ destination,
58
+ ignore: command[:ignore] || [],
59
+ exclude: command[:exclude] || [],
60
+ delete: command[:delete] || false
61
+ )
62
+ end
70
63
  end
71
64
 
72
65
  build_sync_result(source, destination, result, duration)
@@ -74,12 +67,6 @@ module Kdeploy
74
67
 
75
68
  private
76
69
 
77
- def build_sync_description(source, destination, delete)
78
- desc = "sync: #{source} -> #{destination}"
79
- desc += " (delete: #{delete})" if delete
80
- desc
81
- end
82
-
83
70
  def build_sync_result(source, destination, result, duration)
84
71
  {
85
72
  command: "sync: #{source} -> #{destination}",
@@ -99,54 +86,16 @@ module Kdeploy
99
86
  [result, duration]
100
87
  end
101
88
 
102
- def show_command_output(output)
103
- return unless output.is_a?(Hash)
104
-
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?
123
- end
124
- end
125
-
126
- def show_command_header(host_name, type, description)
127
- # Don't show command header during execution - it will be shown in results
128
- # This reduces noise during execution
129
- end
130
-
131
- def pastel_instance
132
- @output.respond_to?(:pastel) ? @output.pastel : Pastel.new
133
- end
134
-
135
- def format_command_by_type(type, description, pastel)
136
- case type
137
- when :run
138
- format_run_command(description, pastel)
139
- when :upload
140
- @output.write_line(pastel.green(" [upload] #{description}"))
141
- when :upload_template
142
- @output.write_line(pastel.yellow(" [template] #{description}"))
143
- end
144
- end
89
+ def with_retries
90
+ attempts = 0
91
+ begin
92
+ attempts += 1
93
+ yield
94
+ rescue SSHError, SCPError, TemplateError
95
+ raise if attempts > (@retries + 1)
145
96
 
146
- def format_run_command(description, pastel)
147
- @output.write_line(pastel.cyan(" [run] #{description.lines.first.strip}"))
148
- description.lines[1..].each do |line|
149
- @output.write_line(" > #{line.strip}") unless line.strip.empty?
97
+ sleep(@retry_delay) if @retry_delay.positive?
98
+ retry
150
99
  end
151
100
  end
152
101
  end
@@ -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
@@ -1,16 +1,56 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'set'
4
+ require 'shellwords'
4
5
 
5
6
  module Kdeploy
7
+ # Helper for template resource block: captures source and variables
8
+ class TemplateOptions
9
+ def initialize
10
+ @source = nil
11
+ @variables = {}
12
+ end
13
+
14
+ def variables(val = nil)
15
+ @variables = val if val
16
+ @variables
17
+ end
18
+
19
+ def source(val = nil)
20
+ @source = val if val
21
+ @source
22
+ end
23
+ end
24
+
6
25
  # Domain-specific language for defining hosts, roles, and tasks
7
26
  module DSL
27
+ def self.included(base)
28
+ # Support `include Kdeploy::DSL` by promoting the DSL methods to class methods.
29
+ # This keeps tests and external integrations simpler while preserving the
30
+ # primary usage pattern (CLI uses `extend DSL`).
31
+ base.extend(self)
32
+ end
33
+
8
34
  def self.extended(base)
9
35
  base.instance_variable_set(:@kdeploy_hosts, {})
10
36
  base.instance_variable_set(:@kdeploy_tasks, {})
11
37
  base.instance_variable_set(:@kdeploy_roles, {})
12
38
  end
13
39
 
40
+ # Stable read accessors for tests/integrations.
41
+ # Keep these as aliases so internal storage can evolve without breaking callers.
42
+ def hosts
43
+ kdeploy_hosts
44
+ end
45
+
46
+ def tasks
47
+ kdeploy_tasks
48
+ end
49
+
50
+ def roles
51
+ kdeploy_roles
52
+ end
53
+
14
54
  def kdeploy_hosts
15
55
  @kdeploy_hosts ||= {}
16
56
  end
@@ -133,6 +173,60 @@ module Kdeploy
133
173
  }
134
174
  end
135
175
 
176
+ # -------------------------------------------------------------------------
177
+ # Chef-style resource DSL (compiles to run/upload/upload_template)
178
+ # -------------------------------------------------------------------------
179
+
180
+ # åŽ‰čŖ…įŗģįģŸåŒ…。éģ˜čޤ apt åšŗå°īŧ›æ”¯æŒ platform: :yum。
181
+ def package(name, version: nil, platform: :apt)
182
+ @kdeploy_commands ||= []
183
+ cmd = build_package_command(name, version, platform)
184
+ @kdeploy_commands << { type: :run, command: cmd, sudo: true }
185
+ end
186
+
187
+ # įŽĄį†įŗģį쟿œåŠĄīŧˆsystemdīŧ‰ã€‚action 支持 :start, :stop, :restart, :reload, :enable, :disable。
188
+ def service(name, action: :start)
189
+ @kdeploy_commands ||= []
190
+ actions = Array(action)
191
+ actions.each do |a|
192
+ cmd = "systemctl #{a} #{Shellwords.escape(name.to_s)}"
193
+ @kdeploy_commands << { type: :run, command: cmd, sudo: true }
194
+ end
195
+ end
196
+
197
+ # 部įŊ˛ ERB æ¨Ąæŋ到čŋœį¨‹čˇ¯åž„。支持 block æˆ–å…ŗé”Žå­—å‚æ•°ã€‚
198
+ def template(destination, source: nil, variables: nil, &block)
199
+ @kdeploy_commands ||= []
200
+ if block
201
+ opts = TemplateOptions.new
202
+ opts.instance_eval(&block)
203
+ src = opts.source || raise(ArgumentError, 'template requires source')
204
+ vars = opts.variables || {}
205
+ else
206
+ raise ArgumentError, 'template requires source' unless source
207
+
208
+ src = source
209
+ vars = variables || {}
210
+ end
211
+ upload_template(src, destination, vars)
212
+ end
213
+
214
+ # 上äŧ æœŦ地文äģļ到čŋœį¨‹čˇ¯åž„。
215
+ def file(destination, source:)
216
+ @kdeploy_commands ||= []
217
+ upload(source, destination)
218
+ end
219
+
220
+ # įĄŽäŋčŋœį¨‹į›ŽåŊ•存在。支持 mode 参数。
221
+ def directory(path, mode: nil)
222
+ @kdeploy_commands ||= []
223
+ cmd = "mkdir -p #{Shellwords.escape(path.to_s)}"
224
+ @kdeploy_commands << { type: :run, command: cmd, sudo: true }
225
+ return unless mode
226
+
227
+ @kdeploy_commands << { type: :run, command: "chmod #{mode} #{Shellwords.escape(path.to_s)}", sudo: true }
228
+ end
229
+
136
230
  def inventory(&block)
137
231
  instance_eval(&block) if block_given?
138
232
  end
@@ -167,5 +261,20 @@ module Kdeploy
167
261
  end
168
262
  end
169
263
  end
264
+
265
+ def build_package_command(name, version, platform)
266
+ n = Shellwords.escape(name.to_s)
267
+ case platform.to_sym
268
+ when :yum, :rpm
269
+ if version
270
+ "yum install -y #{n}-#{Shellwords.escape(version.to_s)}"
271
+ else
272
+ "yum install -y #{n}"
273
+ end
274
+ else
275
+ base = "apt-get update && apt-get install -y #{n}"
276
+ version ? "#{base}=#{Shellwords.escape(version.to_s)}" : base
277
+ end
278
+ end
170
279
  end
171
280
  end
@@ -20,12 +20,16 @@ module Kdeploy
20
20
 
21
21
  # Raised when SSH operation fails
22
22
  class SSHError < Error
23
- def initialize(message, original_error = nil)
23
+ def initialize(message, original_error = nil, command: nil, exit_status: nil, stdout: nil, stderr: nil)
24
24
  super("SSH operation failed: #{message}")
25
25
  @original_error = original_error
26
+ @command = command
27
+ @exit_status = exit_status
28
+ @stdout = stdout
29
+ @stderr = stderr
26
30
  end
27
31
 
28
- attr_reader :original_error
32
+ attr_reader :original_error, :command, :exit_status, :stdout, :stderr
29
33
  end
30
34
 
31
35
  # Raised when SCP operation fails
@@ -38,16 +38,21 @@ module Kdeploy
38
38
  def execute_command_on_ssh(ssh, command)
39
39
  stdout = String.new
40
40
  stderr = String.new
41
+ exit_status = nil
41
42
 
42
43
  ssh.open_channel do |channel|
43
44
  channel.exec(command) do |_ch, success|
44
45
  raise SSHError, "Could not execute command: #{command}" unless success
45
46
 
46
47
  setup_channel_handlers(channel, stdout, stderr)
48
+ channel.on_request('exit-status') do |_ch, data|
49
+ exit_status = data.read_long
50
+ end
47
51
  end
48
52
  end
49
53
  ssh.loop
50
- build_command_result(stdout, stderr, command)
54
+ raise_nonzero_exit!(command, exit_status, stdout, stderr)
55
+ build_command_result(stdout, stderr, command, exit_status)
51
56
  end
52
57
 
53
58
  def setup_channel_handlers(channel, stdout, stderr)
@@ -60,14 +65,29 @@ module Kdeploy
60
65
  end
61
66
  end
62
67
 
63
- def build_command_result(stdout, stderr, command)
68
+ def build_command_result(stdout, stderr, command, exit_status)
64
69
  {
65
70
  stdout: stdout.strip,
66
71
  stderr: stderr.strip,
67
- command: command
72
+ command: command,
73
+ exit_status: exit_status
68
74
  }
69
75
  end
70
76
 
77
+ def raise_nonzero_exit!(command, exit_status, stdout, stderr)
78
+ return if exit_status.nil?
79
+ return if exit_status.zero?
80
+
81
+ raise SSHError.new(
82
+ "Command exited with status #{exit_status}",
83
+ nil,
84
+ command: command,
85
+ exit_status: exit_status,
86
+ stdout: stdout.strip,
87
+ stderr: stderr.strip
88
+ )
89
+ end
90
+
71
91
  def upload(source, destination, use_sudo: nil)
72
92
  use_sudo = @use_sudo if use_sudo.nil?
73
93
 
@@ -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
 
@@ -39,27 +39,21 @@ module Kdeploy
39
39
  role :web, %w[web01 web02]
40
40
  role :db, %w[db01]
41
41
 
42
- # Define deployment task for web servers
42
+ # Define deployment task for web servers (Chef-style resource DSL)
43
43
  task :deploy_web, roles: :web do
44
- # Example: Using sudo option for specific command
45
- # run "systemctl stop nginx", sudo: true
46
- run <<~SHELL
47
- sudo systemctl stop nginx
48
- echo "Deploying..."
49
- SHELL
50
-
51
- upload_template './config/nginx.conf.erb', '/etc/nginx/nginx.conf',
52
- domain_name: 'example.com',
53
- port: 3000,
54
- worker_processes: 4,
55
- worker_connections: 2048
56
-
57
- upload './config/app.conf', '/etc/nginx/conf.d/app.conf'
58
-
59
- run <<~SHELL
60
- sudo systemctl start nginx
61
- sudo systemctl status nginx
62
- SHELL
44
+ package 'nginx'
45
+ directory '/etc/nginx/conf.d'
46
+ template '/etc/nginx/nginx.conf',
47
+ source: './config/nginx.conf.erb',
48
+ variables: {
49
+ domain_name: 'example.com',
50
+ port: 3000,
51
+ worker_processes: 4,
52
+ worker_connections: 2048
53
+ }
54
+ file '/etc/nginx/conf.d/app.conf', source: './config/app.conf'
55
+ run 'nginx -t', sudo: true
56
+ service 'nginx', action: %i[enable restart]
63
57
  end
64
58
 
65
59
  # Define backup task for database servers
@@ -71,13 +65,11 @@ module Kdeploy
71
65
  SHELL
72
66
  end
73
67
 
74
- # Define task for specific hosts
68
+ # Define task for specific hosts (resource DSL example)
75
69
  task :maintenance, on: %w[web01] do
76
- run <<~SHELL
77
- sudo systemctl stop nginx
78
- sudo apt-get update && sudo apt-get upgrade -y
79
- sudo systemctl start nginx
80
- SHELL
70
+ service 'nginx', action: :stop
71
+ run 'apt-get update && apt-get upgrade -y', sudo: true
72
+ service 'nginx', action: %i[start enable]
81
73
  end
82
74
 
83
75
  # Define task for all hosts
@@ -199,12 +191,12 @@ module Kdeploy
199
191
  server_name <%= domain_name %>;
200
192
  ```
201
193
 
202
- Variables are passed when uploading the template:
194
+ Variables are passed when uploading the template (Chef-style resource DSL):
203
195
 
204
196
  ```ruby
205
- upload_template "./config/nginx.conf.erb", "/etc/nginx/nginx.conf",
206
- domain_name: "example.com",
207
- worker_processes: 4
197
+ template "/etc/nginx/nginx.conf",
198
+ source: "./config/nginx.conf.erb",
199
+ variables: { domain_name: "example.com", worker_processes: 4 }
208
200
  ```
209
201
 
210
202
  ## 🔐 Using Sudo