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,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'confctl/flake_lock_diff'
4
+ require 'confctl/inputs/commit_message'
5
+
6
+ RSpec.describe ConfCtl::Inputs::CommitMessage do
7
+ def build_change(name:, old_rev:, new_rev:, url:)
8
+ ConfCtl::FlakeLockDiff::Change.new(
9
+ name: name,
10
+ old_info: old_rev ? { rev: old_rev, short_rev: old_rev[0, 8], url: url } : nil,
11
+ new_info: { rev: new_rev, short_rev: new_rev[0, 8], url: url }
12
+ )
13
+ end
14
+
15
+ def expect_git_log(url:, from:, to:, output:)
16
+ mirror = instance_double(ConfCtl::GitRepoMirror)
17
+
18
+ expect(ConfCtl::GitRepoMirror).to receive(:new).with(url, quiet: true).and_return(mirror)
19
+ expect(mirror).to receive(:setup)
20
+ expect(mirror).to receive(:log).with(from, to, opts: ['--oneline']).and_return(output)
21
+ end
22
+
23
+ it 'includes the new revision in title for a single input' do
24
+ changes = [
25
+ build_change(
26
+ name: 'nixpkgs',
27
+ old_rev: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
28
+ new_rev: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
29
+ url: 'https://github.com/NixOS/nixpkgs'
30
+ )
31
+ ]
32
+
33
+ title = described_class.build(changes: changes, changelog: false).lines.first.chomp
34
+
35
+ expect(title).to eq('inputs: update nixpkgs to bbbbbbbb')
36
+ end
37
+
38
+ it 'includes the new revision in title for multiple inputs from the same source' do
39
+ changes = [
40
+ build_change(
41
+ name: 'nixpkgsStable',
42
+ old_rev: '1111111111111111111111111111111111111111',
43
+ new_rev: '2222222222222222222222222222222222222222',
44
+ url: 'https://github.com/NixOS/nixpkgs'
45
+ ),
46
+ build_change(
47
+ name: 'nixpkgsUnstable',
48
+ old_rev: '3333333333333333333333333333333333333333',
49
+ new_rev: '2222222222222222222222222222222222222222',
50
+ url: 'https://github.com/NixOS/nixpkgs/'
51
+ )
52
+ ]
53
+
54
+ title = described_class.build(changes: changes, changelog: false).lines.first.chomp
55
+
56
+ expect(title).to eq('inputs: update nixpkgsStable, nixpkgsUnstable to 22222222')
57
+ end
58
+
59
+ it 'keeps no-changelog input summaries compact' do
60
+ changes = [
61
+ build_change(
62
+ name: 'nixpkgsStable',
63
+ old_rev: '1111111111111111111111111111111111111111',
64
+ new_rev: '2222222222222222222222222222222222222222',
65
+ url: 'https://github.com/NixOS/nixpkgs'
66
+ ),
67
+ build_change(
68
+ name: 'nixpkgsUnstable',
69
+ old_rev: '3333333333333333333333333333333333333333',
70
+ new_rev: '2222222222222222222222222222222222222222',
71
+ url: 'https://github.com/NixOS/nixpkgs'
72
+ )
73
+ ]
74
+
75
+ message = described_class.build(changes: changes, changelog: false)
76
+
77
+ expect(message).to eq(<<~MSG.strip)
78
+ inputs: update nixpkgsStable, nixpkgsUnstable to 22222222
79
+
80
+ nixpkgsStable: 11111111 -> 22222222
81
+ nixpkgsUnstable: 33333333 -> 22222222
82
+ MSG
83
+ end
84
+
85
+ it 'groups changelog for inputs with the same source and same revision change' do
86
+ old_rev = '1111111111111111111111111111111111111111'
87
+ new_rev = '2222222222222222222222222222222222222222'
88
+ url = 'https://github.com/vpsfreecz/vpsadminos'
89
+ log = <<~LOG.strip
90
+ git log for #{old_rev}..#{new_rev}
91
+ > 222222222 shared change
92
+ LOG
93
+
94
+ changes = [
95
+ build_change(
96
+ name: 'vpsadminosStaging',
97
+ old_rev: old_rev,
98
+ new_rev: new_rev,
99
+ url: url
100
+ ),
101
+ build_change(
102
+ name: 'vpsadminosProduction',
103
+ old_rev: old_rev,
104
+ new_rev: new_rev,
105
+ url: "#{url}/"
106
+ )
107
+ ]
108
+
109
+ expect_git_log(url: url, from: old_rev, to: new_rev, output: log)
110
+
111
+ message = described_class.build(changes: changes, changelog: true)
112
+
113
+ expect(message).to eq(<<~MSG.strip)
114
+ inputs: update vpsadminosProduction, vpsadminosStaging to 22222222
115
+
116
+ vpsadminosStaging: 11111111 -> 22222222
117
+ vpsadminosProduction: 11111111 -> 22222222
118
+ git log for #{old_rev}..#{new_rev}
119
+ > 222222222 shared change
120
+ MSG
121
+ end
122
+
123
+ it 'keeps separate changelog groups for inputs with different old revisions' do
124
+ old_staging_rev = '1111111111111111111111111111111111111111'
125
+ old_os_staging_rev = '3333333333333333333333333333333333333333'
126
+ new_rev = '2222222222222222222222222222222222222222'
127
+ url = 'https://github.com/vpsfreecz/vpsadminos'
128
+ staging_log = <<~LOG.strip
129
+ git log for #{old_staging_rev}..#{new_rev}
130
+ > 222222222 staging change
131
+ LOG
132
+ os_staging_log = <<~LOG.strip
133
+ git log for #{old_os_staging_rev}..#{new_rev}
134
+ > 222222222 os staging change
135
+ LOG
136
+
137
+ changes = [
138
+ build_change(
139
+ name: 'vpsadminosStaging',
140
+ old_rev: old_staging_rev,
141
+ new_rev: new_rev,
142
+ url: url
143
+ ),
144
+ build_change(
145
+ name: 'vpsadminosProduction',
146
+ old_rev: old_os_staging_rev,
147
+ new_rev: new_rev,
148
+ url: url
149
+ )
150
+ ]
151
+
152
+ expect_git_log(url: url, from: old_staging_rev, to: new_rev, output: staging_log)
153
+ expect_git_log(url: url, from: old_os_staging_rev, to: new_rev, output: os_staging_log)
154
+
155
+ message = described_class.build(changes: changes, changelog: true)
156
+
157
+ expect(message).to eq(<<~MSG.strip)
158
+ inputs: update vpsadminosProduction, vpsadminosStaging to 22222222
159
+
160
+ vpsadminosStaging: 11111111 -> 22222222
161
+ git log for #{old_staging_rev}..#{new_rev}
162
+ > 222222222 staging change
163
+
164
+ vpsadminosProduction: 33333333 -> 22222222
165
+ git log for #{old_os_staging_rev}..#{new_rev}
166
+ > 222222222 os staging change
167
+ MSG
168
+ end
169
+
170
+ it 'does not include the revision in title for changes from different sources' do
171
+ changes = [
172
+ build_change(
173
+ name: 'nixpkgs',
174
+ old_rev: '4444444444444444444444444444444444444444',
175
+ new_rev: '5555555555555555555555555555555555555555',
176
+ url: 'https://github.com/NixOS/nixpkgs'
177
+ ),
178
+ build_change(
179
+ name: 'vpsadminos',
180
+ old_rev: '6666666666666666666666666666666666666666',
181
+ new_rev: '5555555555555555555555555555555555555555',
182
+ url: 'https://github.com/vpsfreecz/vpsadminos'
183
+ )
184
+ ]
185
+
186
+ title = described_class.build(changes: changes, changelog: false).lines.first.chomp
187
+
188
+ expect(title).to eq('inputs: update nixpkgs, vpsadminos')
189
+ end
190
+
191
+ it 'includes revision in title for set action' do
192
+ changes = [
193
+ build_change(
194
+ name: 'vpsadminos',
195
+ old_rev: nil,
196
+ new_rev: '7777777777777777777777777777777777777777',
197
+ url: 'https://github.com/vpsfreecz/vpsadminos'
198
+ )
199
+ ]
200
+
201
+ title = described_class.build(changes: changes, changelog: false, action: :set).lines.first.chomp
202
+
203
+ expect(title).to eq('inputs: set vpsadminos to 77777777')
204
+ end
205
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'confctl/exceptions'
5
+ require 'confctl/inputs/setter'
6
+ require 'confctl/inputs/updater'
7
+ require 'tmpdir'
8
+ require 'tty-command'
9
+
10
+ RSpec.describe 'Nix cached fallback handling' do
11
+ def cached_fallback_result
12
+ stderr = <<~ERR
13
+ warning: error: unable to download 'https://api.github.com/repos/vpsfreecz/vpsadminos/commits/staging': HTTP error 403
14
+ {"message":"API rate limit exceeded"}; using cached version
15
+ ERR
16
+
17
+ TTY::Command::Result.new(0, '', stderr)
18
+ end
19
+
20
+ let(:cmd) { instance_double(TTY::Command) }
21
+
22
+ before do
23
+ allow(ConfCtl::SystemCommand).to receive(:new).and_return(cmd)
24
+ allow(cmd).to receive(:run).and_return(cached_fallback_result)
25
+ end
26
+
27
+ it 'fails flake updates that used cached GitHub metadata' do
28
+ Dir.mktmpdir do |dir|
29
+ expect do
30
+ ConfCtl::Inputs::Updater.send(:run_nix_flake_update!, dir, ['vpsadminos'])
31
+ end.to raise_error(ConfCtl::Error, /GitHub API rate limit was exceeded/)
32
+ end
33
+ end
34
+
35
+ it 'fails flake locks that used cached GitHub metadata' do
36
+ Dir.mktmpdir do |dir|
37
+ tmpfile = File.join(dir, 'flake.lock.tmp')
38
+
39
+ expect do
40
+ ConfCtl::Inputs::Setter.send(
41
+ :run_nix_flake_lock!,
42
+ dir,
43
+ 'vpsadminos',
44
+ 'github:vpsfreecz/vpsadminos/staging',
45
+ tmpfile
46
+ )
47
+ end.to raise_error(ConfCtl::Error, /GitHub API rate limit was exceeded/)
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'confctl/exceptions'
5
+ require 'confctl/inputs/nix_output_guard'
6
+ require 'tty-command'
7
+
8
+ RSpec.describe ConfCtl::Inputs::NixOutputGuard do
9
+ def command_result(stderr)
10
+ TTY::Command::Result.new(0, '', stderr)
11
+ end
12
+
13
+ it 'raises when Nix falls back to cached GitHub metadata after rate limiting' do
14
+ stderr = <<~ERR
15
+ warning: error: unable to download 'https://api.github.com/repos/vpsfreecz/vpsadminos/commits/staging': HTTP error 403
16
+
17
+ response body:
18
+
19
+ {"message":"API rate limit exceeded for 192.0.2.1."}; using cached version
20
+ ERR
21
+
22
+ expect do
23
+ described_class.check!(command_result(stderr))
24
+ end.to raise_error(ConfCtl::Error, /GitHub API rate limit was exceeded/)
25
+ end
26
+
27
+ it 'raises when Nix falls back to cached GitHub metadata for another API error' do
28
+ stderr = <<~ERR
29
+ warning: error: unable to download 'https://api.github.com/repos/example/project/commits/main': HTTP error 503
30
+ ; using cached version
31
+ ERR
32
+
33
+ expect do
34
+ described_class.check!(command_result(stderr))
35
+ end.to raise_error(ConfCtl::Error, /used a cached version/)
36
+ end
37
+
38
+ it 'does not raise for unrelated Nix warnings' do
39
+ stderr = "warning: '--update-input' is a deprecated alias for 'flake update'\n"
40
+
41
+ expect do
42
+ described_class.check!(command_result(stderr))
43
+ end.not_to raise_error
44
+ end
45
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ $:.unshift(File.expand_path('../lib', __dir__))
4
+
5
+ RSpec.configure do |config|
6
+ config.disable_monkey_patching!
7
+ config.expect_with :rspec do |expectations|
8
+ expectations.syntax = :expect
9
+ end
10
+ end
11
+
12
+ Dir[File.join(__dir__, 'support', '*.rb')].each { |f| require f }
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'open3'
5
+
6
+ module CliHelper
7
+ Result = Struct.new(:out, :err, :status) do
8
+ def success?
9
+ status.success?
10
+ end
11
+ end
12
+
13
+ def repo_root
14
+ File.expand_path('../..', __dir__)
15
+ end
16
+
17
+ def confctl_bin
18
+ ENV.fetch('CONFCTL_BIN', File.join(repo_root, 'bin', 'confctl'))
19
+ end
20
+
21
+ def run_confctl(*, chdir:, env: {})
22
+ default_env = {
23
+ 'CONFCTL_TTY' => '0',
24
+ 'NO_COLOR' => '1',
25
+ 'PAGER' => ''
26
+ }
27
+
28
+ out, err, status = Open3.capture3(default_env.merge(env), confctl_bin, *, chdir:)
29
+ Result.new(out, err, status)
30
+ end
31
+
32
+ def run_cmd(*, chdir:, env: {})
33
+ out, err, status = Open3.capture3(env, *, chdir:)
34
+ Result.new(out, err, status)
35
+ end
36
+
37
+ def init_git_repo(path)
38
+ run_cmd('git', 'init', chdir: path).tap do |r|
39
+ raise "git init failed: #{r.err}" unless r.success?
40
+ end
41
+ run_cmd('git', 'config', 'user.email', 'confctl-tests@example.invalid', chdir: path).tap do |r|
42
+ raise "git config user.email failed: #{r.err}" unless r.success?
43
+ end
44
+ run_cmd('git', 'config', 'user.name', 'confctl-tests', chdir: path).tap do |r|
45
+ raise "git config user.name failed: #{r.err}" unless r.success?
46
+ end
47
+ end
48
+
49
+ def git_commit_all(path, message)
50
+ run_cmd('git', 'add', '.', chdir: path).tap do |r|
51
+ raise "git add failed: #{r.err}" unless r.success?
52
+ end
53
+ run_cmd('git', 'commit', '-m', message, chdir: path).tap do |r|
54
+ raise "git commit failed: #{r.err}" unless r.success?
55
+ end
56
+ end
57
+ end
data/test-runner.sh ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ ROOT="$(cd -- "$(dirname "$0")" && pwd)"
5
+ cd "$ROOT"
6
+
7
+ exec nix run .#test-runner -- "$@"
@@ -0,0 +1,30 @@
1
+ {
2
+ pkgs ? <nixpkgs>,
3
+ system ? builtins.currentSystem,
4
+ suiteArgs ? { },
5
+ testConfig ? { },
6
+ configuration ? null,
7
+ testFramework,
8
+ }:
9
+ let
10
+ nixpkgs = import pkgs { inherit system; };
11
+ lib = nixpkgs.lib;
12
+ testLib = testFramework.makeTestLib {
13
+ inherit
14
+ pkgs
15
+ system
16
+ lib
17
+ suiteArgs
18
+ testConfig
19
+ configuration
20
+ ;
21
+ suitePath = ./suite;
22
+ };
23
+ in
24
+ testLib.makeTests [
25
+ "carrier/deploy"
26
+ "carrier/netboot"
27
+ "deploy/flakes"
28
+ "deploy/swpins"
29
+ "auto_rollback"
30
+ ]
@@ -0,0 +1,15 @@
1
+ testFn:
2
+ { testFramework, ... }@args:
3
+ let
4
+ upstream = testFramework.makeTest testFn;
5
+ mergedExtraArgs = {
6
+ vpsadminos = testFramework.sourcePath;
7
+ confctlSrc = args.confctlSrc or null;
8
+ confctlPackage = args.confctlPackage or null;
9
+ }
10
+ // (args.extraArgs or { });
11
+ argsWithExtra = args // {
12
+ extraArgs = mergedExtraArgs;
13
+ };
14
+ in
15
+ upstream argsWithExtra
@@ -0,0 +1,238 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+ require 'open3'
6
+
7
+ module ConfctlTestHelpers
8
+ STORE_PATH_RE = %r{/nix/store/[0-9a-z]{32}-[^\s'"]+}
9
+
10
+ def confctl_setup!(bin:, src:, conf_dir:, home_dir:, nix_path: nil)
11
+ @confctl_bin = bin
12
+ @confctl_src = src
13
+ @conf_dir = conf_dir
14
+ @home_dir = home_dir
15
+ @nix_path = nix_path
16
+ end
17
+
18
+ def confctl_bin
19
+ @confctl_bin || raise('confctl_setup! must be called before confctl helpers are used')
20
+ end
21
+
22
+ def confctl_src
23
+ @confctl_src || raise('confctl_setup! must be called before confctl helpers are used')
24
+ end
25
+
26
+ def conf_dir
27
+ @conf_dir || raise('confctl_setup! must be called before confctl helpers are used')
28
+ end
29
+
30
+ def home_dir
31
+ @home_dir || raise('confctl_setup! must be called before confctl helpers are used')
32
+ end
33
+
34
+ def nix_path
35
+ @nix_path
36
+ end
37
+
38
+ def run_local(argv, chdir: nil, env: {})
39
+ kwargs = {}
40
+ kwargs[:chdir] = chdir if chdir
41
+ out, err, status = Open3.capture3(env, *argv, **kwargs)
42
+ [out, err, status]
43
+ end
44
+
45
+ def run_local!(argv, chdir: nil, env: {})
46
+ out, err, status = run_local(argv, chdir: chdir, env: env)
47
+
48
+ return [out, err] if status.success?
49
+
50
+ msg = []
51
+ msg << "Command failed: #{argv.join(' ')}"
52
+ msg << "Exit status: #{status.exitstatus}"
53
+ msg << "STDOUT:\n#{out}" unless out.empty?
54
+ msg << "STDERR:\n#{err}" unless err.empty?
55
+ raise msg.join("\n\n")
56
+ end
57
+
58
+ def prepare_fixture_dir!(path, template: 'example-flake')
59
+ FileUtils.rm_rf(path)
60
+ FileUtils.mkdir_p(path)
61
+
62
+ run_local!(['cp', '-r', "#{File.join(confctl_src, template)}/.", path])
63
+ run_local!(['chmod', '-R', 'u+w', path])
64
+
65
+ # Test fixtures regenerate local state such as swpins pins and channels.
66
+ # Drop any copied state from the repository template to keep runs deterministic.
67
+ %w[.bin .confctl .gems .man swpins].each do |dir|
68
+ FileUtils.rm_rf(File.join(path, dir))
69
+ end
70
+ end
71
+
72
+ def nix_escape_string(str)
73
+ str.gsub('\\', '\\\\').gsub('"', '\\"')
74
+ end
75
+
76
+ def write_admin_ssh_keys!(path, pubkey, admin_name: 'someone')
77
+ escaped_key = nix_escape_string(pubkey)
78
+
79
+ File.write(File.join(path, 'data/ssh-keys.nix'), <<~NIX)
80
+ rec {
81
+ admins = [ #{admin_name} ];
82
+
83
+ #{admin_name} = "#{escaped_key}";
84
+ }
85
+ NIX
86
+ end
87
+
88
+ def write_cluster_modules!(path, entries)
89
+ modules = entries.map { |v| " ./#{v}" }.join("\n")
90
+
91
+ File.write(File.join(path, 'cluster/cluster.nix'), <<~NIX)
92
+ # This file is generated by confctl, changes will be lost
93
+ [
94
+ #{modules}
95
+ ]
96
+ NIX
97
+ end
98
+
99
+ def init_fixture_repo!(path, message: 'fixture: initial')
100
+ run_local!(%w[git init], chdir: path)
101
+ run_local!(%w[git config user.email confctl-tests@example.invalid], chdir: path)
102
+ run_local!(%w[git config user.name confctl-tests], chdir: path)
103
+ run_local!(%w[git add .], chdir: path)
104
+ run_local!(['git', 'commit', '-m', message], chdir: path)
105
+ end
106
+
107
+ def setup_ssh_home!(path)
108
+ FileUtils.rm_rf(path)
109
+ ssh_dir = File.join(path, '.ssh')
110
+ key_path = File.join(ssh_dir, 'id_ed25519')
111
+ FileUtils.mkdir_p(ssh_dir)
112
+
113
+ run_local!(['ssh-keygen', '-q', '-t', 'ed25519', '-N', '', '-f', key_path])
114
+
115
+ File.write(File.join(ssh_dir, 'config'), <<~CFG)
116
+ Host *
117
+ StrictHostKeyChecking no
118
+ UserKnownHostsFile /dev/null
119
+ IdentityFile #{key_path}
120
+ IdentitiesOnly yes
121
+ ConnectTimeout 10
122
+ ServerAliveInterval 5
123
+ ServerAliveCountMax 6
124
+ LogLevel ERROR
125
+ CFG
126
+
127
+ File.chmod(0o700, ssh_dir)
128
+ File.chmod(0o600, key_path)
129
+ File.chmod(0o600, "#{key_path}.pub")
130
+ File.chmod(0o600, File.join(ssh_dir, 'config'))
131
+
132
+ File.read("#{key_path}.pub").strip
133
+ end
134
+
135
+ def install_pubkey!(machine, pubkey, timeout: 300)
136
+ wait_for_block(name: "install SSH key on #{machine.name}", timeout:) do
137
+ machine.succeeds('mkdir -p /root/.ssh && chmod 700 /root/.ssh')
138
+ machine.succeeds("cat > /root/.ssh/authorized_keys <<'EOF'\n#{pubkey}\nEOF\nchmod 600 /root/.ssh/authorized_keys")
139
+ true
140
+ rescue EOFError, OsVm::MachineShellClosed, OsVm::CommandFailed
141
+ false
142
+ end
143
+ end
144
+
145
+ def confctl_env
146
+ ssh_config = File.join(home_dir, '.ssh', 'config')
147
+ ssh_key = File.join(home_dir, '.ssh', 'id_ed25519')
148
+ ssh_opts = [
149
+ "-F #{ssh_config}",
150
+ "-i #{ssh_key}",
151
+ '-o IdentitiesOnly=yes',
152
+ '-o StrictHostKeyChecking=no',
153
+ '-o UserKnownHostsFile=/dev/null',
154
+ '-o LogLevel=ERROR',
155
+ '-o ConnectTimeout=10'
156
+ ].join(' ')
157
+
158
+ {
159
+ 'HOME' => home_dir,
160
+ 'CONFCTL_TTY' => '0',
161
+ 'CONFCTL_SSH_CONFIG' => ssh_config,
162
+ 'NIX_SSHOPTS' => ssh_opts,
163
+ 'NIX_PATH' => [nix_path, ENV.fetch('NIX_PATH', nil)].compact.join(':'),
164
+ 'NIX_CONFIG' => [ENV.fetch('NIX_CONFIG', nil), 'sandbox = false'].compact.join("\n"),
165
+ 'NO_COLOR' => '1',
166
+ 'PAGER' => ''
167
+ }
168
+ end
169
+
170
+ def confctl(*args)
171
+ run_local([confctl_bin, *args], chdir: conf_dir, env: confctl_env)
172
+ end
173
+
174
+ def confctl!(*args)
175
+ run_local!([confctl_bin, *args], chdir: conf_dir, env: confctl_env)
176
+ end
177
+
178
+ def confctl_ssh(machine, *remote_argv)
179
+ confctl('ssh', '--yes', machine, *remote_argv.map(&:to_s))
180
+ end
181
+
182
+ def confctl_ssh!(machine, *remote_argv)
183
+ confctl!('ssh', '--yes', machine, *remote_argv.map(&:to_s))
184
+ end
185
+
186
+ def extract_store_path(text)
187
+ text.to_s.scan(STORE_PATH_RE).last
188
+ end
189
+
190
+ def confctl_remote_realpath(machine, path)
191
+ out, = confctl_ssh!(machine, 'realpath', path)
192
+ store_path = extract_store_path(out)
193
+ return store_path unless store_path.nil?
194
+
195
+ raise "unable to parse store path from confctl ssh output for #{machine}:#{path}: #{out.inspect}"
196
+ end
197
+
198
+ def confctl_store_path_exists!(machine, store_path)
199
+ confctl_ssh!(machine, 'test', '-e', store_path)
200
+ end
201
+
202
+ def confctl_machine_state(machine)
203
+ {
204
+ profile: confctl_remote_realpath(machine, '/nix/var/nix/profiles/system'),
205
+ current: confctl_remote_realpath(machine, '/run/current-system')
206
+ }
207
+ end
208
+
209
+ def wait_for_confctl_connectivity!(expected_successes:, timeout: 120)
210
+ deadline = Time.now + timeout
211
+
212
+ loop do
213
+ out, _err, status = confctl('test-connection', '--yes')
214
+ return out if status.success? && out.include?("#{expected_successes} successful")
215
+
216
+ raise "confctl test-connection did not report #{expected_successes} successful hosts in #{timeout}s" if Time.now >= deadline
217
+
218
+ sleep(2)
219
+ end
220
+ end
221
+
222
+ def confctl_generation_info(host, selector = 'current')
223
+ host_dir = File.join(conf_dir, '.confctl', 'generations', host.tr('/', ':'))
224
+
225
+ generation_name =
226
+ if selector == 'current'
227
+ current_link = File.join(host_dir, 'current')
228
+ File.basename(File.readlink(current_link))
229
+ else
230
+ selector
231
+ end
232
+
233
+ json_path = File.join(host_dir, generation_name, 'generation.json')
234
+ JSON.parse(File.read(json_path)).merge('name' => generation_name)
235
+ end
236
+ end
237
+
238
+ TestRunner::TestEvaluator.include(ConfctlTestHelpers)