confctl 1.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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/.editorconfig +1 -1
  3. data/.gitignore +1 -0
  4. data/.rubocop.yml +1 -0
  5. data/CHANGELOG.md +30 -1
  6. data/README.md +4 -9
  7. data/confctl.gemspec +14 -14
  8. data/docs/carrier.md +150 -0
  9. data/lib/confctl/cli/app.rb +19 -0
  10. data/lib/confctl/cli/cluster.rb +214 -49
  11. data/lib/confctl/cli/configuration.rb +7 -2
  12. data/lib/confctl/cli/gen_data.rb +19 -1
  13. data/lib/confctl/cli/generation.rb +47 -16
  14. data/lib/confctl/generation/build.rb +42 -1
  15. data/lib/confctl/generation/build_list.rb +10 -0
  16. data/lib/confctl/generation/host.rb +9 -5
  17. data/lib/confctl/generation/host_list.rb +22 -7
  18. data/lib/confctl/generation/unified.rb +5 -0
  19. data/lib/confctl/generation/unified_list.rb +10 -0
  20. data/lib/confctl/git_repo_mirror.rb +2 -2
  21. data/lib/confctl/machine.rb +105 -11
  22. data/lib/confctl/machine_control.rb +10 -2
  23. data/lib/confctl/machine_list.rb +18 -1
  24. data/lib/confctl/machine_status.rb +51 -4
  25. data/lib/confctl/nix.rb +90 -22
  26. data/lib/confctl/nix_copy.rb +5 -5
  27. data/lib/confctl/null_logger.rb +7 -0
  28. data/lib/confctl/swpins/specs/git.rb +1 -1
  29. data/lib/confctl/swpins/specs/git_rev.rb +1 -1
  30. data/lib/confctl/system_command.rb +3 -2
  31. data/lib/confctl/version.rb +1 -1
  32. data/libexec/auto-rollback.rb +106 -0
  33. data/man/man8/confctl-options.nix.8 +165 -1
  34. data/man/man8/confctl-options.nix.8.md +165 -1
  35. data/man/man8/confctl.8 +109 -73
  36. data/man/man8/confctl.8.md +86 -55
  37. data/nix/evaluator.nix +26 -7
  38. data/nix/lib/default.nix +64 -17
  39. data/nix/lib/machine/default.nix +14 -11
  40. data/nix/lib/machine/info.nix +3 -3
  41. data/nix/modules/cluster/default.nix +162 -3
  42. data/nix/modules/confctl/carrier/base.nix +35 -0
  43. data/nix/modules/confctl/carrier/carrier-env.rb +81 -0
  44. data/nix/modules/confctl/carrier/netboot/build-netboot-server.rb +962 -0
  45. data/nix/modules/confctl/carrier/netboot/nixos.nix +185 -0
  46. data/nix/modules/confctl/kexec-netboot/default.nix +36 -0
  47. data/nix/modules/confctl/kexec-netboot/kexec-netboot.8.adoc +62 -0
  48. data/nix/modules/confctl/kexec-netboot/kexec-netboot.rb +455 -0
  49. data/nix/modules/system-list.nix +10 -0
  50. metadata +17 -7
  51. data/.ruby-version +0 -1
