gatewayd: config, [approvals] users and socket
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
@@ -17,6 +17,18 @@ pub struct Config {
|
||||
pub paths: Paths,
|
||||
#[serde(default)]
|
||||
pub limits: Limits,
|
||||
#[serde(default)]
|
||||
pub approvals: ApprovalsConfig,
|
||||
}
|
||||
|
||||
/// `[approvals]`: who may answer approvals, and where `brokerd` brings them.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
|
||||
#[serde(deny_unknown_fields, default)]
|
||||
pub struct ApprovalsConfig {
|
||||
/// Mattermost user ids; empty means everyone in `[allow] users`.
|
||||
pub users: Vec<String>,
|
||||
/// Empty: `<home>/run/gateway/gateway.sock`.
|
||||
pub socket: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
@@ -184,6 +196,13 @@ impl Config {
|
||||
));
|
||||
}
|
||||
}
|
||||
for id in &self.approvals.users {
|
||||
if !self.allow.users.contains(id) {
|
||||
return Some(format!(
|
||||
"[approvals] {id:?} is not in [allow] users: an approver who is not heard could never answer"
|
||||
));
|
||||
}
|
||||
}
|
||||
if self.limits.queue == 0 {
|
||||
return Some("[limits] queue must be at least 1".to_string());
|
||||
}
|
||||
@@ -221,6 +240,27 @@ impl Config {
|
||||
self.loop_.socket.clone()
|
||||
}
|
||||
|
||||
/// Who may answer approvals: `[approvals] users`, or `[allow] users` when that is empty.
|
||||
pub fn approvers(&self) -> &[String] {
|
||||
if self.approvals.users.is_empty() {
|
||||
return &self.allow.users;
|
||||
}
|
||||
&self.approvals.users
|
||||
}
|
||||
|
||||
/// The first approver, or `None` when there are none.
|
||||
pub fn first_approver(&self) -> Option<&str> {
|
||||
self.approvers().first().map(String::as_str)
|
||||
}
|
||||
|
||||
/// `gateway.sock`: `[approvals] socket`, or `<home>/run/gateway/gateway.sock` when empty.
|
||||
pub fn gateway_socket(&self) -> PathBuf {
|
||||
if self.approvals.socket.as_os_str().is_empty() {
|
||||
return self.paths.home.join("run/gateway/gateway.sock");
|
||||
}
|
||||
self.approvals.socket.clone()
|
||||
}
|
||||
|
||||
pub fn state_path(&self) -> PathBuf {
|
||||
// <home>/gateway/state.json.
|
||||
self.paths.home.join("gateway/state.json")
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
//! `[approvals]` in `gatewayd.toml`: who may answer, the first approver, and `gateway.sock`
|
||||
//! (M4b spec, section 3). Do not edit.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use gatewayd::config::Config;
|
||||
|
||||
const KYLE: &str = "k0000000000000000000000000";
|
||||
const HERMES: &str = "h0000000000000000000000000";
|
||||
const OTHER: &str = "o0000000000000000000000000";
|
||||
|
||||
fn text(approvals: &str) -> String {
|
||||
format!(
|
||||
"[mattermost]\nurl = \"https://chat.example\"\n[secrets.mattermost_token]\nenv = \"T\"\n[allow]\nusers = [\"{KYLE}\", \"{HERMES}\"]\n[paths]\nhome = \"/srv/bx\"\n{approvals}"
|
||||
)
|
||||
}
|
||||
|
||||
fn config(approvals: &str) -> Config {
|
||||
let c = Config::parse(&text(approvals)).unwrap();
|
||||
assert_eq!(c.problem(), None);
|
||||
c
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_the_section_everyone_allowed_may_answer() {
|
||||
let c = config("");
|
||||
assert_eq!(c.approvers(), [KYLE, HERMES]);
|
||||
assert_eq!(c.first_approver(), Some(KYLE));
|
||||
assert_eq!(
|
||||
c.gateway_socket(),
|
||||
PathBuf::from("/srv/bx/run/gateway/gateway.sock")
|
||||
);
|
||||
assert_eq!(
|
||||
config("[approvals]\nusers = []\n").approvers(),
|
||||
[KYLE, HERMES]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_list_narrows_who_may_answer() {
|
||||
let c = config(&format!(
|
||||
"[approvals]\nusers = [\"{HERMES}\"]\nsocket = \"/run/gw.sock\"\n"
|
||||
));
|
||||
assert_eq!(c.approvers(), [HERMES]);
|
||||
assert_eq!(c.first_approver(), Some(HERMES));
|
||||
assert_eq!(c.gateway_socket(), PathBuf::from("/run/gw.sock"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_approver_must_be_heard() {
|
||||
let c = Config::parse(&text(&format!(
|
||||
"[approvals]\nusers = [\"{KYLE}\", \"{OTHER}\"]\n"
|
||||
)))
|
||||
.unwrap();
|
||||
let why = c.problem().expect("refused");
|
||||
assert_eq!(
|
||||
why,
|
||||
format!(
|
||||
"[approvals] \"{OTHER}\" is not in [allow] users: an approver who is not heard could never answer"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_keys_are_errors() {
|
||||
assert!(Config::parse(&text("[approvals]\napprovers = []\n")).is_err());
|
||||
assert!(Config::parse(&text("[approvals]\nusers = []\nttl = 5\n")).is_err());
|
||||
}
|
||||
@@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone.
|
||||
|
||||
| Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| M4b/07-gatewayd-config-approvals | 2026-09-24 | done | 1 | pass | none | Copied `docs/plans/M4b/files/crates/gatewayd/tests/config_approvals.rs` into `crates/gatewayd/tests/`. In `config.rs`: added `#[serde(default)] pub approvals: ApprovalsConfig,` after `pub limits: Limits,` in `Config`, the `ApprovalsConfig { users: Vec<String>, socket: PathBuf }` struct (`#[serde(deny_unknown_fields, default)]`, with the doc comments) after `struct Config`, the `[approvals] users` check in `problem` right before the `[limits] queue` check (each id must be in `allow.users`), and the three methods before `state_path`: `approvers` (`&self.approvals.users`, or `&self.allow.users` when empty), `first_approver` (`.first().map(String::as_str)`), and `gateway_socket` (`self.approvals.socket`, or `<home>/run/gateway/gateway.sock` when `as_os_str().is_empty()`). The `ApprovalsConfig` derives `Default` so the empty `socket` deserialises from an absent field; `PathBuf`'s default is empty, matching the runbook's "empty" rule. `cargo fmt --all` then `cargo test -p gatewayd --test config_approvals --test config` green (4 and 7 passed); `make gate` prints `gate: ok` on the first run. | ? |
|
||||
| M4b/06-brokerd-courier | 2026-09-24 | done | 1 | pass | none | Filled the four `todo!()`s in the copied `courier.rs`: `finished` (`stop.load(SeqCst) || !broker.table().contains(id)`); `pause` (a `while Instant::now() < until && !finished` loop sleeping `timing.tick.min(until.saturating_duration_since(now))`); `frames` (`try_clone` the stream, spawn a reader thread that hands whole frames over an mpsc channel so the courier can `recv_timeout` without losing half a frame, `Ok(rx)`); `answer` (`take` the entry → `Gone {}` if gone, build the `Answer` from `reply.approved`, call `admin::answer`, map the returned `DecisionRecord` through the five-arm match to `Runs`/`Denied`/`Refused`/`Denied`/`Refused`). The four `broker.rs` changes: `RecvTimeoutError` import widened to include `Receiver`, `use crate::courier;` after the `config` use, `let shown = info.clone();` before the insert, step 4 replaced by `courier::alongside(broker, &shown, || wait_for_verdict(...))` matching `Waited`, and the old step 4 moved to `wait_for_verdict` (with the `Waited` enum) before `pub fn alive`. `cargo build -p brokerd` clean; courier test went 1 pass/9 fail then 10 passed three times running; full brokerd suite green; `cargo fmt --all` then `make gate` prints `gate: ok` on the first run. | ? |
|
||||
| M4b/05-brokerd-config-gateway | 2026-09-24 | done | 1 | pass | none | Copied `docs/plans/M4b/files/crates/brokerd/tests/config_gateway.rs` and the new `support/rig.rs` (whose `Config` gained `gateway: None`) into `crates/brokerd/tests/`. In `config.rs`: added the `Gateway { socket: PathBuf }` struct (`#[serde(deny_unknown_fields, default)]`) before `default_podman`, the `gateway: Option<Gateway>` field (with `#[serde(default)]`) after `runner` in `Config`, and the `gateway_socket()` method before `audit_dir` — `None` when the section is absent, the default `<home>/run/gateway/gateway.sock` when the socket is empty, else the path as given. `cargo fmt --all` then `cargo test -p brokerd` green with `config_gateway` 4 passed; `make gate` prints `gate: ok` on the first run. | ? |
|
||||
| M4b/04-brokerd-answer-post | 2026-09-24 | done | 1 | pass | none | Moved `enum Answer` and `struct Answered` from `ledger.rs` into `approvals.rs` before `pub struct Entry` (with doc comments), added `pub use crate::approvals::{Answer, Answered};` next to the `Verdict` use and `DecisionRecord` to the `use proto` line in `approvals.rs`. Added the `post` field to `Answer::Approved`/`Answer::Refused`, threaded it through `ledger::answer` (step 3 binds `post`, step 6 writes it into `AuditEvent::Approval` instead of `None`), and gave the two `bxctl` answers in `admin.rs` `post: None`. Added `Table::contains` before `take`. Copied `ledger_post.rs`, `broker_pending.rs`, `broker_sequence.rs` and `ledger_answer.rs` into `crates/brokerd/tests/`; every suite passes, `ledger_post` 3 passed, `ledger.rs` is 487 lines, and `make gate` prints `gate: ok` on the first run. | ? |
|
||||
|
||||
+9
-5
@@ -423,13 +423,17 @@ file or a directory, then this entry.
|
||||
|
||||
**Why.** `gatewayd` could not read or parse `gatewayd.toml`, or a value in it is not allowed: a
|
||||
`url` that is not `http://` or `https://` with a host and an optional port, an id that is not 26
|
||||
characters of `a-z0-9`, an empty `[allow] users`, or a secret with not exactly one of `credential`,
|
||||
`env` and `file`. Or it could not create `<home>/gateway/`. It will not guess at a configuration
|
||||
that decides who it answers.
|
||||
characters of `a-z0-9`, an empty `[allow] users`, an id in `[approvals] users` that is not also in
|
||||
`[allow] users`, or a secret with not exactly one of `credential`, `env` and `file`. Or it could
|
||||
not create `<home>/gateway/`, or could not listen on `gateway.sock` (its directory is made 0700
|
||||
and the socket 0600, as `brokerd`'s are; a path of 108 bytes or more cannot be a socket). It will
|
||||
not guess at a configuration that decides who it answers and who approves.
|
||||
|
||||
**Confirm.** The line says which: `<config path>: …` for the file (unknown keys are errors; check
|
||||
it against `docs/specs/2026-09-23-m4a-gateway.md`, section 3), or `cannot prepare <dir>: …` for the
|
||||
directory (`ls -ld <dir> "$(dirname <dir>)"`).
|
||||
it against `docs/specs/2026-09-23-m4a-gateway.md`, section 3, and
|
||||
`docs/specs/2026-09-24-m4b-approvals.md`, section 3), `cannot prepare <dir>: …` for a directory
|
||||
(`ls -ld <dir> "$(dirname <dir>)"`), or `cannot listen on <socket>: …` for `gateway.sock`
|
||||
(`Address already in use` means another `gatewayd` runs: `pgrep -x gatewayd`).
|
||||
|
||||
**Fix.** Correct the file or the directory's ownership. A user id is shown in Mattermost under the
|
||||
user's profile, or by `GET /api/v4/users/username/<name>`.
|
||||
|
||||
Reference in New Issue
Block a user