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,95 @@
1
+ module ConfCtl
2
+ class NixBuildFlake
3
+ # @param args [Array<String>]
4
+ # @param chdir [String]
5
+ def initialize(args, chdir:)
6
+ @args = args
7
+ @chdir = chdir
8
+ @build_progress = 0
9
+ @build_total = 0
10
+ @fetch_progress = 0
11
+ @fetch_total = 0
12
+ end
13
+
14
+ # @yieldparam type [:build, :fetch]
15
+ # @yieldparam progress [Integer]
16
+ # @yieldparam total [Integer]
17
+ # @yieldparam path [String]
18
+ def run(&block)
19
+ cmd = SystemCommand.new
20
+
21
+ line_buf = StdLineBuffer.new do |_out, err|
22
+ parse_line(err, &block) if err && block
23
+ end
24
+
25
+ ret = cmd.run('nix', 'build', *args, chdir: chdir, &line_buf.feed_block)
26
+
27
+ line_buf.flush
28
+ ret
29
+ end
30
+
31
+ protected
32
+
33
+ attr_reader :args, :chdir
34
+
35
+ def parse_line(line)
36
+ # Beware that nix can fetch/build stuff even before those two
37
+ # summary lines are printed. Therefore we report progress with total=0
38
+ # (indeterminate) until the total becomes known.
39
+
40
+ # Nix >= around 2.11
41
+ case line
42
+ when /^this derivation will be built:/
43
+ @build_total = 1
44
+ return
45
+ when /^these (\d+) derivations will be built:/
46
+ @build_total = ::Regexp.last_match(1).to_i
47
+ return
48
+ when /^this path will be fetched /
49
+ @fetch_total = 1
50
+ return
51
+ when /^these (\d+) paths will be fetched /
52
+ @fetch_total = ::Regexp.last_match(1).to_i
53
+ return
54
+ end
55
+
56
+ # Nix < around 2.11, we can drop this in the future
57
+ if /^these derivations will be built:/ =~ line
58
+ @in_derivation_list = true
59
+ @in_fetch_list = false
60
+ return
61
+ elsif /^these paths will be fetched / =~ line
62
+ @in_derivation_list = false
63
+ @in_fetch_list = true
64
+ return
65
+ end
66
+
67
+ if @in_derivation_list
68
+ if %r{^\s+/nix/store/} =~ line
69
+ @build_total += 1
70
+ return
71
+ else
72
+ @in_derivation_list = false
73
+ @build_total += @build_progress
74
+ end
75
+
76
+ elsif @in_fetch_list
77
+ if %r{^\s+/nix/store/} =~ line
78
+ @fetch_total += 1
79
+ return
80
+ else
81
+ @in_fetch_list = false
82
+ @fetch_total += @fetch_progress
83
+ end
84
+ end
85
+
86
+ if /^building '([^']+)/ =~ line
87
+ @build_progress += 1
88
+ yield(:build, @build_progress, @build_total, ::Regexp.last_match(1))
89
+ elsif /^copying path '([^']+)/ =~ line
90
+ @fetch_progress += 1
91
+ yield(:fetch, @fetch_progress, @fetch_total, ::Regexp.last_match(1))
92
+ end
93
+ end
94
+ end
95
+ end
@@ -1,10 +1,12 @@
1
1
  module ConfCtl
2
2
  class NixCopy
3
3
  # @param target [String]
4
+ # @param port [Integer]
4
5
  # @param store_paths [Array<String>]
5
- def initialize(target, store_paths)
6
+ def initialize(target, store_paths, port: 22)
6
7
  @target = target
7
8
  @store_paths = store_paths
9
+ @port = port.to_i
8
10
  @total = nil
9
11
  @progress = 0
10
12
  end
@@ -23,6 +25,7 @@ module ConfCtl
23
25
  'nix-copy-closure',
24
26
  '--to', "root@#{target}",
25
27
  *store_paths,
28
+ env: nix_ssh_env,
26
29
  &line_buf.feed_block
27
30
  )
28
31
 
@@ -32,7 +35,16 @@ module ConfCtl
32
35
 
33
36
  protected
34
37
 
35
- attr_reader :target, :store_paths
38
+ attr_reader :target, :store_paths, :port
39
+
40
+ def nix_ssh_env
41
+ return {} if port == 22
42
+
43
+ ssh_opts = [ENV.fetch('NIX_SSHOPTS', nil), "-p #{port}"].compact.join(' ').strip
44
+ {
45
+ 'NIX_SSHOPTS' => ssh_opts
46
+ }
47
+ end
36
48
 
