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.
- checksums.yaml +4 -4
- data/README.md +53 -1
- data/README_EN.md +86 -2
- data/lib/kdeploy/{cli.rb → cli/cli.rb} +74 -7
- data/lib/kdeploy/{help_formatter.rb → cli/help_formatter.rb} +5 -0
- data/lib/kdeploy/{configuration.rb → config/configuration.rb} +25 -1
- data/lib/kdeploy/{dsl.rb → dsl/dsl.rb} +9 -7
- data/lib/kdeploy/{initializer.rb → dsl/initializer.rb} +3 -3
- data/lib/kdeploy/errors.rb +7 -0
- data/lib/kdeploy/executor/command_executor.rb +155 -0
- data/lib/kdeploy/{executor.rb → executor/executor.rb} +151 -20
- data/lib/kdeploy/{output_formatter.rb → output/output_formatter.rb} +1 -0
- data/lib/kdeploy/{runner.rb → runner/runner.rb} +114 -43
- data/lib/kdeploy/{template.rb → template/template.rb} +25 -2
- data/lib/kdeploy/version.rb +1 -1
- data/lib/kdeploy.rb +12 -12
- metadata +14 -15
- data/lib/kdeploy/command_executor.rb +0 -102
- data/r.md +0 -1
- /data/lib/kdeploy/{file_filter.rb → executor/file_filter.rb} +0 -0
- /data/lib/kdeploy/{output.rb → output/output.rb} +0 -0
|
@@ -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,
|
|
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 =
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
92
|
-
@host_names = @hosts.keys
|
|
87
|
+
@future_meta = {}
|
|
93
88
|
@hosts.map do |name, config|
|
|
94
|
-
Concurrent::
|
|
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] =
|
|
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
|
data/lib/kdeploy/version.rb
CHANGED