confctl 2.0.0 → 2.2.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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +19 -1
  3. data/Gemfile +6 -0
  4. data/README.md +2 -1
  5. data/docs/carrier.md +12 -0
  6. data/lib/confctl/cli/app.rb +19 -0
  7. data/lib/confctl/cli/cluster.rb +183 -47
  8. data/lib/confctl/cli/generation.rb +44 -15
  9. data/lib/confctl/cli/swpins/channel.rb +6 -4
  10. data/lib/confctl/cli/swpins/cluster.rb +6 -4
  11. data/lib/confctl/cli/swpins/core.rb +6 -4
  12. data/lib/confctl/generation/build.rb +42 -1
  13. data/lib/confctl/generation/build_list.rb +10 -0
  14. data/lib/confctl/generation/host.rb +9 -5
  15. data/lib/confctl/generation/host_list.rb +20 -5
  16. data/lib/confctl/generation/unified.rb +5 -0
  17. data/lib/confctl/generation/unified_list.rb +10 -0
  18. data/lib/confctl/machine.rb +4 -0
  19. data/lib/confctl/machine_control.rb +3 -2
  20. data/lib/confctl/machine_list.rb +4 -0
  21. data/lib/confctl/machine_status.rb +1 -1
  22. data/lib/confctl/nix.rb +63 -18
  23. data/lib/confctl/nix_copy.rb +5 -5
  24. data/lib/confctl/null_logger.rb +7 -0
  25. data/lib/confctl/swpins/change_set.rb +11 -4
  26. data/lib/confctl/swpins/specs/git.rb +23 -16
  27. data/lib/confctl/swpins/specs/git_rev.rb +1 -1
  28. data/lib/confctl/system_command.rb +3 -2
  29. data/lib/confctl/version.rb +1 -1
  30. data/libexec/auto-rollback.rb +106 -0
  31. data/man/man8/confctl.8 +109 -72
  32. data/man/man8/confctl.8.md +91 -54
  33. data/nix/evaluator.nix +26 -1
  34. data/nix/modules/cluster/default.nix +20 -0
  35. data/nix/modules/confctl/carrier/base.nix +8 -6
  36. data/nix/modules/confctl/carrier/netboot/build-netboot-server.rb +209 -50
  37. data/nix/modules/confctl/carrier/netboot/nixos.nix +5 -3
  38. data/nix/modules/confctl/kexec-netboot/default.nix +38 -0
  39. data/nix/modules/confctl/kexec-netboot/kexec-netboot.8.adoc +62 -0
  40. data/nix/modules/confctl/kexec-netboot/kexec-netboot.rb +455 -0
  41. data/nix/modules/confctl/overlays.nix +15 -0
  42. data/nix/modules/module-list.nix +1 -0
  43. data/nix/modules/system-list.nix +3 -1
  44. data/shell.nix +9 -2
  45. metadata +8 -6
