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