kdeploy 1.3.4 → 1.4.1

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.
@@ -22,12 +22,132 @@ module Kdeploy
22
22
  end
23
23
  end
24
24
 
25
+ # Per-invocation collector so parallel hosts do not race on DSL ivars.
26
+ class TaskContext
27
+ def initialize(dsl, commands, on_failure_commands, vars)
28
+ @dsl = dsl
29
+ @commands = commands
30
+ @on_failure_commands = on_failure_commands
31
+ @vars = vars.transform_keys(&:to_sym)
32
+ @failure_mode = false
33
+ end
34
+
35
+ def on_failure(&block)
36
+ raise ArgumentError, 'on_failure requires a block' unless block
37
+
38
+ previous = @failure_mode
39
+ @failure_mode = true
40
+ instance_eval(&block)
41
+ ensure
42
+ @failure_mode = previous
43
+ end
44
+
45
+ def run(command, sudo: nil)
46
+ buf << { type: :run, command: interpolate(command), sudo: sudo }
47
+ end
48
+
49
+ def upload(source, destination)
50
+ buf << { type: :upload, source: interpolate(source), destination: interpolate(destination) }
51
+ end
52
+
53
+ def upload_template(source, destination, variables = {})
54
+ buf << {
55
+ type: :upload_template,
56
+ source: interpolate(source),
57
+ destination: interpolate(destination),
58
+ variables: interpolate_hash(variables)
59
+ }
60
+ end
61
+
62
+ def sync(source, destination, ignore: [], delete: false, exclude: [], fast: nil, parallel: nil)
63
+ buf << {
64
+ type: :sync,
65
+ source: interpolate(source),
66
+ destination: interpolate(destination),
67
+ ignore: Array(ignore).map { |p| interpolate(p.to_s) },
68
+ exclude: Array(exclude).map { |p| interpolate(p.to_s) },
69
+ delete: delete,
70
+ fast: fast,
71
+ parallel: parallel
72
+ }
73
+ end
74
+
75
+ def package(name, version: nil, platform: :apt)
76
+ cmd = @dsl.build_package_command(interpolate(name.to_s), version && interpolate(version.to_s), platform)
77
+ buf << { type: :run, command: cmd, sudo: true }
78
+ end
79
+
80
+ def service(name, action: :start)
81
+ Array(action).each do |a|
82
+ cmd = "systemctl #{a} #{Shellwords.escape(interpolate(name.to_s))}"
83
+ buf << { type: :run, command: cmd, sudo: true }
84
+ end
85
+ end
86
+
87
+ def template(destination, source: nil, variables: nil, &block)
88
+ if block
89
+ opts = TemplateOptions.new
90
+ opts.instance_eval(&block)
91
+ src = opts.source || raise(ArgumentError, 'template requires source')
92
+ vars = opts.variables || {}
93
+ else
94
+ raise ArgumentError, 'template requires source' unless source
95
+
96
+ src = source
97
+ vars = variables || {}
98
+ end
99
+ upload_template(src, destination, vars)
100
+ end
101
+
102
+ def file(destination, source:)
103
+ upload(source, destination)
104
+ end
105
+
106
+ def directory(path, mode: nil)
107
+ cmd = "mkdir -p #{Shellwords.escape(interpolate(path.to_s))}"
108
+ buf << { type: :run, command: cmd, sudo: true }
109
+ return unless mode
110
+
111
+ buf << { type: :run, command: "chmod #{mode} #{Shellwords.escape(interpolate(path.to_s))}", sudo: true }
112
+ end
113
+
114
+ def wait_port(port, timeout: 30, interval: 1)
115
+ buf << {
116
+ type: :wait_port,
117
+ port: port.to_i,
118
+ timeout: timeout.to_f,
119
+ interval: interval.to_f
120
+ }
121
+ end
122
+
123
+ def wait_http(url, timeout: 30, interval: 1, code: 200)
124
+ buf << {
125
+ type: :wait_http,
126
+ url: interpolate(url),
127
+ timeout: timeout.to_f,
128
+ interval: interval.to_f,
129
+ code: code.to_i
130
+ }
131
+ end
132
+
133
+ private
134
+
135
+ def buf
136
+ @failure_mode ? @on_failure_commands : @commands
137
+ end
138
+
139
+ def interpolate(value)
140
+ @dsl.interpolate_string(value, @vars)
141
+ end
142
+
143
+ def interpolate_hash(hash)
144
+ hash.to_h.transform_values { |v| v.is_a?(String) ? interpolate(v) : v }
145
+ end
146
+ end
147
+
25
148
  # Domain-specific language for defining hosts, roles, and tasks
