kdeploy 1.2.0 → 1.2.2

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/cli.rb CHANGED
@@ -7,6 +7,7 @@ require 'tty-box'
7
7
  require 'fileutils'
8
8
 
9
9
  module Kdeploy
10
+ # Command-line interface for Kdeploy
10
11
  class CLI < Thor
11
12
  extend DSL
12
13
 
@@ -27,41 +28,7 @@ module Kdeploy
27
28
  if command
28
29
  super
29
30
  else
30
- pastel = Pastel.new
31
- puts Kdeploy::Banner.show
32
- puts <<~HELP
33
- #{pastel.bright_white('📖 Available Commands:')}
34
-
35
- #{pastel.bright_yellow('🚀')} #{pastel.bright_white('execute TASK_FILE [TASK]')} Execute deployment tasks from file
36
- #{pastel.dim(' --limit HOSTS')} Limit to specific hosts (comma-separated)
37
- #{pastel.dim(' --parallel NUM')} Number of parallel executions (default: 5)
38
- #{pastel.dim(' --dry-run')} Show what would be done without executing
39
-
40
- #{pastel.bright_yellow('🆕')} #{pastel.bright_white('init [DIR]')} Initialize new deployment project
41
- #{pastel.bright_yellow('ℹ️')} #{pastel.bright_white('version')} Show version information
42
- #{pastel.bright_yellow('❓')} #{pastel.bright_white('help [COMMAND]')} Show help information
43
-
44
- #{pastel.bright_white('💡 Examples:')}
45
-
46
- #{pastel.dim('# Initialize a new project')}
47
- #{pastel.bright_cyan('kdeploy init my-deployment')}
48
-
49
- #{pastel.dim('# Deploy to web servers')}
50
- #{pastel.bright_cyan('kdeploy execute deploy.rb deploy_web')}
51
-
52
- #{pastel.dim('# Backup database')}
53
- #{pastel.bright_cyan('kdeploy execute deploy.rb backup_db')}
54
-
55
- #{pastel.dim('# Run maintenance on specific hosts')}
56
- #{pastel.bright_cyan('kdeploy execute deploy.rb maintenance --limit web01')}
57
-
58
- #{pastel.dim('# Preview deployment')}
59
- #{pastel.bright_cyan('kdeploy execute deploy.rb deploy_web --dry-run')}
60
-
61
- #{pastel.bright_white('📚 Documentation:')}
62
- #{pastel.bright_cyan('https://github.com/kevin197011/kdeploy')}
63
-
64
- HELP
31
+ show_general_help
65
32
  end
66
33
  end
67
34
 
@@ -79,40 +46,11 @@ module Kdeploy
79
46
  method_option :parallel, type: :numeric, default: 10, desc: 'Number of parallel executions'
80
47
  method_option :dry_run, type: :boolean, desc: 'Show what would be done'
81
48
  def execute(task_file, task_name = nil)
82
- # 只在最前面输出一次 banner
83
- @banner_printed ||= false
84
- unless @banner_printed
85
- puts Kdeploy::Banner.show
86
- @banner_printed = true
87
- end
88
-
49
+ show_banner_once
89
50
  load_task_file(task_file)
90
51
 
91
- tasks_to_run = if task_name
92
- [task_name.to_sym]
93
- else
94
- self.class.kdeploy_tasks.keys
95
- end
96
-
97
- tasks_to_run.each do |task|
98
- task_hosts = self.class.get_task_hosts(task)
99
- hosts = filter_hosts(options[:limit], task_hosts)
100
-
101
- if hosts.empty?
102
- puts Kdeploy::Banner.show_error("No hosts found for task: #{task}")
103
- next
104
- end
105
-
106
- if options[:dry_run]
107
- print_dry_run(hosts, task)
108
- next
109
- end
110
-
111
- output = ConsoleOutput.new
112
- runner = Runner.new(hosts, self.class.kdeploy_tasks, parallel: options[:parallel], output: output)
113
- results = runner.run(task)
114
- print_results(results, task)
115
- end
52
+ tasks_to_run = determine_tasks(task_name)
53
+ execute_tasks(tasks_to_run)
116
54
  rescue StandardError => e
117
55
  puts Kdeploy::Banner.show_error(e.message)
