Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "ciris-status"
version = "0.3.70"
version = "0.3.71"
edition = "2021"
description = "The ciris.ai public health/status surface — now a ciris-server fabric node + a StatusAdapter (mirrors CIRISAgent's adapter model). Serves /health, /v1/status, /api/v1/status, /api/v1/status/history + the public scoring roster (Flow A, own corpus) + live SSE/WS, by live outbound probes + a SQLite uptime history. The federation node (engine, edge, consent:replication, read API, ownership, safety, NAT-traversal) is ciris-server's serve; the status page is the adapter."
license = "AGPL-3.0-or-later"
Expand Down
31 changes: 29 additions & 2 deletions DEPLOY.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,20 +41,47 @@ resolves everything else from **signed CEG objects in its own corpus**, authored
by the OWNER at runtime. `.env.example` documents this (there is nothing to put
in a `.env`).

### Boot inputs (CLI flags — the ONLY two)
### Boot inputs (CLI flags — two that shape the node, one that opens a door)

| Flag | Default | Meaning |
|---|---|---|
| `--home <path>` | `/var/lib/ciris` | the data root. `data_dir = <home>/data`; the corpus is `<data_dir>/ciris_engine.db`, the minted Ed25519 + ML-DSA-65 identity lives under `<home>`, and the uptime-history DB is **derived** as `<data_dir>/status.db`. The docker-compose deploy passes `--home /data` (the mounted volume). |
| `--key-id <name>` | `ciris-status` | this node's federation `key_id` — the observation attester. `serve_with_adapter` self-registers it at boot, so Flow B rows admit with no extra step. |
| `--diagnostics` | off | mount `GET /api/v1/debug/memory` (the `mallinfo2` live/free split). `CIRIS_DIAGNOSTICS=1` does the same thing — ciris-server's own switch, read through its own parser so the truthy set cannot drift. See below. |

```sh
ciris-status --home /data --key-id ciris-status # docker-compose passes this as command:
```

The listen address, transport/NAT-traversal toggles, replication cadence, and
mode are themselves the **node's** `config:*` CEG (resolved at boot, hot-applied)
— see `ciris-server`'s `src/config.rs`. There is no `CIRIS_*` env any more.
— see `ciris-server`'s `src/config.rs`. No `CIRIS_*` env configures the NODE;
`CIRIS_DIAGNOSTICS` is the substrate's own debug switch, not adapter config.

#### Diagnostics — off by default, and there ARE two ways to open it

