confctl 2.0.0 → 2.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,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
@@ -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
  }
metadata CHANGED
@@ -1,7 +1,7 @@
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.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jakub Skokan
@@ -285,6 +285,7 @@ files:
285
285
  - lib/confctl/nix_copy.rb
286
286
  - lib/confctl/nix_format.rb
287
287
  - lib/confctl/nix_literal_expression.rb
288
+ - lib/confctl/null_logger.rb
288
289
  - lib/confctl/parallel_executor.rb
289
290
  - lib/confctl/pattern.rb
290
291
  - lib/confctl/settings.rb
@@ -307,6 +308,7 @@ files:
307
308
  - lib/confctl/user_scripts.rb
308
309
  - lib/confctl/utils/file.rb
309
310
  - lib/confctl/version.rb
311
+ - libexec/auto-rollback.rb
310
312
  - man/man8/confctl-options.nix.8
311
313
  - man/man8/confctl-options.nix.8.md
312
314
  - man/man8/confctl.8
@@ -325,6 +327,9 @@ files:
325
327
  - nix/modules/confctl/carrier/netboot/nixos.nix
326
328
  - nix/modules/confctl/cli.nix
327
329
  - nix/modules/confctl/generations.nix
330
+ - nix/modules/confctl/kexec-netboot/default.nix
331
+ - nix/modules/confctl/kexec-netboot/kexec-netboot.8.adoc
332
+ - nix/modules/confctl/kexec-netboot/kexec-netboot.rb
328
333
  - nix/modules/confctl/nix.nix
329
334
  - nix/modules/confctl/swpins.nix
330
335
  - nix/modules/module-list.nix
@@ -351,7 +356,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
351
356
  - !ruby/object:Gem::Version
352
357
  version: '0'
353
358
  requirements: []
354
- rubygems_version: 3.5.9
359
+ rubygems_version: 3.5.22
355
360
  signing_key:
356
361
  specification_version: 4
357
362
  summary: Nix deployment management tool