kdeploy 1.3.4 → 1.4.1

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.
@@ -21,7 +21,8 @@ module Kdeploy
21
21
  @port = host_config[:port] # Added custom port support
22
22
  @use_sudo = host_config[:use_sudo] || false
23
23
  @sudo_password = host_config[:sudo_password]
24
- @base_dir = host_config[:base_dir] # Base directory for resolving relative paths
24
+ raw_base = host_config[:base_dir]
25
+ @base_dir = raw_base ? File.expand_path(raw_base) : nil
25
26
  end
26
27
 
27
28
  def execute(command, use_sudo: nil)
@@ -29,7 +30,7 @@ module Kdeploy
29
30
  command = wrap_with_sudo(command) if use_sudo
30
31
 
31
32
  Net::SSH.start(@ip, @user, ssh_options) do |ssh|
32
- execute_command_on_ssh(ssh, command)
33
+ with_active_ssh(ssh) { execute_command_on_ssh(ssh, command) }
33
34
  end
34
35
  rescue Net::SSH::AuthenticationFailed => e
35
36
  raise SSHError.new("SSH authentication failed: #{e.message}", e)
@@ -37,6 +38,36 @@ module Kdeploy
37
38
  raise SSHError.new("SSH execution failed: #{e.message}", e)
38
39
  end
39
40
 
41
+ # Best-effort close of the in-flight SSH session (step/host timeout).
42
+ def host_ip
43
+ @ip
44
+ end
45
+
46
+ def host_name
47
+ @host
48
+ end
49
+
50
+ def abort!
51
+ ssh = @active_ssh
52
+ return unless ssh
53
+
54
+ warn "Warning: aborting SSH session for #{@host} (#{@ip})"
55
+ begin
56
+ ssh.close
57
+ rescue StandardError
58
+ # ignore
59
+ ensure
60
+ @active_ssh = nil
61
+ end
62
+ end
63
+
64
+ def with_active_ssh(ssh)
65
+ @active_ssh = ssh
66
+ yield
67
+ ensure
68
+ @active_ssh = nil if @active_ssh.equal?(ssh)
69
+ end
70
+
40
71
  def execute_command_on_ssh(ssh, command)
41
72
  stdout = String.new
42
73
  stderr = String.new
@@ -53,6 +84,7 @@ module Kdeploy
53
84
  end
54
85
  end
55
86
  ssh.loop
87
+ raise_missing_exit_status!(command, stdout, stderr) if exit_status.nil?
56
88
  raise_nonzero_exit!(command, exit_status, stdout, stderr)
57
89
  build_command_result(stdout, stderr, command, exit_status)
58
90
  end
@@ -76,8 +108,18 @@ module Kdeploy
76
108
  }
77
109
  end
78
110
 
111
+ def raise_missing_exit_status!(command, stdout, stderr)
112
+ raise SSHError.new(
113
+ 'Command completed without exit status',
114
+ nil,
115
+ command: command,
116
+ exit_status: nil,
117
+ stdout: stdout.strip,
118
+ stderr: stderr.strip
119
+ )
120
+ end
121
+
79
122
  def raise_nonzero_exit!(command, exit_status, stdout, stderr)
80
- return if exit_status.nil?
81
123
  return if exit_status.zero?
82
124
 
83
125
  raise SSHError.new(
@@ -101,7 +143,9 @@ module Kdeploy
101
143
  upload_with_sudo(resolved_source, destination)
102
144
  else
103
145
  Net::SCP.start(@ip, @user, ssh_options) do |scp|
104
- scp.upload!(resolved_source, destination)
146
+ with_active_ssh(scp.respond_to?(:session) ? scp.session : nil) do
147
+ scp.upload!(resolved_source, destination)
148
+ end
105
149
  end
106
150
  end
107
151
  rescue StandardError => e
@@ -112,6 +156,8 @@ module Kdeploy
112
156
  # Resolve relative paths relative to base_dir
113
157
  resolved_source = resolve_path(source)
114
158
  Template.render_and_upload(self, resolved_source, destination, variables)
159
+ rescue ArgumentError, Errno::ENOENT, FileNotFoundError
160
+ raise
115
161
  rescue StandardError => e
116
162
  raise TemplateError.new("Template upload failed: #{e.message}", e)
117
163
  end
@@ -140,6 +186,8 @@ module Kdeploy
140
186
  use_sudo: use_sudo
141
187
  )
