ace-lab 0.7.0 → 0.9.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.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -0
  3. data/docs/usage.md +343 -0
  4. data/lib/ace/lab/cli/commands/assignment.rb +92 -0
  5. data/lib/ace/lab/cli/commands/command_execute.rb +28 -0
  6. data/lib/ace/lab/cli/commands/run.rb +65 -0
  7. data/lib/ace/lab/cli/commands/service.rb +12 -28
  8. data/lib/ace/lab/cli.rb +16 -1
  9. data/lib/ace/lab/molecules/command_owner_admission.rb +183 -0
  10. data/lib/ace/lab/molecules/command_policy.rb +158 -0
  11. data/lib/ace/lab/molecules/grant_resolver.rb +15 -0
  12. data/lib/ace/lab/molecules/ordinary_admission_resolver.rb +20 -1
  13. data/lib/ace/lab/molecules/ordinary_listener.rb +41 -17
  14. data/lib/ace/lab/molecules/ordinary_merge_read.rb +45 -0
  15. data/lib/ace/lab/molecules/ordinary_native_read.rb +106 -0
  16. data/lib/ace/lab/molecules/ordinary_owner_admission.rb +152 -0
  17. data/lib/ace/lab/molecules/ordinary_owner_config.rb +232 -0
  18. data/lib/ace/lab/molecules/ordinary_receiver_config.rb +12 -4
  19. data/lib/ace/lab/molecules/ordinary_socket.rb +6 -9
  20. data/lib/ace/lab/molecules/ordinary_worktree_read.rb +216 -0
  21. data/lib/ace/lab/molecules/service_executor.rb +9 -39
  22. data/lib/ace/lab/molecules/service_policy.rb +15 -4
  23. data/lib/ace/lab/organisms/authority_composition.rb +1 -1
  24. data/lib/ace/lab/organisms/command_executor.rb +220 -0
  25. data/lib/ace/lab/organisms/command_run_client.rb +131 -0
  26. data/lib/ace/lab/organisms/ordinary_assignment_client.rb +120 -0
  27. data/lib/ace/lab/organisms/ordinary_authority_composition.rb +153 -0
  28. data/lib/ace/lab/organisms/ordinary_merge_receiver.rb +77 -61
  29. data/lib/ace/lab/organisms/ordinary_merge_request.rb +41 -7
  30. data/lib/ace/lab/version.rb +1 -1
  31. metadata +33 -6
  32. data/lib/ace/lab/molecules/ordinary_ff_merge.rb +0 -86
@@ -3,6 +3,7 @@
3
3
  require "yaml"
4
4
  require_relative "../errors"
5
5
  require_relative "service_policy"
6
+ require_relative "grant_resolver"
6
7
 
7
8
  module Ace
8
9
  module Lab
@@ -16,26 +17,32 @@ module Ace
16
17
  class OrdinaryReceiverConfig
17
18
  KEYS = %w[project receiver_id socket_path receiver_uid principal principal_uid
18
19
  repo_path policy_path execution_identity].freeze
20
+ OPTIONAL_KEYS = %w[socket_gid forge_config_path].freeze
19
21
  IDENTITY_KEYS = %w[checkout_id worktree_id branch].freeze
20
22
  ID = ServicePolicy::ID
21
23
 
22
24
  def self.load(path)
23
25
  raise Ace::Lab::InvalidConfigurationError, "receiver config path is required" unless path.is_a?(String) && !path.empty?
24
- document = YAML.safe_load_file(path, permitted_classes: [Date], aliases: false)
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)
25
29
  new(document, source: path)
26
30
  rescue Errno::ENOENT, Errno::EACCES, Psych::Exception
27
31
  raise Ace::Lab::InvalidConfigurationError, "receiver configuration is unreadable: #{path}"
28
32
  end
29
33
 
30
34
  attr_reader :project, :receiver_id, :socket_path, :receiver_uid, :principal,
31
- :principal_uid, :repo_path, :policy_path, :execution_identity, :source
35
+ :principal_uid, :repo_path, :policy_path, :execution_identity, :source, :socket_gid, :forge_config_path
32
36
 
33
37
  def initialize(document, source: nil)
34
- unless document.is_a?(Hash) && (document.keys - KEYS).empty? &&
38
+ unless document.is_a?(Hash) && (document.keys - KEYS - OPTIONAL_KEYS).empty? &&
35
39
  (KEYS - document.keys).empty?
36
40
  raise Ace::Lab::InvalidConfigurationError,
37
- "receiver configuration must contain exactly: #{KEYS.join(', ')}"
41
+ "receiver configuration requires #{KEYS.join(', ')}; optional: #{OPTIONAL_KEYS.join(', ')}"
38
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")
39
46
  @source = source
40
47
  @project = string!(document.fetch("project"), "project")
41
48
  @receiver_id = string!(document.fetch("receiver_id"), "receiver_id")