26
149
  module DSL
27
150
  def self.included(base)
28
- # Support `include Kdeploy::DSL` by promoting the DSL methods to class methods.
29
- # This keeps tests and external integrations simpler while preserving the
30
- # primary usage pattern (CLI uses `extend DSL`).
31
151
  base.extend(self)
32
152
  end
33
153
 
@@ -35,10 +155,9 @@ module Kdeploy
35
155
  base.instance_variable_set(:@kdeploy_hosts, {})
36
156
  base.instance_variable_set(:@kdeploy_tasks, {})
37
157
  base.instance_variable_set(:@kdeploy_roles, {})
158
+ base.instance_variable_set(:@kdeploy_vars, {})
38
159
  end
39
160
 
40
- # Stable read accessors for tests/integrations.
41
- # Keep these as aliases so internal storage can evolve without breaking callers.
42
161
  def hosts
43
162
  kdeploy_hosts
44
163
  end
@@ -63,6 +182,44 @@ module Kdeploy
63
182
  @kdeploy_roles ||= {}
64
183
  end
65
184
 
185
+ def kdeploy_vars
186
+ @kdeploy_vars ||= {}
187
+ end
188
+
189
+ def clear_inventory!
190
+ kdeploy_hosts.clear
191
+ kdeploy_roles.clear
192
+ kdeploy_tasks.clear
193
+ kdeploy_vars.clear
194
+ end
195
+
196
+ def vars(hash = nil, **kwargs)
197
+ merged = {}
198
+ merged.merge!(hash) if hash.is_a?(Hash)
199
+ merged.merge!(kwargs)
200
+ merged.each { |k, v| kdeploy_vars[k.to_sym] = v }
201
+ kdeploy_vars
202
+ end
203
+
204
+ def merge_extra_vars!(extra)
205
+ return if extra.nil? || extra.empty?
206
+
207
+ extra.each { |k, v| kdeploy_vars[k.to_sym] = v }
208
+ end
209
+
210
+ def interpolate_string(value, local_vars = {})
211
+ return value unless value.is_a?(String)
212
+ return value unless value.include?('%{')
213
+
214
+ binding_vars = kdeploy_vars.merge(local_vars.transform_keys(&:to_sym))
215
+ value.gsub(/%\{(\w+)\}/) do
216
+ key = Regexp.last_match(1).to_sym
217
+ raise ArgumentError, "Unknown variable %{#{key}}" unless binding_vars.key?(key)
218
+
219
+ binding_vars[key].to_s
220
+ end
221
+ end
222
+
66
223
  def host(name, **options)
67
224
  kdeploy_hosts[name] = options.merge(name: name)
68
225
  end
@@ -71,46 +228,37 @@ module Kdeploy
71
228
  kdeploy_roles[name] = hosts
72
229
  end
73
230
 
74
- def task(name, on: nil, roles: nil, &block)
231
+ def task(name, on: nil, roles: nil, tags: nil, &block)
75
232
  kdeploy_tasks[name] = {
76
233
  hosts: normalize_hosts_option(on),
77
234
  roles: normalize_roles_option(roles),
235
+ tags: normalize_tags_option(tags),
78
236
  block: create_task_block(block)
79
237
  }