142
188
  return rsync_result if rsync_result
189
+
190
+ warn 'Warning: rsync fast path unavailable; falling back to SCP sync'
143
191
  end
144
192
 
145
193
  # Collect files to sync
@@ -163,6 +211,8 @@ module Kdeploy
163
211
  deleted: deleted_count,
164
212
  total: files_to_sync.size
165
213
  }
214
+ rescue FileNotFoundError, ArgumentError
215
+ raise
166
216
  rescue StandardError => e
167
217
  raise SCPError.new("Directory sync failed: #{e.message}", e)
168
218
  end
@@ -175,16 +225,18 @@ module Kdeploy
175
225
 
176
226
  # Upload to temp location first
177
227
  Net::SCP.start(@ip, @user, ssh_options) do |scp|
178
- scp.upload!(source, temp_dest)
228
+ with_active_ssh(scp.respond_to?(:session) ? scp.session : nil) do
229
+ scp.upload!(source, temp_dest)
230
+ end
179
231
  end
180
232
 
181
233
  # Move to final destination with sudo
182
- move_command = "mv #{temp_dest} #{destination}"
234
+ move_command = "mv #{Shellwords.escape(temp_dest)} #{Shellwords.escape(destination)}"
183
235
  execute(move_command, use_sudo: true)
184
236
  rescue StandardError => e
185
237
  # Try to clean up temp file if it exists
186
238
  begin
187
- execute("rm -f #{temp_dest}", use_sudo: false) if defined?(temp_dest)
239
+ execute("rm -f #{Shellwords.escape(temp_dest)}", use_sudo: false) if defined?(temp_dest)
188
240
  rescue StandardError
189
241
  # Ignore cleanup errors
190
242
  end
@@ -257,7 +309,7 @@ module Kdeploy
257
309
  sudo_flag,
258
310
  "-e \"#{ssh_cmd}\"",
259
311
  "#{Shellwords.escape(source)}/",
260
- "#{@user}@#{@ip}:#{Shellwords.escape(destination)}/"
312
+ "#{Shellwords.escape(@user)}@#{Shellwords.escape(@ip)}:#{Shellwords.escape(destination)}/"
261
313
  ].reject(&:empty?)
262
314
  parts.join(' ')
263
315
  end
@@ -314,29 +366,43 @@ module Kdeploy
314
366
  # Do not add sudo again if command already starts with sudo
315
367
  return command if command.strip.start_with?('sudo')
316
368
 
317
- # Wrap multi-line/control/compound commands so sudo applies to the full block
369
+ # Wrap multi-line/control/compound commands so sudo applies to the full block.
370
+ # Encode payload as base64 so local shell quoting cannot break on quotes/newlines.
318
371
  needs_wrap = command.include?("\n") ||
319
372
  command.match?(/\b(if|for|while|case|function)\b/) ||
320
373
  command.match?(/\s(&&|\|\||;)\s/)
321
374
 
322
375
  if needs_wrap
323
- # Escape single quotes in command, then execute via bash -c
324
- escaped_command = command.gsub("'", "'\"'\"'")
376
+ remote = base64_bash_command(command)
325
377
  if @sudo_password
326
- escaped_password = @sudo_password.gsub('\'', "'\"'\"'").gsub('$', '\\$').gsub('`', '\\`')
327
- "echo '#{escaped_password}' | sudo -S bash -c '#{escaped_command}'"
378
+ "#{sudo_password_prefix}sudo -S #{remote}"
328
379
  else
329
- "sudo bash -c '#{escaped_command}'"
380
+ "sudo #{remote}"
330
381
  end
331
382
  elsif @sudo_password
332
- # Wrap single-line command directly
333
- escaped_password = @sudo_password.gsub('\'', "'\"'\"'").gsub('$', '\\$').gsub('`', '\\`')
334
- "echo '#{escaped_password}' | sudo -S #{command}"
383
+ "#{sudo_password_prefix}sudo -S #{command}"
335
384
  else
336
385
  "sudo #{command}"
337
386
  end
338
387
  end
339
388
 