118
56
  exit 1
@@ -121,11 +59,7 @@ module Kdeploy
121
59
  private
122
60
 
123
61
  def load_task_file(file)
124
- unless File.exist?(file)
125
- puts Kdeploy::Banner.show_error("Task file not found: #{file}")
126
- exit 1
127
- end
128
-
62
+ validate_task_file(file)
129
63
  # 用 instance_eval 并传递顶层 binding,兼容 heredoc
130
64
  self.class.module_eval(File.read(file), file)
131
65
  rescue StandardError => e
@@ -134,6 +68,19 @@ module Kdeploy
134
68
  raise
135
69
  end
136
70
 
71
+ def validate_task_file(file)
72
+ return if File.exist?(file)
73
+
74
+ puts Kdeploy::Banner.show_error("Task file not found: #{file}")
75
+ exit 1
76
+ end
77
+
78
+ def show_general_help
79
+ formatter = HelpFormatter.new
80
+ puts Kdeploy::Banner.show
81
+ puts formatter.format_help
82
+ end
83
+
137
84
  def filter_hosts(limit, task_hosts)
138
85
  hosts = self.class.kdeploy_hosts.slice(*task_hosts)
139
86
  return hosts unless limit
@@ -143,139 +90,137 @@ module Kdeploy
143
90
  end
144
91
 
145
92
  def print_dry_run(hosts, task_name)
93
+ formatter = OutputFormatter.new
146
94
  puts Kdeploy::Banner.show
147
- pastel = Pastel.new
148
- puts TTY::Box.frame(
149
- 'Showing what would be done without executing any commands',
150
- title: { top_left: ' Dry Run Mode ', bottom_right: ' Kdeploy ' },
151
- style: {
152
- border: {
153
- fg: :blue
154
- }
155
- }
156
- )
95
+ puts formatter.format_dry_run_header
157
96
  puts
158
97
 
159
98
  hosts.each do |name, config|
160
- commands = self.class.kdeploy_tasks[task_name][:block].call
161
- output = commands.map do |command|
162
- case command[:type]
163
- when :run
164
- "#{pastel.green('>')} #{command[:command]}"
165
- when :upload
166
- "#{pastel.blue('>')} Upload: #{command[:source]} -> #{command[:destination]}"
167
- end
168
- end.join("\n")
169
-
170
- puts TTY::Box.frame(
171
- output,
172
- title: { top_left: " #{name} (#{config[:ip]}) " },
173
- style: {
174
- border: {
175
- fg: :yellow
176
- }
177
- }
178
- )
179
- puts
99
+ print_dry_run_for_host(name, config, task_name, formatter)
180
100
  end
181
101
  end
182
102
 
103
+ def print_dry_run_for_host(name, config, task_name, formatter)
104
+ commands = self.class.kdeploy_tasks[task_name][:block].call
105
+ output = commands.map { |cmd| formatter.format_command_for_dry_run(cmd) }.join("\n")
106
+ title = "#{name} (#{config[:ip]})"
107
+ puts formatter.format_dry_run_box(title, output)
108
+ puts
109
+ end
110
+
183
111
  def print_results(results, task_name)
184
- pastel = Pastel.new
185
- puts pastel.cyan("\nPLAY [#{task_name}] " + ('*' * 64))
112
+ formatter = OutputFormatter.new
113
+ puts formatter.format_task_header(task_name)
186
114
 
187
115
  results.each do |host, result|
