ace-lab 0.8.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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +363 -92
  3. data/README.md +66 -100
  4. data/docs/usage.md +529 -404
  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 -13
  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 +49 -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 -102
  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 -45
  40. data/lib/ace/lab/cli/commands/service.rb +0 -240
  41. data/lib/ace/lab/models/service_receipt.rb +0 -18
  42. data/lib/ace/lab/molecules/hitl_authorizer.rb +0 -63
  43. data/lib/ace/lab/molecules/ordinary_admission_resolver.rb +0 -131
  44. data/lib/ace/lab/molecules/ordinary_listener.rb +0 -262
  45. data/lib/ace/lab/molecules/ordinary_merge_read.rb +0 -45
  46. data/lib/ace/lab/molecules/ordinary_native_read.rb +0 -106
  47. data/lib/ace/lab/molecules/ordinary_owner_admission.rb +0 -139
  48. data/lib/ace/lab/molecules/ordinary_owner_config.rb +0 -232
  49. data/lib/ace/lab/molecules/ordinary_receiver_config.rb +0 -94
  50. data/lib/ace/lab/molecules/ordinary_socket.rb +0 -126
  51. data/lib/ace/lab/molecules/ordinary_worktree_read.rb +0 -216
  52. data/lib/ace/lab/molecules/protected_cleanup_owner_admission.rb +0 -163
  53. data/lib/ace/lab/molecules/protected_cleanup_owner_client.rb +0 -165
  54. data/lib/ace/lab/molecules/protected_cleanup_owner_identity.rb +0 -239
  55. data/lib/ace/lab/molecules/protected_publication_publisher.rb +0 -110
  56. data/lib/ace/lab/molecules/protected_service_handler.rb +0 -43
  57. data/lib/ace/lab/molecules/protected_service_policy.rb +0 -156
  58. data/lib/ace/lab/molecules/service_executor.rb +0 -99
  59. data/lib/ace/lab/molecules/service_policy.rb +0 -289
  60. data/lib/ace/lab/organisms/authority_composition.rb +0 -87
  61. data/lib/ace/lab/organisms/ordinary_assignment_client.rb +0 -149
  62. data/lib/ace/lab/organisms/ordinary_authority_composition.rb +0 -123
  63. data/lib/ace/lab/organisms/ordinary_merge_receiver.rb +0 -688
  64. data/lib/ace/lab/organisms/ordinary_merge_request.rb +0 -456
  65. data/lib/ace/lab/organisms/protected_cleanup_owner.rb +0 -361
  66. data/lib/ace/lab/organisms/protected_service_client.rb +0 -129
  67. data/lib/ace/lab/organisms/protected_service_listener.rb +0 -181
  68. data/lib/ace/lab/organisms/protected_service_publication.rb +0 -268
  69. data/lib/ace/lab/organisms/protected_service_receiver.rb +0 -267
  70. data/lib/ace/lab/organisms/protected_service_recovery.rb +0 -185
  71. data/lib/ace/lab/organisms/protected_service_request.rb +0 -128
  72. data/lib/ace/lab/organisms/protected_service_worker.rb +0 -89
  73. data/lib/ace/lab/organisms/service_request_service.rb +0 -249
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "ace/support/cli"
5
+ require "ace/core"
6
+ require_relative "../../organisms/project_add"
7
+
8
+ module Ace
9
+ module Lab
10
+ module CLI
11
+ module Commands
12
+ class ProjectAdd < Ace::Support::Cli::Command
13
+ include Ace::Support::Cli::Base
14
+
15
+ SECTION_NAMERS = {
16
+ "repository" => ->(item) { item["path"] || item["location"] },
17
+ "overseer" => ->(item) { item["dir"] || item["location"] },
18
+ "registry" => ->(item) { item["path"] || item["location"] }
19
+ }.freeze
20
+
21
+ desc "Register a project and make it ready for team work " \
22
+ "(repository, private _overseer, lab config registry)"
23
+
24
+ example [
25
+ "atlas --path /Users/ace/lab/atlas --remote git@forge:atlas/atlas.git",
26
+ "atlas --path /Users/ace/lab/atlas --dry-run # Predict without mutating",
27
+ "atlas --path /Users/ace/lab/atlas --format json"
28
+ ]
29
+
30
+ argument :id, type: :string, required: true, desc: "Project id (topology id)"
31
+
32
+ option :path, type: :string, required: true, desc: "Absolute project path"
33
+ option :remote, type: :string, desc: "Origin remote URL (git clone source)"
34
+ option :label, type: :string, desc: "Display label recorded in the registry"
35
+ option :dry_run, type: :boolean, desc: "Predict changes without applying them"
36
+ option :format, type: :string, desc: "Output format (table, json; default: table)"
37
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress output"
38
+
39
+ def initialize(project_add: nil)
40
+ super()
41
+ @project_add = project_add
42
+ end
43
+
44
+ def call(**options)
45
+ format = options[:format] || "table"
46
+ unless %w[table json].include?(format)
47
+ raise Ace::Support::Cli::Error,
48
+ "--format \"#{format}\" is not supported; table or json"
49
+ end
50
+
51
+ path = File.expand_path(options[:path])
52
+ organism = @project_add || Organisms::ProjectAdd.new
53
+ report = organism.call(
54
+ id: options[:id], path: path, remote: options[:remote],
55
+ label: options[:label], dry_run: options[:dry_run] || false
56
+ )
57
+ render(report, format: format, quiet: options[:quiet])
58
+
59
+ unless report["ok"] || report["dry_run"]
60
+ raise Ace::Support::Cli::Error, failure_summary(report)
61
+ end
62
+ rescue Ace::Lab::InvalidConfigurationError => e
63
+ raise Ace::Support::Cli::Error, e.message
64
+ end
65
+
66
+ private
67
+
68
+ def render(report, format:, quiet:)
69
+ return if quiet
70
+
71
+ puts format == "json" ? JSON.generate(report) : render_table(report)
72
+ end
73
+
74
+ def render_table(report)
75
+ lines = ["Project: #{report['project']} path: #{report['path']}" \
76
+ "#{" remote: #{report['remote']}" if report['remote']}" \
77
+ "#{" (dry run)" if report['dry_run']}"]
78
+ SECTION_NAMERS.each do |section, namer|
79
+ lines << "#{section.capitalize}:"
80
+ item = report[section]
81
+ detail = item["message"] ? " #{item['message']}" : ""
82
+ lines << " #{namer.call(item).to_s.ljust(34)} #{item['status']}#{detail}"
83
+ end
84
+ lines << "Result: #{report['ok'] ? 'ok' : 'FAILED'}"
85
+ lines.join("\n")
86
+ end
87
+
88
+ def failure_summary(report)
89
+ failures = SECTION_NAMERS.map do |section, namer|
90
+ item = report[section]
91
+ next unless item["status"] == "failed"
92
+
93
+ "#{section}/#{namer.call(item)}: #{item['message']}"
94
+ end.compact
95
+ "project add failed (#{failures.size} step(s)): #{failures.join('; ')}"
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "ace/support/cli"
5
+ require "ace/core"
6
+ require_relative "support"
7
+
8
+ module Ace
9
+ module Lab
10
+ module CLI
11
+ module Commands
12
+ class Status < Ace::Support::Cli::Command
13
+ include Ace::Support::Cli::Base
14
+ include Runtime
15
+
16
+ desc <<~DESC.strip
17
+ Show lab configuration and local file state (read-only)
18
+
19
+ Distinguishes a valid empty set, missing configuration, and a
20
+ source error. Reads files only: no services are started and no
21
+ installer is required. The enriched worktree view stays
22
+ ace-git-worktree list.
23
+ DESC
24
+
25
+ example ["--format json # Full report as JSON"]
26
+
27
+ option :format, type: :string, default: "table", desc: "Output format: table or json"
28
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress output"
29
+
30
+ def initialize(status_service: nil)
31
+ @status_service = status_service
32
+ end
33
+
34
+ def call(**options)
35
+ format = options[:format] || "table"
36
+ cli_error("Invalid format: #{format} (use table or json)") unless %w[table json].include?(format)
37
+
38
+ report = status_service.report
39
+ unless options[:quiet]
40
+ puts(format == "json" ? JSON.generate(report.to_h) : render_table(report))
41
+ end
42
+ # Full report first; source failures then fail the command
43
+ unless report.ok?
44
+ raise Ace::Support::Cli::Error, "lab status reported source errors: #{first_error(report)}"
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def status_service
51
+ @status_service ||= Organisms::StatusService.from_config
52
+ end
53
+
54
+ def first_error(report)
55
+ report.config[:error] ||
56
+ report.runtime_config[:error] ||
57
+ report.state.find { |location| location[:state] == :error }&.dig(:note) ||
58
+ "unreadable state location"
59
+ end
60
+
61
+ def render_table(report)
62
+ [config_line(report), runtime_line(report), "Local state (root: #{report.root}):"]
63
+ .concat(report.state.map { |location| location_line(location) })
64
+ .join("\n")
65
+ end
66
+
67
+ def config_line(report)
68
+ config = report.config
69
+ case config[:state]
70
+ when :configured
71
+ "Lab configuration: configured — #{config[:source_path]} " \
72
+ "(topology: #{config[:topology][:projects]} project(s), " \
73
+ "#{config[:topology][:agents]} agent(s), #{config[:topology][:services]} service(s))"
74
+ when :defaults_only
75
+ "Lab configuration: missing (defaults only; no .ace/lab/config.yml or ~/.ace/lab/config.yml)"
76
+ else
77
+ "Lab configuration: invalid — #{config[:error]}"
78
+ end
79
+ end
80
+
81
+ def runtime_line(report)
82
+ runtime = report.runtime_config
83
+ case runtime[:state]
84
+ when :valid
85
+ origin = runtime[:path] || runtime[:source]
86
+ "Runtime configuration: #{runtime[:runtime] || '(value unset)'} (#{origin})"
87
+ when :invalid
88
+ "Runtime configuration: invalid — #{runtime[:error]}"
89
+ else
90
+ "Runtime configuration: none (no config file, ACE_RUNTIME unset)"
91
+ end
92
+ end
93
+
94
+ def location_line(location)
95
+ state =
96
+ case location[:state]
97
+ when :present then format("present (%s)", location[:entries])
98
+ when :prepared then "prepared"
99
+ when :error then "ERROR"
100
+ else "not present"
101
+ end
102
+ note = location[:note] ? " (#{location[:note]})" : ""
103
+ format(" %-12s %-16s %s%s", location[:key], state, location[:path], note)
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "ace/support/cli"
5
+ require "ace/core"
6
+ require_relative "../../organisms/verify"
7
+
8
+ module Ace
9
+ module Lab
10
+ module CLI
11
+ module Commands
12
+ class Verify < Ace::Support::Cli::Command
13
+ include Ace::Support::Cli::Base
14
+
15
+ SECTION_LABELS = {
16
+ "accounts" => "account", "directories" => "directory", "keys" => "account",
17
+ "roles" => "id", "repositories" => "project", "grants" => "principal",
18
+ "registry" => "project", "overseer" => "project",
19
+ "adapters" => "adapter", "tools" => "tool"
20
+ }.freeze
21
+
22
+ desc "Verify the installed lab's readiness under the target accounts (read-only): " \
23
+ "accounts, directories, key pairs, role instructions, repository origins and " \
24
+ "target-executor access, grant-based project visibility, private _overseer " \
25
+ "folders, configured communication/HITL adapters, required tools"
26
+
27
+ example [
28
+ "--format json # Machine-readable report for the overseer / next tool",
29
+ " # exit 0 only when every check passes or is unknown"
30
+ ]
31
+
32
+ option :format, type: :string, desc: "Output format (table, json; default: table)"
33
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress output"
34
+
35
+ def initialize(verifier: nil)
36
+ super()
37
+ @verifier = verifier
38
+ end
39
+
40
+ def call(**options)
41
+ format = options[:format] || "table"
42
+ unless %w[table json].include?(format)
43
+ raise Ace::Support::Cli::Error,
44
+ "--format \"#{format}\" is not supported; table or json"
45
+ end
46
+
47
+ report = (@verifier || Organisms::Verify.new).call
48
+ render(report, format: format, quiet: options[:quiet])
49
+
50
+ # A not-ready lab never exits 0: the report renders first
51
+ # (failure beside healthy data — both visible), then the
52
+ # failure list names every failed check concretely.
53
+ unless report["ready"]
54
+ raise Ace::Support::Cli::Error, failure_summary(report)
55
+ end
56
+ rescue Ace::Lab::InvalidConfigurationError => e
57
+ raise Ace::Support::Cli::Error, e.message
58
+ end
59
+
60
+ private
61
+
62
+ def render(report, format:, quiet:)
63
+ return if quiet
64
+
65
+ puts format == "json" ? JSON.generate(report) : render_table(report)
66
+ end
67
+
68
+ def render_table(report)
69
+ counts = report["counts"]
70
+ lines = ["Machine: #{report['machine']} manifest: #{report['manifest']}"]
71
+ report["checks"].each do |section, checks|
72
+ lines << "#{section.capitalize}:"
73
+ if checks.empty?
74
+ lines << " (none declared)"
75
+ next
76
+ end
77
+ checks.each do |check|
78
+ label = check[SECTION_LABELS.fetch(section, "reason")] || check["path"] || section
79
+ detail = detail_of(check)
80
+ lines << " #{label.to_s.ljust(34)} #{check['status'].upcase}#{detail}"
81
+ lines << " #{check['reason']}" if check["reason"]
82
+ end
83
+ end
84
+ lines << "Checks: #{counts['pass']} pass, #{counts['fail']} failed, " \
85
+ "#{counts['unknown']} unknown"
86
+ lines << "Result: #{report['ready'] ? 'ready' : 'NOT READY'}"
87
+ lines.join("\n")
88
+ end
89
+
90
+ def detail_of(check)
91
+ parts = []
92
+ parts << "uid #{check['uid']}" if check.key?("uid")
93
+ parts << "shell #{check['shell']}" if check["shell"]
94
+ parts << "fingerprint #{check['fingerprint']}" if check["fingerprint"]
95
+ parts << "origin #{check['origin']}" if check["origin"]
96
+ parts << "projects #{check['projects'].join(', ')}" if check["projects"].is_a?(Array)
97
+ parts.empty? ? "" : " (#{parts.join(', ')})"
98
+ end
99
+
100
+ def failure_summary(report)
101
+ failures = report["checks"].flat_map do |section, checks|
102
+ checks.select { |check| check["status"] == "fail" }
103
+ .map { |check| "#{section}/#{check_name(check, section)}: #{check['reason']}" }
104
+ end
105
+ "lab is not ready (#{failures.size} failed check(s)): #{failures.join('; ')}"
106
+ end
107
+
108
+ def check_name(check, section)
109
+ check[SECTION_LABELS.fetch(section, "reason")] || check["path"] || section
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
data/lib/ace/lab/cli.rb CHANGED
@@ -8,8 +8,11 @@ require_relative "cli/commands/agents"
8
8
  require_relative "cli/commands/services"