389
+ def base64_bash_command(command)
390
+ encoded = [command].pack('m0')
391
+ "bash -c #{Shellwords.escape("echo #{encoded} | base64 -d | bash")}"
392
+ end
393
+
394
+ def sudo_password_prefix
395
+ validate_sudo_password!
396
+ # printf + Shellwords avoids echo/newline injection into the local shell.
397
+ "printf '%s\\n' #{Shellwords.escape(@sudo_password)} | "
398
+ end
399
+
400
+ def validate_sudo_password!
401
+ return if @sudo_password.nil?
402
+
403
+ raise ArgumentError, 'sudo_password must not contain CR, LF, or NUL' if @sudo_password.match?(/[\r\n\0]/)
404
+ end
405
+
340
406
  def collect_files_to_sync(source_dir, filter)
341
407
  files = []
342
408
  source_path = Pathname.new(source_dir)
@@ -445,6 +511,14 @@ module Kdeploy
445
511
  # Ignore deletion errors
446
512
  end
447
513
 
514
+ # Best-effort: remove empty directories left behind (not the sync root).
515
+ prune = "find #{destination_dir.shellescape} -mindepth 1 -depth -type d -empty -delete 2>/dev/null || true"
516
+ begin
517
+ execute(prune, use_sudo: use_sudo)
518
+ rescue StandardError
519
+ # Ignore prune errors
520
+ end
521
+
448
522
  deleted_count
449
523
  end
450
524
  end
@@ -157,9 +157,18 @@ module Kdeploy
157
157
  when :upload_template
158
158
  "#{@pastel.blue('>')} Template: #{command[:source]} -> #{command[:destination]}"
159
159
  when :sync
160
- ignore_str = command[:ignore]&.any? ? " (ignore: #{command[:ignore].join(', ')})" : ''
161
- delete_str = command[:delete] ? ' (delete: true)' : ''
162
- "#{@pastel.blue('>')} Sync: #{command[:source]} -> #{command[:destination]}#{ignore_str}#{delete_str}"
160
+ extras = []
161
+ extras << "ignore: #{command[:ignore].join(', ')}" if command[:ignore]&.any?
162
+ extras << "exclude: #{command[:exclude].join(', ')}" if command[:exclude]&.any?
163
+ extras << 'delete: true' if command[:delete]
164
+ extras << 'fast: true' if command[:fast]
165
+ extras << "parallel: #{command[:parallel]}" if command[:parallel]
166
+ suffix = extras.empty? ? '' : " (#{extras.join(', ')})"
167
+ "#{@pastel.blue('>')} Sync: #{command[:source]} -> #{command[:destination]}#{suffix}"
168
+ when :wait_port
169
+ "#{@pastel.magenta('>')} wait_port: #{command[:port]} (timeout: #{command[:timeout]})"
170
+ when :wait_http
171
+ "#{@pastel.magenta('>')} wait_http: #{command[:url]} (code: #{command[:code]}, timeout: #{command[:timeout]})"
163
172
  else
164
173
  "#{@pastel.blue('>')} #{command[:type]}: #{command}"
165
174
  end
@@ -26,6 +26,7 @@ module Kdeploy
26
26
  @retry_policy = retry_policy
27
27
  @pool = Concurrent::FixedThreadPool.new(@parallel)
28
28
  @results = Concurrent::Hash.new
29
+ @results_mutex = Mutex.new
29
30
  end
30
31
 
31
32
  def run(task_name)
@@ -66,11 +67,16 @@ module Kdeploy
66
67
  progressed = true
67
68
  elsif timeout_exceeded?(started_at, now)
68
69
  future.cancel if future.respond_to?(:cancel)
69
- @results[host_name] ||= {
70
- status: :failed,
71
- error: "execution timeout after #{@host_timeout}s",
72
- output: []
73
- }
70
+ begin
71
+ meta[:executor_ref]&.get&.abort!
72
+ rescue StandardError
73
+ # ignore abort errors
74
+ end
75
+ store_result_once(host_name, {
76
+ status: :failed,
77
+ error: "execution timeout after #{@host_timeout}s",
78
+ output: []
79
+ })
74
80
  pending.delete(future)
75
81
  progressed = true
76
82
  end
@@ -85,22 +91,24 @@ module Kdeploy
85
91
  def create_task_futures(task, task_name)
86
92
  @future_meta = {}
87
93
  @hosts.map do |name, config|