@@ -0,0 +1,455 @@
1
+ #!@ruby@/bin/ruby
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+ require 'uri'
6
+ require 'optparse'
7
+ require 'tempfile'
8
+
9
+ class KexecNetboot
10
+ MACHINE_FQDN = '@machineFqdn@'.freeze
11
+
12
+ KEXEC = '@kexecTools@/bin/kexec'.freeze
13
+
14
+ def initialize
15
+ @server_url = nil
16
+ @machine_fqdn = nil
17
+ @machine_gen = nil
18
+ @variant_name = nil
19
+ @interactive = false
20
+ @append_params = ''
21
+ @exec = false
22
+ @unload = false
23
+ @machines_json = nil
24
+ @tmp_files = []
25
+ end
26
+
27
+ def run
28
+ parse_arguments
29
+
30
+ if @unload && @exec
31
+ warn 'ERROR: use either --unload or --exec, not both'
32
+ exit 1
33
+ elsif @unload
34
+ return unload_kexec
35
+ elsif @exec
36
+ return exec_kexec
37
+ end
38
+
39
+ httproot = parse_httproot_from_cmdline
40
+ unless httproot
41
+ warn "ERROR: Could not find 'httproot=' parameter in /proc/cmdline"
42
+ exit 1
43
+ end
44
+
45
+ machines_url =
46
+ if @server_url
47
+ File.join(@server_url, 'machines.json')
48
+ else
49
+ derive_machines_json_url(httproot)
50
+ end
51
+
52
+ @machines_json = fetch_machines_json(machines_url)
53
+
54
+ machine_data = pick_machine(@machine_fqdn)
55
+
56
+ if machine_data.nil?
57
+ warn 'ERROR: No suitable machine found.'
58
+ exit 1
59
+ end
60
+
61
+ generation = pick_generation(machine_data, @machine_gen)
62
+
63
+ if generation.nil?
64
+ warn 'ERROR: No generation found/selected.'
65
+ exit 1
66
+ end
67
+
68
+ variant = pick_variant(generation, @variant_name)
69
+
70
+ combined_params = generation['kernel_params'].dup
71
+
72
+ if variant && variant['kernel_params']
73
+ combined_params.concat(variant['kernel_params'])
74
+ end
75
+
76
+ combined_params << @append_params
77
+ final_params = combined_params.join(' ')
78
+
79
+ if @interactive
80
+ puts
81
+ puts 'Selected configuration:'
82
+ puts " Machine: #{machine_data['fqdn']} (spin=#{machine_data['spin']})"
83
+ puts " Generation: #{generation['generation']} (#{generation['time_s']}, rev=#{generation['shortrev']}, kernel=#{generation['kernel_version']})"
84
+ if variant
85
+ puts " Variant: #{variant['name']} (#{variant['label']})"
86
+ else
87
+ puts ' Variant: none'
88
+ end
89
+ puts " Kernel params: #{final_params}"
90
+ puts
91
+
92
+ loop do
93
+ print 'Continue? [y/N]: '
94
+
95
+ case $stdin.readline.strip.downcase
96
+ when 'y'
97
+ puts
98
+ break
99
+ when 'n'
100
+ warn 'Aborting'
101
+ exit(false)
102
+ end
103
+ end
104
+ end
105
+
106
+ # Download kernel + initrd
107
+ kernel_path = download_file(generation['boot_files']['bzImage'])
108
+ initrd_path = download_file(generation['boot_files']['initrd'])
109
+
110
+ # kexec -l
111
+ load_kexec(kernel_path, initrd_path, final_params)
112
+
113
+ # Cleanup
114
+ cleanup_downloads
115
+ end
116
+
117
+ private
118
+
119
+ def parse_arguments
120
+ opt_parser = OptionParser.new do |opts|
121
+ opts.banner = "Usage: #{$0} [options]"
122
+
123
+ opts.on('-s', '--server-url URL', 'Specify URL of the netboot server') do |val|
124
+ @server_url = val
125
+ end
126
+
127
+ opts.on('-m', '--machine FQDN', 'Select machine by FQDN') do |val|
128
+ @machine_fqdn = val
129
+ end
130
+
131
+ opts.on('-g', '--generation N', 'Select generation by number or negative offset') do |val|
132
+ @machine_gen = val
133
+ end
134
+
135
+ opts.on('-v', '--variant NAME', 'Select a specific variant by name') do |val|
136
+ @variant_name = val
137
+ end
138
+
139
+ opts.on('-i', '--interactive', 'Enable interactive mode') do
140
+ @interactive = true
141
+ end
142
+
143
+ opts.on('-a', '--append PARAMS', 'Append parameters to kernel command line') do |val|
144
+ @append_params = val
145
+ end
146
+
147
+ opts.on('-e', '--exec', 'Run the currently loaded kernel') do
148
+ @exec = true
149
+ end
150
+
151
+ opts.on('-u', '--unload', 'Unload the current kexec target kernel and exit') do
152
+ @unload = true
153
+ end
154
+ end
155
+
156
+ opt_parser.parse!(ARGV)
157
+ end
158
+
159
+ def parse_httproot_from_cmdline
160
+ cmdline = File.read('/proc/cmdline').strip
161
+ cmdline[/\bhttproot=([^\s]+)/, 1]
162
+ end
163
+
164
+ def derive_machines_json_url(httproot)
165
+ uri = URI.parse(httproot)
166
+ path_parts = uri.path.split('/').reject(&:empty?)
167
+
168
+ if path_parts.size >= 3
169
+ 3.times { path_parts.pop }
170
+ end
171
+
172
+ new_path = "/#{path_parts.join('/')}"
173
+ new_path << '/' unless new_path.end_with?('/')
174
+ new_path << 'machines.json'
175
+
176
+ URI::HTTP.build(host: uri.host, port: uri.port, path: new_path).to_s
177
+ end
178
+
179
+ def fetch_machines_json(machines_url)
180
+ uri = URI.parse(machines_url)
181
+ response = Net::HTTP.get_response(uri)
182
+
183
+ unless response.is_a?(Net::HTTPSuccess)
184
+ warn "ERROR: Could not download #{machines_url}, HTTP #{response.code}"
185
+ exit 1
186
+ end
187
+
188
+ begin
189
+ JSON.parse(response.body)
190
+ rescue JSON::ParserError => e
191
+ warn "ERROR: Could not parse JSON: #{e}"
192
+ exit 1
193
+ end
194
+ end
195
+
196
+ def pick_machine(requested_fqdn)
197
+ machines = @machines_json['machines']
198
+ return if machines.nil? || machines.empty?
199
+
200
+ if requested_fqdn.nil? && @interactive
201
+ return interactive_pick_machine(machines)
202
+ end
203
+
204
+ requested_fqdn ||= MACHINE_FQDN
205
+
206
+ found = machines.detect { |m| m['fqdn'] == requested_fqdn }
207
+
208
+ if found.nil?
209
+ warn "Machine '#{requested_fqdn}' not found."
210
+ return
211
+ end
212
+
213
+ found
214
+ end
215
+
216
+ def interactive_pick_machine(machines)
217
+ format_str = "%5s %-30s %s\n"
218
+ default_machine = machines.detect { |m| m['fqdn'] == MACHINE_FQDN }
219
+
220
+ loop do
221
+ puts format(format_str, '', 'FQDN', 'SPIN')
222
+
223
+ machines.each_with_index do |m, idx|
224
+ current_mark = m['fqdn'] == default_machine['fqdn'] ? '*' : ''
225
+ puts format(format_str, "[#{idx + 1}]" + current_mark, m['fqdn'], m['spin'])
226
+ end
227
+
228
+ print 'Select a machine by number: '
229
+ input = $stdin.gets
230
+ return if input.nil?
231
+
232
+ idx = input.strip.to_i
233
+
234
+ if idx == 0 && default_machine
235
+ return default_machine
236
+ elsif idx.between?(1, machines.size)
237
+ return machines[idx - 1]
238
+ end
239
+
240
+ puts 'Invalid selection. Please try again.'
241
+ end
242
+ end
243
+
244
+ def pick_generation(machine_data, generation_input)
245
+ gens = machine_data['generations'] || []
246
+ return if gens.empty?
247
+
248
+ unless generation_input.nil?
249
+ parsed = parse_generation_input(generation_input, gens)
250
+
251
+ if parsed.nil?
252
+ warn "Requested generation '#{generation_input}' not found (or invalid)."
253
+ return
254
+ end
255
+
256
+ return parsed
257
+ end
258
+
259
+ if @interactive
260
+ loop do
261
+ current = gens.find { |x| x['current'] == true }
262
+ default_label = if current
263
+ "(default is #{current['generation']} - current)"
264
+ else
265
+ "(no current labeled, default is newest: #{gens[0]['generation']})"
266
+ end
267
+
268
+ puts "Available generations for #{machine_data['fqdn']}: #{default_label}"
269
+ list_generations(gens)
270
+ print 'Enter generation number (or negative offset) [ENTER for default]: '
271
+ input = $stdin.gets
272
+ return if input.nil?
273
+
274
+ line = input.strip
275
+ return current || gens[0] if line == ''
276
+
277
+ parsed = parse_generation_input(line, gens)
278
+ return parsed if parsed
279
+
280
+ puts "Invalid generation '#{line}'. Please try again."
281
+ end
282
+ else
283
+ current = gens.find { |x| x['current'] == true }
284
+ current || gens[0]
285
+ end
286
+ end
287
+
288
+ def list_generations(gens)
289
+ format_str = "%5s %-19s %-10s %s\n"
290
+
291
+ puts format(format_str, '', 'TIME', 'REVISION', 'KERNEL')
292
+
293
+ gens.each do |g|
294
+ current_mark = g['current'] ? '*' : ''
295
+
296
+ line = format(
297
+ format_str,
298
+ "[#{g['generation']}]" + current_mark,
299
+ g['time_s'],
300
+ g['shortrev'],
301
+ g['kernel_version']
302
+ )
303
+ puts line
304
+ end
305
+ end
306
+
307
+ def parse_generation_input(input_str, gens)
308
+ begin
309
+ val = Integer(input_str)
310
+ rescue ArgumentError
311
+ return
312
+ end
313
+
314
+ if val >= 0
315
+ gens.find { |g| g['generation'] == val }
316
+ else
317
+ offset = -val
318
+ return if offset >= gens.size
319
+
320
+ gens[offset]
321
+ end
322
+ end
323
+
324
+ def pick_variant(generation_data, desired_variant_name)
325
+ variants = generation_data['variants'] || []
326
+ return if variants.empty?
327
+
328
+ if desired_variant_name.nil? && @interactive
329
+ interactive_pick_variant(generation_data)
330
+ elsif desired_variant_name
331
+ v = variants.find { |x| x['name'] == desired_variant_name }
332
+
333
+ if v.nil?
334
+ warn "Requested variant '#{desired_variant_name}' not found in generation #{generation_data['generation']}."
335
+ return
336
+ end
337
+
338
+ v
339
+ else
340
+ variants.first
341
+ end
342
+ end
343
+
344
+ def interactive_pick_variant(generation_data)
345
+ variants = generation_data['variants']
346
+
347
+ loop do
348
+ puts "Variants available for generation #{generation_data['generation']}:"
349
+ format_str = "%5s %s\n"
350
+
351
+ puts format(format_str, '', 'LABEL')
352
+
353
+ variants.each_with_index do |v, idx|
354
+ line = format(format_str, "[#{idx + 1}]", v['label'])
355
+ puts line
356
+ end
357
+
358
+ print "Choose variant by number [ENTER for '#{variants[0]['name']}']: "
359
+
360
+ input = $stdin.gets
361
+ return if input.nil?
362
+
363
+ line = input.strip
364
+ return variants[0] if line == ''
365
+
366
+ idx = line.to_i
367
+ return variants[idx - 1] if idx.between?(1, variants.size)
368
+
369
+ puts 'Invalid selection. Please try again.'
370
+ end
371
+ end
372
+
373
+ def download_file(url)
374
+ uri = URI.parse(url)
375
+ basename = File.basename(uri.path)
376
+ tmp_file = Tempfile.new(basename)
377
+
378
+ puts "Downloading #{url} -> #{tmp_file.path}"
379
+
380
+ Net::HTTP.start(uri.host, uri.port) do |http|
381
+ resp = http.get(uri.request_uri)
382
+
383
+ unless resp.is_a?(Net::HTTPSuccess)
384
+ warn "ERROR: Could not download #{url}, HTTP #{resp.code}"
385
+ exit 1
386
+ end
387
+
388
+ tmp_file.write(resp.body)
389
+ end
390
+
391
+ tmp_file.close
392
+
393
+ @tmp_files << tmp_file
394
+ tmp_file.path
395
+ end
396
+
397
+ def load_kexec(kernel_path, initrd_path, kernel_params_string)
398
+ cmd = [
399
+ KEXEC,
400
+ '-l',
401
+ kernel_path,
402
+ "--initrd=#{initrd_path}",
403
+ "--append=\"#{kernel_params_string}\""
404
+ ].join(' ')
405
+
406
+ puts "Executing: #{cmd}"
407
+ system(cmd)
408
+
409
+ if $?.exitstatus == 0
410
+ puts 'kexec -l completed successfully.'
411
+ else
412
+ warn 'ERROR: kexec -l failed!'
413
+ exit 1
414
+ end
415
+ end
416
+
417
+ def exec_kexec
418
+ cmd = [KEXEC, '-e'].join(' ')
419
+
420
+ puts "Executing: #{cmd}"
421
+ system(cmd)
422
+
423
+ if $?.exitstatus == 0
424
+ puts 'kexec -e completed successfully.'
425
+ else
426
+ warn 'ERROR: kexec -u failed!'
427
+ exit 1
428
+ end
429
+ end
430
+
431
+ def unload_kexec
432
+ cmd = [KEXEC, '-u'].join(' ')
433
+
434
+ puts "Executing: #{cmd}"
435
+ system(cmd)
436
+
437
+ if $?.exitstatus == 0
438
+ puts 'kexec -u completed successfully.'
439
+ else
440
+ warn 'ERROR: kexec -u failed!'
441
+ exit 1
442
+ end
443
+ end
444
+
445
+ def cleanup_downloads
446
+ @tmp_files.each do |f|
447
+ f.unlink
448
+ rescue StandardError => e
449
+ warn "Warning: Could not remove tmp file #{f.path}: #{e}"
450
+ end
451
+ end
452
+ end
453
+
454
+ loader = KexecNetboot.new
455
+ loader.run
@@ -0,0 +1,15 @@
1
+ { config, ... }:
2
+ {
3
+ nixpkgs.overlays = [
4
+ (self: super: {
5
+ # nixos-unstable removed pkgs.substituteAll, but nixos-25.05 does not
6
+ # include the new function pkgs.replaceVarsWith
7
+ confReplaceVarsWith =
8
+ { replacements, ... } @ args:
9
+ if builtins.hasAttr "replaceVarsWith" self then
10
+ self.replaceVarsWith args
11
+ else
12
+ self.substituteAll ((builtins.removeAttrs args [ "replacements" ]) // replacements);
13
+ })
14
+ ];
15
+ }
@@ -4,6 +4,7 @@ let
4
4
  ./confctl/generations.nix
5
5
  ./confctl/cli.nix
6
6
  ./confctl/nix.nix
7
+ ./confctl/overlays.nix
7
8
  ./confctl/swpins.nix
8
9
  ];
