kdeploy 0.1.0 → 0.3.0

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.
data/lib/kdeploy/dsl.rb CHANGED
@@ -1,566 +1,102 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Kdeploy
4
- # Domain Specific Language for deployment scripts
5
- class DSL
6
- def initialize(script_dir = nil)
7
- @pipeline = Pipeline.new
8
- @current_task = nil
9
- @inventory = nil
10
- @script_dir = script_dir || Dir.pwd
11
- @template_manager = nil
4
+ module DSL
5
+ def self.extended(base)
6
+ base.instance_variable_set(:@kdeploy_hosts, {})
7
+ base.instance_variable_set(:@kdeploy_tasks, {})
8
+ base.instance_variable_set(:@kdeploy_roles, {})
12
9
  end
13
10
 
14
- # Get pipeline or set pipeline name
15
- # @param name [String] Pipeline name (optional)
16
- # @return [Pipeline] Pipeline instance
17
- def pipeline(name = nil)
18
- @pipeline.instance_variable_set(:@name, name) if name
19
- @pipeline
11
+ def kdeploy_hosts
12
+ @kdeploy_hosts ||= {}
20
13
  end
21
14
 
22
- # Set global variable
23
- # @param key [String, Symbol] Variable key
24
- # @param value [Object] Variable value
25
- def set(key, value)
26
- @pipeline.set_variable(key, value)
15
+ def kdeploy_tasks
16
+ @kdeploy_tasks ||= {}
27
17
  end
28
18
 
29
- # Define host
30
- # @param hostname [String] Hostname or IP address
31
- # @param user [String] SSH user
32
- # @param port [Integer] SSH port
33
- # @param roles [Array] Host roles
34
- # @param vars [Hash] Host variables
35
- # @param ssh_options [Hash] SSH connection options
36
- # @return [Host] Created host
37
- def host(hostname, user: nil, port: nil, roles: [], vars: {}, **ssh_options)
38
- @pipeline.add_host(
39
- hostname,
40
- user: user,
41
- port: port,
42
- roles: roles,
43
- vars: vars,
44
- ssh_options: ssh_options
45
- )
19
+ def kdeploy_roles
20
+ @kdeploy_roles ||= {}
46
21
  end
47
22
 
48
- # Define multiple hosts
49
- # @param hosts_config [Hash] Hosts configuration
50
- def hosts(hosts_config)
51
- @pipeline.add_hosts(hosts_config)
23
+ def host(name, **options)
24
+ kdeploy_hosts[name] = options.merge(name: name)
52
25
  end
53
26
 