80
238
  end
81
239
 
82
- # Assign task to roles or hosts after task definition
83
- def assign_task(task_name, on: nil, roles: nil)
240
+ def assign_task(task_name, on: nil, roles: nil, tags: nil)
84
241
  task = kdeploy_tasks[task_name.to_sym]
85
242
  raise ArgumentError, "Task #{task_name} not found" unless task
86
243
 
87
244
  task[:hosts] = normalize_hosts_option(on) if on
88
245
  task[:roles] = normalize_roles_option(roles) if roles
246
+ task[:tags] = normalize_tags_option(tags) if tags
89
247
  end
90
248
 
91
- # Include task file and automatically assign all tasks to specified roles or hosts
92
249
  def include_tasks(file_path, roles: nil, on: nil)
93
- # Resolve relative paths based on the caller's file location
94
250
  unless File.absolute_path?(file_path)
95
251
  caller_file = caller_locations(1, 1).first.path
96
252
  base_dir = File.dirname(File.expand_path(caller_file))
97
253
  file_path = File.expand_path(file_path, base_dir)
98
254
  end
99
255
 
100
- # Store tasks before loading
101
256
  tasks_before = kdeploy_tasks.keys
102
-
103
- # Load the task file
104
257
  module_eval(File.read(file_path), file_path)
258
+ new_tasks = kdeploy_tasks.keys - tasks_before
105
259
 
106
- # Get newly added tasks
107
- tasks_after = kdeploy_tasks.keys
108
- new_tasks = tasks_after - tasks_before
109
-
110
- # Assign roles/hosts to all new tasks (only if task doesn't already have hosts/roles)
111
260
  new_tasks.each do |task_name|
112
261
  task = kdeploy_tasks[task_name]
113
- # Only assign if task doesn't already have hosts or roles defined
114
262
  next if task[:hosts] || task[:roles]
115
263
 
116
264
  assign_task(task_name, roles: roles, on: on) if roles || on
@@ -119,7 +267,6 @@ module Kdeploy
119
267
 
120
268
  def normalize_hosts_option(on)
121
269
  return on if on.is_a?(Array)
122
-
123
270
  return [on] if on
124
271
 
125
272
  nil
@@ -127,106 +274,40 @@ module Kdeploy
127
274
 
128
275
  def normalize_roles_option(roles)
129
276
  return roles if roles.is_a?(Array)
130
-
131
277
  return [roles] if roles
132
278
 
133
279
  nil
134
280
  end
135
281
 
136
- def create_task_block(block)
137
- lambda {
138
- @kdeploy_commands = []
139
- instance_eval(&block)
140
- @kdeploy_commands
141
- }
142
- end
282
+ def normalize_tags_option(tags)
283
+ return nil if tags.nil?
143
284
 
144
- def run(command, sudo: nil)
145
- @kdeploy_commands ||= []
146
- @kdeploy_commands << { type: :run, command: command, sudo: sudo }
285
+ Array(tags).map { |t| t.to_s.to_sym }
147
286
  end
148
287
 