188
- status = case result[:status]
189
- when :success then pastel.green('ok')
190
- when :changed then pastel.yellow('changed')
191
- else pastel.red('failed')
192
- end
193
- puts pastel.bright_white("\n#{host.ljust(24)} : #{status}")
194
-
195
- if %i[success changed].include?(result[:status])
196
- shown = {}
197
- grouped = result[:output].group_by { |step| step[:type] || :run }
198
- grouped.each do |type, steps|
199
- case type
200
- when :upload
201
- puts pastel.green(' === Upload ===')
202
- steps.each do |step|
203
- key = [step[:command], type].hash
204
- next if shown[key]
205
-
206
- shown[key] = true
207
- duration_str = step[:duration] ? pastel.dim(" [#{'%.2f' % step[:duration]}s]") : ''
208
- puts pastel.green(" [upload] #{step[:command].sub('upload: ', '')}#{duration_str}")
209
- end
210
- when :upload_template
211
- puts pastel.yellow(' === Template ===')
212
- steps.each do |step|
213
- key = [step[:command], type].hash
214
- next if shown[key]
215
-
216
- shown[key] = true
217
- duration_str = step[:duration] ? pastel.dim(" [#{'%.2f' % step[:duration]}s]") : ''
218
- puts pastel.yellow(" [template] #{step[:command].sub('upload_template: ', '')}#{duration_str}")
219
- end
220
- when :run
221
- puts pastel.cyan(' === Run ===')
222
- steps.each do |step|
223
- key = [step[:command], type].hash
224
- next if shown[key]
225
-
226
- shown[key] = true
227
- duration_str = step[:duration] ? pastel.dim(" [#{'%.2f' % step[:duration]}s]") : ''
228
- puts pastel.cyan(" [run] #{step[:command].to_s.lines.first.strip}#{duration_str}")
229
- # 多行命令内容高亮
230
- cmd_lines = step[:command].to_s.lines[1..].map(&:strip).reject(&:empty?)
231
- cmd_lines.each { |line| puts pastel.cyan(" > #{line}") } if cmd_lines.any?
232
- if step[:output].is_a?(Hash) && step[:output][:stdout]
233
- step[:output][:stdout].each_line do |line|
234
- puts pastel.green(" #{line.rstrip}") unless line.strip.empty?
235
- end
236
- elsif step[:output].is_a?(String)
237
- step[:output].each_line do |line|
238
- puts pastel.green(" #{line.rstrip}") unless line.strip.empty?
239
- end
240
- end
241
- end
242
- end
243
- end
244
- else
245
- # 失败主机高亮错误
246
- err = result[:error] || (if result[:output].is_a?(Array)
247
- result[:output].map do |o|
248
- o[:output][:stderr] if o[:output].is_a?(Hash)
249
- end.compact.join("\n")
250
- else
251
- result[:output].to_s
252
- end)
253
- puts pastel.red(" ERROR: #{err}")
254
- end
116
+ puts formatter.format_host_status(host, result[:status])
117
+ print_host_result(host, result, formatter)
118
+ end
119
+
120
+ print_summary(results, formatter)
121
+ end
122
+
123
+ def print_host_result(_host, result, formatter)
124
+ if %i[success changed].include?(result[:status])
125
+ print_success_result(result, formatter)
126
+ else
127
+ print_failure_result(result, formatter)
128
+ end
129
+ end
130
+
131
+ def print_success_result(result, formatter)
132
+ shown = {}
133
+ grouped = group_output_by_type(result[:output])
134
+
135
+ grouped.each do |type, steps|
136
+ output_lines = format_steps_by_type(type, steps, shown, formatter)
137
+ output_lines.each { |line| puts line }
255
138
  end
139
+ end
140
+
141
+ def group_output_by_type(output)
142
+ output.group_by { |step| step[:type] || :run }
143
+ end
144
+
145
+ def format_steps_by_type(type, steps, shown, formatter)
146
+ case type
147
+ when :upload
148
+ formatter.format_upload_steps(steps, shown)
149
+ when :upload_template
150
+ formatter.format_template_steps(steps, shown)
151
+ when :run
152
+ formatter.format_run_steps(steps, shown)
153
+ else
154
+ []
155
+ end
156
+ end
157
+
158
+ def print_failure_result(result, formatter)
159
+ error_message = extract_error_message(result)
160
+ puts formatter.format_error(error_message)
161
+ end
256
162
 
257
- # summary
258
- puts pastel.cyan("\nPLAY RECAP #{'*' * 64}")
163
+ def print_summary(results, formatter)
164
+ puts formatter.format_summary_header
259
165
  max_host_len = results.keys.map(&:length).max || 16
260
- ok_w = 7
261
- changed_w = 11
262
- failed_w = 10
166
+
263
167
  results.keys.sort.each do |host|
264
168
  result = results[host]
