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
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'confctl'
5
+ require 'confctl/cli'
6
+
7
+ RSpec.describe 'flake input set output' do
8
+ let(:gopts) { { color: 'never' } }
9
+ let(:common_opts) { { commit: false, changelog: true, downgrade: false, editor: false } }
10
+ let(:lock) { instance_double(ConfCtl::FlakeLock) }
11
+ let(:resolved_info) { { short_rev: '5361648e', rev: '5361648e4a29f58b5c208d3f185d95cd0a43fe54' } }
12
+
13
+ around do |example|
14
+ old_tty = ENV.fetch('CONFCTL_TTY', nil)
15
+ ENV['CONFCTL_TTY'] = '0'
16
+ example.run
17
+ ensure
18
+ ENV['CONFCTL_TTY'] = old_tty
19
+ end
20
+
21
+ before do
22
+ allow(ConfCtl::ConfDir).to receive(:path).and_return('/conf')
23
+ allow(ConfCtl::ConfigType).to receive(:flake?).with('/conf').and_return(true)
24
+ allow(ConfCtl::Inputs::Setter).to receive(:run!).and_return(changed: true, changes: [])
25
+ allow(ConfCtl::FlakeLock).to receive(:load).with('/conf/flake.lock').and_return(lock)
26
+ allow(lock).to receive(:input_info).with('vpsadmin-input').and_return(resolved_info)
27
+ end
28
+
29
+ it 'prints the resolved locked revision for channel set' do
30
+ command = ConfCtl::Cli::Inputs::Channels.new(
31
+ gopts,
32
+ common_opts.merge(allow_shared: false),
33
+ %w[vpsadmin vpsadmin devel]
34
+ )
35
+
36
+ allow(command).to receive(:eval_channels).and_return(
37
+ 'vpsadmin' => { 'vpsadmin' => 'vpsadmin-input' }
38
+ )
39
+
40
+ expect do
41
+ command.set
42
+ end.to output("Configuring vpsadmin in vpsadmin -> 5361648e\n").to_stdout
43
+
44
+ expect(ConfCtl::Inputs::Setter).to have_received(:run!).with(
45
+ hash_including(conf_dir: '/conf', inputs: ['vpsadmin-input'], rev: 'devel')
46
+ )
47
+ end
48
+
49
+ it 'prints the resolved locked revision for machine set' do
50
+ nix = instance_double(ConfCtl::Nix)
51
+ allow(ConfCtl::Nix).to receive(:new).and_return(nix)
52
+ allow(nix).to receive(:eval_inputs_info).with('build.vpsfree.cz').and_return(
53
+ 'vpsadmin' => { 'input' => 'vpsadmin-input' }
54
+ )
55
+
56
+ command = ConfCtl::Cli::Inputs::Machines.new(
57
+ gopts,
58
+ common_opts,
59
+ %w[build.vpsfree.cz vpsadmin devel]
60
+ )
61
+
62
+ expect do
63
+ command.set
64
+ end.to output("Configuring vpsadmin in build.vpsfree.cz -> 5361648e\n").to_stdout
65
+
66
+ expect(ConfCtl::Inputs::Setter).to have_received(:run!).with(
67
+ hash_including(conf_dir: '/conf', inputs: ['vpsadmin-input'], rev: 'devel')
68
+ )
69
+ end
70
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tmpdir'
4
+
5
+ RSpec.describe 'configuration commands' do
6
+ include CliHelper
7
+
8
+ it 'initializes a flake configuration directory' do
9
+ Dir.mktmpdir('confctl-config-init-') do |dir|
10
+ result = run_confctl('init', chdir: dir)
11
+ flake = File.read(File.join(dir, 'flake.nix'))
12
+
13
+ expect(result.success?).to be(true), result.err
14
+ expect(File).to exist(File.join(dir, 'flake.nix'))
15
+ expect(File).to exist(File.join(dir, 'cluster', 'cluster.nix'))
16
+ expect(File).to exist(File.join(dir, 'configs', 'confctl.nix'))
17
+ expect(File).to exist(File.join(dir, 'data', 'ssh-keys.nix'))
18
+ expect(flake).to include('nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";')
19
+ expect(flake).to include('# vpsadminos.url = "github:vpsfreecz/vpsadminos/staging";')
20
+ expect(flake).to include('# vpsadminos.inputs.nixpkgs.follows = "nixpkgs";')
21
+ expect(flake).to include('confctl.lib.mkConfigDevShell')
22
+ expect(flake).to include('mode = "minimal";')
23
+ end
24
+ end
25
+
26
+ it 'adds and renames machines and rewrites cluster inventory' do
27
+ Dir.mktmpdir('confctl-config-add-rename-') do |dir|
28
+ expect(run_confctl('init', chdir: dir).success?).to be(true)
29
+
30
+ add_result = run_confctl('add', 'nested/test-machine', chdir: dir)
31
+ expect(add_result.success?).to be(true), add_result.err
32
+ expect(File).to exist(File.join(dir, 'cluster', 'nested', 'test-machine', 'module.nix'))
33
+ expect(File.read(File.join(dir, 'cluster', 'cluster.nix'))).to include('./nested/test-machine/module.nix')
34
+
35
+ rename_result = run_confctl('rename', 'nested/test-machine', 'renamed/machine', chdir: dir)
36
+ expect(rename_result.success?).to be(true), rename_result.err
37
+ expect(File).not_to exist(File.join(dir, 'cluster', 'nested', 'test-machine'))
38
+ expect(File).to exist(File.join(dir, 'cluster', 'renamed', 'machine', 'module.nix'))
39
+
40
+ cluster_nix = File.read(File.join(dir, 'cluster', 'cluster.nix'))
41
+ expect(cluster_nix).to include('./renamed/machine/module.nix')
42
+ expect(cluster_nix).not_to include('./nested/test-machine/module.nix')
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'tmpdir'
5
+
6
+ RSpec.describe 'inputs workflows' do
7
+ include CliHelper
8
+
9
+ def create_dummy_repo(path)
10
+ FileUtils.mkdir_p(path)
11
+ init_git_repo(path)
12
+
13
+ File.write(
14
+ File.join(path, 'flake.nix'),
15
+ <<~NIX
16
+ {
17
+ description = "dummy flake input for confctl tests";
18
+ outputs = { self, ... }: {};
19
+ }
20
+ NIX
21
+ )
22
+ File.write(File.join(path, 'dummy.txt'), "A\n")
23
+ git_commit_all(path, 'dummy: A')
24
+ rev_a = run_cmd('git', 'rev-parse', 'HEAD', chdir: path).out.strip
25
+
26
+ [rev_a]
27
+ end
28
+
29
+ def commit_dummy(path, label)
30
+ count = run_cmd('git', 'rev-list', '--count', 'HEAD', chdir: path).out.strip.to_i
31
+ File.write(File.join(path, 'dummy.txt'), "#{label} #{count + 1}\n")
32
+ git_commit_all(path, "dummy: #{label}")
33
+ run_cmd('git', 'rev-parse', 'HEAD', chdir: path).out.strip
34
+ end
35
+
36
+ def locked_rev(lock_path, input)
37
+ lock = JSON.parse(File.read(lock_path))
38
+ node_id = lock.fetch('nodes').fetch('root').fetch('inputs').fetch(input)
39
+ lock.fetch('nodes').fetch(node_id).fetch('locked').fetch('rev')
40
+ end
41
+
42
+ it 'updates and sets local git inputs with and without commits/changelog' do
43
+ Dir.mktmpdir('confctl-inputs-spec-') do |dir|
44
+ dummy_repo = File.join(dir, 'dummy-input')
45
+ rev_a, = create_dummy_repo(dummy_repo)
46
+
47
+ conf_dir = File.join(dir, 'conf')
48
+ FileUtils.mkdir_p(conf_dir)
49
+ init_git_repo(conf_dir)
50
+
51
+ File.write(File.join(conf_dir, 'flake.nix'), <<~NIX)
52
+ {
53
+ description = "confctl inputs test";
54
+
55
+ inputs = {
56
+ dummy-input.url = "git+file://#{dummy_repo}";
57
+ };
58
+
59
+ outputs = { self, ... }: {};
60
+ }
61
+ NIX
62
+
63
+ git_commit_all(conf_dir, 'fixture: initial')
64
+
65
+ res = run_confctl('inputs', 'update', 'dummy-input', chdir: conf_dir)
66
+ expect(res.success?).to be(true), "#{res.out}\n#{res.err}"
67
+
68
+ lock_path = File.join(conf_dir, 'flake.lock')
69
+ expect(File).to exist(lock_path)
70
+ expect(locked_rev(lock_path, 'dummy-input')).to eq(rev_a)
71
+
72
+ log_count = run_cmd('git', 'rev-list', '--count', 'HEAD', chdir: conf_dir).out.strip.to_i
73
+ expect(log_count).to eq(1)
74
+
75
+ rev_b = commit_dummy(dummy_repo, 'B')
76
+
77
+ res = run_confctl(
78
+ 'inputs', 'update', '--commit', '--no-changelog', '--no-editor', 'dummy-input',
79
+ chdir: conf_dir
80
+ )
81
+ expect(res.success?).to be(true), "#{res.out}\n#{res.err}"
82
+ expect(locked_rev(lock_path, 'dummy-input')).to eq(rev_b)
83
+
84
+ log_count = run_cmd('git', 'rev-list', '--count', 'HEAD', chdir: conf_dir).out.strip.to_i
85
+ expect(log_count).to eq(2)
86
+
87
+ last_msg = run_cmd('git', 'log', '-1', '--pretty=%s', chdir: conf_dir).out.strip
88
+ expect(last_msg).to include('inputs: update dummy-input')
89
+
90
+ res = run_confctl(
91
+ 'inputs', 'set', '--commit', '--changelog', '--no-editor', 'dummy-input', rev_a,
92
+ chdir: conf_dir
93
+ )
94
+ expect(res.success?).to be(true), "#{res.out}\n#{res.err}"
95
+ expect(locked_rev(lock_path, 'dummy-input')).to eq(rev_a)
96
+
97
+ log_count = run_cmd('git', 'rev-list', '--count', 'HEAD', chdir: conf_dir).out.strip.to_i
98
+ expect(log_count).to eq(3)
99
+
100
+ last_msg = run_cmd('git', 'log', '-1', '--pretty=%s', chdir: conf_dir).out.strip
101
+ expect(last_msg).to include('inputs: set dummy-input')
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'confctl'
5
+ require 'shellwords'
6
+
7
+ RSpec.describe ConfCtl::MachineControl do
8
+ let(:runner_class) do
9
+ Class.new do
10
+ attr_reader :calls, :result
11
+
12
+ def initialize
13
+ @calls = []
14
+ @result = Object.new
15
+ end
16
+
17
+ def run(*args, **kwargs, &)
18
+ calls << { method: :run, args:, kwargs:, block: block_given? }
19
+ result
20
+ end
21
+
22
+ def run!(*args, **kwargs, &)
23
+ calls << { method: :run!, args:, kwargs:, block: block_given? }
24
+ result
25
+ end
26
+ end
27
+ end
28
+
29
+ let(:runner) { runner_class.new }
30
+
31
+ before do
32
+ allow(ConfCtl::SystemCommand).to receive(:new).and_return(runner)
33
+ allow(ENV).to receive(:fetch).and_call_original
34
+ allow(ENV).to receive(:fetch).with('CONFCTL_SSH_CONFIG', nil).and_return(nil)
35
+ end
36
+
37
+ def machine(localhost:, target_host: 'web.int.vpsfree.cz', target_port: 22)
38
+ instance_double(
39
+ ConfCtl::Machine,
40
+ localhost?: localhost,
41
+ target_host:,
42
+ target_port:
43
+ )
44
+ end
45
+
46
+ it 'passes localhost commands as literal argv' do
47
+ command = %w[printf %s] + ['Host: vpsfree.cz']
48
+
49
+ ret = described_class.new(machine(localhost: true)).execute(*command)
50
+
51
+ expect(ret).to equal(runner.result)
52
+ expect(runner.calls).to contain_exactly(
53
+ method: :run,
54
+ args: command,
55
+ kwargs: {},
56
+ block: false
57
+ )
58
+ end
59
+
60
+ it 'shell-quotes remote argv into one ssh command argument' do
61
+ command = [
62
+ 'printf',
63
+ '%s',
64
+ 'Host: vpsfree.cz',
65
+ "quote'arg",
66
+ '',
67
+ 'semi;colon',
68
+ 'dollar$arg'
69
+ ]
70
+
71
+ ret = described_class.new(machine(localhost: false, target_port: 2222)).execute!(
72
+ *command,
73
+ input: 'stdin'
74
+ )
75
+
76
+ expect(ret).to equal(runner.result)
77
+ expect(runner.calls).to contain_exactly(
78
+ method: :run!,
79
+ args: [
80
+ 'ssh',
81
+ '-l',
82
+ 'root',
83
+ '-p',
84
+ '2222',
85
+ 'web.int.vpsfree.cz',
86
+ Shellwords.join(command)
87
+ ],
88
+ kwargs: { input: 'stdin' },
89
+ block: false
90
+ )
91
+ end
92
+
93
+ it 'keeps extra ssh options before the target host' do
94
+ mc = described_class.new(machine(localhost: false))
95
+
96
+ mc.send(:with_ssh_opts, '-o', 'ConnectTimeout=3') do
97
+ mc.execute!('true')
98
+ end
99
+
100
+ expect(runner.calls.first[:args]).to eq(
101
+ [
102
+ 'ssh',
103
+ '-l',
104
+ 'root',
105
+ '-o',
106
+ 'ConnectTimeout=3',
107
+ 'web.int.vpsfree.cz',
108
+ 'true'
109
+ ]
110
+ )
111
+ end
112
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'confctl'
5
+
6
+ RSpec.describe ConfCtl::MachineList do
7
+ def machine(managed: true, runnable: true, checks: [])
8
+ instance_double(
9
+ ConfCtl::Machine,
10
+ managed:,
11
+ runnable?: runnable,
12
+ health_checks: checks
13
+ )
14
+ end
15
+
16
+ it 'selects only runnable machines' do
17
+ direct = machine
18
+ carried = machine(runnable: false)
19
+ no_target = machine(runnable: false)
20
+
21
+ list = described_class.new(
22
+ machines: {
23
+ 'direct' => direct,
24
+ 'carried' => carried,
25
+ 'no-target' => no_target
26
+ }
27
+ )
28
+
29
+ expect(list.runnable.to_a).to eq([direct])
30
+ end
31
+
32
+ it 'returns health checks only from runnable machines' do
33
+ direct_check = instance_double(ConfCtl::HealthChecks::Base)
34
+ carried_check = instance_double(ConfCtl::HealthChecks::Base)
35
+ direct = machine(checks: [direct_check])
36
+ carried = machine(runnable: false, checks: [carried_check])
37
+
38
+ list = described_class.new(
39
+ machines: {
40
+ 'direct' => direct,
41
+ 'carried' => carried
42
+ }
43
+ )
44
+
45
+ expect(list.health_checks).to eq([direct_check])
46
+ end
47
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'confctl'
5
+ require 'tmpdir'
6
+
7
+ RSpec.describe ConfCtl::NixFlake do
8
+ it 'loads machine metadata from a built JSON file' do
9
+ Dir.mktmpdir do |dir|
10
+ json_path = File.join(dir, 'machine-list.json')
11
+ File.write(
12
+ json_path,
13
+ {
14
+ 'host.example' => {
15
+ 'key' => 'm_host_example',
16
+ '_module' => {},
17
+ 'metaConfig' => {
18
+ '_module' => {}
19
+ }
20
+ }
21
+ }.to_json
22
+ )
23
+
24
+ nix = described_class.new(conf_dir: dir, max_jobs: 'auto')
25
+ allow(nix).to receive(:nix_build_json)
26
+ .with(['.#confctl.machinesJson'])
27
+ .and_return([{ 'outputs' => { 'out' => json_path } }])
28
+ allow(nix).to receive(:nix_eval_json)
29
+
30
+ machines = nix.list_machines
31
+
32
+ expect(machines).to eq(
33
+ 'host.example' => {
34
+ 'key' => 'm_host_example',
35
+ 'metaConfig' => {}
36
+ }
37
+ )
38
+ expect(nix).not_to have_received(:nix_eval_json)
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'tmpdir'
5
+
6
+ RSpec.describe 'swpins workflows' do
7
+ include CliHelper
8
+
9
+ def create_repo_with_commit(path, content, message)
10
+ File.write(File.join(path, 'dummy.txt'), content)
11
+ git_commit_all(path, message)
12
+ run_cmd('git', 'rev-parse', 'HEAD', chdir: path).out.strip
13
+ end
14
+
15
+ def channel_rev(conf_dir)
16
+ json = JSON.parse(File.read(File.join(conf_dir, 'swpins', 'channels', 'nixos-unstable.json')))
17
+ json.fetch('nixpkgs').fetch('state').fetch('rev')
18
+ end
19
+
20
+ it 'updates and sets swpins from a local git repository with commit variants' do
21
+ Dir.mktmpdir('confctl-swpins-spec-') do |dir|
22
+ nixpkgs_path = ENV.fetch('CONFCTL_TEST_NIXPKGS', nil)
23
+ if nixpkgs_path.nil? || nixpkgs_path.empty?
24
+ lookup = run_cmd('nix-instantiate', '--find-file', 'nixpkgs', chdir: dir)
25
+ expect(lookup.success?).to be(true), "#{lookup.out}\n#{lookup.err}"
26
+ nixpkgs_path = lookup.out.strip
27
+ end
28
+
29
+ dummy_repo = File.join(dir, 'dummy-repo')
30
+ FileUtils.mkdir_p(dummy_repo)
31
+ init_git_repo(dummy_repo)
32
+ rev_a = create_repo_with_commit(dummy_repo, "A\n", 'dummy: A')
33
+
34
+ branch = run_cmd('git', 'rev-parse', '--abbrev-ref', 'HEAD', chdir: dummy_repo).out.strip
35
+
36
+ conf_dir = File.join(dir, 'conf')
37
+ FileUtils.mkdir_p(conf_dir)
38
+ expect(run_confctl('init', '--swpins', chdir: conf_dir).success?).to be(true)
39
+
40
+ File.write(File.join(conf_dir, 'configs', 'swpins.nix'), <<~NIX)
41
+ { config, ... }:
42
+ {
43
+ confctl.swpins.core.pins.nixpkgs = {
44
+ type = "directory";
45
+ directory.path = "#{nixpkgs_path}";
46
+ };
47
+
48
+ confctl.swpins.channels = {
49
+ nixos-unstable = {
50
+ nixpkgs = {
51
+ type = "git-rev";
52
+ git-rev = {
53
+ url = "file://#{dummy_repo}";
54
+ fetchSubmodules = false;
55
+ update = {
56
+ auto = false;
57
+ interval = 0;
58
+ ref = "refs/heads/#{branch}";
59
+ };
60
+ };
61
+ };
62
+ };
63
+ };
64
+ }
65
+ NIX
66
+
67
+ init_git_repo(conf_dir)
68
+ git_commit_all(conf_dir, 'fixture: initial')
69
+
70
+ res =
71
+ if ENV['CONFCTL_RSPEC_SANDBOX'] == '1'
72
+ run_confctl('swpins', 'channel', 'update', 'nixos-unstable', 'nixpkgs', chdir: conf_dir)
73
+ else
74
+ run_confctl('swpins', 'update', chdir: conf_dir)
75
+ end
76
+ expect(res.success?).to be(true), "#{res.out}\n#{res.err}"
77
+ expect(channel_rev(conf_dir)).to eq(rev_a)
78
+
79
+ rev_b = create_repo_with_commit(dummy_repo, "B\n", 'dummy: B')
80
+
81
+ res = run_confctl(
82
+ 'swpins', 'channel', 'update',
83
+ '--commit', '--no-changelog', '--no-editor',
84
+ 'nixos-unstable', 'nixpkgs',
85
+ chdir: conf_dir
86
+ )
87
+ expect(res.success?).to be(true), "#{res.out}\n#{res.err}"
88
+ expect(channel_rev(conf_dir)).to eq(rev_b)
89
+
90
+ log_count = run_cmd('git', 'rev-list', '--count', 'HEAD', chdir: conf_dir).out.strip.to_i
91
+ expect(log_count).to eq(2)
92
+
93
+ res = run_confctl(
94
+ 'swpins', 'channel', 'set',
95
+ '--commit', '--changelog', '--no-editor',
96
+ 'nixos-unstable', 'nixpkgs', rev_a,
97
+ chdir: conf_dir
98
+ )
99
+ expect(res.success?).to be(true), "#{res.out}\n#{res.err}"
100
+ expect(channel_rev(conf_dir)).to eq(rev_a)
101
+
102
+ log_count = run_cmd('git', 'rev-list', '--count', 'HEAD', chdir: conf_dir).out.strip.to_i
103
+ expect(log_count).to eq(3)
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'tmpdir'
5
+ require 'fileutils'
6
+
7
+ RSpec.describe 'generation modes' do
8
+ it 'preserves swpins and flakes generation modes and metadata' do
9
+ Dir.mktmpdir('confctl-generation-modes-') do |tmp|
10
+ Dir.chdir(tmp) do
11
+ require 'confctl'
12
+
13
+ allow(ConfCtl::GCRoot).to receive(:dir).and_return(File.join(ConfCtl::ConfDir.cache_dir, 'gcroots'))
14
+
15
+ host = 'example.test'
16
+ host_dir = File.join(ConfCtl::ConfDir.generation_dir, ConfCtl.safe_host_name(host))
17
+ FileUtils.mkdir_p(host_dir)
18
+
19
+ legacy_name = '2026-02-24--00-00-00'
20
+ legacy_dir = File.join(host_dir, legacy_name)
21
+ FileUtils.mkdir_p(legacy_dir)
22
+
23
+ legacy_data = {
24
+ 'date' => '2026-02-24T12:34:56Z',
25
+ 'toplevel' => '/nix/store/legacy-system',
26
+ 'auto_rollback' => '/nix/store/legacy-auto-rollback.rb',
27
+ 'swpins' => {
28
+ 'nixpkgs' => {
29
+ 'path' => '/nix/store/legacy-nixpkgs',
30
+ 'spec' => {
31
+ 'type' => 'git-rev',
32
+ 'name' => 'nixpkgs',
33
+ 'nix_options' => {
34
+ 'url' => 'https://example.com/nixpkgs',
35
+ 'fetchSubmodules' => false,
36
+ 'update' => {
37
+ 'auto' => false,
38
+ 'interval' => 0,
39
+ 'ref' => nil
40
+ }
41
+ },
42
+ 'state' => {
43
+ 'rev' => 'deadbeef'
44
+ }
45
+ }
46
+ }
47
+ }
48
+ }
49
+
50
+ File.write(File.join(legacy_dir, 'generation.json'), JSON.pretty_generate(legacy_data))
51
+
52
+ legacy_gen = ConfCtl::Generation::Build.new(host)
53
+ legacy_gen.load(legacy_name)
54
+ expect(legacy_gen.mode).to eq('swpins')
55
+
56
+ swpin_spec = ConfCtl::Swpins::Spec.for(:'git-rev').new(
57
+ 'nixpkgs',
58
+ {
59
+ 'url' => 'https://example.com/nixpkgs',
60
+ 'fetchSubmodules' => false,
61
+ 'update' => {
62
+ 'auto' => false,
63
+ 'interval' => 0,
64
+ 'ref' => nil
65
+ }
66
+ },
67
+ {
68
+ 'type' => 'git-rev',
69
+ 'name' => 'nixpkgs',
70
+ 'nix_options' => {
71
+ 'url' => 'https://example.com/nixpkgs',
72
+ 'fetchSubmodules' => false,
73
+ 'update' => {
74
+ 'auto' => false,
75
+ 'interval' => 0,
76
+ 'ref' => nil
77
+ }
78
+ },
79
+ 'state' => {
80
+ 'rev' => 'beadfeed'
81
+ }
82
+ }
83
+ )
84
+
85
+ swpins_gen = ConfCtl::Generation::Build.new(host)
86
+ swpins_gen.create(
87
+ '/nix/store/new-system',
88
+ '/nix/store/new-auto-rollback.rb',
89
+ { 'nixpkgs' => '/nix/store/new-nixpkgs' },
90
+ { 'nixpkgs' => swpin_spec },
91
+ date: Time.utc(2026, 2, 24, 13, 0, 0)
92
+ )
93
+ swpins_gen.save
94
+
95
+ swpins_list = ConfCtl::Generation::BuildList.new(host)
96
+ swpins_loaded = swpins_list[swpins_gen.name]
97
+ expect(swpins_loaded.mode).to eq('swpins')
98
+ expect(swpins_loaded.swpin_paths).to eq(swpins_gen.swpin_paths)
99
+
100
+ flakes_gen = ConfCtl::Generation::Build.new(host)
101
+ flakes_gen.create_flake(
102
+ '/nix/store/flake-system',
103
+ '/nix/store/flake-auto-rollback.rb',
104
+ inputs: { 'nixpkgs' => '/nix/store/flake-nixpkgs' },
105
+ inputs_info: {
106
+ 'nixpkgs' => {
107
+ 'input' => 'nixpkgs',
108
+ 'url' => 'https://github.com/NixOS/nixpkgs',
109
+ 'rev' => 'cafebabe',
110
+ 'shortRev' => 'cafebabe',
111
+ 'lastModified' => 0
112
+ }
113
+ },
114
+ date: Time.utc(2026, 2, 24, 14, 0, 0)
115
+ )
116
+ flakes_gen.save
117
+
118
+ flakes_list = ConfCtl::Generation::BuildList.new(host)
119
+ flakes_loaded = flakes_list[flakes_gen.name]
120
+ expect(flakes_loaded.mode).to eq('flakes')
121
+ expect(flakes_loaded.inputs).to eq(flakes_gen.inputs)
122
+ expect(flakes_loaded.swpin_specs).to be_empty
123
+
124
+ flakes_json = JSON.parse(File.read(File.join(flakes_loaded.dir, 'generation.json')))
125
+ expect(flakes_json).not_to have_key('swpins')
126
+
127
+ expect(swpins_list.find(swpins_loaded.toplevel, swpins_loaded.swpin_paths)).to be_truthy
128
+ expect(flakes_list.find(flakes_loaded.toplevel, flakes_loaded.inputs, mode: 'flakes')).to be_truthy
129
+ end
130
+ end
131
+ end
132
+ end