@@ -0,0 +1,962 @@
1
+ #!@ruby@/bin/ruby
2
+ require 'erb'
3
+ require 'fileutils'
4
+ require 'json'
5
+ require 'securerandom'
6
+
7
+ class Config
8
+ class Memtest
9
+ # @return [Boolean]
10
+ attr_reader :enable
11
+
12
+ # @return [String]
13
+ attr_reader :package
14
+
15
+ # @return [String]
16
+ attr_reader :params
17
+
18
+ # @return [String]
19
+ attr_reader :params_line
20
+
21
+ def initialize(cfg)
22
+ @enable = !cfg.nil?
23
+ return unless @enable
24
+
25
+ @package = cfg.fetch('package')
26
+ @params = cfg.fetch('params')
27
+ @params_line = @params.join(' ')
28
+ end
29
+ end
30
+
31
+ class IsoImage
32
+ # @return [String]
33
+ attr_reader :file
34
+
35
+ # @return [String]
36
+ attr_reader :label
37
+
38
+ # @return [String]
39
+ attr_reader :name
40
+
41
+ # @param cfg [Hash]
42
+ # @param index [Integer]
43
+ def initialize(cfg, index)
44
+ @file = cfg.fetch('file')
45
+ @label = cfg.fetch('label')
46
+
47
+ basename = File.basename(@file)
48
+
49
+ @label = basename if @label.empty?
50
+ @name = format('%02d-%s', index, basename)
51
+ end
52
+ end
53
+
54
+ # @param file [String]
55
+ def self.parse(file)
56
+ new(JSON.parse(File.read(file)))
57
+ end
58
+
59
+ # @return [String]
60
+ attr_reader :ruby
61
+
62
+ # @return [String]
63
+ attr_reader :coreutils
64
+
65
+ # @return [String]
66
+ attr_reader :syslinux
67
+
68
+ # @return [String]
69
+ attr_reader :tftp_root
70
+
71
+ # @return [String]
72
+ attr_reader :http_root
73
+
74
+ # @return [String]
75
+ attr_reader :host_name
76
+
77
+ # @return [String]
78
+ attr_reader :http_url
79
+
80
+ # @return [Memtest]
81
+ attr_reader :memtest
82
+
83
+ # @return [Array<IsoImage>]
84
+ attr_reader :iso_images
85
+
86
+ # @param cfg [Hash]
87
+ def initialize(cfg)
88
+ @ruby = cfg.fetch('ruby')
89
+ @coreutils = cfg.fetch('coreutils')
90
+ @syslinux = cfg.fetch('syslinux')
91
+ @tftp_root = cfg.fetch('tftpRoot')
92
+ @http_root = cfg.fetch('httpRoot')
93
+ @host_name = cfg.fetch('hostName')
94
+ @http_url = cfg.fetch('httpUrl')
95
+ @memtest = Memtest.new(cfg.fetch('memtest'))
96
+ @iso_images = cfg.fetch('isoImages').each_with_index.map { |v, i| IsoImage.new(v, i) }
97
+ end
98
+ end
99
+
100
+ class NetbootBuilder
101
+ CONFIG_FILE = '@jsonConfig@'.freeze
102
+
103
+ LINK_DIR = '/nix/var/nix/profiles'.freeze
104
+
105
+ CARRIED_PREFIX = 'confctl'.freeze
106
+
107
+ LOCK_FILE = '/run/confctl/build-netboot-server.lock'.freeze
108
+
109
+ def self.run
110
+ builder = new(Config.parse(CONFIG_FILE))
111
+ builder.run
112
+ end
113
+
114
+ def initialize(config)
115
+ @config = config
116
+ end
117
+
118
+ def run
119
+ lock { safe_run }
120
+ end
121
+
122
+ protected
123
+
124
+ def safe_run
125
+ machines = load_machines
126
+
127
+ machines.each do |m|
128
+ puts m.fqdn
129
+
130
+ m.generations.each do |g|
131
+ puts " - #{g.time_s} - #{g.version}#{g.current ? ' (current)' : ''}"
132
+ end
133
+
134
+ puts
135
+ end
136
+
137
+ random = SecureRandom.hex(3)
138
+
139
+ builders = [
140
+ TftpBuilder.new(@config, random, machines, @config.tftp_root),
141
+ HttpBuilder.new(@config, random, machines, @config.http_root)
142
+ ]
143
+
144
+ builders.each(&:run)
145
+ builders.each(&:install)
146
+ builders.each(&:cleanup)
147
+ end
148
+
149
+ def lock
150
+ FileUtils.mkdir_p(File.dirname(LOCK_FILE))
151
+
152
+ File.open(LOCK_FILE, 'w') do |f|
153
+ f.flock(File::LOCK_EX)
154
+ yield
155
+ end
156
+ end
157
+
158
+ def load_machines
159
+ machines = {}
160
+
161
+ Dir.entries(LINK_DIR).each do |v|
162
+ next if /\A#{Regexp.escape(CARRIED_PREFIX)}-(.+)-(\d+)-link\z/ !~ v
163
+
164
+ name = ::Regexp.last_match(1)
165
+ generation = ::Regexp.last_match(2).to_i
166
+
167
+ link_path = File.join(LINK_DIR, v)
168
+
169
+ machines[name] ||= Machine.new(@config, name)
170
+ machines[name].add_generation(link_path, generation)
171
+ end
172
+
173
+ machines.each_value do |m|
174
+ current_path = File.realpath(File.join(LINK_DIR, "#{CARRIED_PREFIX}-#{m.name}"))
175
+
176
+ m.generations.each do |g|
177
+ next if g.store_path != current_path
178
+
179
+ g.current = true
180
+ break
181
+ end
182
+ end
183
+
184
+ machines.each_value(&:resolve)
185
+ machines.values
186
+ end
187
+ end
188
+
189
+ class RootBuilder
190
+ # @return [Array<Machine>]
191
+ attr_reader :machines
192
+
193
+ # @return [String]
194
+ attr_reader :root
195
+
196
+ # @param config [Config]
197
+ # @param random [String]
198
+ # @param machines [Array<Machine>]
199
+ # @param root [String]
200
+ def initialize(config, random, machines, root)
201
+ @config = config
202
+ @machines = machines
203
+ @target_root = root
204
+ @root = @new_root = "#{root}.#{random}"
205
+
206
+ begin
207
+ @current_root = File.readlink(root)
208
+
209
+ unless @current_root.start_with?('/')
210
+ @current_root = File.join(File.dirname(root), @current_root)
211
+ end
212
+ rescue Errno::ENOENT
213
+ @current_root = nil
214
+ end
215
+ end
216
+
217
+ def run
218
+ mkdir_p('')
219
+ build
220
+ end
221
+
222
+ def build
223
+ raise NotImplementedError
224
+ end
225
+
226
+ def install
227
+ # ln -sfn is called instead of using ruby methods, because ln
228
+ # can replace the symlink atomically.
229
+ return if Kernel.system(File.join(@config.coreutils, 'bin/ln'), '-sfn', File.basename(@new_root), @target_root)
230
+
231
+ raise "Failed to install new root to #{@target_root}"
232
+ end
233
+
234
+ def cleanup
235
+ FileUtils.rm_r(@current_root) if @current_root
236
+ end
237
+
238
+ protected
239
+
240
+ # @param src [String] link target
241
+ # @param dst [String] link path relative to root
242
+ def ln_s(src, dst)
243
+ FileUtils.ln_s(src, File.join(root, dst))
244
+ end
245
+
246
+ # @param src [String] link target
247
+ # @param dst [String] link path relative to root
248
+ def cp(src, dst)
249
+ FileUtils.cp(src, File.join(root, dst))
250
+ end
251
+
252
+ # Create directories within root
253
+ def mkdir_p(*paths)
254
+ paths.each do |v|
255
+ FileUtils.mkdir_p(File.join(root, v))
256
+ end
257
+ end
258
+
259
+ # Create a file within root
260
+ def write_to(path, content)
261
+ mkdir_p(File.dirname(path))
262
+ File.write(File.join(root, path), content)
263
+ end
264
+ end
265
+
266
+ class TftpBuilder < RootBuilder
267
+ PROGRAMS = %w[pxelinux.0 ldlinux.c32 libcom32.c32 libutil.c32 memdisk menu.c32 reboot.c32].freeze
268
+
269
+ SPIN_LABELS = {
270
+ 'nixos' => 'NixOS',
271
+ 'vpsadminos' => 'vpsAdminOS'
272
+ }.freeze
273
+
274
+ def build
275
+ install_syslinux
276
+
277
+ mkdir_p('pxelinux.cfg', 'pxeserver')
278
+
279
+ install_boot_files
280
+
281
+ @spins =
282
+ machines.each_with_object([]) do |m, acc|
283
+ acc << m.spin unless acc.include?(m.spin)
284
+ end.to_h do |spin|
285
+ [spin, SPIN_LABELS.fetch(spin, spin)]
286
+ end
287
+
288
+ render_default_config
289
+ render_spin_configs
290
+ render_machine_configs
291
+ render_iso_image_configs
292
+ end
293
+
294
+ protected
295
+
296
+ def install_syslinux
297
+ PROGRAMS.each do |prog|
298
+ cp(File.join(@config.syslinux, 'share/syslinux', prog), prog)
299
+ end
300
+ end
301
+
302
+ def install_boot_files
303
+ # rubocop:disable Style/GuardClause
304
+
305
+ machines.each do |m|
306
+ m.generations.each do |g|
307
+ path = File.join('boot', m.fqdn, g.generation.to_s)
308
+ mkdir_p(path)
309
+
310
+ g.boot_files.each_value do |boot_file|
311
+ next unless %w[bzImage initrd].include?(boot_file.name)
312
+
313
+ ln_s(boot_file.path, File.join(path, boot_file.name))
314
+ end
315
+
316
+ ln_s(g.generation.to_s, File.join('boot', m.fqdn, 'current')) if g.current
317
+ end
318
+
319
+ m.current.macs.each do |mac|
320
+ # See https://wiki.syslinux.org/wiki/index.php?title=PXELINUX#Configuration
321
+ ln_s("../pxeserver/machines/#{m.fqdn}/auto.cfg", "pxelinux.cfg/01-#{mac.gsub(':', '-')}")
322
+ end
323
+ end
324
+
325
+ if @config.memtest.enable
326
+ mkdir_p('boot/memtest86')
327
+ ln_s(File.join(@config.memtest.package, 'memtest.bin'), 'boot/memtest86/memtest.bin')
328
+ end
329
+
330
+ if @config.iso_images.any?
331
+ mkdir_p('boot/iso-images')
332
+
333
+ @config.iso_images.each do |img|
334
+ ln_s(img.file, File.join('boot/iso-images', img.name))
335
+ end
336
+ end
337
+
338
+ # rubocop:enable Style/GuardClause
339
+ end
340
+
341
+ def render_default_config
342
+ tpl = <<~ERB
343
+ DEFAULT menu.c32
344
+ PROMPT 0
345
+ TIMEOUT 0
346
+ MENU TITLE <%= hostname %>
347
+
348
+ <% spins.each do |spin, label| -%>
349
+ LABEL <%= spin %>
350
+ MENU LABEL <%= label %> >
351
+ KERNEL menu.c32
352
+ APPEND pxeserver/<%= spin %>.cfg
353
+
354
+ <% end -%>
355
+ <% if iso_images.any? -%>
356
+ LABEL isoimages
357
+ MENU LABEL ISO images >
358
+ KERNEL menu.c32
359
+ APPEND pxeserver/iso-images.cfg
360
+
361
+ <% end -%>
362
+ <% if enable_memtest -%>
363
+ LABEL memtest
364
+ MENU LABEL Memtest86
365
+ LINUX boot/memtest86/memtest.bin
366
+ APPEND <%= memtest_params %>
367
+
368
+ <% end -%>
369
+ LABEL local_boot
370
+ MENU LABEL Local Boot
371
+ LOCALBOOT 0
372
+
373
+ LABEL warm_reboot
374
+ MENU LABEL Warm Reboot
375
+ KERNEL reboot.c32
376
+ APPEND --warm
377
+
378
+ LABEL cold_reboot
379
+ MENU LABEL Cold Reboot
380
+ KERNEL reboot.c32
381
+ ERB
382
+
383
+ render_to(
384
+ tpl,
385
+ {
386
+ hostname: @config.host_name,
387
+ spins: @spins,
388
+ enable_memtest: @config.memtest.enable,
389
+ memtest_params: @config.memtest.params_line,
390
+ iso_images: @config.iso_images
391
+ },
392
+ 'pxelinux.cfg/default'
393
+ )
394
+ end
395
+
396
+ def render_spin_configs
397
+ tpl = <<~ERB
398
+ MENU TITLE <%= label %>
399
+
400
+ <% spin_machines.each do |m| -%>
401
+ LABEL <%= m.fqdn %>
402
+ MENU LABEL <%= m.label %> >
403
+ KERNEL menu.c32
404
+ APPEND pxeserver/machines/<%= m.fqdn %>/menu.cfg
405
+ <% end -%>
406
+
407
+ LABEL mainmenu
408
+ MENU LABEL < Back to Main Menu
409
+ KERNEL menu.c32
410
+ APPEND pxelinux.cfg/default
411
+ ERB
412
+
413
+ @spins.each do |spin, label|
414
+ spin_machines = machines.select { |m| m.spin == spin }.sort { |a, b| a.name <=> b.name }
415
+
416
+ render_to(tpl, { spin:, label:, spin_machines: }, "pxeserver/#{spin}.cfg")
417
+ end
418
+ end
419
+
420
+ def render_machine_configs
421
+ mkdir_p('pxeserver/machines')
422
+
423
+ machines.each do |m|
424
+ mkdir_p("pxeserver/machines/#{m.fqdn}")
425
+ send(:"render_machine_#{m.spin}", m)
426
+ end
427
+ end
428
+
429
+ def render_machine_nixos(machine)
430
+ tpl = <<~ERB
431
+ MENU TITLE <%= m.label %>
432
+
433
+ LABEL <%= m.fqdn %>
434
+ MENU LABEL <%= m.label %>
435
+ LINUX boot/<%= m.fqdn %>/<%= m.current.generation %>/bzImage
436
+ INITRD boot/<%= m.fqdn %>/<%= m.current.generation %>/initrd
437
+ APPEND init=<%= m.current.toplevel %>/init loglevel=7
438
+
439
+ <% m.generations[1..].each do |g| -%>
440
+ LABEL <%= m.fqdn %>-<%= g.generation %>
441
+ MENU LABEL Gen <%= g.generation %> - <%= g.time_s %> - <%= g.shortrev %>
442
+ LINUX boot/<%= m.fqdn %>/<%= g.generation %>/bzImage
443
+ INITRD boot/<%= m.fqdn %>/<%= g.generation %>/initrd
444
+ APPEND init=<%= g.toplevel %>/init loglevel=7
445
+
446
+ <% end -%>
447
+ <% if enable_memtest -%>
448
+ LABEL memtest
449
+ MENU LABEL Memtest86
450
+ LINUX boot/memtest86/memtest.bin
451
+ APPEND <%= memtest_params %>
452
+
453
+ <% end -%>
454
+ LABEL mainmenu
455
+ MENU LABEL < Back to Main Menu
456
+ KERNEL menu.c32
457
+ APPEND pxelinux.cfg/default
458
+ ERB
459
+
460
+ render_to(
461
+ tpl,
462
+ {
463
+ m: machine,
464
+ enable_memtest: @config.memtest.enable,
465
+ memtest_params: @config.memtest.params_line
466
+ },
467
+ "pxeserver/machines/#{machine.fqdn}/menu.cfg"
468
+ )
469
+ end
470
+
471
+ def render_machine_vpsadminos(machine)
472
+ render_machine_vpsadminos_config(
473
+ machine,
474
+ generation: machine.current,
475
+ file: 'menu',
476
+ root: true
477
+ )
478
+
479
+ render_machine_vpsadminos_config(
480
+ machine,
481
+ generation: machine.current,
482
+ file: 'auto',
483
+ timeout: 5,
484
+ root: true
485
+ )
486
+
487
+ render_generations(machine)
488
+
489
+ machine.generations.each do |g|
490
+ render_machine_vpsadminos_config(
491
+ machine,
492
+ generation: g,
493
+ file: "generation-#{g.generation}",
494
+ root: false
495
+ )
496
+ end
497
+ end
498
+
499
+ # @param machine [Machine]
500
+ # @param generation [Generation]
501
+ # @param file [String] config base name
502
+ # @param root [Boolean] true if this is machine menu page, not generation menu
503
+ # @param timeout [Integer, nil] timeout in seconds until the default action is taken
504
+ def render_machine_vpsadminos_config(machine, generation:, file:, root:, timeout: nil)
505
+ tpl = <<~ERB
506
+ <% if timeout -%>
507
+ DEFAULT menu.c32
508
+ TIMEOUT 50
509
+ <% end -%>
510
+ MENU TITLE <%= m.short_label %> (<%= g.generation %> - <%= g.current ? 'current' : g.time_s %> - <%= g.shortrev %> - <%= g.kernel_version %>)
511
+
512
+ <% g.variants.each do |variant| -%>
513
+ LABEL <%= variant.name %>
514
+ MENU LABEL <%= variant.label %>
515
+ LINUX boot/<%= m.fqdn %>/<%= g.generation %>/bzImage
516
+ INITRD boot/<%= m.fqdn %>/<%= g.generation %>/initrd
517
+ APPEND <%= g.kernel_params.join(' ') %> <%= variant.kernel_params.join(' ') %>
518
+
519
+ <% end -%>
520
+ LABEL <%= m.fqdn %>-generations
521
+ MENU LABEL <%= root ? 'Generations >' : '< Back to Generations' %>
522
+ KERNEL menu.c32
523
+ APPEND pxeserver/machines/<%= m.fqdn %>/generations.cfg
524
+
525
+ <% if enable_memtest -%>
526
+ LABEL memtest
527
+ MENU LABEL Memtest86
528
+ LINUX boot/memtest86/memtest.bin
529
+ APPEND <%= memtest_params %>
530
+
531
+ <% end -%>
532
+ LABEL mainmenu
533
+ MENU LABEL < Back to Main Menu
534
+ KERNEL menu.c32
535
+ APPEND pxelinux.cfg/default
536
+ ERB
537
+
538
+ render_to(
539
+ tpl,
540
+ {
541
+ m: machine,
542
+ g: generation,
543
+ http_url: @config.http_url,
544
+ root:,
545
+ timeout:,
546
+ enable_memtest: @config.memtest.enable,
547
+ memtest_params: @config.memtest.params_line
548
+ },
549
+ "pxeserver/machines/#{machine.fqdn}/#{file}.cfg"
550
+ )
551
+ end
552
+
553
+ def render_generations(machine)
554
+ tpl = <<~ERB
555
+ MENU TITLE <%= m.label %> - generations
556
+
557
+ <% m.generations.each do |g| -%>
558
+ LABEL generations
559
+ MENU LABEL Gen <%= g.generation %> - <%= g.time_s %> - <%= g.shortrev %> - <%= g.kernel_version %>
560
+ KERNEL menu.c32
561
+ APPEND pxeserver/machines/<%= m.fqdn %>/generation-<%= g.generation %>.cfg
562
+
563
+ <% end -%>
564
+
565
+ LABEL machine
566
+ MENU LABEL < Back to <%= m.label %>
567
+ KERNEL menu.c32
568
+ APPEND pxeserver/machines/<%= m.fqdn %>/menu.cfg
569
+
570
+ LABEL mainmenu
571
+ MENU LABEL < Back to Main Menu
572
+ KERNEL menu.c32
573
+ APPEND pxelinux.cfg/default
574
+ ERB
575
+
576
+ render_to(tpl, { m: machine }, "pxeserver/machines/#{machine.fqdn}/generations.cfg")
577
+ end
578
+
579
+ def render_iso_image_configs
580
+ return if @config.iso_images.empty?
581
+
582
+ tpl = <<~ERB
583
+ MENU TITLE ISO images
584
+
585
+ <% iso_images.each_with_index do |img, i| -%>
586
+ LABEL image<%= i %>
587
+ MENU LABEL <%= img.label %>
588
+ KERNEL memdisk
589
+ APPEND iso initrd=boot/iso-images/<%= img.name %> raw
590
+ <% end -%>
591
+
592
+ LABEL mainmenu
593
+ MENU LABEL < Back to Main Menu
594
+ KERNEL menu.c32
595
+ APPEND pxelinux.cfg/default
596
+ ERB
597
+
598
+ render_to(tpl, { iso_images: @config.iso_images }, 'pxeserver/iso-images.cfg')
599
+ end
600
+
601
+ # @param template [String]
602
+ # @param vars [Hash]
603
+ def render(template, vars)
604
+ erb = ERB.new(template, trim_mode: '-')
605
+ erb.result_with_hash(vars)
606
+ end
607
+
608
+ # @param template [String]
609
+ # @param vars [Hash]
610
+ # @param path [String]
611
+ def render_to(template, vars, path)
612
+ File.write(File.join(root, path), render(template, vars))
613
+ end
614
+ end
615
+
616
+ class HttpBuilder < RootBuilder
617
+ def build
618
+ machines.each do |m|
619
+ m.generations.each do |g|
620
+ gen_path = File.join(m.fqdn, g.generation.to_s)
621
+ mkdir_p(gen_path)
622
+
623
+ g.boot_files.each_value do |boot_file|
624
+ ln_s(boot_file.path, File.join(gen_path, boot_file.name))
625
+ end
626
+
627
+ write_to(File.join(gen_path, 'generation.json'), JSON.pretty_generate(g))
628
+ write_to(File.join(gen_path, 'kernel-params'), g.kernel_params.join(' '))
629
+
630
+ ln_s(g.generation.to_s, File.join(m.fqdn, 'current')) if g.current
631
+ end
632
+
633
+ write_to(File.join(m.fqdn, 'machine.json'), JSON.pretty_generate(m))
634
+ end
635
+
636
+ write_to('machines.json', JSON.pretty_generate({ machines: }))
637
+ end
638
+ end
639
+
640
+ class Machine
641
+ # @return [String]
642
+ attr_reader :name
643
+
644
+ # @return [String]
645
+ attr_reader :spin
646
+
647
+ # @return [String]
648
+ attr_reader :fqdn
649
+
650
+ # @return [String]
651
+ attr_reader :label
652
+
653
+ # @return [String]
654
+ attr_reader :short_label
655
+
656
+ # @return [String]
657
+ attr_reader :url
658
+
659
+ # @return [Array<Generation>]
660
+ attr_reader :generations
661
+
662
+ # @return [Generation]
663
+ attr_reader :current
664
+
665
+ # @param config [Config]
666
+ # @param name [String] machine name
667
+ def initialize(config, name)
668
+ @config = config
669
+ @name = name
670
+ @spin = 'nixos'
671
+ @fqdn = name
672
+ @label = name
673
+ @short_label = name[0..14]
674
+ @toplevel = nil
675
+ @macs = []
676
+ @generations = []
677
+ @current = nil
678
+ end
679
+
680
+ # @param link_path [String] Nix store path
681
+ # @param generation [Integer]
682
+ def add_generation(link_path, generation)
683
+ @generations << Generation.new(self, link_path, generation)
684
+ nil
685
+ end
686
+
687
+ def resolve
688
+ sort_generations
689
+ load_json
690
+
691
+ @url = File.join(@config.http_url, fqdn)
692
+ generations.each(&:resolve)
693
+
694
+ nil
695
+ end
696
+
697
+ def to_json(*args)
698
+ {
699
+ url:,
700
+ name:,
701
+ spin:,
702
+ fqdn:,
703
+ label:,
704
+ generations:
705
+ }.to_json(*args)
706
+ end
707
+
708
+ protected
709
+
710
+ def sort_generations
711
+ @generations.sort! do |a, b|
712
+ b.generation <=> a.generation
713
+ end
714
+ end
715
+
716
+ def load_json
717
+ @current = @generations.detect(&:current)
718
+
719
+ if @current.nil?
720
+ raise "Unable to find current generation of machine #{name}"
721
+ end
722
+
723
+ @spin = @current.json.fetch('spin', spin)
724
+ @fqdn = @current.json.fetch('fqdn', name)
725
+
726
+ # rubocop:disable Naming/MemoizedInstanceVariableName
727
+ @label = @current.json.fetch('label', nil)
728
+ @label ||= @current.json.fetch('fqdn', nil)
729
+ @label ||= name
730
+ # rubocop:enable Naming/MemoizedInstanceVariableName
731
+
732
+ @short_label = @label.split('.')[0..1].join('.')
733
+ end
734
+ end
735
+
736
+ class Generation
737
+ # @return [Machine]
738
+ attr_reader :machine
739
+
740
+ # @return [String]
741
+ attr_reader :link_path
742
+
743
+ # @return [String]
744
+ attr_reader :store_path
745
+
746
+ # @return [Integer]
747
+ attr_reader :generation
748
+
749
+ # @return [Time]
750
+ attr_reader :time
751
+
752
+ # @return [String]
753
+ attr_reader :time_s
754
+
755
+ # @return [String]
756
+ attr_reader :kernel_version
757
+
758
+ # @return [Array<String>]
759
+ attr_reader :kernel_params
760
+
761
+ # @return [Hash<String, BootFile>]
762
+ attr_reader :boot_files
763
+
764
+ # @return [Array<Variant>]
765
+ attr_reader :variants
766
+
767
+ # @return [String] Nix store path to `config.system.build.toplevel`
768
+ attr_reader :toplevel
769
+
770
+ # @return [String, nil] system.nixos.version
771
+ attr_reader :version
772
+
773
+ # @return [String, nil] system.nixos.revision
774
+ attr_reader :revision
775
+
776
+ # @return [String, nil]
777
+ attr_reader :shortrev
778
+
779
+ # @return [Array<String>]
780
+ attr_reader :macs
781
+
782
+ # @return [Boolean]
783
+ attr_accessor :current
784
+
785
+ # @return [Hash] contents of `machine.json`
786
+ attr_reader :json
787
+
788
+ # @return [String]
789
+ attr_reader :url
790
+
791
+ # @param machine [Machine]
792
+ # @param link_path [String] Nix store path
793
+ # @param generation [Integer]
794
+ def initialize(machine, link_path, generation)
795
+ @machine = machine
796
+ @link_path = link_path
797
+ @store_path = File.realpath(link_path)
798
+ @generation = generation
799
+ @time = File.lstat(link_path).mtime
800
+ @time_s = @time.strftime('%Y-%m-%d %H:%M:%S')
801
+ @current = false
802
+
803
+ @json = JSON.parse(File.read(File.join(link_path, 'machine.json')))
804
+ @toplevel = json.fetch('toplevel')
805
+ @version = json.fetch('version', nil)
806
+ @revision = json.fetch('revision', nil)
807
+
808
+ @shortrev =
809
+ if @revision
810
+ @revision[0..8]
811
+ elsif @version
812
+ @version.split('.').last
813
+ end
814
+
815
+ @macs = json.fetch('macs', [])
816
+
817
+ @kernel_version = extract_kernel_version
818
+
819
+ kernel_params_file = File.join(store_path, 'kernel-params')
820
+
821
+ @kernel_params =
822
+ if File.exist?(kernel_params_file)
823
+ File.read(kernel_params_file).strip.split
824
+ else
825
+ json.fetch('kernelParams', [])
826
+ end
827
+ end
828
+
829
+ def resolve
830
+ @url = File.join(machine.url, generation.to_s)
831
+ @boot_files = find_boot_files
832
+ @variants = Variant.for_machine(machine)
833
+
834
+ return if machine.spin != 'vpsadminos'
835
+
836
+ @kernel_params.insert(0, "httproot=#{boot_files['root.squashfs'].url}")
837
+ end
838
+
839
+ def to_json(*args)
840
+ {
841
+ url:,
842
+ store_path:,
843
+ generation:,
844
+ time: time.to_i,
845
+ time_s:,
846
+ current:,
847
+ toplevel:,
848
+ version:,
849
+ revision:,
850
+ shortrev:,
851
+ macs:,
852
+ kernel_version:,
853
+ kernel_params:,
854
+ boot_files:,
855
+ variants:,
856
+ swpins_info: json['swpins-info']
857
+ }.to_json(*args)
858
+ end
859
+
860
+ protected
861
+
862
+ def extract_kernel_version
863
+ link = File.readlink(File.join(toplevel, 'kernel'))
864
+ return unless %r{\A/nix/store/[^-]+-linux-([^/]+)} =~ link
865
+
866
+ ::Regexp.last_match(1)
867
+ rescue Errno::ENOENT
868
+ nil
869
+ end
870
+
871
+ def find_boot_files
872
+ %w[bzImage initrd root.squashfs].to_h do |name|
873
+ [name, BootFile.new(name, File.realpath(File.join(link_path, name)), File.join(url, name))]
874
+ rescue Errno::ENOENT
875
+ [name, nil]
876
+ end.compact
877
+ end
878
+ end
879
+
880
+ class BootFile
881
+ # @return [String]
882
+ attr_reader :name
883
+
884
+ # @return [String]
885
+ attr_reader :path
886
+
887
+ # @return [String]
888
+ attr_reader :url
889
+
890
+ def initialize(name, path, url)
891
+ @name = name
892
+ @path = path
893
+ @url = url
894
+ end
895
+
896
+ def to_json(*args)
897
+ url.to_json(*args)
898
+ end
899
+ end
900
+
901
+ class Variant
902
+ # @param machine [Machine]
903
+ # @return [Array<Variant>]
904
+ def self.for_machine(machine)
905
+ if machine.spin == 'vpsadminos'
906
+ [
907
+ new(
908
+ name: 'default',
909
+ label: 'Default runlevel',
910
+ kernel_params: ['runlevel=default']
911
+ ),
912
+ new(
913
+ name: 'nopools',
914
+ label: 'Default runlevel without container imports',
915
+ kernel_params: ['runlevel=default', 'osctl.pools=0']
916
+ ),
917
+ new(
918
+ name: 'nostart',
919
+ label: 'Default runlevel without container autostart',
920
+ kernel_params: ['runlevel=default', 'osctl.autostart=0']
921
+ ),
922
+ new(
923
+ name: 'rescue',
924
+ label: 'Rescue runlevel (network and sshd)',
925
+ kernel_params: ['runlevel=rescue']
926
+ ),
927
+ new(
928
+ name: 'single',
929
+ label: 'Single-user runlevel (console only)',
930
+ kernel_params: ['runlevel=single']
931
+ )
932
+ ]
933
+ else
934
+ []
935
+ end
936
+ end
937
+
938
+ # @return [String]
939
+ attr_reader :name
940
+
941
+ # @return [String]
942
+ attr_reader :label
943
+
944
+ # @return [String]
945
+ attr_reader :kernel_params
946
+
947
+ def initialize(name:, label:, kernel_params:)
948
+ @name = name
949
+ @label = label
950
+ @kernel_params = kernel_params
951
+ end
952
+
953
+ def to_json(*args)
954
+ {
955
+ name:,
956
+ label:,
957
+ kernel_params:
958
+ }.to_json(*args)
959
+ end
960
+ end
961
+
962
+ NetbootBuilder.run