265
- ok = %i[success changed].include?(result[:status]) ? result[:output].size : 0
266
- failed = result[:status] == :failed ? 1 : 0
267
- changed = result[:status] == :changed ? result[:output].size : 0
268
- ok_str = pastel.green("ok=#{ok.to_s.ljust(ok_w - 3)}")
269
- changed_str = pastel.yellow("changed=#{changed.to_s.ljust(changed_w - 8)}")
270
- failed_str = pastel.red("failed=#{failed.to_s.ljust(failed_w - 7)}")
271
- line = "#{host.ljust(max_host_len)} : #{ok_str} #{changed_str} #{failed_str}"
272
- if failed.positive?
273
- puts pastel.red(line)
274
- elsif ok.positive? && failed.zero?
275
- puts pastel.green(line)
276
- else
277
- puts line
278
- end
169
+ puts formatter.format_summary_line(host, result, max_host_len)
170
+ end
171
+ end
172
+
173
+ def show_banner_once
174
+ @banner_printed ||= false
175
+ return if @banner_printed
176
+
177
+ puts Kdeploy::Banner.show
178
+ @banner_printed = true
179
+ end
180
+
181
+ def determine_tasks(task_name)
182
+ task_name ? [task_name.to_sym] : self.class.kdeploy_tasks.keys
183
+ end
184
+
185
+ def execute_tasks(tasks_to_run)
186
+ tasks_to_run.each do |task|
187
+ execute_single_task(task)
188
+ end
189
+ end
190
+
191
+ def execute_single_task(task)
192
+ task_hosts = self.class.get_task_hosts(task)
193
+ hosts = filter_hosts(options[:limit], task_hosts)
194
+
195
+ if hosts.empty?
196
+ puts Kdeploy::Banner.show_error("No hosts found for task: #{task}")
197
+ return
198
+ end
199
+
200
+ if options[:dry_run]
201
+ print_dry_run(hosts, task)
202
+ return
203
+ end
204
+
205
+ run_task(hosts, task)
206
+ end
207
+
208
+ def run_task(hosts, task)
209
+ output = ConsoleOutput.new
210
+ runner = Runner.new(hosts, self.class.kdeploy_tasks, parallel: options[:parallel], output: output)
211
+ results = runner.run(task)
212
+ print_results(results, task)
213
+ end
214
+
215
+ def extract_error_message(result)
216
+ return result[:error] if result[:error]
217
+
218
+ if result[:output].is_a?(Array)
219
+ result[:output].map do |o|
220
+ o[:output][:stderr] if o[:output].is_a?(Hash)
221
+ end.compact.join("\n")
222
+ else
223
+ result[:output].to_s
279
224
  end
280
225
  end
281
226
  end
@@ -54,35 +54,52 @@ module Kdeploy
54
54
  return unless output.is_a?(Hash)
55
55
 
56
56
  pastel = @output.respond_to?(:pastel) ? @output.pastel : Pastel.new
57
+ show_stdout(output[:stdout])
58
+ show_stderr(output[:stderr], pastel)
59
+ end
60
+
61
+ def show_stdout(stdout)
62
+ return unless stdout && !stdout.empty?
57
63
 
58
- if output[:stdout] && !output[:stdout].empty?
59
- output[:stdout].each_line do |line|
60
- @output.write_line(" #{line.rstrip}") unless line.strip.empty?
61
- end
64
+ stdout.each_line do |line|
65
+ @output.write_line(" #{line.rstrip}") unless line.strip.empty?
62
66
  end
67
+ end
63
68
 
64
- return unless output[:stderr] && !output[:stderr].empty?
69
+ def show_stderr(stderr, pastel)
70
+ return unless stderr && !stderr.empty?
65
71
 
66
- output[:stderr].each_line do |line|
72
+ stderr.each_line do |line|
67
73
  @output.write_line(pastel.green(" #{line.rstrip}")) unless line.strip.empty?
68
74
  end
69
75
  end
70
76
 
71
77
  def show_command_header(host_name, type, description)
72
- pastel = @output.respond_to?(:pastel) ? @output.pastel : Pastel.new
78
+ pastel = get_pastel
73
79
  @output.write_line(pastel.bright_white("\n#{host_name.ljust(24)} : "))
80
+ format_command_by_type(type, description, pastel)
81
+ end
74
82
 