`GET /api/v1/debug/memory` is not mounted unless asked for, because it answered
unauthenticated on the published port (CIRISStatus#73):

```yaml
command: ["--home", "/data", "--key-id", "ciris-status", "--diagnostics"]
# or
environment:
CIRIS_DIAGNOSTICS: "1"
```

Boot says which opener fired, so it is checkable rather than assumed.

**It is NOT loopback-bound here.** ciris-server pairs its gate with
`require_loopback`; an adapter router cannot — that guard is not exported and
the read-API listener does not hand us `ConnectInfo`. With diagnostics on the
route answers from wherever the port reaches, and the edge is what keeps it off
the internet. Turn it on for a reading, turn it back off.

> **0.3.69 and 0.3.70 shipped the gate with NO opener.** `diag::enable()` is
> called from ciris-server's own binary entry point, which this binary does not
> run, so the switch was permanently false: the route was gone, not gated, and
> no flag or env could bring it back. Fixed in 0.3.71.

> **The corpus is its OWN** — `<home>/data/ciris_engine.db`. Never share `--home`
> with the lens node or bind-mount the lens node's `data/`. Node A's `capacity:*`
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ Drop-in for the Lens nginx route (`agents.ciris.ai/lens/api/…` → this servic
| `GET /api/v1/scoring` | **Public scoring roster** (Flow A): opted-in agents `{key_id, capacity_composite, factors?, valid_until}`, consent-gated. Replaces lens-python's scoring feed. Served from cache, populated from this node's OWN corpus by the adapter loop. |
| `GET /api/v1/status` (capabilities) | The same response now carries `capabilities` (per-pool rollup with `min_available`, `available`, and per-member `role`/`status`), an `indicator` (Statuspage v2 severity), and `vantage_failure`. The headline is derived from capabilities, not from whichever component is unhappiest — see `FSD/CAPABILITY_MONITORING.md` |
| `GET /api/v1/ci` | **Substrate build health**: the last 10 GitHub Actions runs per repo (verify → persist → edge → server → agent) as `{repo, runs[]}`, each run one of `success\|failure\|in_progress\|queued\|cancelled`. A ~600-byte projection so a microcontroller can read it in one request; polled server-side with conditional requests (see below). |
| `GET /api/v1/debug/memory` | **Only mounted when `CIRIS_DIAGNOSTICS=1`** (CIRISStatus#73 — it answered unauthenticated on the published port while ciris-server gated the identical report). The allocator's own accounting — `uordblks` (live) vs `fordblks` (freed-but-held), plus the kernel's `RssAnon`/`VmSwap`. glibc does not zero on `free()`, so only a call from inside the process can tell a live working set from churn the allocator kept. NOT loopback-bound: an adapter router cannot see the peer, so keep it off the public edge |
| `GET /api/v1/debug/memory` | **Only mounted when `--diagnostics` or `CIRIS_DIAGNOSTICS=1`** (CIRISStatus#73 — it answered unauthenticated on the published port while ciris-server gated the identical report). The allocator's own accounting — `uordblks` (live) vs `fordblks` (freed-but-held), plus the kernel's `RssAnon`/`VmSwap`. glibc does not zero on `free()`, so only a call from inside the process can tell a live working set from churn the allocator kept. NOT loopback-bound: an adapter router cannot see the peer, so keep it off the public edge |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Document diagnostics in the configuration sections

Once this commit makes CIRIS_DIAGNOSTICS and --diagnostics functional, the primary setup instructions become contradictory: README.md:20-24 and 87-97 still say there are no environment variables and only two CLI inputs, while .env.example:1-7 explicitly says there is nothing to put in an environment file. Operators reading the Configuration section can therefore overlook or reject the opener advertised here; update those sections and the example alongside this endpoint documentation.

Useful? React with 👍 / 👎.

| `GET /api/v1/scoring/live`, `GET /api/v1/status/live` | **SSE** live-push of roster + overall-health deltas (the "extra website sockets"). |
| `GET /api/v1/status/ws` | **WebSocket** variant of the same live-push. |

Expand Down
58 changes: 51 additions & 7 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,26 @@ async fn async_main(arena_cap: diag::ArenaCap) -> anyhow::Result<()> {

// Default: serve the node + StatusAdapter. Reconstruct the arg iterator
// (`first` was consumed by the subcommand peek).
let (home, key_id) = parse_args(first.into_iter().chain(args))?;
let (home, key_id, diagnostics_flag) = parse_args(first.into_iter().chain(args))?;

// THE OPENER. 0.3.69 gated `/api/v1/debug/memory` on
// `ciris_server::diag::enabled()` (CIRISStatus#73) — correct switch, and it
// could never be flipped here: `diag::enable()` is called from
// ciris-server's OWN binary entry point, which this binary does not run. We
// call `serve_with_adapter` as a library, so the atomic stayed false
// forever and the route was not "gated", it was gone, with no way for an
// operator to get it back. A gate whose only opener lives in a `main` you
// do not execute is a removal wearing a switch's clothes.
//
// Both doors, matching the sibling: `--diagnostics` (its flag) and
// `CIRIS_DIAGNOSTICS=1` (its env, read through its own parser so the truthy
// set cannot drift from theirs). Before `serve_with_adapter`, because
// `routers()` asks `enabled()` while building.
if diagnostics_flag {
ciris_server::diag::enable("ciris-status --diagnostics");
} else if ciris_server::diag::env_requests() {
ciris_server::diag::enable("ciris-status CIRIS_DIAGNOSTICS");
}

// Zero-env node config: derived entirely from `--home`/`--key-id` + config:*.
let cfg = ciris_server::ServerConfig::from_home(home, key_id)?;
Expand Down Expand Up @@ -205,9 +224,10 @@ fn parse_config_value(raw: &str) -> ciris_server::ConfigValue {
/// Parse `--home <path>` / `--key-id <name>` (both optional; `--flag=value` also
/// accepted). Unknown args are an error — fail loud, never silently ignore a
/// misspelled flag on the boot path. Mirrors ciris-server's `parse_serve_flags`.
fn parse_args(args: impl Iterator<Item = String>) -> anyhow::Result<(PathBuf, String)> {
fn parse_args(args: impl Iterator<Item = String>) -> anyhow::Result<(PathBuf, String, bool)> {
let mut home: Option<String> = None;
let mut key_id: Option<String> = None;
let mut diagnostics = false;

let mut it = args;
while let Some(arg) = it.next() {
Expand All @@ -226,9 +246,14 @@ fn parse_args(args: impl Iterator<Item = String>) -> anyhow::Result<(PathBuf, St
match name.as_str() {
"--home" => home = Some(take("--home")?),
"--key-id" => key_id = Some(take("--key-id")?),
// Mirrors ciris-server's own flag. Takes no value; `--diagnostics=1`
// is accepted too so an operator who types it either way gets what
// they meant rather than "needs a value".
"--diagnostics" => diagnostics = true,
Comment on lines +249 to +252

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject values on the diagnostics switch

When a deployment renders this boolean as --diagnostics=false, --diagnostics=0, or even a mistyped value, split_once places that value in eq_value but this arm ignores it and enables diagnostics unconditionally. Because the newly enabled endpoint is explicitly unauthenticated and not loopback-gated, a configuration intended to keep diagnostics off can expose it; accept only the documented bare switch (or validate an explicit value rather than treating every value as true).

Useful? React with 👍 / 👎.

other => {
return Err(anyhow::anyhow!(
"unknown arg: {other} (usage: ciris-status [--home <path>] [--key-id <name>])"
"unknown arg: {other} (usage: ciris-status [--home <path>] [--key-id <name>] \
[--diagnostics])"
))
}
}
Expand All @@ -237,31 +262,50 @@ fn parse_args(args: impl Iterator<Item = String>) -> anyhow::Result<(PathBuf, St
Ok((
PathBuf::from(home.unwrap_or_else(|| DEFAULT_HOME.to_string())),
key_id.unwrap_or_else(|| DEFAULT_KEY_ID.to_string()),
diagnostics,
))
}

#[cfg(test)]
mod tests {
use super::*;

fn parse(args: &[&str]) -> anyhow::Result<(PathBuf, String)> {
fn parse(args: &[&str]) -> anyhow::Result<(PathBuf, String, bool)> {
parse_args(args.iter().map(|s| s.to_string()))
}

#[test]
fn defaults_when_no_flags() {
let (home, key_id) = parse(&[]).unwrap();
let (home, key_id, diagnostics) = parse(&[]).unwrap();
assert_eq!(home, PathBuf::from(DEFAULT_HOME));
assert_eq!(key_id, DEFAULT_KEY_ID);
assert!(!diagnostics, "diagnostics stay OFF unless asked for");
}

/// CIRISStatus#73 gated the memory route on the server's switch; 0.3.69
/// shipped that gate with no way to open it from THIS binary, because
/// `diag::enable()` is called from ciris-server's own `main`, which we do
/// not run. The flag is one of the two openers — without it the route is
/// not gated, it is gone.
#[test]
fn diagnostics_flag_is_accepted_and_off_by_default() {
let (_, _, on) = parse(&["--diagnostics"]).unwrap();
assert!(on);
// Takes no value, and does not swallow the next argument.
let (home, key_id, on) =
parse(&["--diagnostics", "--home", "/data", "--key-id", "node-b"]).unwrap();
assert!(on);
assert_eq!(home, PathBuf::from("/data"));
assert_eq!(key_id, "node-b");
}

#[test]
fn space_and_eq_forms_parse() {
let (home, key_id) = parse(&["--home", "/data", "--key-id", "ciris-status"]).unwrap();
let (home, key_id, _) = parse(&["--home", "/data", "--key-id", "ciris-status"]).unwrap();
assert_eq!(home, PathBuf::from("/data"));
assert_eq!(key_id, "ciris-status");

let (home, key_id) = parse(&["--home=/data", "--key-id=node-b"]).unwrap();
let (home, key_id, _) = parse(&["--home=/data", "--key-id=node-b"]).unwrap();
assert_eq!(home, PathBuf::from("/data"));
assert_eq!(key_id, "node-b");
}
Expand Down
Loading