9
10
 
@@ -4,5 +4,7 @@
4
4
  ./confctl/carrier/netboot/nixos.nix
5
5
  ];
6
6
 
7
- vpsadminos = [];
7
+ vpsadminos = [
8
+ ./confctl/kexec-netboot
9
+ ];
8
10
  }
data/shell.nix CHANGED
@@ -19,12 +19,19 @@ in stdenv.mkDerivation rec {
19
19
  export GEM_HOME="$(pwd)/.gems"
20
20
  BINDIR="$(ruby -e 'puts Gem.bindir')"
21
21
  mkdir -p "$BINDIR"
22
+
22
23
  export PATH="$BINDIR:$PATH"
23
24
  export RUBYLIB="$GEM_HOME:$CONFCTL/lib"
24
25
  export MANPATH="$CONFCTL/man:$(man --path)"
25
- gem install --no-document bundler overcommit rubocop
26
+ gem install --no-document bundler
26
27
  pushd "$CONFCTL"
27
- bundle install
28
+
29
+ # Purity disabled because of prism gem, which has a native extension.
30
+ # The extension has its header files in .gems, which gets stripped but
31
+ # cc wrapper in Nix. Without NIX_ENFORCE_PURITY=0, we get prism.h not found
32
+ # error.
33
+ NIX_ENFORCE_PURITY=0 bundle install
34
+
28
35
  bundle exec rake md2man:man
29
36
  popd
30
37
 
metadata CHANGED
@@ -1,11 +1,10 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: confctl
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 2.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jakub Skokan
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
10
  date: 1980-01-01 00:00:00.000000000 Z
@@ -285,6 +284,7 @@ files:
285
284
  - lib/confctl/nix_copy.rb
286
285
  - lib/confctl/nix_format.rb
287
286
  - lib/confctl/nix_literal_expression.rb
287
+ - lib/confctl/null_logger.rb
288
288
  - lib/confctl/parallel_executor.rb
289
289
  - lib/confctl/pattern.rb
290
290
  - lib/confctl/settings.rb
@@ -307,6 +307,7 @@ files:
307
307
  - lib/confctl/user_scripts.rb
308
308
  - lib/confctl/utils/file.rb
309
309
  - lib/confctl/version.rb
310
+ - libexec/auto-rollback.rb
310
311
  - man/man8/confctl-options.nix.8
311
312
  - man/man8/confctl-options.nix.8.md
312
313
  - man/man8/confctl.8
@@ -325,18 +326,20 @@ files:
325
326
  - nix/modules/confctl/carrier/netboot/nixos.nix
326
327
  - nix/modules/confctl/cli.nix
327
328
  - nix/modules/confctl/generations.nix
329
+ - nix/modules/confctl/kexec-netboot/default.nix
330
+ - nix/modules/confctl/kexec-netboot/kexec-netboot.8.adoc
331
+ - nix/modules/confctl/kexec-netboot/kexec-netboot.rb
328
332
  - nix/modules/confctl/nix.nix
333
+ - nix/modules/confctl/overlays.nix
329
334
  - nix/modules/confctl/swpins.nix
330
335
  - nix/modules/module-list.nix
331
336
  - nix/modules/system-list.nix
332
337
  - shell.nix
333
338
  - template/confctl-options.nix/main.erb
334
339
  - template/confctl-options.nix/options.erb
335
- homepage:
336
340
  licenses:
337
341
  - GPL-3.0-only
338
342
  metadata: {}
339
- post_install_message:
340
343
  rdoc_options: []
341
344
  require_paths:
342
345
  - lib
@@ -351,8 +354,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
351
354
  - !ruby/object:Gem::Version
352
355
  version: '0'
353
356
  requirements: []
354
- rubygems_version: 3.5.9
355
- signing_key:
357
+ rubygems_version: 3.6.6
356
358
  specification_version: 4
357
359
  summary: Nix deployment management tool
358
360
  test_files: []