37
49
  def parse_line(line)
38
50
  if @total.nil? && /^copying (\d+) paths/ =~ line
@@ -0,0 +1,449 @@
1
+ require 'json'
2
+ require_relative 'nix_build_flake'
3
+ require_relative 'nix/args'
4
+
5
+ module ConfCtl
6
+ class NixFlake < NixLegacy
7
+ def confctl_settings
8
+ @confctl_settings ||= begin
9
+ settings = nix_eval_json('.#confctl.settings', impure: false, settings: {})
10
+ demodulify(settings)
11
+ end
12
+ end
13
+
14
+ # Returns an array with options from all confctl modules
15
+ # @return [Array]
16
+ def module_options
17
+ []
18
+ end
19
+
20
+ # Returns an array with machine fqdns
21
+ # @return [Array<String>]
22
+ def list_machine_fqdns
23
+ nix_eval_json('.#confctl.machineNames')
24
+ end
25
+
26
+ # Return machines and their config in a hash
27
+ # @return [Hash]
28
+ def list_machines
29
+ machines_json = nix_build_output_path('.#confctl.machinesJson')
30
+ machines = JSON.parse(File.read(machines_json))
31
+ machines = demodulify(machines)
32
+ refresh_machine_key_maps(machines)
33
+ machines
34
+ end
35
+
36
+ # Evaluate flake installable and parse JSON
37
+ # @param installable [String]
38
+ # @return [Object]
39
+ def eval_json(installable)
40
+ nix_eval_json(installable)
41
+ end
42
+
43
+ # Return list of swpins channels
44
+ # @return [Hash]
45
+ def list_swpins_channels
46
+ confctl_settings.fetch('swpins', {}).fetch('channels', {})
47
+ end
48
+
49
+ # Evaluate core swpins for host
50
+ # @return [Hash]
51
+ def eval_core_swpins
52
+ {}
53
+ end
54
+
55
+ # Evaluate swpins for hosts
56
+ # @param hosts [Array<String>]
57
+ # @return [Hash] host => swpins
58
+ def eval_host_swpins(hosts)
59
+ plan = build_plan
60
+
61
+ hosts.to_h do |host|
62
+ host_plan = plan.fetch(host)
63
+ [host, host_plan['inputs'] || {}]
64
+ end
65
+ end
66
+
67
+ # Evaluate inputs info for host
68
+ # @param host [String]
69
+ # @return [Hash]
70
+ def eval_inputs_info(host)
71
+ nix_eval_json(inputs_info_installable(host))
72
+ end
73
+
74
+ # Evaluate inputs for host
75
+ # @param host [String]
76
+ # @return [Hash]
77
+ def eval_inputs(host)
78
+ nix_eval_json(inputs_installable(host))
79
+ end
80
+
81
+ # Build config.system.build.toplevel for selected hosts
82
+ #
83
+ # @param hosts [Array<String>]
84
+ # @param swpin_paths [Hash]
85
+ # @param host_swpin_specs [Hash]
86
+ # @param time [Time]
87
+ #
88
+ # @yieldparam type [:build, :fetch]
89
+ # @yieldparam progress [Integer]
90
+ # @yieldparam total [Integer]
91
+ # @yieldparam path [String]
92
+ #
93
+ # @return [Hash<String, Generation::Build>]
94
+ def build_attributes(hosts: [], swpin_paths: {}, host_swpin_specs: {}, time: nil, &)
95
+ plan = build_plan
96
+ time ||= Time.now
97
+ ret_generations = {}
98
+ host_plans = {}
99
+ installables = []
100
+ machine_keys = []
101
+
102
+ hosts.each do |host|
103
+ host_plan = plan.fetch(host)
104
+ machine_key = host_plan['key'] || host_plan['machineKey'] || host_plan['flakeKey'] || machine_key_for(host)
105
+ host_plans[host] = host_plan
106
+ installables << ".#confctl.build.#{machine_key}.toplevel"
107
+ installables << ".#confctl.build.#{machine_key}.autoRollback"
108
+ machine_keys << machine_key
109
+ end
110
+
111
+ legacy_args = legacy_nix_path_args(hosts)
112
+ nix_build_json(installables, legacy_nix_path_args: legacy_args, &)
113
+ build_outputs = build_outputs_for_keys(machine_keys)
114
+ pending_generations = {}
115
+
116
+ hosts.each do |host|
117
+ host_plan = host_plans[host]
118
+ machine_key = host_plan['key'] || host_plan['machineKey'] || host_plan['flakeKey'] || machine_key_for(host)
119
+ host_input_paths = host_plan['inputs'] || {}
120
+
121
+ outputs = build_outputs[machine_key]
122
+ if outputs.nil?
123
+ raise ConfCtl::Error, "missing build outputs for #{machine_key.inspect}"
124
+ end
125
+
126
+ toplevel_path = outputs['toplevel']
127
+ auto_rollback_path = outputs['autoRollback']
128
+
129
+ if toplevel_path.nil? || auto_rollback_path.nil?
130
+ raise ConfCtl::Error, "invalid build outputs for #{machine_key.inspect}"
131
+ end
132
+
133
+ host_generations = Generation::BuildList.new(host)
134
+ generation = host_generations.find(toplevel_path, host_input_paths, mode: 'flakes')
135
+
136
+ if generation.nil?
137
+ pending_generations[host] = {
138
+ host_generations: host_generations,
139
+ machine_key: machine_key,
140
+ toplevel_path: toplevel_path,
141
+ auto_rollback_path: auto_rollback_path,
142
+ host_input_paths: host_input_paths
143
+ }
144
+ next
145
+ end
146
+
147
+ host_generations.current = generation
148
+ ret_generations[host] = generation
149
+ end
150
+
151
+ if pending_generations.any?
152
+ inputs_infos = inputs_info_for_keys(pending_generations.values.map { |v| v[:machine_key] })
153
+
154
+ pending_generations.each do |host, data|
155
+ inputs_info = inputs_infos[data[:machine_key]] || eval_inputs_info(host)
156
+ generation = Generation::Build.new(host)
157
+ generation.create_flake(
158
+ data[:toplevel_path],
159
+ data[:auto_rollback_path],
160
+ inputs: data[:host_input_paths],
161
+ inputs_info: inputs_info,
162
+ date: time
163
+ )
164
+ generation.save
165
+
166
+ data[:host_generations].current = generation
167
+ ret_generations[host] = generation
168
+ end
169
+ end
170
+
171
+ ret_generations
172
+ end
173
+
174
+ protected
175
+
176
+ def build_plan
177
+ @build_plan ||= nix_eval_json('.#confctl.buildPlan')
178
+ end
179
+
180
+ def inputs_info_installable(host)
181
+ ".#confctl.inputsInfo.#{machine_key_for(host)}"
182
+ end
183
+
184
+ def inputs_installable(host)
185
+ ".#confctl.inputs.#{machine_key_for(host)}"
186
+ end
187
+
188
+ def nix_eval_json(installable, impure: nil, settings: nil, apply: nil)
189
+ settings_for_args = settings || confctl_settings
190
+
191
+ result = run_nix_with_fallback do |extra_experimental, no_update_lock_file|
192
+ args_builder = nix_args(
193
+ settings: settings_for_args,
194
+ impure: impure,
195
+ no_update_lock_file: no_update_lock_file
196
+ )
197
+
198
+ nix_eval_args(
199
+ installable,
200
+ args_builder: args_builder,
201
+ extra_experimental: extra_experimental,
202
+ apply: apply
203
+ )
204
+ end
205
+
206
+ out, = result.stdout
207
+ JSON.parse(out)
208
+ end
209
+
210
+ def nix_build_json(installables, legacy_nix_path_args: [], &block)
211
+ extra_experimental = false
212
+ no_update_lock_file = true
213
+
214
+ loop do
215
+ args_builder = nix_args(
216
+ settings: confctl_settings,
217
+ no_update_lock_file: no_update_lock_file
218
+ )
219
+
220
+ args = nix_build_args(
221
+ installables,
222
+ args_builder: args_builder,
223
+ extra_experimental: extra_experimental,
224
+ legacy_nix_path_args: legacy_nix_path_args
225
+ )
226
+
227
+ nb = NixBuildFlake.new(args, chdir: conf_dir)
228
+ result = nb.run(&block)
229
+ out, = result.stdout
230
+ return JSON.parse(out)
231
+ rescue TTY::Command::ExitError => e
232
+ if no_update_lock_file && no_update_lock_file_error?(e.message)
233
+ no_update_lock_file = false
234
+ retry
235
+ elsif !extra_experimental && experimental_error?(e.message)
236
+ extra_experimental = true
237
+ retry
238
+ else
239
+ raise
240
+ end
241
+ end
242
+ end
243
+
244
+ def nix_build_output_path(installable)
245
+ outputs = nix_build_json([installable])
246
+ output_path = outputs.dig(0, 'outputs', 'out')
247
+
248
+ unless output_path
249
+ raise ConfCtl::Error, "missing output path for #{installable.inspect}"
250
+ end
251
+
252
+ output_path
253
+ end
254
+
255
+ def run_nix_with_fallback
256
+ extra_experimental = false
257
+ no_update_lock_file = true
258
+
259
+ loop do
260
+ args = yield(extra_experimental, no_update_lock_file)
261
+
262
+ begin
263
+ return cmd.run(*args, chdir: conf_dir)
264
+ rescue TTY::Command::ExitError => e
265
+ if no_update_lock_file && no_update_lock_file_error?(e.message)
266
+ no_update_lock_file = false
267
+ next
268
+ end
269
+
270
+ if !extra_experimental && experimental_error?(e.message)
271
+ extra_experimental = true
272
+ next
273
+ end
274
+
275
+ raise
276
+ end
277
+ end
278
+ end
279
+
280
+ def nix_args(settings:, impure: nil, no_update_lock_file: true)
281
+ ConfCtl::Nix::Args.new(
282
+ settings: settings,
283
+ impure: impure,
284
+ no_update_lock_file: no_update_lock_file
285
+ )
286
+ end
287
+
288
+ def nix_eval_args(installable, args_builder:, extra_experimental:, apply: nil)
289
+ args = ['nix', 'eval', '--json']
290
+ args.concat(
291
+ nix_common_args(
292
+ args_builder.eval_args,
293
+ extra_experimental: extra_experimental
294
+ )
295
+ )
296
+ if apply
297
+ args << '--apply' << apply
298
+ end
299
+ args << installable
300
+ args
301
+ end
302
+
303
+ def nix_build_args(installables, args_builder:, extra_experimental:, legacy_nix_path_args: [])
304
+ args = ['--json', '--no-link']
305
+ args.concat(
306
+ nix_common_args(
307
+ args_builder.build_args,
308
+ extra_experimental: extra_experimental
309
+ )
310
+ )
311
+ args.concat(legacy_nix_path_args)
312
+ args.concat(installables)
313
+ args
314
+ end
315
+
316
+ def nix_common_args(base_args, extra_experimental:)
317
+ args = []
318
+ if extra_experimental
319
+ args << '--extra-experimental-features' << 'nix-command'
320
+ args << '--extra-experimental-features' << 'flakes'
321
+ end
322
+
323
+ args.concat(base_args)
324
+
325
+ args << '--show-trace' if show_trace
326
+
327
+ if max_jobs
328
+ args << '--option' << 'max-jobs' << max_jobs.to_s
329
+ end
330
+
331
+ if cores
332
+ args << '--option' << 'cores' << cores.to_s
333
+ end
334
+ args
335
+ end
336
+
337
+ def nix_attr_string(value)
338
+ escaped = value.to_s.gsub('\\', '\\\\').gsub('"', '\\"')
339
+ "\"#{escaped}\""
340
+ end
341
+
342
+ def build_outputs_for_keys(machine_keys)
343
+ return {} if machine_keys.empty?
344
+
345
+ keys = machine_keys.uniq
346
+ apply = list_to_attrs_apply_expr(keys)
347
+ nix_eval_json('.#confctl.build', apply: apply)
348
+ end
349
+
350
+ def inputs_info_for_keys(machine_keys)
351
+ return {} if machine_keys.empty?
352
+
353
+ keys = machine_keys.uniq
354
+ apply = list_to_attrs_apply_expr(keys)
355
+ nix_eval_json('.#confctl.inputsInfo', apply: apply)
356
+ end
357
+
358
+ def inputs_for_keys(machine_keys)
359
+ return {} if machine_keys.empty?
360
+
361
+ keys = machine_keys.uniq
362
+ apply = list_to_attrs_apply_expr(keys)
363
+ nix_eval_json('.#confctl.inputs', apply: apply)
364
+ end
365
+
366
+ def list_to_attrs_apply_expr(keys)
367
+ list = keys.map { |k| nix_attr_string(k) }.join(' ')
368
+ "x: builtins.listToAttrs (map (k: { name = k; value = x.${k}; }) [ #{list} ])"
369
+ end
370
+
371
+ def legacy_nix_path_args(hosts)
372
+ return [] if hosts.empty?
373
+
374
+ args_builder = nix_args(settings: confctl_settings)
375
+ return [] unless args_builder.legacy_nix_path?
376
+
377
+ unless args_builder.impure?
378
+ raise ConfCtl::Error, 'legacyNixPath requires impureEval'
379
+ end
380
+
381
+ merged_inputs = {}
382
+ host_keys = {}
383
+ hosts.each { |host| host_keys[host] = machine_key_for(host) }
384
+ inputs_by_key = inputs_for_keys(host_keys.values)
385
+
386
+ hosts.each do |host|
387
+ machine_key = host_keys[host]
388
+ inputs = inputs_by_key[machine_key] || nix_eval_json(inputs_installable(host))
389
+
390
+ inputs.each do |name, path|
391
+ next unless path.is_a?(String) && !path.empty?
392
+
393
+ key = name.to_s
394
+ if merged_inputs.has_key?(key) && merged_inputs[key] != path
395
+ raise ConfCtl::Error,
396
+ "legacyNixPath requires consistent input #{key} across hosts; build hosts separately"
397
+ end
398
+ merged_inputs[key] = path
399
+ end
400
+ end
401
+
402
+ args_builder.legacy_names.each_with_object([]) do |name, acc|
403
+ path = merged_inputs[name.to_s]
404
+ next unless path.is_a?(String) && !path.empty?
405
+
406
+ acc << '-I' << "#{name}=#{path}"
407
+ end
408
+ end
409
+
410
+ def refresh_machine_key_maps(machines)
411
+ @machine_name_to_key = {}
412
+ @machine_key_to_name = {}
413
+
414
+ machines.each do |name, info|
415
+ key = info['key'] || info['machineKey'] || info['flakeKey'] || name
416
+ @machine_name_to_key[name] = key
417
+ @machine_key_to_name[key] = name
418
+ end
419
+ end
420
+
421
+ def ensure_machine_key_maps
422
+ return if @machine_name_to_key && @machine_key_to_name
423
+
424
+ mapping = nix_eval_json('.#confctl.machineKeys')
425
+ @machine_name_to_key = mapping
426
+ @machine_key_to_name = mapping.to_h { |name, key| [key, name] }
427
+ end
428
+
429
+ def machine_key_for(host)
430
+ ensure_machine_key_maps
431
+
432
+ if @machine_name_to_key.has_key?(host)
433
+ @machine_name_to_key[host]
434
+ elsif @machine_key_to_name.has_key?(host)
435
+ host
436
+ else
437
+ raise ConfCtl::Error, "Unknown machine #{host.inspect}"
438
+ end
439
+ end
440
+
441
+ def no_update_lock_file_error?(message)
442
+ message.match?(/--no-update-lock-file/) && message.match?(/unknown|unrecognized|invalid|unsupported/i)
443
+ end
444
+
445
+ def experimental_error?(message)
446
+ message.match?(/experimental/i) && message.match?(/nix-command|flakes/i)
447
+ end
448
+ end
449
+ end
@@ -80,7 +80,7 @@ module ConfCtl
80
80
  'implement #to_nix()?'
81
81
  end
82
82
 
83
- pad("#{simple}#{semicolon ? ';' : ''}#{nl ? "\n" : ''}", indent:)
83
+ pad("#{simple}#{';' if semicolon}#{"\n" if nl}", indent:)
84
84
  end
85
85
  end
86
86
 
@@ -105,7 +105,7 @@ module ConfCtl
105
105
  end
106
106
  end
107
107
 
108
- ret << pad("}#{semicolon ? ';' : ''}#{nl ? "\n" : ''}")
108
+ ret << pad("}#{';' if semicolon}#{"\n" if nl}")
109
109
  ret
110
110
  end
111
111
 
@@ -118,7 +118,7 @@ module ConfCtl
118
118
  end
119
119
  end
120
120
 
121
- ret << pad("]#{semicolon ? ';' : ''}#{nl ? "\n" : ''}")
121
+ ret << pad("]#{';' if semicolon}#{"\n" if nl}")
122
122
  end
123
123
  end
124
124
  end