ace-lab 0.9.0 → 0.13.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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +363 -104
  3. data/README.md +66 -100
  4. data/docs/usage.md +529 -592
  5. data/lib/ace/lab/atoms/install_schema.rb +373 -0
  6. data/lib/ace/lab/cli/commands/install.rb +161 -0
  7. data/lib/ace/lab/cli/commands/prepare.rb +118 -0
  8. data/lib/ace/lab/cli/commands/project_add.rb +101 -0
  9. data/lib/ace/lab/cli/commands/status.rb +109 -0
  10. data/lib/ace/lab/cli/commands/verify.rb +115 -0
  11. data/lib/ace/lab/cli.rb +21 -23
  12. data/lib/ace/lab/install_manifest_path.rb +23 -0
  13. data/lib/ace/lab/models/status_report.rb +38 -0
  14. data/lib/ace/lab/molecules/account_provisioner.rb +196 -0
  15. data/lib/ace/lab/molecules/config_state_classifier.rb +145 -0
  16. data/lib/ace/lab/molecules/directory_provisioner.rb +208 -0
  17. data/lib/ace/lab/molecules/grant_provisioner.rb +250 -0
  18. data/lib/ace/lab/molecules/grant_resolver.rb +34 -23
  19. data/lib/ace/lab/molecules/hitl_routes.rb +263 -0
  20. data/lib/ace/lab/molecules/install_manifest_loader.rb +64 -0
  21. data/lib/ace/lab/molecules/key_provisioner.rb +736 -0
  22. data/lib/ace/lab/molecules/lab_state_scanner.rb +55 -0
  23. data/lib/ace/lab/molecules/overseer_provisioner.rb +188 -0
  24. data/lib/ace/lab/molecules/project_registry.rb +251 -0
  25. data/lib/ace/lab/molecules/repository_provisioner.rb +141 -0
  26. data/lib/ace/lab/molecules/role_verifier.rb +223 -0
  27. data/lib/ace/lab/organisms/install.rb +213 -0
  28. data/lib/ace/lab/organisms/prepare.rb +156 -0
  29. data/lib/ace/lab/organisms/project_add.rb +170 -0
  30. data/lib/ace/lab/organisms/status_service.rb +70 -0
  31. data/lib/ace/lab/organisms/verify.rb +768 -0
  32. data/lib/ace/lab/version.rb +1 -1
  33. data/lib/ace/lab.rb +34 -6
  34. metadata +34 -108
  35. data/lib/ace/lab/atoms/protected_publication_input.rb +0 -50
  36. data/lib/ace/lab/atoms/protected_workspace_prune_input.rb +0 -114
  37. data/lib/ace/lab/atoms/protected_workspace_prune_preview.rb +0 -86
  38. data/lib/ace/lab/atoms/service_input.rb +0 -92
  39. data/lib/ace/lab/cli/commands/assignment.rb +0 -92
  40. data/lib/ace/lab/cli/commands/command_execute.rb +0 -28
  41. data/lib/ace/lab/cli/commands/run.rb +0 -65
  42. data/lib/ace/lab/cli/commands/service.rb +0 -240
  43. data/lib/ace/lab/models/service_receipt.rb +0 -18
  44. data/lib/ace/lab/molecules/command_owner_admission.rb +0 -183
  45. data/lib/ace/lab/molecules/command_policy.rb +0 -158
  46. data/lib/ace/lab/molecules/hitl_authorizer.rb +0 -63
  47. data/lib/ace/lab/molecules/ordinary_admission_resolver.rb +0 -131
  48. data/lib/ace/lab/molecules/ordinary_listener.rb +0 -262
  49. data/lib/ace/lab/molecules/ordinary_merge_read.rb +0 -45
  50. data/lib/ace/lab/molecules/ordinary_native_read.rb +0 -106
  51. data/lib/ace/lab/molecules/ordinary_owner_admission.rb +0 -152
  52. data/lib/ace/lab/molecules/ordinary_owner_config.rb +0 -232
  53. data/lib/ace/lab/molecules/ordinary_receiver_config.rb +0 -94
  54. data/lib/ace/lab/molecules/ordinary_socket.rb +0 -126
  55. data/lib/ace/lab/molecules/ordinary_worktree_read.rb +0 -216
  56. data/lib/ace/lab/molecules/protected_cleanup_owner_admission.rb +0 -163
  57. data/lib/ace/lab/molecules/protected_cleanup_owner_client.rb +0 -165
  58. data/lib/ace/lab/molecules/protected_cleanup_owner_identity.rb +0 -239
  59. data/lib/ace/lab/molecules/protected_publication_publisher.rb +0 -110
  60. data/lib/ace/lab/molecules/protected_service_handler.rb +0 -43
  61. data/lib/ace/lab/molecules/protected_service_policy.rb +0 -156
  62. data/lib/ace/lab/molecules/service_executor.rb +0 -99
  63. data/lib/ace/lab/molecules/service_policy.rb +0 -289
  64. data/lib/ace/lab/organisms/authority_composition.rb +0 -87
  65. data/lib/ace/lab/organisms/command_executor.rb +0 -220
  66. data/lib/ace/lab/organisms/command_run_client.rb +0 -131
  67. data/lib/ace/lab/organisms/ordinary_assignment_client.rb +0 -120
  68. data/lib/ace/lab/organisms/ordinary_authority_composition.rb +0 -153
  69. data/lib/ace/lab/organisms/ordinary_merge_receiver.rb +0 -679
  70. data/lib/ace/lab/organisms/ordinary_merge_request.rb +0 -456
  71. data/lib/ace/lab/organisms/protected_cleanup_owner.rb +0 -361
  72. data/lib/ace/lab/organisms/protected_service_client.rb +0 -129
  73. data/lib/ace/lab/organisms/protected_service_listener.rb +0 -181
  74. data/lib/ace/lab/organisms/protected_service_publication.rb +0 -268
  75. data/lib/ace/lab/organisms/protected_service_receiver.rb +0 -267
  76. data/lib/ace/lab/organisms/protected_service_recovery.rb +0 -185
  77. data/lib/ace/lab/organisms/protected_service_request.rb +0 -128
  78. data/lib/ace/lab/organisms/protected_service_worker.rb +0 -89
  79. data/lib/ace/lab/organisms/service_request_service.rb +0 -249
