confctl 2.2.2 → 3.0.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 (178) hide show
  1. checksums.yaml +4 -4
  2. data/.git-hooks/pre_commit/nixfmt.rb +13 -0
  3. data/.github/workflows/rspec.yml +64 -0
  4. data/.github/workflows/rubocop.yml +27 -0
  5. data/.github/workflows/tests.yml +139 -0
  6. data/.gitignore +12 -7
  7. data/.overcommit.yml +2 -0
  8. data/.rspec +1 -0
  9. data/.rubocop.yml +8 -1
  10. data/AGENTS.md +42 -0
  11. data/CHANGELOG.md +57 -0
  12. data/Gemfile +3 -2
  13. data/Gemfile.lock +198 -0
  14. data/README.md +166 -58
  15. data/Rakefile +5 -0
  16. data/confctl.gemspec +20 -2
  17. data/docs/carrier.md +3 -3
  18. data/docs/flake-inputs.md +159 -0
  19. data/docs/swpins-to-flakes.md +325 -0
  20. data/example/cluster/module-list.nix +2 -1
  21. data/example/cluster/nixos-machine/config.nix +12 -2
  22. data/example/cluster/nixos-machine/hardware.nix +6 -1
  23. data/example/cluster/vpsadminos-container/config.nix +6 -1
  24. data/example/cluster/vpsadminos-container/module.nix +4 -1
  25. data/example/cluster/vpsadminos-machine/config.nix +6 -1
  26. data/example/cluster/vpsadminos-machine/hardware.nix +6 -1
  27. data/example/cluster/vpsadminos-machine/module.nix +5 -2
  28. data/example/cluster/vpsfreecz-vps/config.nix +6 -1
  29. data/example/cluster/vpsfreecz-vps/module.nix +4 -1
  30. data/example/configs/swpins.nix +8 -3
  31. data/example/environments/base.nix +6 -1
  32. data/example/swpins/core.json +35 -0
  33. data/example-flake/.gitignore +2 -0
  34. data/example-flake/README.md +38 -0
  35. data/example-flake/cluster/cluster.nix +5 -0
  36. data/example-flake/cluster/module-list.nix +4 -0
  37. data/example-flake/cluster/nested/nixos-machine/config.nix +25 -0
  38. data/example-flake/cluster/nested/nixos-machine/hardware.nix +9 -0
  39. data/example-flake/cluster/nested/nixos-machine/module.nix +8 -0
  40. data/example-flake/cluster/nixos-machine/config.nix +25 -0
  41. data/example-flake/cluster/nixos-machine/hardware.nix +9 -0
  42. data/example-flake/cluster/nixos-machine/module.nix +8 -0
  43. data/example-flake/cluster/vpsadminos-container/config.nix +28 -0
  44. data/example-flake/cluster/vpsadminos-container/module.nix +8 -0
  45. data/example-flake/cluster/vpsadminos-machine/config.nix +27 -0
  46. data/example-flake/cluster/vpsadminos-machine/hardware.nix +9 -0
  47. data/example-flake/cluster/vpsadminos-machine/module.nix +8 -0
  48. data/example-flake/cluster/vpsfreecz-vps/config.nix +31 -0
  49. data/example-flake/cluster/vpsfreecz-vps/module.nix +8 -0
  50. data/example-flake/configs/confctl.nix +10 -0
  51. data/example-flake/data/default.nix +5 -0
  52. data/example-flake/data/ssh-keys.nix +7 -0
  53. data/example-flake/environments/base.nix +18 -0
  54. data/example-flake/flake.lock +75 -0
  55. data/example-flake/flake.nix +36 -0
  56. data/example-flake/modules/module-list.nix +13 -0
  57. data/example-flake/shell.nix +11 -0
  58. data/flake.lock +159 -0
  59. data/flake.nix +189 -0
  60. data/gemset.nix +1022 -0
  61. data/lib/confctl/cli/app.rb +145 -0
  62. data/lib/confctl/cli/attr_filters.rb +1 -1
  63. data/lib/confctl/cli/cluster.rb +681 -108
  64. data/lib/confctl/cli/command.rb +24 -2
  65. data/lib/confctl/cli/configuration.rb +171 -106
  66. data/lib/confctl/cli/generation.rb +65 -1
  67. data/lib/confctl/cli/inputs/channels.rb +190 -0
  68. data/lib/confctl/cli/inputs/machines.rb +83 -0
  69. data/lib/confctl/cli/inputs/root.rb +101 -0
  70. data/lib/confctl/cli/inputs.rb +5 -0
  71. data/lib/confctl/cli/log_view.rb +21 -10
  72. data/lib/confctl/cli/migrate/swpins_to_flakes.rb +866 -0
  73. data/lib/confctl/cli/migrate.rb +5 -0
  74. data/lib/confctl/cli/output_formatter.rb +5 -7
  75. data/lib/confctl/cli/swpins/base.rb +9 -0
  76. data/lib/confctl/cli/swpins/channel.rb +2 -5
  77. data/lib/confctl/cli/swpins/cluster.rb +2 -5
  78. data/lib/confctl/cli/swpins/core.rb +2 -5
  79. data/lib/confctl/config_type.rb +7 -0
  80. data/lib/confctl/flake_lock.rb +78 -0
  81. data/lib/confctl/flake_lock_diff.rb +36 -0
  82. data/lib/confctl/generation/build.rb +131 -24
  83. data/lib/confctl/generation/build_list.rb +4 -3
  84. data/lib/confctl/generation/unified.rb +14 -1
  85. data/lib/confctl/git_repo_mirror.rb +2 -2
  86. data/lib/confctl/health_checks/run_command.rb +3 -2
  87. data/lib/confctl/health_checks/systemd/properties.rb +1 -1
  88. data/lib/confctl/health_checks/systemd/property_list.rb +2 -2
  89. data/lib/confctl/inputs/commit_message.rb +125 -0
  90. data/lib/confctl/inputs/git_commit.rb +17 -0
  91. data/lib/confctl/inputs/nix_output_guard.rb +37 -0
  92. data/lib/confctl/inputs/setter.rb +179 -0
  93. data/lib/confctl/inputs/updater.rb +76 -0
  94. data/lib/confctl/inputs.rb +5 -0
  95. data/lib/confctl/inputs_info.rb +50 -0
  96. data/lib/confctl/line_buffer.rb +1 -1
  97. data/lib/confctl/machine.rb +18 -3
  98. data/lib/confctl/machine_control.rb +8 -2
  99. data/lib/confctl/machine_list.rb +2 -2
  100. data/lib/confctl/machine_status.rb +63 -20
  101. data/lib/confctl/nix/args.rb +48 -0
  102. data/lib/confctl/nix.rb +30 -437
  103. data/lib/confctl/nix_build_flake.rb +95 -0
  104. data/lib/confctl/nix_copy.rb +14 -2
  105. data/lib/confctl/nix_flake.rb +449 -0
  106. data/lib/confctl/nix_format.rb +3 -3
  107. data/lib/confctl/nix_legacy.rb +467 -0
  108. data/lib/confctl/swpins/change_set.rb +29 -3
  109. data/lib/confctl/swpins/specs/base.rb +2 -2
  110. data/lib/confctl/ui.rb +19 -0
  111. data/lib/confctl/version.rb +1 -1
  112. data/man/index.html +11 -0
  113. data/man/man8/confctl-options.nix.8.html +113 -0
  114. data/man/man8/confctl.8 +152 -21
  115. data/man/man8/confctl.8.html +355 -0
  116. data/man/man8/confctl.8.md +148 -17
  117. data/man/style.css +301 -0
  118. data/nix/evaluator.nix +94 -69
  119. data/nix/flake/mk-confctl-devshell.nix +85 -0
  120. data/nix/flake/mk-confctl-outputs.nix +522 -0
  121. data/nix/flake/mk-config-devshell.nix +168 -0
  122. data/nix/lib/default.nix +118 -65
  123. data/nix/lib/machine/default.nix +65 -45
  124. data/nix/lib/machine/info.nix +16 -5
  125. data/nix/lib/swpins/eval.nix +42 -29
  126. data/nix/lib/swpins/options.nix +6 -2
  127. data/nix/machines.nix +23 -15
  128. data/nix/modules/cluster/default.nix +104 -41
  129. data/nix/modules/confctl/carrier/base.nix +11 -4
  130. data/nix/modules/confctl/carrier/carrier-env.rb +2 -2
  131. data/nix/modules/confctl/carrier/netboot/build-netboot-server.rb +50 -16
  132. data/nix/modules/confctl/carrier/netboot/nixos.nix +56 -24
  133. data/nix/modules/confctl/configuration-info.nix +17 -0
  134. data/nix/modules/confctl/generations.nix +2 -2
  135. data/nix/modules/confctl/host.nix +13 -0
  136. data/nix/modules/confctl/inputs-info.nix +21 -0
  137. data/nix/modules/confctl/kexec-netboot/default.nix +13 -6
  138. data/nix/modules/confctl/kexec-netboot/kexec-netboot.8.adoc +3 -0
  139. data/nix/modules/confctl/kexec-netboot/kexec-netboot.rb +25 -16
  140. data/nix/modules/confctl/nix.nix +31 -1
  141. data/nix/modules/confctl/swpins.nix +13 -6
  142. data/nix/modules/module-list.nix +4 -2
  143. data/nix/modules/system-list.nix +4 -1
  144. data/nix/package.nix +37 -0
  145. data/shell.nix +39 -8
  146. data/skills/confctl-configuration-update/SKILL.md +228 -0
  147. data/skills/confctl-configuration-update/agents/openai.yaml +4 -0
  148. data/skills/confctl-release/SKILL.md +102 -0
  149. data/skills/confctl-release/agents/openai.yaml +4 -0
  150. data/spec/confctl/cli/cluster_health_check_spec.rb +43 -0
  151. data/spec/confctl/cli/cluster_skip_current_deploy_spec.rb +157 -0
  152. data/spec/confctl/cli/cluster_status_flake_spec.rb +83 -0
  153. data/spec/confctl/cli/inputs_set_output_spec.rb +70 -0
  154. data/spec/confctl/configuration_spec.rb +45 -0
  155. data/spec/confctl/inputs_spec.rb +104 -0
  156. data/spec/confctl/machine_control_spec.rb +112 -0
  157. data/spec/confctl/machine_list_spec.rb +47 -0
  158. data/spec/confctl/nix_flake_spec.rb +41 -0
  159. data/spec/confctl/swpins_spec.rb +106 -0
  160. data/spec/generation/build_modes_spec.rb +132 -0
  161. data/spec/inputs/commit_message_spec.rb +205 -0
  162. data/spec/inputs/nix_cached_fallback_spec.rb +50 -0
  163. data/spec/inputs/nix_output_guard_spec.rb +45 -0
  164. data/spec/spec_helper.rb +12 -0
  165. data/spec/support/cli_helper.rb +57 -0
  166. data/test-runner.sh +7 -0
  167. data/tests/all-tests.nix +30 -0
  168. data/tests/make-test.nix +15 -0
  169. data/tests/runner/extensions/confctl_helpers.rb +238 -0
  170. data/tests/runner/extensions/hostfwd_ports.rb +41 -0
  171. data/tests/suite/auto_rollback.nix +344 -0
  172. data/tests/suite/carrier/deploy.nix +751 -0
  173. data/tests/suite/carrier/netboot.nix +849 -0
  174. data/tests/suite/deploy/base.nix +806 -0
  175. data/tests/suite/deploy/flakes.nix +1 -0
  176. data/tests/suite/deploy/swpins.nix +1 -0
  177. metadata +104 -4
  178. data/nix/modules/confctl/overlays.nix +0 -15
