From 412f80c4700f8ee0f532d1f4cdab20d9081574a8 Mon Sep 17 00:00:00 2001 From: "K. Isom" Date: Thu, 24 Sep 2026 04:18:04 -0700 Subject: [PATCH] brokerd: config, [gateway] socket for Mattermost approvals --- crates/brokerd/src/config.rs | 19 ++++++++++++ crates/brokerd/tests/config_gateway.rs | 43 ++++++++++++++++++++++++++ crates/brokerd/tests/support/rig.rs | 1 + docs/implementer-log.md | 1 + 4 files changed, 64 insertions(+) create mode 100644 crates/brokerd/tests/config_gateway.rs diff --git a/crates/brokerd/src/config.rs b/crates/brokerd/src/config.rs index 5e4c7ca..5ab11a4 100644 --- a/crates/brokerd/src/config.rs +++ b/crates/brokerd/src/config.rs @@ -45,6 +45,14 @@ impl Default for Approvals { } } +/// `[gateway]`: where `gatewayd` takes approvals to post in Mattermost. Without the section, +/// Mattermost approvals are off and only `bxctl` answers. An empty path means the default. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)] +#[serde(deny_unknown_fields, default)] +pub struct Gateway { + pub socket: PathBuf, +} + fn default_podman() -> PathBuf { PathBuf::from("podman") } @@ -129,6 +137,8 @@ pub struct Config { pub approvals: Approvals, #[serde(default)] pub runner: Option, + #[serde(default)] + pub gateway: Option, } /// The longest an approval may wait: a day, the longest `loopd` waits after a pending frame. @@ -262,6 +272,15 @@ impl Config { self.sockets.admin.clone() } } + /// `gateway.sock`, when `[gateway]` is set. + pub fn gateway_socket(&self) -> Option { + let gateway = self.gateway.as_ref()?; + if gateway.socket.as_os_str().is_empty() { + Some(self.paths.home.join("run/gateway/gateway.sock")) + } else { + Some(gateway.socket.clone()) + } + } pub fn audit_dir(&self) -> PathBuf { self.paths.home.join("audit") } diff --git a/crates/brokerd/tests/config_gateway.rs b/crates/brokerd/tests/config_gateway.rs new file mode 100644 index 0000000..e655767 --- /dev/null +++ b/crates/brokerd/tests/config_gateway.rs @@ -0,0 +1,43 @@ +//! `[gateway]` in `brokerd.toml`: absent means Mattermost approvals are off; an empty socket is +//! the default under `home` (M4b spec, section 3). Do not edit. + +use std::path::PathBuf; + +use brokerd::config::Config; + +fn parse(text: &str) -> Config { + Config::parse(&format!("[paths]\nhome = \"/srv/bx\"\n{text}")).unwrap() +} + +#[test] +fn without_the_section_approvals_stay_in_bxctl() { + let config = parse(""); + assert_eq!(config.gateway, None); + assert_eq!(config.gateway_socket(), None); +} + +#[test] +fn an_empty_section_or_socket_is_the_default() { + let default = Some(PathBuf::from("/srv/bx/run/gateway/gateway.sock")); + assert_eq!(parse("[gateway]\n").gateway_socket(), default); + assert_eq!( + parse("[gateway]\nsocket = \"\"\n").gateway_socket(), + default + ); +} + +#[test] +fn a_socket_path_is_used_as_given() { + let config = parse("[gateway]\nsocket = \"/run/bx/gw.sock\"\n"); + assert_eq!( + config.gateway_socket(), + Some(PathBuf::from("/run/bx/gw.sock")) + ); +} + +#[test] +fn unknown_keys_are_errors() { + assert!(Config::parse("[gateway]\nsockets = \"/x\"\n").is_err()); + assert!(Config::parse("[gateway]\nsocket = \"/x\"\nenabled = true\n").is_err()); + assert!(Config::parse("[gatewy]\nsocket = \"/x\"\n").is_err()); +} diff --git a/crates/brokerd/tests/support/rig.rs b/crates/brokerd/tests/support/rig.rs index 36de54e..431094c 100644 --- a/crates/brokerd/tests/support/rig.rs +++ b/crates/brokerd/tests/support/rig.rs @@ -41,6 +41,7 @@ impl Rig { sockets: Sockets::default(), approvals: Approvals { ttl_ms }, runner: None, + gateway: None, }; Rig { dir, diff --git a/docs/implementer-log.md b/docs/implementer-log.md index 8e32ad9..108b0cf 100644 --- a/docs/implementer-log.md +++ b/docs/implementer-log.md @@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone. | Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model | |---|---|---|---|---|---|---|---| +| 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` field (with `#[serde(default)]`) after `runner` in `Config`, and the `gateway_socket()` method before `audit_dir` — `None` when the section is absent, the default `/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. | ? | | M4b/03-bxctl-deny | 2026-09-24 | done | 1 | pass | none | Copied `docs/plans/M4b/files/crates/bxctl/tests/cli_deny.rs` into `crates/bxctl/tests/`. In `parse`, `"refuse" => parse_refuse(rest, home)` became `"refuse" | "deny" => parse_refuse(rest, home)`, so `deny` produces the same `Command::Refuse` as `refuse` for every argument list including the error cases. Added a blank line and the two-line `bxctl deny [--reason TEXT] [--admin-socket PATH]` / `Another name for refuse.` block to `USAGE` after the `refuse` block, keeping the two-space and six-space indents. The copied test's `deny_parses_as_refuse` and `usage_names_deny` both failed before the changes and pass after; `cargo test -p bxctl` is green and `make gate` prints `gate: ok` on the first run. | ? | | M4b/02-proto-gateway-wire | 2026-09-24 | done | 2 | fail | none | Copied `gateway.rs`, `gateway_wire.rs` and the eight `gateway_*.json` fixtures into place; added `pub mod gateway;` after `pub mod frame;` and the re-export to `lib.rs`, the four `Message` variants plus the `use` to `wire.rs`, and the four `kind` arms (changing "fourteen" to "eighteen") in `broker.rs`. Filled `is_mattermost_id` (`id.len() == 26 && every byte a-z0-9`) and `ApprovalReply::check` in the given order: `by`, `post`, then a reason over `MAX_REASON` chars by `chars().count()`. All 3 `gateway_wire` tests pass; the first `make gate` failed on clippy `collapsible_if`, fixed by collapsing the nested `if let` into a let-chain guard, and the second prints `gate: ok`. | ? |