@@ -44,6 +51,7 @@ module Ace
44
51
  @repo_path = absolute!(document.fetch("repo_path"), "repo_path")
45
52
  @policy_path = absolute!(document.fetch("policy_path"), "policy_path")
46
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?
47
55
  @principal_uid = uid!(document.fetch("principal_uid"), "principal_uid")
48
56
  identity = document.fetch("execution_identity")
49
57
  unless identity.is_a?(Hash) && (identity.keys - IDENTITY_KEYS).empty? &&
@@ -9,7 +9,7 @@ module Ace
9
9
  # Admission transport between the ordinary fixed merge producer and the
10
10
  # installed ordinary receiver: one bounded JSON document each way over
11
11
  # a configured unix stream socket with a fixed deadline. Authentication
12
- # is mutual through SO_PEERCRED — the producer verifies the receiver's
12
+ # is mutual through OS peer credentials — the producer verifies the receiver's
13
13
  # configured UID and transmits the verified local caller identity, so
14
14
  # no caller-selected credential, token or endpoint exists. This is not
15
15
  # an HTTP transport; a lost or unauthenticated response is a
@@ -33,19 +33,19 @@ module Ace
33
33
  def submit(document)
34
34
  payload = JSON.generate(document)
35
35
  raise OrdinaryUnavailable, "request document exceeds the wire bound" if payload.bytesize > LIMIT
36
- socket = Socket.new(:UNIX, :STREAM, 0)
36
+ socket = nil
37
37
  begin
38
- socket.connect(Addrinfo.unix(@socket_path))
38
+ socket = UNIXSocket.new(@socket_path)
39
39
  verify_peer!(socket)
40
40
  write_all!(socket, "#{HANDSHAKE} #{payload.bytesize}\n#{payload}")
41
41
  response = read_response!(socket)
42
42
  rescue OrdinaryUnavailable
43
43
  raise
44
- rescue SystemCallError, IOError => e
44
+ rescue SystemCallError, IOError, SocketError, NotImplementedError => e
45
45
  raise OrdinaryUnavailable, "receiver transport failed: #{e.class}"
46
46
  ensure
47
47
  begin
48
- socket.close
48
+ socket&.close
49
49
  rescue IOError
50
50
  nil
51
51
  end
@@ -58,10 +58,7 @@ module Ace
58
58
  private
59
59
 
60
60
  def verify_peer!(socket)
61
- credentials = socket.getsockopt(:SOCKET, :PEERCRED)
62
- # struct ucred is {pid, uid, gid} on Linux; the producer binds the
63
- # receiver to its installed UID and nothing else.
64
- _peer_pid, peer_uid, = credentials.unpack("L3")
61
+ peer_uid, = socket.getpeereid
65
62
  return if peer_uid == @receiver_uid
66
63
  raise OrdinaryUnavailable, "receiver endpoint identity is not the installed receiver"
67
64
  end
@@ -0,0 +1,216 @@
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
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
- require "open3"
4
+ require "ace/herdr/molecules/bounded_process"
5
5
  require "socket"
6
6
  require "timeout"
7
7
  require "time"
@@ -40,7 +40,8 @@ module Ace
40
40
  return nil unless valid_response?(response, request)
41
41
  {"outcome" => response.fetch("outcome"), "evidence" => response.fetch("evidence"),
42
42
  "executor_uid" => current.fetch("executor_uid")}
43
- rescue JSON::ParserError, Timeout::Error, SystemCallError, IOError
43
+ rescue JSON::ParserError, Timeout::Error, SystemCallError, IOError,
44
+ Ace::Herdr::Molecules::BoundedProcess::PostLaunchError
44
45
  # Transport failures after the claim (broken pipe, connection
45
46
  # reset, socket loss) leave the effect unknown: uncertainty, not a
46
47
  # classified crash, is the honest outcome.
@@ -49,49 +50,18 @@ module Ace
49
50
 
50
51
  private
51
52
 
52
- # Handler output is bounded DURING execution: a noisy handler is
53
- # killed and reaped once its response exceeds the receipt limit
54
- # instead of being buffered without bound in this process.
53
+ # The existing process owner bounds both streams and the whole run,
54
+ # and cleans the owned process group on timeout and normal exit.
55
55
  def invoke_local(operation, request, input)
56
56
  unless Process.uid == operation.fetch("executor_uid") && Process.euid == operation.fetch("executor_uid")
57
57
  raise SecurityError, "current OS identity is not the configured executor"
58
58
  end
