ace-lab 0.4.0 → 0.5.1
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 +20 -0
- data/docs/usage.md +10 -0
- data/lib/ace/lab/atoms/protected_publication_input.rb +50 -0
- data/lib/ace/lab/atoms/protected_workspace_prune_input.rb +114 -0
- data/lib/ace/lab/atoms/protected_workspace_prune_preview.rb +86 -0
- data/lib/ace/lab/atoms/service_input.rb +20 -8
- data/lib/ace/lab/authorization_path.rb +20 -0
- data/lib/ace/lab/cli/commands/service.rb +45 -0
- data/lib/ace/lab/errors.rb +11 -0
- data/lib/ace/lab/molecules/grant_resolver.rb +2 -0
- data/lib/ace/lab/molecules/protected_cleanup_owner_admission.rb +163 -0
- data/lib/ace/lab/molecules/protected_cleanup_owner_client.rb +165 -0
- data/lib/ace/lab/molecules/protected_cleanup_owner_identity.rb +239 -0
- data/lib/ace/lab/molecules/protected_publication_publisher.rb +110 -0
- data/lib/ace/lab/molecules/protected_service_handler.rb +9 -40
- data/lib/ace/lab/molecules/protected_service_policy.rb +49 -5
- data/lib/ace/lab/molecules/service_policy.rb +26 -0
- data/lib/ace/lab/organisms/authority_composition.rb +25 -4
- data/lib/ace/lab/organisms/protected_cleanup_owner.rb +361 -0
- data/lib/ace/lab/organisms/protected_service_client.rb +129 -0
- data/lib/ace/lab/organisms/protected_service_listener.rb +181 -0
- data/lib/ace/lab/organisms/protected_service_publication.rb +268 -0
- data/lib/ace/lab/organisms/protected_service_receiver.rb +101 -11
- data/lib/ace/lab/organisms/protected_service_recovery.rb +185 -0
- data/lib/ace/lab/organisms/protected_service_request.rb +128 -0
- data/lib/ace/lab/organisms/protected_service_worker.rb +89 -0
- data/lib/ace/lab/version.rb +1 -1
- data/lib/ace/lab.rb +2 -22
- metadata +36 -6
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "protected_service_listener"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Lab
|
|
7
|
+
module Organisms
|
|
8
|
+
# Worker-side short claim exchange. No local journal/executor fallback;
|
|
9
|
+
# outcome/recovery remains the existing protected authority service API.
|
|
10
|
+
class ProtectedServiceClient
|
|
11
|
+
def initialize(mapping_id:, service_id:, deployment: Ace::Assign::Authority::Deployment.load,
|
|
12
|
+
kernel: Ace::Runtime::Molecules::ProtectedLinux.new, wire: Ace::Runtime::Molecules::ProtectedSocket)
|
|
13
|
+
@deployment, @kernel, @wire, @mapping_id = deployment, kernel, wire, mapping_id
|
|
14
|
+
@map = deployment.mapping(mapping_id)
|
|
15
|
+
project = deployment.project(@map.fetch("project_id"))
|
|
16
|
+
@selection = project.fetch("service_receivers").fetch(service_id)
|
|
17
|
+
@credentials = project.fetch("peer_credentials").fetch(@selection.fetch("executor_uid").to_s)
|
|
18
|
+
@scratch = project.fetch("peer_credentials").fetch(@map.fetch("worker_uid").to_s).fetch("scratch_root")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def preview_workspace_prune(intent:)
|
|
22
|
+
intent = Atoms::ProtectedWorkspacePrunePreview.intent!(JSON.parse(JSON.generate(intent)))
|
|
23
|
+
bytes = JSON.generate(intent)
|
|
24
|
+
deadline = @wire.deadline(5)
|
|
25
|
+
@deployment.verify!(@mapping_id, kernel: @kernel)
|
|
26
|
+
caller = @kernel.capture(Process.pid)
|
|
27
|
+
unless caller.values_at("uid", "gid", "groups") == @map.values_at("worker_uid", "worker_gid", "worker_groups")
|
|
28
|
+
raise SecurityError, "preview caller differs"
|
|
29
|
+
end
|
|
30
|
+
codec = Ace::Assign::Authority::TransferCodec.new(root: @scratch)
|
|
31
|
+
descriptor = codec.descriptor([bytes], purpose: :service_input)
|
|
32
|
+
path = @selection.fetch("socket_path")
|
|
33
|
+
before = @wire.socket_identity(path)
|
|
34
|
+
raise SecurityError, "preview receiver owner differs" unless before.last == @selection.fetch("executor_uid")
|
|
35
|
+
@wire.connect(path, deadline: deadline) do |socket|
|
|
36
|
+
peer = @kernel.peer(socket)
|
|
37
|
+
unless peer.values_at("uid", "gid", "groups") == [@selection.fetch("executor_uid"), @credentials.fetch("gid"), @credentials.fetch("groups")] &&
|
|
38
|
+
@wire.socket_identity(path) == before
|
|
39
|
+
raise SecurityError, "preview receiver peer changed"
|
|
40
|
+
end
|
|
41
|
+
@wire.write(socket, {"version" => 1, "kind" => "preview_workspace_prune", "transfer" => descriptor},
|
|
42
|
+
deadline: deadline, limit: ProtectedServiceListener::LIMIT)
|
|
43
|
+
codec.send(socket, parts: [bytes], descriptor: descriptor, purpose: :service_input, deadline: deadline)
|
|
44
|
+
socket.shutdown(Socket::SHUT_WR)
|
|
45
|
+
guarded = Ace::Runtime::Molecules::ProtectedSocket::Ingress.new(socket)
|
|
46
|
+
result = @wire.read(guarded, deadline: deadline, limit: ProtectedServiceListener::LIMIT)
|
|
47
|
+
eof!(guarded, deadline)
|
|
48
|
+
raise SecurityError, "preview result unavailable" unless result.is_a?(Hash) && result.key?("maintenance_context")
|
|
49
|
+
Atoms::ProtectedWorkspacePrunePreview.context!(result.fetch("maintenance_context"))
|
|
50
|
+
Atoms::ProtectedWorkspacePrunePreview.result!(result, intent: intent, context: result.fetch("maintenance_context"))
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def submit(submission:, input_bytes:, mutation_id:)
|
|
55
|
+
unless submission.is_a?(Hash) && submission.keys.sort == ProtectedServiceReceiver::SUBMISSION.sort &&
|
|
56
|
+
input_bytes.is_a?(String) && mutation_id.is_a?(String) && mutation_id.match?(Ace::Assign::Molecules::JournalMutation::ID)
|
|
57
|
+
raise ArgumentError, "receiver submission is invalid"
|
|
58
|
+
end
|
|
59
|
+
submission = JSON.parse(JSON.generate(submission))
|
|
60
|
+
bytes = input_bytes.dup.freeze
|
|
61
|
+
mutation_id = mutation_id.dup.freeze
|
|
62
|
+
deadline = @wire.deadline(5)
|
|
63
|
+
@deployment.verify!(@mapping_id, kernel: @kernel)
|
|
64
|
+
caller = @kernel.capture(Process.pid)
|
|
65
|
+
unless caller.values_at("uid", "gid", "groups") == @map.values_at("worker_uid", "worker_gid", "worker_groups")
|
|
66
|
+
raise SecurityError, "receiver caller differs"
|
|
67
|
+
end
|
|
68
|
+
codec = Ace::Assign::Authority::TransferCodec.new(root: @scratch)
|
|
69
|
+
descriptor = codec.descriptor([bytes], purpose: :service_input)
|
|
70
|
+
path = @selection.fetch("socket_path")
|
|
71
|
+
before = @wire.socket_identity(path)
|
|
72
|
+
raise SecurityError, "receiver endpoint owner differs" unless before.last == @selection.fetch("executor_uid")
|
|
73
|
+
@wire.connect(path, deadline: deadline) do |socket|
|
|
74
|
+
peer = @kernel.peer(socket)
|
|
75
|
+
unless peer.values_at("uid", "gid", "groups") == [@selection.fetch("executor_uid"), @credentials.fetch("gid"), @credentials.fetch("groups")] &&
|
|
76
|
+
@wire.socket_identity(path) == before
|
|
77
|
+
raise SecurityError, "receiver endpoint peer changed"
|
|
78
|
+
end
|
|
79
|
+
@wire.write(socket, {"version" => 1, "submission" => submission,
|
|
80
|
+
"mutation_id" => mutation_id, "transfer" => descriptor}, deadline: deadline, limit: ProtectedServiceListener::LIMIT)
|
|
81
|
+
codec.send(socket, parts: [bytes], descriptor: descriptor, purpose: :service_input, deadline: deadline)
|
|
82
|
+
socket.shutdown(Socket::SHUT_WR)
|
|
83
|
+
guarded = Ace::Runtime::Molecules::ProtectedSocket::Ingress.new(socket)
|
|
84
|
+
reply = @wire.read(guarded, deadline: deadline, limit: ProtectedServiceListener::LIMIT)
|
|
85
|
+
eof!(guarded, deadline)
|
|
86
|
+
validate_reply!(reply, submission.fetch("request_id"))
|
|
87
|
+
reply
|
|
88
|
+
end
|
|
89
|
+
rescue Ace::Runtime::RuntimeUnavailableError, IOError, SystemCallError, Timeout::Error
|
|
90
|
+
{"version" => 1, "type" => "service_claim_unconfirmed", "required_action" => "inspect_canonical_service_status"}
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def eof!(socket, deadline)
|
|
96
|
+
loop do
|
|
97
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
98
|
+
unless remaining.positive? && IO.select([socket], nil, nil, remaining)
|
|
99
|
+
raise Ace::Runtime::RuntimeUnavailableError, "receiver reply EOF deadline expired"
|
|
100
|
+
end
|
|
101
|
+
byte = socket.read_nonblock(1, exception: false)
|
|
102
|
+
next if byte == :wait_readable
|
|
103
|
+
raise SecurityError, "receiver reply has trailing bytes" unless byte.nil?
|
|
104
|
+
return
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def validate_reply!(reply, request_id)
|
|
109
|
+
unless reply.is_a?(Hash) && reply["version"].is_a?(Integer) && reply["version"] == 1
|
|
110
|
+
raise SecurityError, "receiver claim reply is invalid"
|
|
111
|
+
end
|
|
112
|
+
if reply["type"] == "service_claim_refused"
|
|
113
|
+
raise SecurityError, "receiver claim refusal is invalid" unless reply.keys.sort == %w[code type version] && %w[busy unavailable].include?(reply["code"])
|
|
114
|
+
return
|
|
115
|
+
end
|
|
116
|
+
data = reply["data"]
|
|
117
|
+
unless reply.keys.sort == %w[data type version] && reply["type"] == "service_claim_accepted" &&
|
|
118
|
+
data.is_a?(Hash) && (data.keys - %w[request_id generation journal_commit state claim replayed]).empty? &&
|
|
119
|
+
data["request_id"] == request_id && data["generation"].is_a?(Integer) && data["generation"].positive? &&
|
|
120
|
+
data["journal_commit"].is_a?(String) && data["journal_commit"].match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/) &&
|
|
121
|
+
%w[uncertain succeeded failed failed-settled].include?(data["state"]) && [true, false].include?(data["replayed"]) &&
|
|
122
|
+
(!data.key?("claim") || %w[created retained].include?(data["claim"]))
|
|
123
|
+
raise SecurityError, "receiver canonical claim identity differs"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require_relative "protected_service_worker"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Lab
|
|
8
|
+
module Organisms
|
|
9
|
+
# Fixed receiver ingress. The short connection observes canonical claim
|
|
10
|
+
# acceptance; it does not own or cancel the admitted execution lifetime.
|
|
11
|
+
class ProtectedServiceListener
|
|
12
|
+
LIMIT = 16_384
|
|
13
|
+
REQUEST_KEYS = %w[mutation_id submission transfer version].freeze
|
|
14
|
+
|
|
15
|
+
def initialize(mapping_id:, service_id:, deployment: Ace::Assign::Authority::Deployment.load,
|
|
16
|
+
kernel: Ace::Runtime::Molecules::ProtectedLinux.new, receiver: nil,
|
|
17
|
+
wire: Ace::Runtime::Molecules::ProtectedSocket)
|
|
18
|
+
@deployment, @kernel, @wire = deployment, kernel, wire
|
|
19
|
+
@map = deployment.mapping(mapping_id)
|
|
20
|
+
project = deployment.project(@map.fetch("project_id"))
|
|
21
|
+
@selection = project.fetch("service_receivers").fetch(service_id)
|
|
22
|
+
@credentials = project.fetch("peer_credentials").fetch(@selection.fetch("executor_uid").to_s)
|
|
23
|
+
@path = @selection.fetch("socket_path")
|
|
24
|
+
@receiver = receiver || ProtectedServiceReceiver.new(
|
|
25
|
+
mapping_id: mapping_id, service_id: service_id, deployment: deployment, kernel: kernel)
|
|
26
|
+
@worker = ProtectedServiceWorker.new(receiver: @receiver)
|
|
27
|
+
@stopping = false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def serve
|
|
31
|
+
own_principal!
|
|
32
|
+
parent = File.dirname(@path)
|
|
33
|
+
@wire.root_path!(parent, directory: true, owner: @selection.fetch("executor_uid"))
|
|
34
|
+
# The installed receiver must actually be able to publish a socket
|
|
35
|
+
# accessible to its mapped worker. No ambient ACL/root fixup exists.
|
|
36
|
+
unless @credentials.fetch("gid") == @map.fetch("worker_gid") || @credentials.fetch("groups").include?(@map.fetch("worker_gid"))
|
|
37
|
+
raise SecurityError, "receiver socket group is unavailable"
|
|
38
|
+
end
|
|
39
|
+
@lock = File.open("#{@path}.lock", File::RDWR | File::CREAT | File::NOFOLLOW, 0o600)
|
|
40
|
+
stat = @lock.stat
|
|
41
|
+
unless stat.file? && stat.uid == Process.uid && stat.nlink == 1 && (stat.mode & 0o7777) == 0o600
|
|
42
|
+
raise SecurityError, "receiver listener lock differs"
|
|
43
|
+
end
|
|
44
|
+
unless @lock.flock(File::LOCK_EX | File::LOCK_NB)
|
|
45
|
+
raise Ace::Assign::AttemptErrors::Conflict, "receiver listener already active"
|
|
46
|
+
end
|
|
47
|
+
remove_stale_endpoint!
|
|
48
|
+
@listener = UNIXServer.new(@path)
|
|
49
|
+
File.chown(nil, @map.fetch("worker_gid"), @path)
|
|
50
|
+
File.chmod(0o660, @path)
|
|
51
|
+
@identity = @wire.socket_identity(@path)
|
|
52
|
+
until @stopping
|
|
53
|
+
begin
|
|
54
|
+
socket = @listener.accept
|
|
55
|
+
receive(socket)
|
|
56
|
+
ensure
|
|
57
|
+
socket&.close
|
|
58
|
+
socket = nil
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
rescue IOError, Errno::EBADF
|
|
62
|
+
raise unless @stopping
|
|
63
|
+
ensure
|
|
64
|
+
# Never release listener exclusion while an admitted worker exists.
|
|
65
|
+
# Actual operation transport supplies its separate bounded lifetime.
|
|
66
|
+
@worker.close(timeout: 1) until @worker.close(timeout: 0)
|
|
67
|
+
@listener&.close
|
|
68
|
+
remove_owned_endpoint!
|
|
69
|
+
@lock&.close
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def stop
|
|
73
|
+
@stopping = true
|
|
74
|
+
@worker.close(timeout: 0)
|
|
75
|
+
@listener&.close
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def receive(socket)
|
|
81
|
+
deadline = @wire.deadline(5)
|
|
82
|
+
peer = @kernel.peer(socket)
|
|
83
|
+
unless peer.values_at("uid", "gid", "groups") == @map.values_at("worker_uid", "worker_gid", "worker_groups")
|
|
84
|
+
raise SecurityError, "receiver worker peer differs"
|
|
85
|
+
end
|
|
86
|
+
guarded = Ace::Runtime::Molecules::ProtectedSocket::Ingress.new(socket)
|
|
87
|
+
request = @wire.read(guarded, deadline: deadline, limit: LIMIT)
|
|
88
|
+
if request.is_a?(Hash) && request["kind"] == "preview_workspace_prune"
|
|
89
|
+
unless request.keys.sort == %w[kind transfer version] && request["version"].is_a?(Integer) && request["version"] == 1
|
|
90
|
+
raise ArgumentError, "receiver preview header differs"
|
|
91
|
+
end
|
|
92
|
+
codec = Ace::Assign::Authority::TransferCodec.new(root: @selection.fetch("staging_root"))
|
|
93
|
+
bytes = codec.receive(guarded, descriptor: request.fetch("transfer"), purpose: :service_input, deadline: deadline) do |input|
|
|
94
|
+
raise ArgumentError, "preview requires one intent part" unless input.count == 1
|
|
95
|
+
input.bytes
|
|
96
|
+
end
|
|
97
|
+
intent = JSON.parse(bytes, create_additions: false, max_nesting: 16, allow_nan: false,
|
|
98
|
+
allow_comments: false, allow_duplicate_key: false)
|
|
99
|
+
intent = Atoms::ProtectedWorkspacePrunePreview.intent!(intent)
|
|
100
|
+
result = @receiver.preview_workspace_prune(intent: intent, peer: peer, deadline: deadline)
|
|
101
|
+
@wire.write(socket, result, deadline: deadline, limit: LIMIT)
|
|
102
|
+
return
|
|
103
|
+
end
|
|
104
|
+
strict_request!(request)
|
|
105
|
+
codec = Ace::Assign::Authority::TransferCodec.new(root: @selection.fetch("staging_root"))
|
|
106
|
+
bytes = codec.receive(guarded, descriptor: request.fetch("transfer"), purpose: :service_input, deadline: deadline) { |input| input.bytes }
|
|
107
|
+
completed = Queue.new
|
|
108
|
+
@worker.start(submission: request.fetch("submission"), peer: peer,
|
|
109
|
+
input_bytes: bytes, mutation_id: request.fetch("mutation_id"), deadline: deadline,
|
|
110
|
+
on_claim: lambda do |identity|
|
|
111
|
+
begin
|
|
112
|
+
@wire.write(socket, {"version" => 1, "type" => "service_claim_accepted", "data" => identity},
|
|
113
|
+
deadline: deadline, limit: LIMIT)
|
|
114
|
+
rescue Ace::Runtime::RuntimeUnavailableError, IOError, SystemCallError
|
|
115
|
+
# Only bounded transport failure is swallowed. Original work
|
|
116
|
+
# continues and its accepted identity is observed via status.
|
|
117
|
+
nil
|
|
118
|
+
ensure
|
|
119
|
+
completed << true
|
|
120
|
+
end
|
|
121
|
+
end)
|
|
122
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
123
|
+
completed.pop(timeout: remaining) if remaining.positive?
|
|
124
|
+
rescue Ace::Assign::AttemptErrors::Conflict
|
|
125
|
+
refuse(socket, "busy", deadline)
|
|
126
|
+
rescue SecurityError, ArgumentError, KeyError, TypeError, Ace::Assign::Error,
|
|
127
|
+
Ace::Runtime::RuntimeUnavailableError, IOError, SystemCallError, Timeout::Error
|
|
128
|
+
refuse(socket, "unavailable", deadline)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def strict_request!(request)
|
|
132
|
+
unless request.is_a?(Hash) && request.keys.sort == REQUEST_KEYS &&
|
|
133
|
+
request["version"].is_a?(Integer) && request["version"] == 1 &&
|
|
134
|
+
request["transfer"].is_a?(Hash) && request["transfer"]["version"].is_a?(Integer) && request["transfer"]["version"] == 1 &&
|
|
135
|
+
request["mutation_id"].is_a?(String) && request["mutation_id"].match?(Ace::Assign::Molecules::JournalMutation::ID) &&
|
|
136
|
+
request["submission"].is_a?(Hash) && request["submission"].keys.sort == ProtectedServiceReceiver::SUBMISSION.sort
|
|
137
|
+
raise ArgumentError, "receiver request is invalid"
|
|
138
|
+
end
|
|
139
|
+
walk = [[request, 0]]
|
|
140
|
+
until walk.empty?
|
|
141
|
+
value, depth = walk.pop
|
|
142
|
+
raise ArgumentError, "receiver request depth exceeds bound" if depth > 16
|
|
143
|
+
children = value.is_a?(Hash) ? value.values : (value.is_a?(Array) ? value : [])
|
|
144
|
+
children.each { |child| walk << [child, depth + 1] }
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def refuse(socket, code, deadline)
|
|
149
|
+
@wire.write(socket, {"version" => 1, "type" => "service_claim_refused", "code" => code}, deadline: deadline, limit: LIMIT)
|
|
150
|
+
rescue Ace::Runtime::RuntimeUnavailableError, IOError, SystemCallError
|
|
151
|
+
nil
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def own_principal!
|
|
155
|
+
me = @kernel.capture(Process.pid)
|
|
156
|
+
unless me.values_at("uid", "gid", "groups") == [@selection.fetch("executor_uid"), @credentials.fetch("gid"), @credentials.fetch("groups")]
|
|
157
|
+
raise SecurityError, "receiver listener principal differs"
|
|
158
|
+
end
|
|
159
|
+
@kernel.live!(me)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def remove_stale_endpoint!
|
|
163
|
+
stat = File.lstat(@path)
|
|
164
|
+
unless stat.socket? && stat.uid == Process.uid
|
|
165
|
+
raise SecurityError, "receiver endpoint differs"
|
|
166
|
+
end
|
|
167
|
+
File.unlink(@path)
|
|
168
|
+
rescue Errno::ENOENT
|
|
169
|
+
nil
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def remove_owned_endpoint!
|
|
173
|
+
File.unlink(@path) if @identity && @wire.socket_identity(@path) == @identity
|
|
174
|
+
rescue Errno::ENOENT
|
|
175
|
+
nil
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "securerandom"
|
|
3
|
+
require "rubygems/package"
|
|
4
|
+
require "ace/hitl/providers/lab"
|
|
5
|
+
require_relative "../molecules/protected_publication_publisher"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Lab
|
|
9
|
+
module Organisms
|
|
10
|
+
class ProtectedServiceReceiver
|
|
11
|
+
# One invocation is retained by the existing receiver worker, not a
|
|
12
|
+
# second effect queue. Only canonical continuation can authorize a push.
|
|
13
|
+
def begin_publication!(admitted:, materialized:, deadline:)
|
|
14
|
+
raise SecurityError, "publication receiver already owns an invocation" if @publication
|
|
15
|
+
@publication_hitl ||= Ace::Hitl::Providers::Lab.boundary_client(
|
|
16
|
+
socket_path: Ace::Hitl::Providers::Lab::DEFAULT_SOCKET_PATH,
|
|
17
|
+
policy: Ace::Hitl::Providers::Lab.grants_policy(grants_path: Ace::Hitl::Providers::Lab::DEFAULT_GRANTS_PATH))
|
|
18
|
+
params = admitted.fetch(:params)
|
|
19
|
+
started = admitted.fetch(:started)
|
|
20
|
+
unless started.fetch("executor_process_binding") == @kernel.capture(Process.pid)
|
|
21
|
+
raise SecurityError, "publication original receiver differs"
|
|
22
|
+
end
|
|
23
|
+
@publication = admitted.merge(materialized: materialized, handles: [], challenge: nil,
|
|
24
|
+
ask_id: nil, disposition: "dispatch_started")
|
|
25
|
+
hold_publication_artifact!(@publication)
|
|
26
|
+
verified = publication_registry!(@publication, deadline)
|
|
27
|
+
return complete_publication!(@publication, verified, deadline) if %w[succeeded failed].include?(verified.fetch("classification"))
|
|
28
|
+
unless verified.fetch("classification") == "absent"
|
|
29
|
+
@publication[:disposition] = "uncertain"
|
|
30
|
+
return publication_status("uncertain", verified.fetch("code"))
|
|
31
|
+
end
|
|
32
|
+
publication_push!(@publication, nil, deadline)
|
|
33
|
+
rescue StandardError, SecurityError
|
|
34
|
+
publication_status("uncertain", "publication_admission_unavailable")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def tick_publication!(request_id:, deadline:)
|
|
38
|
+
entry = @publication
|
|
39
|
+
return {"request_id" => request_id, "state" => "publication-blocked", "code" => "original-executor-unavailable"} unless entry && entry.dig(:params, "request_id") == request_id
|
|
40
|
+
check_publication!(entry)
|
|
41
|
+
status = publication_call!(entry, "service_status", {}, deadline).data
|
|
42
|
+
if %w[succeeded failed].include?(status["state"])
|
|
43
|
+
close_publication!(entry)
|
|
44
|
+
return projection(status)
|
|
45
|
+
end
|
|
46
|
+
if entry[:disposition] == "uncertain"
|
|
47
|
+
verified = publication_registry!(entry, deadline)
|
|
48
|
+
return complete_publication!(entry, verified, deadline) if verified["classification"] == "succeeded"
|
|
49
|
+
return publication_status("uncertain", "publication_outcome_unconfirmed")
|
|
50
|
+
end
|
|
51
|
+
unless status["dispatch_phase"] == "otp_pending" && status["publication_challenge_digest"] == entry.fetch(:challenge).fetch("publication_challenge_digest")
|
|
52
|
+
return publication_status("uncertain", "publication_challenge_changed")
|
|
53
|
+
end
|
|
54
|
+
publication_ask!(entry, deadline) unless entry[:ask_created]
|
|
55
|
+
ask = @publication_hitl.read(entry.fetch(:ask_id), deadline: deadline)
|
|
56
|
+
check_publication_ask!(entry, ask)
|
|
57
|
+
if %w[cancelled consumed].include?(ask["state"])
|
|
58
|
+
entry[:ask_id] = nil
|
|
59
|
+
entry[:ask_created] = false
|
|
60
|
+
entry[:ask_otp] = nil
|
|
61
|
+
return publication_status("publication-pending", "otp-answer-unavailable")
|
|
62
|
+
end
|
|
63
|
+
if ask.fetch("otp").fetch("expires_at") <= Time.now.to_i
|
|
64
|
+
@publication_hitl.cancel(entry.fetch(:ask_id), reason: "otp-answer-expired", deadline: deadline)
|
|
65
|
+
return publication_status("publication-pending", "otp-answer-expired")
|
|
66
|
+
end
|
|
67
|
+
return publication_status("publication-pending", "otp-answer-pending") unless ask["state"] == "answer-delivered"
|
|
68
|
+
return publication_status("publication-pending", "otp-tick-budget") if publication_remaining!(deadline) < 1.5
|
|
69
|
+
answer = @publication_hitl.consume(entry.fetch(:ask_id), timeout: 1, operation: "publish", deadline: deadline)
|
|
70
|
+
otp = answer["answer"]
|
|
71
|
+
unless otp.is_a?(String) && otp.match?(/\A[0-9]{6}\z/) && ask.fetch("otp").fetch("expires_at") > Time.now.to_i
|
|
72
|
+
return publication_status("publication-pending", "otp-answer-unavailable")
|
|
73
|
+
end
|
|
74
|
+
check_publication!(entry)
|
|
75
|
+
return publication_status("publication-pending", "otp-tick-budget") if publication_remaining!(deadline) < 1
|
|
76
|
+
status = publication_call!(entry, "service_status", {}, deadline).data
|
|
77
|
+
return publication_status("publication-pending", "otp-answer-expired") unless ask.fetch("otp").fetch("expires_at") > Time.now.to_i
|
|
78
|
+
entry[:disposition] = "uncertain" # A lost issuing reply is never retried.
|
|
79
|
+
issued = publication_call!(entry, "publication_continue", {
|
|
80
|
+
"expected_generation" => status.fetch("generation"), "input_digest" => entry.fetch(:params).fetch("input_digest"),
|
|
81
|
+
"challenge_digest" => entry.fetch(:challenge).fetch("publication_challenge_digest")}, deadline,
|
|
82
|
+
mutation_id: Digest::SHA256.hexdigest("publication-continue:#{entry.fetch(:challenge).fetch('publication_challenge_digest')}")).data
|
|
83
|
+
return publication_status("uncertain", "publication_issuing_unconfirmed") unless issued["invocation"] == "permitted" && issued["dispatch_phase"] == "issuing"
|
|
84
|
+
entry[:started] = issued
|
|
85
|
+
publication_push!(entry, otp, deadline)
|
|
86
|
+
rescue StandardError, SecurityError
|
|
87
|
+
publication_status("uncertain", "publication_tick_unavailable")
|
|
88
|
+
ensure
|
|
89
|
+
otp&.clear if otp.is_a?(String) && !otp.frozen?
|
|
90
|
+
answer&.delete("answer")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def publication_active?
|
|
94
|
+
!@publication.nil?
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Called only after the existing worker has stopped its bounded tick.
|
|
98
|
+
# Durable uncertain state and candidate bytes remain for read-only recovery.
|
|
99
|
+
def release_publication_lifetime!
|
|
100
|
+
@publication&.fetch(:handles)&.each { |_path, handle, _identity| handle.close unless handle.closed? }
|
|
101
|
+
@publication = nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def hold_publication_artifact!(entry)
|
|
107
|
+
root = entry.fetch(:materialized).fetch("directory")
|
|
108
|
+
publication = entry.fetch(:envelope).fetch("input").fetch("publication")
|
|
109
|
+
components = publication.fetch("artifact_relative_path").split("/")
|
|
110
|
+
paths = [root]
|
|
111
|
+
components.each { |component| paths << File.join(paths.last, component) }
|
|
112
|
+
paths.each_with_index do |path, index|
|
|
113
|
+
handle = File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK)
|
|
114
|
+
stat = handle.stat
|
|
115
|
+
valid = index == paths.size - 1 ? stat.file? && stat.size.between?(1, 64 * 1024 * 1024) : stat.directory?
|
|
116
|
+
unless valid && stat.uid == Process.uid && (stat.mode & 0o022).zero?
|
|
117
|
+
handle.close
|
|
118
|
+
raise SecurityError, "publication staging protection differs"
|
|
119
|
+
end
|
|
120
|
+
entry.fetch(:handles) << [path, handle, publication_identity(stat)]
|
|
121
|
+
end
|
|
122
|
+
entry[:artifact] = paths.last
|
|
123
|
+
spec = Gem::Package.new(paths.last).spec
|
|
124
|
+
unless spec.name == publication.fetch("gem_name") && spec.version.to_s == publication.fetch("version")
|
|
125
|
+
raise SecurityError, "publication artifact metadata differs"
|
|
126
|
+
end
|
|
127
|
+
entry[:platform] = spec.platform.to_s
|
|
128
|
+
check_publication!(entry)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def check_publication!(entry)
|
|
132
|
+
@kernel.live!(entry.fetch(:started).fetch("executor_process_binding"))
|
|
133
|
+
raise SecurityError, "publication receiver birth differs" unless @kernel.capture(Process.pid) == entry.fetch(:started).fetch("executor_process_binding")
|
|
134
|
+
entry.fetch(:handles).each do |path, handle, expected|
|
|
135
|
+
unless publication_identity(handle.stat) == expected && publication_identity(File.lstat(path)) == expected
|
|
136
|
+
raise SecurityError, "publication held staging changed"
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
artifact = entry.fetch(:handles).last.fetch(1)
|
|
140
|
+
artifact.rewind
|
|
141
|
+
digest = Digest::SHA256.new
|
|
142
|
+
while (chunk = artifact.read(65_536))
|
|
143
|
+
digest.update(chunk)
|
|
144
|
+
end
|
|
145
|
+
unless digest.hexdigest == entry.fetch(:envelope).fetch("input").fetch("target").fetch("artifact_digest")
|
|
146
|
+
raise SecurityError, "publication artifact bytes differ"
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def publication_identity(stat)
|
|
151
|
+
stat.directory? ? [stat.dev, stat.ino, stat.uid, stat.gid, stat.mode] :
|
|
152
|
+
[stat.dev, stat.ino, stat.uid, stat.gid, stat.mode, stat.size, stat.mtime, stat.ctime]
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def publication_registry!(entry, deadline)
|
|
156
|
+
check_publication!(entry)
|
|
157
|
+
@publication_publisher.verify!(publication: entry.fetch(:envelope).fetch("input").fetch("publication"),
|
|
158
|
+
artifact_digest: entry.fetch(:params).fetch("target").fetch("artifact_digest"), platform: entry.fetch(:platform), deadline: deadline)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def publication_push!(entry, otp, deadline)
|
|
162
|
+
check_publication!(entry)
|
|
163
|
+
entry[:disposition] = "uncertain"
|
|
164
|
+
result = @publication_publisher.push!(operation: entry.fetch(:operation),
|
|
165
|
+
publication: entry.fetch(:envelope).fetch("input").fetch("publication"), artifact: entry.fetch(:artifact),
|
|
166
|
+
artifact_digest: entry.fetch(:params).fetch("target").fetch("artifact_digest"), otp: otp, deadline: deadline)
|
|
167
|
+
check_publication!(entry)
|
|
168
|
+
verified = publication_registry!(entry, deadline)
|
|
169
|
+
return complete_publication!(entry, verified, deadline) if %w[succeeded failed].include?(verified["classification"])
|
|
170
|
+
if %w[otp-required otp-rejected otp-expired].include?(result["classification"]) && verified["classification"] == "absent"
|
|
171
|
+
return retain_publication_challenge!(entry, result, deadline)
|
|
172
|
+
end
|
|
173
|
+
return complete_publication!(entry, result, deadline) if result["classification"] == "failed" && verified["classification"] == "absent"
|
|
174
|
+
publication_status("uncertain", "publication_outcome_unconfirmed")
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def retain_publication_challenge!(entry, result, deadline)
|
|
178
|
+
input = entry.fetch(:envelope).fetch("input")
|
|
179
|
+
params = entry.fetch(:params)
|
|
180
|
+
previous = entry[:challenge]&.fetch("publication_challenge_digest")
|
|
181
|
+
need = params.slice("request_id", "input_digest", "candidate_generation", "head").merge(
|
|
182
|
+
"schema" => "ace.publication-otp-need/v1", "classification" => result.fetch("classification"),
|
|
183
|
+
"claim_binding" => entry.fetch(:claim).fetch("claim_binding"),
|
|
184
|
+
"registry" => input.fetch("publication").fetch("registry"), "gem_name" => input.fetch("publication").fetch("gem_name"),
|
|
185
|
+
"version" => input.fetch("publication").fetch("version"), "artifact_digest" => input.fetch("target").fetch("artifact_digest"),
|
|
186
|
+
"dispatch_event_digest" => entry.fetch(:dispatch_event_digest), "previous_challenge_digest" => previous)
|
|
187
|
+
bytes = JSON.generate(need)
|
|
188
|
+
status = publication_call!(entry, "service_status", {}, deadline).data
|
|
189
|
+
entry[:challenge] = publication_call!(entry, "publication_challenge", {
|
|
190
|
+
"input_digest" => params.fetch("input_digest"), "expected_generation" => status.fetch("generation"),
|
|
191
|
+
"receipt_sha256" => Digest::SHA256.hexdigest(bytes)}, deadline,
|
|
192
|
+
mutation_id: Digest::SHA256.hexdigest("publication-challenge:#{Digest::SHA256.hexdigest(bytes)}"),
|
|
193
|
+
upload_parts: [bytes], purpose: :receipt_artifacts).data
|
|
194
|
+
entry[:disposition] = "otp_pending"
|
|
195
|
+
entry[:ask_id] = nil
|
|
196
|
+
entry[:ask_created] = false
|
|
197
|
+
entry[:ask_otp] = nil
|
|
198
|
+
publication_ask!(entry, deadline)
|
|
199
|
+
publication_status("publication-pending", result.fetch("classification"))
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def publication_ask!(entry, deadline)
|
|
203
|
+
entry[:ask_id] ||= "publication-#{SecureRandom.hex(12)}"
|
|
204
|
+
binding = entry.fetch(:params).slice("assignment_id", "attempt_id", "request_id", "input_digest", "candidate_generation", "head").merge(
|
|
205
|
+
"schema" => "ace.hitl-publication-binding/v1", "mapping_id" => @mapping_id,
|
|
206
|
+
"claim_binding" => entry.fetch(:claim).fetch("claim_binding"),
|
|
207
|
+
"challenge_digest" => entry.fetch(:challenge).fetch("publication_challenge_digest"))
|
|
208
|
+
entry[:ask_binding] = immutable(binding)
|
|
209
|
+
entry[:ask_otp] ||= immutable({"operation" => "publish", "input_digest" => binding.fetch("input_digest"),
|
|
210
|
+
"result_ref" => entry.fetch(:challenge).fetch("publication_challenge_ref").fetch("ref"), "expires_at" => Time.now.to_i + 900})
|
|
211
|
+
@publication_hitl.create(id: entry.fetch(:ask_id), assignment: binding.fetch("assignment_id"), attempt: binding.fetch("attempt_id"),
|
|
212
|
+
project: @map.fetch("project_id"), harness: "lab-admin", plan: "Scoped RubyGems publication", kind: "otp",
|
|
213
|
+
question: "Provide the RubyGems OTP for this authorized artifact publication.", ace_hitl_id: entry.fetch(:ask_id),
|
|
214
|
+
publication_binding: binding, otp: entry.fetch(:ask_otp), deadline: deadline)
|
|
215
|
+
entry[:ask_created] = true
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def check_publication_ask!(entry, ask)
|
|
219
|
+
unless ask["id"] == entry.fetch(:ask_id) && ask["publication_binding"] == entry.fetch(:ask_binding) &&
|
|
220
|
+
ask["publication_requester_peer"] == entry.fetch(:started).fetch("executor_process_binding") &&
|
|
221
|
+
ask.dig("otp", "input_digest") == entry.fetch(:params).fetch("input_digest") &&
|
|
222
|
+
ask.dig("otp", "result_ref") == entry.fetch(:challenge).fetch("publication_challenge_ref").fetch("ref")
|
|
223
|
+
raise SecurityError, "publication HITL retained request differs"
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def publication_call!(entry, operation, extra, deadline, **options)
|
|
228
|
+
params = entry.fetch(:params).slice("assignment_id", "attempt_id", "candidate_generation", "head", "request_id")
|
|
229
|
+
params["claim_binding"] = entry.fetch(:claim).fetch("claim_binding") unless operation == "service_status"
|
|
230
|
+
@client.call(operation, params.merge(extra), timeout: publication_remaining!(deadline), **options)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def complete_publication!(entry, result, deadline)
|
|
234
|
+
check_publication!(entry)
|
|
235
|
+
input = entry.fetch(:envelope).fetch("input")
|
|
236
|
+
evidence = JSON.generate(input.merge("schema" => "ace.publication-result/v1", "classification" => result.fetch("classification"),
|
|
237
|
+
"code" => result.fetch("code"), "request_id" => entry.fetch(:params).fetch("request_id"),
|
|
238
|
+
"input_digest" => entry.fetch(:params).fetch("input_digest"), "claim_binding" => entry.fetch(:claim).fetch("claim_binding")))
|
|
239
|
+
receipt = entry.fetch(:envelope).fetch("request").slice(*Ace::Assign::Molecules::EvidenceJournal::TERMINAL_BINDING_FIELDS).merge(
|
|
240
|
+
"outcome" => result.fetch("classification") == "succeeded" ? "succeeded" : "failed",
|
|
241
|
+
"evidence" => [{"ref" => "publication-result", "sha256" => Digest::SHA256.hexdigest(evidence)}])
|
|
242
|
+
bytes = JSON.generate(receipt)
|
|
243
|
+
completed = publication_call!(entry, "complete_service", {"receipt_sha256" => Digest::SHA256.hexdigest(bytes)}, deadline,
|
|
244
|
+
mutation_id: Digest::SHA256.hexdigest("publication-complete:#{entry.fetch(:mutation_id)}"), upload_parts: [bytes, evidence], purpose: :receipt_artifacts).data
|
|
245
|
+
return publication_status("uncertain", "publication_completion_unconfirmed") unless %w[succeeded failed].include?(completed["state"])
|
|
246
|
+
close_publication!(entry)
|
|
247
|
+
projection(completed)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def close_publication!(entry)
|
|
251
|
+
entry.fetch(:handles).each { |_path, handle, _identity| handle.close }
|
|
252
|
+
cleanup_staging(entry.fetch(:materialized).fetch("directory"), entry.fetch(:staging_identity))
|
|
253
|
+
@publication = nil
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def publication_remaining!(deadline)
|
|
257
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
258
|
+
raise IOError, "publication tick deadline expired" unless remaining.positive?
|
|
259
|
+
remaining
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def publication_status(state, code)
|
|
263
|
+
{"request_id" => @publication&.dig(:params, "request_id"), "state" => state, "code" => code}.freeze
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|