ace-hitl 0.14.0 → 0.15.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d921414979ccc0569dbfc0c8e102b3aa29c929f5e9ccde725968dc2a7227da9a
4
- data.tar.gz: fb5c118b0f25a93507602223c5fb7f0751149883c4ead52ab96018d2651e4664
3
+ metadata.gz: '0827bd8ade04c613021128ac335a1d0fea74d5954e718122bd243a780bebb675'
4
+ data.tar.gz: df48a9612ca6a87670142ee8f511a837e206af2294a1a609be8045bfc14c1830
5
5
  SHA512:
6
- metadata.gz: 67d4f65668300b1c111937e9f0dd11aa4ab11684db06c0cbe65cc2f73d227601985b32734bcd21849e82740a7bf44133fbdeb4bd860c45836e2f8cd541874d25
7
- data.tar.gz: a7ee80f146804aedf4f7f14d901c6678cee3e8de3d909feb79ef189b5c36a97980a3ce0892760943533907bbce766847848492f3c8e9f8578064ba859261f699
6
+ metadata.gz: b0f0b827b307e8baf05a25c9503455fde43c8734e121f7d3f5f222a4d0a0aa201653bb346366fd6b8445c700031113068ff133cf24e4566b825e4bf59633f622
7
+ data.tar.gz: 462e2cef1a68a7a4a3975b96b324977223101a53ba384940875197e61c103054d3e52cbb83d4337f98aea0f5c92dfd306d0c372caa61e2bd8ab20517aca73cf3
data/CHANGELOG.md CHANGED
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.15.0] - 2026-10-14
11
+
12
+ ### Added
13
+
14
+ - Add `ace-hitl serve --tls-authority FILE` for the host/Incus mTLS crossing: a TLS 1.3-only loopback lifecycle authority with mutual X.509 identity (closed-form URI SANs, exact SHA-256 fingerprint pins, configured issuing CA, serverAuth/clientAuth EKU), fail-closed credential provisioning-layout enforcement before any socket opens, and remote peer-to-project binding that refuses pending flows, creates, and proposals for any non-bound project. Unix peer-credential serving is unchanged; shared frame/store logic is extracted without behavior change.
15
+
16
+ ### Changed
17
+
18
+ - Narrow `ace-hitl serve --group` to dedicated lifecycle groups: a missing group, broad or known-shared control groups (gid 0, `lab-control`), the legacy `/run/lab` directory, and directories containing a legacy `labd.sock` entry now refuse startup fail-closed before any endpoint is created.
19
+
10
20
  ## [0.14.0] - 2026-10-11
11
21
 
12
22
  ### Added
data/docs/usage.md CHANGED
@@ -472,3 +472,38 @@ Missing local dependency archives fail visibly without network fallback. A faile
472
472
  consumer retains diagnostic artifacts; inspect `result.json` and `service.log`
473
473
  before retrying. Each invocation retains a new run directory and requires a new
474
474
  empty GEM_HOME.
