ace-lab 0.9.0 → 0.13.1

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 (80) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +399 -102
  3. data/README.md +66 -100
  4. data/docs/usage.md +563 -592
  5. data/lib/ace/lab/atoms/install_schema.rb +444 -0
  6. data/lib/ace/lab/cli/commands/install.rb +161 -0
  7. data/lib/ace/lab/cli/commands/prepare.rb +120 -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/gemset_provisioner.rb +222 -0
  18. data/lib/ace/lab/molecules/grant_provisioner.rb +250 -0
  19. data/lib/ace/lab/molecules/grant_resolver.rb +34 -23
  20. data/lib/ace/lab/molecules/hitl_routes.rb +263 -0
  21. data/lib/ace/lab/molecules/install_manifest_loader.rb +64 -0
  22. data/lib/ace/lab/molecules/key_provisioner.rb +736 -0
  23. data/lib/ace/lab/molecules/lab_state_scanner.rb +55 -0
  24. data/lib/ace/lab/molecules/overseer_provisioner.rb +188 -0
  25. data/lib/ace/lab/molecules/project_registry.rb +251 -0
  26. data/lib/ace/lab/molecules/repository_provisioner.rb +141 -0
  27. data/lib/ace/lab/molecules/role_verifier.rb +223 -0
  28. data/lib/ace/lab/organisms/install.rb +218 -0
  29. data/lib/ace/lab/organisms/prepare.rb +170 -0
  30. data/lib/ace/lab/organisms/project_add.rb +170 -0
  31. data/lib/ace/lab/organisms/status_service.rb +70 -0
  32. data/lib/ace/lab/organisms/verify.rb +836 -0
  33. data/lib/ace/lab/version.rb +1 -1
  34. data/lib/ace/lab.rb +34 -6
  35. metadata +35 -108
  36. data/lib/ace/lab/atoms/protected_publication_input.rb +0 -50
  37. data/lib/ace/lab/atoms/protected_workspace_prune_input.rb +0 -114
  38. data/lib/ace/lab/atoms/protected_workspace_prune_preview.rb +0 -86
  39. data/lib/ace/lab/atoms/service_input.rb +0 -92
  40. data/lib/ace/lab/cli/commands/assignment.rb +0 -92
  41. data/lib/ace/lab/cli/commands/command_execute.rb +0 -28
  42. data/lib/ace/lab/cli/commands/run.rb +0 -65
  43. data/lib/ace/lab/cli/commands/service.rb +0 -240
  44. data/lib/ace/lab/models/service_receipt.rb +0 -18
  45. data/lib/ace/lab/molecules/command_owner_admission.rb +0 -183
  46. data/lib/ace/lab/molecules/command_policy.rb +0 -158
  47. data/lib/ace/lab/molecules/hitl_authorizer.rb +0 -63
  48. data/lib/ace/lab/molecules/ordinary_admission_resolver.rb +0 -131
  49. data/lib/ace/lab/molecules/ordinary_listener.rb +0 -262
  50. data/lib/ace/lab/molecules/ordinary_merge_read.rb +0 -45
  51. data/lib/ace/lab/molecules/ordinary_native_read.rb +0 -106
  52. data/lib/ace/lab/molecules/ordinary_owner_admission.rb +0 -152
  53. data/lib/ace/lab/molecules/ordinary_owner_config.rb +0 -232
  54. data/lib/ace/lab/molecules/ordinary_receiver_config.rb +0 -94
  55. data/lib/ace/lab/molecules/ordinary_socket.rb +0 -126
  56. data/lib/ace/lab/molecules/ordinary_worktree_read.rb +0 -216
  57. data/lib/ace/lab/molecules/protected_cleanup_owner_admission.rb +0 -163
  58. data/lib/ace/lab/molecules/protected_cleanup_owner_client.rb +0 -165
  59. data/lib/ace/lab/molecules/protected_cleanup_owner_identity.rb +0 -239
  60. data/lib/ace/lab/molecules/protected_publication_publisher.rb +0 -110
  61. data/lib/ace/lab/molecules/protected_service_handler.rb +0 -43
  62. data/lib/ace/lab/molecules/protected_service_policy.rb +0 -156
  63. data/lib/ace/lab/molecules/service_executor.rb +0 -99
  64. data/lib/ace/lab/molecules/service_policy.rb +0 -289
  65. data/lib/ace/lab/organisms/authority_composition.rb +0 -87
  66. data/lib/ace/lab/organisms/command_executor.rb +0 -220
  67. data/lib/ace/lab/organisms/command_run_client.rb +0 -131
  68. data/lib/ace/lab/organisms/ordinary_assignment_client.rb +0 -120
  69. data/lib/ace/lab/organisms/ordinary_authority_composition.rb +0 -153
  70. data/lib/ace/lab/organisms/ordinary_merge_receiver.rb +0 -679
  71. data/lib/ace/lab/organisms/ordinary_merge_request.rb +0 -456
  72. data/lib/ace/lab/organisms/protected_cleanup_owner.rb +0 -361
  73. data/lib/ace/lab/organisms/protected_service_client.rb +0 -129
  74. data/lib/ace/lab/organisms/protected_service_listener.rb +0 -181
  75. data/lib/ace/lab/organisms/protected_service_publication.rb +0 -268
  76. data/lib/ace/lab/organisms/protected_service_receiver.rb +0 -267
  77. data/lib/ace/lab/organisms/protected_service_recovery.rb +0 -185
  78. data/lib/ace/lab/organisms/protected_service_request.rb +0 -128
  79. data/lib/ace/lab/organisms/protected_service_worker.rb +0 -89
  80. data/lib/ace/lab/organisms/service_request_service.rb +0 -249
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+ require "open3"
5
+ require "etc"
6
+ require "securerandom"
7
+
8
+ module Ace
9
+ module Lab
10
+ module Molecules
11
+ # Idempotently ensures a lab account exists, per the trusted install
12
+ # manifest. Existing accounts are reported with their uid; missing
13
+ # accounts either fail with an actionable message (create: false, or
14
+ # creation without administrator privileges) or are created through
15
+ # the platform's own account tooling (darwin dscl / linux useradd),
16
+ # always with argv arrays (ADR-031) — never a shell string. Every
17
+ # failure message names the account and the remedy; command failures
18
+ # surface only the exit status and the first stderr line, never file
19
+ # content.
20
+ class AccountProvisioner
21
+ DEFAULT_SHELL = "/bin/zsh"
22
+ DARWIN_PRIMARY_GROUP = 20
23
+
24
+ attr_reader :runner, :account_lookup, :privileged, :platform, :dry_run
25
+
26
+ def initialize(runner: nil, account_lookup: nil, privileged: nil, platform: nil, dry_run: false)
27
+ @runner = runner || method(:run_argv)
28
+ @account_lookup = account_lookup || method(:lookup_passwd)
29
+ @privileged = privileged || -> { Process.euid.zero? }
30
+ @platform = platform || Ace::Lab.host_platform
31
+ @dry_run = dry_run
32
+ end
33
+
34
+ # @param account [Hash] normalized manifest account entry
35
+ # @return [Hash] {username:, status:, uid:, message:}
36
+ def call(account:)
37
+ username = account["username"]
38
+ existing = account_lookup.call(username)
39
+ if existing
40
+ return {"username" => username, "status" => "exists", "uid" => existing.uid,
41
+ "message" => nil} if existing.dir == account["home"]
42
+
43
+ # Drift is reported, not repaired: the manifest home stays
44
+ # authoritative (key default paths derive from it).
45
+ {"username" => username, "status" => "exists", "uid" => existing.uid,
46
+ "message" => "account '#{username}' home is '#{existing.dir}' but the manifest " \
47
+ "declares '#{account["home"]}'; the manifest home is authoritative for key paths"}
48
+ else
49
+
50
+ unless account["create"]
51
+ return failure(username,
52
+ "account '#{username}' does not exist; create it manually (administrator action) " \
53
+ "or set create: true in the install manifest")
54
+ end
55
+
56
+ unless privileged.call
57
+ return failure(username,
58
+ "creating account '#{username}' requires administrator privileges; rerun ace-lab prepare " \
59
+ "with sudo or create the account manually")
60
+ end
61
+
62
+ # Platform support is classified BEFORE the dry-run
63
+ # prediction: --dry-run must predict the wet failure on an
64
+ # unsupported platform, not would_create.
65
+ creation = case platform
66
+ when "darwin" then :create_darwin
67
+ when "linux" then :create_linux
68
+ end
69
+ if creation.nil?
70
+ return failure(username,
71
+ "unsupported platform '#{platform}' for account creation; create account '#{username}' " \
72
+ "manually and set create: false in the install manifest")
73
+ end
74
+
75
+ if dry_run
76
+ return {"username" => username, "status" => "would_create", "uid" => nil, "message" => nil}
77
+ end
78
+
79
+ send(creation, account)
80
+ end
81
+ end
82
+
83
+ private
84
+
85
+ def failure(username, message)
86
+ {"username" => username, "status" => "failed", "uid" => nil, "message" => message}
87
+ end
88
+
89
+ def created(username, uid)
90
+ {"username" => username, "status" => "created", "uid" => uid, "message" => nil}
91
+ end
92
+
93
+ def create_darwin(account)
94
+ username = account["username"]
95
+ listing = invoke(["dscl", ".", "-list", "/Users", "UniqueID"])
96
+ return failure(username, listing["message"]) unless listing["ok"]
97
+
98
+ uid = next_darwin_uid(listing["out"])
99
+ steps = [
100
+ ["dscl", ".", "-create", "/Users/#{username}", "RealName", username],
101
+ ["dscl", ".", "-create", "/Users/#{username}", "UniqueID", uid.to_s],
102
+ ["dscl", ".", "-create", "/Users/#{username}", "PrimaryGroupID", DARWIN_PRIMARY_GROUP.to_s],
103
+ ["dscl", ".", "-create", "/Users/#{username}", "UserShell", DEFAULT_SHELL],
104
+ ["dscl", ".", "-create", "/Users/#{username}", "NFSHomeDirectory", account["home"]],
105
+ ["dscl", ".", "-create", "/Users/#{username}", "GeneratedUID", SecureRandom.uuid],
106
+ ["dscl", ".", "-create", "/Users/#{username}", "Password", "*"],
107
+ ["createhomedir", "-c", "-u", username]
108
+ ]
109
+ steps.each do |argv|
110
+ result = invoke(argv)
111
+ return failure(username, result["message"]) unless result["ok"]
112
+ end
113
+
114
+ created(username, uid)
115
+ end
116
+
117
+ # Allocate the next free UniqueID above the current maximum,
118
+ # ignoring the reserved high sentinel values (darwin's `nobody`
119
+ # reports 4294967294). Parse only the trailing UniqueID column
120
+ # of each `dscl -list` line — digits inside account names such
121
+ # as `deploy-2026` must never be taken for a UID. Deliberately
122
+ # simple: dscl owns the directory; concurrent creation is
123
+ # reconciled by the idempotent re-run.
124
+ def next_darwin_uid(out)
125
+ ids = out.lines.filter_map { |line| line[/\s(\d+)\s*\z/, 1] }
126
+ .map(&:to_i).reject { |id| id >= 2**31 }
127
+ return 501 if ids.empty?
128
+
129
+ [ids.max + 1, 501].max
130
+ end
131
+
132
+ def create_linux(account)
133
+ username = account["username"]
134
+ result = invoke([
135
+ "useradd", "-m", "-s", DEFAULT_SHELL, "-d", account["home"], username
136
+ ])
137
+ return failure(username, result["message"]) unless result["ok"]
138
+
139
+ entry = account_lookup.call(username)
140
+ if entry
141
+ created(username, entry.uid)
142
+ else
143
+ {"username" => username, "status" => "created", "uid" => nil,
144
+ "message" => "account '#{username}' was created but its uid could not be " \
145
+ "resolved yet; rerun ace-lab prepare to report it"}
146
+ end
147
+ end
148
+
149
+ # @return [Hash] {ok:, out:, message:} — failure message carries
150
+ # the exit status and the first stderr line only, never command
151
+ # output bodies or file content.
152
+ def invoke(argv)
153
+ result = runner.call(argv)
154
+ if result["status"] == "ok"
155
+ {"ok" => true, "out" => result["out"], "message" => nil}
156
+ elsif result["status"] == "enoent"
157
+ {"ok" => false, "out" => result["out"],
158
+ "message" => "#{argv.first} not found in PATH; install it and rerun ace-lab prepare"}
159
+ elsif result["status"] == "exec_failed"
160
+ # Exec-level SystemCallError, not a subprocess exit status —
161
+ # render the errno class so the message never says "exit failed".
162
+ {"ok" => false, "out" => result["out"],
163
+ "message" => "command could not be executed (#{argv.first}, #{result['err']})"}
164
+ else
165
+ first_line = result["err"].to_s.lines.map(&:strip).reject(&:empty?).first
166
+ # Round 5 (C2): a signal-killed child has no exitstatus —
167
+ # the runner renders "signal <termsig>" instead of nil.
168
+ message = "command failed (#{argv.first}, #{exit_detail(result['status'])})"
169
+ message += ": #{first_line}" if first_line
170
+ {"ok" => false, "out" => result["out"], "message" => message}
171
+ end
172
+ end
173
+
174
+ def exit_detail(status)
175
+ status.is_a?(Integer) ? "exit #{status}" : status.to_s
176
+ end
177
+
178
+ def run_argv(argv)
179
+ out, err, status = Open3.capture3(*argv)
180
+ {"status" => status.success? ? "ok" : (status.exitstatus || "signal #{status.termsig}"),
181
+ "out" => out, "err" => err}
182
+ rescue Errno::ENOENT
183
+ {"status" => "enoent", "out" => "", "err" => ""}
184
+ rescue SystemCallError => e
185
+ {"status" => "exec_failed", "out" => "", "err" => e.class.name}
186
+ end
187
+
188
+ def lookup_passwd(username)
189
+ Etc.getpwnam(username)
190
+ rescue ArgumentError
191
+ nil
192
+ end
193
+ end
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Lab
5
+ module Molecules
6
+ # Classifies the lab configuration namespace into exactly three
7
+ # distinguished states (task 8xg.t.sbz.6.0): a valid empty set, missing
8
+ # configuration, and a source error are never conflated.
9
+ #
10
+ # :defaults_only — no overlay exists (gem defaults only) ⇒ missing
11
+ # configuration; informational, exit-eligible success
12
+ # :configured — at least one overlay present and valid; carries
13
+ # topology counts (0/0/0 is a valid empty set)
14
+ # :invalid — any cascade document unparsable/non-mapping or a
15
+ # schema violation; carries the path-bearing message
16
+ #
17
+ # Also echoes runtime *configuration files* (project, user, ACE_RUNTIME
18
+ # env): presence + the configured runtime value. No runtime detection
19
+ # and no ace-runtime dependency — live runtime availability is
20
+ # ace-herdr status's dimension.
21
+ class ConfigStateClassifier
22
+ RUNTIME_PATTERNS = %w[runtime/config.yml runtime/config.yaml].freeze
23
+
24
+ class << self
25
+ # @param documents [Array<Hash>, nil] cascade_documents output;
26
+ # nil means the cascade itself raised before documents existed
27
+ # @param cascade_error [String, nil] path-bearing message from the
28
+ # InvalidConfigurationError raised while reading the cascade
29
+ # @param root [String] project root for runtime config reads
30
+ # @return [Hash] classified configuration section
31
+ def classify(documents:, cascade_error: nil, root: Dir.pwd)
32
+ return invalid_config(cascade_error) if cascade_error
33
+
34
+ documents ||= []
35
+ # No cascade documents at all (not even gem defaults): nothing
36
+ # is broken, the lab is simply unconfigured.
37
+ return missing_config if documents.empty?
38
+
39
+ overlays = documents.reject { |document| document[:defaults] }
40
+ user_overlay_dir = File.join(Dir.home, ".ace")
41
+ project = overlays.find { |document| !document[:path].start_with?(user_overlay_dir) }
42
+ user = overlays.find { |document| document[:path].start_with?(user_overlay_dir) }
43
+
44
+ merged = deep_merge_documents(documents)
45
+ begin
46
+ topology = Atoms::TopologySchema.normalize!(merged).fetch("topology", {})
47
+ {
48
+ state: overlays.empty? ? :defaults_only : :configured,
49
+ overlays: {project: project&.dig(:path), user: user&.dig(:path)},
50
+ source_path: overlays.first&.dig(:path),
51
+ error: nil,
52
+ topology: {
53
+ projects: topology.fetch("projects", []).length,
54
+ agents: topology.fetch("agents", []).length,
55
+ services: topology.fetch("services", []).length
56
+ }
57
+ }
58
+ rescue Ace::Lab::InvalidConfigurationError => e
59
+ invalid_config(e.message)
60
+ end
61
+ end
62
+
63
+ # Runtime configuration echo: files first (project, then user),
64
+ # then the ACE_RUNTIME environment variable. Presence and value
65
+ # only — never liveness.
66
+ #
67
+ # @param root [String] project root
68
+ # @return [Hash] runtime configuration section
69
+ def runtime_config(root: Dir.pwd)
70
+ candidates = [
71
+ {source: "project", paths: RUNTIME_PATTERNS.map { |pattern| File.join(root, ".ace", pattern) }},
72
+ {source: "user", paths: RUNTIME_PATTERNS.map { |pattern| File.join(Dir.home, ".ace", pattern) }}
73
+ ]
74
+ found = candidates.find do |candidate|
75
+ candidate[:paths].any? { |candidate_path| File.exist?(candidate_path) }
76
+ end
77
+ if found
78
+ path = found[:paths].find { |candidate_path| File.exist?(candidate_path) }
79
+ document = begin
80
+ require "yaml"
81
+ YAML.safe_load_file(path, permitted_classes: [Date], aliases: true)
82
+ rescue StandardError
83
+ nil
84
+ end
85
+ if document.is_a?(Hash)
86
+ return {state: :valid, source: found[:source], path: path, runtime: document["runtime"]}
87
+ end
88
+
89
+ return {state: :invalid, source: found[:source], path: path, runtime: nil,
90
+ error: "#{path} could not be parsed as a YAML mapping"}
91
+ end
92
+ return runtime_from_env if ENV["ACE_RUNTIME"] && !ENV["ACE_RUNTIME"].empty?
93
+
94
+ {state: :absent, source: nil, path: nil, runtime: nil}
95
+ end
96
+
97
+ private
98
+
99
+ def runtime_from_env
100
+ {state: :valid, source: "env", path: nil, runtime: ENV["ACE_RUNTIME"]}
101
+ end
102
+
103
+ def invalid_config(message)
104
+ {
105
+ state: :invalid,
106
+ overlays: {},
107
+ source_path: nil,
108
+ error: message,
109
+ topology: {projects: 0, agents: 0, services: 0}
110
+ }
111
+ end
112
+
113
+ def missing_config
114
+ {
115
+ state: :defaults_only,
116
+ overlays: {},
117
+ source_path: nil,
118
+ error: nil,
119
+ topology: {projects: 0, agents: 0, services: 0}
120
+ }
121
+ end
122
+
123
+ # Cascade merge mirroring the canonical ADR-22 resolver:
124
+ # cascade_documents lists documents highest-priority-first, so
125
+ # reduce in reverse (defaults first, nearest overlay last — later
126
+ # wins). Arrays replace (they are topology inventories, not
127
+ # additive sets).
128
+ def deep_merge_documents(documents)
129
+ documents.reverse.reduce({}) { |merged, document| deep_merge(merged, document[:document]) }
130
+ end
131
+
132
+ def deep_merge(base, overlay)
133
+ base.merge(overlay) do |_key, base_value, overlay_value|
134
+ if base_value.is_a?(Hash) && overlay_value.is_a?(Hash)
135
+ deep_merge(base_value, overlay_value)
136
+ else
137
+ overlay_value
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+ require "etc"
5
+ require "fileutils"
6
+
7
+ module Ace
8
+ module Lab
9
+ module Molecules
10
+ # Idempotently ensures a manifest-declared directory exists with the
11
+ # declared mode and ownership. Pre-existing directories are never
12
+ # silently taken over: a symlink at the path, a non-directory, or a
13
+ # directory owned by a different account than declared is refused
14
+ # with a resolve-manually message; only mode drift on an
15
+ # owner-matching directory is repaired. Permission failures
16
+ # (EPERM/EACCES) classify as actionable missing-permission reports.
17
+ class DirectoryProvisioner
18
+ attr_reader :account_lookup, :dry_run
19
+
20
+ # available_accounts [Enumerable<String>, nil] usernames the
21
+ # account phase will have (exists/created/would_create) — the
22
+ # orchestrator's already-computed set, threaded in so dry-run
23
+ # can predict the wet run's owner_unavailable failure for a
24
+ # directory whose owner account failed permanently (mirroring
25
+ # keys/roles dependency prediction). nil = predict from live
26
+ # passwd only (standalone use).
27
+ def initialize(account_lookup: nil, dry_run: false, available_accounts: nil)
28
+ @account_lookup = account_lookup || method(:lookup_passwd)
29
+ @dry_run = dry_run
30
+ @available_accounts = available_accounts
31
+ end
32
+
33
+ # @param directory [Hash] normalized manifest directory entry
34
+ # @return [Hash] {path:, owner:, mode:, status:, message:}
35
+ def call(directory:)
36
+ path = directory["path"]
37
+ mode = directory["mode"].to_i(8)
38
+ stat = begin
39
+ File.lstat(path)
40
+ rescue Errno::ENOENT
41
+ nil
42
+ end
43
+
44
+ if stat.nil?
45
+ create_missing(path, directory, mode)
46
+ elsif stat.symlink?
47
+ refuse_symlink(path, directory)
48
+ elsif !stat.directory?
49
+ failure(path, "path '#{path}' is occupied by a non-directory; refusing", directory)
50
+ else
51
+ ensure_existing(path, directory, stat, mode)
52
+ end
53
+ rescue Errno::EPERM, Errno::EACCES
54
+ failure(path,
55
+ "insufficient permissions while preparing '#{path}'; rerun ace-lab prepare with sudo " \
56
+ "or adjust ownership manually", directory)
57
+ rescue SystemCallError => e
58
+ failure(path, "path '#{path}' could not be prepared (#{e.class.name})", directory)
59
+ end
60
+
61
+ private
62
+
63
+ def base(path, directory)
64
+ {"path" => path, "owner" => directory["owner"], "mode" => directory["mode"]}
65
+ end
66
+
67
+ # Every failed entry carries the full {path, owner, mode} shape —
68
+ # JSON consumers must see one shape per section, including
69
+ # rescue-path failures.
70
+ def failure(path, message, directory)
71
+ base(path, directory).merge("status" => "failed", "message" => message)
72
+ end
73
+
74
+ def refuse_symlink(path, directory)
75
+ failure(path, "path '#{path}' is a symbolic link; refusing", directory)
76
+ end
77
+
78
+ # Ownership an existing directory must match: the declared owner
79
+ # account's uid, or the installer's euid when no owner is declared.
80
+ # Returns nil when the declared owner account is not resolvable on
81
+ # this machine (account phase not yet green).
82
+ def expected_owner_uid(directory)
83
+ return Process.euid unless directory["owner"]
84
+
85
+ entry = account_lookup.call(directory["owner"])
86
+ entry && entry.uid
87
+ end
88
+
89
+ def create_missing(path, directory, mode)
90
+ if dry_run
91
+ # A declared owner whose account phase failed permanently
92
+ # (not merely would_create) makes the wet run fail with
93
+ # owner_unavailable — the prediction must say so instead of
94
+ # would_create.
95
+ if directory["owner"] && @available_accounts &&
96
+ !@available_accounts.include?(directory["owner"])
97
+ return owner_unavailable(path, directory, "fix the account phase first")
98
+ end
99
+
100
+ return base(path, directory).merge("status" => "would_create", "message" => nil)
101
+ end
102
+
103
+ missing = chown_target(directory)
104
+ return owner_unavailable(path, directory) if directory["owner"] && missing.nil?
105
+
106
+ refusal = create_leaf(path, directory)
107
+ return refusal if refusal
108
+
109
+ FileUtils.chmod(mode, path)
110
+ chown_to_owner(path, directory, missing)
111
+ base(path, directory).merge("status" => "created", "message" => nil)
112
+ end
113
+
114
+ # Close the create-time TOCTOU window as far as stdlib allows:
115
+ # intermediate components are created per the documented policy
116
+ # (mkdir_p follows existing components — the manifest is
117
+ # root-owned and only the leaf carries declared values), but the
118
+ # leaf uses Dir.mkdir, whose EEXIST on a planted symlink never
119
+ # follows it, and the leaf is re-lstat'd before any chmod/chown:
120
+ # a leaf that is not a real directory this process just created
121
+ # is refused instead of mutated. The window between this
122
+ # re-lstat and the chmod/chown that follow is a documented
123
+ # accepted residual (usage.md).
124
+ def create_leaf(path, directory)
125
+ FileUtils.mkdir_p(File.dirname(path))
126
+ Dir.mkdir(path)
127
+ post = lstat(path)
128
+ return nil if post && post.directory? && post.uid == Process.euid
129
+
130
+ failure(path, "path '#{path}' changed while preparing; refusing — rerun ace-lab prepare",
131
+ directory)
132
+ rescue Errno::EEXIST
133
+ failure(path, "path '#{path}' appeared or was replaced while preparing; " \
134
+ "refusing — rerun ace-lab prepare", directory)
135
+ rescue Errno::ENOENT, Errno::ENOTDIR
136
+ failure(path, "path '#{path}' could not be created; rerun ace-lab prepare", directory)
137
+ end
138
+
139
+ def ensure_existing(path, directory, stat, mode)
140
+ expected_uid = expected_owner_uid(directory)
141
+ if directory["owner"] && expected_uid.nil?
142
+ return owner_unavailable(path, directory)
143
+ end
144
+
145
+ if stat.uid != expected_uid
146
+ owner = directory["owner"] || "installer"
147
+ return failure(path,
148
+ "directory '#{path}' exists but is owned by uid #{stat.uid} " \
149
+ "(expected account '#{owner}'); refusing takeover — resolve ownership manually " \
150
+ "or rerun ace-lab prepare with sudo", directory)
151
+ end
152
+
153
+ if (stat.mode & 0o7777) == mode
154
+ return base(path, directory).merge("status" => "exists", "message" => nil)
155
+ end
156
+
157
+ return base(path, directory).merge("status" => "would_fix_mode", "message" => nil) if dry_run
158
+
159
+ # Stale-stat re-check: the owner-matching lstat above may be
160
+ # outdated by the time chmod runs; re-lstat and refuse unless
161
+ # the leaf is still a real, owner-matching directory.
162
+ fresh = lstat(path)
163
+ unless fresh && fresh.directory? && fresh.uid == expected_uid
164
+ return failure(path,
165
+ "directory '#{path}' changed while preparing; refusing — rerun ace-lab prepare", directory)
166
+ end
167
+
168
+ FileUtils.chmod(mode, path)
169
+ base(path, directory).merge("status" => "mode_fixed", "message" => nil)
170
+ end
171
+
172
+ def lstat(path)
173
+ File.lstat(path)
174
+ rescue Errno::ENOENT, Errno::ENOTDIR
175
+ nil
176
+ end
177
+
178
+ def owner_unavailable(path, directory, remedy = nil)
179
+ remedy ||= if dry_run
180
+ "the account phase would create it; ownership of existing '#{path}' cannot be " \
181
+ "predicted before the account exists — rerun prepare --dry-run after the account phase"
182
+ else
183
+ "fix the account phase first"
184
+ end
185
+ failure(path,
186
+ "owner account '#{directory["owner"]}' is not available on this machine; " \
187
+ "#{remedy}", directory)
188
+ end
189
+
190
+ def chown_target(directory)
191
+ return nil unless directory["owner"]
192
+
193
+ account_lookup.call(directory["owner"])
194
+ end
195
+
196
+ def chown_to_owner(path, directory, entry)
197
+ FileUtils.chown(entry.uid, entry.gid, path) if entry
198
+ end
199
+
200
+ def lookup_passwd(username)
201
+ Etc.getpwnam(username)
202
+ rescue ArgumentError
203
+ nil
204
+ end
205
+ end
206
+ end
207
+ end
208
+ end