88
- started_at = Concurrent::AtomicReference.new(nil)
94
+ # Start the host-timeout clock when scheduled so pool wait cannot skip timeout.
95
+ started_at = Concurrent::AtomicReference.new(Time.now)
96
+ executor_ref = Concurrent::AtomicReference.new(nil)
89
97
  future = Concurrent::Future.execute(executor: @pool) do
90
- started_at.set(Time.now)
91
- execute_task_for_host(name, config, task, task_name)
98
+ execute_task_for_host(name, config, task, task_name, executor_ref)
92
99
  end
93
- @future_meta[future] = { host_name: name, started_at: started_at }
100
+ @future_meta[future] = { host_name: name, started_at: started_at, executor_ref: executor_ref }
94
101
  future
95
102
  end
96
103
  end
97
104
 
98
105
  private
99
106
 
100
- def execute_task_for_host(name, config, task, task_name)
107
+ def execute_task_for_host(name, config, task, task_name, executor_ref = nil)
101
108
  # Add base_dir to config for path resolution
102
109
  config_with_base_dir = config.merge(base_dir: @base_dir)
103
110
  executor = Executor.new(config_with_base_dir)
111
+ executor_ref&.set(executor)
104
112
  command_executor = CommandExecutor.new(
105
113
  executor,
106
114
  @output,
@@ -126,7 +134,9 @@ module Kdeploy
126
134
  end
127
135
 
128
136
  def execute_commands(task, command_executor, name, result, task_name)
129
- commands = task[:block].call
137
+ plan = Kdeploy::DSL.normalize_plan(task[:block].call)
138
+ commands = plan[:commands]
139
+ on_failure = plan[:on_failure]
130
140
 
131
141
  commands.each do |command|
132
142
  step_result = execute_command(command_executor, command, name)
@@ -142,26 +152,46 @@ module Kdeploy
142
152
  error: "#{e.class}: #{e.message}",
143
153
  output: error_output_for_step(e)
144
154
  }
155
+ run_on_failure(on_failure, command_executor, name, result)
145
156
  break
146
157
  end
147
158
  end
148
159
 
160
+ def run_on_failure(on_failure, command_executor, name, result)
161
+ return if on_failure.nil? || on_failure.empty?
162
+
163
+ on_failure.each do |command|
164
+ step_result = execute_command(command_executor, command, name)
165
+ step_result = step_result.merge(on_failure: true)
166
+ result[:output] << step_result
167
+ rescue StandardError => e
168
+ result[:output] << {
169
+ type: command[:type],
170
+ command: step_command_string(command),
171
+ duration: 0.0,
172
+ on_failure: true,
173
+ error: "#{e.class}: #{e.message}",
174
+ output: error_output_for_step(e)
175
+ }
176
+ end
177
+ end
178
+
149
179
  def collect_future_result(future, host_name)
150
- return if @results.key?(host_name)
180
+ return if result_recorded?(host_name)
151
181
 
152
182
  begin
153
183
  future_result = future.value
154
184
  if future_result.nil?
155
- @results[host_name] = { status: :unknown, error: 'Future returned nil', output: [] }
185
+ store_result_once(host_name, { status: :unknown, error: 'Future returned nil', output: [] })
156
186
  elsif future_result.is_a?(Array) && future_result.length == 2
157
187
  name, result = future_result
158
- @results[name] = result
188
+ store_result_once(name, result)
159
189
  else
160
- @results[host_name] = {
161
- status: :unknown,
162
- error: "Unexpected result format: #{future_result.class}",
163
- output: []
164
- }
190
+ store_result_once(host_name, {
191
+ status: :unknown,
192
+ error: "Unexpected result format: #{future_result.class}",
193
+ output: []
194
+ })
165
195
  end
166
196
 
167
197
  if future.rejected?
@@ -170,12 +200,22 @@ module Kdeploy
170
200
  rescue StandardError
171
201
  'Unknown error'
172
202
  end
173
- @results[host_name] ||= { status: :failed, error: error, output: [] }
203
+ store_result_once(host_name, { status: :failed, error: error, output: [] })
174
204
  end
175
205
  rescue StandardError => e
176
- @results[host_name] = { status: :failed, error: "#{e.class}: #{e.message}", output: [] }
206
+ store_result_once(host_name, { status: :failed, error: "#{e.class}: #{e.message}", output: [] })
177
207
  ensure
