ace-lab 0.1.0 → 0.2.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 +15 -0
- data/README.md +26 -14
- data/docs/usage.md +116 -10
- data/lib/ace/lab/atoms/service_input.rb +71 -0
- data/lib/ace/lab/cli/commands/service.rb +68 -0
- data/lib/ace/lab/cli.rb +6 -1
- data/lib/ace/lab/models/service_receipt.rb +18 -0
- data/lib/ace/lab/molecules/grant_resolver.rb +16 -0
- data/lib/ace/lab/molecules/service_executor.rb +81 -0
- data/lib/ace/lab/molecules/service_policy.rb +101 -0
- data/lib/ace/lab/organisms/service_request_service.rb +125 -0
- data/lib/ace/lab/version.rb +1 -1
- data/lib/ace/lab.rb +5 -0
- metadata +22 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c6487b764fe853b0272a43afa43fd2e6819669c6dc3593022ab55946925b2a2e
|
|
4
|
+
data.tar.gz: c588bd4bc130eff246e02546b40a11fa9ed06e8c575560d292a291c5d2d1665e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3c6974042b3ac8cc638d3782b3d2e4c4be19de2de5860cc6c91e3d020cdc0338e7cb187d932ae64353f7994304b3b0f284533cef21b6919233e313a7af1bea6a
|
|
7
|
+
data.tar.gz: 2e6908502fdd3870f7403e7d6f56b07d1b5edb07acbdc6591135aae8a0227ae85ab8724c7e079e4a1d91bbf61a53277d0a84e38c23be958e540c1b9c7d8306ae
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.2.0] - 2026-10-02
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `service request` and `service status` for exact authorized operations,
|
|
15
|
+
idempotent assignment-journal claims, peer-verified execution receipts,
|
|
16
|
+
dry-run previews, and conservative uncertain-state recovery.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- Recheck the configured operation, authorization, and lease at dispatch;
|
|
21
|
+
bind terminal receipts to the assignment request before acceptance.
|
|
22
|
+
|
|
8
23
|
## [0.1.0] - 2026-09-28
|
|
9
24
|
|
|
10
25
|
### Added
|
data/README.md
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
## ace-lab
|
|
2
2
|
|
|
3
|
-
Topology and
|
|
4
|
-
|
|
3
|
+
Topology, routing, and scoped service request CLI for the Lab. Part of ACE
|
|
4
|
+
(Agentic Coding Environment).
|
|
5
5
|
|
|
6
6
|
`ace-lab` answers one question -- *what is the stable ID of the project, agent,
|
|
7
7
|
or service I mean, and what is it authorized and able to do?* -- from
|
|
8
|
-
configuration
|
|
9
|
-
|
|
8
|
+
configuration. Topology queries never invoke a service. The `service`
|
|
9
|
+
commands submit configured operations through a verified executor and use
|
|
10
|
+
`ace-assign` for durable request evidence. No command hands credentials to
|
|
11
|
+
the caller or guesses transient pane or session identifiers.
|
|
10
12
|
|
|
11
13
|
### Install
|
|
12
14
|
|
|
@@ -17,12 +19,12 @@ work, and never guesses transient pane or session identifiers.
|
|
|
17
19
|
Configure topology via the ADR-022 cascade (`~/.ace/lab/config.yml` or
|
|
18
20
|
`.ace/lab/config.yml`; deployed values are owned by the `lab-config`
|
|
19
21
|
repository). Authorization grants live in a separate deployment-controlled
|
|
20
|
-
file at the fixed path `/etc/lab/ace-lab/authorization.yml`
|
|
21
|
-
not group/world-writable, verified at every query
|
|
22
|
+
file at the fixed path `/etc/lab/ace-lab/authorization.yml` -- root-owned and
|
|
23
|
+
not group/world-writable, verified at every query -- never in the
|
|
22
24
|
caller-writable cascade and never at a caller-selected location:
|
|
23
25
|
|
|
24
26
|
```yaml
|
|
25
|
-
# .ace/lab/config.yml
|
|
27
|
+
# .ace/lab/config.yml -- topology only
|
|
26
28
|
schema_version: 1
|
|
27
29
|
topology:
|
|
28
30
|
projects:
|
|
@@ -56,6 +58,17 @@ Query it:
|
|
|
56
58
|
ace-lab resolve --id atlas-planner --format json
|
|
57
59
|
ace-lab route --project atlas --capability search --format json
|
|
58
60
|
|
|
61
|
+
Request a configured operation for an active, managed assignment attempt:
|
|
62
|
+
|
|
63
|
+
ace-lab service request --project atlas --assignment A --attempt ATT \
|
|
64
|
+
--operation publish --input release.json --authorization DECISION \
|
|
65
|
+
--request-id REQUEST --dry-run
|
|
66
|
+
ace-lab service status --request REQUEST --format json
|
|
67
|
+
|
|
68
|
+
The trusted grants file may also contain exact `operations` and
|
|
69
|
+
`authorizations` mappings. See [usage](docs/usage.md) for the request schema,
|
|
70
|
+
executor transport, and uncertain-state recovery.
|
|
71
|
+
|
|
59
72
|
Every command prints one deterministic JSON document:
|
|
60
73
|
|
|
61
74
|
{"status":"ok","data":{"entry":{"id":"atlas-search","project":"atlas",
|
|
@@ -92,19 +105,18 @@ Failures are classified: `missing`, `ambiguous`, `stale`, `unauthorized`,
|
|
|
92
105
|
are `missing`; several equal candidates need a configured `default_for`, or
|
|
93
106
|
the result is `ambiguous`. Another project's service is never picked.
|
|
94
107
|
|
|
95
|
-
- **
|
|
108
|
+
- **Execution evidence is separate from topology.** `ace-lab service` uses a
|
|
96
109
|
|
|
97
|
-
|
|
98
|
-
|
|
110
|
+
managed `ace-assign` attempt and its evidence ref. A lost executor receipt
|
|
111
|
+
remains uncertain and a repeated request ID never replays the effect.
|
|
99
112
|
|
|
100
113
|
### Non-goals
|
|
101
114
|
|
|
102
|
-
`ace-lab`
|
|
103
|
-
|
|
104
|
-
schedule anything. Deployed topology values are owned by `lab-config`
|
|
115
|
+
`ace-lab` does not track Works or schedule anything. `ace-assign` owns attempt
|
|
116
|
+
state; deployed operation handlers and topology values are owned by `lab-config`
|
|
105
117
|
(`8wl.t.gad`); this package defines and validates the schema.
|
|
106
118
|
|
|
107
119
|
### Development
|
|
108
120
|
|
|
109
121
|
bundle install
|
|
110
|
-
ace-test ace-lab
|
|
122
|
+
bin/ace-test ace-lab all
|
data/docs/usage.md
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
# ace-lab usage
|
|
2
2
|
|
|
3
|
-
Topology and
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
Topology, routing, and scoped service requests for the Lab. Resolves stable
|
|
4
|
+
project, agent, and service IDs without transient pane IDs or shared service
|
|
5
|
+
credentials.
|
|
6
6
|
|
|
7
7
|
## Commands
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
Topology commands accept `--format json` (the only supported format) and
|
|
10
|
+
`-q/--quiet`. Service status accepts `--format json`; service request always
|
|
11
|
+
emits JSON.
|
|
11
12
|
|
|
12
13
|
### `ace-lab projects`
|
|
13
14
|
|
|
@@ -58,6 +59,68 @@ Select a configured capable service in the requested project.
|
|
|
58
59
|
|
|
59
60
|
Routing never invokes the service and never grants credentials.
|
|
60
61
|
|
|
62
|
+
### `ace-lab service request`
|
|
63
|
+
|
|
64
|
+
**Goal:** Ask a configured service to perform one named operation for an
|
|
65
|
+
active, task-attached assignment attempt. First create a JSON input file:
|
|
66
|
+
|
|
67
|
+
```json
|
|
68
|
+
{"target":{"resource":"release/1.2.3","artifact_digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"arguments":{"version":"1.2.3"}}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Run a preview, then submit the exact request:
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
ace-lab service request --project atlas --assignment A --attempt ATT \
|
|
75
|
+
--operation publish --input release.json --authorization DECISION \
|
|
76
|
+
--request-id REQUEST --dry-run
|
|
77
|
+
ace-lab service request --project atlas --assignment A --attempt ATT \
|
|
78
|
+
--operation publish --input release.json --authorization DECISION \
|
|
79
|
+
--request-id REQUEST
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The preview reports `outcome: accepted` with `dry_run: true` and the selected
|
|
83
|
+
service ID, target, and candidate head. It creates no request claim or effect.
|
|
84
|
+
The second command returns `succeeded`, `failed`, or `uncertain` with a
|
|
85
|
+
non-secret receipt when the executor confirms an outcome. A missing response
|
|
86
|
+
leaves `uncertain` in the assignment evidence ref; do not retry with a new ID
|
|
87
|
+
until the external outcome is reconciled. Repeating the same ID and input
|
|
88
|
+
returns its stored state without dispatching again. A changed binding under
|
|
89
|
+
the same ID is a `conflict` error.
|
|
90
|
+
|
|
91
|
+
Input must be a JSON object of at most 64 KiB. `target.resource` is a stable
|
|
92
|
+
resource ID; `target.artifact_digest`, when supplied, is a SHA-256 hex digest.
|
|
93
|
+
Credential, password, secret, token, private-key, authorization, and env keys
|
|
94
|
+
are rejected recursively. Operation arguments remain structured JSON sent to
|
|
95
|
+
the executor; no caller-provided shell command or argv is accepted.
|
|
96
|
+
|
|
97
|
+
| Option | Purpose |
|
|
98
|
+
|--------|---------|
|
|
99
|
+
| `--project` | Project stable ID |
|
|
100
|
+
| `--assignment` | Assignment ID |
|
|
101
|
+
| `--attempt` | Active managed attempt ID |
|
|
102
|
+
| `--operation` | Configured named operation |
|
|
103
|
+
| `--input` | Structured JSON file |
|
|
104
|
+
| `--authorization` | Exact decision or configured automation reference |
|
|
105
|
+
| `--request-id` | Idempotency key; use the same ID for retries/status |
|
|
106
|
+
| `--dry-run` | Validate and show scope without an effect |
|
|
107
|
+
|
|
108
|
+
### `ace-lab service status`
|
|
109
|
+
|
|
110
|
+
```sh
|
|
111
|
+
ace-lab service status --request REQUEST --format json
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Expected output after a lost executor receipt:
|
|
115
|
+
|
|
116
|
+
```json
|
|
117
|
+
{"status":"ok","data":{"request_id":"REQUEST","outcome":"uncertain","state":"uncertain"}}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Only the original OS caller identity with project visibility can read the
|
|
121
|
+
request. The authoritative state survives loss of the local assignment cache
|
|
122
|
+
because it lives in the separate assignment evidence Git ref.
|
|
123
|
+
|
|
61
124
|
## Error semantics
|
|
62
125
|
|
|
63
126
|
Errors are one deterministic JSON document on stdout plus a non-zero exit.
|
|
@@ -69,6 +132,10 @@ Errors are one deterministic JSON document on stdout plus a non-zero exit.
|
|
|
69
132
|
| `stale` | Binding is inactive, unattested, or the instance identity was replaced |
|
|
70
133
|
| `unauthorized` | Caller's verified local identity has no principal for the project |
|
|
71
134
|
| `invalid_configuration` | Topology config violates the schema; message is actionable |
|
|
135
|
+
| `invalid_input` | Service input or request ID is malformed |
|
|
136
|
+
| `invalid_attempt` | Assignment attempt is absent, unmanaged, terminal, or has a stale candidate head |
|
|
137
|
+
| `conflict` | Request ID is already bound to different input or scope |
|
|
138
|
+
| `evidence_unavailable` | Assignment evidence ref cannot be written or read |
|
|
72
139
|
|
|
73
140
|
Example:
|
|
74
141
|
|
|
@@ -115,7 +182,7 @@ Grants never live in the cascade: project and user documents are
|
|
|
115
182
|
caller-writable, so an `authorization` section there is rejected as
|
|
116
183
|
`invalid_configuration`. Grants come from a single deployment-controlled
|
|
117
184
|
file at the **fixed path** `/etc/lab/ace-lab/authorization.yml` (installed
|
|
118
|
-
by the `lab-config` deployment). The location is not caller-selectable
|
|
185
|
+
by the `lab-config` deployment). The location is not caller-selectable --
|
|
119
186
|
there is no flag or environment override. At every query the tool verifies
|
|
120
187
|
the file and every directory on its real path are root-owned and not
|
|
121
188
|
group/world-writable, and opens the file `O_NOFOLLOW`; any failed
|
|
@@ -130,12 +197,51 @@ principals:
|
|
|
130
197
|
The grants file is machine-global: it is validated structurally only, so a
|
|
131
198
|
principal may reference projects absent from the current directory's
|
|
132
199
|
topology. Such grants are valid but never match a locally configured
|
|
133
|
-
project. All-digit principal keys are matched as uids only
|
|
200
|
+
project. All-digit principal keys are matched as uids only -- an all-digit
|
|
134
201
|
passwd username is authorized solely through its uid, so it can never
|
|
135
202
|
consume a different account's numeric-uid grant.
|
|
136
203
|
|
|
137
204
|
A missing trusted file means nobody is authorized (fail closed).
|
|
138
205
|
|
|
206
|
+
### Trusted service policy
|
|
207
|
+
|
|
208
|
+
The same deployment-owned file may contain `operations` and
|
|
209
|
+
`authorizations`. An operation names one exact project and stable service ID,
|
|
210
|
+
an executor OS UID, a valid lease, and either a fixed local argv or an absolute
|
|
211
|
+
Unix socket path. The domain deployment owns actual handlers and credentials.
|
|
212
|
+
The Unix service must authenticate the client's OS peer identity, validate
|
|
213
|
+
the exact authorization and current lease itself, and return a structured
|
|
214
|
+
receipt; ACE verifies the service peer UID before accepting that response.
|
|
215
|
+
|
|
216
|
+
```yaml
|
|
217
|
+
operations:
|
|
218
|
+
forge-sync:
|
|
219
|
+
project: atlas
|
|
220
|
+
service_id: atlas-sync
|
|
221
|
+
transport: unix
|
|
222
|
+
socket_path: /run/lab/atlas-sync.sock
|
|
223
|
+
executor_uid: 997
|
|
224
|
+
lease_expires_at: '2026-10-02T16:00:00Z'
|
|
225
|
+
authorizations:
|
|
226
|
+
DECISION:
|
|
227
|
+
operation: forge-sync
|
|
228
|
+
project_id: atlas
|
|
229
|
+
assignment_id: A
|
|
230
|
+
attempt_id: ATT
|
|
231
|
+
input_digest: <sha256-of-canonical-json>
|
|
232
|
+
target: { resource: forge/repository, artifact_digest: null }
|
|
233
|
+
candidate_head: <exact-git-head>
|
|
234
|
+
caller_uid: 1000
|
|
235
|
+
expires_at: '2026-10-02T16:00:00Z'
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
The decision must match every listed field exactly and remain valid. A
|
|
239
|
+
decision never supplies missing executor capability or credentials. For
|
|
240
|
+
authenticated host maintenance, the policy also identifies an executable and
|
|
241
|
+
evidence sink outside the deployment being replaced; its domain handler must
|
|
242
|
+
enforce quiescence for all other product work. Environment `review-approval`
|
|
243
|
+
is a named operation and never substitutes for an independent code review.
|
|
244
|
+
|
|
139
245
|
Validation of the topology cascade rejects duplicate IDs (globally unique
|
|
140
246
|
across projects, agents, services), unknown project references, malformed
|
|
141
247
|
capabilities, defaults for undeclared capabilities, unusable endpoints
|
|
@@ -146,7 +252,7 @@ unknown projects.
|
|
|
146
252
|
**Error messages are value-free by design:** validation runs before
|
|
147
253
|
authorization, so messages use positional field locations
|
|
148
254
|
(`topology.agents[0].project references an unknown project`) and never echo
|
|
149
|
-
configured IDs, project names, or principal names
|
|
255
|
+
configured IDs, project names, or principal names -- configuration defects
|
|
150
256
|
cannot disclose topology to unauthorized callers.
|
|
151
257
|
|
|
152
258
|
**Ownership:** deployed topology and authorization values are maintained by
|
|
@@ -172,7 +278,7 @@ reads; it never provisions.
|
|
|
172
278
|
classify it `stale` -- an explicit unavailable result, never a routeable
|
|
173
279
|
answer.
|
|
174
280
|
|
|
175
|
-
- No Works
|
|
281
|
+
- No Works or scheduling. Service request claims and receipts use
|
|
176
282
|
|
|
177
|
-
`
|
|
283
|
+
`ace-assign`'s evidence journal. The Lab execution binary
|
|
178
284
|
(`/usr/local/bin/lab`) is never invoked or required.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Lab
|
|
8
|
+
module Atoms
|
|
9
|
+
# Bounded structured input for a named service operation. Only the
|
|
10
|
+
# digest enters the request index; input values never enter the journal.
|
|
11
|
+
module ServiceInput
|
|
12
|
+
MAX_BYTES = 64 * 1024
|
|
13
|
+
FORBIDDEN_KEYS = /credential|password|secret|token|private.key|authorization|\Aenv\z/i
|
|
14
|
+
SHA256 = /\A[0-9a-f]{64}\z/
|
|
15
|
+
RESOURCE = /\A[a-zA-Z0-9_.:\/-]{1,256}\z/
|
|
16
|
+
|
|
17
|
+
def self.load(path)
|
|
18
|
+
raise ArgumentError, "input file is missing" unless File.file?(path)
|
|
19
|
+
raise ArgumentError, "input exceeds #{MAX_BYTES} bytes" if File.size(path) > MAX_BYTES
|
|
20
|
+
data = JSON.parse(File.read(path))
|
|
21
|
+
raise ArgumentError, "input must be a JSON object" unless data.is_a?(Hash)
|
|
22
|
+
validate!(data)
|
|
23
|
+
data
|
|
24
|
+
rescue JSON::ParserError
|
|
25
|
+
raise ArgumentError, "input must be valid JSON"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.digest(data)
|
|
29
|
+
Digest::SHA256.hexdigest(JSON.generate(canonical(data)))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.target(data)
|
|
33
|
+
target = data["target"]
|
|
34
|
+
raise ArgumentError, "input.target must be an object" unless target.is_a?(Hash)
|
|
35
|
+
resource = target["resource"]
|
|
36
|
+
artifact = target["artifact_digest"]
|
|
37
|
+
raise ArgumentError, "input.target.resource must be a stable resource ID" unless
|
|
38
|
+
resource.is_a?(String) && resource.match?(RESOURCE)
|
|
39
|
+
if artifact && (!artifact.is_a?(String) || !artifact.match?(SHA256))
|
|
40
|
+
raise ArgumentError, "input.target.artifact_digest must be SHA-256"
|
|
41
|
+
end
|
|
42
|
+
{"resource" => resource, "artifact_digest" => artifact}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.canonical(value)
|
|
46
|
+
case value
|
|
47
|
+
when Hash then value.keys.sort.each_with_object({}) { |key, result| result[key] = canonical(value[key]) }
|
|
48
|
+
when Array then value.map { |item| canonical(item) }
|
|
49
|
+
else value
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.validate!(value, depth = 0)
|
|
54
|
+
raise ArgumentError, "input nesting is too deep" if depth > 16
|
|
55
|
+
case value
|
|
56
|
+
when Hash
|
|
57
|
+
value.each do |key, item|
|
|
58
|
+
raise ArgumentError, "input has a forbidden field" if !key.is_a?(String) || key.match?(FORBIDDEN_KEYS)
|
|
59
|
+
validate!(item, depth + 1)
|
|
60
|
+
end
|
|
61
|
+
when Array then value.each { |item| validate!(item, depth + 1) }
|
|
62
|
+
when String, Integer, Float, TrueClass, FalseClass, NilClass
|
|
63
|
+
raise ArgumentError, "input contains a non-finite number" if value.is_a?(Float) && !value.finite?
|
|
64
|
+
else
|
|
65
|
+
raise ArgumentError, "input contains an unsupported value"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "ace/support/cli"
|
|
5
|
+
require_relative "support"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Lab
|
|
9
|
+
module CLI
|
|
10
|
+
module Commands
|
|
11
|
+
module Service
|
|
12
|
+
module Output
|
|
13
|
+
def service
|
|
14
|
+
@service ||= Organisms::ServiceRequestService.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def emit(envelope)
|
|
18
|
+
puts JSON.generate(envelope)
|
|
19
|
+
return if envelope["status"] == "ok"
|
|
20
|
+
error = envelope.fetch("error")
|
|
21
|
+
code = error.fetch("code")
|
|
22
|
+
message = error.fetch("message")
|
|
23
|
+
raise Ace::Support::Cli::Error, "#{code}: #{message}"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class Request < Ace::Support::Cli::Command
|
|
28
|
+
include Ace::Support::Cli::Base
|
|
29
|
+
include Runtime
|
|
30
|
+
include Output
|
|
31
|
+
|
|
32
|
+
desc "Request one authorized, configured service operation"
|
|
33
|
+
option :project, type: :string, required: true, desc: "Project stable ID"
|
|
34
|
+
option :assignment, type: :string, required: true, desc: "Assignment ID"
|
|
35
|
+
option :attempt, type: :string, required: true, desc: "Managed attempt ID"
|
|
36
|
+
option :operation, type: :string, required: true, desc: "Named operation"
|
|
37
|
+
option :input, type: :string, required: true, desc: "Structured JSON input file"
|
|
38
|
+
option :authorization, type: :string, required: true, desc: "Exact authorization reference"
|
|
39
|
+
option :request_id, type: :string, required: true, desc: "Idempotency key"
|
|
40
|
+
option :dry_run, type: :boolean, desc: "Validate without claiming or executing"
|
|
41
|
+
|
|
42
|
+
def call(project:, assignment:, attempt:, operation:, input:, authorization:, request_id:, **options)
|
|
43
|
+
reject_identity_flags!(options)
|
|
44
|
+
emit(service.request(project: project, assignment: assignment, attempt: attempt,
|
|
45
|
+
operation: operation, input_path: input, authorization: authorization,
|
|
46
|
+
request_id: request_id, dry_run: options[:dry_run]))
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
class Status < Ace::Support::Cli::Command
|
|
51
|
+
include Ace::Support::Cli::Base
|
|
52
|
+
include Runtime
|
|
53
|
+
include Output
|
|
54
|
+
|
|
55
|
+
desc "Read the authoritative outcome of a service request"
|
|
56
|
+
option :request, type: :string, required: true, desc: "Request ID"
|
|
57
|
+
option :format, type: :string, desc: "Output format (json only)"
|
|
58
|
+
|
|
59
|
+
def call(request:, **options)
|
|
60
|
+
ensure_json_format!(options)
|
|
61
|
+
emit(service.status(request_id: request))
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
data/lib/ace/lab/cli.rb
CHANGED
|
@@ -8,6 +8,7 @@ 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"
|
|
11
12
|
|
|
12
13
|
module Ace
|
|
13
14
|
module Lab
|
|
@@ -23,7 +24,9 @@ module Ace
|
|
|
23
24
|
["agents", "List lab agents in a project by stable ID"],
|
|
24
25
|
["services", "List lab services in a project by stable ID"],
|
|
25
26
|
["resolve", "Resolve one lab entry by exact stable ID"],
|
|
26
|
-
["route", "Select a configured capable service in a project"]
|
|
27
|
+
["route", "Select a configured capable service in a project"],
|
|
28
|
+
["service request", "Request one authorized service operation"],
|
|
29
|
+
["service status", "Read a service request outcome"]
|
|
27
30
|
].freeze
|
|
28
31
|
|
|
29
32
|
HELP_EXAMPLES = [
|
|
@@ -47,6 +50,8 @@ module Ace
|
|
|
47
50
|
register "services", CLI::Commands::Services.new
|
|
48
51
|
register "resolve", CLI::Commands::Resolve.new
|
|
49
52
|
register "route", CLI::Commands::Route.new
|
|
53
|
+
register "service request", CLI::Commands::Service::Request.new
|
|
54
|
+
register "service status", CLI::Commands::Service::Status.new
|
|
50
55
|
|
|
51
56
|
# Register version command
|
|
52
57
|
version_cmd = Ace::Support::Cli::VersionCommand.build(
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Lab
|
|
5
|
+
module Models
|
|
6
|
+
# Non-secret result submitted to the assignment evidence coordinator.
|
|
7
|
+
# The coordinator independently verifies every binding before acceptance.
|
|
8
|
+
module ServiceReceipt
|
|
9
|
+
BINDING_FIELDS = %w[request_id assignment_id attempt_id project_id operation
|
|
10
|
+
input_digest target candidate_head].freeze
|
|
11
|
+
|
|
12
|
+
def self.build(binding, executor_result)
|
|
13
|
+
BINDING_FIELDS.to_h { |field| [field, binding.fetch(field)] }.merge(executor_result)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -23,6 +23,22 @@ module Ace
|
|
|
23
23
|
MAX_SYMLINK_HOPS = 8
|
|
24
24
|
|
|
25
25
|
class << self
|
|
26
|
+
# Read the full deployment-owned policy for service execution.
|
|
27
|
+
# Topology remains a routing hint; it never supplies authority.
|
|
28
|
+
def trusted_document(path)
|
|
29
|
+
content = read_verified(path)
|
|
30
|
+
return {} if content.nil?
|
|
31
|
+
document = YAML.safe_load(content, permitted_classes: [Date], aliases: true)
|
|
32
|
+
unless document.is_a?(Hash)
|
|
33
|
+
raise Ace::Lab::InvalidConfigurationError,
|
|
34
|
+
"invalid lab configuration: trusted authorization file must contain a mapping"
|
|
35
|
+
end
|
|
36
|
+
document
|
|
37
|
+
rescue Psych::Exception
|
|
38
|
+
raise Ace::Lab::InvalidConfigurationError,
|
|
39
|
+
"invalid lab configuration: trusted authorization file could not be parsed"
|
|
40
|
+
end
|
|
41
|
+
|
|
26
42
|
# @param documents [Array<Hash>] {path:, document:, defaults:}
|
|
27
43
|
# from Ace::Lab.cascade_documents
|
|
28
44
|
# @param topology [Hash] normalized topology (project IDs)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "socket"
|
|
6
|
+
require "timeout"
|
|
7
|
+
require "time"
|
|
8
|
+
|
|
9
|
+
module Ace
|
|
10
|
+
module Lab
|
|
11
|
+
module Molecules
|
|
12
|
+
# Executes one fixed, deployment-configured argv under its OS account.
|
|
13
|
+
# Domain handlers receive structured JSON on stdin and return a small
|
|
14
|
+
# receipt; their stdout/stderr are never journaled or returned to callers.
|
|
15
|
+
class ServiceExecutor
|
|
16
|
+
def execute(operation:, request:, input:, policy:, authorization:)
|
|
17
|
+
current = policy.operation!(request.fetch("operation"), project: request.fetch("project_id"),
|
|
18
|
+
service_id: request.fetch("service_id"))
|
|
19
|
+
raise SecurityError, "executor operation changed before dispatch" unless current == operation
|
|
20
|
+
policy.authorize!(authorization, request)
|
|
21
|
+
if Time.iso8601(operation.fetch("lease_expires_at")) <= Time.now.utc
|
|
22
|
+
raise SecurityError, "executor lease has expired"
|
|
23
|
+
end
|
|
24
|
+
out = if operation.fetch("transport", "local") == "unix"
|
|
25
|
+
invoke_unix(operation, request, input)
|
|
26
|
+
else
|
|
27
|
+
invoke_local(operation, request, input)
|
|
28
|
+
end
|
|
29
|
+
return nil unless out && out.bytesize <= 16 * 1024
|
|
30
|
+
response = JSON.parse(out)
|
|
31
|
+
return nil unless valid_response?(response, request)
|
|
32
|
+
{"outcome" => response.fetch("outcome"), "evidence" => response.fetch("evidence"),
|
|
33
|
+
"executor_uid" => operation.fetch("executor_uid")}
|
|
34
|
+
rescue JSON::ParserError, Errno::ENOENT, Errno::EACCES, Errno::ECONNREFUSED, EOFError, Timeout::Error
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def invoke_local(operation, request, input)
|
|
41
|
+
unless Process.uid == operation.fetch("executor_uid") && Process.euid == operation.fetch("executor_uid")
|
|
42
|
+
raise SecurityError, "current OS identity is not the configured executor"
|
|
43
|
+
end
|
|
44
|
+
out, _stderr, status = Timeout.timeout(30) do
|
|
45
|
+
Open3.capture3(*operation.fetch("argv"),
|
|
46
|
+
stdin_data: JSON.generate({"request" => request, "input" => input}))
|
|
47
|
+
end
|
|
48
|
+
status.success? ? out : nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def invoke_unix(operation, request, input)
|
|
52
|
+
path = operation.fetch("socket_path")
|
|
53
|
+
stat = File.lstat(path)
|
|
54
|
+
unless stat.socket? && stat.uid == operation.fetch("executor_uid") && (stat.mode & 0o002).zero?
|
|
55
|
+
raise SecurityError, "service socket owner or permissions do not match the executor"
|
|
56
|
+
end
|
|
57
|
+
UNIXSocket.open(path) do |socket|
|
|
58
|
+
peer_uid, = socket.getpeereid
|
|
59
|
+
raise SecurityError, "service peer identity does not match the executor" unless peer_uid == stat.uid
|
|
60
|
+
socket.write(JSON.generate({"request" => request, "input" => input}) + "\n")
|
|
61
|
+
socket.flush
|
|
62
|
+
Timeout.timeout(30) { socket.gets(16 * 1024 + 1) }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def valid_response?(response, request)
|
|
67
|
+
response.is_a?(Hash) && response.keys.sort == %w[evidence input_digest outcome request_id] &&
|
|
68
|
+
response["request_id"] == request["request_id"] &&
|
|
69
|
+
response["input_digest"] == request["input_digest"] &&
|
|
70
|
+
%w[succeeded failed].include?(response["outcome"]) &&
|
|
71
|
+
response["evidence"].is_a?(Array) && !response["evidence"].empty? &&
|
|
72
|
+
response["evidence"].all? do |item|
|
|
73
|
+
item.is_a?(Hash) && item.keys.sort == %w[ref sha256] &&
|
|
74
|
+
item["ref"].is_a?(String) && item["ref"].match?(/\A[a-zA-Z0-9_.:\/-]{1,256}\z/) &&
|
|
75
|
+
item["sha256"].is_a?(String) && item["sha256"].match?(/\A[0-9a-f]{64}\z/)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Lab
|
|
7
|
+
module Molecules
|
|
8
|
+
# Deployment-owned operation and authorization facts. The caller may
|
|
9
|
+
# name a reference but cannot define its scope or executor binding.
|
|
10
|
+
class ServicePolicy
|
|
11
|
+
NAME = /\A[a-z][a-z0-9-]{0,63}\z/
|
|
12
|
+
ID = /\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/
|
|
13
|
+
|
|
14
|
+
def initialize(document)
|
|
15
|
+
@operations = document.fetch("operations", {})
|
|
16
|
+
@authorizations = document.fetch("authorizations", {})
|
|
17
|
+
unless @operations.is_a?(Hash) && @authorizations.is_a?(Hash)
|
|
18
|
+
raise Ace::Lab::InvalidConfigurationError, "trusted service policy must contain mappings"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def operation!(name, project:, service_id:)
|
|
23
|
+
raise ArgumentError, "invalid operation name" unless name.to_s.match?(NAME)
|
|
24
|
+
operation = @operations[name]
|
|
25
|
+
raise SecurityError, "operation is not configured" unless operation.is_a?(Hash)
|
|
26
|
+
unless operation["project"] == project && operation["service_id"] == service_id
|
|
27
|
+
raise SecurityError, "operation does not authorize the selected service in this project"
|
|
28
|
+
end
|
|
29
|
+
transport = operation.fetch("transport", "local")
|
|
30
|
+
unless %w[local unix].include?(transport)
|
|
31
|
+
raise Ace::Lab::InvalidConfigurationError, "configured service transport is invalid"
|
|
32
|
+
end
|
|
33
|
+
if transport == "local"
|
|
34
|
+
argv = operation["argv"]
|
|
35
|
+
unless argv.is_a?(Array) && !argv.empty? && argv.all? { |v| v.is_a?(String) && !v.empty? } &&
|
|
36
|
+
argv.first.start_with?("/") && File.file?(argv.first) && File.executable?(argv.first)
|
|
37
|
+
raise Ace::Lab::InvalidConfigurationError, "configured operation needs a fixed executable argv"
|
|
38
|
+
end
|
|
39
|
+
elsif !operation["socket_path"].is_a?(String) || !operation["socket_path"].start_with?("/")
|
|
40
|
+
raise Ace::Lab::InvalidConfigurationError, "unix service needs an absolute socket path"
|
|
41
|
+
end
|
|
42
|
+
uid = operation["executor_uid"]
|
|
43
|
+
raise Ace::Lab::InvalidConfigurationError, "configured executor UID is invalid" unless uid.is_a?(Integer) && uid >= 0
|
|
44
|
+
validate_executable!(operation["argv"].first, uid) if transport == "local"
|
|
45
|
+
expires_at = parse_time(operation["lease_expires_at"])
|
|
46
|
+
raise SecurityError, "executor lease has expired" if expires_at <= Time.now.utc
|
|
47
|
+
if operation["host_maintenance"] == true
|
|
48
|
+
deployment = operation["deployment_root"].to_s
|
|
49
|
+
sink = operation["evidence_sink"].to_s
|
|
50
|
+
executable = operation.dig("argv", 0).to_s
|
|
51
|
+
unless deployment.start_with?("/") && sink.start_with?("/") && executable.start_with?("/") &&
|
|
52
|
+
!inside?(executable, deployment) && !inside?(sink, deployment)
|
|
53
|
+
raise Ace::Lab::InvalidConfigurationError,
|
|
54
|
+
"host maintenance needs executable and evidence sink outside the replaced deployment"
|
|
55
|
+
end
|
|
56
|
+
validate_executable!(executable, uid)
|
|
57
|
+
end
|
|
58
|
+
operation
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def authorize!(reference, binding)
|
|
62
|
+
raise ArgumentError, "invalid authorization reference" unless reference.to_s.match?(ID)
|
|
63
|
+
decision = @authorizations[reference]
|
|
64
|
+
raise SecurityError, "authorization reference is unresolved" unless decision.is_a?(Hash)
|
|
65
|
+
required = %w[operation project_id assignment_id attempt_id input_digest target candidate_head caller_uid]
|
|
66
|
+
unless required.all? { |key| decision[key] == binding[key] }
|
|
67
|
+
raise SecurityError, "authorization does not match the exact service request"
|
|
68
|
+
end
|
|
69
|
+
raise SecurityError, "authorization has expired" if parse_time(decision["expires_at"]) <= Time.now.utc
|
|
70
|
+
decision
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def validate_executable!(path, uid)
|
|
76
|
+
file = File.stat(path)
|
|
77
|
+
parent = File.stat(File.dirname(File.realpath(path)))
|
|
78
|
+
unless [0, uid].include?(file.uid) && [0, uid].include?(parent.uid) &&
|
|
79
|
+
(file.mode & 0o022).zero? && (parent.mode & 0o022).zero?
|
|
80
|
+
raise Ace::Lab::InvalidConfigurationError,
|
|
81
|
+
"configured executable or containing directory is writable by another identity"
|
|
82
|
+
end
|
|
83
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
84
|
+
raise Ace::Lab::InvalidConfigurationError, "configured executable is unavailable"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def inside?(path, root)
|
|
88
|
+
expanded = File.expand_path(path)
|
|
89
|
+
base = File.expand_path(root)
|
|
90
|
+
expanded == base || expanded.start_with?(base + File::SEPARATOR)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def parse_time(value)
|
|
94
|
+
Time.iso8601(value.to_s)
|
|
95
|
+
rescue ArgumentError
|
|
96
|
+
raise Ace::Lab::InvalidConfigurationError, "trusted policy has invalid expiry"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "ace/assign"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Lab
|
|
8
|
+
module Organisms
|
|
9
|
+
# Coordinates the generic request contract. ace-assign owns the durable
|
|
10
|
+
# claim and receipt; this class never writes evidence files directly.
|
|
11
|
+
class ServiceRequestService
|
|
12
|
+
def initialize(topology: nil, policy: nil, coordinator: nil, executor: nil, repo_root: Dir.pwd)
|
|
13
|
+
@topology = topology || TopologyService.from_config
|
|
14
|
+
@policy = policy
|
|
15
|
+
@coordinator = coordinator || Ace::Assign::Organisms::AttemptCoordinator.new(repo_root: repo_root)
|
|
16
|
+
@executor = executor || Molecules::ServiceExecutor.new
|
|
17
|
+
@repo_root = repo_root
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def request(project:, assignment:, attempt:, operation:, input_path:, authorization:, request_id:, dry_run: false)
|
|
21
|
+
{project: project, assignment: assignment, attempt: attempt, request: request_id}.each do |name, value|
|
|
22
|
+
raise ArgumentError, "invalid #{name} ID" unless value.is_a?(String) &&
|
|
23
|
+
value.match?(Molecules::ServicePolicy::ID)
|
|
24
|
+
end
|
|
25
|
+
input = Atoms::ServiceInput.load(input_path)
|
|
26
|
+
target = Atoms::ServiceInput.target(input)
|
|
27
|
+
route = @topology.route(project: project, capability: operation)
|
|
28
|
+
return route.envelope unless route.ok?
|
|
29
|
+
service_id = route.data.fetch("entry").fetch("id")
|
|
30
|
+
trusted = Molecules::GrantResolver.trusted_document(Ace::Lab.authorization_path) unless @policy
|
|
31
|
+
policy = @policy || Molecules::ServicePolicy.new(trusted)
|
|
32
|
+
head = current_head
|
|
33
|
+
binding = {"request_id" => request_id, "assignment_id" => assignment, "attempt_id" => attempt,
|
|
34
|
+
"project_id" => project, "operation" => operation,
|
|
35
|
+
"input_digest" => Atoms::ServiceInput.digest(input), "target" => target,
|
|
36
|
+
"candidate_head" => head, "caller_uid" => Process.uid}
|
|
37
|
+
binding["authorization"] = authorization
|
|
38
|
+
binding["service_id"] = service_id
|
|
39
|
+
binding["caller_uid"] = Process.uid
|
|
40
|
+
validate_attempt!(binding)
|
|
41
|
+
begin
|
|
42
|
+
operation_policy = policy.operation!(operation, project: project, service_id: service_id)
|
|
43
|
+
policy.authorize!(authorization, binding)
|
|
44
|
+
rescue SecurityError, Ace::Lab::InvalidConfigurationError
|
|
45
|
+
@coordinator.reject_service_request(binding, reason: "policy_rejected") unless dry_run
|
|
46
|
+
raise
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
if dry_run
|
|
50
|
+
preview = {"outcome" => "accepted", "dry_run" => true, "request_id" => request_id,
|
|
51
|
+
"project" => project, "operation" => operation, "service_id" => service_id,
|
|
52
|
+
"target" => target, "candidate_head" => head}
|
|
53
|
+
return {"status" => "ok", "data" => preview}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
claimed = @coordinator.claim_service_request(binding)
|
|
57
|
+
return result(claimed) unless claimed["state"] == "accepted" && claimed["journal_commit"]
|
|
58
|
+
|
|
59
|
+
# From here, a crash or lost executor response must not authorize a
|
|
60
|
+
# replay. Record uncertainty before invoking an external process.
|
|
61
|
+
@coordinator.transition_service_request(request_id, state: "uncertain")
|
|
62
|
+
receipt = @executor.execute(operation: operation_policy, request: binding, input: input,
|
|
63
|
+
policy: policy, authorization: authorization)
|
|
64
|
+
return result(@coordinator.service_request_status(request_id)) if receipt.nil?
|
|
65
|
+
|
|
66
|
+
state = receipt.fetch("outcome")
|
|
67
|
+
full_receipt = Models::ServiceReceipt.build(binding, receipt)
|
|
68
|
+
result(@coordinator.transition_service_request(request_id, state: state, receipt: full_receipt))
|
|
69
|
+
rescue ArgumentError => e
|
|
70
|
+
failure("invalid_input", e.message)
|
|
71
|
+
rescue SecurityError => e
|
|
72
|
+
failure("unauthorized", e.message)
|
|
73
|
+
rescue Ace::Lab::InvalidConfigurationError => e
|
|
74
|
+
failure("invalid_configuration", e.message)
|
|
75
|
+
rescue Ace::Assign::AttemptErrors::Conflict => e
|
|
76
|
+
failure("conflict", e.message)
|
|
77
|
+
rescue Ace::Assign::AttemptErrors::NotFound, Ace::Assign::AttemptErrors::ReceiptRejected => e
|
|
78
|
+
failure("invalid_attempt", e.message)
|
|
79
|
+
rescue Ace::Assign::AttemptErrors::InvalidState => e
|
|
80
|
+
failure("invalid_attempt", e.message)
|
|
81
|
+
rescue Ace::Assign::AttemptErrors::EvidenceUnavailable => e
|
|
82
|
+
failure("evidence_unavailable", e.message)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def status(request_id:)
|
|
86
|
+
request = @coordinator.service_request_status(request_id)
|
|
87
|
+
return failure("missing", "service request not found") unless request
|
|
88
|
+
return failure("unauthorized", "caller does not own the request") unless request["caller_uid"] == Process.uid
|
|
89
|
+
visibility = @topology.services(project: request["project_id"])
|
|
90
|
+
return visibility.envelope unless visibility.ok?
|
|
91
|
+
result(request)
|
|
92
|
+
rescue ArgumentError => e
|
|
93
|
+
failure("invalid_input", e.message)
|
|
94
|
+
rescue Ace::Assign::AttemptErrors::EvidenceUnavailable => e
|
|
95
|
+
failure("evidence_unavailable", e.message)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def validate_attempt!(binding)
|
|
101
|
+
attempt = @coordinator.service_attempt(binding)
|
|
102
|
+
unless attempt.candidate_head.nil? || attempt.candidate_head == binding["candidate_head"]
|
|
103
|
+
raise SecurityError, "request is not bound to an active managed attempt"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def current_head
|
|
108
|
+
out, _stderr, status = Open3.capture3("git", "rev-parse", "HEAD", chdir: @repo_root)
|
|
109
|
+
raise SecurityError, "candidate head is unavailable" unless status.success?
|
|
110
|
+
out.strip
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def result(request)
|
|
114
|
+
public = request.slice("request_id", "assignment_id", "attempt_id", "project_id", "operation",
|
|
115
|
+
"input_digest", "target", "candidate_head", "service_id", "state", "receipt")
|
|
116
|
+
{"status" => "ok", "data" => public.merge("outcome" => request.fetch("state"))}
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def failure(code, message)
|
|
120
|
+
{"status" => "error", "error" => {"code" => code, "message" => message}}
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
data/lib/ace/lab/version.rb
CHANGED
data/lib/ace/lab.rb
CHANGED
|
@@ -16,16 +16,21 @@ end
|
|
|
16
16
|
require_relative "lab/atoms/topology_schema"
|
|
17
17
|
require_relative "lab/atoms/binding_freshness"
|
|
18
18
|
require_relative "lab/atoms/public_projection"
|
|
19
|
+
require_relative "lab/atoms/service_input"
|
|
20
|
+
require_relative "lab/models/service_receipt"
|
|
19
21
|
require_relative "lab/models/runtime_binding"
|
|
20
22
|
require_relative "lab/models/topology_entry"
|
|
21
23
|
require_relative "lab/models/query_result"
|
|
22
24
|
require_relative "lab/molecules/topology_loader"
|
|
23
25
|
require_relative "lab/molecules/caller_authorizer"
|
|
24
26
|
require_relative "lab/molecules/grant_resolver"
|
|
27
|
+
require_relative "lab/molecules/service_policy"
|
|
28
|
+
require_relative "lab/molecules/service_executor"
|
|
25
29
|
require_relative "lab/molecules/inventory_query"
|
|
26
30
|
require_relative "lab/molecules/exact_resolver"
|
|
27
31
|
require_relative "lab/molecules/capability_router"
|
|
28
32
|
require_relative "lab/organisms/topology_service"
|
|
33
|
+
require_relative "lab/organisms/service_request_service"
|
|
29
34
|
require_relative "lab/cli"
|
|
30
35
|
|
|
31
36
|
module Ace
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ace-lab
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Michal Czyz
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-
|
|
10
|
+
date: 2026-10-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: ace-support-cli
|
|
@@ -51,6 +51,20 @@ dependencies:
|
|
|
51
51
|
- - "~>"
|
|
52
52
|
- !ruby/object:Gem::Version
|
|
53
53
|
version: '0.18'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: ace-assign
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '0.61'
|
|
61
|
+
type: :runtime
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '0.61'
|
|
54
68
|
- !ruby/object:Gem::Dependency
|
|
55
69
|
name: ace-support-test-helpers
|
|
56
70
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -155,23 +169,29 @@ files:
|
|
|
155
169
|
- lib/ace/lab.rb
|
|
156
170
|
- lib/ace/lab/atoms/binding_freshness.rb
|
|
157
171
|
- lib/ace/lab/atoms/public_projection.rb
|
|
172
|
+
- lib/ace/lab/atoms/service_input.rb
|
|
158
173
|
- lib/ace/lab/atoms/topology_schema.rb
|
|
159
174
|
- lib/ace/lab/cli.rb
|
|
160
175
|
- lib/ace/lab/cli/commands/agents.rb
|
|
161
176
|
- lib/ace/lab/cli/commands/projects.rb
|
|
162
177
|
- lib/ace/lab/cli/commands/resolve.rb
|
|
163
178
|
- lib/ace/lab/cli/commands/route.rb
|
|
179
|
+
- lib/ace/lab/cli/commands/service.rb
|
|
164
180
|
- lib/ace/lab/cli/commands/services.rb
|
|
165
181
|
- lib/ace/lab/cli/commands/support.rb
|
|
166
182
|
- lib/ace/lab/models/query_result.rb
|
|
167
183
|
- lib/ace/lab/models/runtime_binding.rb
|
|
184
|
+
- lib/ace/lab/models/service_receipt.rb
|
|
168
185
|
- lib/ace/lab/models/topology_entry.rb
|
|
169
186
|
- lib/ace/lab/molecules/caller_authorizer.rb
|
|
170
187
|
- lib/ace/lab/molecules/capability_router.rb
|
|
171
188
|
- lib/ace/lab/molecules/exact_resolver.rb
|
|
172
189
|
- lib/ace/lab/molecules/grant_resolver.rb
|
|
173
190
|
- lib/ace/lab/molecules/inventory_query.rb
|
|
191
|
+
- lib/ace/lab/molecules/service_executor.rb
|
|
192
|
+
- lib/ace/lab/molecules/service_policy.rb
|
|
174
193
|
- lib/ace/lab/molecules/topology_loader.rb
|
|
194
|
+
- lib/ace/lab/organisms/service_request_service.rb
|
|
175
195
|
- lib/ace/lab/organisms/topology_service.rb
|
|
176
196
|
- lib/ace/lab/version.rb
|
|
177
197
|
homepage: https://github.com/cs3b/ace
|