9
9
  require_relative "cli/commands/resolve"
10
10
  require_relative "cli/commands/route"
11
- require_relative "cli/commands/service"
12
- require_relative "cli/commands/assignment"
11
+ require_relative "cli/commands/status"
12
+ require_relative "cli/commands/prepare"
13
+ require_relative "cli/commands/project_add"
14
+ require_relative "cli/commands/install"
15
+ require_relative "cli/commands/verify"
13
16
 
14
17
  module Ace
15
18
  module Lab
@@ -26,18 +29,23 @@ module Ace
26
29
  ["services", "List lab services in a project by stable ID"],
27
30
  ["resolve", "Resolve one lab entry by exact stable ID"],
28
31
  ["route", "Select a configured capable service in a project"],
29
- ["service request", "Request one authorized service operation"],
30
- ["service status", "Read a service request outcome"],
31
- ["service listen", "Serve the ordinary fixed merge receiver on its configured socket"],
32
- ["assignment prepare", "Retain reviewed task input for ordinary Assign registration"],
33
- ["assignment request", "Send one retained operation to the installed ordinary Assign owner"]
32
+ ["status", "Show lab configuration and local file state (read-only)"],
33
+ ["prepare", "Prepare accounts, directories, permissions, keys and roles from the trusted install manifest"],
34
+ ["project add", "Register a project and make it ready for team work (repository, _overseer, lab config)"],
35
+ ["install", "Perform the full new-lab installation from the trusted install manifest"],
36
+ ["verify", "Verify the installed lab's readiness under the target accounts (read-only)"]
34
37
  ].freeze