59
- out = nil
60
- status = nil
61
- Open3.popen3(*operation.fetch("argv")) do |stdin, stdout, stderr, waiter|
62
- pid = waiter.pid
63
- begin
64
- Timeout.timeout(30) do
65
- stdin.write(JSON.generate({"request" => request, "input" => input}))
66
- stdin.close
67
- errors = Thread.new { bounded_read(stderr, 8192) }
68
- out = bounded_read(stdout, 16 * 1024 + 1)
69
- errors.join
70
- Process.kill("KILL", pid) if out && out.bytesize > 16 * 1024
71
- status = waiter.value
72
- end
73
- rescue Timeout::Error
74
- Process.kill("KILL", pid) rescue nil
75
- waiter.value
76
- raise
77
- end
78
- end
79
- out if out && out.bytesize <= 16 * 1024 && status&.success?
80
- end
81
-
82
- def bounded_read(io, limit)
83
- io.read(limit).to_s
84
- rescue IOError, Errno::EBADF
85
- ""
86
- ensure
87
- begin
88
- io.close
89
- rescue IOError, Errno::EBADF
90
- nil
91
- end
59
+ result = Ace::Herdr::Molecules::BoundedProcess.call(operation.fetch("argv"),
60
+ stdin_data: JSON.generate({"request" => request, "input" => input}),
61
+ timeout_s: 30, output_limit: 16 * 1024, stderr_limit: 8192, cleanup_group: true)
62
+ result.stdout if result.status.success? && !result.oversized
92
63
  end
93
64
 
94
-
95
65
  def invoke_unix(operation, request, input)
96
66
  path = operation.fetch("socket_path")
97
67
  stat = File.lstat(path)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../errors"
4
+ require_relative "../atoms/service_input"
4
5
  require "ace/assign/errors"
5
6
  require "pathname"
6
7
  require "time"
@@ -24,10 +25,14 @@ module Ace
24
25
  end
25
26
  end
26
27
 
27
- # The trusted installation document as loaded; the ordinary producer
28
- # binds its configuration digest to these exact bytes.
28
+ # The trusted installation document as loaded. Decisions are mutable;
29
+ # all other configuration remains bound for the receiver lifetime.
29
30
  attr_reader :document
30
31
 
32
+ def self.configuration_digest(document)
33
+ Atoms::ServiceInput.digest(document.except("authorizations"))
34
+ end
35
+
31
36
  def operation!(name, project:, service_id:)
32
37
  raise ArgumentError, "invalid operation name" unless name.to_s.match?(NAME)
33
38
  operation = @operations[name]
@@ -115,7 +120,7 @@ module Ace
115
120
  # admission flag is rollback: new admission is unavailable while
116
121
  # canonical records and read-only status stay preserved.
117
122
  ORDINARY_SECTION_KEYS = %w[projects].freeze
118
- ORDINARY_PROJECT_KEYS = %w[receiver_id socket_path receiver_uid principal admission].freeze
123
+ ORDINARY_PROJECT_KEYS = %w[receiver_id socket_path receiver_uid principal admission journal_repository journal_ref].freeze
119
124
  ORDINARY_AUTHORIZATION_KEYS = %w[operation project_id assignment_id attempt_id queue_entry
120
125
  input_digest forge pr_url head_repository head_ref candidate_head base_repository
121
126
  base_ref expected_base_head expires_at].freeze
@@ -160,7 +165,7 @@ module Ace
160
165
  end
161
166
  unless entry["receiver_id"].is_a?(String) && entry["receiver_id"].match?(ID) &&
162
167
  entry["socket_path"].is_a?(String) && entry["socket_path"].start_with?("/") &&
163
- entry["receiver_uid"].is_a?(Integer) && entry["receiver_uid"] >= 0 &&
168
+ entry["receiver_uid"].is_a?(Integer) && entry["receiver_uid"].positive? &&
164
169
  entry["principal"].is_a?(String) && !entry["principal"].empty?
165
170
  raise Ace::Lab::InvalidConfigurationError, "ordinary receiver mapping is incomplete"
166
171
  end
@@ -168,6 +173,12 @@ module Ace
168
173
  unless %w[enabled disabled].include?(admission)
169
174
  raise Ace::Lab::InvalidConfigurationError, "ordinary admission flag is invalid"
170
175
  end
176
+ repository, ref = entry.values_at("journal_repository", "journal_ref")
177
+ unless repository.is_a?(String) && repository.start_with?("/") && !repository.include?("\0") &&
178
+ File.expand_path(repository) == repository &&
179
+ ref.is_a?(String) && ref.match?(%r{\Arefs/[a-zA-Z0-9_-]+(?:/[a-zA-Z0-9_-]+)+\z})
180
+ raise Ace::Lab::InvalidConfigurationError, "ordinary journal selection is invalid"
181
+ end
171
182
  end
172
183
  true
173
184
  end
@@ -65,7 +65,7 @@ module Ace
65
65
  service_policy: policy, deployment_history: history)
66
66
  router = Ace::Assign::Authority::Router.new(launch: launch, handlers: [endcap])
67
67
  Ace::Assign::Authority::Server.new(authority_id: @authority_id, deployment: @deployment, kernel: @kernel,
68
- lifecycle: router, composition: "services", hitl_service: publication_hitl(endcap, journals))
68
+ lifecycle: router, composition: "services", services: [publication_hitl(endcap, journals)])
69
69
  end
70
70
 
71
71
  private