kdeploy 0.1.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.
@@ -0,0 +1,566 @@
1
+ # frozen_string_literal: true
2
+
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
12
+ end
13
+
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
20
+ end
21
+
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)
27
+ end
28
+
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
+ )
46
+ end
47
+
48
+ # Define multiple hosts
49
+ # @param hosts_config [Hash] Hosts configuration
50
+ def hosts(hosts_config)
51
+ @pipeline.add_hosts(hosts_config)
52
+ end
53
+
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?
199
+ end
200
+
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
326
+ }
327
+ end
328
+
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
348
+ end
349
+
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
360
+ end
361
+
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")
480
+ end
481
+
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}")
486
+ end
487
+ end
488
+
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
499
+
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)
504
+
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
511
+ end
512
+ end
513
+
514
+ private
515
+
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)
528
+ 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
+
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}")
564
+ end
565
+ end
566
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kdeploy
4
+ # Host class for managing remote host configuration and connection details
5
+ class Host
6
+ attr_reader :hostname, :user, :port, :ssh_options, :roles, :vars
7
+
8
+ def initialize(hostname, user: nil, port: nil, ssh_options: {}, roles: [], vars: {})
9
+ @hostname = hostname
10
+ @user = user || Kdeploy.configuration&.default_user || ENV.fetch('USER', nil)
11
+ @port = port || Kdeploy.configuration&.default_port || 22
12
+ @ssh_options = ssh_options
13
+ @roles = Array(roles)
14
+ @vars = vars || {}
15
+ end
16
+
17
+ # Check if host has specific role
18
+ # @param role [String, Symbol] Role to check
19
+ # @return [Boolean] True if host has role
20
+ def has_role?(role)
21
+ @roles.include?(role.to_s) || @roles.include?(role.to_sym)
22
+ end
23
+
24
+ # Get variable value
25
+ # @param key [String, Symbol] Variable key
26
+ # @return [Object] Variable value
27
+ def var(key)
28
+ @vars[key.to_s] || @vars[key.to_sym]
29
+ end
30
+
31
+ # Set variable value
32
+ # @param key [String, Symbol] Variable key
33
+ # @param value [Object] Variable value
34
+ # @return [Object] Set value
35
+ def set_var(key, value)
36
+ @vars[key.to_s] = value
37
+ end
38
+
39
+ # Get connection string for display
40
+ # @return [String] Connection string
41
+ def connection_string
42
+ "#{@user}@#{@hostname}:#{@port}"
43
+ end
44
+
45
+ # Get SSH connection options
46
+ # @return [Hash] SSH options
47
+ def connection_options
48
+ base_options = Kdeploy.configuration&.merged_ssh_options(@ssh_options) || @ssh_options
49
+ base_options.merge(
50
+ timeout: Kdeploy.configuration&.ssh_timeout || 30
51
+ )
52
+ end
53
+
54
+ # String representation of the host
55
+ # @return [String] Connection string
56
+ def to_s
57
+ connection_string
58
+ end
59
+
60
+ # Detailed string representation of the host
61
+ # @return [String] Host details
62
+ def inspect
63
+ "#<Kdeploy::Host #{connection_string} roles=#{@roles} vars=#{@vars.keys}>"
64
+ end
65
+
66
+ # Compare hosts for equality
67
+ # @param other [Host] Host to compare with
68
+ # @return [Boolean] True if hosts are equal
69
+ def ==(other)
70
+ return false unless other.is_a?(Host)
71
+
72
+ hostname == other.hostname &&
73
+ user == other.user &&
74
+ port == other.port
75
+ end
76
+
77
+ alias eql? ==
78
+
79
+ # Generate hash code for host
80
+ # @return [Integer] Hash code
81
+ def hash
82
+ [hostname, user, port].hash
83
+ end
84
+ end
85
+ end