@@ -2,6 +2,7 @@ require_relative 'command'
2
2
  require_relative '../hook'
3
3
  require 'json'
4
4
  require 'rainbow'
5
+ require 'shellwords'
5
6
  require 'tty-pager'
6
7
  require 'tty-progressbar'
7
8
  require 'tty-spinner'
@@ -61,7 +62,7 @@ module ConfCtl::Cli
61
62
  list_machines(machines)
62
63
  puts
63
64
  puts "Generation: #{opts[:generation] || 'new build'}"
64
- puts "Target action: #{action}#{opts[:reboot] ? ' + reboot' : ''}"
65
+ puts "Target action: #{action}#{' + reboot' if opts[:reboot]}"
65
66
  end
66
67
 
67
68
  ConfCtl::Hook.call(:cluster_deploy, kwargs: {
@@ -79,6 +80,9 @@ module ConfCtl::Cli
79
80
  end
80
81
 
81
82
  nix = ConfCtl::Nix.new(show_trace: opts['show-trace'])
83
+ machines, host_generations = skip_current_deploy_targets(machines, host_generations, action)
84
+
85
+ return if host_generations.empty?
82
86
 
83
87
  if opts['one-by-one']
84
88
  deploy_one_by_one(machines, host_generations, nix, action)
@@ -88,10 +92,10 @@ module ConfCtl::Cli
88
92
  end
89
93
 
90
94
  def health_check
91
- machines = select_machines(args[0]).managed.select do |_host, machine|
95
+ machines = select_machines(args[0]).managed.runnable.select do |_host, machine|
92
96
  machine.health_checks.any?
93
97
  end
94
- raise 'No machines to check or no health checks configured' if machines.empty?
98
+ raise 'No runnable machines to check or no health checks configured' if machines.empty?
95
99
 
96
100
  run_checks = machines.health_checks
97
101
 
@@ -110,6 +114,8 @@ module ConfCtl::Cli
110
114
  end
111
115
 
112
116
  def status
117
+ return status_flake if flake_config?
118
+
113
119
  machines = select_machines(args[0]).managed.select do |_host, machine|
114
120
  machine.target_host || machine.carried?
115
121
  end
@@ -216,7 +222,100 @@ module ConfCtl::Cli
216
222
  OutputFormatter.print(rows, cols, layout: :columns, color: use_color?)
217
223
  end
218
224
 
225
+ def status_flake
226
+ machines = select_machines(args[0]).managed.select do |_host, machine|
227
+ machine.target_host || machine.carried?
228
+ end
229
+
230
+ raise 'No machines to check' if machines.empty?
231
+
232
+ ask_confirmation! do
233
+ if opts[:generation]
234
+ puts 'The following machines will be checked:'
235
+ else
236
+ puts 'The following machines will be built and then checked:'
237
+ end
238
+
239
+ list_machines(machines)
240
+ puts
241
+ puts "Generation: #{opts[:generation] || 'new build'}"
242
+ end
243
+
244
+ statuses = machines.transform_values do |machine|
245
+ ConfCtl::MachineStatus.new(machine)
246
+ end
247
+
248
+ if opts[:generation] == 'none'
249
+ host_generations = nil
250
+ elsif opts[:generation]
251
+ host_generations = find_generations(machines, opts[:generation])
252
+
253
+ # Ignore statuses when no generation was found
254
+ statuses.delete_if do |host, _st|
255
+ !host_generations.has_key?(host)
256
+ end
257
+ else
258
+ host_generations = do_build(machines)
259
+ puts
260
+ end
261
+
262
+ if host_generations
263
+ host_generations.each do |host, gen|
264
+ statuses[host].target_toplevel = gen.toplevel
265
+ end
266
+ end
267
+
268
+ assign_target_inputs_info(
269
+ statuses,
270
+ host_generations: opts[:generation] && opts[:generation] != 'none' ? host_generations : nil
271
+ )
272
+
273
+ tw = ConfCtl::ParallelExecutor.new(machines.length)
274
+
275
+ statuses.each_value do |st|
276
+ tw.add do
277
+ st.query(toplevel: opts[:generation] != 'none', swpins: false, inputs: true)
278
+ end
279
+ end
280
+
281
+ tw.run
282
+
283
+ roles = inputs_roles_for_status(statuses)
284
+ rows = []
285
+ cols = %w[host online uptime status generations] + roles
286
+
287
+ statuses.each do |host, st|
288
+ build_generations = ConfCtl::Generation::BuildList.new(host)
289
+ online = st.uptime ? true : false
290
+
291
+ inputs_ok = inputs_ok?(st)
292
+ status_ok = online && inputs_ok
293
+ status_ok &&= st.target_toplevel == st.current_toplevel if st.target_toplevel
294
+
295
+ row = {
296
+ 'host' => host,
297
+ 'online' => online && Rainbow('yes').green,
298
+ 'uptime' => st.uptime && format_duration(st.uptime),
299
+ 'status' => status_ok ? Rainbow('ok').green : Rainbow('outdated').red,
300
+ 'generations' => "#{build_generations.count}:#{st.generations && st.generations.count}"
301
+ }
302
+
303
+ roles.each do |role|
304
+ t_info = st.target_inputs_info && st.target_inputs_info[role]
305
+ d_info = st.inputs_info && st.inputs_info[role]
306
+ state = inputs_info_state(t_info, d_info)
307
+ row[role] = format_inputs_state(state, d_info)
308
+ end
309
+
310
+ rows << row
311
+ end
312
+
313
+ OutputFormatter.print(rows, cols, layout: :columns, color: use_color?)
314
+ end
315
+
219
316
  def changelog
317
+ return changelog_flake if flake_config?
318
+
220
319
  compare_swpins do |io, _host, status, sw_name, spec|
221
320
  s = spec.string_changelog_info(
222
321
  opts[:downgrade] ? :downgrade : :upgrade,
@@ -233,6 +332,8 @@ module ConfCtl::Cli
233
332
  end
234
333
 
235
334
  def diff
335
+ return diff_flake if flake_config?
336
+
236
337
  compare_swpins do |io, _host, status, sw_name, spec|
237
338
  s = spec.string_diff_info(
238
339
  opts[:downgrade] ? :downgrade : :upgrade,
@@ -246,6 +347,96 @@ module ConfCtl::Cli
246
347
  end
247
348
  end
248
349
 
350
+ def changelog_flake
351
+ compare_inputs_info do |io, host, target_info, deployed_info|
352
+ roles = inputs_roles(target_info, deployed_info)
353
+ if roles.empty?
354
+ io.puts "#{host}: no inputs info"
355
+ io.puts ''
356
+ next
357
+ end
358
+
359
+ entries = inputs_info_entries(target_info, deployed_info, pattern: args[1])
360
+ next if entries.empty?
361
+
362
+ any_changed = false
363
+ any_unknown = false
364
+
365
+ entries.each do |entry|
366
+ role = entry[:role]
367
+ t_info = entry[:target]
368
+ d_info = entry[:deployed]
369
+
370
+ case entry[:status]
371
+ when :unknown
372
+ any_unknown = true
373
+ io.puts "#{host} @ #{role} in unknown state"
374
+ when :changed
375
+ any_changed = true
376
+ io.puts "#{host} @ #{role}:"
377
+
378
+ begin
379
+ log = inputs_git_log(t_info, d_info)
380
+ io.puts(log || '(changelog unavailable)')
381
+ rescue StandardError => e
382
+ io.puts e.message
383
+ end
384
+
385
+ io.puts ''
386
+ end
387
+ end
388
+
389
+ unless any_changed || any_unknown
390
+ io.puts "#{host}: no changes"
391
+ io.puts ''
392
+ end
393
+
394
+ io.puts '' if any_unknown && !any_changed
395
+ end
396
+ end
397
+
398
+ def diff_flake
399
+ compare_inputs_info do |io, host, target_info, deployed_info|
400
+ roles = inputs_roles(target_info, deployed_info)
401
+ if roles.empty?
402
+ io.puts "#{host}: no inputs info"
403
+ io.puts ''
404
+ next
405
+ end
406
+
407
+ entries = inputs_info_entries(target_info, deployed_info, pattern: args[1])
408
+ next if entries.empty?
409
+
410
+ any_output = false
411
+
412
+ entries.each do |entry|
413
+ role = entry[:role]
414
+ t_info = entry[:target]
415
+ d_info = entry[:deployed]
416
+
417
+ case entry[:status]
418
+ when :unknown
419
+ any_output = true
420
+ io.puts "#{host} @ #{role} in unknown state"
421
+ when :changed
422
+ any_output = true
423
+ from = inputs_short_rev(d_info) || 'unknown'
424
+ to = inputs_short_rev(t_info) || 'unknown'
425
+ from, to = to, from if opts[:downgrade]
426
+ url = inputs_url(t_info, d_info)
427
+ label = url ? "#{role} (#{url})" : role
428
+ io.puts "#{host} @ #{label}: #{from} -> #{to}"
429
+ end
430
+ end
431
+
432
+ unless any_output
433
+ io.puts "#{host}: no changes"
434
+ end
435
+
436
+ io.puts ''
437
+ end
438
+ end
439
+
249
440
  def test_connection
250
441
  machines = select_machines_with_managed(args[0]).runnable
251
442
  raise 'No machines to test' if machines.empty?
@@ -327,6 +518,64 @@ module ConfCtl::Cli
327
518
 
328
519
  attr_reader :wait_online
329
520
 
521
+ def skip_current_deploy_targets(machines, host_generations, action)
522
+ return [machines, host_generations] if opts['copy-only']
523
+
524
+ statuses = deploy_target_statuses(machines, host_generations)
525
+
526
+ filtered_generations = host_generations.reject do |host, generation|
527
+ machine = machines[host]
528
+ status = statuses[host]
529
+
530
+ if already_using_target_generation?(machine, status, generation, action)
531
+ puts Rainbow("Skipping #{host}: already using target generation #{generation.name}").yellow
532
+ true
533
+ else
534
+ false
535
+ end
536
+ end
537
+
538
+ filtered_machines = machines.select do |host, _machine|
539
+ filtered_generations.has_key?(host)
540
+ end
541
+
542
+ [filtered_machines, filtered_generations]
543
+ end
544
+
545
+ def deploy_target_statuses(machines, host_generations)
546
+ statuses = {}
547
+ executor = ConfCtl::ParallelExecutor.new([host_generations.length, 5].min)
548
+
549
+ host_generations.each_key do |host|
550
+ statuses[host] = ConfCtl::MachineStatus.new(machines[host])
551
+
552
+ executor.add do
553
+ statuses[host].query(toplevel: true, generations: true, swpins: false)
554
+ end
555
+ end
556
+
557
+ executor.run
558
+ statuses
559
+ end
560
+
561
+ def already_using_target_generation?(machine, status, generation, action)
562
+ return false if status.nil? || status.current_toplevel.nil?
563
+
564
+ target_current = status.current_toplevel == generation.toplevel
565
+
566
+ return target_current if machine.carried? || %w[test dry-activate].include?(action)
567
+ return target_current && current_profile_toplevel(status) == generation.toplevel if %w[boot switch].include?(action)
568
+
569
+ false
570
+ end
571
+
572
+ def current_profile_toplevel(status)
573
+ return if status.generations.nil?
574
+
575
+ current = status.generations.current
576
+ current && current.toplevel
577
+ end
578
+
330
579
  def deploy_in_bulk(machines, host_generations, nix, action)
331
580
  skipped_copy = []
332
581
  skipped_activation = []
@@ -426,6 +675,16 @@ module ConfCtl::Cli
426
675
 
427
676
  return :skip if opts[:interactive] && !ask_confirmation(always: true)
428
677
 
678
+ unless ui_enabled?
679
+ ret = nix_copy(nix, machine, build_generation) do |i, n, path|
680
+ puts "[#{host}] [#{i}/#{n}] #{path}"
681
+ end
682
+
683
+ raise "Error while copying system to #{host}" unless ret
684
+
685
+ return true
686
+ end
687
+
429
688
  LogView.open(
430
689
  header: "#{Rainbow('Copying to').bright} #{host}\n",
431
690
  title: Rainbow('Live view').bright
@@ -453,6 +712,26 @@ module ConfCtl::Cli
453
712
  def concurrent_copy(machines, host_generations, nix)
454
713
  sorted_generations = sort_generations_for_copy(machines, host_generations)
455
714
 
715
+ unless ui_enabled?
716
+ executor = ConfCtl::ParallelExecutor.new(opts['max-concurrent-copy'])
717
+
718
+ sorted_generations.each do |host, gen|
719
+ executor.add do
720
+ ret = nix_copy(nix, machines[host], gen) do |i, n, path|
721
+ puts "[#{host}] [#{i}/#{n}] #{path}"
722
+ end
723
+
724
+ puts("[#{host}] copy failed") unless ret
725
+ ret ? nil : host
726
+ end
727
+ end
728
+
729
+ failed = executor.run.compact
730
+ raise "Copy failed to: #{failed.join(', ')}" if failed.any?
731
+
732
+ return
733
+ end
734
+
456
735
  LogView.open(
457
736
  header: "#{Rainbow("Copying to #{sorted_generations.length} machines").bright}\n",
458
737
  title: Rainbow('Live view').bright
@@ -521,13 +800,13 @@ module ConfCtl::Cli
521
800
  end
522
801
 
523
802
  until carried_groups.empty?
524
- carried_groups.delete_if do |_, machines|
525
- m = machines.shift
803
+ carried_groups.delete_if do |_, group_machines|
804
+ m = group_machines.shift
526
805
  next(true) if m.nil?
527
806
 
528
807
  sorted_generations << [m.name, host_generations[m.name]]
529
808
 
530
- machines.empty?
809
+ group_machines.empty?
531
810
  end
532
811
  end
533
812
 
@@ -655,26 +934,43 @@ module ConfCtl::Cli
655
934
  m.reboot
656
935
  else
657
936
  since = Time.now
658
- spinner = nil
659
-
660
- secs = m.reboot_and_wait(
661
- timeout: wait_online == :wait ? nil : wait_online
662
- ) do |state, timeleft|
663
- if state == :reboot
664
- spinner = TTY::Spinner.new(
665
- ":spinner Waiting for #{host} (:seconds s)",
666
- format: :classic
667
- )
668
- spinner.auto_spin
669
- elsif state == :is_up
670
- spinner.success('up')
671
- next
937
+
938
+ if ui_enabled?
939
+ spinner = nil
940
+
941
+ secs = m.reboot_and_wait(
942
+ timeout: wait_online == :wait ? nil : wait_online
943
+ ) do |state, timeleft|
944
+ if state == :reboot
945
+ spinner = TTY::Spinner.new(
946
+ ":spinner Waiting for #{host} (:seconds s)",
947
+ format: :classic
948
+ )
949
+ spinner.auto_spin
950
+ elsif state == :is_up
951
+ spinner.success('up')
952
+ next
953
+ end
954
+
955
+ if wait_online == :wait
956
+ spinner.update(seconds: (Time.now - since).round)
957
+ else
958
+ spinner.update(seconds: timeleft.round)
959
+ end
672
960
  end
961
+ else
962
+ puts "Waiting for #{host} to reboot..."
673
963
 
674
- if wait_online == :wait
675
- spinner.update(seconds: (Time.now - since).round)
676
- else
677
- spinner.update(seconds: timeleft.round)
964
+ secs = m.reboot_and_wait(
965
+ timeout: wait_online == :wait ? nil : wait_online
966
+ ) do |state, timeleft|
967
+ next unless %i[went_down is_down].include?(state)
968
+
969
+ if wait_online == :wait
970
+ puts " #{host}: waiting #{(Time.now - since).round}s"
971
+ else
972
+ puts " #{host}: #{timeleft.round}s left"
973
+ end
678
974
  end
679
975
  end
680
976
 
@@ -753,42 +1049,68 @@ module ConfCtl::Cli
753
1049
 
754
1050
  header << "\n" << Rainbow('Full log: ').bright << ConfCtl::Logger.relative_path << "\n"
755
1051
 
756
- LogView.open(
757
- header:,
758
- title: Rainbow('Live view').bright,
759
- size: :auto,
760
- reserved_lines: 10
761
- ) do |lw|
762
- pb = TTY::ProgressBar.new(
763
- 'Checks [:bar] :current/:total (:percent)',
764
- width: 80,
765
- total: run_checks.length
766
- )
1052
+ if ui_enabled?
1053
+ LogView.open(
1054
+ header:,
1055
+ title: Rainbow('Live view').bright,
1056
+ size: :auto,
1057
+ reserved_lines: 10
1058
+ ) do |lw|
1059
+ pb = TTY::ProgressBar.new(
1060
+ 'Checks [:bar] :current/:total (:percent)',
1061
+ width: 80,
1062
+ total: run_checks.length
1063
+ )
1064
+
1065
+ run_checks.each_with_index do |check, i|
1066
+ tw.add do
1067
+ prefix = "[#{i + 1}/#{run_checks.length}] #{check.machine}> "
1068
+ lw << "#{prefix}#{check.description}"
1069
+
1070
+ check.run do |attempt, _errors|
1071
+ lw << "#{prefix}failed ##{attempt}: #{check.message}"
1072
+ ConfCtl::Logger.keep
1073
+ end
1074
+
1075
+ lw << if check.successful?
1076
+ "#{prefix}succeeded"
1077
+ else
1078
+ "#{prefix}error: #{check.message}"
1079
+ end
1080
+
1081
+ lw.sync_console do
1082
+ pb.advance
1083
+ end
1084
+ end
1085
+ end
1086
+
1087
+ tw.run
1088
+ lw.flush
1089
+ end
1090
+ else
1091
+ puts header
767
1092
 
768
1093
  run_checks.each_with_index do |check, i|
769
1094
  tw.add do
770
1095
  prefix = "[#{i + 1}/#{run_checks.length}] #{check.machine}> "
771
- lw << "#{prefix}#{check.description}"
1096
+ puts "#{prefix}#{check.description}"
772
1097
 
773
1098
  check.run do |attempt, _errors|
774
- lw << "#{prefix}failed ##{attempt}: #{check.message}"
1099
+ puts "#{prefix}failed ##{attempt}: #{check.message}"
775
1100
  ConfCtl::Logger.keep
776
1101
  end
777
1102
 
778
- lw << if check.successful?
779
- "#{prefix}succeeded"
780
- else
781
- "#{prefix}error: #{check.message}"
782
- end
783
-
784
- lw.sync_console do
785
- pb.advance
786
- end
1103
+ puts(
1104
+ if check.successful?
1105
+ "#{prefix}succeeded"
1106
+ else
1107
+ "#{prefix}error: #{check.message}"
1108
+ end
1109
+ )
787
1110
  end
788
1111
  end
789
1112
 
790
1113
  tw.run
791
- lw.flush
792
1114
  end
793
1115
 
794
1116
  successful = []
@@ -847,7 +1169,7 @@ module ConfCtl::Cli
847
1169
  puts 'Run command over SSH on the following machines:'
848
1170
  list_machines(machines)
849
1171
  puts
850
- puts "Command: #{cmd.map(&:inspect).join(' ')}"
1172
+ puts "Command: #{Shellwords.join(cmd)}"
851
1173
  end
852
1174
 
853
1175
  if opts[:parallel]
@@ -889,31 +1211,44 @@ module ConfCtl::Cli
889
1211
  results = {}
890
1212
  tw = ConfCtl::ParallelExecutor.new(machines.length)
891
1213
 
892
- LogView.open_with_logger(
893
- header: Rainbow('Executing').bright + " #{cmd.join(' ')}\n",
894
- title: Rainbow('Live view').bright,
895
- size: :auto,
896
- reserved_lines: 10
897
- ) do |lw|
898
- pb = TTY::ProgressBar.new(
899
- 'Command [:bar] :current/:total (:percent)',
900
- width: 80,
901
- total: machines.length
902
- )
1214
+ if ui_enabled?
1215
+ LogView.open_with_logger(
1216
+ header: Rainbow('Executing').bright + " #{Shellwords.join(cmd)}\n",
1217
+ title: Rainbow('Live view').bright,
1218
+ size: :auto,
1219
+ reserved_lines: 10
1220
+ ) do |lw|
1221
+ pb = TTY::ProgressBar.new(
1222
+ 'Command [:bar] :current/:total (:percent)',
1223
+ width: 80,
1224
+ total: machines.length
1225
+ )
1226
+
1227
+ machines.each do |host, machine|
1228
+ tw.add do
1229
+ mc = ConfCtl::MachineControl.new(machine)
1230
+
1231
+ result = run_ssh_command_on_machine(mc, cmd)
1232
+ results[host] = result
1233
+
1234
+ lw.sync_console { pb.advance }
1235
+ end
1236
+ end
1237
+
1238
+ tw.run
1239
+ lw.flush
1240
+ end
1241
+ else
1242
+ puts "Executing #{Shellwords.join(cmd)} on #{machines.length} machines"
903
1243
 
904
1244
  machines.each do |host, machine|
905
1245
  tw.add do
906
1246
  mc = ConfCtl::MachineControl.new(machine)
907
-
908
- result = run_ssh_command_on_machine(mc, cmd)
909
- results[host] = result
910
-
911
- lw.sync_console { pb.advance }
1247
+ results[host] = run_ssh_command_on_machine(mc, cmd)
912
1248
  end
913
1249
  end
914
1250
 
915
1251
  tw.run
916
- lw.flush
917
1252
  end
918
1253
 
919
1254
  if aggregate
@@ -1013,11 +1348,28 @@ module ConfCtl::Cli
1013
1348
 
1014
1349
  def list_generations(host_generations, missing_hosts:)
1015
1350
  swpin_names = []
1351
+ input_roles = []
1016
1352
 
1017
1353
  host_generations.each_value do |gen|
1018
1354
  gen.swpin_names.each do |name|
1019
1355
  swpin_names << name unless swpin_names.include?(name)
1020
1356
  end
1357
+
1358
+ next unless flake_generation?(gen)
1359
+
1360
+ inputs_info = normalized_inputs_info(gen)
1361
+ next if inputs_info.nil? || inputs_info.empty?
1362
+
1363
+ inputs_info.each_key do |role|
1364
+ input_roles << role unless input_roles.include?(role)
1365
+ end
1366
+ end
1367
+
1368
+ input_column_names = {}
1369
+ input_columns = input_roles.map do |role|
1370
+ name = swpin_names.include?(role) ? "input:#{role}" : role
1371
+ input_column_names[role] = name
1372
+ { name: name, label: role.to_s.upcase }
1021
1373
  end
1022
1374
 
1023
1375
  rows = host_generations.map do |host, gen|
@@ -1031,6 +1383,13 @@ module ConfCtl::Cli
1031
1383
  row[name] = spec.version
1032
1384
  end
1033
1385
 
1386
+ if flake_generation?(gen)
1387
+ inputs_info = normalized_inputs_info(gen) || {}
1388
+ input_roles.each do |role|
1389
+ row[input_column_names[role]] = inputs_short_rev(inputs_info[role])
1390
+ end
1391
+ end
1392
+
1034
1393
  row
1035
1394
  end
1036
1395
 
@@ -1040,7 +1399,7 @@ module ConfCtl::Cli
1040
1399
 
1041
1400
  OutputFormatter.print(
1042
1401
  rows,
1043
- %w[name generation kernel] + swpin_names,
1402
+ %w[name generation kernel] + swpin_names + input_columns,
1044
1403
  layout: :columns,
1045
1404
  sort: %w[name generation]
1046
1405
  )
@@ -1058,7 +1417,11 @@ module ConfCtl::Cli
1058
1417
 
1059
1418
  raise 'one or more swpins need to be updated' unless host_swpin_specs
1060
1419
 
1061
- puts Rainbow("Evaluating swpins for #{machines.length} machines...").bright
1420
+ if ConfCtl::ConfigType.flake?(ConfCtl::ConfDir.path)
1421
+ puts Rainbow("Evaluating inputs for #{machines.length} machines...").bright
1422
+ else
1423
+ puts Rainbow("Evaluating swpins for #{machines.length} machines...").bright
1424
+ end
1062
1425
 
1063
1426
  hosts_swpin_paths = nix.eval_host_swpins(machines.map { |host, _| host })
1064
1427
 
@@ -1066,7 +1429,7 @@ module ConfCtl::Cli
1066
1429
  hosts_swpin_paths[host].update(m.nix_paths)
1067
1430
  end
1068
1431
 
1069
- grps = swpin_build_groups(hosts_swpin_paths)
1432
+ grps = swpin_build_groups(hosts_swpin_paths, nix)
1070
1433
  puts
1071
1434
  puts "Machines will be built in #{grps.length} groups"
1072
1435
  puts
@@ -1112,7 +1475,7 @@ module ConfCtl::Cli
1112
1475
  def do_build_group(group_index, group_count, hosts, swpin_paths, host_swpin_specs, nix, time)
1113
1476
  puts Rainbow('Building machines').bright
1114
1477
  hosts.each { |h| puts " #{h}" }
1115
- puts 'with swpins'
1478
+ puts ConfCtl::ConfigType.flake?(ConfCtl::ConfDir.path) ? 'with inputs' : 'with swpins'
1116
1479
  swpin_paths.each { |k, v| puts " #{k}=#{v}" }
1117
1480
 
1118
1481
  header = '' \
@@ -1126,55 +1489,72 @@ module ConfCtl::Cli
1126
1489
  << " #{ConfCtl::Logger.relative_path}" \
1127
1490
  << "\n\n"
1128
1491
 
1129
- LogView.open_with_logger(
1130
- header:,
1131
- title: Rainbow('Live view').bright,
1132
- size: :auto,
1133
- reserved_lines: 10
1134
- ) do |lw|
1135
- multibar = TTY::ProgressBar::Multi.new(
1136
- 'nix-build [:bar] :current/:total (:percent)',
1137
- width: 80
1138
- )
1492
+ if ui_enabled?
1493
+ LogView.open_with_logger(
1494
+ header:,
1495
+ title: Rainbow('Live view').bright,
1496
+ size: :auto,
1497
+ reserved_lines: 10
1498
+ ) do |lw|
1499
+ multibar = TTY::ProgressBar::Multi.new(
1500
+ 'nix-build [:bar] :current/:total (:percent)',
1501
+ width: 80
1502
+ )
1139
1503
 
1140
- build_pb = multibar.register(
1141
- 'Building [:bar] :current/:total (:percent)'
1142
- )
1504
+ build_pb = multibar.register(
1505
+ 'Building [:bar] :current/:total (:percent)'
1506
+ )
1143
1507
 
1144
- fetch_pb = multibar.register(
1145
- 'Fetching [:bar] :current/:total (:percent)'
1146
- )
1508
+ fetch_pb = multibar.register(
1509
+ 'Fetching [:bar] :current/:total (:percent)'
1510
+ )
1147
1511
 
1148
- built_generations = nix.build_attributes(
1149
- hosts:,
1150
- swpin_paths:,
1151
- time:,
1152
- host_swpin_specs:
1153
- ) do |type, _progress, total, _path|
1154
- if type == :build
1155
- lw.sync_console do
1156
- build_pb.update(total:) if total > 0 && build_pb.total.nil?
1157
- build_pb.advance
1158
- end
1159
- elsif type == :fetch
1160
- lw.sync_console do
1161
- fetch_pb.update(total:) if total > 0 && fetch_pb.total.nil?
1162
- fetch_pb.advance
1512
+ built_generations = nix.build_attributes(
1513
+ hosts:,
1514
+ swpin_paths:,
1515
+ time:,
1516
+ host_swpin_specs:
1517
+ ) do |type, _progress, total, _path|
1518
+ if type == :build
1519
+ lw.sync_console do
1520
+ build_pb.update(total:) if total > 0 && build_pb.total.nil?
1521
+ build_pb.advance
1522
+ end
1523
+ elsif type == :fetch
1524
+ lw.sync_console do
1525
+ fetch_pb.update(total:) if total > 0 && fetch_pb.total.nil?
1526
+ fetch_pb.advance
1527
+ end
1163
1528
  end
1164
- end
1165
1529
 
1166
- if build_pb.total && fetch_pb.total && multibar.top_bar.total.nil?
1167
- lw.sync_console do
1168
- multibar.top_bar.update(total: multibar.total)
1530
+ if build_pb.total && fetch_pb.total && multibar.top_bar.total.nil?
1531
+ lw.sync_console do
1532
+ multibar.top_bar.update(total: multibar.total)
1533
+ end
1169
1534
  end
1170
1535
  end
1171
- end
1172
1536
 
1173
- built_generations
1537
+ built_generations
1538
+ end
1539
+ else
1540
+ puts header
1541
+ nix.build_attributes(
1542
+ hosts:,
1543
+ swpin_paths:,
1544
+ time:,
1545
+ host_swpin_specs:
1546
+ ) do |type, progress, total, path|
1547
+ puts "[#{type}] #{progress}/#{total} #{path}"
1548
+ end
1174
1549
  end
1175
1550
  end
1176
1551
 
1177
1552
  def autoupdate_swpins(machines)
1553
+ if ConfCtl::ConfigType.flake?(ConfCtl::ConfDir.path)
1554
+ puts Rainbow('Skipping swpins auto updates for flake config').bright
1555
+ return
1556
+ end
1557
+
1178
1558
  puts Rainbow('Running swpins auto updates...').bright
1179
1559
  channels_update = []
1180
1560
  any_updated = false
@@ -1250,6 +1630,13 @@ module ConfCtl::Cli
1250
1630
  end
1251
1631
 
1252
1632
  def check_swpins(machines)
1633
+ if ConfCtl::ConfigType.flake?(ConfCtl::ConfDir.path)
1634
+ puts Rainbow('Skipping swpins checks for flake config').bright
1635
+ ret = {}
1636
+ machines.each_key { |host| ret[host] = {} }
1637
+ return ret
1638
+ end
1639
+
1253
1640
  ret = {}
1254
1641
  valid = true
1255
1642
 
@@ -1278,7 +1665,15 @@ module ConfCtl::Cli
1278
1665
  valid ? ret : false
1279
1666
  end
1280
1667
 
1281
- def swpin_build_groups(hosts_swpins)
1668
+ def swpin_build_groups(hosts_swpins, nix = nil)
1669
+ if ConfCtl::ConfigType.flake?(ConfCtl::ConfDir.path)
1670
+ legacy_nix_path = false
1671
+ if nix
1672
+ legacy_nix_path = nix.confctl_settings.dig('nix', 'legacyNixPath') == true
1673
+ end
1674
+ return [[hosts_swpins.keys, {}]] unless legacy_nix_path
1675
+ end
1676
+
1282
1677
  ret = []
1283
1678
  all_swpins = hosts_swpins.values.uniq
1284
1679
 
@@ -1295,6 +1690,50 @@ module ConfCtl::Cli
1295
1690
  ret
1296
1691
  end
1297
1692
 
1693
+ def compare_inputs_info
1694
+ machines = select_machines(args[0]).managed
1695
+
1696
+ ask_confirmation! do
1697
+ puts 'Compare inputs on the following machines:'
1698
+ list_machines(machines)
1699
+ puts
1700
+ puts "Generation: #{opts[:generation] || 'current configuration'}"
1701
+ end
1702
+
1703
+ statuses = machines.transform_values do |machine|
1704
+ ConfCtl::MachineStatus.new(machine)
1705
+ end
1706
+
1707
+ if opts[:generation]
1708
+ host_generations = find_generations(machines, opts[:generation])
1709
+
1710
+ # Ignore statuses when no generation was found
1711
+ statuses.delete_if do |host, _st|
1712
+ !host_generations.has_key?(host)
1713
+ end
1714
+
1715
+ assign_target_inputs_info(statuses, host_generations:)
1716
+ else
1717
+ assign_target_inputs_info(statuses)
1718
+ end
1719
+
1720
+ TTY::Pager.page(enabled: use_pager?) do |io|
1721
+ statuses.each do |host, st|
1722
+ st.query(toplevel: false, generations: false, swpins: false, inputs: true)
1723
+
1724
+ if st.uptime.nil?
1725
+ io.puts "#{host} is offline"
1726
+ next
1727
+ end
1728
+
1729
+ target_info = st.target_inputs_info || {}
1730
+ deployed_info = st.inputs_info || {}
1731
+
1732
+ yield(io, host, target_info, deployed_info)
1733
+ end
1734
+ end
1735
+ end
1736
+
1298
1737
  def compare_swpins
1299
1738
  machines = select_machines(args[0]).managed
1300
1739
 
@@ -1355,6 +1794,132 @@ module ConfCtl::Cli
1355
1794
  end
1356
1795
  end
1357
1796
 
1797
+ def assign_target_inputs_info(statuses, host_generations: nil)
1798
+ if host_generations
1799
+ host_generations.each do |host, gen|
1800
+ next unless statuses[host]
1801
+
1802
+ statuses[host].target_inputs_info = ConfCtl::InputsInfo.normalize(gen.inputs_info)
1803
+ end
1804
+ else
1805
+ nix = ConfCtl::Nix.new(show_trace: opts['show-trace'])
1806
+
1807
+ statuses.each do |host, st|
1808
+ st.target_inputs_info = ConfCtl::InputsInfo.normalize(nix.eval_inputs_info(host))
1809
+ rescue StandardError
1810
+ st.target_inputs_info = nil
1811
+ end
1812
+ end
1813
+ end
1814
+
1815
+ def inputs_roles_for_status(statuses)
1816
+ roles = []
1817
+
1818
+ statuses.each_value do |st|
1819
+ roles.concat((st.target_inputs_info || {}).keys)
1820
+ roles.concat((st.inputs_info || {}).keys)
1821
+ end
1822
+
1823
+ roles.uniq.sort
1824
+ end
1825
+
1826
+ def inputs_ok?(status)
1827
+ target_info = status.target_inputs_info
1828
+ return false if target_info.nil? || target_info.empty?
1829
+
1830
+ target_info.all? do |role, info|
1831
+ inputs_info_state(info, status.inputs_info && status.inputs_info[role]) == :same
1832
+ end
1833
+ end
1834
+
1835
+ def inputs_info_entries(target_info, deployed_info, pattern: nil)
1836
+ inputs_roles(target_info, deployed_info).filter_map do |role|
1837
+ next if pattern && !ConfCtl::Pattern.match?(pattern, role)
1838
+
1839
+ t_info = target_info[role]
1840
+ d_info = deployed_info[role]
1841
+ { role:, target: t_info, deployed: d_info, status: inputs_info_state(t_info, d_info) }
1842
+ end
1843
+ end
1844
+
1845
+ def inputs_roles(target_info, deployed_info)
1846
+ roles = []
1847
+ roles.concat(target_info.keys) if target_info
1848
+ roles.concat(deployed_info.keys) if deployed_info
1849
+ roles.uniq.sort
1850
+ end
1851
+
1852
+ def inputs_info_state(target_info, deployed_info)
1853
+ return :unknown if target_info.nil? || deployed_info.nil?
1854
+
1855
+ target_rev = inputs_rev(target_info)
1856
+ deployed_rev = inputs_rev(deployed_info)
1857
+ return :unknown if target_rev.nil? || deployed_rev.nil?
1858
+
1859
+ target_rev == deployed_rev ? :same : :changed
1860
+ end
1861
+
1862
+ def inputs_rev(info)
1863
+ return nil unless info.is_a?(Hash)
1864
+
1865
+ info['rev']
1866
+ end
1867
+
1868
+ def inputs_short_rev(info)
1869
+ return nil unless info.is_a?(Hash)
1870
+
1871
+ info['shortRev'] || (info['rev'] && info['rev'][0, 8])
1872
+ end
1873
+
1874
+ def normalized_inputs_info(gen)
1875
+ return nil unless gen.respond_to?(:inputs_info)
1876
+
1877
+ ConfCtl::InputsInfo.normalize(gen.inputs_info)
1878
+ end
1879
+
1880
+ def flake_generation?(gen)
1881
+ gen.respond_to?(:flakes_mode?) && gen.flakes_mode?
1882
+ end
1883
+
1884
+ def inputs_url(target_info, deployed_info)
1885
+ (target_info && target_info['url']) || (deployed_info && deployed_info['url'])
1886
+ end
1887
+
1888
+ def inputs_git_log(target_info, deployed_info)
1889
+ url = inputs_url(target_info, deployed_info)
1890
+ old_rev = inputs_rev(deployed_info)
1891
+ new_rev = inputs_rev(target_info)
1892
+ return nil if url.nil? || old_rev.nil? || new_rev.nil?
1893
+
1894
+ old_rev, new_rev = new_rev, old_rev if opts[:downgrade]
1895
+
1896
+ log_opts = []
1897
+ log_opts << '--oneline' unless opts[:verbose]
1898
+ log_opts << '-p' if opts[:patch]
1899
+ log_opts << '--max-count=50' unless opts[:verbose] || opts[:patch]
1900
+
1901
+ mirror = ConfCtl::GitRepoMirror.new(url, quiet: true)
1902
+ mirror.setup
1903
+ mirror.log(old_rev, new_rev, opts: log_opts)
1904
+ end
1905
+
1906
+ def format_inputs_state(state, deployed_info)
1907
+ rev = inputs_short_rev(deployed_info) || 'unknown'
1908
+
1909
+ case state
1910
+ when :same
1911
+ Rainbow(rev).green
1912
+ when :changed
1913
+ Rainbow(rev).red
1914
+ else
1915
+ Rainbow(rev).yellow
1916
+ end
1917
+ end
1918
+
1919
+ def flake_config?
1920
+ ConfCtl::ConfigType.flake?(ConfCtl::ConfDir.path)
1921
+ end
1922
+
1358
1923
  def parse_wait_online
1359
1924
  @wait_online =
1360
1925
  case opts['wait-online']
@@ -1371,9 +1936,13 @@ module ConfCtl::Cli
1371
1936
 
1372
1937
  def format_command(reserved_cols = 0)
1373
1938
  cmd = "#{$0.split('/').last} #{ARGV.join(' ')}"
1374
- _, cols = IO.console.winsize
1939
+ cols = IO.console&.winsize&.last
1940
+ cols = cols.to_i
1941
+ cols = 80 if cols <= 0
1375
1942
  max_length = cols - reserved_cols
1376
1943
 
1944
+ return cmd if max_length <= 4
1945
+
1377
1946
  if cmd.length > max_length
1378
1947
  "#{cmd[0..(max_length - 4)]}..."
1379
1948
  else
@@ -1404,6 +1973,10 @@ module ConfCtl::Cli
1404
1973
  nix.copy(machine, paths, &)
1405
1974
  end
1406
1975
 
1976
+ def ui_enabled?
1977
+ ConfCtl::Ui.tty?
1978
+ end
1979
+
1407
1980
  def enable_auto_rollback?(machine, generation)
1408
1981
  !opts['disable-auto-rollback'] \
1409
1982
  && (opts['enable-auto-rollback'] || machine.auto_rollback?) \