475
+
476
+ ## The host/Incus mTLS crossing (nh0-A)
477
+
478
+ `ace-hitl serve --tls-authority FILE` runs one lifecycle authority of
479
+ the shared-Hermes topology on a loopback TCP endpoint where the
480
+ project's Incus TCP proxy device connects. The authority config
481
+ (`ace.hitl.tls.authority/v1`, closed fields, root-owned runtime file
482
+ `root:<service-group> 0640`) owns the loopback listen address, the
483
+ project ID, the service account names and the server credential
484
+ reference — never secret bytes:
485
+
486
+ ```json
487
+ {"schema": "ace.hitl.tls.authority/v1",
488
+ "project_id": "proj-a", "listen": "127.0.0.1", "port": 8443,
489
+ "service_user": "hitl-proj-a", "service_group": "hitl-proj-a",
490
+ "tls_server_credential": {
491
+ "certificate_file": "/etc/ace-hitl/proj-a/server.pem",
492
+ "private_key_file": "/etc/ace-hitl/proj-a/server-key.pem",
493
+ "ca_file": "/etc/ace-hitl/proj-a/ca.pem",
494
+ "server_uri_san": "urn:ace:hitl:proj-a:lifecycle-server",
495
+ "allowed_client_uri_san": "urn:ace:hitl:proj-a:hermes-client",
496
+ "allowed_client_certificate_sha256": "<64 lowercase hex>"}}
497
+ ```
498
+
499
+ Startup enforces the exact provisioning layout (ancestors `root:root`
500
+ 0755, project directory `root:<service-group>` 0750, private key
501
+ `<service-user>:<service-group>` 0400 and never hard-linked,
502
+ certificate/CA `root:root` 0444) BEFORE any socket opens, validates its
503
+ own certificate identity (single URI SAN, serverAuth EKU, key match),
504
+ speaks TLS 1.3 only, and admits exactly the configured client
505
+ SAN/fingerprint pair — bound to the authority's one project, with
506
+ pending flows and creates refused for any other project. Unix
507
+ peer-credential serving (`serve` without `--tls-authority`) remains the
508
+ same-runtime boundary. lab-config (gad.8/gad.b) owns accounts, file
509
+ provisioning, Incus proxy devices and the tailnet listeners.
@@ -19,15 +19,20 @@ module Ace
19
19
  desc "Run the authenticated HITL store boundary service (peer-credential access)"
20
20
 
21
21
  option :socket, type: :string, desc: "Boundary socket path (default: ACE_HITL_SOCKET or /run/lab/hitl.sock)"
22
- option :group, type: :string, desc: "Dedicated control group allowed to connect (default: lab-control)"
22
+ option :group, type: :string,
23
+ desc: "Dedicated lifecycle group allowed to connect (required; root and lab-control are refused)"
23
24
  option :store_root, type: :string, desc: "Private store root (default: ACE_HITL_STORE_ROOT or /run/lab/hitl)"
24
25
  option :grants_path, type: :string, desc: "Trusted grants document (default: ACE_HITL_GRANTS_PATH or /etc/lab/ace-lab/authorization.yml)"
25
26
  option :repo_root, type: :string, desc: "Candidate repository for the managed attempt authority"
27
+ option :tls_authority, type: :string, desc: "Serve the host/Incus mTLS crossing from this authority config (root:<service-group> 0640; loopback TCP + verified project client identity)"
26
28
 
27
29
  option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
28
30
  option :verbose, type: :boolean, aliases: %w[-v], desc: "Show verbose output"
29
31
 
30
32
  def call(**options)
33
+ return serve_tls_authority(options) if options[:tls_authority]
34
+
35
+ group = dedicated_group(options)
31
36
  policy = Providers::Lab.grants_policy(grants_path: grants_path(options))
32
37
  unless policy.service_uid
33
38
  raise_cli_error(
@@ -42,7 +47,7 @@ module Ace
42
47
  binding: binding_policy,
43
48
  policy: policy,
44
49
  socket_path: socket_path(options),
45
- group: options[:group] || Lifecycle::Store::DEFAULT_GROUP,
50
+ group: group,
46
51
  logger: options[:verbose] ? ->(message) { warn message } : nil
47
52
  )
48
53
  service.run
@@ -52,6 +57,36 @@ module Ace
52
57
 
53
58
  private
54
59
 
60
+ # The host/Incus crossing (spec 8xa.t.nh0): authority config,
61
+ # credential layout and TLS material are verified fail-closed
62
+ # BEFORE the loopback listener opens; the verified project-
63
+ # scoped client certificate is the only admitted peer.
64
+ def serve_tls_authority(options)
65
+ authority = Lifecycle::TlsAuthority.load(options[:tls_authority])
66
+ binding_policy = Providers::Lab.assignment_binding(repo_root: options[:repo_root])
67
+ service = Lifecycle::TlsService.new(
68
+ root: store_root(options),
69
+ binding: binding_policy,
70
+ project: authority.project_id,
71
+ credential: authority.credential,
72
+ ssl_context: authority.ssl_context!,
73
+ listen: authority.listen,
74
+ port: authority.port,
75
+ group: authority.service_group,
76
+ logger: options[:verbose] ? ->(message) { warn message } : nil
77
+ )
78
+ service.run
79
+ rescue Lifecycle::Error, Providers::ProviderUnavailableError => e
80
+ raise_cli_error(e.message)
81
+ end
82
+
83
+ def dedicated_group(options)
84
+ group = options[:group].to_s
85
+ return group unless group.empty?
86
+
87
+ raise_cli_error("--group is required and must name a dedicated lifecycle group")
88
+ end
89
+
55
90
  def socket_path(options)