149
- def upload(source, destination)
150
- @kdeploy_commands ||= []
151
- @kdeploy_commands << { type: :upload, source: source, destination: destination }
152
- end
153
-
154
- def upload_template(source, destination, variables = {})
155
- @kdeploy_commands ||= []
156
- @kdeploy_commands << {
157
- type: :upload_template,
158
- source: source,
159
- destination: destination,
160
- variables: variables
161
- }
162
- end
163
-
164
- def sync(source, destination, ignore: [], delete: false, exclude: [], fast: nil, parallel: nil)
165
- @kdeploy_commands ||= []
166
- @kdeploy_commands << {
167
- type: :sync,
168
- source: source,
169
- destination: destination,
170
- ignore: Array(ignore),
171
- exclude: Array(exclude),
172
- delete: delete,
173
- fast: fast,
174
- parallel: parallel
288
+ def create_task_block(block)
289
+ dsl = self
290
+ lambda {
291
+ commands = []
292
+ on_failure = []
293
+ ctx = TaskContext.new(dsl, commands, on_failure, dsl.kdeploy_vars.dup)
294
+ ctx.instance_eval(&block)
295
+ { commands: commands, on_failure: on_failure }
175
296
  }
176
297
  end
177
298
 
178
- # -------------------------------------------------------------------------
179
- # Chef-style resource DSL (compiles to run/upload/upload_template)
180
- # -------------------------------------------------------------------------
181
-
182
- # Install a system package. Defaults to apt; supports platform: :yum.
183
- def package(name, version: nil, platform: :apt)
184
- @kdeploy_commands ||= []
185
- cmd = build_package_command(name, version, platform)
186
- @kdeploy_commands << { type: :run, command: cmd, sudo: true }
187
- end
188
-
189
- # Manage a systemd service. action supports :start, :stop, :restart, :reload, :enable, :disable.
190
- def service(name, action: :start)
191
- @kdeploy_commands ||= []
192
- actions = Array(action)
193
- actions.each do |a|
194
- cmd = "systemctl #{a} #{Shellwords.escape(name.to_s)}"
195
- @kdeploy_commands << { type: :run, command: cmd, sudo: true }
196
- end
197
- end
198
-
199
- # Deploy an ERB template to a remote path. Supports block and keyword args.
200
- def template(destination, source: nil, variables: nil, &block)
201
- @kdeploy_commands ||= []
202
- if block
203
- opts = TemplateOptions.new
204
- opts.instance_eval(&block)
205
- src = opts.source || raise(ArgumentError, 'template requires source')
206
- vars = opts.variables || {}
299
+ def self.normalize_plan(raw)
300
+ case raw
301
+ when Hash
302
+ {
303
+ commands: Array(raw[:commands] || raw['commands']),
304
+ on_failure: Array(raw[:on_failure] || raw['on_failure'])
305
+ }
306
+ when Array
307
+ { commands: raw, on_failure: [] }
207
308
  else
208
- raise ArgumentError, 'template requires source' unless source
209
-
210
- src = source
211
- vars = variables || {}
309
+ { commands: Array(raw), on_failure: [] }
212
310
  end
213
- upload_template(src, destination, vars)
214
- end
215
-
216
- # Upload a local file to a remote path.
217
- def file(destination, source:)
218
- @kdeploy_commands ||= []
219
- upload(source, destination)
220
- end
221
-
222
- # Ensure a remote directory exists. Supports mode option.
223
- def directory(path, mode: nil)
224
- @kdeploy_commands ||= []
225
- cmd = "mkdir -p #{Shellwords.escape(path.to_s)}"
226
- @kdeploy_commands << { type: :run, command: cmd, sudo: true }
227
- return unless mode
228
-
229
- @kdeploy_commands << { type: :run, command: "chmod #{mode} #{Shellwords.escape(path.to_s)}", sudo: true }
230
311
  end
231
312
 
232
313
  def inventory(&block)
@@ -243,23 +324,43 @@ module Kdeploy
243
324
  hosts.to_a
244
325
  end
245
326
 
327
+ def tasks_matching_tags(tag_list)
328
+ wanted = Array(tag_list).map { |t| t.to_s.to_sym }
329
+ return kdeploy_tasks.keys if wanted.empty?
330
+
331
+ kdeploy_tasks.select do |_name, task|
332
+ task[:tags]&.any? { |t| wanted.include?(t) }
333
+ end.keys
334
+ end
335
+
246
336
  def task_empty?(task)
247
337
  !task || (!task[:hosts] && !task[:roles])
248
338
  end
249
339
 
250
340
  def add_explicit_hosts(task, hosts)
251
341
  task[:hosts]&.each do |host|
252
- hosts.add(host) if kdeploy_hosts.key?(host)
342
+ if kdeploy_hosts.key?(host)
343
+ hosts.add(host)
344
+ else
345
+ warn "Warning: task host not in inventory (skipped): #{host}"
346
+ end
253
347
  end
254
348
  end
255
349
 
256
350
  def add_role_hosts(task, hosts)
257
351
  task[:roles]&.each do |role|
258
352
  role_hosts = kdeploy_roles[role]
259
- next unless role_hosts
353
+ unless role_hosts
354
+ warn "Warning: task role not defined (skipped): #{role}"
355
+ next
356
+ end
260
357
 
261
358
  role_hosts.each do |host|
262
- hosts.add(host) if kdeploy_hosts.key?(host)
359
+ if kdeploy_hosts.key?(host)
360
+ hosts.add(host)
361
+ else
362
+ warn "Warning: role #{role} host not in inventory (skipped): #{host}"
363
+ end
263
364
  end
264
365
  end
265
366
  end
@@ -13,6 +13,7 @@ module Kdeploy
13
13
  create_directory_structure
14
14
  create_deploy_file
15
15
  create_config_files
16
+ create_kdeploy_yml
16
17
  create_readme
17
18
  show_success_message
18
19
  end
@@ -165,6 +166,20 @@ module Kdeploy
165
166
  CONF
166
167
  end
167
168
 
169
+ def create_kdeploy_yml
170
+ path = File.join(@target_dir, '.kdeploy.yml')
171
+ return if File.exist?(path)
172
+
173
+ File.write(path, <<~YAML)
174
+ # Kdeploy project config (see SECURITY.md in the kdeploy repo)
175
+ parallel: 10
176
+ ssh_timeout: 30
177
+ # Library default is accept_new (safer). Use never only for ephemeral labs.
178
+ verify_host_key: accept_new
179
+ retries: 0
180
+ YAML
181
+ end
182
+
168
183
  def create_readme
169
184
  File.write(File.join(@target_dir, 'README.md'), <<~MD)
170
185
  # Deployment Project
@@ -215,6 +230,10 @@ module Kdeploy
215
230
 
216
231
  See the main documentation for more details on sudo usage.
217
232
 
233
+ ## Security
234
+
235
+ Default `verify_host_key` is `accept_new`. Use `always` for strict known_hosts, or `never` only for ephemeral labs.
236
+
218
237
  ## 🚀 Usage
219
238
 
220
239
  ### Task Execution
@@ -283,6 +302,7 @@ module Kdeploy
283
302
  #{pastel.dim(" #{File.join(@target_dir, 'config/nginx.conf.erb')}")}
284
303
  #{pastel.dim(" #{File.join(@target_dir, 'config/app.conf')}")}
285
304
  #{pastel.dim(" #{File.join(@target_dir, 'README.md')}")}
305
+ #{pastel.dim(" #{File.join(@target_dir, '.kdeploy.yml')}")}
286
306
 
287
307
  #{pastel.bright_white('Try running:')}
288
308
  #{pastel.bright_cyan(" kdeploy execute #{File.join(@target_dir, 'deploy.rb')} deploy_web --dry-run")}
@@ -1,6 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'timeout'
4
+ require 'socket'
5
+ require 'uri'
6
+ require 'net/http'
4
7
 
5
8
  module Kdeploy
6
9
  # Executes a single command and records execution time
@@ -83,6 +86,32 @@ module Kdeploy
83
86
  build_sync_result(source, destination, result, duration)
84
87
  end
85
88
 
89
+ def execute_wait_port(command, _host_name)
90
+ port = command[:port]
91
+ timeout = command[:timeout] || 30
92
+ interval = command[:interval] || 1
93
+ ip = @executor.host_ip
94
+ _result, duration = measure_time do
95
+ with_timeout { wait_until(timeout, interval, "port #{ip}:#{port}") { port_open?(ip, port) } }
96
+ end
97
+ { command: "wait_port: #{ip}:#{port}", duration: duration, type: :wait_port }
98
+ end
99
+
100
+ # -- runtime %{ip}/%{host} placeholders
101
+ def execute_wait_http(command, host_name)
102
+ url = command[:url].to_s.gsub('%{ip}', @executor.host_ip.to_s).gsub('%{host}', host_name.to_s)
103
+ # rubocop:enable Style/FormatStringToken
104
+ timeout = command[:timeout] || 30
105
+ interval = command[:interval] || 1
106
+ expect_code = command[:code] || 200
107
+ _result, duration = measure_time do
108
+ with_timeout do
109
+ wait_until(timeout, interval, "http #{url}") { http_ok?(url, expect_code) }
110
+ end
111
+ end
112
+ { command: "wait_http: #{url} (#{expect_code})", duration: duration, type: :wait_http }
113
+ end
114
+
86
115
  private
87
116
 
88
117
  def build_sync_result(source, destination, result, duration)
@@ -112,7 +141,7 @@ module Kdeploy
112
141
  attempts += 1
113
142
  yield
114
143
  rescue SSHError, SCPError, TemplateError => e
115
- raise if e.is_a?(SSHError) && e.exit_status && !retry_on_exit_status?(e.exit_status, exit_codes)
144
+ raise unless retryable_error?(e, exit_codes)
116
145
  raise if attempts > (max_retries + 1)
117
146
 
118
147
  sleep(@retry_delay) if @retry_delay.positive?
@@ -120,14 +149,61 @@ module Kdeploy
120
149
  end
121
150
  end
122
151
 
152
+ def retryable_error?(error, exit_codes)
153
+ return true unless error.is_a?(SSHError)
154
+ return retry_on_exit_status?(error.exit_status, exit_codes) if error.exit_status
155
+
156
+ !authentication_error?(error)
157
+ end
158
+
159
+ def authentication_error?(error)
160
+ original = error.respond_to?(:original_error) ? error.original_error : nil
161
+ return true if original && original.class.name.include?('AuthenticationFailed')
162
+
163
+ error.message.match?(/authentication failed/i)
164
+ end
165
+
123
166
  def with_timeout(&block)
124
167
  return yield unless @step_timeout&.positive?
125
168
 
126
169
  Timeout.timeout(@step_timeout, &block)
127
170
  rescue Timeout::Error
171
+ @executor.abort! if @executor.respond_to?(:abort!)
128
172
  raise StepTimeoutError, "exceeded #{@step_timeout}s"
129
173
  end
130
174
 
175
+ def wait_until(timeout, interval, label)
176
+ deadline = Time.now + timeout.to_f
177
+ loop do
178
+ return true if yield
179
+ raise StepTimeoutError, "#{label} not ready within #{timeout}s" if Time.now >= deadline
180
+
181
+ sleep([interval.to_f, 0.1].max)
182
+ end
183
+ end
184
+
185
+ def port_open?(ip, port)
186
+ Socket.tcp(ip, port, connect_timeout: 2) { true }
187
+ rescue StandardError
188
+ false
189
+ end
190
+
191
+ def http_ok?(url, expect_code)
192
+ uri = URI.parse(url)
193
+ response = Net::HTTP.start(
194
+ uri.host,
195
+ uri.port,
196
+ use_ssl: uri.scheme == 'https',
197
+ open_timeout: 2,
198
+ read_timeout: 2
199
+ ) do |http|
200
+ http.request(Net::HTTP::Get.new(uri.request_uri))
201
+ end
202
+ response.code.to_i == expect_code.to_i
203
+ rescue StandardError
204
+ false
205
+ end
206
+
131
207
  def retries_for(step_type)
132
208
  return @retries unless @retry_policy.is_a?(Hash) && step_type
133
209