ace-hitl 0.9.0 → 0.11.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 +30 -0
- data/README.md +40 -1
- data/docs/usage.md +153 -13
- data/lib/ace/hitl/cli/commands/ask.rb +107 -58
- data/lib/ace/hitl/cli/commands/cancel.rb +34 -0
- data/lib/ace/hitl/cli/commands/consume.rb +37 -0
- data/lib/ace/hitl/cli/commands/deliver.rb +33 -0
- data/lib/ace/hitl/cli/commands/duty.rb +29 -0
- data/lib/ace/hitl/cli/commands/lifecycle_command.rb +38 -0
- data/lib/ace/hitl/cli/commands/overseer_ack.rb +30 -0
- data/lib/ace/hitl/cli/commands/overseer_pending.rb +28 -0
- data/lib/ace/hitl/cli/commands/overseer_send.rb +35 -0
- data/lib/ace/hitl/cli/commands/pending.rb +29 -0
- data/lib/ace/hitl/cli/commands/serve.rb +71 -0
- data/lib/ace/hitl/cli/commands/states.rb +28 -0
- data/lib/ace/hitl/cli/commands/wait.rb +2 -2
- data/lib/ace/hitl/cli.rb +30 -1
- data/lib/ace/hitl/lifecycle/atomic_json.rb +79 -0
- data/lib/ace/hitl/lifecycle/binding.rb +38 -0
- data/lib/ace/hitl/lifecycle/client.rb +161 -0
- data/lib/ace/hitl/lifecycle/duty.rb +43 -0
- data/lib/ace/hitl/lifecycle/effects.rb +279 -0
- data/lib/ace/hitl/lifecycle/errors.rb +38 -0
- data/lib/ace/hitl/lifecycle/identity.rb +66 -0
- data/lib/ace/hitl/lifecycle/kinds.rb +69 -0
- data/lib/ace/hitl/lifecycle/otp_vault.rb +131 -0
- data/lib/ace/hitl/lifecycle/overseer.rb +111 -0
- data/lib/ace/hitl/lifecycle/peer.rb +61 -0
- data/lib/ace/hitl/lifecycle/policy.rb +172 -0
- data/lib/ace/hitl/lifecycle/protocol.rb +109 -0
- data/lib/ace/hitl/lifecycle/service.rb +289 -0
- data/lib/ace/hitl/lifecycle/store.rb +890 -0
- data/lib/ace/hitl/lifecycle.rb +24 -0
- data/lib/ace/hitl/molecules/lab_projection_observer.rb +9 -1
- data/lib/ace/hitl/providers/errors.rb +24 -0
- data/lib/ace/hitl/providers/lab/assignment_binding.rb +82 -0
- data/lib/ace/hitl/providers/lab/composite_binding.rb +47 -0
- data/lib/ace/hitl/providers/lab/daemon_binding.rb +210 -0
- data/lib/ace/hitl/providers/lab.rb +165 -0
- data/lib/ace/hitl/providers/providers.rb +41 -0
- data/lib/ace/hitl/providers/ref.rb +62 -0
- data/lib/ace/hitl/version.rb +1 -1
- data/lib/ace/hitl.rb +3 -0
- metadata +50 -3
- data/lib/ace/hitl/molecules/lab_request_submitter.rb +0 -82
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/support/cli"
|
|
4
|
+
require_relative "lifecycle_command"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Hitl
|
|
8
|
+
module CLI
|
|
9
|
+
module Commands
|
|
10
|
+
# The standing-duty projection: pending + escalated (spec
|
|
11
|
+
# 8wm.t.y21 §7).
|
|
12
|
+
class Duty < Ace::Support::Cli::Command
|
|
13
|
+
include Ace::Support::Cli::Base
|
|
14
|
+
include LifecycleCommand
|
|
15
|
+
|
|
16
|
+
desc "Project pending and escalated HITL requests (transport operation)"
|
|
17
|
+
|
|
18
|
+
option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
|
|
19
|
+
|
|
20
|
+
def call(**options)
|
|
21
|
+
emit(Lifecycle::Duty.project(pending: lifecycle_client.pending, states: lifecycle_client.states))
|
|
22
|
+
rescue Lifecycle::Error, Providers::ProviderUnavailableError => e
|
|
23
|
+
raise_lifecycle_error(e.message)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Hitl
|
|
7
|
+
module CLI
|
|
8
|
+
module Commands
|
|
9
|
+
# Shared plumbing for the lifecycle commands (spec 8wm.t.y21 §8,
|
|
10
|
+
# scoped by 8wq.t.34i): every lifecycle operation reaches the
|
|
11
|
+
# store through the AUTHENTICATED BOUNDARY CLIENT — direct
|
|
12
|
+
# shared-store access is not a CLI option — and machine outputs
|
|
13
|
+
# are one JSON line, byte-compatible with the migrated CLI
|
|
14
|
+
# contract.
|
|
15
|
+
module LifecycleCommand
|
|
16
|
+
STDIN_READER = ->(limit) { $stdin.read(limit) }.freeze
|
|
17
|
+
|
|
18
|
+
def lifecycle_client
|
|
19
|
+
Providers::Lab.boundary_client
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def overseer
|
|
23
|
+
channel_root = ENV.fetch("ACE_HITL_OVERSEER_CHANNEL_ROOT", "/lab/state/overseer-channel")
|
|
24
|
+
Lifecycle::Overseer.new(outbox_dir: File.join(channel_root, "outbox"))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def emit(result)
|
|
28
|
+
puts JSON.generate(result)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def raise_lifecycle_error(message)
|
|
32
|
+
raise Ace::Support::Cli::Error.new(message)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/support/cli"
|
|
4
|
+
require_relative "lifecycle_command"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Hitl
|
|
8
|
+
module CLI
|
|
9
|
+
module Commands
|
|
10
|
+
# Host-broker acknowledgement of one relayed Overseer response.
|
|
11
|
+
class OverseerAck < Ace::Support::Cli::Command
|
|
12
|
+
include Ace::Support::Cli::Base
|
|
13
|
+
include LifecycleCommand
|
|
14
|
+
|
|
15
|
+
desc "Acknowledge (remove) one relayed Overseer response"
|
|
16
|
+
|
|
17
|
+
argument :id, required: true, desc: "Overseer response message id"
|
|
18
|
+
|
|
19
|
+
option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
|
|
20
|
+
|
|
21
|
+
def call(id:, **options)
|
|
22
|
+
emit(overseer.ack(id))
|
|
23
|
+
rescue Lifecycle::Error => e
|
|
24
|
+
raise_lifecycle_error(e.message)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/support/cli"
|
|
4
|
+
require_relative "lifecycle_command"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Hitl
|
|
8
|
+
module CLI
|
|
9
|
+
module Commands
|
|
10
|
+
# Host-broker drain view of the Overseer response outbox.
|
|
11
|
+
class OverseerPending < Ace::Support::Cli::Command
|
|
12
|
+
include Ace::Support::Cli::Base
|
|
13
|
+
include LifecycleCommand
|
|
14
|
+
|
|
15
|
+
desc "List queued Overseer responses (host-broker operation)"
|
|
16
|
+
|
|
17
|
+
option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
|
|
18
|
+
|
|
19
|
+
def call(**options)
|
|
20
|
+
emit(overseer.pending)
|
|
21
|
+
rescue Lifecycle::Error => e
|
|
22
|
+
raise_lifecycle_error(e.message)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/support/cli"
|
|
4
|
+
require_relative "lifecycle_command"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Hitl
|
|
8
|
+
module CLI
|
|
9
|
+
module Commands
|
|
10
|
+
# The Root Overseer's bounded, type-tagged response through the
|
|
11
|
+
# reverse address (spec 8wm.t.y21 §6). The response is read from
|
|
12
|
+
# stdin.
|
|
13
|
+
class OverseerSend < Ace::Support::Cli::Command
|
|
14
|
+
include Ace::Support::Cli::Base
|
|
15
|
+
include LifecycleCommand
|
|
16
|
+
|
|
17
|
+
desc "Queue a bounded, type-tagged Overseer response (stdin)"
|
|
18
|
+
|
|
19
|
+
option :"reply-to", type: :string, desc: "Source message id this response answers"
|
|
20
|
+
|
|
21
|
+
option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
|
|
22
|
+
|
|
23
|
+
def call(**options)
|
|
24
|
+
emit(overseer.send_response(
|
|
25
|
+
reply_to: options[:"reply-to"] || "",
|
|
26
|
+
reader: LifecycleCommand::STDIN_READER
|
|
27
|
+
))
|
|
28
|
+
rescue Lifecycle::Error => e
|
|
29
|
+
raise_lifecycle_error(e.message)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/support/cli"
|
|
4
|
+
require_relative "lifecycle_command"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Hitl
|
|
8
|
+
module CLI
|
|
9
|
+
module Commands
|
|
10
|
+
# Host-broker listing of answerable relay requests; never purges
|
|
11
|
+
# or cancels anything (W651).
|
|
12
|
+
class Pending < Ace::Support::Cli::Command
|
|
13
|
+
include Ace::Support::Cli::Base
|
|
14
|
+
include LifecycleCommand
|
|
15
|
+
|
|
16
|
+
desc "List answerable HITL relay requests (transport operation)"
|
|
17
|
+
|
|
18
|
+
option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
|
|
19
|
+
|
|
20
|
+
def call(**options)
|
|
21
|
+
emit(lifecycle_client.pending)
|
|
22
|
+
rescue Lifecycle::Error, Providers::ProviderUnavailableError => e
|
|
23
|
+
raise_lifecycle_error(e.message)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/support/cli"
|
|
4
|
+
require_relative "../../lifecycle"
|
|
5
|
+
require_relative "../../providers/providers"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Hitl
|
|
9
|
+
module CLI
|
|
10
|
+
module Commands
|
|
11
|
+
# The installed scoped store boundary (spec 8wq.t.34i): serves
|
|
12
|
+
# the private HITL store over a peer-authenticated UNIX socket.
|
|
13
|
+
# Authorization facts come only from the trusted grants
|
|
14
|
+
# document; the store root and socket paths come from the
|
|
15
|
+
# deployment (options/environment), never from caller payloads.
|
|
16
|
+
class Serve < Ace::Support::Cli::Command
|
|
17
|
+
include Ace::Support::Cli::Base
|
|
18
|
+
|
|
19
|
+
desc "Run the authenticated HITL store boundary service (peer-credential access)"
|
|
20
|
+
|
|
21
|
+
option :socket, type: :string, desc: "Boundary socket path (default: ACE_HITL_SOCKET or /run/lab/hitl.sock)"
|
|
22
|
+
option :store_root, type: :string, desc: "Private store root (default: ACE_HITL_STORE_ROOT or /run/lab/hitl)"
|
|
23
|
+
option :grants_path, type: :string, desc: "Trusted grants document (default: ACE_HITL_GRANTS_PATH or /etc/lab/ace-lab/authorization.yml)"
|
|
24
|
+
option :repo_root, type: :string, desc: "Candidate repository for the managed attempt authority"
|
|
25
|
+
|
|
26
|
+
option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
|
|
27
|
+
option :verbose, type: :boolean, aliases: %w[-v], desc: "Show verbose output"
|
|
28
|
+
|
|
29
|
+
def call(**options)
|
|
30
|
+
policy = Providers::Lab.grants_policy(grants_path: grants_path(options))
|
|
31
|
+
unless policy.service_uid
|
|
32
|
+
raise_cli_error(
|
|
33
|
+
"the HITL service requires trusted grants with hitl.service_uid " \
|
|
34
|
+
"(#{grants_path(options)}); serving without a trusted service identity would " \
|
|
35
|
+
"fail every client endpoint verification"
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
binding_policy = Providers::Lab::CompositeBinding.new(
|
|
39
|
+
assignment: Providers::Lab.assignment_binding(repo_root: options[:repo_root]),
|
|
40
|
+
work: Providers::Lab::DaemonBinding.new
|
|
41
|
+
)
|
|
42
|
+
service = Lifecycle::Service.new(
|
|
43
|
+
root: store_root(options),
|
|
44
|
+
binding: binding_policy,
|
|
45
|
+
policy: policy,
|
|
46
|
+
socket_path: socket_path(options),
|
|
47
|
+
logger: options[:verbose] ? ->(message) { warn message } : nil
|
|
48
|
+
)
|
|
49
|
+
service.run
|
|
50
|
+
rescue Lifecycle::Error, Providers::ProviderUnavailableError => e
|
|
51
|
+
raise_cli_error(e.message)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def socket_path(options)
|
|
57
|
+
options[:socket] || ENV[Providers::Lab::SOCKET_PATH_ENV] || Providers::Lab::DEFAULT_SOCKET_PATH
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def store_root(options)
|
|
61
|
+
options[:store_root] || ENV[Providers::Lab::STORE_ROOT_ENV] || Providers::Lab::DEFAULT_STORE_ROOT
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def grants_path(options)
|
|
65
|
+
options[:grants_path] || ENV[Providers::Lab::GRANTS_PATH_ENV] || Providers::Lab::DEFAULT_GRANTS_PATH
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/support/cli"
|
|
4
|
+
require_relative "lifecycle_command"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Hitl
|
|
8
|
+
module CLI
|
|
9
|
+
module Commands
|
|
10
|
+
# Host-broker listing of all public lifecycle projections.
|
|
11
|
+
class States < Ace::Support::Cli::Command
|
|
12
|
+
include Ace::Support::Cli::Base
|
|
13
|
+
include LifecycleCommand
|
|
14
|
+
|
|
15
|
+
desc "List public HITL lifecycle projections (transport operation)"
|
|
16
|
+
|
|
17
|
+
option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
|
|
18
|
+
|
|
19
|
+
def call(**options)
|
|
20
|
+
emit(lifecycle_client.states)
|
|
21
|
+
rescue Lifecycle::Error, Providers::ProviderUnavailableError => e
|
|
22
|
+
raise_lifecycle_error(e.message)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -55,11 +55,11 @@ module Ace
|
|
|
55
55
|
lab_request_id = event.metadata["lab_request_id"]
|
|
56
56
|
puts "Lab request delivered: #{event.id} (#{result[:lab_state]})"
|
|
57
57
|
puts "Lab request: #{lab_request_id}"
|
|
58
|
-
puts "Consume the answer when ready
|
|
58
|
+
puts "Consume the answer when ready from the lab relay."
|
|
59
59
|
if result[:lab_state] == "callback-ok"
|
|
60
60
|
puts "Effect callback: ok"
|
|
61
61
|
elsif result[:lab_state] == "callback-escalated"
|
|
62
|
-
puts "Effect callback: escalated; inspect
|
|
62
|
+
puts "Effect callback: escalated; inspect the lab duty projection"
|
|
63
63
|
end
|
|
64
64
|
when :timeout
|
|
65
65
|
raise Ace::Support::Cli::Error.new("Timed out waiting for HITL event '#{ref}'")
|
data/lib/ace/hitl/cli.rb
CHANGED
|
@@ -8,6 +8,16 @@ require_relative "cli/commands/show"
|
|
|
8
8
|
require_relative "cli/commands/list"
|
|
9
9
|
require_relative "cli/commands/update"
|
|
10
10
|
require_relative "cli/commands/wait"
|
|
11
|
+
require_relative "cli/commands/deliver"
|
|
12
|
+
require_relative "cli/commands/consume"
|
|
13
|
+
require_relative "cli/commands/cancel"
|
|
14
|
+
require_relative "cli/commands/pending"
|
|
15
|
+
require_relative "cli/commands/states"
|
|
16
|
+
require_relative "cli/commands/duty"
|
|
17
|
+
require_relative "cli/commands/serve"
|
|
18
|
+
require_relative "cli/commands/overseer_send"
|
|
19
|
+
require_relative "cli/commands/overseer_pending"
|
|
20
|
+
require_relative "cli/commands/overseer_ack"
|
|
11
21
|
|
|
12
22
|
module Ace
|
|
13
23
|
module Hitl
|
|
@@ -22,7 +32,16 @@ module Ace
|
|
|
22
32
|
["show", "Show HITL event details"],
|
|
23
33
|
["list", "List HITL events"],
|
|
24
34
|
["update", "Update HITL event metadata or answer"],
|
|
25
|
-
["wait", "Wait for an answer on a specific HITL event"]
|
|
35
|
+
["wait", "Wait for an answer on a specific HITL event"],
|
|
36
|
+
["deliver", "Deliver an answer (stdin) to a pending HITL relay request"],
|
|
37
|
+
["consume", "Wait for and consume the answer of one own HITL relay request"],
|
|
38
|
+
["cancel", "Cancel one HITL relay request with an audited reason"],
|
|
39
|
+
["pending", "List answerable HITL relay requests (transport)"],
|
|
40
|
+
["states", "List public HITL lifecycle projections (transport)"],
|
|
41
|
+
["duty", "Project pending and escalated HITL requests (transport)"],
|
|
42
|
+
["overseer-send", "Queue a bounded, type-tagged Overseer response (stdin)"],
|
|
43
|
+
["overseer-pending", "List queued Overseer responses (host-broker)"],
|
|
44
|
+
["overseer-ack", "Acknowledge one relayed Overseer response"]
|
|
26
45
|
].freeze
|
|
27
46
|
|
|
28
47
|
HELP_EXAMPLES = [
|
|
@@ -40,6 +59,16 @@ module Ace
|
|
|
40
59
|
register "list", CLI::Commands::List
|
|
41
60
|
register "update", CLI::Commands::Update
|
|
42
61
|
register "wait", CLI::Commands::Wait
|
|
62
|
+
register "deliver", CLI::Commands::Deliver
|
|
63
|
+
register "consume", CLI::Commands::Consume
|
|
64
|
+
register "cancel", CLI::Commands::Cancel
|
|
65
|
+
register "pending", CLI::Commands::Pending
|
|
66
|
+
register "states", CLI::Commands::States
|
|
67
|
+
register "duty", CLI::Commands::Duty
|
|
68
|
+
register "serve", CLI::Commands::Serve
|
|
69
|
+
register "overseer-send", CLI::Commands::OverseerSend
|
|
70
|
+
register "overseer-pending", CLI::Commands::OverseerPending
|
|
71
|
+
register "overseer-ack", CLI::Commands::OverseerAck
|
|
43
72
|
|
|
44
73
|
version_cmd = Ace::Support::Cli::VersionCommand.build(
|
|
45
74
|
gem_name: "ace-hitl",
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Hitl
|
|
9
|
+
module Lifecycle
|
|
10
|
+
# Durable, permission-preserving JSON record writes (port of
|
|
11
|
+
# atomic_json): O_EXCL|O_NOFOLLOW tempfile in the target directory,
|
|
12
|
+
# fsync, chmod, chown, rename. Ownership transitions go through the
|
|
13
|
+
# injectable ownership strategy; production performs real chown(2),
|
|
14
|
+
# tests map names onto the current identity (spec 8wm.t.y21 §2).
|
|
15
|
+
module AtomicJson
|
|
16
|
+
Ownership = Struct.new(:uid, :gid)
|
|
17
|
+
|
|
18
|
+
DEFAULT_OWNERSHIP = Object.new.tap do |strategy|
|
|
19
|
+
strategy.define_singleton_method(:chown) do |path, ownership|
|
|
20
|
+
File.chown(ownership.uid, ownership.gid, path) if ownership
|
|
21
|
+
end
|
|
22
|
+
end.freeze
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# mode is an Integer permission bits value; ownership nil keeps
|
|
27
|
+
# the current identity. exclusive commits with link(2) instead of
|
|
28
|
+
# rename(2), so an existing destination raises Errno::EEXIST
|
|
29
|
+
# instead of being silently replaced (create-once writes).
|
|
30
|
+
def call(path, value, mode:, ownership: nil, ownership_strategy: DEFAULT_OWNERSHIP,
|
|
31
|
+
exclusive: false)
|
|
32
|
+
path = Pathname.new(path)
|
|
33
|
+
path.parent.mkpath
|
|
34
|
+
# The random suffix makes the temporary unique per invocation:
|
|
35
|
+
# a fixed pid-based name lets a competing writer's cleanup
|
|
36
|
+
# delete our in-flight temporary and fail both writes (review
|
|
37
|
+
# 8wq2zttt on PR#336). Cleanup only ever touches this call's
|
|
38
|
+
# own file.
|
|
39
|
+
temporary = path.parent.join(".#{path.basename}.tmp.#{$PROCESS_ID}.#{SecureRandom.hex(4)}")
|
|
40
|
+
flags = File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW
|
|
41
|
+
fd = IO.sysopen(temporary, flags, mode)
|
|
42
|
+
begin
|
|
43
|
+
io = IO.for_fd(fd, mode: "w")
|
|
44
|
+
begin
|
|
45
|
+
io.write(JSON.generate(value))
|
|
46
|
+
io.write("\n")
|
|
47
|
+
io.flush
|
|
48
|
+
io.fsync
|
|
49
|
+
ensure
|
|
50
|
+
io.close
|
|
51
|
+
end
|
|
52
|
+
rescue
|
|
53
|
+
begin
|
|
54
|
+
temporary.unlink
|
|
55
|
+
rescue
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
raise
|
|
59
|
+
end
|
|
60
|
+
File.chmod(mode, temporary)
|
|
61
|
+
ownership_strategy.chown(temporary, ownership)
|
|
62
|
+
if exclusive
|
|
63
|
+
File.link(temporary, path)
|
|
64
|
+
else
|
|
65
|
+
File.rename(temporary, path)
|
|
66
|
+
end
|
|
67
|
+
ensure
|
|
68
|
+
temporary.unlink if temporary&.exist?
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def read(path)
|
|
72
|
+
JSON.parse(File.read(path))
|
|
73
|
+
rescue SystemCallError, JSON::ParserError
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Hitl
|
|
5
|
+
module Lifecycle
|
|
6
|
+
# The binding seam between the generic request lifecycle and the
|
|
7
|
+
# domain that owns Work/Attempt liveness and attribution (spec
|
|
8
|
+
# 8wm.t.y21 §1). The store REQUIRES a binding; the generic core
|
|
9
|
+
# never carries Work/Attempt semantics itself.
|
|
10
|
+
#
|
|
11
|
+
# Implementations must fail closed: any doubt raises BindingError.
|
|
12
|
+
class Binding
|
|
13
|
+
# Validate a request creation against the requester's exact live
|
|
14
|
+
# attempt. Raises BindingError on any violation or unavailability.
|
|
15
|
+
def validate_request(work: nil, assignment: nil, attempt:, project:, requester:)
|
|
16
|
+
raise NotImplementedError
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Re-verify that the attempt is still live before an answer is
|
|
20
|
+
# delivered or consumed. Raises BindingError when terminal.
|
|
21
|
+
def require_active(work:, attempt:)
|
|
22
|
+
raise NotImplementedError
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Hold verified live authority across ONE whole transition: the
|
|
26
|
+
# check and the caller's locked commit happen inside the same
|
|
27
|
+
# exclusion, so a concurrent attempt termination cannot land
|
|
28
|
+
# between them (spec 8wq.t.34i). The yielded value is opaque.
|
|
29
|
+
# Implementations without a holdable exclusion verify and yield
|
|
30
|
+
# (the legacy gap); the assignment authority holds its lock.
|
|
31
|
+
def with_active(work: nil, assignment: nil, attempt:, project: nil, requester: nil)
|
|
32
|
+
require_active(work: work, attempt: attempt)
|
|
33
|
+
yield
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require_relative "protocol"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Hitl
|
|
8
|
+
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.
|
|
12
|
+
#
|
|
13
|
+
# Both endpoint directions are authenticated:
|
|
14
|
+
# - the client authenticates the SERVICE: the socket must be a
|
|
15
|
+
# socket owned by the configured service uid, not group/world
|
|
16
|
+
# writable, and the connected peer must BE the service uid;
|
|
17
|
+
# - the service authenticates the client from kernel peer
|
|
18
|
+
# credentials (server side).
|
|
19
|
+
#
|
|
20
|
+
# Transport failures raise classified Lifecycle::TransportError;
|
|
21
|
+
# classified lifecycle errors arrive from the service and are
|
|
22
|
+
# re-raised under their own classes.
|
|
23
|
+
class Client
|
|
24
|
+
attr_reader :socket_path, :service_uid
|
|
25
|
+
|
|
26
|
+
def initialize(socket_path:, service_uid:, deadline_seconds: Protocol::DEFAULT_DEADLINE_SECONDS)
|
|
27
|
+
@socket_path = socket_path.to_s
|
|
28
|
+
@service_uid = Integer(service_uid)
|
|
29
|
+
@deadline_seconds = deadline_seconds
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @return [Hash] the result frame
|
|
33
|
+
def request(op, params = {})
|
|
34
|
+
verify_endpoint!
|
|
35
|
+
socket = UNIXSocket.open(@socket_path)
|
|
36
|
+
begin
|
|
37
|
+
verify_service_peer!(socket)
|
|
38
|
+
socket.write(Protocol.encode_request(op, params))
|
|
39
|
+
socket.flush
|
|
40
|
+
line = read_line(socket, deadline_for(op, params))
|
|
41
|
+
Protocol.decode_response(line)
|
|
42
|
+
ensure
|
|
43
|
+
socket.close
|
|
44
|
+
end
|
|
45
|
+
rescue SystemCallError, IOError, EOFError => e
|
|
46
|
+
raise TransportError, "HITL boundary is unavailable (#{e.class}: #{e.message})"
|
|
47
|
+
rescue JSON::GeneratorError => e
|
|
48
|
+
raise TransportError, "HITL boundary request is not serializable (#{e.message})"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def create(**params)
|
|
52
|
+
request("create", params.transform_keys(&:to_s))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def read(id)
|
|
56
|
+
request("read", {"id" => id})
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def deliver(id, answer)
|
|
60
|
+
request("deliver", {"id" => id, "answer" => answer.to_s})
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def consume(id, timeout: 0, operation: nil)
|
|
64
|
+
params = {"id" => id, "timeout" => Integer(timeout)}
|
|
65
|
+
params["operation"] = operation if operation
|
|
66
|
+
request("consume", params)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def cancel(id, reason: "")
|
|
70
|
+
request("cancel", {"id" => id, "reason" => reason.to_s})
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def pending
|
|
74
|
+
request("pending")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def states
|
|
78
|
+
request("states")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def ping
|
|
82
|
+
request("ping")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
# The endpoint must be owned by the trusted service identity and
|
|
88
|
+
# safe to write: an attacker-owned or world-writable socket is a
|
|
89
|
+
# credential-disclosure channel (answers, OTP) and is refused
|
|
90
|
+
# before any byte is sent.
|
|
91
|
+
def verify_endpoint!
|
|
92
|
+
stat = File.lstat(@socket_path)
|
|
93
|
+
unless stat.socket?
|
|
94
|
+
raise TransportError, "HITL boundary endpoint is not a socket: #{@socket_path}"
|
|
95
|
+
end
|
|
96
|
+
if stat.uid != @service_uid
|
|
97
|
+
raise TransportError,
|
|
98
|
+
"HITL boundary endpoint is owned by uid #{stat.uid}, not the trusted service uid #{@service_uid}"
|
|
99
|
+
end
|
|
100
|
+
# The designed endpoint mode is 0660 service:control-group —
|
|
101
|
+
# group-write is the legitimate access path; WORLD-write is
|
|
102
|
+
# never acceptable (no chmod-to-world workaround, spec 8wq.t.34i).
|
|
103
|
+
if (stat.mode & 0o002) != 0
|
|
104
|
+
raise TransportError, "HITL boundary endpoint is world-writable"
|
|
105
|
+
end
|
|
106
|
+
rescue Errno::ENOENT
|
|
107
|
+
raise TransportError, "HITL boundary endpoint does not exist: #{@socket_path}"
|
|
108
|
+
rescue SystemCallError
|
|
109
|
+
raise TransportError, "HITL boundary endpoint is not verifiable: #{@socket_path}"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def verify_service_peer!(socket)
|
|
113
|
+
peer_uid, = peer_credentials(socket)
|
|
114
|
+
return if peer_uid == @service_uid
|
|
115
|
+
|
|
116
|
+
raise TransportError,
|
|
117
|
+
"HITL boundary peer is uid #{peer_uid}, not the trusted service uid #{@service_uid}"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def peer_credentials(socket)
|
|
121
|
+
socket.getpeereid
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# A consume without a timeout waits indefinitely at the client
|
|
125
|
+
# boundary too; every bounded call gets a deadline slightly
|
|
126
|
+
# beyond its own wait so the server result wins the race.
|
|
127
|
+
def deadline_for(op, params)
|
|
128
|
+
if op == "consume" && Integer(params["timeout"]) <= 0
|
|
129
|
+
nil
|
|
130
|
+
else
|
|
131
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) + @deadline_seconds + Integer(params["timeout"])
|
|
132
|
+
end
|
|
133
|
+
rescue ArgumentError, TypeError
|
|
134
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) + @deadline_seconds
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def read_line(socket, deadline)
|
|
138
|
+
data = +""
|
|
139
|
+
loop do
|
|
140
|
+
remaining = deadline && deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
141
|
+
raise TransportError, "HITL boundary deadline exceeded" if remaining && remaining <= 0
|
|
142
|
+
ready = IO.select([socket], nil, nil, remaining)
|
|
143
|
+
raise TransportError, "HITL boundary deadline exceeded" unless ready
|
|
144
|
+
|
|
145
|
+
chunk = socket.read_nonblock(4096, exception: false)
|
|
146
|
+
raise TransportError, "HITL boundary closed before responding" if chunk.nil?
|
|
147
|
+
# A spurious not-ready result goes back to select; only the
|
|
148
|
+
# deadline ends the wait (review 8x32r9b4).
|
|
149
|
+
next if chunk == :wait_readable
|
|
150
|
+
|
|
151
|
+
data << chunk
|
|
152
|
+
if data.bytesize > Protocol::MAX_FRAME_BYTES
|
|
153
|
+
raise TransportError, "HITL boundary response is too large"
|
|
154
|
+
end
|
|
155
|
+
return data if data.include?("\n")
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|