56
91
  options[:socket] || ENV[Providers::Lab::SOCKET_PATH_ENV] || Providers::Lab::DEFAULT_SOCKET_PATH
57
92
  end
@@ -2,13 +2,18 @@
2
2
 
3
3
  require "socket"
4
4
  require_relative "protocol"
5
+ require_relative "frame_exchange"
6
+ require_relative "lifecycle_requests"
5
7
 
6
8
  module Ace
7
9
  module Hitl
8
10
  module Lifecycle
9
- # The authenticated client of the scoped store boundary (spec
10
- # 8wq.t.34i). The CLI lifecycle operations run through this client;
11
- # direct shared-store access is not a client-side option.
11
+ # The authenticated Unix-socket client of the scoped store
12
+ # boundary (spec 8wq.t.34i). The CLI lifecycle operations run
13
+ # through this client; direct shared-store access is not a
14
+ # client-side option. Cross-runtime clients use the mutual-TLS
15
+ # Hermes client of the host/Incus crossing (spec 8xa.t.nh0)
16
+ # instead; both share the request surface and frame exchange.
12
17
  #
13
18
  # Both endpoint directions are authenticated:
14
19
  # - the client authenticates the SERVICE: the socket must be a
@@ -21,43 +26,10 @@ module Ace
21
26
  # classified lifecycle errors arrive from the service and are
22
27
  # re-raised under their own classes.
23
28
  class Client
24
- attr_reader :socket_path, :service_uid
25
-
26
- def proposal_create(**params)
27
- request("proposal-create", params)
28
- end
29
-
30
- def proposal_show(id, project:, history_after: 0)
31
- request("proposal-show", {"id" => id, "project" => project, "history_after" => history_after})
32
- end
33
-
34
- def proposal_revise(id, project:, expected_revision:, operation_id:, document:)
35
- request("proposal-revise", {"id" => id, "project" => project, "expected_revision" => expected_revision, "operation_id" => operation_id, "document" => document})
36
- end
29
+ include FrameExchange
30
+ include LifecycleRequests
37
31
 
38
- def proposal_acknowledge(id, project:, submitted_at:)
39
- request("proposal-ack", {"id" => id, "project" => project, "submitted_at" => submitted_at})
40
- end
41
-
42
- def proposal_reply(id, project:, **params)
43
- request("proposal-reply", {"id" => id, "project" => project}.merge(params))
44
- end
45
-
46
- def proposal_reconcile(id, project:, checkpoint:)
47
- request("proposal-reconcile", {"id" => id, "project" => project, "checkpoint" => checkpoint})
48
- end
49
-
50
- def proposal_wake(project:, after: nil)
51
- request("proposal-wake", {"project" => project, "after" => after}.compact)
52
- end
53
-
54
- def proposal_due(project:, after: nil)
55
- request("proposal-due", {"project" => project, "after" => after}.compact)
56
- end
57
-
58
- def proposal_history(project:, query: "", after: nil)
59
- request("proposal-history", {"project" => project, "query" => query, "after" => after}.compact)
60
- end
32
+ attr_reader :socket_path, :service_uid
61
33
 
62
34
  def initialize(socket_path:, service_uid:, deadline_seconds: Protocol::DEFAULT_DEADLINE_SECONDS)
63
35
  @socket_path = socket_path.to_s
@@ -85,57 +57,6 @@ module Ace
85
57
  raise TransportError, "HITL boundary request is not serializable (#{e.message})"
86
58
  end
87
59
 
