ace-lab 0.6.0 → 0.7.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 +13 -0
- data/lib/ace/lab/cli/commands/service.rb +80 -0
- data/lib/ace/lab/cli.rb +3 -1
- data/lib/ace/lab/molecules/forge_merge_config.rb +127 -0
- data/lib/ace/lab/molecules/forgejo_merge_adapter.rb +150 -0
- data/lib/ace/lab/molecules/grant_resolver.rb +15 -13
- data/lib/ace/lab/molecules/ordinary_admission_resolver.rb +112 -0
- data/lib/ace/lab/molecules/ordinary_ff_merge.rb +86 -0
- data/lib/ace/lab/molecules/ordinary_listener.rb +238 -0
- data/lib/ace/lab/molecules/ordinary_receiver_config.rb +86 -0
- data/lib/ace/lab/organisms/ordinary_merge_receiver.rb +663 -0
- data/lib/ace/lab/version.rb +1 -1
- metadata +25 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d3799161a4575055b78e316deb18a4fa952fb1d84fa9fb10cd6e5c081a36acee
|
|
4
|
+
data.tar.gz: dd2d0fa5764e5bce423396e93e4fd1751fddd880649219d6a130306d0ec49667
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1556efc67f0fa45a412b40430570355578c7e61000bda7af07982345631f023c3f56c709d2d02a25c3faf84495b510d6fb171a904d8f910e7c336b4b8113cd44
|
|
7
|
+
data.tar.gz: a78076debb811ce94046ff8cb056648b6455f8dbe30514a5d575ac417bcf98adce21bb42c3f6f712f0d0e2d6d4bbb3c67ba3764dfb9e4138cb6eb17e6dd6a113
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.7.0] - 2026-10-16
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Run the ordinary merge listener and receiver through `service listen`, with authenticated admission, canonical Assign acknowledgement/status/receipts, restart reconciliation and bounded fixed execution.
|
|
15
|
+
- Invoke and inspect real Forgejo merges under the installed scoped service identity; revalidate authorization per request and bind the provider mutation to the approved candidate head.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Require ace-assign 0.69 and ace-git-forgejo 0.7 for the receiver record-listing and scoped provider APIs.
|
|
20
|
+
|
|
21
|
+
- Move the Forgejo merge provider ownership out of ace-lab (xz9.5): `ForgejoMergeAdapter` is now a thin delegation to the `ace-git-forgejo` provider (`Ace::Git::Forgejo::PullRequestApi` over its `HttpClient`, under the host-installed scoped service identity) — HTTP transport, authentication, payload shape, and failure classification live in the provider package, while the adapter keeps the receiver's closed outcome translation (`:invoked`/`:refused`/`:ambiguous`/`Unavailable`), the exact-head binding guard, and the normalized readback/nil-absence contract unchanged.
|
|
22
|
+
|
|
10
23
|
## [0.6.0] - 2026-10-14
|
|
11
24
|
|
|
12
25
|
### Added
|
|
@@ -6,6 +6,9 @@ require "ace/assign"
|
|
|
6
6
|
require_relative "support"
|
|
7
7
|
require_relative "../../organisms/protected_service_request"
|
|
8
8
|
require_relative "../../organisms/ordinary_merge_request"
|
|
9
|
+
require_relative "../../organisms/ordinary_merge_receiver"
|
|
10
|
+
require_relative "../../molecules/ordinary_listener"
|
|
11
|
+
require_relative "../../molecules/ordinary_receiver_config"
|
|
9
12
|
|
|
10
13
|
module Ace
|
|
11
14
|
module Lab
|
|
@@ -169,6 +172,83 @@ module Ace
|
|
|
169
172
|
@ordinary_producer ||= Organisms::OrdinaryMergeRequest.new
|
|
170
173
|
end
|
|
171
174
|
end
|
|
175
|
+
|
|
176
|
+
# The ordinary receiver serve entrypoint (spec 8x3.t.xz9.4
|
|
177
|
+
# execution clauses; 8x3.t.xz9.5 integration handoff): binds the
|
|
178
|
+
# configured unix socket, mutually authenticates every connection
|
|
179
|
+
# through SO_PEERCRED, admits through the canonical Assign
|
|
180
|
+
# coordinator, executes the authorized fixed operation — one REAL
|
|
181
|
+
# Forgejo merge under the scoped host-installed service identity —
|
|
182
|
+
# and settles the exact terminal receipt proven by real forge
|
|
183
|
+
# readback. Configuration is one explicit deployment-owned file
|
|
184
|
+
# plus the host-installed authorization and forge-merge documents;
|
|
185
|
+
# the host owns their placement and credentials. Blocks until
|
|
186
|
+
# stopped.
|
|
187
|
+
class Listen < Ace::Support::Cli::Command
|
|
188
|
+
include Ace::Support::Cli::Base
|
|
189
|
+
include Output
|
|
190
|
+
|
|
191
|
+
desc "Serve the ordinary fixed merge receiver on its configured socket"
|
|
192
|
+
option :mode, type: :string, required: true,
|
|
193
|
+
desc: "ordinary-merge selects the ordinary fixed merge receiver"
|
|
194
|
+
option :project, type: :string, required: true, desc: "Project stable ID"
|
|
195
|
+
option :config, type: :string, required: true,
|
|
196
|
+
desc: "Deployment-owned receiver configuration file (YAML)"
|
|
197
|
+
|
|
198
|
+
def call(mode:, project:, config:, **options)
|
|
199
|
+
unless options.empty?
|
|
200
|
+
return emit(Organisms::OrdinaryMergeRequest.refusal_envelope(
|
|
201
|
+
"ordinary_merge_invalid_request",
|
|
202
|
+
"unknown listen selector: #{options.keys.sort.join(", ")}",
|
|
203
|
+
request_id: "unknown", input_digest: nil
|
|
204
|
+
))
|
|
205
|
+
end
|
|
206
|
+
unless mode == Organisms::OrdinaryMergeRequest::MODE
|
|
207
|
+
return emit(Organisms::OrdinaryMergeRequest.refusal_envelope(
|
|
208
|
+
"ordinary_merge_invalid_request", "unknown service mode",
|
|
209
|
+
request_id: "unknown", input_digest: nil
|
|
210
|
+
))
|
|
211
|
+
end
|
|
212
|
+
receiver_config = Molecules::OrdinaryReceiverConfig.load(config)
|
|
213
|
+
unless receiver_config.project == project
|
|
214
|
+
return emit(Organisms::OrdinaryMergeRequest.refusal_envelope(
|
|
215
|
+
"ordinary_merge_selection_mismatch",
|
|
216
|
+
"configured project does not match the requested project",
|
|
217
|
+
request_id: "unknown", input_digest: nil
|
|
218
|
+
))
|
|
219
|
+
end
|
|
220
|
+
policy_document = Molecules::GrantResolver.trusted_document(
|
|
221
|
+
receiver_config.policy_path
|
|
222
|
+
)
|
|
223
|
+
# The ONE canonical Assign journal: the coordinator binds the
|
|
224
|
+
# evidence ref of the canonical repository the receiver is
|
|
225
|
+
# installed for (config repo_path — the producer project's
|
|
226
|
+
# canonical checkout, the same authoritative journal the
|
|
227
|
+
# producer coordinator uses). Never an integration-checkout
|
|
228
|
+
# duplicate; the service identity's reviewed permissions are
|
|
229
|
+
# journal ref read/append, evidence checkout and no-effect
|
|
230
|
+
# attestation writes inside that repository.
|
|
231
|
+
coordinator = Ace::Assign::Organisms::AttemptCoordinator.new(
|
|
232
|
+
repo_root: receiver_config.repo_path
|
|
233
|
+
)
|
|
234
|
+
receiver = Organisms::OrdinaryMergeReceiver.new(
|
|
235
|
+
config: receiver_config, coordinator: coordinator,
|
|
236
|
+
policy_document: policy_document
|
|
237
|
+
)
|
|
238
|
+
listener = Molecules::OrdinaryListener.new(
|
|
239
|
+
socket_path: receiver_config.socket_path,
|
|
240
|
+
peer_uid: receiver_config.principal_uid,
|
|
241
|
+
handler: receiver
|
|
242
|
+
)
|
|
243
|
+
receiver.recover_pending!
|
|
244
|
+
listener.serve
|
|
245
|
+
rescue Ace::Lab::InvalidConfigurationError, Ace::Assign::AttemptErrors::EvidenceUnavailable => e
|
|
246
|
+
emit(Organisms::OrdinaryMergeRequest.refusal_envelope(
|
|
247
|
+
"ordinary_merge_unavailable", e.message,
|
|
248
|
+
request_id: "unknown", input_digest: nil
|
|
249
|
+
))
|
|
250
|
+
end
|
|
251
|
+
end
|
|
172
252
|
end
|
|
173
253
|
end
|
|
174
254
|
end
|
data/lib/ace/lab/cli.rb
CHANGED
|
@@ -26,7 +26,8 @@ module Ace
|
|
|
26
26
|
["resolve", "Resolve one lab entry by exact stable ID"],
|
|
27
27
|
["route", "Select a configured capable service in a project"],
|
|
28
28
|
["service request", "Request one authorized service operation"],
|
|
29
|
-
["service status", "Read a service request outcome"]
|
|
29
|
+
["service status", "Read a service request outcome"],
|
|
30
|
+
["service listen", "Serve the ordinary fixed merge receiver on its configured socket"]
|
|
30
31
|
].freeze
|
|
31
32
|
|
|
32
33
|
HELP_EXAMPLES = [
|
|
@@ -52,6 +53,7 @@ module Ace
|
|
|
52
53
|
register "route", CLI::Commands::Route.new
|
|
53
54
|
register "service request", CLI::Commands::Service::Request.new
|
|
54
55
|
register "service status", CLI::Commands::Service::Status.new
|
|
56
|
+
register "service listen", CLI::Commands::Service::Listen.new
|
|
55
57
|
|
|
56
58
|
# Register version command
|
|
57
59
|
version_cmd = Ace::Support::Cli::VersionCommand.build(
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require_relative "../errors"
|
|
5
|
+
require_relative "grant_resolver"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Lab
|
|
9
|
+
module Molecules
|
|
10
|
+
# The host-installed forge merge configuration for the ordinary merge
|
|
11
|
+
# receiver (spec 8x3.t.xz9.5 integration handoff). The scoped service
|
|
12
|
+
# identity that may merge pull requests is provisioned by the HOST in
|
|
13
|
+
# one root-owned file at a fixed path; this loader reads it through the
|
|
14
|
+
# same trusted-storage verification as the authorization grants and
|
|
15
|
+
# fails closed on anything else. An absent file is an immediate
|
|
16
|
+
# actionable refusal naming the path and its owner — never a silent
|
|
17
|
+
# local fallback and never an invented credential: a receiver without
|
|
18
|
+
# the host-installed identity cannot merge and must say so.
|
|
19
|
+
#
|
|
20
|
+
# Contract (host installs, root-owned 0640, service-readable):
|
|
21
|
+
#
|
|
22
|
+
# base_url: https://forgejo.example.net
|
|
23
|
+
# owner: cs3b
|
|
24
|
+
# repo: ace
|
|
25
|
+
# token: <scoped: PR read + PR merge + PR comment on that repo only>
|
|
26
|
+
# merge_timeout_seconds: 900
|
|
27
|
+
# escalation_owner: host-integrator
|
|
28
|
+
# review_attachment:
|
|
29
|
+
# enabled: true
|
|
30
|
+
class ForgeMergeConfig
|
|
31
|
+
DEFAULT_PATH = "/etc/lab/ace-lab/forge-merge.yml"
|
|
32
|
+
DEFAULT_ESCALATION_OWNER = "host-integrator"
|
|
33
|
+
KEYS = %w[base_url owner repo token merge_timeout_seconds escalation_owner
|
|
34
|
+
review_attachment].freeze
|
|
35
|
+
|
|
36
|
+
# @raise [Ace::Lab::InvalidConfigurationError] absent file ⇒ the
|
|
37
|
+
# actionable `config missing:` message naming the path and owner;
|
|
38
|
+
# every other trust/shape failure also fails closed.
|
|
39
|
+
def self.load(path = DEFAULT_PATH)
|
|
40
|
+
content = GrantResolver.read_verified(path)
|
|
41
|
+
unless content
|
|
42
|
+
raise Ace::Lab::InvalidConfigurationError,
|
|
43
|
+
"config missing: #{path} (owner: #{DEFAULT_ESCALATION_OWNER} — install the " \
|
|
44
|
+
"root-owned 0640 forge merge configuration for the scoped service identity)"
|
|
45
|
+
end
|
|
46
|
+
document = YAML.safe_load(content, permitted_classes: [Date], aliases: false)
|
|
47
|
+
new(document, source: path)
|
|
48
|
+
rescue Psych::Exception
|
|
49
|
+
raise Ace::Lab::InvalidConfigurationError,
|
|
50
|
+
"invalid lab configuration: forge merge configuration #{path} could not be parsed"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
attr_reader :base_url, :owner, :repo, :token, :merge_timeout_seconds, :escalation_owner,
|
|
54
|
+
:review_attachment_enabled, :source
|
|
55
|
+
|
|
56
|
+
def initialize(document, source: nil)
|
|
57
|
+
unless document.is_a?(Hash) && (document.keys - KEYS).empty? &&
|
|
58
|
+
(KEYS - document.keys).empty?
|
|
59
|
+
raise Ace::Lab::InvalidConfigurationError,
|
|
60
|
+
"forge merge configuration must contain exactly: #{KEYS.join(", ")}"
|
|
61
|
+
end
|
|
62
|
+
@source = source
|
|
63
|
+
@base_url = base_url!(document.fetch("base_url"))
|
|
64
|
+
@owner = name!(document.fetch("owner"), "owner")
|
|
65
|
+
@repo = name!(document.fetch("repo"), "repo")
|
|
66
|
+
@token = token!(document.fetch("token"))
|
|
67
|
+
@merge_timeout_seconds = timeout!(document.fetch("merge_timeout_seconds"))
|
|
68
|
+
@escalation_owner = name!(document.fetch("escalation_owner"), "escalation_owner")
|
|
69
|
+
attachment = document.fetch("review_attachment")
|
|
70
|
+
unless attachment.is_a?(Hash) && attachment.keys == %w[enabled] &&
|
|
71
|
+
[true, false].include?(attachment.fetch("enabled"))
|
|
72
|
+
raise Ace::Lab::InvalidConfigurationError,
|
|
73
|
+
"forge merge review_attachment must be exactly {enabled: true|false}"
|
|
74
|
+
end
|
|
75
|
+
@review_attachment_enabled = attachment.fetch("enabled")
|
|
76
|
+
freeze
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# The token is a live credential: never rendered, never logged.
|
|
80
|
+
def inspect
|
|
81
|
+
"#<#{self.class.name} base_url=#{@base_url} owner=#{@owner} repo=#{@repo} " \
|
|
82
|
+
"token=[REDACTED] merge_timeout_seconds=#{@merge_timeout_seconds} " \
|
|
83
|
+
"escalation_owner=#{@escalation_owner} " \
|
|
84
|
+
"review_attachment_enabled=#{@review_attachment_enabled}>"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def base_url!(value)
|
|
90
|
+
uri = URI.parse(value.to_s)
|
|
91
|
+
unless uri.is_a?(URI::HTTPS) && uri.host && uri.path.empty? && uri.to_s == value
|
|
92
|
+
raise Ace::Lab::InvalidConfigurationError,
|
|
93
|
+
"forge merge base_url must be an https origin without path, query or fragment"
|
|
94
|
+
end
|
|
95
|
+
value
|
|
96
|
+
rescue URI::Error
|
|
97
|
+
raise Ace::Lab::InvalidConfigurationError,
|
|
98
|
+
"forge merge base_url must be an https origin without path, query or fragment"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def name!(value, name)
|
|
102
|
+
unless value.is_a?(String) && !value.empty? && value.length <= 256 &&
|
|
103
|
+
value.match?(%r{\A[A-Za-z0-9._-]+\z})
|
|
104
|
+
raise Ace::Lab::InvalidConfigurationError, "forge merge #{name} is invalid"
|
|
105
|
+
end
|
|
106
|
+
value
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def token!(value)
|
|
110
|
+
unless value.is_a?(String) && value.length.between?(8, 512) &&
|
|
111
|
+
value.match?(%r{\A[A-Za-z0-9._~+/=-]+\z})
|
|
112
|
+
raise Ace::Lab::InvalidConfigurationError, "forge merge token is invalid"
|
|
113
|
+
end
|
|
114
|
+
value
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def timeout!(value)
|
|
118
|
+
unless value.is_a?(Integer) && value.positive? && value <= 86_400
|
|
119
|
+
raise Ace::Lab::InvalidConfigurationError,
|
|
120
|
+
"forge merge merge_timeout_seconds must be a positive integer"
|
|
121
|
+
end
|
|
122
|
+
value
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/git/forgejo"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Lab
|
|
7
|
+
module Molecules
|
|
8
|
+
# The receiver's forge merge boundary (spec 8x3.t.xz9.5 integration
|
|
9
|
+
# handoff): a THIN delegation to the `ace-git-forgejo` provider, which
|
|
10
|
+
# owns every Forgejo API/PR/review/merge/readback responsibility —
|
|
11
|
+
# HTTP transport, authentication, payload shape, and failure
|
|
12
|
+
# classification live behind `Ace::Git::Forgejo::PullRequestApi` and
|
|
13
|
+
# its `HttpClient`. This molecule holds no HTTP stack of its own: it
|
|
14
|
+
# binds the host-installed scoped service identity from
|
|
15
|
+
# {ForgeMergeConfig} to a repository-scoped provider client and
|
|
16
|
+
# translates the provider's closed outcomes into the receiver's
|
|
17
|
+
# closed outcome set (`:invoked`/`:refused`/`:ambiguous`/`Unavailable`).
|
|
18
|
+
#
|
|
19
|
+
# The receiver's one authorized effect is a real pull request merge on
|
|
20
|
+
# the real forge, invoked under the scoped service identity, and every
|
|
21
|
+
# terminal outcome is proven by a real readback — a local git effect is
|
|
22
|
+
# never presented as a forge result.
|
|
23
|
+
#
|
|
24
|
+
# Outcome translation (provider closed world → receiver closed world):
|
|
25
|
+
#
|
|
26
|
+
# - `2xx` mutation outcome → `:invoked`; a provider refusal outcome
|
|
27
|
+
# (404/405/409/422 — including the server-side exact-head refusal
|
|
28
|
+
# for a moved head) → `:refused`; an authentication error (the
|
|
29
|
+
# provider raises the shared taxonomy for 401/403) → `:refused` —
|
|
30
|
+
# a definitive refusal can never have merged.
|
|
31
|
+
# - An unknown mutation outcome (5xx responses and transport failures
|
|
32
|
+
# on the POST — the forge may or may not have processed it) →
|
|
33
|
+
# `:ambiguous`; the receiver reconciles by readback, never by
|
|
34
|
+
# repeating the merge.
|
|
35
|
+
# - Every unverifiable read outcome (unreachable forge, malformed
|
|
36
|
+
# evidence, misrouted payload, unusable endpoint) raises
|
|
37
|
+
# `Unavailable`, because the true state cannot be proven. The token
|
|
38
|
+
# is never rendered in any error (provider-sanitized messages only).
|
|
39
|
+
#
|
|
40
|
+
# The exact-head contract is carried by the provider's native merge
|
|
41
|
+
# signature: the POST body binds `head_commit_id` to the approved
|
|
42
|
+
# candidate server-side, so a race between the state readback and the
|
|
43
|
+
# merge POST can never merge an unreviewed head. This molecule never
|
|
44
|
+
# substitutes another merge strategy and validates the binding before
|
|
45
|
+
# the provider is constructed.
|
|
46
|
+
class ForgejoMergeAdapter
|
|
47
|
+
# The single authorized merge strategy on the wire.
|
|
48
|
+
MERGE_METHOD = "fast-forward-only"
|
|
49
|
+
|
|
50
|
+
class Unavailable < ::StandardError; end
|
|
51
|
+
|
|
52
|
+
# outcome: :invoked | :refused | :ambiguous; status: Integer|nil;
|
|
53
|
+
# reason: closed refuse/ambiguity detail (token-free).
|
|
54
|
+
Result = Struct.new(:outcome, :status, :reason, keyword_init: true)
|
|
55
|
+
|
|
56
|
+
def initialize(base_url:, token:, timeout_seconds: 900)
|
|
57
|
+
@base_url = base_url.to_s.sub(%r{/+\z}, "")
|
|
58
|
+
@token = token
|
|
59
|
+
@timeout_seconds = timeout_seconds
|
|
60
|
+
freeze
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Invoke the eligible merge bound to the approved candidate head.
|
|
64
|
+
# `head_commit_id` is required and validated (full 40-character hex
|
|
65
|
+
# sha) BEFORE any provider construction: the forge server-side
|
|
66
|
+
# refuses to merge any head other than the bound one. Returns a
|
|
67
|
+
# Result; never raises for HTTP outcomes (ambiguity is a result, not
|
|
68
|
+
# an exception).
|
|
69
|
+
def merge_pull_request!(owner:, repo:, index:, head_commit_id:)
|
|
70
|
+
unless head_commit_id.is_a?(String) && head_commit_id.match?(/\A[0-9a-f]{40}\z/)
|
|
71
|
+
raise ArgumentError,
|
|
72
|
+
"head_commit_id must be a full 40-character lowercase hex sha " \
|
|
73
|
+
"(got: #{head_commit_id.inspect})"
|
|
74
|
+
end
|
|
75
|
+
translate_mutation do
|
|
76
|
+
provider(owner, repo).merge_pull_request(index,
|
|
77
|
+
do_method: MERGE_METHOD, head_commit_id: head_commit_id)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The PR state readback, normalized to the receiver's closed
|
|
82
|
+
# PR-state shape (`merged`, `merge_commit_sha`, head/base shas) or
|
|
83
|
+
# nil when the forge definitively reports the PR absent (404 — the
|
|
84
|
+
# provider raises the absence taxonomy). Raises Unavailable for
|
|
85
|
+
# every unverifiable outcome.
|
|
86
|
+
def pull_request(owner:, repo:, index:)
|
|
87
|
+
payload = provider(owner, repo).pull_request(index)
|
|
88
|
+
{
|
|
89
|
+
"merged" => payload["merged"] == true,
|
|
90
|
+
"merge_commit_sha" => payload["merge_commit_sha"].is_a?(String) ?
|
|
91
|
+
payload["merge_commit_sha"] : nil,
|
|
92
|
+
"head" => {"sha" => payload.dig("head", "sha").is_a?(String) ?
|
|
93
|
+
payload.dig("head", "sha") : nil},
|
|
94
|
+
"base" => {"sha" => payload.dig("base", "sha").is_a?(String) ?
|
|
95
|
+
payload.dig("base", "sha") : nil}
|
|
96
|
+
}
|
|
97
|
+
rescue Ace::Git::ProviderObjectNotFoundError
|
|
98
|
+
nil
|
|
99
|
+
rescue Ace::Git::Error => e
|
|
100
|
+
raise Unavailable, e.message
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Attach one comment to the PR (the independent final-head review
|
|
104
|
+
# body handed over by the request — never the executor's own
|
|
105
|
+
# approval).
|
|
106
|
+
def create_comment(owner:, repo:, index:, body:)
|
|
107
|
+
translate_mutation do
|
|
108
|
+
provider(owner, repo).create_comment(index, body)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
# One repository-scoped provider client per exchange: the origin is
|
|
115
|
+
# narrowed with the forge identity this receiver already pinned
|
|
116
|
+
# against the host-installed configuration, and the scoped token
|
|
117
|
+
# rides the provider transport instead of any local credential.
|
|
118
|
+
def provider(owner, repo)
|
|
119
|
+
Ace::Git::Forgejo::PullRequestApi.new(
|
|
120
|
+
server: Ace::Git::ResolvedServer.new(name: "lab-forge-merge",
|
|
121
|
+
provider: :forgejo, url: "#{@base_url}/#{owner}/#{repo}"),
|
|
122
|
+
timeout: @timeout_seconds, token: @token
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Closed mutation translation: the provider returns an outcome only
|
|
127
|
+
# for statuses whose meaning is endpoint-specific (2xx success and
|
|
128
|
+
# the refusal family); everything else it raises as the shared
|
|
129
|
+
# taxonomy, which maps to :refused (authentication — a definitive
|
|
130
|
+
# refusal), :ambiguous (unknown outcome), or Unavailable (a read
|
|
131
|
+
# classified as unreachable).
|
|
132
|
+
def translate_mutation
|
|
133
|
+
outcome = yield
|
|
134
|
+
if (200..299).cover?(outcome.status)
|
|
135
|
+
Result.new(outcome: :invoked, status: outcome.status, reason: nil)
|
|
136
|
+
else
|
|
137
|
+
Result.new(outcome: :refused, status: outcome.status,
|
|
138
|
+
reason: outcome.message || "HTTP #{outcome.status}")
|
|
139
|
+
end
|
|
140
|
+
rescue Ace::Git::ProviderAuthenticationError => e
|
|
141
|
+
Result.new(outcome: :refused, status: nil, reason: e.message)
|
|
142
|
+
rescue Ace::Git::ProviderUnknownOutcomeError => e
|
|
143
|
+
Result.new(outcome: :ambiguous, status: nil, reason: e.message)
|
|
144
|
+
rescue Ace::Git::Error => e
|
|
145
|
+
raise Unavailable, e.message
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -64,19 +64,13 @@ module Ace
|
|
|
64
64
|
parse_grants(content, trusted_path)
|
|
65
65
|
end
|
|
66
66
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
# Read the grants document only after proving the deployment owns
|
|
76
|
-
# the storage along the original path. Genuine absence is nil
|
|
77
|
-
# (nobody authorized); every other filesystem failure — including
|
|
78
|
-
# races between lstat and open — classifies fail-closed
|
|
79
|
-
# (review rounds 6-7, F2).
|
|
67
|
+
# Read a deployment-owned file only after proving the deployment
|
|
68
|
+
# owns the storage along the original path (same trust rules as
|
|
69
|
+
# `resolve`). Genuine absence is nil; every other filesystem
|
|
70
|
+
# failure — including races between lstat and open — classifies
|
|
71
|
+
# fail-closed (review rounds 6-7, F2). Public so other trusted
|
|
72
|
+
# host-installed documents (e.g. the forge merge service identity)
|
|
73
|
+
# read through the same verification.
|
|
80
74
|
# @return [String, nil] file content, or nil when absent
|
|
81
75
|
def read_verified(path)
|
|
82
76
|
candidate = begin
|
|
@@ -104,6 +98,14 @@ module Ace
|
|
|
104
98
|
end
|
|
105
99
|
end
|
|
106
100
|
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def verify_error(path)
|
|
104
|
+
"invalid lab configuration: trusted authorization file #{path} failed the deployment " \
|
|
105
|
+
"ownership verification or could not be read; failing closed " \
|
|
106
|
+
"(required: every traversed element root-owned and not group/world-writable)"
|
|
107
|
+
end
|
|
108
|
+
|
|
107
109
|
# Walk the ORIGINAL path component by component. Symlinks are
|
|
108
110
|
# followed only when the link itself is root-owned (a caller-writable
|
|
109
111
|
# redirect is rejected); symlink permission bits are ignored because
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "service_policy"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Lab
|
|
8
|
+
module Molecules
|
|
9
|
+
# The scoped ordinary-merge decision/admission resolver (spec 8x3.t.xz9.4,
|
|
10
|
+
# execution clauses). The deployment-owned YAML authorization file remains
|
|
11
|
+
# the sole source of truth; this resolver only makes the exact root
|
|
12
|
+
# authorizations natively executable inside the receiver: one per-request
|
|
13
|
+
# authorization selector resolves to a typed decision within the closed
|
|
14
|
+
# refusal-code family — or it refuses. Scoping rules:
|
|
15
|
+
#
|
|
16
|
+
# - only `operation: merge` decisions of the ordinary shape resolve;
|
|
17
|
+
# anything else (missing reference, shape mismatch, non-merge
|
|
18
|
+
# operation, any binding mismatch, expiry) is a typed
|
|
19
|
+
# `ordinary_merge_authorization_invalid` decision;
|
|
20
|
+
# - exact binding match on every closed key; there are NO wildcards,
|
|
21
|
+
# defaults, prefix grants or broad grants of any kind — a decision
|
|
22
|
+
# authorizes exactly one bound request and nothing else;
|
|
23
|
+
# - expiry bounds the decision itself.
|
|
24
|
+
#
|
|
25
|
+
# Per-request refresh (spec 8x3.t.xz9.5 integration handoff): a
|
|
26
|
+
# long-lived receiver would otherwise decide every sequential candidate
|
|
27
|
+
# against its boot-time snapshot of the authorization document. When a
|
|
28
|
+
# trusted path is installed, every decision re-reads the document
|
|
29
|
+
# through the trusted loader and resolves against the FRESH grants —
|
|
30
|
+
# a root-revoked or expired decision fails closed at the next request
|
|
31
|
+
# without any receiver restart. The refresh is scoped: it reloads and
|
|
32
|
+
# re-validates the exact-request grant only; it never broadens
|
|
33
|
+
# authorization beyond the resolved reference.
|
|
34
|
+
class OrdinaryAdmissionResolver
|
|
35
|
+
BINDING_KEYS = ServicePolicy::ORDINARY_BINDING_KEYS.freeze
|
|
36
|
+
DECISION_KEYS = ServicePolicy::ORDINARY_AUTHORIZATION_KEYS.freeze
|
|
37
|
+
OPERATION = "merge"
|
|
38
|
+
|
|
39
|
+
Decision = Struct.new(:authorized?, :decision, :code, :message, keyword_init: true)
|
|
40
|
+
|
|
41
|
+
# @param policy_document [Hash] boot-time trusted document (the
|
|
42
|
+
# configuration digest is derived from it once, upstream)
|
|
43
|
+
# @param path [String, nil] trusted policy path to re-read per decision
|
|
44
|
+
# @param loader [Proc, nil] trusted reader (default: GrantResolver);
|
|
45
|
+
# the only override point, for non-privileged test documents
|
|
46
|
+
def initialize(policy_document, path: nil, loader: nil)
|
|
47
|
+
unless policy_document.is_a?(Hash) && policy_document["authorizations"].is_a?(Hash)
|
|
48
|
+
raise Ace::Lab::InvalidConfigurationError,
|
|
49
|
+
"trusted policy document must contain an authorizations mapping"
|
|
50
|
+
end
|
|
51
|
+
@authorizations = policy_document.fetch("authorizations")
|
|
52
|
+
@path = path
|
|
53
|
+
@loader = loader || ->(trusted_path) { GrantResolver.trusted_document(trusted_path) }
|
|
54
|
+
freeze
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Resolve one authorization selector against the exact ordinary merge
|
|
58
|
+
# request binding. Returns a typed Decision; `authorized?` is true only
|
|
59
|
+
# for an exact, unexpired, ordinary-merge-shaped match.
|
|
60
|
+
def decide(reference, binding)
|
|
61
|
+
authorizations = current_authorizations
|
|
62
|
+
unless reference.is_a?(String) && reference.match?(ServicePolicy::ID)
|
|
63
|
+
return refuse("invalid authorization reference")
|
|
64
|
+
end
|
|
65
|
+
unless binding.is_a?(Hash) && binding.fetch("operation", nil) == OPERATION
|
|
66
|
+
return refuse("only the ordinary merge operation is resolvable")
|
|
67
|
+
end
|
|
68
|
+
decision = authorizations[reference]
|
|
69
|
+
return refuse("authorization reference is unresolved") unless decision.is_a?(Hash)
|
|
70
|
+
unless DECISION_KEYS.all? { |key| decision.key?(key) }
|
|
71
|
+
return refuse("authorization reference is not an ordinary merge decision")
|
|
72
|
+
end
|
|
73
|
+
unless decision.fetch("operation") == OPERATION
|
|
74
|
+
return refuse("authorization does not cover the ordinary merge operation")
|
|
75
|
+
end
|
|
76
|
+
mismatch = BINDING_KEYS.find { |key| decision.fetch(key) != binding.fetch(key, nil) }
|
|
77
|
+
return refuse("authorization does not match the exact ordinary merge request") if mismatch
|
|
78
|
+
begin
|
|
79
|
+
expired = Time.iso8601(decision.fetch("expires_at").to_s) <= Time.now.utc
|
|
80
|
+
rescue ArgumentError
|
|
81
|
+
return refuse("authorization decision has an invalid expiry")
|
|
82
|
+
end
|
|
83
|
+
return refuse("authorization has expired") if expired
|
|
84
|
+
Decision.new(authorized?: true, decision: decision, code: nil, message: nil)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
# The authorizations mapping the CURRENT decision resolves against:
|
|
90
|
+
# the boot snapshot when no trusted path is installed, otherwise the
|
|
91
|
+
# freshly re-read document. A failed trusted re-read fails closed
|
|
92
|
+
# (InvalidConfigurationError — the unavailable family), never falls
|
|
93
|
+
# back to the stale snapshot.
|
|
94
|
+
def current_authorizations
|
|
95
|
+
return @authorizations unless @path
|
|
96
|
+
document = @loader.call(@path)
|
|
97
|
+
unless document.is_a?(Hash) && document["authorizations"].is_a?(Hash)
|
|
98
|
+
raise Ace::Lab::InvalidConfigurationError,
|
|
99
|
+
"invalid lab configuration: trusted authorization refresh at #{@path} " \
|
|
100
|
+
"did not yield an authorizations mapping"
|
|
101
|
+
end
|
|
102
|
+
document.fetch("authorizations")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def refuse(message)
|
|
106
|
+
Decision.new(authorized?: false, decision: nil,
|
|
107
|
+
code: "ordinary_merge_authorization_invalid", message: message)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Lab
|
|
7
|
+
module Molecules
|
|
8
|
+
# The fixed merge method executor (spec 8x3.t.xz9.4, merge-method
|
|
9
|
+
# contract). The closed request schema admits literal `method=merge`;
|
|
10
|
+
# the installed executor gives that method FAST-FORWARD-ONLY semantics:
|
|
11
|
+
# the candidate head must be a descendant of the current base head.
|
|
12
|
+
# A non-fast-forwardable candidate is NOT merged silently and never
|
|
13
|
+
# falls back to a merge commit — it reports `:non_fast_forward` so the
|
|
14
|
+
# receiver answers in the moved-target/needs-rebase refusal family.
|
|
15
|
+
# The mutation itself is one atomic compare-and-swap on the base ref
|
|
16
|
+
# (`git update-ref <base> <candidate> <expected>`), so concurrent
|
|
17
|
+
# writers can never both move the ref: the loser provably performed
|
|
18
|
+
# no effect. Under this strategy the resulting base head equals the
|
|
19
|
+
# candidate head, and the receipt joins `merge_commit ==
|
|
20
|
+
# resulting_target_head == merged_candidate_head` — that join is the
|
|
21
|
+
# receipt's fast-forward proof. Any future non-ff strategy is a
|
|
22
|
+
# separate reviewed contract extension, never implicit.
|
|
23
|
+
class OrdinaryFfMerge
|
|
24
|
+
STRATEGY = "ff"
|
|
25
|
+
|
|
26
|
+
Result = Struct.new(:outcome, :resulting_head, :previous_head, :reason, keyword_init: true)
|
|
27
|
+
|
|
28
|
+
def initialize(repo_path:, base_ref:)
|
|
29
|
+
unless repo_path.is_a?(String) && repo_path.start_with?("/") &&
|
|
30
|
+
base_ref.is_a?(String) && base_ref.start_with?("refs/")
|
|
31
|
+
raise ArgumentError, "ordinary ff merge needs an absolute repo path and a full base ref"
|
|
32
|
+
end
|
|
33
|
+
@repo_path = repo_path
|
|
34
|
+
@base_ref = base_ref
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def base_head
|
|
38
|
+
out, stderr, status = git("rev-parse", @base_ref)
|
|
39
|
+
return out.strip if status.success?
|
|
40
|
+
raise GitUnavailable, "base ref #{@base_ref} is unreadable: #{stderr.strip[0, 200]}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def candidate_commit?(sha)
|
|
44
|
+
git("cat-file", "-e", "#{sha}^{commit}").last.success?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# True only when the candidate is a descendant of (or equal to) the
|
|
48
|
+
# current base head — the fast-forward precondition.
|
|
49
|
+
def fast_forwardable?(candidate_head)
|
|
50
|
+
git("merge-base", "--is-ancestor", @base_ref, candidate_head).last.success?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The single effect: one compare-and-swap ref update. Returns a
|
|
54
|
+
# Result with outcome `:merged`, `:already_merged`, or `:race_lost`
|
|
55
|
+
# (the expected head moved before the swap — provably no effect from
|
|
56
|
+
# this invocation). Raises GitUnavailable when git itself fails.
|
|
57
|
+
def merge!(candidate_head, expected_head)
|
|
58
|
+
current = base_head
|
|
59
|
+
return Result.new(outcome: :already_merged, resulting_head: current,
|
|
60
|
+
previous_head: current, reason: nil) if current == candidate_head
|
|
61
|
+
unless current == expected_head
|
|
62
|
+
return Result.new(outcome: :race_lost, resulting_head: current,
|
|
63
|
+
previous_head: current, reason: "base head moved before the swap")
|
|
64
|
+
end
|
|
65
|
+
out, stderr, status = git("update-ref", @base_ref, candidate_head, expected_head)
|
|
66
|
+
unless status.success?
|
|
67
|
+
return Result.new(outcome: :race_lost, resulting_head: base_head,
|
|
68
|
+
previous_head: base_head, reason: "compare-and-swap lost: #{stderr.strip[0, 200]}")
|
|
69
|
+
end
|
|
70
|
+
Result.new(outcome: :merged, resulting_head: candidate_head,
|
|
71
|
+
previous_head: expected_head, reason: nil)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
class GitUnavailable < ::StandardError; end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def git(*argv)
|
|
79
|
+
Open3.capture3("git", *argv, chdir: @repo_path)
|
|
80
|
+
rescue Errno::ENOENT => e
|
|
81
|
+
raise GitUnavailable, "git is unavailable: #{e.class}"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|