35
38
 
36
39
  HELP_EXAMPLES = [
37
40
  "ace-lab projects --format json",
38
41
  "ace-lab agents --project atlas --format json",
39
42
  "ace-lab resolve --id atlas-planner --format json",
40
- "ace-lab route --project atlas --capability search --format json"
43
+ "ace-lab route --project atlas --capability search --format json",
44
+ "ace-lab status --format json",
45
+ "ace-lab prepare --dry-run",
46
+ "ace-lab project add atlas --path /Users/ace/lab/atlas --remote git@forge:atlas/atlas.git",
47
+ "ace-lab install --dry-run",
48
+ "ace-lab verify --format json"
41
49
  ].freeze
42
50
 
43
51
  # Start the CLI
@@ -54,11 +62,11 @@ module Ace
54
62
  register "services", CLI::Commands::Services.new
55
63
  register "resolve", CLI::Commands::Resolve.new
56
64
  register "route", CLI::Commands::Route.new
57
- register "service request", CLI::Commands::Service::Request.new
58
- register "service status", CLI::Commands::Service::Status.new
59
- register "service listen", CLI::Commands::Service::Listen.new
60
- register "assignment prepare", CLI::Commands::Assignment::Prepare.new
61
- register "assignment request", CLI::Commands::Assignment::Request.new
65
+ register "status", CLI::Commands::Status.new
66
+ register "prepare", CLI::Commands::Prepare.new
67
+ register "project add", CLI::Commands::ProjectAdd.new
68
+ register "install", CLI::Commands::Install.new
69
+ register "verify", CLI::Commands::Verify.new
62
70
 
