ace-lab 0.1.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 +7 -0
- data/.ace-defaults/lab/config.yml +41 -0
- data/CHANGELOG.md +22 -0
- data/LICENSE +21 -0
- data/README.md +110 -0
- data/Rakefile +12 -0
- data/docs/usage.md +178 -0
- data/exe/ace-lab +19 -0
- data/lib/ace/lab/atoms/binding_freshness.rb +33 -0
- data/lib/ace/lab/atoms/public_projection.rb +98 -0
- data/lib/ace/lab/atoms/topology_schema.rb +280 -0
- data/lib/ace/lab/cli/commands/agents.rb +43 -0
- data/lib/ace/lab/cli/commands/projects.rb +43 -0
- data/lib/ace/lab/cli/commands/resolve.rb +44 -0
- data/lib/ace/lab/cli/commands/route.rb +48 -0
- data/lib/ace/lab/cli/commands/services.rb +43 -0
- data/lib/ace/lab/cli/commands/support.rb +57 -0
- data/lib/ace/lab/cli.rb +71 -0
- data/lib/ace/lab/models/query_result.rb +55 -0
- data/lib/ace/lab/models/runtime_binding.rb +44 -0
- data/lib/ace/lab/models/topology_entry.rb +74 -0
- data/lib/ace/lab/molecules/caller_authorizer.rb +63 -0
- data/lib/ace/lab/molecules/capability_router.rb +69 -0
- data/lib/ace/lab/molecules/exact_resolver.rb +56 -0
- data/lib/ace/lab/molecules/grant_resolver.rb +180 -0
- data/lib/ace/lab/molecules/inventory_query.rb +78 -0
- data/lib/ace/lab/molecules/topology_loader.rb +49 -0
- data/lib/ace/lab/organisms/topology_service.rb +105 -0
- data/lib/ace/lab/version.rb +7 -0
- data/lib/ace/lab.rb +136 -0
- metadata +203 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Lab
|
|
7
|
+
module Atoms
|
|
8
|
+
# Pure schema validation and normalization for ace-lab topology
|
|
9
|
+
# configuration (spec 8wq.t.1w4). Takes the ADR-022-resolved config hash
|
|
10
|
+
# and returns a normalized structure; raises
|
|
11
|
+
# Ace::Lab::InvalidConfigurationError on any violation (duplicate IDs,
|
|
12
|
+
# unknown project references, malformed capabilities, invalid defaults,
|
|
13
|
+
# unusable endpoints, unsupported schema version).
|
|
14
|
+
#
|
|
15
|
+
# Normalization: strings are stripped, capabilities are lowercased,
|
|
16
|
+
# binding state is lowercased. Unknown keys are ignored.
|
|
17
|
+
#
|
|
18
|
+
# Error messages use positional field locations (e.g.
|
|
19
|
+
# "topology.agents[0].project") and never echo configured values:
|
|
20
|
+
# validation runs before authorization, so messages must not disclose
|
|
21
|
+
# topology to unauthorized callers (review round 2, R2).
|
|
22
|
+
module TopologySchema
|
|
23
|
+
SCHEMA_VERSION = 1
|
|
24
|
+
ENDPOINT_KINDS = %w[http https].freeze
|
|
25
|
+
# Bindings are entry-typed: agents run in runtimes, services are
|
|
26
|
+
# service endpoints (review round 4, F1)
|
|
27
|
+
AGENT_BINDING_KINDS = %w[runtime].freeze
|
|
28
|
+
SERVICE_BINDING_KINDS = %w[service].freeze
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
# Validate and normalize the full lab configuration
|
|
32
|
+
#
|
|
33
|
+
# @param config [Hash] ADR-022-resolved configuration
|
|
34
|
+
# @return [Hash] Normalized configuration
|
|
35
|
+
# @raise [Ace::Lab::InvalidConfigurationError]
|
|
36
|
+
def normalize!(config)
|
|
37
|
+
raise invalid("configuration must be a mapping") unless config.is_a?(Hash)
|
|
38
|
+
|
|
39
|
+
version = config["schema_version"]
|
|
40
|
+
unless version == SCHEMA_VERSION
|
|
41
|
+
raise invalid("unsupported schema_version (expected #{SCHEMA_VERSION})")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
topology = normalized_topology(config["topology"])
|
|
45
|
+
authorization = normalized_authorization(config["authorization"], topology)
|
|
46
|
+
|
|
47
|
+
{
|
|
48
|
+
"schema_version" => SCHEMA_VERSION,
|
|
49
|
+
"authorization" => authorization,
|
|
50
|
+
"topology" => topology
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def normalized_topology(raw)
|
|
57
|
+
raise invalid("topology must be a mapping") unless raw.nil? || raw.is_a?(Hash)
|
|
58
|
+
|
|
59
|
+
raw ||= {}
|
|
60
|
+
projects = require_array(raw["projects"], "topology.projects").map.with_index do |entry, i|
|
|
61
|
+
normalized_project(entry, "topology.projects[#{i}]")
|
|
62
|
+
end
|
|
63
|
+
project_ids = projects.map { |p| p["id"] }
|
|
64
|
+
agents = require_array(raw["agents"], "topology.agents").map.with_index do |entry, i|
|
|
65
|
+
normalized_agent(entry, "topology.agents[#{i}]", project_ids)
|
|
66
|
+
end
|
|
67
|
+
services = require_array(raw["services"], "topology.services").map.with_index do |entry, i|
|
|
68
|
+
normalized_service(entry, "topology.services[#{i}]", project_ids)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
ensure_globally_unique!(projects, agents, services)
|
|
72
|
+
|
|
73
|
+
{"projects" => projects, "agents" => agents, "services" => services}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def normalized_project(entry, context)
|
|
77
|
+
raise invalid("#{context} must be a mapping") unless entry.is_a?(Hash)
|
|
78
|
+
|
|
79
|
+
{
|
|
80
|
+
"id" => require_id(entry["id"], "#{context}.id"),
|
|
81
|
+
"label" => optional_string(entry["label"], "#{context}.label")
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def normalized_agent(entry, context, project_ids)
|
|
86
|
+
raise invalid("#{context} must be a mapping") unless entry.is_a?(Hash)
|
|
87
|
+
|
|
88
|
+
id = require_id(entry["id"], "#{context}.id")
|
|
89
|
+
project = require_project_ref(entry["project"], "#{context}.project", project_ids)
|
|
90
|
+
|
|
91
|
+
{
|
|
92
|
+
"id" => id,
|
|
93
|
+
"project" => project,
|
|
94
|
+
"label" => optional_string(entry["label"], "#{context}.label"),
|
|
95
|
+
"role" => require_non_empty(entry["role"], "#{context}.role must be a non-empty string"),
|
|
96
|
+
"capabilities" => normalize_capabilities(entry["capabilities"], "#{context}.capabilities"),
|
|
97
|
+
"binding" => normalize_binding(entry["binding"], "#{context}.binding", AGENT_BINDING_KINDS)
|
|
98
|
+
}
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def normalized_service(entry, context, project_ids)
|
|
102
|
+
raise invalid("#{context} must be a mapping") unless entry.is_a?(Hash)
|
|
103
|
+
|
|
104
|
+
id = require_id(entry["id"], "#{context}.id")
|
|
105
|
+
project = require_project_ref(entry["project"], "#{context}.project", project_ids)
|
|
106
|
+
capabilities = normalize_capabilities(entry["capabilities"], "#{context}.capabilities")
|
|
107
|
+
|
|
108
|
+
{
|
|
109
|
+
"id" => id,
|
|
110
|
+
"project" => project,
|
|
111
|
+
"label" => optional_string(entry["label"], "#{context}.label"),
|
|
112
|
+
"capabilities" => capabilities,
|
|
113
|
+
"default_for" => normalize_default_for(entry["default_for"], "#{context}.default_for", capabilities),
|
|
114
|
+
"endpoint" => normalize_endpoint(entry["endpoint"], "#{context}.endpoint"),
|
|
115
|
+
"binding" => normalize_binding(entry["binding"], "#{context}.binding", SERVICE_BINDING_KINDS)
|
|
116
|
+
}
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def ensure_globally_unique!(projects, agents, services)
|
|
120
|
+
seen = Hash.new { |h, k| h[k] = [] }
|
|
121
|
+
projects.each_with_index { |e, i| seen[e["id"]] << "topology.projects[#{i}]" }
|
|
122
|
+
agents.each_with_index { |e, i| seen[e["id"]] << "topology.agents[#{i}]" }
|
|
123
|
+
services.each_with_index { |e, i| seen[e["id"]] << "topology.services[#{i}]" }
|
|
124
|
+
|
|
125
|
+
_id, locations = seen.find { |_key, list| list.length > 1 }
|
|
126
|
+
return unless locations
|
|
127
|
+
|
|
128
|
+
raise invalid("duplicate stable id: #{locations.join(" and ")} define the same id; " \
|
|
129
|
+
"stable IDs must be globally unique")
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def normalized_authorization(raw, topology)
|
|
133
|
+
raise invalid("authorization must be a mapping") unless raw.nil? || raw.is_a?(Hash)
|
|
134
|
+
|
|
135
|
+
raw ||= {}
|
|
136
|
+
principals_raw = raw["principals"]
|
|
137
|
+
unless principals_raw.nil? || principals_raw.is_a?(Hash)
|
|
138
|
+
raise invalid("authorization.principals must be a mapping")
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
project_ids = topology["projects"].map { |p| p["id"] }
|
|
142
|
+
principals = (principals_raw || {}).map.with_index do |(principal, policy), i|
|
|
143
|
+
context = "authorization.principals[#{i}]"
|
|
144
|
+
name = require_non_empty(principal, "#{context} name must be a non-empty string")
|
|
145
|
+
raise invalid("#{context} policy must be a mapping") unless policy.is_a?(Hash)
|
|
146
|
+
|
|
147
|
+
allowed = require_array(policy["projects"], "#{context}.projects").map.with_index do |project, j|
|
|
148
|
+
unless project_ids.include?(project)
|
|
149
|
+
raise invalid("#{context}.projects[#{j}] references an unknown project")
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
project
|
|
153
|
+
end
|
|
154
|
+
[name, {"projects" => allowed.uniq}]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
{"principals" => principals.to_h}
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def require_id(value, context)
|
|
161
|
+
require_non_empty(value, "#{context} must be a non-empty string")
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Malformed collections must classify as invalid_configuration, not
|
|
165
|
+
# crash with NoMethodError/TypeError mid-iteration (review R2)
|
|
166
|
+
def require_array(value, context)
|
|
167
|
+
return [] if value.nil?
|
|
168
|
+
raise invalid("#{context} must be an array") unless value.is_a?(Array)
|
|
169
|
+
|
|
170
|
+
value
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def require_project_ref(value, context, project_ids)
|
|
174
|
+
project = require_non_empty(value, "#{context} must be a non-empty string")
|
|
175
|
+
unless project_ids.include?(project)
|
|
176
|
+
raise invalid("#{context} references an unknown project")
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
project
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def normalize_capabilities(value, context)
|
|
183
|
+
unless value.is_a?(Array)
|
|
184
|
+
raise invalid("#{context} must be a non-empty array of strings")
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
capabilities = value.map do |capability|
|
|
188
|
+
require_non_empty(capability, "#{context} entry must be a non-empty string").downcase
|
|
189
|
+
end
|
|
190
|
+
unless capabilities.any?
|
|
191
|
+
raise invalid("#{context} must be a non-empty array of strings")
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
capabilities.uniq
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def normalize_default_for(value, context, capabilities)
|
|
198
|
+
return [] if value.nil?
|
|
199
|
+
unless value.is_a?(Array)
|
|
200
|
+
raise invalid("#{context} must be an array of capabilities")
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
value.map do |capability|
|
|
204
|
+
normalized = require_non_empty(capability, "#{context} entry must be a non-empty string").downcase
|
|
205
|
+
unless capabilities.include?(normalized)
|
|
206
|
+
raise invalid("#{context} entry does not match a declared capability")
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
normalized
|
|
210
|
+
end.uniq
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def normalize_endpoint(value, context)
|
|
214
|
+
unless value.is_a?(Hash)
|
|
215
|
+
raise invalid("#{context} must be a mapping with kind and url")
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
kind = require_non_empty(value["kind"], "#{context}.kind must be a non-empty string").downcase
|
|
219
|
+
unless ENDPOINT_KINDS.include?(kind)
|
|
220
|
+
raise invalid("#{context}.kind must be one of: #{ENDPOINT_KINDS.join(", ")}")
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
url = require_non_empty(value["url"], "#{context}.url must be a non-empty string")
|
|
224
|
+
validate_endpoint_url(url, context)
|
|
225
|
+
|
|
226
|
+
{"kind" => kind, "url" => url}
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# A malformed endpoint must fail configuration loading, never
|
|
230
|
+
# become a routing candidate with an empty public identity
|
|
231
|
+
# (review round 2, R1). Port bounds included: Ruby's URI parser
|
|
232
|
+
# accepts out-of-range ports (review round 4, F2).
|
|
233
|
+
def validate_endpoint_url(url, context)
|
|
234
|
+
uri = URI.parse(url)
|
|
235
|
+
usable = (uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)) &&
|
|
236
|
+
uri.host && !uri.host.empty? &&
|
|
237
|
+
uri.port.is_a?(Integer) && (1..65_535).cover?(uri.port)
|
|
238
|
+
raise invalid("#{context}.url must be an absolute http(s) URL with a host and a valid port") unless usable
|
|
239
|
+
rescue URI::Error, ArgumentError
|
|
240
|
+
raise invalid("#{context}.url must be an absolute http(s) URL with a host and a valid port")
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def normalize_binding(value, context, allowed_kinds)
|
|
244
|
+
unless value.is_a?(Hash)
|
|
245
|
+
raise invalid("#{context} must be a mapping")
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
kind = require_non_empty(value["kind"], "#{context}.kind must be a non-empty string").downcase
|
|
249
|
+
unless allowed_kinds.include?(kind)
|
|
250
|
+
raise invalid("#{context}.kind must be one of: #{allowed_kinds.join(", ")}")
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
{
|
|
254
|
+
"kind" => kind,
|
|
255
|
+
"state" => optional_string(value["state"], "#{context}.state")&.downcase,
|
|
256
|
+
"instance_id" => optional_string(value["instance_id"], "#{context}.instance_id"),
|
|
257
|
+
"attested_instance_id" => optional_string(value["attested_instance_id"], "#{context}.attested_instance_id")
|
|
258
|
+
}
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def require_non_empty(value, message)
|
|
262
|
+
raise invalid(message) unless value.is_a?(String) && !value.strip.empty?
|
|
263
|
+
|
|
264
|
+
value.strip
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def optional_string(value, context)
|
|
268
|
+
return nil if value.nil?
|
|
269
|
+
|
|
270
|
+
require_non_empty(value, "#{context} must be a non-empty string")
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def invalid(message)
|
|
274
|
+
Ace::Lab::InvalidConfigurationError.new("invalid lab configuration: #{message}")
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
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 Agents < Ace::Support::Cli::Command
|
|
13
|
+
include Ace::Support::Cli::Base
|
|
14
|
+
include Runtime
|
|
15
|
+
|
|
16
|
+
desc <<~DESC.strip
|
|
17
|
+
List lab agents in a project by stable ID
|
|
18
|
+
|
|
19
|
+
Agents are disclosed only to callers authorized for the project;
|
|
20
|
+
runtime pane/session bindings are never exposed.
|
|
21
|
+
DESC
|
|
22
|
+
|
|
23
|
+
example ["--project atlas --format json"]
|
|
24
|
+
|
|
25
|
+
option :project, type: :string, required: true, desc: "Project stable ID"
|
|
26
|
+
option :format, type: :string, desc: "Output format (json only)"
|
|
27
|
+
option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress output"
|
|
28
|
+
|
|
29
|
+
def initialize(topology_service: nil)
|
|
30
|
+
@topology_service = topology_service
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def call(project:, **options)
|
|
34
|
+
ensure_json_format!(options)
|
|
35
|
+
reject_identity_flags!(options)
|
|
36
|
+
|
|
37
|
+
emit_result(topology_service.agents(project: project), quiet: options[:quiet])
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
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 Projects < Ace::Support::Cli::Command
|
|
13
|
+
include Ace::Support::Cli::Base
|
|
14
|
+
include Runtime
|
|
15
|
+
|
|
16
|
+
desc <<~DESC.strip
|
|
17
|
+
List lab projects visible to the verified caller
|
|
18
|
+
|
|
19
|
+
Only projects covered by a configured authorization principal are
|
|
20
|
+
returned; a caller with no principal receives a classified
|
|
21
|
+
unauthorized error.
|
|
22
|
+
DESC
|
|
23
|
+
|
|
24
|
+
example ["--format json"]
|
|
25
|
+
|
|
26
|
+
option :format, type: :string, desc: "Output format (json only)"
|
|
27
|
+
option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress output"
|
|
28
|
+
|
|
29
|
+
def initialize(topology_service: nil)
|
|
30
|
+
@topology_service = topology_service
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def call(**options)
|
|
34
|
+
ensure_json_format!(options)
|
|
35
|
+
reject_identity_flags!(options)
|
|
36
|
+
|
|
37
|
+
emit_result(topology_service.projects, quiet: options[:quiet])
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
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 Resolve < Ace::Support::Cli::Command
|
|
13
|
+
include Ace::Support::Cli::Base
|
|
14
|
+
include Runtime
|
|
15
|
+
|
|
16
|
+
desc <<~DESC.strip
|
|
17
|
+
Resolve one lab entry by exact stable ID
|
|
18
|
+
|
|
19
|
+
Labels are display values and never resolve; an unknown ID is a
|
|
20
|
+
classified missing error, a replaced process a classified stale
|
|
21
|
+
error.
|
|
22
|
+
DESC
|
|
23
|
+
|
|
24
|
+
example ["--id atlas-planner --format json"]
|
|
25
|
+
|
|
26
|
+
option :id, type: :string, required: true, desc: "Stable entry ID"
|
|
27
|
+
option :format, type: :string, desc: "Output format (json only)"
|
|
28
|
+
option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress output"
|
|
29
|
+
|
|
30
|
+
def initialize(topology_service: nil)
|
|
31
|
+
@topology_service = topology_service
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def call(id:, **options)
|
|
35
|
+
ensure_json_format!(options)
|
|
36
|
+
reject_identity_flags!(options)
|
|
37
|
+
|
|
38
|
+
emit_result(topology_service.resolve(id: id), quiet: options[:quiet])
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
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 Route < Ace::Support::Cli::Command
|
|
13
|
+
include Ace::Support::Cli::Base
|
|
14
|
+
include Runtime
|
|
15
|
+
|
|
16
|
+
desc <<~DESC.strip
|
|
17
|
+
Select a configured capable service in a project
|
|
18
|
+
|
|
19
|
+
Routing never invokes a service or grants credentials; zero
|
|
20
|
+
candidates are missing, several without a configured default are
|
|
21
|
+
ambiguous.
|
|
22
|
+
DESC
|
|
23
|
+
|
|
24
|
+
example ["--project atlas --capability search --format json"]
|
|
25
|
+
|
|
26
|
+
option :project, type: :string, required: true, desc: "Project stable ID"
|
|
27
|
+
option :capability, type: :string, required: true, desc: "Capability to route"
|
|
28
|
+
option :format, type: :string, desc: "Output format (json only)"
|
|
29
|
+
option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress output"
|
|
30
|
+
|
|
31
|
+
def initialize(topology_service: nil)
|
|
32
|
+
@topology_service = topology_service
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def call(project:, capability:, **options)
|
|
36
|
+
ensure_json_format!(options)
|
|
37
|
+
reject_identity_flags!(options)
|
|
38
|
+
|
|
39
|
+
emit_result(
|
|
40
|
+
topology_service.route(project: project, capability: capability),
|
|
41
|
+
quiet: options[:quiet]
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
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 Services < Ace::Support::Cli::Command
|
|
13
|
+
include Ace::Support::Cli::Base
|
|
14
|
+
include Runtime
|
|
15
|
+
|
|
16
|
+
desc <<~DESC.strip
|
|
17
|
+
List lab services in a project by stable ID
|
|
18
|
+
|
|
19
|
+
Endpoints are sanitized to scheme/host identity; tokens, auth
|
|
20
|
+
files, userinfo, query parameters, and fragments never appear.
|
|
21
|
+
DESC
|
|
22
|
+
|
|
23
|
+
example ["--project atlas --format json"]
|
|
24
|
+
|
|
25
|
+
option :project, type: :string, required: true, desc: "Project stable ID"
|
|
26
|
+
option :format, type: :string, desc: "Output format (json only)"
|
|
27
|
+
option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress output"
|
|
28
|
+
|
|
29
|
+
def initialize(topology_service: nil)
|
|
30
|
+
@topology_service = topology_service
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def call(project:, **options)
|
|
34
|
+
ensure_json_format!(options)
|
|
35
|
+
reject_identity_flags!(options)
|
|
36
|
+
|
|
37
|
+
emit_result(topology_service.services(project: project), quiet: options[:quiet])
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "ace/support/cli"
|
|
5
|
+
require "ace/core"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Lab
|
|
9
|
+
module CLI
|
|
10
|
+
module Commands
|
|
11
|
+
# Shared collaborator construction for ace-lab commands. The topology
|
|
12
|
+
# service is injectable so tests can substitute fixtures; registered
|
|
13
|
+
# commands resolve the ADR-022 cascade. Output is deterministic JSON
|
|
14
|
+
# only; classified failures emit one error document and then raise
|
|
15
|
+
# for a non-zero exit (ADR-023).
|
|
16
|
+
module Runtime
|
|
17
|
+
def topology_service
|
|
18
|
+
@topology_service ||= Organisms::TopologyService.from_config
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The interface accepts only JSON; anything else is a usage error
|
|
22
|
+
def ensure_json_format!(options)
|
|
23
|
+
format = options[:format]
|
|
24
|
+
return if format.nil? || format == "json"
|
|
25
|
+
|
|
26
|
+
cli_error("--format #{format.inspect} is not supported; only --format json is available")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Caller identity is derived from the verified local process owner.
|
|
30
|
+
# Role/principal flags would blur the authorization boundary and are
|
|
31
|
+
# rejected outright (spec 8wq.t.1w4).
|
|
32
|
+
def reject_identity_flags!(options)
|
|
33
|
+
spoofed = %i[role principal caller].select { |flag| options[flag] }
|
|
34
|
+
return if spoofed.empty?
|
|
35
|
+
|
|
36
|
+
cli_error("--#{spoofed.first.to_s.tr("_", "-")} is not supported; caller identity is derived " \
|
|
37
|
+
"from the verified local process owner")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Emit the result envelope; classified errors still print exactly
|
|
41
|
+
# one deterministic JSON document before failing
|
|
42
|
+
def emit_result(result, quiet: false)
|
|
43
|
+
envelope = result.envelope
|
|
44
|
+
puts JSON.generate(envelope) unless quiet
|
|
45
|
+
return if result.ok?
|
|
46
|
+
|
|
47
|
+
raise Ace::Support::Cli::Error, "#{envelope["error"]["code"]}: #{envelope["error"]["message"]}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def cli_error(message)
|
|
51
|
+
raise Ace::Support::Cli::Error, message
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
data/lib/ace/lab/cli.rb
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/support/cli"
|
|
4
|
+
require "ace/core"
|
|
5
|
+
require_relative "../lab"
|
|
6
|
+
require_relative "cli/commands/projects"
|
|
7
|
+
require_relative "cli/commands/agents"
|
|
8
|
+
require_relative "cli/commands/services"
|
|
9
|
+
require_relative "cli/commands/resolve"
|
|
10
|
+
require_relative "cli/commands/route"
|
|
11
|
+
|
|
12
|
+
module Ace
|
|
13
|
+
module Lab
|
|
14
|
+
# ace-support-cli based CLI registry for ace-lab
|
|
15
|
+
module CLI
|
|
16
|
+
extend Ace::Support::Cli::RegistryDsl
|
|
17
|
+
|
|
18
|
+
PROGRAM_NAME = "ace-lab"
|
|
19
|
+
|
|
20
|
+
# Application commands with descriptions (for help output)
|
|
21
|
+
REGISTERED_COMMANDS = [
|
|
22
|
+
["projects", "List lab projects visible to the verified caller"],
|
|
23
|
+
["agents", "List lab agents in a project by stable ID"],
|
|
24
|
+
["services", "List lab services in a project by stable ID"],
|
|
25
|
+
["resolve", "Resolve one lab entry by exact stable ID"],
|
|
26
|
+
["route", "Select a configured capable service in a project"]
|
|
27
|
+
].freeze
|
|
28
|
+
|
|
29
|
+
HELP_EXAMPLES = [
|
|
30
|
+
"ace-lab projects --format json",
|
|
31
|
+
"ace-lab agents --project atlas --format json",
|
|
32
|
+
"ace-lab resolve --id atlas-planner --format json",
|
|
33
|
+
"ace-lab route --project atlas --capability search --format json"
|
|
34
|
+
].freeze
|
|
35
|
+
|
|
36
|
+
# Start the CLI
|
|
37
|
+
#
|
|
38
|
+
# @param args [Array<String>] Command-line arguments
|
|
39
|
+
# @return [Integer] Exit code (0 for success, non-zero for failure)
|
|
40
|
+
def self.start(args)
|
|
41
|
+
Ace::Support::Cli::Runner.new(self).call(args: args)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Register application commands
|
|
45
|
+
register "projects", CLI::Commands::Projects.new
|
|
46
|
+
register "agents", CLI::Commands::Agents.new
|
|
47
|
+
register "services", CLI::Commands::Services.new
|
|
48
|
+
register "resolve", CLI::Commands::Resolve.new
|
|
49
|
+
register "route", CLI::Commands::Route.new
|
|
50
|
+
|
|
51
|
+
# Register version command
|
|
52
|
+
version_cmd = Ace::Support::Cli::VersionCommand.build(
|
|
53
|
+
gem_name: "ace-lab",
|
|
54
|
+
version: Ace::Lab::VERSION
|
|
55
|
+
)
|
|
56
|
+
register "version", version_cmd
|
|
57
|
+
register "--version", version_cmd
|
|
58
|
+
|
|
59
|
+
# Register help command
|
|
60
|
+
help_cmd = Ace::Support::Cli::HelpCommand.build(
|
|
61
|
+
program_name: PROGRAM_NAME,
|
|
62
|
+
version: Ace::Lab::VERSION,
|
|
63
|
+
commands: REGISTERED_COMMANDS,
|
|
64
|
+
examples: HELP_EXAMPLES
|
|
65
|
+
)
|
|
66
|
+
register "help", help_cmd
|
|
67
|
+
register "--help", help_cmd
|
|
68
|
+
register "-h", help_cmd
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Lab
|
|
5
|
+
module Models
|
|
6
|
+
# Immutable classified result of a topology query (spec 8wq.t.1w4).
|
|
7
|
+
# Either ok with public data, or a classified error with one of the
|
|
8
|
+
# ERROR_CODES. Serialized as the deterministic JSON envelope:
|
|
9
|
+
# {"status":"ok","data":{...}}
|
|
10
|
+
# {"status":"error","error":{"code":...,"message":...,...}}
|
|
11
|
+
class QueryResult
|
|
12
|
+
ERROR_CODES = %w[missing ambiguous stale unauthorized invalid_configuration].freeze
|
|
13
|
+
|
|
14
|
+
attr_reader :data, :error_code, :message, :context
|
|
15
|
+
|
|
16
|
+
def initialize(data: nil, error_code: nil, message: nil, context: {})
|
|
17
|
+
if error_code.nil? && message.nil?
|
|
18
|
+
@data = data
|
|
19
|
+
else
|
|
20
|
+
unless ERROR_CODES.include?(error_code)
|
|
21
|
+
raise ArgumentError, "unknown error code: #{error_code.inspect}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
@error_code = error_code
|
|
25
|
+
@message = message
|
|
26
|
+
@context = context.dup.freeze
|
|
27
|
+
end
|
|
28
|
+
freeze
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.ok(data)
|
|
32
|
+
new(data: data)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.failure(code, message, **context)
|
|
36
|
+
new(error_code: code, message: message, context: context)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def ok?
|
|
40
|
+
(!error_code.nil?) ? false : true
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def envelope
|
|
44
|
+
if ok?
|
|
45
|
+
{"status" => "ok", "data" => data}
|
|
46
|
+
else
|
|
47
|
+
error = {"code" => error_code, "message" => message}
|
|
48
|
+
error.merge!(context.select { |_k, v| !v.nil? })
|
|
49
|
+
{"status" => "error", "error" => error}
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|