54
- # Load hosts from inventory file
55
- # @param inventory_file [String] Path to inventory file
56
- def inventory(inventory_file = nil)
57
- inventory_file ||= default_inventory_file
58
- resolved_path = resolve_inventory_path(inventory_file)
59
-
60
- return unless File.exist?(resolved_path)
61
-
62
- load_inventory(resolved_path)
63
- end
64
-
65
- # Initialize template manager
66
- # @param template_dir [String] Template directory path
67
- def template_dir(template_dir = nil)
68
- template_dir ||= default_template_dir
69
- resolved_path = resolve_template_path(template_dir)
70
-
71
- @template_manager = TemplateManager.new(resolved_path, @pipeline.variables)
72
- KdeployLogger.info("Template directory set to: #{resolved_path}")
73
- end
74
-
75
- # Get or initialize template manager
76
- # @return [TemplateManager] Template manager instance
77
- def template_manager
78
- @template_manager ||= begin
79
- dir = default_template_dir
80
- resolved_path = resolve_template_path(dir)
81
- TemplateManager.new(resolved_path, @pipeline.variables)
82
- end
83
- end
84
-
85
- # Define task
86
- # @param name [String] Task name
87
- # @param on [Array, Symbol] Target hosts or roles
88
- # @param parallel [Boolean] Execute in parallel
89
- # @param fail_fast [Boolean] Stop on first failure
90
- # @param max_concurrent [Integer] Maximum concurrent executions
91
- # @return [Task] Created task
92
- def task(name, on: nil, parallel: true, fail_fast: false, max_concurrent: nil, &block)
93
- target_hosts = resolve_target_hosts(on)
94
-
95
- @current_task = @pipeline.add_task(
96
- name,
97
- hosts: target_hosts,
98
- parallel: parallel,
99
- fail_fast: fail_fast,
100
- max_concurrent: max_concurrent
101
- )
102
-
103
- instance_eval(&block) if block
104
- @current_task = nil
105
- end
106
-
107
- # Execute command in current task
108
- # @param command [String] Command to execute (supports heredoc)
109
- # @param name [String] Command name (optional)
110
- # @param timeout [Integer] Command timeout
111
- # @param retry_count [Integer] Number of retries
112
- # @param retry_delay [Integer] Delay between retries
113
- # @param ignore_errors [Boolean] Continue on error
114
- # @param only [Array, Symbol] Run only on specified roles
115
- # @param except [Array, Symbol] Skip specified roles
116
- # @return [Array<Command>] Created commands
117
- def run(command, name: nil, timeout: nil, retry_count: nil, retry_delay: nil,
118
- ignore_errors: false, only: nil, except: nil)
119
- raise 'run can only be called within a task block' unless @current_task
120
-
121
- process_commands(command, name).each_with_object([]) do |(cmd_name, cmd), commands|
122
- commands << add_command_to_task(
123
- cmd_name, cmd, timeout, retry_count, retry_delay,
124
- ignore_errors, only, except
125
- )
126
- end
127
- end
128
-
129
- # Execute local command
130
- # @param command [String] Local command to execute
131
- # @param name [String] Command name (optional)
132
- # @return [Array<Hash>] Command results
133
- def local(command, name: nil)
134
- require 'open3'
135
-
136
- process_commands(command, name).map do |cmd_name, cmd|
137
- execute_local_command(cmd, cmd_name)
138
- end
139
- end
140
-
141
- # Upload file to remote host
142
- # @param local_path [String] Local file path
143
- # @param remote_path [String] Remote file path
144
- # @param name [String] Command name (optional)
145
- # @return [Command] Created upload command
146
- def upload(local_path, remote_path, name: nil)
147
- raise 'upload can only be called within a task block' unless @current_task
148
-
149
- ensure_remote_directory(remote_path)
150
- command_name = name || "upload_#{File.basename(local_path)}"
151
- add_upload_command(local_path, remote_path, command_name)
152
- end
153
-
154
- # Upload template to remote host
155
- # @param template_name [String] Template name
156
- # @param remote_path [String] Remote file path
157
- # @param variables [Hash] Template variables
158
- # @param name [String] Command name (optional)
159
- # @return [Command] Created template upload command
160
- def upload_template(template_name, remote_path, variables: {}, name: nil)
161
- raise 'upload_template can only be called within a task block' unless @current_task
162
-
163
- ensure_remote_directory(remote_path)
164
- command_name = name || "upload_template_#{File.basename(template_name)}"
165
- add_template_upload_command(template_name, remote_path, variables, command_name)
166
- end
167
-
168
- # Download file from remote host
169
- # @param remote_path [String] Remote file path
170
- # @param local_path [String] Local file path
171
- # @param name [String] Command name (optional)
172
- # @return [Command] Created download command
173
- def download(remote_path, local_path, name: nil)
174
- raise 'download can only be called within a task block' unless @current_task
175
-
176
- command_name = name || "download_#{File.basename(remote_path)}"
177
- add_download_command(remote_path, local_path, command_name)
178
- end
179
-
180
- # Get hosts by role
181
- # @param role [String, Symbol] Role to filter by
182
- # @return [Array<Host>] Hosts with specified role
183
- def role(role)
184
- @pipeline.hosts_with_role(role)
185
- end
186
-
187
- # Conditional execution
188
- # @param condition [Boolean] Condition to check
189
- # @yield Block to execute if condition is true
190
- def when(condition, &)
191
- instance_eval(&) if condition && block_given?
192
- end
193
-
194
- # Inverse conditional execution
195
- # @param condition [Boolean] Condition to check
196
- # @yield Block to execute if condition is false
197
- def unless(condition, &)
198
- instance_eval(&) if !condition && block_given?
27
+ def role(name, hosts)
28
+ kdeploy_roles[name] = hosts
199
29
  end