88
- def create(deadline: nil, **params)
89
- request("create", params.transform_keys(&:to_s), deadline: deadline)
90
- end
91
-
92
- def read(id, deadline: nil)
93
- request("read", {"id" => id}, deadline: deadline)
94
- end
95
-
96
- def deliver(id, answer)
97
- request("deliver", {"id" => id, "answer" => answer.to_s})
98
- end
99
-
100
- def consume(id, timeout: 0, operation: nil, native_delivery: false, deadline: nil)
101
- params = {"id" => id, "timeout" => Integer(timeout)}
102
- params["operation"] = operation if operation
103
- params["native_delivery"] = true if native_delivery
104
- request("consume", params, deadline: deadline)
105
- end
106
-
107
- def cancel(id, reason: "", deadline: nil)
108
- request("cancel", {"id" => id, "reason" => reason.to_s}, deadline: deadline)
109
- end
110
-
111
- def pending(project: nil)
112
- items = []
113
- after = nil
114
- loop do
115
- page = pending_page(project: project, after: after)
116
- items.concat(page.fetch("items"))
117
- cursor = page.fetch("next")
118
- break unless cursor
119
- unless cursor.is_a?(String) && (after.nil? || cursor > after)
120
- raise TransportError, "pending cursor did not advance"
121
- end
122
- after = cursor
123
- end
124
- items
125
- end
126
-
127
- def pending_page(project: nil, after: nil)
128
- request("pending", {"project" => project, "after" => after}.compact)
129
- end
130
-
131
- def states
132
- request("states")
133
- end
134
-
135
- def ping
136
- request("ping")
137
- end
138
-
139
60
  private
140
61
 
141
62
  # The endpoint must be owned by the trusted service identity and
@@ -175,24 +96,6 @@ module Ace
175
96
  socket.getpeereid
176
97
  end
177
98
 
178
- # A consume without a timeout waits indefinitely at the client
179
- # boundary too; every bounded call gets a deadline slightly
180
- # beyond its own wait so the server result wins the race.
181
- def request_deadline(op, params, selected)
182
- if !selected.nil? && (!selected.is_a?(Numeric) || !selected.finite?)
183
- raise TransportError, "HITL boundary deadline is invalid"
184
- end
185
- local = deadline_for(op, params)
186
- [selected, local].compact.min
187
- end
188
-
189
- def await!(socket, direction, deadline)
190
- remaining = deadline && deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
191
- raise TransportError, "HITL boundary deadline exceeded" if remaining && remaining <= 0
192
- readable, writable = direction == :read ? [[socket], nil] : [nil, [socket]]
193
- raise TransportError, "HITL boundary deadline exceeded" unless IO.select(readable, writable, nil, remaining)
194
- end
195
-
196
99
  def connect!(socket, deadline)
197
100
  if deadline && deadline <= Process.clock_gettime(Process::CLOCK_MONOTONIC)
198
101
  raise TransportError, "HITL boundary deadline exceeded"
@@ -203,49 +106,6 @@ module Ace
203
106
  error = socket.getsockopt(Socket::SOL_SOCKET, Socket::SO_ERROR).int
204
107
  raise SystemCallError.new("HITL boundary connection failed", error) unless error.zero?
205
108
  end
206
-
207
- def write_frame!(socket, frame, deadline)
208
- offset = 0
209
- while offset < frame.bytesize
210
- await!(socket, :write, deadline)
211
- written = socket.write_nonblock(frame.byteslice(offset..), exception: false)
212
- next if written == :wait_writable
213
- raise TransportError, "HITL boundary closed before request" unless written.is_a?(Integer) && written.positive?
214
- offset += written
215
- end
216
- end
217
-
218
- def deadline_for(op, params)
219
- if op == "consume" && Integer(params["timeout"]) <= 0
220
- nil
221
- else
222
- Process.clock_gettime(Process::CLOCK_MONOTONIC) + @deadline_seconds + Integer(params["timeout"])
223
- end
224
- rescue ArgumentError, TypeError
225
- Process.clock_gettime(Process::CLOCK_MONOTONIC) + @deadline_seconds
226
- end
227
-
228
- def read_line(socket, deadline)
229
- data = +""
230
- loop do
231
- remaining = deadline && deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
232
- raise TransportError, "HITL boundary deadline exceeded" if remaining && remaining <= 0
233
- ready = IO.select([socket], nil, nil, remaining)
234
- raise TransportError, "HITL boundary deadline exceeded" unless ready
235
-
236
- chunk = socket.read_nonblock(4096, exception: false)
237
- raise TransportError, "HITL boundary closed before responding" if chunk.nil?
238
- # A spurious not-ready result goes back to select; only the
239
- # deadline ends the wait (review 8x32r9b4).
240
- next if chunk == :wait_readable
241
-
242
- data << chunk
243
- if data.bytesize > Protocol::MAX_FRAME_BYTES
244
- raise TransportError, "HITL boundary response is too large"
245
- end
246
- return data if data.include?("\n")
247
- end
248
- end
249
109
  end
