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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +363 -92
- data/README.md +66 -100
- data/docs/usage.md +529 -404
- data/lib/ace/lab/atoms/install_schema.rb +373 -0
- data/lib/ace/lab/cli/commands/install.rb +161 -0
- data/lib/ace/lab/cli/commands/prepare.rb +118 -0
- data/lib/ace/lab/cli/commands/project_add.rb +101 -0
- data/lib/ace/lab/cli/commands/status.rb +109 -0
- data/lib/ace/lab/cli/commands/verify.rb +115 -0
- data/lib/ace/lab/cli.rb +21 -13
- data/lib/ace/lab/install_manifest_path.rb +23 -0
- data/lib/ace/lab/models/status_report.rb +38 -0
- data/lib/ace/lab/molecules/account_provisioner.rb +196 -0
- data/lib/ace/lab/molecules/config_state_classifier.rb +145 -0
- data/lib/ace/lab/molecules/directory_provisioner.rb +208 -0
- data/lib/ace/lab/molecules/grant_provisioner.rb +250 -0
- data/lib/ace/lab/molecules/grant_resolver.rb +49 -23
- data/lib/ace/lab/molecules/hitl_routes.rb +263 -0
- data/lib/ace/lab/molecules/install_manifest_loader.rb +64 -0
- data/lib/ace/lab/molecules/key_provisioner.rb +736 -0
- data/lib/ace/lab/molecules/lab_state_scanner.rb +55 -0
- data/lib/ace/lab/molecules/overseer_provisioner.rb +188 -0
- data/lib/ace/lab/molecules/project_registry.rb +251 -0
- data/lib/ace/lab/molecules/repository_provisioner.rb +141 -0
- data/lib/ace/lab/molecules/role_verifier.rb +223 -0
- data/lib/ace/lab/organisms/install.rb +213 -0
- data/lib/ace/lab/organisms/prepare.rb +156 -0
- data/lib/ace/lab/organisms/project_add.rb +170 -0
- data/lib/ace/lab/organisms/status_service.rb +70 -0
- data/lib/ace/lab/organisms/verify.rb +768 -0
- data/lib/ace/lab/version.rb +1 -1
- data/lib/ace/lab.rb +34 -6
- metadata +34 -102
- data/lib/ace/lab/atoms/protected_publication_input.rb +0 -50
- data/lib/ace/lab/atoms/protected_workspace_prune_input.rb +0 -114
- data/lib/ace/lab/atoms/protected_workspace_prune_preview.rb +0 -86
- data/lib/ace/lab/atoms/service_input.rb +0 -92
- data/lib/ace/lab/cli/commands/assignment.rb +0 -45
- data/lib/ace/lab/cli/commands/service.rb +0 -240
- data/lib/ace/lab/models/service_receipt.rb +0 -18
- data/lib/ace/lab/molecules/hitl_authorizer.rb +0 -63
- data/lib/ace/lab/molecules/ordinary_admission_resolver.rb +0 -131
- data/lib/ace/lab/molecules/ordinary_listener.rb +0 -262
- data/lib/ace/lab/molecules/ordinary_merge_read.rb +0 -45
- data/lib/ace/lab/molecules/ordinary_native_read.rb +0 -106
- data/lib/ace/lab/molecules/ordinary_owner_admission.rb +0 -139
- data/lib/ace/lab/molecules/ordinary_owner_config.rb +0 -232
- data/lib/ace/lab/molecules/ordinary_receiver_config.rb +0 -94
- data/lib/ace/lab/molecules/ordinary_socket.rb +0 -126
- data/lib/ace/lab/molecules/ordinary_worktree_read.rb +0 -216
- data/lib/ace/lab/molecules/protected_cleanup_owner_admission.rb +0 -163
- data/lib/ace/lab/molecules/protected_cleanup_owner_client.rb +0 -165
- data/lib/ace/lab/molecules/protected_cleanup_owner_identity.rb +0 -239
- data/lib/ace/lab/molecules/protected_publication_publisher.rb +0 -110
- data/lib/ace/lab/molecules/protected_service_handler.rb +0 -43
- data/lib/ace/lab/molecules/protected_service_policy.rb +0 -156
- data/lib/ace/lab/molecules/service_executor.rb +0 -99
- data/lib/ace/lab/molecules/service_policy.rb +0 -289
- data/lib/ace/lab/organisms/authority_composition.rb +0 -87
- data/lib/ace/lab/organisms/ordinary_assignment_client.rb +0 -149
- data/lib/ace/lab/organisms/ordinary_authority_composition.rb +0 -123
- data/lib/ace/lab/organisms/ordinary_merge_receiver.rb +0 -688
- data/lib/ace/lab/organisms/ordinary_merge_request.rb +0 -456
- data/lib/ace/lab/organisms/protected_cleanup_owner.rb +0 -361
- data/lib/ace/lab/organisms/protected_service_client.rb +0 -129
- data/lib/ace/lab/organisms/protected_service_listener.rb +0 -181
- data/lib/ace/lab/organisms/protected_service_publication.rb +0 -268
- data/lib/ace/lab/organisms/protected_service_receiver.rb +0 -267
- data/lib/ace/lab/organisms/protected_service_recovery.rb +0 -185
- data/lib/ace/lab/organisms/protected_service_request.rb +0 -128
- data/lib/ace/lab/organisms/protected_service_worker.rb +0 -89
- 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/
|
|
12
|
-
require_relative "cli/commands/
|
|
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
|
-
["
|
|
30
|
-
["
|
|
31
|
-
["
|
|
32
|
-
["
|
|
33
|
-
["
|
|
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 "
|
|
58
|
-
register "
|
|
59
|
-
register "
|
|
60
|
-
register "
|
|
61
|
-
register "
|
|
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
|