63
71
  # Register version command
64
72
  version_cmd = Ace::Support::Cli::VersionCommand.build(
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Lab
5
+ # The trusted install manifest is a deployment document at a FIXED path
6
+ # — never in the caller-writable configuration cascade and never at a
7
+ # caller-selected location. It must be root-owned and not
8
+ # group/world-writable along its whole path, exactly like
9
+ # AUTHORIZATION_PATH; InstallManifestLoader verifies through
10
+ # GrantResolver.read_verified and fails closed. A caller-writable
11
+ # manifest could provision accounts, directories and keys, i.e.
12
+ # manufacture privileges.
13
+ INSTALL_MANIFEST_PATH = "/etc/lab/ace-lab/install.yml"
14
+
15
+ # Path of the trusted install manifest. A method (not a bare constant
16
+ # reference) so tests stub the seam without any caller-controllable
17
+ # production override; the CLI deliberately exposes no --manifest flag.
18
+ # @return [String]
19
+ def self.install_manifest_path
20
+ INSTALL_MANIFEST_PATH
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Lab
5
+ module Models
6
+ # Immutable value object carrying the assembled status report.
7
+ # ok? is false exactly when a configuration source is invalid or a
8
+ # state location failed to read — an empty-but-valid lab is ok.
9
+ class StatusReport
10
+ attr_reader :config, :runtime_config, :state, :root
11
+
12
+ def initialize(config:, runtime_config:, state:, root:)
13
+ @config = config.freeze
14
+ @runtime_config = runtime_config.freeze
15
+ @state = state.map(&:freeze).freeze
16
+ @root = root
17
+ freeze
18
+ end
19
+
20
+ def ok?
21
+ @config[:state] != :invalid &&
22
+ @runtime_config[:state] != :invalid &&
23
+ @state.none? { |location| location[:state] == :error }
24
+ end
25
+
26
+ def to_h
27
+ {
28
+ config: @config,
29
+ runtime_config: @runtime_config,
30
+ state: @state,
31
+ root: @root,
32
+ ok: ok?
33
+ }
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -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