250
110
  end
251
111
  end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "etc"
4
+
5
+ module Ace
6
+ module Hitl
7
+ module Lifecycle
8
+ # Provisioning-time enforcement of the one exact ownership/mode
9
+ # layout of the host/Incus crossing (spec 8xa.t.nh0). There are no
10
+ # mode alternatives:
11
+ #
12
+ # - every credential-tree ancestor above the project leaf is
13
+ # `root:root` mode `0755` (verified up to the filesystem root);
14
+ # - the project credential directory is `root:<service-group>`
15
+ # mode `0750`;
16
+ # - the referenced private key is `<service-user>:<service-group>`
17
+ # mode `0400` and never hard-linked;
18
+ # - the referenced certificate and CA bundle are `root:root`
19
+ # mode `0444`;
20
+ # - the root-owned runtime file containing endpoint/credential
21
+ # references is `root:<service-group>` mode `0640`;
22
+ # - each path component and file is a regular owned object: no
23
+ # symlink, group/world-writable component, hard-linked key or
24
+ # ownership substitution is accepted.
25
+ #
26
+ # Exact mode + exact owner/group verification is the enforceable
27
+ # ACL proxy: any POSIX ACL that grants beyond the declared mode
28
+ # surfaces in the group class bits and fails the exact match.
29
+ #
30
+ # Any missing, unreadable, redirected, over-broad, incorrectly
31
+ # owned/mode-set or mismatched object REFUSES STARTUP before a
32
+ # socket is opened: every check runs before the TLS service binds.
33
+ class CredentialPathPolicy
34
+ Error = Class.new(Lifecycle::Error)
35
+
36
+ ANCESTOR_MODE = 0o755
37
+ PROJECT_DIR_MODE = 0o750
38
+ KEY_MODE = 0o400
39
+ PUBLIC_MODE = 0o444
40
+ RUNTIME_FILE_MODE = 0o640
41
+
42
+ class << self
43
+ # Verify the three referenced credential files of one project
44
+ # credential object (server or client form; both share the
45
+ # layout).
46
+ def verify_credential_tree!(credential:, service_user:, service_group:)
47
+ new(service_user: service_user, service_group: service_group)
48
+ .verify_credential_tree!(credential)
49
+ end
50
+
51
+ # Verify one root-owned runtime configuration file that holds
52
+ # endpoint/credential references.
53
+ def verify_runtime_file!(path, service_group:)
54
+ new(service_user: nil, service_group: service_group).verify_runtime_file!(path)
55
+ end
56
+ end
57
+
58
+ def initialize(service_user:, service_group:)
59
+ # The runtime-file check verifies root ownership only; the
60
+ # service user is required for the key ownership rule.
61
+ @service_uid = service_user.nil? ? nil : resolve_user(service_user)
62
+ @service_gid = resolve_group(service_group)
63
+ end
64
+
65
+ def verify_credential_tree!(credential)
66
+ roles = {"certificate_file" => :public, "private_key_file" => :key, "ca_file" => :public}
67
+ # Pass one: structural verification of EVERY referenced file
68
+ # (existence, no symlink, regular file) before any ownership
69
+ # comparison — structural refusals never depend on privilege.
70
+ paths = roles.map do |field, role|
71
+ path = credential.fetch(field)
72
+ lstat_file!(path, "credential file")
73
+ [path, role]
74
+ end
75
+ # Pass two: the exact ownership/mode rules per role.
76
+ paths.each do |path, role|
77
+ verify_owner_mode!(path, role)
78
+ end
79
+ project_dir = File.dirname(paths.first.first)
80
+ unless paths.all? { |path, _role| File.dirname(path) == project_dir }
81
+ raise Error,
82
+ "credential files must share one project credential directory " \
83
+ "(#{paths.map(&:first).join(", ")})"
84
+ end
85
+ verify_project_directory(project_dir)
86
+ verify_ancestors(project_dir)
87
+ self
88
+ end
89
+
90
+ def verify_runtime_file!(path)
91
+ stat = lstat_file!(path, "runtime configuration file")
92
+ unless stat.uid.zero? && stat.gid == @service_gid && (stat.mode & 0o7777) == RUNTIME_FILE_MODE
93
+ raise Error,
94
+ "runtime configuration file must be root:<service-group> mode 0640: #{path} " \
95
+ "(got uid=#{stat.uid} gid=#{stat.gid} mode=#{stat.mode.to_s(8)})"
96
+ end
97
+
98
+ self
99
+ end
100
+
101
+ private
102
+
103
+ def verify_owner_mode!(path, role)
104
+ stat = File.lstat(path)
105
+ expected_owner = role == :key ? [@service_uid, @service_gid] : [0, 0]
106
+ expected_mode = role == :key ? KEY_MODE : PUBLIC_MODE
107
+ unless [stat.uid, stat.gid] == expected_owner && (stat.mode & 0o7777) == expected_mode
108
+ raise Error,
109
+ "credential file has the wrong owner or mode: #{path} (got uid=#{stat.uid} " \
110
+ "gid=#{stat.gid} mode=#{stat.mode.to_s(8)})"
111
+ end
112
+ if role == :key && stat.nlink != 1
113
+ raise Error, "credential private key must not be hard-linked: #{path}"
114
+ end
115
+ end
116
+
117
+ def verify_project_directory(path)
118
+ stat = File.lstat(path)
119
+ unless stat.directory? && !stat.symlink?
120
+ raise Error, "project credential directory is not a regular directory: #{path}"
121
+ end
122
+ unless stat.uid.zero? && stat.gid == @service_gid && (stat.mode & 0o7777) == PROJECT_DIR_MODE
123
+ raise Error,
124
+ "project credential directory must be root:<service-group> mode 0750: #{path} " \
125
+ "(got uid=#{stat.uid} gid=#{stat.gid} mode=#{stat.mode.to_s(8)})"
126
+ end
127
+ rescue Errno::ENOENT
128
+ raise Error, "project credential directory does not exist: #{path}"
129
+ end
130
+
131
+ # Every ancestor above the project leaf — up to the filesystem
132
+ # root — is a root:root 0755 directory. A group/world-writable,
133
+ # foreign-owned or symlinked component anywhere on the chain
134
+ # would allow credential substitution.
135
+ def verify_ancestors(path)
136
+ parent = File.expand_path("..", path)
137
+ components = parent.split("/").reject(&:empty?)
138
+ verify_ancestor!("/", File.lstat("/"))
139
+ components.each_with_object("") do |component, current|
140
+ current << "/#{component}"
141
+ stat = File.lstat(current)
142
+ verify_ancestor!(current, stat)
143
+ end
144
+ end
145
+
146
+ def verify_ancestor!(path, stat)
147
+ unless stat.directory? && !stat.symlink? && stat.uid.zero? && stat.gid.zero? &&
148
+ (stat.mode & 0o7777) == ANCESTOR_MODE
149
+ raise Error,
150
+ "credential path ancestor must be a root:root mode 0755 directory: #{path} " \
151
+ "(got uid=#{stat.uid} gid=#{stat.gid} mode=#{stat.mode.to_s(8)})"
152
+ end
153
+ rescue Errno::ENOENT
154
+ raise Error, "credential path ancestor does not exist: #{path}"
155
+ end
156
+
157
+ def lstat_file!(path, label)
158
+ stat = File.lstat(path)
159
+ raise Error, "#{label} must not be a symlink: #{path}" if stat.symlink?
160
+ raise Error, "#{label} is not a regular file: #{path}" unless stat.file?
161
+
162
+ stat
163
+ rescue Errno::ENOENT
164
+ raise Error, "#{label} does not exist: #{path}"
165
+ rescue SystemCallError
166
+ raise Error, "#{label} is not verifiable: #{path}"
167
+ end
168
+
169
+ def resolve_user(name)
170
+ Etc.getpwnam(name.to_s).uid
171
+ rescue ArgumentError
172
+ raise Error, "the configured service user does not exist: #{name.inspect}"
173
+ end
174
+
175
+ def resolve_group(name)
176
+ Etc.getgrnam(name.to_s).gid
177
+ rescue ArgumentError
178
+ raise Error, "the configured service group does not exist: #{name.inspect}"
179
+ end
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Hitl
5
+ module Lifecycle
6
+ # The deadline-bounded frame exchange of a lifecycle client (spec
7
+ # 8wq.t.34i, shared by the Unix and the TLS 1.3/TCP transports):
8
+ # one newline-delimited request frame out, one bounded response
9
+ # frame back, size and deadline rules identical on both transports.
10
+ # The includer supplies `@deadline_seconds` and implements
11
+ # `#request(op, params, deadline:)`.
12
+ module FrameExchange
13
+ private
14
+
15
+ def await!(socket, direction, deadline)
16
+ remaining = deadline && deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
17
+ raise TransportError, "HITL boundary deadline exceeded" if remaining && remaining <= 0
18
+ readable, writable = direction == :read ? [[socket], nil] : [nil, [socket]]
19
+ raise TransportError, "HITL boundary deadline exceeded" unless IO.select(readable, writable, nil, remaining)
20
+ end
21
+
22
+ def write_frame!(socket, frame, deadline)
23
+ offset = 0
24
+ while offset < frame.bytesize
25
+ await!(socket, :write, deadline)
26
+ written = socket.write_nonblock(frame.byteslice(offset..), exception: false)
27
+ next if written == :wait_writable
28
+ raise TransportError, "HITL boundary closed before request" unless written.is_a?(Integer) && written.positive?
29
+ offset += written
30
+ end
31
+ end
32
+
33
+ def read_line(socket, deadline)
34
+ data = +""
35
+ loop do
36
+ remaining = deadline && deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
37
+ raise TransportError, "HITL boundary deadline exceeded" if remaining && remaining <= 0
38
+ ready = IO.select([socket], nil, nil, remaining)
39
+ raise TransportError, "HITL boundary deadline exceeded" unless ready
40
+
41
+ chunk = socket.read_nonblock(4096, exception: false)
42
+ raise TransportError, "HITL boundary closed before responding" if chunk.nil?
43
+ # A spurious not-ready result goes back to select; only the
44
+ # deadline ends the wait (review 8x32r9b4).
45
+ next if chunk == :wait_readable
46
+
47
+ data << chunk
48
+ if data.bytesize > Protocol::MAX_FRAME_BYTES
49
+ raise TransportError, "HITL boundary response is too large"
50
+ end
51
+ return data if data.include?("\n")
52
+ end
53
+ end
54
+
55
+ # A consume without a timeout waits indefinitely at the client
56
+ # boundary too; every bounded call gets a deadline slightly
57
+ # beyond its own wait so the server result wins the race.
58
+ def request_deadline(op, params, selected)
59
+ if !selected.nil? && (!selected.is_a?(Numeric) || !selected.finite?)
60
+ raise TransportError, "HITL boundary deadline is invalid"
61
+ end
62
+ local = deadline_for(op, params)
63
+ [selected, local].compact.min
64
+ end
65
+
66
+ def deadline_for(op, params)
67
+ if op == "consume" && Integer(params["timeout"]) <= 0
68
+ nil
69
+ else
70
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) + @deadline_seconds + Integer(params["timeout"])
71
+ end
72
+ rescue ArgumentError, TypeError
73
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) + @deadline_seconds
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end