200
30
 
201
- # Include external script
202
- # @param script_path [String] Path to script file
203
- def include(script_path)
204
- return unless File.exist?(script_path)
205
-
206
- script_content = File.read(script_path)
207
- instance_eval(script_content, script_path)
208
- end
209
-
210
- private
211
-
212
- def default_inventory_file
213
- Kdeploy.configuration&.inventory_file || 'inventory.yml'
214
- end
215
-
216
- def resolve_inventory_path(inventory_file)
217
- return inventory_file if File.absolute_path?(inventory_file)
218
-
219
- File.join(@script_dir, inventory_file)
220
- end
221
-
222
- def load_inventory(inventory_file)
223
- @inventory = Inventory.new(inventory_file)
224
- add_inventory_hosts
225
- set_inventory_variables
226
- log_inventory_loaded(inventory_file)
227
- end
228
-
229
- def add_inventory_hosts
230
- @inventory.all_hosts.each do |host|
231
- @pipeline.hosts << host unless @pipeline.hosts.include?(host)
232
- end
233
- end
234
-
235
- def set_inventory_variables
236
- @inventory.vars.each do |key, value|
237
- @pipeline.set_variable(key, value)
238
- end
239
- end
240
-
241
- def log_inventory_loaded(inventory_file)
242
- KdeployLogger.info(
243
- "Loaded #{@inventory.hosts.size} hosts from inventory: #{inventory_file}"
244
- )
245
- end
246
-
247
- def default_template_dir
248
- Kdeploy.configuration&.template_dir || 'templates'
249
- end
250
-
251
- def resolve_template_path(template_dir)
252
- return template_dir if File.absolute_path?(template_dir)
253
-
254
- File.join(@script_dir, template_dir)
255
- end
256
-
257
- def process_commands(command, base_name)
258
- commands = process_heredoc_command(command)
259
- commands.each_with_index.map do |cmd, index|
260
- cmd = cmd.strip
261
- next if cmd.empty? || cmd.start_with?('#')
262
-
263
- [generate_command_name(cmd, base_name, index, commands.size), cmd]
264
- end.compact
265
- end
266
-
267
- def generate_command_name(cmd, base_name, index, total)
268
- return base_name if base_name && total == 1
269
-
270
- prefix = base_name || (cmd.split.first || 'unnamed')
271
- total > 1 ? "#{prefix}_#{index + 1}" : prefix
272
- end
273
-
274
- def add_command_to_task(name, cmd, timeout, retry_count, retry_delay,
275
- ignore_errors, only, except)
276
- @current_task.add_command(
277
- name,
278
- cmd,
279
- timeout: timeout,
280
- retry_count: retry_count,
281
- retry_delay: retry_delay,
282
- ignore_errors: ignore_errors,
283
- only: only,
284
- except: except
285
- )
286
- end
287
-
288
- def execute_local_command(cmd, name)
289
- processed_cmd = process_local_command_variables(cmd)
290
- start_time = Time.now
291
-
292
- log_local_command_start(name, processed_cmd)
293
- stdout, stderr, status = Open3.capture3(processed_cmd)
294
- duration = Time.now - start_time
295
-
296
- handle_local_command_result(name, duration, status.exitstatus, stdout, stderr)
297
- end
298
-
299
- def process_local_command_variables(cmd)
300
- processed_cmd = cmd.dup
301
- @pipeline.variables.each do |key, value|
302
- processed_cmd.gsub!(/\$\{#{key}\}/, value.to_s)
303
- end
304
- processed_cmd
305
- end
306
-
307
- def log_local_command_start(name, cmd)
308
- KdeployLogger.info("🚀 Executing local command '#{name}'")
309
- KdeployLogger.debug("Command: #{cmd}")
310
- end
311
-
312
- def handle_local_command_result(name, duration, exit_code, stdout, stderr)
313
- if exit_code.zero?
314
- log_local_command_success(name, duration, stdout)
315
- else
316
- log_local_command_failure(name, duration, exit_code, stdout, stderr)
317
- end
318
-
319
- {
320
- name: name,
321
- success: exit_code.zero?,
322
- duration: duration,
323
- exit_code: exit_code,
324
- stdout: stdout,
325
- stderr: stderr
31
+ def task(name, on: nil, roles: nil, &block)
32
+ kdeploy_tasks[name] = {
33
+ hosts: if on.is_a?(Array)
34
+ on
35
+ else
36
+ (on ? [on] : nil)
37
+ end,
38
+ roles: if roles.is_a?(Array)
39
+ roles
40
+ else
41
+ (roles ? [roles] : nil)
42
+ end,
43
+ block: lambda {
44
+ @kdeploy_commands = []
45
+ instance_eval(&block)
46
+ @kdeploy_commands
47
+ }
326
48
  }
327
49
  end
328
50
 
329
- def log_local_command_success(name, duration, stdout)
330
- KdeployLogger.info("✅ Local command '#{name}' completed in #{duration.round(2)}s")
331
- KdeployLogger.debug("Output: #{stdout}") unless stdout.strip.empty?
332
- end
333
-
334
- def log_local_command_failure(name, duration, exit_code, stdout, stderr)
335
- KdeployLogger.error("❌ Local command '#{name}' failed in #{duration.round(2)}s (exit code: #{exit_code})")
336
- KdeployLogger.error("STDERR: #{stderr}") unless stderr.empty?
337
- KdeployLogger.error("STDOUT: #{stdout}") unless stdout.strip.empty?
338
- end
339
-
340
- def ensure_remote_directory(remote_path)
341
- run("mkdir -p #{File.dirname(remote_path)}", name: 'create directory')
342
- end
343
-
344
- def add_upload_command(local_path, remote_path, _command_name)
345
- upload_command = UploadCommand.new(local_path, remote_path, @pipeline.variables)
346
- @current_task.commands << upload_command
347
- upload_command
51
+ def run(command)
52
+ @kdeploy_commands ||= []
53
+ @kdeploy_commands << { type: :run, command: command }
348
54
  end
349
55
 
350
- def add_template_upload_command(template_name, remote_path, variables, _command_name)
351
- command = TemplateUploadCommand.new(
352
- template_name,
353
- remote_path,
354
- variables,
355
- template_manager,
356
- @pipeline.variables
357
- )
358
- @current_task.commands << command
359
- command
56
+ def upload(source, destination)
57
+ @kdeploy_commands ||= []
58
+ @kdeploy_commands << { type: :upload, source: source, destination: destination }
360
59
  end
361
60
 
362
- def add_download_command(remote_path, local_path, _command_name)
363
- command = DownloadCommand.new(remote_path, local_path, @pipeline.variables)
364
- @current_task.commands << command
365
- command
366
- end
367
-
368
- def resolve_target_hosts(target)
369
- return @pipeline.hosts if target.nil?
370
-
371
- Array(target).flat_map { |t| resolve_single_target(t) }.uniq
372
- end
373
-
374
- def resolve_single_target(target)
375
- case target
376
- when Host
377
- target
378
- when Symbol
379
- @pipeline.hosts_with_role(target)
380
- when String
381
- resolve_host_by_name(target) || @pipeline.hosts_with_role(target.to_sym)
382
- when Array
383
- target.flat_map { |t| resolve_single_target(t) }
384
- else
385
- []
386
- end
387
- end
388
-
389
- def resolve_host_by_name(name)
390
- host = @pipeline.hosts.find { |h| h.hostname == name }
391
- [host] if host
392
- end
393
-
394
- def process_heredoc_command(command)
395
- command.split(/\r?\n/)
396
- end
397
- end
398
-
399
- # Command class for file upload operations
400
- class UploadCommand < Command
401
- def initialize(local_path, remote_path, global_variables = {})
402
- @local_path = local_path
403
- @remote_path = remote_path
404
- @global_variables = global_variables
405
- super()
406
- end
407
-
408
- def execute(host, connection)
409
- start_time = Time.now
410
- processed_path = process_remote_path(host)
411
-
412
- begin
413
- connection.upload(@local_path, processed_path)
414
- record_success(start_time, host)
415
- rescue StandardError => e
416
- record_failure(e, start_time, host)
417
- raise
418
- end
419
- end
420
-
421
- private
422
-
423
- def process_remote_path(host)
424
- path = @remote_path.dup
425
- variables = @global_variables.merge(host.vars)
426
- variables.each do |key, value|
427
- path.gsub!(/\$\{#{key}\}/, value.to_s)
428
- end
429
- path
430
- end
431
-
432
- def record_success(start_time, host)
433
- duration = Time.now - start_time
434
- KdeployLogger.info("✅ Uploaded #{@local_path} to #{host.hostname}:#{@remote_path} in #{duration.round(2)}s")
435
- end
436
-
437
- def record_failure(error, start_time, host)
438
- duration = Time.now - start_time
439
- KdeployLogger.error("❌ Failed to upload #{@local_path} to #{host.hostname}:#{@remote_path} in #{duration.round(2)}s")
440
- KdeployLogger.error("Error: #{error.message}")
441
- end
442
- end
443
-
444
- # Command class for file download operations
445
- class DownloadCommand < Command
446
- def initialize(remote_path, local_path, global_variables = {})
447
- @remote_path = remote_path
448
- @local_path = local_path
449
- @global_variables = global_variables
450
- super()
451
- end
452
-
453
- def execute(host, connection)
454
- start_time = Time.now
455
- processed_path = process_remote_path(host)
456
-
457
- begin
458
- connection.download(processed_path, @local_path)
459
- record_success(start_time, host)
460
- rescue StandardError => e
461
- record_failure(e, start_time, host)
462
- raise
463
- end
464
- end
465
-
466
- private
467
-
468
- def process_remote_path(host)
469
- path = @remote_path.dup
470
- variables = @global_variables.merge(host.vars)
471
- variables.each do |key, value|
472
- path.gsub!(/\$\{#{key}\}/, value.to_s)
473
- end
474
- path
475
- end
476
-
477
- def record_success(start_time, host)
478
- duration = Time.now - start_time
479
- KdeployLogger.info("✅ Downloaded #{host.hostname}:#{@remote_path} to #{@local_path} in #{duration.round(2)}s")
61
+ def upload_template(source, destination, variables = {})
62
+ @kdeploy_commands ||= []
63
+ @kdeploy_commands << {
64
+ type: :upload_template,
65
+ source: source,
66
+ destination: destination,
67
+ variables: variables
68
+ }
480
69
  end
481
70
 
482
- def record_failure(error, start_time, host)
483
- duration = Time.now - start_time
484
- KdeployLogger.error("❌ Failed to download #{host.hostname}:#{@remote_path} to #{@local_path} in #{duration.round(2)}s")
485
- KdeployLogger.error("Error: #{error.message}")
71
+ def inventory(&)
72
+ instance_eval(&) if block_given?
486
73
  end
487
- end
488
74
 
489
- # Command class for template upload operations
490
- class TemplateUploadCommand < Command
491
- def initialize(template_name, remote_path, variables, template_manager, global_variables = {})
492
- @template_name = template_name
493
- @remote_path = remote_path
494
- @variables = variables
495
- @template_manager = template_manager
496
- @global_variables = global_variables
497
- super()
498
- end
75
+ def get_task_hosts(task_name)
76
+ task = kdeploy_tasks[task_name]
77
+ return kdeploy_hosts.keys if !task || (!task[:hosts] && !task[:roles])
499
78
 
500
- def execute(host, connection)
501
- start_time = Time.now
502
- host_variables = build_host_variables(host)
503
- processed_path = process_remote_path_variables(host_variables)
79
+ hosts = Set.new
504
80
 
505
- begin
506
- perform_template_upload(host, connection, host_variables, processed_path)
507
- record_success(start_time, host)
508
- rescue StandardError => e
509
- record_failure(e, start_time, host)
510
- raise
81
+ # 添加指定的主机
82
+ if task[:hosts]
83
+ task[:hosts].each do |host|
84
+ hosts.add(host) if kdeploy_hosts.key?(host)
85
+ end
511
86
  end
512
- end
513
87
 
514
- private
88
+ # 添加角色中的主机
89
+ if task[:roles]
90
+ task[:roles].each do |role|
91
+ next unless role_hosts = kdeploy_roles[role]
515
92
 
516
- def build_host_variables(host)
517
- @global_variables.merge(@variables).merge(host.vars).merge(
518
- hostname: host.hostname,
519
- user: host.user,
520
- port: host.port
521
- )
522
- end
523
-
524
- def process_remote_path_variables(host_variables)
525
- path = @remote_path.dup
526
- host_variables.each do |key, value|
527
- path.gsub!(/\$\{#{key}\}/, value.to_s)
93
+ role_hosts.each do |host|
94
+ hosts.add(host) if kdeploy_hosts.key?(host)
95
+ end
96
+ end
528
97
  end
529
- path
530
- end
531
-
532
- def perform_template_upload(_host, connection, host_variables, processed_path)
533
- rendered_content = @template_manager.render(@template_name, host_variables)
534
- temp_file = create_temp_file(rendered_content)
535
- upload_template_file(connection, temp_file, processed_path)
536
- ensure
537
- cleanup_temp_file(temp_file) if temp_file
538
- end
539
-
540
- def create_temp_file(content)
541
- temp_file = "/tmp/kdeploy_template_#{Time.now.to_i}_#{Process.pid}"
542
- File.write(temp_file, content)
543
- temp_file
544
- end
545
-
546
- def upload_template_file(connection, temp_file, processed_path)
547
- connection.execute("mkdir -p #{File.dirname(processed_path)}")
548
- connection.upload(temp_file, processed_path)
549
- end
550
-
551
- def cleanup_temp_file(temp_file)
552
- FileUtils.rm_f(temp_file)
553
- end
554
-
555
- def record_success(start_time, host)
556
- duration = Time.now - start_time
557
- KdeployLogger.info("✅ Uploaded template #{@template_name} to #{host.hostname}:#{@remote_path} in #{duration.round(2)}s")
558
- end
559
98
 
560
- def record_failure(error, start_time, host)
561
- duration = Time.now - start_time
562
- KdeployLogger.error("❌ Failed to upload template #{@template_name} to #{host.hostname}:#{@remote_path} in #{duration.round(2)}s")
563
- KdeployLogger.error("Error: #{error.message}")
99
+ hosts.to_a
564
100
  end
565
101
  end
566
102
  end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/ssh'
4
+ require 'net/scp'
5
+
6
+ module Kdeploy
7
+ class Executor
8
+ def initialize(host_config)
9
+ @host = host_config[:name]
10
+ @user = host_config[:user]
11
+ @ip = host_config[:ip]
12
+ @password = host_config[:password]
13
+ @key = host_config[:key]
14
+ end
15
+
16
+ def execute(command)
17
+ Net::SSH.start(@ip, @user, ssh_options) do |ssh|
18
+ stdout = String.new
19
+ stderr = String.new
20
+
21
+ ssh.exec!(command) do |_channel, stream, data|
22
+ case stream
23
+ when :stdout
24
+ stdout << data
25
+ when :stderr
26
+ stderr << data
27
+ end
28
+ end
29
+
30
+ {
31
+ stdout: stdout.strip,
32
+ stderr: stderr.strip,
33
+ command: command
34
+ }
35
+ end
36
+ rescue Net::SSH::AuthenticationFailed => e
37
+ raise "SSH authentication failed: #{e.message}"
38
+ rescue StandardError => e
39
+ raise "SSH execution failed: #{e.message}"
40
+ end
41
+
42
+ def upload(source, destination)
43
+ Net::SCP.start(@ip, @user, ssh_options) do |scp|
44
+ scp.upload!(source, destination)
45
+ end
46
+ rescue StandardError => e
47
+ raise "SCP upload failed: #{e.message}"
48
+ end
49
+
50
+ def upload_template(source, destination, variables = {})
51
+ Template.render_and_upload(self, source, destination, variables)
52
+ rescue StandardError => e
53
+ raise "Template upload failed: #{e.message}"
54
+ end
55
+
56
+ private
57
+
58
+ def ssh_options
59
+ options = {
60
+ verify_host_key: :never,
61
+ timeout: 30
62
+ }
63
+
64
+ if @password
65
+ options[:password] = @password
66
+ elsif @key
67
+ options[:keys] = [@key]
68
+ end
69
+
70
+ options
71
+ end
72
+ end
73
+ end