178
- @results[host_name] ||= { status: :unknown, error: 'No result collected', output: [] }
208
+ store_result_once(host_name, { status: :unknown, error: 'No result collected', output: [] })
209
+ end
210
+ end
211
+
212
+ def result_recorded?(host_name)
213
+ @results_mutex.synchronize { @results.key?(host_name) }
214
+ end
215
+
216
+ def store_result_once(host_name, result)
217
+ @results_mutex.synchronize do
218
+ @results[host_name] ||= result
179
219
  end
180
220
  end
181
221
 
@@ -225,6 +265,10 @@ module Kdeploy
225
265
  command_executor.execute_upload_template(command, host_name)
226
266
  when :sync
227
267
  command_executor.execute_sync(command, host_name)
268
+ when :wait_port
269
+ command_executor.execute_wait_port(command, host_name)
270
+ when :wait_http
271
+ command_executor.execute_wait_http(command, host_name)
228
272
  else
229
273
  raise ConfigurationError, "Unknown command type: #{command[:type]}"
230
274
  end
@@ -240,6 +284,10 @@ module Kdeploy
240
284
  "upload_template: #{command[:source]} -> #{command[:destination]}"
241
285
  when :sync
242
286
  "sync: #{command[:source]} -> #{command[:destination]}"
287
+ when :wait_port
288
+ "wait_port: #{command[:port]}"
289
+ when :wait_http
290
+ "wait_http: #{command[:url]}"
243
291
  else
244
292
  command[:type].to_s
245
293
  end
@@ -256,6 +304,10 @@ module Kdeploy
256
304
  "upload_template: #{command[:source]} -> #{command[:destination]}"
257
305
  when :sync
258
306
  "sync: #{command[:source]} -> #{command[:destination]}"
307
+ when :wait_port
308
+ "wait_port: #{command[:port]}"
309
+ when :wait_http
310
+ "wait_http: #{command[:url]}"
259
311
  else
260
312
  command[:type].to_s
261
313
  end
@@ -51,7 +51,8 @@ module Kdeploy
51
51
  end
52
52
 
53
53
  def self.extract_template_identifiers(template_content)
54
- identifiers = template_content.scan(/<%=\s*([a-zA-Z_]\w*)/).flatten
54
+ # Match <%= name %> and <%= @name %>; still ignores method calls / hash access.
55
+ identifiers = template_content.scan(/<%=\s*@?([a-zA-Z_]\w*)/).flatten
55
56
  identifiers.uniq - ruby_keywords
56
57
  end
57
58
 
@@ -2,5 +2,5 @@
2
2
 
3
3
  # Kdeploy module for version management
4
4
  module Kdeploy
5
- VERSION = '1.3.4' unless const_defined?(:VERSION)
5
+ VERSION = '1.4.1' unless const_defined?(:VERSION)
6
6
  end
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.3.4
4
+ version: 1.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kk
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-07-07 00:00:00.000000000 Z
11
+ date: 2026-11-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bcrypt_pbkdf
@@ -160,8 +160,12 @@ extensions:
160
160
  - ext/mkrf_conf.rb
161
161
  extra_rdoc_files: []
162
162
  files:
163
+ - CHANGELOG.md
164
+ - CONTRIBUTING.md
165
+ - LICENSE
163
166
  - README.md
164
167
  - README_EN.md
168
+ - SECURITY.md
165
169
  - exe/kdeploy
166
170
  - ext/mkrf_conf.rb
167
171
  - lib/kdeploy.rb
@@ -188,8 +192,10 @@ licenses:
188
192
  - MIT
189
193
  metadata:
190
194
  homepage_uri: https://github.com/kevin197011/kdeploy
191
- source_code_uri: https://github.com/kevin197011/kdeploy.git
195
+ source_code_uri: https://github.com/kevin197011/kdeploy
192
196
  changelog_uri: https://github.com/kevin197011/kdeploy/blob/main/CHANGELOG.md
197
+ bug_tracker_uri: https://github.com/kevin197011/kdeploy/issues
198
+ documentation_uri: https://github.com/kevin197011/kdeploy/blob/main/docs/REQUIREMENTS.md
193
199
  rubygems_mfa_required: 'true'
194
200
  post_install_message: "\U0001F389 Thanks for installing kdeploy!\n\nShell completion
195
201
  will be configured automatically.\nYou may need to restart your shell or run:\n