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,866 @@
1
+ require 'confctl/cli/command'
2
+ require 'confctl/conf_dir'
3
+ require 'confctl/inputs/setter'
4
+ require 'confctl/nix_legacy'
5
+ require 'confctl/system_command'
6
+ require 'fileutils'
7
+ require 'json'
8
+
9
+ module ConfCtl::Cli
10
+ class Migrate::SwpinsToFlakes < Command
11
+ INPUTS_BEGIN = '# confctl migrate swpins-to-flakes: BEGIN inputs'.freeze
12
+ INPUTS_END = '# confctl migrate swpins-to-flakes: END inputs'.freeze
13
+ CHANNELS_BEGIN = '# confctl migrate swpins-to-flakes: BEGIN channels'.freeze
14
+ CHANNELS_END = '# confctl migrate swpins-to-flakes: END channels'.freeze
15
+
16
+ def all
17
+ return unless flake
18
+ return unless machines
19
+
20
+ imports
21
+ clean
22
+
23
+ puts "\nNext steps:"
24
+ puts ' nix develop'
25
+ puts ' confctl ls'
26
+ puts ' confctl build <one-machine>'
27
+ end
28
+
29
+ def flake
30
+ step('Flake')
31
+
32
+ flake_path = File.join(conf_dir, 'flake.nix')
33
+ swpins_config = File.join(conf_dir, 'configs', 'swpins.nix')
34
+ core_path = File.join(conf_dir, 'swpins', 'core.json')
35
+
36
+ raise ConfCtl::Error, 'flake.nix already exists' if File.exist?(flake_path)
37
+ raise ConfCtl::Error, 'configs/swpins.nix missing' unless File.exist?(swpins_config)
38
+ raise ConfCtl::Error, 'swpins/core.json missing; update swpins state first' unless File.exist?(core_path)
39
+
40
+ legacy_channels = load_legacy_channels
41
+ channel_names = legacy_channels.keys.sort
42
+
43
+ channel_state = {}
44
+ channel_names.each do |channel|
45
+ path = File.join(conf_dir, 'swpins', 'channels', "#{channel}.json")
46
+ unless File.exist?(path)
47
+ raise ConfCtl::Error,
48
+ "swpins/channels/#{channel}.json missing; update swpins state first"
49
+ end
50
+
51
+ channel_state[channel] = load_json(path, "swpins/channels/#{channel}.json")
52
+ end
53
+
54
+ core_json = load_json(core_path, 'swpins/core.json')
55
+ core_entry = core_json['nixpkgs']
56
+ raise ConfCtl::Error, 'swpins/core.json missing nixpkgs entry; update swpins state first' unless core_entry
57
+
58
+ core_spec = build_pin_spec(core_entry, 'core nixpkgs')
59
+ core_url = flake_url_for(core_spec)
60
+
61
+ plan = build_channel_plan(legacy_channels, channel_state, core_spec)
62
+ input_defs = plan[:input_defs]
63
+ channels = plan[:channels]
64
+ input_revs = plan[:input_revs]
65
+
66
+ flake_content = render_flake(core_url, input_defs, channels)
67
+ @generated_flake_content = flake_content
68
+
69
+ ops = []
70
+ ops << [:create, 'flake.nix']
71
+ ops << [:run, 'nix flake lock']
72
+ ops << [:set, "root nixpkgs rev #{core_spec[:rev]}"] if core_spec[:rev]
73
+
74
+ channels.keys.sort.each do |channel|
75
+ channels[channel].keys.sort.each do |role|
76
+ input_name = channels[channel][role]
77
+ rev = input_revs[input_name]
78
+ next unless rev
79
+
80
+ ops << [:set, "channel #{channel} role #{role} rev #{rev}"]
81
+ end
82
+ end
83
+
84
+ return false unless confirm_step(ops, label: 'flake migration', required: true)
85
+ return true if dry_run?
86
+
87
+ write_atomic(flake_path, flake_content)
88
+ run_nix_flake_lock!
89
+ apply_input_revisions(input_revs)
90
+ true
91
+ end
92
+
93
+ def machines
94
+ step('Machines')
95
+
96
+ flake_path = File.join(conf_dir, 'flake.nix')
97
+
98
+ cluster_dir = File.join(conf_dir, 'cluster')
99
+ module_paths = Dir.glob(File.join(cluster_dir, '**', 'module.nix'))
100
+ if module_paths.empty?
101
+ puts 'No cluster/**/module.nix files found.'
102
+ return true
103
+ end
104
+
105
+ flake_content =
106
+ if File.exist?(flake_path)
107
+ File.read(flake_path)
108
+ elsif dry_run? && @generated_flake_content
109
+ @generated_flake_content
110
+ else
111
+ raise ConfCtl::Error, 'flake.nix missing; run flake migration first'
112
+ end
113
+
114
+ edits = {}
115
+ override_inputs = {}
116
+ override_revs = {}
117
+ set_ops = []
118
+
119
+ module_paths.each do |path|
120
+ relative = path.sub(%r{\A#{Regexp.escape(cluster_dir)}/}, '')
121
+ machine = File.dirname(relative)
122
+ next if machine == '.'
123
+
124
+ content = File.read(path)
125
+ updated = content.gsub(/\bswpins\.channels\b/, 'inputs.channels')
126
+
127
+ overrides_json = load_machine_overrides(machine)
128
+ overrides = {}
129
+ overrides_json.keys.map(&:to_s).sort.each do |role|
130
+ spec_entry = overrides_json[role]
131
+ spec = build_pin_spec(spec_entry, "machine #{machine} role #{role}")
132
+ input_name = sanitize_input_name("#{role}__#{machine}")
133
+ overrides[role.to_s] = { input: input_name, spec: spec }
134
+ end
135
+
136
+ if overrides.any?
137
+ mapping = overrides.transform_values { |v| v[:input] }
138
+ updated, status = insert_overrides(updated, mapping)
139
+
140
+ if status == :missing_anchor
141
+ msg = "Warning: could not find inputs.channels in cluster/#{machine}/module.nix; " \
142
+ 'skipping overrides for this machine'
143
+ puts Rainbow(msg).yellow
144
+ else
145
+ overrides.each do |role, data|
146
+ input_name = data[:input]
147
+ spec = data[:spec]
148
+ identity = pin_identity(spec)
149
+
150
+ if override_inputs[input_name] && pin_identity(override_inputs[input_name]) != identity
151
+ raise ConfCtl::Error, "conflicting override input '#{input_name}' for machine #{machine}"
152
+ end
153
+
154
+ override_inputs[input_name] ||= spec
155
+
156
+ rev = spec[:rev]
157
+ next unless rev
158
+
159
+ if override_revs[input_name] && override_revs[input_name] != rev
160
+ raise ConfCtl::Error, "conflicting revisions for override input '#{input_name}'"
161
+ end
162
+
163
+ override_revs[input_name] = rev
164
+ set_ops << [:set, "machine #{machine} role #{role} rev #{rev}"]
165
+ end
166
+ end
167
+ end
168
+
169
+ edits[path] = updated if updated != content
170
+ end
171
+
172
+ new_flake_content, added_inputs = add_inputs_to_flake(flake_content, override_inputs)
173
+ edits[flake_path] = new_flake_content if added_inputs.any?
174
+
175
+ ops = edits.keys.sort.map { |path| [:edit, display_path(path)] }
176
+ ops.concat(set_ops)
177
+
178
+ return true if ops.empty? && override_inputs.empty?
179
+ return false unless confirm_step(ops, label: 'machine metadata migration', required: true)
180
+ return true if dry_run?
181
+
182
+ edits.each do |path, content|
183
+ write_atomic(path, content)
184
+ end
185
+
186
+ if override_revs.any? && !File.exist?(File.join(conf_dir, 'flake.lock'))
187
+ raise ConfCtl::Error, 'flake.lock missing; run nix flake lock first'
188
+ end
189
+
190
+ apply_input_revisions(override_revs)
191
+ true
192
+ end
193
+
194
+ def imports
195
+ step('Imports')
196
+
197
+ matches = scan_legacy_imports
198
+ if matches.empty?
199
+ puts 'No legacy NIX_PATH imports detected.'
200
+ return true
201
+ end
202
+
203
+ puts Rainbow('Legacy NIX_PATH imports detected:').yellow
204
+ matches.each do |m|
205
+ puts " #{m[:path]}:#{m[:line]}: #{m[:content]}"
206
+ end
207
+
208
+ puts
209
+ puts 'Pure flake evaluation will fail unless these imports are migrated or legacy mode is enabled.'
210
+
211
+ change = legacy_confctl_change
212
+ if change.nil?
213
+ puts 'Compatibility options already set in configs/confctl.nix.'
214
+ return true
215
+ end
216
+
217
+ op(:edit, 'configs/confctl.nix')
218
+ proceed = ask_confirmation { puts 'Enable compatibility mode in configs/confctl.nix?' }
219
+ return true unless proceed
220
+ return true if dry_run?
221
+
222
+ write_confctl_change(change)
223
+ true
224
+ end
225
+
226
+ def clean
227
+ step('Clean')
228
+
229
+ flake_path = File.join(conf_dir, 'flake.nix')
230
+ unless File.exist?(flake_path) || (dry_run? && @generated_flake_content)
231
+ raise ConfCtl::Error, 'flake.nix missing; refusing to remove swpins artifacts'
232
+ end
233
+
234
+ ops = []
235
+ swpins_config = File.join(conf_dir, 'configs', 'swpins.nix')
236
+ swpins_dir = File.join(conf_dir, 'swpins')
237
+
238
+ ops << [:delete, 'configs/swpins.nix'] if File.exist?(swpins_config)
239
+ ops << [:delete, 'swpins/'] if Dir.exist?(swpins_dir)
240
+
241
+ if ops.any?
242
+ return true unless confirm_step(ops, label: 'swpins cleanup', required: false)
243
+ return true if dry_run?
244
+
245
+ FileUtils.rm_f(swpins_config)
246
+ FileUtils.rm_rf(swpins_dir)
247
+ else
248
+ puts 'No swpins artifacts to remove.'
249
+ end
250
+
251
+ shell_path = File.join(conf_dir, 'shell.nix')
252
+ return true unless File.exist?(shell_path)
253
+
254
+ op(:delete, 'shell.nix')
255
+ proceed = ask_confirmation { puts 'Remove shell.nix?' }
256
+ return true unless proceed
257
+ return true if dry_run?
258
+
259
+ FileUtils.rm_f(shell_path)
260
+ true
261
+ end
262
+
263
+ protected
264
+
265
+ def conf_dir
266
+ ConfCtl::ConfDir.path
267
+ end
268
+
269
+ def step(title)
270
+ puts Rainbow("\n== #{title} ==").bright.cyan
271
+ end
272
+
273
+ def op(kind, target)
274
+ color =
275
+ case kind
276
+ when :create then :green
277
+ when :edit then :yellow
278
+ when :delete then :red
279
+ when :run, :set then :blue
280
+ else :white
281
+ end
282
+ puts "#{Rainbow(kind.to_s.upcase.ljust(6)).public_send(color)} #{target}"
283
+ end
284
+
285
+ def dry_run?
286
+ opts[:'dry-run'] || opts[:dry_run]
287
+ end
288
+
289
+ def write_atomic(path, content)
290
+ tmp = "#{path}.new"
291
+ File.write(tmp, content)
292
+ File.rename(tmp, path)
293
+ end
294
+
295
+ def display_path(path)
296
+ prefix = "#{conf_dir}/"
297
+ path.start_with?(prefix) ? path.delete_prefix(prefix) : path
298
+ end
299
+
300
+ def confirm_step(ops, label:, required:)
301
+ if ops.empty?
302
+ puts 'No changes.'
303
+ return true
304
+ end
305
+
306
+ ops.each { |kind, target| op(kind, target) }
307
+
308
+ if ask_confirmation
309
+ true
310
+ else
311
+ action = required ? 'Aborting' : 'Skipping'
312
+ puts Rainbow("#{action} #{label}.").yellow
313
+ false
314
+ end
315
+ end
316
+
317
+ def load_legacy_channels
318
+ legacy = ConfCtl::NixLegacy.new(conf_dir: conf_dir)
319
+ channels = legacy.list_swpins_channels
320
+ unless channels.is_a?(Hash)
321
+ raise ConfCtl::Error, 'unable to evaluate swpins channels from legacy config'
322
+ end
323
+
324
+ channels.transform_values { |v| v.is_a?(Hash) ? v : {} }
325
+ end
326
+
327
+ def load_machine_overrides(machine)
328
+ safe_machine = machine.tr('/', ':')
329
+ path = File.join(conf_dir, 'swpins', 'cluster', "#{safe_machine}.json")
330
+ return {} unless File.exist?(path)
331
+
332
+ load_json(path, "swpins/cluster/#{safe_machine}.json")
333
+ end
334
+
335
+ def load_json(path, label)
336
+ JSON.parse(File.read(path))
337
+ rescue JSON::ParserError => e
338
+ raise ConfCtl::Error, "#{label} is not valid JSON: #{e.message}"
339
+ end
340
+
341
+ def build_pin_spec(entry, context)
342
+ raise ConfCtl::Error, "#{context}: missing swpin state" unless entry.is_a?(Hash)
343
+
344
+ type = entry['type']
345
+ nix_opts = entry['nix_options'] || {}
346
+
347
+ case type
348
+ when 'git', 'git-rev'
349
+ url = nix_opts['url']
350
+ raise ConfCtl::Error, "#{context}: missing nix_options.url" if url.nil? || url == ''
351
+
352
+ ref = normalize_ref(nix_opts.dig('update', 'ref'))
353
+ submodules = nix_opts['fetchSubmodules'] ? true : false
354
+ rev = entry.dig('state', 'rev')
355
+ if rev.nil? || rev == ''
356
+ raise ConfCtl::Error, "#{context}: missing state.rev; update swpins state first"
357
+ end
358
+
359
+ {
360
+ type: type,
361
+ url: url,
362
+ ref: ref,
363
+ submodules: submodules,
364
+ rev: rev
365
+ }
366
+ when 'directory'
367
+ path = nix_opts['path']
368
+ raise ConfCtl::Error, "#{context}: missing nix_options.path" if path.nil? || path == ''
369
+
370
+ {
371
+ type: type,
372
+ path: path,
373
+ rev: entry.dig('state', 'rev')
374
+ }
375
+ else
376
+ raise ConfCtl::Error, "#{context}: unsupported swpin type '#{type}'"
377
+ end
378
+ end
379
+
380
+ def build_channel_plan(legacy_channels, channel_state, core_spec)
381
+ used_names = {}
382
+ used_names['nixpkgs'] = pin_identity(core_spec)
383
+ used_names['confctl'] = :reserved
384
+
385
+ input_defs = {}
386
+ input_revs = {}
387
+ input_revs['nixpkgs'] = core_spec[:rev] if core_spec[:rev]
388
+ channels = {}
389
+
390
+ legacy_channels.keys.sort.each do |channel|
391
+ roles = legacy_channels[channel] || {}
392
+ channels[channel] = {}
393
+
394
+ roles.keys.map(&:to_s).sort.each do |role|
395
+ state = channel_state[channel][role]
396
+ unless state
397
+ raise ConfCtl::Error,
398
+ "swpins/channels/#{channel}.json missing role '#{role}'; update swpins state first"
399
+ end
400
+
401
+ spec = build_pin_spec(state, "channel #{channel} role #{role}")
402
+ identity = pin_identity(spec)
403
+
404
+ base = sanitize_input_name(role)
405
+ name = if used_names[base] && used_names[base] != identity
406
+ sanitize_input_name("#{role}__#{channel}")
407
+ else
408
+ base
409
+ end
410
+
411
+ name = ensure_unique_name(name, identity, used_names)
412
+ used_names[name] = identity
413
+
414
+ channels[channel][role] = name
415
+
416
+ unless input_defs.has_key?(name)
417
+ input_defs[name] = {
418
+ url: flake_url_for(spec),
419
+ flake: false
420
+ }
421
+ end
422
+
423
+ rev = spec[:rev]
424
+ next unless rev
425
+
426
+ if input_revs[name] && input_revs[name] != rev
427
+ raise ConfCtl::Error, "conflicting revisions for input '#{name}'"
428
+ end
429
+
430
+ input_revs[name] = rev
431
+ end
432
+ end
433
+
434
+ input_defs.delete('nixpkgs')
435
+ { input_defs: input_defs, channels: channels, input_revs: input_revs }
436
+ end
437
+
438
+ def ensure_unique_name(base, identity, used_names)
439
+ name = base
440
+ idx = 2
441
+ while used_names.has_key?(name) && used_names[name] != identity
442
+ name = "#{base}__#{idx}"
443
+ idx += 1
444
+ end
445
+ name
446
+ end
447
+
448
+ def pin_identity(spec)
449
+ [
450
+ spec[:type],
451
+ spec[:url],
452
+ spec[:path],
453
+ spec[:ref],
454
+ spec[:submodules],
455
+ spec[:rev]
456
+ ]
457
+ end
458
+
459
+ def sanitize_input_name(name)
460
+ sanitized = name.to_s.gsub(/[^A-Za-z0-9_-]/, '_')
461
+ raise ConfCtl::Error, "unable to sanitize input name from '#{name}'" if sanitized.empty?
462
+
463
+ sanitized
464
+ end
465
+
466
+ def normalize_ref(ref)
467
+ return nil if ref.nil? || ref == ''
468
+
469
+ ref.sub(%r{\Arefs/(heads|tags)/}, '')
470
+ end
471
+
472
+ def github_repo_from_url(url)
473
+ m = url.match(%r{\Ahttps://github\.com/([^/]+)/([^/]+?)(?:\.git)?/?\z})
474
+ return nil unless m
475
+
476
+ [m[1], m[2]]
477
+ end
478
+
479
+ def flake_url_for(spec)
480
+ case spec[:type]
481
+ when 'directory'
482
+ "path:#{spec[:path]}"
483
+ when 'git', 'git-rev'
484
+ url = spec[:url].to_s.sub(/\Agit\+/, '')
485
+ ref = spec[:ref]
486
+ submodules = spec[:submodules]
487
+
488
+ gh = github_repo_from_url(url)
489
+ if gh && !submodules
490
+ base = "github:#{gh[0]}/#{gh[1]}"
491
+ return ref ? "#{base}/#{ref}" : base
492
+ end
493
+
494
+ git_url = "git+#{url}"
495
+ git_url = append_query(git_url, 'ref', ref) if ref && ref != ''
496
+ git_url = append_query(git_url, 'submodules', '1') if submodules
497
+ git_url
498
+ else
499
+ raise ConfCtl::Error, "unsupported swpin type '#{spec[:type]}'"
500
+ end
501
+ end
502
+
503
+ def append_query(url, key, value)
504
+ separator = url.include?('?') ? '&' : '?'
505
+ "#{url}#{separator}#{key}=#{value}"
506
+ end
507
+
508
+ def nix_attr_key(name)
509
+ str = name.to_s
510
+ if str.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
511
+ str
512
+ else
513
+ "\"#{nix_string(str)}\""
514
+ end
515
+ end
516
+
517
+ def nix_attr_path(parts)
518
+ parts.map { |p| nix_attr_key(p) }.join('.')
519
+ end
520
+
521
+ def nix_string(str)
522
+ str.to_s.gsub('\\', '\\\\').gsub('"', '\"')
523
+ end
524
+
525
+ def render_flake(core_url, input_defs, channels)
526
+ lines = []
527
+ lines << '{'
528
+ lines << ' description = "confctl migrated swpins config";'
529
+ lines << ''
530
+ lines << ' inputs = {'
531
+ lines << ' confctl.url = "github:vpsfreecz/confctl";'
532
+ lines << " nixpkgs.url = \"#{nix_string(core_url)}\";"
533
+ lines << ''
534
+ lines << " #{INPUTS_BEGIN}"
535
+ lines.concat(render_input_defs(input_defs, ' '))
536
+ lines << " #{INPUTS_END}"
537
+ lines << ' };'
538
+ lines << ''
539
+ lines << ' outputs = inputs@{ self, confctl, ... }:'
540
+ lines << ' let'
541
+ lines << ' channels = {'
542
+ lines << " #{CHANNELS_BEGIN}"
543
+ lines.concat(render_channels(channels, ' '))
544
+ lines << " #{CHANNELS_END}"
545
+ lines << ' };'
546
+ lines << ' in'
547
+ lines << ' {'
548
+ lines << ' confctl = confctl.lib.mkConfctlOutputs {'
549
+ lines << ' confDir = ./.;'
550
+ lines << ' inherit inputs channels;'
551
+ lines << ' };'
552
+ lines << ''
553
+ lines << ' devShells.x86_64-linux.default = confctl.lib.mkConfigDevShell {'
554
+ lines << ' system = "x86_64-linux";'
555
+ lines << ' mode = "minimal";'
556
+ lines << ' };'
557
+ lines << ' };'
558
+ lines << '}'
559
+ lines << ''
560
+ lines.join("\n")
561
+ end
562
+
563
+ def render_input_defs(input_defs, indent)
564
+ lines = []
565
+ input_defs.keys.sort.each do |name|
566
+ spec = input_defs[name]
567
+ key = nix_attr_key(name)
568
+ lines << "#{indent}#{key} = {"
569
+ lines << "#{indent} url = \"#{nix_string(spec[:url])}\";"
570
+ lines << "#{indent} flake = false;" if spec[:flake] == false
571
+ lines << "#{indent}};"
572
+ lines << ''
573
+ end
574
+ lines
575
+ end
576
+
577
+ def render_channels(channels, indent)
578
+ lines = []
579
+ channels.keys.sort.each do |channel|
580
+ roles = channels[channel] || {}
581
+ lines << "#{indent}#{nix_attr_key(channel)} = {"
582
+ roles.keys.sort.each do |role|
583
+ input_name = roles[role]
584
+ lines << "#{indent} #{nix_attr_key(role)} = \"#{nix_string(input_name)}\";"
585
+ end
586
+ lines << "#{indent}};"
587
+ lines << ''
588
+ end
589
+ lines
590
+ end
591
+
592
+ def insert_overrides(content, overrides)
593
+ return [content, :no_overrides] if overrides.empty?
594
+
595
+ lines = content.lines
596
+ idx = lines.index { |line| line.match?(/\binputs\.channels\b/) && !line.lstrip.start_with?('#') }
597
+ return [content, :missing_anchor] unless idx
598
+
599
+ indent = lines[idx][/^\s*/]
600
+ new_lines = []
601
+
602
+ overrides.each do |role, input_name|
603
+ next if override_line_present?(content, role)
604
+
605
+ attr_path = nix_attr_path(%w[inputs overrides] + [role])
606
+ new_lines << "#{indent}#{attr_path} = \"#{nix_string(input_name)}\";\n"
607
+ end
608
+
609
+ return [content, :already_present] if new_lines.empty?
610
+
611
+ lines.insert(idx + 1, *new_lines)
612
+ [lines.join, :inserted]
613
+ end
614
+
615
+ def override_line_present?(content, role)
616
+ if role.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
617
+ content.match?(/\binputs\.overrides\.#{Regexp.escape(role)}\b/)
618
+ else
619
+ quoted = Regexp.escape("\"#{role}\"")
620
+ content.match?(/inputs\.overrides\.#{quoted}/)
621
+ end
622
+ end
623
+
624
+ def add_inputs_to_flake(content, inputs)
625
+ return [content, []] if inputs.empty?
626
+
627
+ lines = content.lines
628
+ begin_idx = lines.index { |line| line.include?(INPUTS_BEGIN) }
629
+ end_idx = lines.index { |line| line.include?(INPUTS_END) }
630
+
631
+ unless begin_idx && end_idx && begin_idx < end_idx
632
+ raise ConfCtl::Error, 'flake.nix missing inputs marker block'
633
+ end
634
+
635
+ marker_indent = lines[begin_idx][/^\s*/]
636
+ entry_indent = marker_indent
637
+ lines[(begin_idx + 1)...end_idx].each do |line|
638
+ stripped = line.strip
639
+ next if stripped.empty? || stripped.start_with?('#')
640
+
641
+ entry_indent = line[/^\s*/]
642
+ break
643
+ end
644
+
645
+ existing = extract_input_names(lines[(begin_idx + 1)...end_idx], entry_indent)
646
+
647
+ new_names = inputs.keys.reject { |n| existing.include?(n) }.sort
648
+ return [content, []] if new_names.empty?
649
+
650
+ new_lines = []
651
+ new_names.each do |name|
652
+ spec = inputs[name]
653
+ key = nix_attr_key(name)
654
+ new_lines << "#{entry_indent}#{key} = {"
655
+ new_lines << "#{entry_indent} url = \"#{nix_string(flake_url_for(spec))}\";"
656
+ new_lines << "#{entry_indent} flake = false;"
657
+ new_lines << "#{entry_indent}};"
658
+ new_lines << ''
659
+ end
660
+
661
+ lines.insert(end_idx, *new_lines)
662
+ [lines.join, new_names]
663
+ end
664
+
665
+ def extract_input_names(lines, entry_indent)
666
+ names = []
667
+ lines.each do |line|
668
+ stripped = line.strip
669
+ next if stripped.empty? || stripped.start_with?('#')
670
+ next unless line.start_with?(entry_indent)
671
+ next if line.start_with?("#{entry_indent} ")
672
+
673
+ match = stripped.match(/^([^=\s]+)\s*=\s*/)
674
+ match ||= stripped.match(/^([^=\s]+)\.url\s*=/)
675
+ next unless match
676
+
677
+ name = unquote(match[1])
678
+ names << name
679
+ end
680
+ names
681
+ end
682
+
683
+ def unquote(str)
684
+ return str unless str.start_with?('"') && str.end_with?('"')
685
+
686
+ str[1..-2]
687
+ end
688
+
689
+ def scan_legacy_imports
690
+ matches = []
691
+ dirs = %w[cluster configs modules environments data overlays]
692
+ dirs.each do |dir|
693
+ base = File.join(conf_dir, dir)
694
+ next unless Dir.exist?(base)
695
+
696
+ Dir.glob(File.join(base, '**', '*.nix')).each do |path|
697
+ File.readlines(path).each_with_index do |line, idx|
698
+ next unless line.match?(/<[^>\n]+>/)
699
+
700
+ matches << {
701
+ path: display_path(path),
702
+ line: idx + 1,
703
+ content: line.rstrip
704
+ }
705
+ end
706
+ end
707
+ end
708
+ matches
709
+ end
710
+
711
+ def legacy_confctl_change
712
+ path = File.join(conf_dir, 'configs', 'confctl.nix')
713
+
714
+ if File.exist?(path)
715
+ content = File.read(path)
716
+ needs_impure = !content.match?(/\bconfctl\.nix\.impureEval\s*=/)
717
+ needs_legacy = !content.match?(/\bconfctl\.nix\.legacyNixPath\s*=/)
718
+ return nil unless needs_impure || needs_legacy
719
+
720
+ {
721
+ path: path,
722
+ content: content,
723
+ needs_impure: needs_impure,
724
+ needs_legacy: needs_legacy,
725
+ create: false
726
+ }
727
+ else
728
+ {
729
+ path: path,
730
+ content: nil,
731
+ needs_impure: true,
732
+ needs_legacy: true,
733
+ create: true
734
+ }
735
+ end
736
+ end
737
+
738
+ def write_confctl_change(change)
739
+ path = change[:path]
740
+
741
+ if change[:create]
742
+ FileUtils.mkdir_p(File.dirname(path))
743
+ content = <<~NIX
744
+ { ... }:
745
+ {
746
+ # confctl migrate swpins-to-flakes: legacy NIX_PATH compatibility
747
+ confctl.nix.impureEval = true;
748
+ confctl.nix.legacyNixPath = true;
749
+ }
750
+ NIX
751
+ write_atomic(path, content)
752
+ return
753
+ end
754
+
755
+ lines = change[:content].lines
756
+ close_idx = lines.rindex { |line| line.strip == '}' }
757
+ close_idx ||= lines.rindex { |line| line.strip == '};' }
758
+ raise ConfCtl::Error, 'unable to edit configs/confctl.nix' unless close_idx
759
+
760
+ indent = lines[close_idx][/^\s*/]
761
+ inner_indent = "#{indent} "
762
+
763
+ insert_lines = []
764
+ insert_lines << "#{inner_indent}# confctl migrate swpins-to-flakes: legacy NIX_PATH compatibility\n"
765
+ insert_lines << "#{inner_indent}confctl.nix.impureEval = true;\n" if change[:needs_impure]
766
+ insert_lines << "#{inner_indent}confctl.nix.legacyNixPath = true;\n" if change[:needs_legacy]
767
+
768
+ lines.insert(close_idx, *insert_lines)
769
+ write_atomic(path, lines.join)
770
+ end
771
+
772
+ def apply_input_revisions(input_revs)
773
+ with_tracked_flake do
774
+ input_revs.keys.sort.each do |input|
775
+ rev = input_revs[input]
776
+ next if rev.nil? || rev == ''
777
+
778
+ ConfCtl::Inputs::Setter.run!(
779
+ conf_dir: conf_dir,
780
+ inputs: [input],
781
+ rev: rev,
782
+ commit: false,
783
+ changelog: false,
784
+ downgrade: false,
785
+ editor: false
786
+ )
787
+ end
788
+ end
789
+ end
790
+
791
+ def run_nix_flake_lock!
792
+ with_tracked_flake do
793
+ cmd = ConfCtl::SystemCommand.new
794
+ extra_experimental = false
795
+
796
+ loop do
797
+ args = ['nix']
798
+ if extra_experimental
799
+ args << '--extra-experimental-features' << 'nix-command'
800
+ args << '--extra-experimental-features' << 'flakes'
801
+ end
802
+ args << 'flake' << 'lock'
803
+
804
+ begin
805
+ Dir.chdir(conf_dir) { cmd.run(*args) }
806
+ break
807
+ rescue TTY::Command::ExitError => e
808
+ if !extra_experimental && experimental_error?(e.message)
809
+ extra_experimental = true
810
+ next
811
+ end
812
+
813
+ raise
814
+ end
815
+ end
816
+ end
817
+ end
818
+
819
+ def experimental_error?(message)
820
+ message.match?(/experimental/i) && message.match?(/nix-command|flakes/i)
821
+ end
822
+
823
+ def with_tracked_flake
824
+ flake_path = File.join(conf_dir, 'flake.nix')
825
+ return yield unless File.exist?(flake_path)
826
+ return yield unless git_repo?
827
+
828
+ added = false
829
+
830
+ unless git_tracked_file?('flake.nix')
831
+ git_add_intent('flake.nix')
832
+ added = true
833
+ end
834
+
835
+ yield
836
+ ensure
837
+ git_reset_file('flake.nix') if added
838
+ end
839
+
840
+ def git_repo?
841
+ cmd = ConfCtl::SystemCommand.new
842
+ Dir.chdir(conf_dir) { cmd.run('git', 'rev-parse', '--is-inside-work-tree') }
843
+ true
844
+ rescue TTY::Command::ExitError
845
+ false
846
+ end
847
+
848
+ def git_tracked_file?(path)
849
+ cmd = ConfCtl::SystemCommand.new
850
+ Dir.chdir(conf_dir) { cmd.run('git', 'ls-files', '--error-unmatch', path) }
851
+ true
852
+ rescue TTY::Command::ExitError
853
+ false
854
+ end
855
+
856
+ def git_add_intent(path)
857
+ cmd = ConfCtl::SystemCommand.new
858
+ Dir.chdir(conf_dir) { cmd.run('git', 'add', '--intent-to-add', path) }
859
+ end
860
+
861
+ def git_reset_file(path)
862
+ cmd = ConfCtl::SystemCommand.new
863
+ Dir.chdir(conf_dir) { cmd.run('git', 'reset', '-q', '--', path) }
864
+ end
865
+ end
866
+ end