ace-lab 0.8.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +363 -92
- data/README.md +66 -100
- data/docs/usage.md +529 -404
- data/lib/ace/lab/atoms/install_schema.rb +373 -0
- data/lib/ace/lab/cli/commands/install.rb +161 -0
- data/lib/ace/lab/cli/commands/prepare.rb +118 -0
- data/lib/ace/lab/cli/commands/project_add.rb +101 -0
- data/lib/ace/lab/cli/commands/status.rb +109 -0
- data/lib/ace/lab/cli/commands/verify.rb +115 -0
- data/lib/ace/lab/cli.rb +21 -13
- data/lib/ace/lab/install_manifest_path.rb +23 -0
- data/lib/ace/lab/models/status_report.rb +38 -0
- data/lib/ace/lab/molecules/account_provisioner.rb +196 -0
- data/lib/ace/lab/molecules/config_state_classifier.rb +145 -0
- data/lib/ace/lab/molecules/directory_provisioner.rb +208 -0
- data/lib/ace/lab/molecules/grant_provisioner.rb +250 -0
- data/lib/ace/lab/molecules/grant_resolver.rb +49 -23
- data/lib/ace/lab/molecules/hitl_routes.rb +263 -0
- data/lib/ace/lab/molecules/install_manifest_loader.rb +64 -0
- data/lib/ace/lab/molecules/key_provisioner.rb +736 -0
- data/lib/ace/lab/molecules/lab_state_scanner.rb +55 -0
- data/lib/ace/lab/molecules/overseer_provisioner.rb +188 -0
- data/lib/ace/lab/molecules/project_registry.rb +251 -0
- data/lib/ace/lab/molecules/repository_provisioner.rb +141 -0
- data/lib/ace/lab/molecules/role_verifier.rb +223 -0
- data/lib/ace/lab/organisms/install.rb +213 -0
- data/lib/ace/lab/organisms/prepare.rb +156 -0
- data/lib/ace/lab/organisms/project_add.rb +170 -0
- data/lib/ace/lab/organisms/status_service.rb +70 -0
- data/lib/ace/lab/organisms/verify.rb +768 -0
- data/lib/ace/lab/version.rb +1 -1
- data/lib/ace/lab.rb +34 -6
- metadata +34 -102
- data/lib/ace/lab/atoms/protected_publication_input.rb +0 -50
- data/lib/ace/lab/atoms/protected_workspace_prune_input.rb +0 -114
- data/lib/ace/lab/atoms/protected_workspace_prune_preview.rb +0 -86
- data/lib/ace/lab/atoms/service_input.rb +0 -92
- data/lib/ace/lab/cli/commands/assignment.rb +0 -45
- data/lib/ace/lab/cli/commands/service.rb +0 -240
- data/lib/ace/lab/models/service_receipt.rb +0 -18
- data/lib/ace/lab/molecules/hitl_authorizer.rb +0 -63
- data/lib/ace/lab/molecules/ordinary_admission_resolver.rb +0 -131
- data/lib/ace/lab/molecules/ordinary_listener.rb +0 -262
- data/lib/ace/lab/molecules/ordinary_merge_read.rb +0 -45
- data/lib/ace/lab/molecules/ordinary_native_read.rb +0 -106
- data/lib/ace/lab/molecules/ordinary_owner_admission.rb +0 -139
- data/lib/ace/lab/molecules/ordinary_owner_config.rb +0 -232
- data/lib/ace/lab/molecules/ordinary_receiver_config.rb +0 -94
- data/lib/ace/lab/molecules/ordinary_socket.rb +0 -126
- data/lib/ace/lab/molecules/ordinary_worktree_read.rb +0 -216
- data/lib/ace/lab/molecules/protected_cleanup_owner_admission.rb +0 -163
- data/lib/ace/lab/molecules/protected_cleanup_owner_client.rb +0 -165
- data/lib/ace/lab/molecules/protected_cleanup_owner_identity.rb +0 -239
- data/lib/ace/lab/molecules/protected_publication_publisher.rb +0 -110
- data/lib/ace/lab/molecules/protected_service_handler.rb +0 -43
- data/lib/ace/lab/molecules/protected_service_policy.rb +0 -156
- data/lib/ace/lab/molecules/service_executor.rb +0 -99
- data/lib/ace/lab/molecules/service_policy.rb +0 -289
- data/lib/ace/lab/organisms/authority_composition.rb +0 -87
- data/lib/ace/lab/organisms/ordinary_assignment_client.rb +0 -149
- data/lib/ace/lab/organisms/ordinary_authority_composition.rb +0 -123
- data/lib/ace/lab/organisms/ordinary_merge_receiver.rb +0 -688
- data/lib/ace/lab/organisms/ordinary_merge_request.rb +0 -456
- data/lib/ace/lab/organisms/protected_cleanup_owner.rb +0 -361
- data/lib/ace/lab/organisms/protected_service_client.rb +0 -129
- data/lib/ace/lab/organisms/protected_service_listener.rb +0 -181
- data/lib/ace/lab/organisms/protected_service_publication.rb +0 -268
- data/lib/ace/lab/organisms/protected_service_receiver.rb +0 -267
- data/lib/ace/lab/organisms/protected_service_recovery.rb +0 -185
- data/lib/ace/lab/organisms/protected_service_request.rb +0 -128
- data/lib/ace/lab/organisms/protected_service_worker.rb +0 -89
- data/lib/ace/lab/organisms/service_request_service.rb +0 -249
|
@@ -1,232 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "yaml"
|
|
4
|
-
require "json"
|
|
5
|
-
require "digest"
|
|
6
|
-
require "etc"
|
|
7
|
-
require "ace/assign/authority/private_directory"
|
|
8
|
-
require "ace/assign/authority/protected_assignment_context"
|
|
9
|
-
require "ace/assign/molecules/installed_journal_read"
|
|
10
|
-
require "ace/runtime/molecules/protected_artifact_set"
|
|
11
|
-
require "ace/hitl/lifecycle/store"
|
|
12
|
-
require_relative "../errors"
|
|
13
|
-
|
|
14
|
-
module Ace
|
|
15
|
-
module Lab
|
|
16
|
-
module Molecules
|
|
17
|
-
# Placement and principals only. Source selects lifecycle operations.
|
|
18
|
-
# Root installs this independently of requester worktrees/config cascades.
|
|
19
|
-
class OrdinaryOwnerConfig
|
|
20
|
-
PATH = "/etc/lab/ace-lab/assign-owners.yml"
|
|
21
|
-
ID = /\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/
|
|
22
|
-
PROJECT_KEYS = %w[service journal assignment_root lifecycle_root runtime_binary native worktrees participants hitl].freeze
|
|
23
|
-
SERVICE_KEYS = %w[uid gid socket_path state_root].freeze
|
|
24
|
-
JOURNAL_KEYS = %w[repository ref checkout_root].freeze
|
|
25
|
-
PARTICIPANT_KEYS = %w[gid actor runtime workspace supervisor_panes].freeze
|
|
26
|
-
PANE = /\A[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,127}\z/
|
|
27
|
-
NATIVE_KEYS = %w[socket_path socket_identity server_identity version protocol].freeze
|
|
28
|
-
HITL_KEYS = %w[root socket_path group grants_path].freeze
|
|
29
|
-
HITL_OPTIONAL_KEYS = %w[tls_authority].freeze
|
|
30
|
-
|
|
31
|
-
def self.load(project:, path: PATH)
|
|
32
|
-
reader = Ace::Runtime::Molecules::ProtectedArtifactSet.new(file_limit: 65_536, total_limit: 65_536)
|
|
33
|
-
bytes = reader.with do |held|
|
|
34
|
-
content, = held.read_path!(path)
|
|
35
|
-
held.verify_unchanged!
|
|
36
|
-
content
|
|
37
|
-
end
|
|
38
|
-
new(YAML.safe_load(bytes, aliases: false), project: project, source: path)
|
|
39
|
-
rescue Psych::Exception, Ace::Runtime::Error
|
|
40
|
-
raise InvalidConfigurationError, "ordinary assignment owner configuration is unavailable: #{path}"
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
attr_reader :project, :service, :journal, :assignment_root, :lifecycle_root,
|
|
44
|
-
:runtime_binary, :native, :worktrees, :participants, :hitl, :source
|
|
45
|
-
|
|
46
|
-
def initialize(document, project:, source:)
|
|
47
|
-
unless document.is_a?(Hash) && document.keys.sort == %w[projects version] && document["version"] == 1 &&
|
|
48
|
-
document["projects"].is_a?(Hash) && document["projects"].keys.all? { |id| id.is_a?(String) && ID.match?(id) } &&
|
|
49
|
-
project.is_a?(String) && ID.match?(project)
|
|
50
|
-
invalid!
|
|
51
|
-
end
|
|
52
|
-
selected = document.fetch("projects")[project]
|
|
53
|
-
validate_principal_separation!(document.fetch("projects"))
|
|
54
|
-
invalid! unless selected.is_a?(Hash) && selected.keys.sort == PROJECT_KEYS.sort
|
|
55
|
-
invalid! unless absolute?(source)
|
|
56
|
-
@project, @source = project.dup.freeze, source.dup.freeze
|
|
57
|
-
# Validate a detached immutable copy; callers cannot mutate trusted selection.
|
|
58
|
-
selected = JSON.parse(JSON.generate(selected))
|
|
59
|
-
@service, @journal, @participants = selected.values_at("service", "journal", "participants")
|
|
60
|
-
invalid! unless @service.is_a?(Hash) && @service.keys.sort == SERVICE_KEYS.sort &&
|
|
61
|
-
%w[uid gid].all? { |key| uid?(@service[key]) } &&
|
|
62
|
-
%w[socket_path state_root].all? { |key| absolute?(@service[key]) }
|
|
63
|
-
invalid! unless @journal.is_a?(Hash) && @journal.keys.sort == JOURNAL_KEYS.sort &&
|
|
64
|
-
%w[repository checkout_root].all? { |key| absolute?(@journal[key]) } &&
|
|
65
|
-
@journal["ref"].is_a?(String) && @journal["ref"].match?(%r{\Arefs/[a-zA-Z0-9_-]+(?:/[a-zA-Z0-9_-]+)+\z})
|
|
66
|
-
@assignment_root, @lifecycle_root, @runtime_binary = selected.values_at("assignment_root", "lifecycle_root", "runtime_binary")
|
|
67
|
-
invalid! unless [@assignment_root, @lifecycle_root, @runtime_binary].all? { |path| absolute?(path) }
|
|
68
|
-
@native = selected.fetch("native")
|
|
69
|
-
invalid! unless @native.is_a?(Hash) && @native.keys.sort == NATIVE_KEYS.sort && absolute?(@native["socket_path"]) &&
|
|
70
|
-
@native["protocol"] == 22 && @native["protocol"].is_a?(Integer) && @native["version"].is_a?(String) &&
|
|
71
|
-
@native["version"].match?(/\A\d+\.\d+\.\d+\z/)
|
|
72
|
-
server = @native["server_identity"]
|
|
73
|
-
invalid! unless server.is_a?(Hash) && server.keys.sort == %w[gid host pid started_at uid] &&
|
|
74
|
-
%w[pid uid gid].all? { |key| uid?(server[key]) } &&
|
|
75
|
-
%w[host started_at].all? { |key| server[key].is_a?(String) && server[key].bytesize.between?(1, 512) && !server[key].include?("\0") }
|
|
76
|
-
endpoint = @native["socket_identity"]
|
|
77
|
-
invalid! unless endpoint.is_a?(Array) && endpoint.size == 3 && endpoint.all? { |value| value.is_a?(Integer) && value >= 0 } && endpoint.last.zero?
|
|
78
|
-
@worktrees = selected.fetch("worktrees")
|
|
79
|
-
invalid! unless @worktrees.is_a?(Hash) && @worktrees.keys.sort == %w[common_dir root] && @worktrees.values.all? { |path| absolute?(path) }
|
|
80
|
-
@hitl = selected.fetch("hitl")
|
|
81
|
-
invalid! unless @hitl.is_a?(Hash) && (HITL_KEYS - @hitl.keys).empty? &&
|
|
82
|
-
(@hitl.keys - HITL_KEYS - HITL_OPTIONAL_KEYS).empty? &&
|
|
83
|
-
%w[root socket_path grants_path].all? { |key| absolute?(@hitl[key]) } &&
|
|
84
|
-
(!@hitl.key?("tls_authority") || absolute?(@hitl["tls_authority"])) &&
|
|
85
|
-
@hitl["group"].is_a?(String) && ID.match?(@hitl["group"]) &&
|
|
86
|
-
[@service.fetch("socket_path"), @native.fetch("socket_path"), @hitl.fetch("socket_path")].uniq.size == 3
|
|
87
|
-
private_paths = [@assignment_root, @lifecycle_root, @journal.fetch("checkout_root"), @hitl.fetch("root")]
|
|
88
|
-
invalid! unless private_paths.all? { |path| path.start_with?(@service.fetch("state_root") + "/") } &&
|
|
89
|
-
private_paths.uniq.size == private_paths.size && private_paths.combination(2).none? { |a, b| a.start_with?(b + "/") || b.start_with?(a + "/") }
|
|
90
|
-
invalid! if @journal.fetch("repository").start_with?(@service.fetch("state_root") + "/") ||
|
|
91
|
-
@service.fetch("state_root").start_with?(@journal.fetch("repository") + "/") ||
|
|
92
|
-
@service.fetch("state_root") == @journal.fetch("repository")
|
|
93
|
-
invalid! unless @participants.is_a?(Hash) && !@participants.empty? && @participants.size <= 64
|
|
94
|
-
@participants.each do |uid, participant|
|
|
95
|
-
invalid! unless uid.is_a?(String) && uid.match?(/\A[1-9][0-9]{0,9}\z/) && uid?(uid.to_i) && uid.to_i != @service.fetch("uid") &&
|
|
96
|
-
participant.is_a?(Hash) && participant.keys.sort == PARTICIPANT_KEYS.sort && uid?(participant["gid"]) &&
|
|
97
|
-
participant["actor"].is_a?(String) && ID.match?(participant["actor"]) &&
|
|
98
|
-
participant["runtime"] == "herdr" && participant["workspace"].is_a?(String) && ID.match?(participant["workspace"]) &&
|
|
99
|
-
participant["supervisor_panes"].is_a?(Array) && participant["supervisor_panes"].size <= 16 &&
|
|
100
|
-
participant["supervisor_panes"].uniq == participant["supervisor_panes"] &&
|
|
101
|
-
participant["supervisor_panes"].all? { |pane| pane.is_a?(String) && PANE.match?(pane) }
|
|
102
|
-
end
|
|
103
|
-
invalid! unless @participants.values.map { |value| value.fetch("actor") }.uniq.size == @participants.size
|
|
104
|
-
@fingerprint = Digest::SHA256.hexdigest(Ace::Assign::Atoms::EvidenceDigest.canonical_json(selected))
|
|
105
|
-
freeze_value(selected)
|
|
106
|
-
freeze
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
def verify_selection!
|
|
110
|
-
current = self.class.load(project: @project, path: @source)
|
|
111
|
-
unless current.fingerprint == @fingerprint
|
|
112
|
-
raise InvalidConfigurationError, "ordinary assignment owner selection changed; restart with installed configuration"
|
|
113
|
-
end
|
|
114
|
-
true
|
|
115
|
-
end
|
|
116
|
-
|
|
117
|
-
def verify_listener!
|
|
118
|
-
verify_owner!
|
|
119
|
-
unless [Process.uid, Process.euid, Process.gid, Process.egid] ==
|
|
120
|
-
[@service.fetch("uid"), @service.fetch("uid"), @service.fetch("gid"), @service.fetch("gid")]
|
|
121
|
-
raise InvalidConfigurationError, "ordinary assignment owner process differs"
|
|
122
|
-
end
|
|
123
|
-
([service.fetch("uid")] + participants.keys.map(&:to_i)).each { |uid| exclude_protected!(uid) }
|
|
124
|
-
[@service.fetch("state_root"), @assignment_root, @lifecycle_root, @journal.fetch("checkout_root")].each do |path|
|
|
125
|
-
Ace::Assign::Authority::PrivateDirectory.verify!(path)
|
|
126
|
-
end
|
|
127
|
-
verify_hitl_root!
|
|
128
|
-
wire = Ace::Runtime::Molecules::ProtectedSocket
|
|
129
|
-
wire.root_path!(@runtime_binary)
|
|
130
|
-
invalid! unless File.file?(@runtime_binary) && File.executable?(@runtime_binary)
|
|
131
|
-
invalid! unless File.lstat(@journal.fetch("repository")).uid == @service.fetch("uid")
|
|
132
|
-
reader = Ace::Assign::Molecules::InstalledJournalRead.new(repo_root: @journal.fetch("repository"),
|
|
133
|
-
ref: @journal.fetch("ref"), owner_uid: @service.fetch("uid"))
|
|
134
|
-
reader.with(deadline: wire.deadline(30)) { |_snapshot| true }
|
|
135
|
-
true
|
|
136
|
-
end
|
|
137
|
-
|
|
138
|
-
def verify_owner!
|
|
139
|
-
verify_selection!
|
|
140
|
-
exclude_protected!(@service.fetch("uid"))
|
|
141
|
-
true
|
|
142
|
-
end
|
|
143
|
-
|
|
144
|
-
def participant!(peer)
|
|
145
|
-
participant = selected_principal!(peer.fetch("uid"))
|
|
146
|
-
unless participant.fetch("gid") == peer.fetch("gid")
|
|
147
|
-
raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "ordinary participant group differs"
|
|
148
|
-
end
|
|
149
|
-
participant
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
# Selection only: consumers of canonical native history have an
|
|
153
|
-
# observed process UID, not a new authenticated socket from that owner.
|
|
154
|
-
def selected_principal!(uid)
|
|
155
|
-
verify_owner!
|
|
156
|
-
exclude_protected!(uid)
|
|
157
|
-
participant = @participants[uid.to_s]
|
|
158
|
-
unless participant
|
|
159
|
-
raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "ordinary participant is not installed for this project"
|
|
160
|
-
end
|
|
161
|
-
# HITL and the canonical coordinator use the authenticated passwd
|
|
162
|
-
# principal. An unrelated display label cannot replace that identity.
|
|
163
|
-
unless Etc.getpwuid(uid).name == participant.fetch("actor")
|
|
164
|
-
raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "ordinary actor differs from installed OS principal"
|
|
165
|
-
end
|
|
166
|
-
participant
|
|
167
|
-
rescue ArgumentError
|
|
168
|
-
raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "ordinary OS principal is unavailable"
|
|
169
|
-
end
|
|
170
|
-
|
|
171
|
-
protected
|
|
172
|
-
|
|
173
|
-
attr_reader :fingerprint
|
|
174
|
-
|
|
175
|
-
private
|
|
176
|
-
|
|
177
|
-
# HITL maintains its own layout (root 0711, private request/secret
|
|
178
|
-
# directories). The already-verified private state_root encloses it.
|
|
179
|
-
# Requiring a 0700 HITL leaf would reject every normal service restart.
|
|
180
|
-
def verify_hitl_root!
|
|
181
|
-
root = @hitl.fetch("root")
|
|
182
|
-
Ace::Runtime::Molecules::ProtectedSocket.root_path!(root, directory: true, owner: @service.fetch("uid"))
|
|
183
|
-
unless (File.stat(root).mode & 0o7777) == Ace::Hitl::Lifecycle::Store::ROOT_MODE
|
|
184
|
-
raise InvalidConfigurationError, "ordinary HITL store root must use the maintained store mode"
|
|
185
|
-
end
|
|
186
|
-
true
|
|
187
|
-
end
|
|
188
|
-
|
|
189
|
-
def validate_principal_separation!(projects)
|
|
190
|
-
invalid! unless projects.size.between?(1, 64)
|
|
191
|
-
owners, callers = [], []
|
|
192
|
-
projects.each_value do |entry|
|
|
193
|
-
invalid! unless entry.is_a?(Hash) && entry["service"].is_a?(Hash) &&
|
|
194
|
-
uid?(entry["service"]["uid"]) && entry["participants"].is_a?(Hash)
|
|
195
|
-
owners << entry.fetch("service").fetch("uid")
|
|
196
|
-
entry.fetch("participants").each_key do |uid|
|
|
197
|
-
invalid! unless uid.is_a?(String) && uid.match?(/\A[1-9][0-9]{0,9}\z/) && uid?(uid.to_i)
|
|
198
|
-
callers << uid.to_i
|
|
199
|
-
end
|
|
200
|
-
end
|
|
201
|
-
invalid! unless (owners & callers).empty?
|
|
202
|
-
end
|
|
203
|
-
|
|
204
|
-
def exclude_protected!(uid)
|
|
205
|
-
context = Ace::Assign::Authority::ProtectedAssignmentContext.load(uid: uid)
|
|
206
|
-
if context.protected_participant?
|
|
207
|
-
raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "protected principal cannot use ordinary assignment authority"
|
|
208
|
-
end
|
|
209
|
-
end
|
|
210
|
-
|
|
211
|
-
def absolute?(value)
|
|
212
|
-
value.is_a?(String) && value.bytesize.between?(1, 4096) && !value.include?("\0") &&
|
|
213
|
-
value.start_with?("/") && File.expand_path(value) == value
|
|
214
|
-
end
|
|
215
|
-
|
|
216
|
-
def uid?(value) = value.is_a?(Integer) && value.between?(1, 2**31 - 1)
|
|
217
|
-
|
|
218
|
-
def freeze_value(value)
|
|
219
|
-
case value
|
|
220
|
-
when Hash then value.each { |key, item| key.freeze; freeze_value(item) }
|
|
221
|
-
when Array then value.each { |item| freeze_value(item) }
|
|
222
|
-
end
|
|
223
|
-
value.freeze
|
|
224
|
-
end
|
|
225
|
-
|
|
226
|
-
def invalid!
|
|
227
|
-
raise InvalidConfigurationError, "ordinary assignment owner configuration is invalid"
|
|
228
|
-
end
|
|
229
|
-
end
|
|
230
|
-
end
|
|
231
|
-
end
|
|
232
|
-
end
|
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "yaml"
|
|
4
|
-
require_relative "../errors"
|
|
5
|
-
require_relative "service_policy"
|
|
6
|
-
require_relative "grant_resolver"
|
|
7
|
-
|
|
8
|
-
module Ace
|
|
9
|
-
module Lab
|
|
10
|
-
module Molecules
|
|
11
|
-
# Project-scoped, explicitly filed receiver configuration (spec
|
|
12
|
-
# 8x3.t.xz9.4 execution clauses). The host owns the file, its
|
|
13
|
-
# placement, its credentials and its deployment; this loader only
|
|
14
|
-
# validates the exact closed shape and fails closed on anything else.
|
|
15
|
-
# It invents no credential and accepts no caller-selected endpoint:
|
|
16
|
-
# every value comes from one explicit deployment-owned YAML file.
|
|
17
|
-
class OrdinaryReceiverConfig
|
|
18
|
-
KEYS = %w[project receiver_id socket_path receiver_uid principal principal_uid
|
|
19
|
-
repo_path policy_path execution_identity].freeze
|
|
20
|
-
OPTIONAL_KEYS = %w[socket_gid forge_config_path].freeze
|
|
21
|
-
IDENTITY_KEYS = %w[checkout_id worktree_id branch].freeze
|
|
22
|
-
ID = ServicePolicy::ID
|
|
23
|
-
|
|
24
|
-
def self.load(path)
|
|
25
|
-
raise Ace::Lab::InvalidConfigurationError, "receiver config path is required" unless path.is_a?(String) && !path.empty?
|
|
26
|
-
bytes = GrantResolver.read_verified(path)
|
|
27
|
-
raise Ace::Lab::InvalidConfigurationError, "receiver configuration is missing" unless bytes
|
|
28
|
-
document = YAML.safe_load(bytes, permitted_classes: [Date], aliases: false)
|
|
29
|
-
new(document, source: path)
|
|
30
|
-
rescue Errno::ENOENT, Errno::EACCES, Psych::Exception
|
|
31
|
-
raise Ace::Lab::InvalidConfigurationError, "receiver configuration is unreadable: #{path}"
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
attr_reader :project, :receiver_id, :socket_path, :receiver_uid, :principal,
|
|
35
|
-
:principal_uid, :repo_path, :policy_path, :execution_identity, :source, :socket_gid, :forge_config_path
|
|
36
|
-
|
|
37
|
-
def initialize(document, source: nil)
|
|
38
|
-
unless document.is_a?(Hash) && (document.keys - KEYS - OPTIONAL_KEYS).empty? &&
|
|
39
|
-
(KEYS - document.keys).empty?
|
|
40
|
-
raise Ace::Lab::InvalidConfigurationError,
|
|
41
|
-
"receiver configuration requires #{KEYS.join(', ')}; optional: #{OPTIONAL_KEYS.join(', ')}"
|
|
42
|
-
end
|
|
43
|
-
@socket_gid = uid!(document.fetch("socket_gid"), "socket_gid") if document.key?("socket_gid")
|
|
44
|
-
@forge_config_path = absolute!(document.fetch("forge_config_path"), "forge_config_path") if
|
|
45
|
-
document.key?("forge_config_path")
|
|
46
|
-
@source = source
|
|
47
|
-
@project = string!(document.fetch("project"), "project")
|
|
48
|
-
@receiver_id = string!(document.fetch("receiver_id"), "receiver_id")
|
|
49
|
-
@principal = string!(document.fetch("principal"), "principal")
|
|
50
|
-
@socket_path = absolute!(document.fetch("socket_path"), "socket_path")
|
|
51
|
-
@repo_path = absolute!(document.fetch("repo_path"), "repo_path")
|
|
52
|
-
@policy_path = absolute!(document.fetch("policy_path"), "policy_path")
|
|
53
|
-
@receiver_uid = uid!(document.fetch("receiver_uid"), "receiver_uid")
|
|
54
|
-
raise Ace::Lab::InvalidConfigurationError, "ordinary receiver must have a non-root owner" if @receiver_uid.zero?
|
|
55
|
-
@principal_uid = uid!(document.fetch("principal_uid"), "principal_uid")
|
|
56
|
-
identity = document.fetch("execution_identity")
|
|
57
|
-
unless identity.is_a?(Hash) && (identity.keys - IDENTITY_KEYS).empty? &&
|
|
58
|
-
IDENTITY_KEYS.all? { |key| identity[key].is_a?(String) && !identity[key].empty? }
|
|
59
|
-
raise Ace::Lab::InvalidConfigurationError,
|
|
60
|
-
"receiver execution_identity is incomplete"
|
|
61
|
-
end
|
|
62
|
-
@execution_identity = IDENTITY_KEYS.to_h { |key| [key, identity.fetch(key)] }.freeze
|
|
63
|
-
unless @repo_path.start_with?("/") && File.directory?(@repo_path)
|
|
64
|
-
raise Ace::Lab::InvalidConfigurationError, "receiver repo_path is not a directory"
|
|
65
|
-
end
|
|
66
|
-
freeze
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
private
|
|
70
|
-
|
|
71
|
-
def string!(value, name)
|
|
72
|
-
unless value.is_a?(String) && !value.empty? && value.length <= 256 && value.match?(ID)
|
|
73
|
-
raise Ace::Lab::InvalidConfigurationError, "receiver #{name} is invalid"
|
|
74
|
-
end
|
|
75
|
-
value
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
def absolute!(value, name)
|
|
79
|
-
unless value.is_a?(String) && value.start_with?("/") && value.length <= 1024
|
|
80
|
-
raise Ace::Lab::InvalidConfigurationError, "receiver #{name} must be an absolute path"
|
|
81
|
-
end
|
|
82
|
-
value
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
def uid!(value, name)
|
|
86
|
-
unless value.is_a?(Integer) && value >= 0
|
|
87
|
-
raise Ace::Lab::InvalidConfigurationError, "receiver #{name} must be a non-negative integer"
|
|
88
|
-
end
|
|
89
|
-
value
|
|
90
|
-
end
|
|
91
|
-
end
|
|
92
|
-
end
|
|
93
|
-
end
|
|
94
|
-
end
|
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "json"
|
|
4
|
-
require "socket"
|
|
5
|
-
|
|
6
|
-
module Ace
|
|
7
|
-
module Lab
|
|
8
|
-
module Molecules
|
|
9
|
-
# Admission transport between the ordinary fixed merge producer and the
|
|
10
|
-
# installed ordinary receiver: one bounded JSON document each way over
|
|
11
|
-
# a configured unix stream socket with a fixed deadline. Authentication
|
|
12
|
-
# is mutual through OS peer credentials — the producer verifies the receiver's
|
|
13
|
-
# configured UID and transmits the verified local caller identity, so
|
|
14
|
-
# no caller-selected credential, token or endpoint exists. This is not
|
|
15
|
-
# an HTTP transport; a lost or unauthenticated response is a
|
|
16
|
-
# caller-side blocker (ordinary_merge_unavailable), never a refusal.
|
|
17
|
-
class OrdinarySocket
|
|
18
|
-
LIMIT = 16_384
|
|
19
|
-
HANDSHAKE = "ace-lab/ordinary-merge/v1"
|
|
20
|
-
|
|
21
|
-
# @param socket_path [String] installed receiver endpoint (configuration)
|
|
22
|
-
# @param receiver_uid [Integer] the only UID the receiver may run as
|
|
23
|
-
# @param deadline [Time] absolute deadline for connect/write/read
|
|
24
|
-
def initialize(socket_path:, receiver_uid:, deadline: Time.now.utc + 30)
|
|
25
|
-
@socket_path = socket_path
|
|
26
|
-
@receiver_uid = receiver_uid
|
|
27
|
-
@deadline = deadline
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
# Submit one bounded request document; returns the parsed response
|
|
31
|
-
# document. Any framing, deadline or identity failure raises
|
|
32
|
-
# OrdinaryUnavailable so the producer can classify the blocker.
|
|
33
|
-
def submit(document)
|
|
34
|
-
payload = JSON.generate(document)
|
|
35
|
-
raise OrdinaryUnavailable, "request document exceeds the wire bound" if payload.bytesize > LIMIT
|
|
36
|
-
socket = nil
|
|
37
|
-
begin
|
|
38
|
-
socket = UNIXSocket.new(@socket_path)
|
|
39
|
-
verify_peer!(socket)
|
|
40
|
-
write_all!(socket, "#{HANDSHAKE} #{payload.bytesize}\n#{payload}")
|
|
41
|
-
response = read_response!(socket)
|
|
42
|
-
rescue OrdinaryUnavailable
|
|
43
|
-
raise
|
|
44
|
-
rescue SystemCallError, IOError, SocketError, NotImplementedError => e
|
|
45
|
-
raise OrdinaryUnavailable, "receiver transport failed: #{e.class}"
|
|
46
|
-
ensure
|
|
47
|
-
begin
|
|
48
|
-
socket&.close
|
|
49
|
-
rescue IOError
|
|
50
|
-
nil
|
|
51
|
-
end
|
|
52
|
-
end
|
|
53
|
-
response
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
class OrdinaryUnavailable < ::StandardError; end
|
|
57
|
-
|
|
58
|
-
private
|
|
59
|
-
|
|
60
|
-
def verify_peer!(socket)
|
|
61
|
-
peer_uid, = socket.getpeereid
|
|
62
|
-
return if peer_uid == @receiver_uid
|
|
63
|
-
raise OrdinaryUnavailable, "receiver endpoint identity is not the installed receiver"
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
def write_all!(socket, bytes)
|
|
67
|
-
written = 0
|
|
68
|
-
while written < bytes.bytesize
|
|
69
|
-
begin
|
|
70
|
-
raise OrdinaryUnavailable, "receiver write deadline expired" if expired?
|
|
71
|
-
written += socket.write_nonblock(bytes.byteslice(written..))
|
|
72
|
-
rescue IO::WaitWritable
|
|
73
|
-
socket.wait_writable(remaining!) or raise OrdinaryUnavailable, "receiver write deadline expired"
|
|
74
|
-
end
|
|
75
|
-
end
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
def read_response!(socket)
|
|
79
|
-
header = read_line!(socket)
|
|
80
|
-
match = /\A#{Regexp.escape(HANDSHAKE)} (\d{1,5})\n\z/o.match(header)
|
|
81
|
-
raise OrdinaryUnavailable, "receiver response header is invalid" unless match
|
|
82
|
-
length = Integer(match[1])
|
|
83
|
-
raise OrdinaryUnavailable, "receiver response exceeds the wire bound" if length > LIMIT
|
|
84
|
-
body = read_exact!(socket, length)
|
|
85
|
-
JSON.parse(body, allow_duplicate_key: false, create_additions: false, max_nesting: 16)
|
|
86
|
-
rescue JSON::ParserError
|
|
87
|
-
raise OrdinaryUnavailable, "receiver response is not valid JSON"
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
def read_line!(socket)
|
|
91
|
-
line = +""
|
|
92
|
-
until line.end_with?("\n")
|
|
93
|
-
raise OrdinaryUnavailable, "receiver response header is unbounded" if line.bytesize > 128
|
|
94
|
-
line << read_exact!(socket, 1)
|
|
95
|
-
end
|
|
96
|
-
line
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
def read_exact!(socket, length)
|
|
100
|
-
buffer = +""
|
|
101
|
-
while buffer.bytesize < length
|
|
102
|
-
begin
|
|
103
|
-
raise OrdinaryUnavailable, "receiver read deadline expired" if expired?
|
|
104
|
-
chunk = socket.read_nonblock(length - buffer.bytesize)
|
|
105
|
-
raise OrdinaryUnavailable, "receiver closed the connection" if chunk.nil?
|
|
106
|
-
buffer << chunk
|
|
107
|
-
rescue IO::WaitReadable
|
|
108
|
-
socket.wait_readable(remaining!) or raise OrdinaryUnavailable, "receiver read deadline expired"
|
|
109
|
-
end
|
|
110
|
-
end
|
|
111
|
-
buffer
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
def expired?
|
|
115
|
-
Time.now.utc >= @deadline
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
def remaining!
|
|
119
|
-
left = @deadline - Time.now.utc
|
|
120
|
-
raise OrdinaryUnavailable, "receiver deadline expired" if left <= 0
|
|
121
|
-
left
|
|
122
|
-
end
|
|
123
|
-
end
|
|
124
|
-
end
|
|
125
|
-
end
|
|
126
|
-
end
|
|
@@ -1,216 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "ace/assign/molecules/installed_journal_read"
|
|
4
|
-
|
|
5
|
-
module Ace
|
|
6
|
-
module Lab
|
|
7
|
-
module Molecules
|
|
8
|
-
# Observe a mutable ordinary worker checkout. The root-installed project
|
|
9
|
-
# selection supplies both boundaries; caller input never selects a Git
|
|
10
|
-
# repository. This is historical observation, not a checkout freeze.
|
|
11
|
-
class OrdinaryWorktreeRead
|
|
12
|
-
def initialize(root:, common_dir:, deadline:)
|
|
13
|
-
@root, @common, @deadline = root, common_dir, deadline
|
|
14
|
-
[root, common_dir].each { |path| absolute!(path) }
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
def observe(cwd)
|
|
18
|
-
absolute!(cwd)
|
|
19
|
-
remaining
|
|
20
|
-
unless within?(cwd, @root)
|
|
21
|
-
unavailable!("worker working directory is outside installed worktrees")
|
|
22
|
-
end
|
|
23
|
-
before = selection(cwd)
|
|
24
|
-
head = git(before, "rev-parse", "--verify", "HEAD^{commit}").strip
|
|
25
|
-
unavailable!("worker HEAD is unavailable") unless head.match?(/\A[0-9a-f]{40}\z/)
|
|
26
|
-
after = selection(cwd)
|
|
27
|
-
unless before == after && git(after, "rev-parse", "--verify", "HEAD^{commit}").strip == head
|
|
28
|
-
unavailable!("worker checkout changed during observation")
|
|
29
|
-
end
|
|
30
|
-
{"cwd" => cwd, "worktree" => before.fetch("worktree"), "git_dir" => before.fetch("git_dir"),
|
|
31
|
-
"common_dir" => @common, "head" => head}
|
|
32
|
-
rescue SystemCallError, IOError, Timeout::Error, Ace::Herdr::Molecules::BoundedProcess::PostLaunchError
|
|
33
|
-
unavailable!("worker checkout cannot be observed")
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
def ref_head(cwd, ref)
|
|
37
|
-
unless ref.is_a?(String) && ref.match?(%r{\Arefs/[a-zA-Z0-9_./-]+\z}) && !ref.include?("..")
|
|
38
|
-
unavailable!("selected Git ref is invalid")
|
|
39
|
-
end
|
|
40
|
-
with_selected_read(cwd) do |selected|
|
|
41
|
-
head = git(selected, "rev-parse", "--verify", "#{ref}^{commit}").strip
|
|
42
|
-
unavailable!("selected ref is not a commit") unless head.match?(/\A[0-9a-f]{40}\z/)
|
|
43
|
-
head
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def commit?(cwd, head)
|
|
48
|
-
exact_head!(head)
|
|
49
|
-
with_selected_read(cwd) do |selected|
|
|
50
|
-
git(selected, "rev-parse", "--verify", "--quiet", "#{head}^{commit}", allowed_status: [0, 1], result: true).status.success?
|
|
51
|
-
end
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
def ancestor?(cwd, ancestor, descendant)
|
|
55
|
-
exact_head!(ancestor); exact_head!(descendant)
|
|
56
|
-
with_selected_read(cwd) do |selected|
|
|
57
|
-
git(selected, "merge-base", "--is-ancestor", ancestor, descendant, allowed_status: [0, 1], result: true).status.success?
|
|
58
|
-
end
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
private
|
|
62
|
-
|
|
63
|
-
def exact_head!(head)
|
|
64
|
-
unavailable!("selected Git head is invalid") unless head.is_a?(String) && head.match?(/\A[0-9a-f]{40}\z/)
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
def with_selected_read(cwd)
|
|
68
|
-
absolute!(cwd)
|
|
69
|
-
unavailable!("selected repository leaves installed worktrees") unless within?(cwd, @root)
|
|
70
|
-
before = selection(cwd)
|
|
71
|
-
value = yield before
|
|
72
|
-
unavailable!("selected repository changed during read") unless before == selection(cwd)
|
|
73
|
-
value
|
|
74
|
-
rescue SystemCallError, IOError, Timeout::Error, Ace::Herdr::Molecules::BoundedProcess::PostLaunchError => error
|
|
75
|
-
unavailable!("selected repository cannot be observed (#{error.class})")
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
def selection(cwd)
|
|
79
|
-
directory!(cwd)
|
|
80
|
-
directory!(@common)
|
|
81
|
-
worktree = cwd
|
|
82
|
-
loop do
|
|
83
|
-
break if File.exist?(File.join(worktree, ".git")) || File.symlink?(File.join(worktree, ".git"))
|
|
84
|
-
unavailable!("worker checkout is unavailable") if worktree == @root
|
|
85
|
-
worktree = File.dirname(worktree)
|
|
86
|
-
end
|
|
87
|
-
marker = File.join(worktree, ".git")
|
|
88
|
-
metadata = {}
|
|
89
|
-
git_dir = if File.lstat(marker).directory?
|
|
90
|
-
directory!(marker)
|
|
91
|
-
unavailable!("worker belongs to another project") unless marker == @common
|
|
92
|
-
marker
|
|
93
|
-
else
|
|
94
|
-
bytes = regular!(marker, metadata, 4096)
|
|
95
|
-
match = bytes.match(/\Agitdir: ([^\n\0]+)\n?\z/)
|
|
96
|
-
unavailable!("worker Git pointer is invalid") unless match
|
|
97
|
-
selected = File.expand_path(match[1], worktree)
|
|
98
|
-
unless File.dirname(selected) == File.join(@common, "worktrees")
|
|
99
|
-
unavailable!("worker Git pointer leaves installed project")
|
|
100
|
-
end
|
|
101
|
-
directory!(selected)
|
|
102
|
-
common = regular!(File.join(selected, "commondir"), metadata, 4096).strip
|
|
103
|
-
unavailable!("worker common directory differs") unless File.expand_path(common, selected) == @common
|
|
104
|
-
back = regular!(File.join(selected, "gitdir"), metadata, 4096).strip
|
|
105
|
-
unavailable!("worker Git backpointer differs") unless back == marker
|
|
106
|
-
selected
|
|
107
|
-
end
|
|
108
|
-
@entries = 0
|
|
109
|
-
%w[objects refs].each { |name| storage!(File.join(@common, name)) }
|
|
110
|
-
%w[commondir shallow reftable info/grafts objects/info/alternates objects/info/http-alternates].each do |name|
|
|
111
|
-
path = File.join(@common, name)
|
|
112
|
-
unavailable!("worker object storage is redirected") if File.exist?(path) || File.symlink?(path)
|
|
113
|
-
end
|
|
114
|
-
regular!(File.join(git_dir, "HEAD"), metadata, 4096)
|
|
115
|
-
packed = File.join(@common, "packed-refs")
|
|
116
|
-
storage!(packed) if File.exist?(packed) || File.symlink?(packed)
|
|
117
|
-
local_refs = File.join(git_dir, "refs")
|
|
118
|
-
storage!(local_refs) if git_dir != @common && (File.exist?(local_refs) || File.symlink?(local_refs))
|
|
119
|
-
config!(File.join(@common, "config"), metadata)
|
|
120
|
-
local = File.join(git_dir, "config.worktree")
|
|
121
|
-
config!(local, metadata) if File.exist?(local) || File.symlink?(local)
|
|
122
|
-
{"worktree" => worktree, "git_dir" => git_dir, "metadata" => metadata,
|
|
123
|
-
"directories" => [cwd, worktree, git_dir, @common].to_h { |path| [path, identity(File.lstat(path))] }}
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
def config!(path, metadata)
|
|
127
|
-
bytes = regular!(path, metadata, 65_536)
|
|
128
|
-
output = run(["config", "--no-includes", "--null", "--list", "--file", "/dev/stdin"], stdin: bytes)
|
|
129
|
-
output.split("\0").each do |entry|
|
|
130
|
-
key, value = entry.split("\n", 2)
|
|
131
|
-
if key.start_with?("include.", "includeif.", "extensions.") || key == "core.worktree" ||
|
|
132
|
-
key == "core.repositoryformatversion" && value != "0"
|
|
133
|
-
unavailable!("worker Git configuration redirects project storage")
|
|
134
|
-
end
|
|
135
|
-
end
|
|
136
|
-
end
|
|
137
|
-
|
|
138
|
-
def git(selected, *argv, allowed_status: [0], result: false)
|
|
139
|
-
captured = run_result(["--git-dir=#{selected.fetch('git_dir')}", "--work-tree=#{selected.fetch('worktree')}",
|
|
140
|
-
"-c", "safe.directory=#{selected.fetch('worktree')}", "-c", "core.hooksPath=/dev/null",
|
|
141
|
-
"-c", "core.fsmonitor=false", "-c", "protocol.allow=never", *argv],
|
|
142
|
-
environment: {"GIT_COMMON_DIR" => @common, "GIT_OBJECT_DIRECTORY" => File.join(@common, "objects")}, allowed_status: allowed_status)
|
|
143
|
-
result ? captured : captured.stdout
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
def run(argv, stdin: "", environment: {})
|
|
147
|
-
run_result(argv, stdin: stdin, environment: environment).stdout
|
|
148
|
-
end
|
|
149
|
-
|
|
150
|
-
def run_result(argv, stdin: "", environment: {}, allowed_status: [0])
|
|
151
|
-
result = Ace::Herdr::Molecules::BoundedProcess.call(["/usr/bin/git", "--no-pager", *argv],
|
|
152
|
-
environment: Ace::Assign::Molecules::InstalledJournalRead::ENVIRONMENT.merge(environment), chdir: "/",
|
|
153
|
-
stdin_data: stdin, timeout_s: remaining, output_limit: 65_536, cleanup_group: true)
|
|
154
|
-
unavailable!("worker Git read failed or exceeded its bound") unless allowed_status.include?(result.status.exitstatus) && !result.oversized
|
|
155
|
-
result
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
def regular!(path, metadata, limit)
|
|
159
|
-
directory!(File.dirname(path))
|
|
160
|
-
File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK) do |file|
|
|
161
|
-
before = file.stat
|
|
162
|
-
unavailable!("worker metadata must be a bounded regular file") unless before.file? && before.size <= limit
|
|
163
|
-
bytes = file.read(limit + 1)
|
|
164
|
-
unless bytes.bytesize <= limit && stamp(before) == stamp(file.stat) && stamp(before) == stamp(File.lstat(path))
|
|
165
|
-
unavailable!("worker metadata changed during read")
|
|
166
|
-
end
|
|
167
|
-
metadata[path] = [stamp(before), bytes]
|
|
168
|
-
bytes
|
|
169
|
-
end
|
|
170
|
-
end
|
|
171
|
-
|
|
172
|
-
def directory!(path)
|
|
173
|
-
current = path
|
|
174
|
-
loop do
|
|
175
|
-
remaining
|
|
176
|
-
stat = File.lstat(current)
|
|
177
|
-
unavailable!("worker directory is redirected") unless stat.directory? && !stat.symlink?
|
|
178
|
-
break if current == "/"
|
|
179
|
-
current = File.dirname(current)
|
|
180
|
-
end
|
|
181
|
-
end
|
|
182
|
-
|
|
183
|
-
def storage!(path)
|
|
184
|
-
remaining
|
|
185
|
-
@entries += 1
|
|
186
|
-
unavailable!("worker storage inventory exceeds its bound") if @entries > Ace::Assign::Molecules::InstalledJournalRead::ENTRY_LIMIT
|
|
187
|
-
stat = File.lstat(path)
|
|
188
|
-
unavailable!("worker storage is redirected") unless !stat.symlink? && (stat.file? || stat.directory?)
|
|
189
|
-
Dir.each_child(path) { |name| storage!(File.join(path, name)) } if stat.directory?
|
|
190
|
-
end
|
|
191
|
-
|
|
192
|
-
def absolute!(path)
|
|
193
|
-
unless path.is_a?(String) && path.bytesize.between?(1, 4096) && !path.include?("\0") &&
|
|
194
|
-
path.start_with?("/") && File.expand_path(path) == path
|
|
195
|
-
unavailable!("worker path is invalid")
|
|
196
|
-
end
|
|
197
|
-
end
|
|
198
|
-
|
|
199
|
-
def within?(path, parent) = path == parent || path.start_with?(parent + "/")
|
|
200
|
-
def identity(stat) = [stat.dev, stat.ino]
|
|
201
|
-
def stamp(stat) = [stat.dev, stat.ino, stat.size, stat.mtime, stat.ctime]
|
|
202
|
-
|
|
203
|
-
def remaining
|
|
204
|
-
unless @deadline.is_a?(Numeric) && @deadline.finite? && (left = @deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)).positive?
|
|
205
|
-
unavailable!("worker observation deadline elapsed")
|
|
206
|
-
end
|
|
207
|
-
left
|
|
208
|
-
end
|
|
209
|
-
|
|
210
|
-
def unavailable!(reason)
|
|
211
|
-
raise Ace::Assign::AttemptErrors::EvidenceUnavailable, reason
|
|
212
|
-
end
|
|
213
|
-
end
|
|
214
|
-
end
|
|
215
|
-
end
|
|
216
|
-
end
|