@@ -0,0 +1,263 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+ require "yaml"
5
+ require "date"
6
+ require "fileutils"
7
+
8
+ module Ace
9
+ module Lab
10
+ module Molecules
11
+ # Installs the manifest's HITL adapter declarations as the runtime
12
+ # route configuration the ace-hitl cascade actually reads
13
+ # (PR #393 final audit, FINAL-LAB2): `hitl.adapters.routes` in the
14
+ # project's `.ace/hitl/config.yml`. The trusted install manifest
15
+ # stays the single declaration owner — this molecule only
16
+ # PROJECTS that declaration into the existing HITL configuration
17
+ # contract; ace-hitl remains its owner and reader. A `kind: hitl`
18
+ # adapter becomes one route entry named after the adapter id
19
+ # (config entries are carried verbatim; a `route` key renames the
20
+ # route), so the runtime route loader finds a non-empty, valid
21
+ # route set instead of failing with NoRoutesError.
22
+ #
23
+ # Validation mirrors Ace::Hitl::Molecules::HitlRouteLoader
24
+ # (non-empty list of mappings, non-empty `adapter`, single-segment
25
+ # route names — delivery retention persists route names as literal
26
+ # dot-keys — and no duplicate names); the committed suite
27
+ # cross-checks the written document against the real loader.
28
+ #
29
+ # Convergence rules (review 8xir47, findings 5-6): routes are
30
+ # converged BY ROUTE NAME. On re-install the manifest-declared
31
+ # keys win over the existing same-name route, but operator
32
+ # extensions SURVIVE: extra keys the operator added to a
33
+ # same-name route (e.g. `pane: p5` on `console`) are kept and
34
+ # merged around the manifest keys, and operator-added routes
35
+ # survive untouched. Verify applies the same rule — a route is
36
+ # converged when a same-name entry carries every
37
+ # manifest-declared key/value; operator extra keys never fail
38
+ # verification. An unchanged route list is never rewritten, so
39
+ # an idempotent no-op install leaves the file (including any
40
+ # operator comments) byte-identical. When a write DOES happen
41
+ # it goes through a YAML round-trip (atomic: exclusive temp
42
+ # file + rename, the ProjectRegistry pattern), which normalizes
43
+ # formatting and DROPS comments in the file — comments are not
44
+ # preserved across a converging write.
45
+ class HitlRoutes
46
+ RELATIVE_CONFIG_PATH = %w[.ace hitl config.yml].freeze
47
+ ROUTE_NAME_PATTERN = /\A[a-z0-9][a-z0-9_-]*\z/i.freeze
48
+
49
+ # @param adapters [Array<Hash>] normalized manifest adapters
50
+ # @return [Array<Hash>] route entries for the `kind: hitl`
51
+ # declarations (string keys, `adapter` => adapter id merged
52
+ # with the declaration's config)
53
+ def self.expected_routes(adapters)
54
+ adapters.filter_map do |adapter|
55
+ next unless adapter["kind"] == "hitl"
56
+
57
+ {"adapter" => adapter["id"]}.merge(adapter["config"] || {})
58
+ end
59
+ end
60
+
61
+ # @param route [Hash] route entry
62
+ # @return [String] the entry's route name (defaults to the
63
+ # adapter name, exactly like the runtime loader)
64
+ def self.route_name(route)
65
+ name = route["route"].to_s.strip
66
+ name.empty? ? route["adapter"].to_s : name
67
+ end
68
+
69
+ # The runtime loader's validation contract; nil when the route
70
+ # list is loadable, otherwise the concrete reason.
71
+ # @param routes [Object] the routes value from the config
72
+ # @return [String, nil]
73
+ def self.validation_reason(routes)
74
+ unless routes.is_a?(Array) && !routes.empty?
75
+ return "hitl.adapters.routes must be a non-empty list"
76
+ end
77
+
78
+ names = []
79
+ routes.each do |route|
80
+ return "route entry must be a mapping" unless route.is_a?(Hash)
81
+
82
+ if route["adapter"].to_s.strip.empty?
83
+ return "route entry missing required 'adapter'"
84
+ end
85
+
86
+ name = route_name(route)
87
+ unless name.match?(ROUTE_NAME_PATTERN)
88
+ return "route name '#{name}' is invalid (must match #{ROUTE_NAME_PATTERN.source})"
89
+ end
90
+ names << name
91
+ end
92
+ duplicates = names.tally.select { |_, count| count > 1 }.keys
93
+ return "duplicate route name(s) #{duplicates.sort.join(', ')}" unless duplicates.empty?
94
+
95
+ nil
96
+ end
97
+
98
+ # Readiness probe (verify): the project's installed HITL
99
+ # configuration must exist, parse, satisfy the loader contract,
100
+ # and contain the expected route BY NAME with every
101
+ # manifest-declared key matching (operator extra keys on the
102
+ # same-name route are allowed — an extended route is converged,
103
+ # not drift). nil when ready, otherwise the concrete reason.
104
+ # @param root [String] project root
105
+ # @param expected [Hash] expected route entry
106
+ # @return [String, nil]
107
+ def self.config_reason(root:, expected:)
108
+ path = File.join(root, *RELATIVE_CONFIG_PATH)
109
+ unless File.file?(path)
110
+ return "no HITL adapter routes are configured: '#{path}' is missing " \
111
+ "(the runtime cannot deliver HITL; rerun ace-lab install)"
112
+ end
113
+
114
+ begin
115
+ document = YAML.safe_load_file(path, permitted_classes: [Date], aliases: true)
116
+ rescue Psych::Exception
117
+ return "'#{path}' could not be parsed as YAML"
118
+ end
119
+ return "'#{path}' must contain a YAML mapping" unless document.is_a?(Hash)
120
+
121
+ routes = document.dig("hitl", "adapters", "routes")
122
+ reason = validation_reason(routes)
123
+ return "hitl.adapters.routes in '#{path}' is invalid: #{reason}" if reason
124
+
125
+ name = route_name(expected)
126
+ route = routes.find { |candidate| route_name(candidate) == name }
127
+ unless route && expected.all? { |key, value| route[key] == value }
128
+ return "route '#{name}' is not configured in '#{path}' with the declared " \
129
+ "settings (rerun ace-lab install)"
130
+ end
131
+
132
+ nil
133
+ end
134
+
135
+ # @param root [String] project root
136
+ # @param adapters [Array<Hash>] normalized manifest adapters
137
+ # @param dry_run [Boolean]
138
+ # @return [Hash] {location:, status:, message:} — status is
139
+ # created / exists / would_create / skipped / failed
140
+ def install(root:, adapters:, dry_run: false)
141
+ path = File.join(root, *RELATIVE_CONFIG_PATH)
142
+ base = {"location" => path}
143
+ expected = self.class.expected_routes(adapters)
144
+ return base.merge("status" => "skipped", "message" => nil) if expected.empty?
145
+
146
+ document, reason = load_document(path)
147
+ return base.merge("status" => "failed", "message" => reason) if reason
148
+
149
+ routes = document.dig("hitl", "adapters", "routes")
150
+ routes = routes.is_a?(Array) ? routes : []
151
+ merged = merge_routes(routes, expected)
152
+ if (invalid = self.class.validation_reason(merged))
153
+ return base.merge("status" => "failed",
154
+ "message" => "resulting hitl.adapters.routes is invalid: #{invalid}")
155
+ end
156
+ return base.merge("status" => "exists", "message" => nil) if routes == merged
157
+ return base.merge("status" => "would_create", "message" => nil) if dry_run
158
+
159
+ document["hitl"] ||= {}
160
+ document["hitl"]["adapters"] ||= {}
161
+ document["hitl"]["adapters"]["routes"] = merged
162
+ begin
163
+ dir = File.dirname(path)
164
+ FileUtils.mkdir_p(dir)
165
+ # Atomic write (review 8xir47, finding 5, the ProjectRegistry
166
+ # pattern): a crash mid-write must never truncate the runtime
167
+ # route configuration. The temp file is created EXCLUSIVELY
168
+ # (O_CREAT|O_EXCL) under an unguessable name so a planted
169
+ # symlink can never redirect the write.
170
+ tmp = write_exclusive(dir, YAML.dump(document))
171
+ File.rename(tmp, path)
172
+ rescue SystemCallError, IOError => e
173
+ File.delete(tmp) if defined?(tmp) && tmp && File.exist?(tmp)
174
+ return base.merge("status" => "failed",
175
+ "message" => "writing '#{path}' failed: #{e.class}: #{e.message}")
176
+ end
177
+ base.merge("status" => "created", "message" => nil)
178
+ end
179
+
180
+ private
181
+
182
+ # An existing configuration document is preserved (root_dir and
183
+ # operator-owned settings survive); only the lab-declared routes
184
+ # are converged. Unparseable or foreign-shaped content is
185
+ # refused instead of overwritten.
186
+ def load_document(path)
187
+ return [{}, nil] unless File.exist?(path)
188
+
189
+ begin
190
+ document = YAML.safe_load_file(path, permitted_classes: [Date], aliases: true)
191
+ rescue Psych::Exception
192
+ return [nil, "'#{path}' exists but could not be parsed as YAML; resolve it " \
193
+ "manually and rerun ace-lab install"]
194
+ end
195
+ return [nil, "'#{path}' must contain a YAML mapping"] unless document.is_a?(Hash)
196
+
197
+ [document, nil]
198
+ end
199
+
200
+ # Converge the expected routes into the existing list by route
201
+ # name: a declared route's keys are MERGED into the entry
202
+ # carrying the same name (manifest-declared keys win, operator
203
+ # extra keys like `pane: p5` survive), a route with no
204
+ # same-name counterpart is appended; operator-added routes and
205
+ # their order are untouched.
206
+ def merge_routes(routes, expected)
207
+ merged = routes.map(&:dup)
208
+ expected.each do |route|
209
+ name = self.class.route_name(route)
210
+ index = merged.index { |existing| self.class.route_name(existing) == name }
211
+ if index
212
+ merged[index] = merged[index].merge(route)
213
+ else
214
+ merged << route.dup
215
+ end
216
+ end
217
+ merged
218
+ end
219
+
220
+ # NOTE: merge_routes uses a shallow Hash#merge — manifest keys
221
+ # win per-key, but a nested manifest value would replace an
222
+ # operator's nested value wholesale. Current route shapes are
223
+ # flat (adapter/pane/runtime/route), so shallow is correct.
224
+
225
+ # Create the temp file exclusively (never following a symlink),
226
+ # write the payload, close, and return the path — the
227
+ # ProjectRegistry atomic-write discipline. Raises
228
+ # SystemCallError/IOError on failure so the caller classifies.
229
+ def write_exclusive(dir, payload)
230
+ require "securerandom"
231
+ 100.times do
232
+ candidate = File.join(dir, ".config.yml.tmp.#{Process.pid}.#{SecureRandom.hex(8)}")
233
+ next if File.exist?(candidate) # includes symlinks (lstat)
234
+
235
+ begin
236
+ file = File.open(candidate, File::RDWR | File::CREAT | File::EXCL, 0o600)
237
+ rescue Errno::EEXIST
238
+ next
239
+ end
240
+ begin
241
+ file.write(payload)
242
+ rescue StandardError
243
+ # A failed write must not leak the exclusive temp file:
244
+ # close, remove, and re-raise verbatim so the caller
245
+ # still classifies (encoding failures included).
246
+ file.close unless file.closed?
247
+ begin
248
+ File.delete(candidate)
249
+ rescue SystemCallError
250
+ nil
251
+ end
252
+ raise
253
+ ensure
254
+ file.close unless file.closed?
255
+ end
256
+ return candidate
257
+ end
258
+ raise SystemCallError.new("could not create an exclusive temporary file in '#{dir}'", 0)
259
+ end
260
+ end
261
+ end
262
+ end
263
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+ require_relative "../atoms/install_schema"
5
+ require_relative "grant_resolver"
6
+ require "yaml"
7
+
8
+ module Ace
9
+ module Lab
10
+ module Molecules
11
+ # Loads the trusted install manifest through the same fail-closed
12
+ # deployment-ownership verification as the authorization document
13
+ # (GrantResolver.read_verified): every traversed element root-owned
14
+ # and not group/world-writable, O_NOFOLLOW on the final file. The
15
+ # loader adds nothing to trust — it only classifies absence and parse
16
+ # failures with value-free, path-bearing messages.
17
+ class InstallManifestLoader
18
+ class << self
19
+ # @param path [String] trusted manifest location (default: the
20
+ # fixed deployment path)
21
+ # @param platform [String, nil] platform for home-path defaults
22
+ # @return [Hash] normalized manifest (Atoms::InstallSchema)
23
+ # @raise [Ace::Lab::InvalidConfigurationError] missing, untrusted,
24
+ # unparseable or schema-invalid manifest
25
+ def load(path: Ace::Lab.install_manifest_path, platform: nil)
26
+ # The nested GrantResolver message speaks of the
27
+ # "authorization file" — a wrong remedy lead inside an
28
+ # install-manifest error. The wrapper already states the
29
+ # requirement, so the nested message is not embedded.
30
+ content = begin
31
+ GrantResolver.read_verified(path)
32
+ rescue Ace::Lab::InvalidConfigurationError
33
+ raise Ace::Lab::InvalidConfigurationError,
34
+ "invalid lab install manifest: #{path} failed trusted deployment verification " \
35
+ "or could not be read; failing closed"
36
+ end
37
+
38
+ if content.nil?
39
+ raise Ace::Lab::InvalidConfigurationError,
40
+ "invalid lab install manifest: #{path} not found; create the root-owned " \
41
+ "deployment manifest at this path (administrator action) " \
42
+ "and keep it outside the caller-writable configuration cascade"
43
+ end
44
+
45
+ document = begin
46
+ YAML.safe_load(content, permitted_classes: [], aliases: false)
47
+ rescue Psych::DisallowedClass
48
+ # It parsed; it just carries types outside the permitted
49
+ # set — a different remedy than fixing YAML syntax.
50
+ raise Ace::Lab::InvalidConfigurationError,
51
+ "invalid lab install manifest: #{path} contains YAML types outside the " \
52
+ "permitted set; use plain scalars, sequences and mappings only"
53
+ rescue Psych::Exception
54
+ raise Ace::Lab::InvalidConfigurationError,
55
+ "invalid lab install manifest: #{path} could not be parsed as YAML"
56
+ end
57
+
58
+ Atoms::InstallSchema.normalize(document, platform: platform)
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end