83
+ def get_pastel
84
+ @output.respond_to?(:pastel) ? @output.pastel : Pastel.new
85
+ end
86
+
87
+ def format_command_by_type(type, description, pastel)
75
88
  case type
76
89
  when :run
77
- @output.write_line(pastel.cyan(" [run] #{description.lines.first.strip}"))
78
- description.lines[1..].each do |line|
79
- @output.write_line(" > #{line.strip}") unless line.strip.empty?
80
- end
90
+ format_run_command(description, pastel)
81
91
  when :upload
82
92
  @output.write_line(pastel.green(" [upload] #{description}"))
83
93
  when :upload_template
84
94
  @output.write_line(pastel.yellow(" [template] #{description}"))
85
95
  end
86
96
  end
97
+
98
+ def format_run_command(description, pastel)
99
+ @output.write_line(pastel.cyan(" [run] #{description.lines.first.strip}"))
100
+ description.lines[1..].each do |line|
101
+ @output.write_line(" > #{line.strip}") unless line.strip.empty?
102
+ end
103
+ end
87
104
  end
88
105
  end
@@ -11,12 +11,10 @@ module Kdeploy
11
11
 
12
12
  def self.group_key_for(cmd)
13
13
  case cmd[:type]
14
- when :upload
14
+ when :upload, :upload_template
15
15
  "#{cmd[:type]}_#{cmd[:source]}"
16
16
  when :run
17
17
  "#{cmd[:type]}_#{cmd[:command].to_s.lines.first.strip}"
18
- when :upload_template
19
- "#{cmd[:type]}_#{cmd[:source]}"
20
18
  else
21
19
  cmd[:type].to_s
22
20
  end
data/lib/kdeploy/dsl.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require 'set'
4
4
 
5
5
  module Kdeploy
6
+ # Domain-specific language for defining hosts, roles, and tasks
6
7
  module DSL
7
8
  def self.extended(base)
8
9
  base.instance_variable_set(:@kdeploy_hosts, {})
@@ -32,21 +33,31 @@ module Kdeploy
32
33
 
33
34
  def task(name, on: nil, roles: nil, &block)
34
35
  kdeploy_tasks[name] = {
35
- hosts: if on.is_a?(Array)
36
- on
37
- else
38
- (on ? [on] : nil)
39
- end,
40
- roles: if roles.is_a?(Array)
41
- roles
42
- else
43
- (roles ? [roles] : nil)
44
- end,
45
- block: lambda {
46
- @kdeploy_commands = []
47
- instance_eval(&block)
48
- @kdeploy_commands
49
- }
36
+ hosts: normalize_hosts_option(on),
37
+ roles: normalize_roles_option(roles),
38
+ block: create_task_block(block)
39
+ }
40
+ end
41
+
42
+ def normalize_hosts_option(on)
43
+ return on if on.is_a?(Array)
44
+ return [on] if on
45
+
46
+ nil
47
+ end
48
+
49
+ def normalize_roles_option(roles)
50
+ return roles if roles.is_a?(Array)
51
+ return [roles] if roles
52
+
53
+ nil
54
+ end
55
+
56
+ def create_task_block(block)
57
+ lambda {
58
+ @kdeploy_commands = []
59
+ instance_eval(&block)
60
+ @kdeploy_commands
50
61
  }
51
62
  end
52
63
 
@@ -70,31 +81,39 @@ module Kdeploy
70
81
  }
71
82
  end
72
83
 
73
- def inventory(&)
74
- instance_eval(&) if block_given?
84
+ def inventory(&block)
85
+ instance_eval(&block) if block_given?
75
86
  end
76
87
 
77
88
  def get_task_hosts(task_name)
78
89
  task = kdeploy_tasks[task_name]
79
- return kdeploy_hosts.keys if !task || (!task[:hosts] && !task[:roles])
90
+ return kdeploy_hosts.keys if task_empty?(task)
80
91
 
81
92
  hosts = Set.new
93
+ add_explicit_hosts(task, hosts)
94
+ add_role_hosts(task, hosts)
95
+ hosts.to_a
96
+ end
97
+
98
+ def task_empty?(task)
99
+ !task || (!task[:hosts] && !task[:roles])
100
+ end
82
101
 
83
- # 添加指定的主机
102
+ def add_explicit_hosts(task, hosts)
84
103
  task[:hosts]&.each do |host|
85
104
  hosts.add(host) if kdeploy_hosts.key?(host)
86
105
  end
106
+ end
87
107
 
88
- # 添加角色中的主机
108
+ def add_role_hosts(task, hosts)
89
109
  task[:roles]&.each do |role|
90
- next unless (role_hosts = kdeploy_roles[role])
110
+ role_hosts = kdeploy_roles[role]
111
+ next unless role_hosts
91
112
 
92
113
  role_hosts.each do |host|
93
114
  hosts.add(host) if kdeploy_hosts.key?(host)
94
115
  end
95
116
  end
96
-
97
- hosts.to_a
98
117
  end
99
118
  end
100
119
  end
@@ -4,6 +4,7 @@ require 'net/ssh'
4
4
  require 'net/scp'
5
5
 
6
6
  module Kdeploy
7
+ # SSH/SCP executor for remote command execution and file operations
7
8
  class Executor
8
9
  def initialize(host_config)
9
10
  @host = host_config[:name]
@@ -16,28 +17,7 @@ module Kdeploy
16
17
 
17
18
  def execute(command)
18
19
  Net::SSH.start(@ip, @user, ssh_options) do |ssh|
19
- stdout = String.new
20
- stderr = String.new
21
-
22
- ssh.open_channel do |channel|
23
- channel.exec(command) do |_ch, success|
24
- raise SSHError, "Could not execute command: #{command}" unless success
25
-
26
- channel.on_data do |_ch, data|
27
- stdout << data
28
- end
29
-
30
- channel.on_extended_data do |_ch, _type, data|
31
- stderr << data
32
- end
33
- end
34
- end
35
- ssh.loop
36
- {
37
- stdout: stdout.strip,
38
- stderr: stderr.strip,
39
- command: command
40
- }
20
+ execute_command_on_ssh(ssh, command)
41
21
  end
42
22
  rescue Net::SSH::AuthenticationFailed => e
43
23
  raise SSHError.new("SSH authentication failed: #{e.message}", e)
@@ -45,6 +25,39 @@ module Kdeploy
45
25
  raise SSHError.new("SSH execution failed: #{e.message}", e)
46
26
  end
47
27
 
28
+ def execute_command_on_ssh(ssh, command)
29
+ stdout = String.new
30
+ stderr = String.new
31
+
32
+ ssh.open_channel do |channel|
33
+ channel.exec(command) do |_ch, success|
34
+ raise SSHError, "Could not execute command: #{command}" unless success
35
+
36
+ setup_channel_handlers(channel, stdout, stderr)
37
+ end
38
+ end
39
+ ssh.loop
40
+ build_command_result(stdout, stderr, command)
41
+ end
42
+
43
+ def setup_channel_handlers(channel, stdout, stderr)
44
+ channel.on_data do |_ch, data|
45
+ stdout << data
46
+ end
47
+
48
+ channel.on_extended_data do |_ch, _type, data|
49
+ stderr << data
50
+ end
51
+ end
52
+
53
+ def build_command_result(stdout, stderr, command)
54
+ {
55
+ stdout: stdout.strip,
56
+ stderr: stderr.strip,
57
+ command: command
58
+ }
59
+ end
60
+
48
61
  def upload(source, destination)
49
62
  Net::SCP.start(@ip, @user, ssh_options) do |scp|
50
63
  scp.upload!(source, destination)
@@ -62,18 +75,29 @@ module Kdeploy
62
75
  private
63
76
 
64
77
  def ssh_options
65
- options = {
78
+ options = base_ssh_options
79
+ add_authentication(options)
80
+ add_port_option(options)
81
+ options
82
+ end
83
+
84
+ def base_ssh_options
85
+ {
66
86
  verify_host_key: :never,
67
87
  timeout: 30
68
88
  }
89
+ end
69
90
 
91
+ def add_authentication(options)
70
92
  if @password
71
93
  options[:password] = @password
72
94
  elsif @key
73
95
  options[:keys] = [@key]
74
96
  end
75
- options[:port] = @port if @port # 新增端口传递
76
- options
97
+ end
98
+
99
+ def add_port_option(options)
100
+ options[:port] = @port if @port
77
101
  end
78
102
  end
79
103
  end