Compare commits

..
17 changed files with 75 additions and 535 deletions

1
Cargo.lock generated
View file

@ -4591,7 +4591,6 @@ dependencies = [
"hive-jobq",
"hive-jobq-wire",
"hive-types",
"problem_details",
"reqwest 0.13.1",
"serde",
"serde_json",

View file

@ -318,28 +318,6 @@ refactor's concern. The dashboard frontend parses via
`util.js::epochSec` wherever it needs arithmetic and feeds the string
straight to `new Date(s)` for display.
### HTTP error bodies
Every HTTP API in this repo answers failures with **RFC 9457
`application/problem+json`** (`{ type, title, status, detail }`), with the
human-readable cause in `detail`. An endpoint of ours returning a bare string
or a bespoke error shape is a **bug to file against the backend**, not
something for the caller to work around.
Use the `problem_details` crate (`features = ["axum"]`), which the daemons
already depend on: type a handler `Result<_, ProblemDetails>` and hand
`ProblemDetails::from_status_code(...).with_detail(...)` to `Err`.
The reason is the consumer, not tidiness. The UIs show errors through one
shared component with a copy button, so a caller has to know **which part of
the body is the message**. A bare string forces it to treat the whole payload
as prose, which is the difference between offering "copy the cause" and
dumping a response — and the cause is frequently the entire diagnosis (a
JetStream permission refusal, a TLS chain failure) rather than a summary.
Not in scope: the `hivectl` host-admin and in-agent unix sockets. Those are a
JSON-line protocol with their own result types; RFC 9457 is an HTTP format.
## Tool groups
The MCP tool surface an agent receives is derived from a set of named

View file

@ -21,18 +21,15 @@ operator has to place, and where.
### 1 · Forge
```bash
# Provision (or refresh) ruth's own forge account — do this first. Ruth's
# bootstrap bypasses the normal spawn-approval flow (see step 6), so unlike
# every other agent it does not get its forge account auto-provisioned —
# this manual step is still load-bearing.
# Provision (or refresh) ruth's own forge account — do this first
hivectl forge create-user ruth
# Sub-agents spawned later (via the approval flow in step 6) get their
# forge accounts auto-provisioned — nothing to run here for them.
```
# Create a human operator account (prints the token to stdout)
hivectl forge create-user mara --password hunter2
The human operator's own forge account is created via swarm SSO instead of
a manual `hivectl` step — see step 3 (`swarmctl user add`).
# Provision forge accounts for any sub-agents spawned later
hivectl forge create-user <agent>
```
### 2 · Gateway (HTTP Basic auth)
@ -92,21 +89,20 @@ control: [`swarm/ui.md`](swarm/ui.md).
# 5a. Ensure the hive-internal admin account exists first
hivectl matrix sync-admin
# 5b. Provision ruth's own matrix account — same bootstrap-bypass reasoning
# as forge above, still a required manual step.
# 5b. Provision ruth's own matrix account
hivectl matrix create-user ruth
# 5c. Invite the operator to the hive Space (and optionally to rooms)
# 5c. Create a human matrix account
hivectl matrix create-user mara --password hunter2
# 5d. Invite the operator to the hive Space (and optionally to rooms)
hivectl matrix invite mara
hivectl matrix invite @mara:yourserver --room '#hive-chat:yourserver'
# 5d. Promote the operator to homeserver admin if needed
# 5e. Promote the operator to homeserver admin if needed
hivectl matrix promote-user mara
```
The human operator's own matrix account is created via swarm SSO instead of
a manual `hivectl` step — see step 3 (`swarmctl user add`).
### 6 · Spawn sub-agents
Sub-agent creation goes through the approval queue — ruth proposes, the

View file

@ -23,7 +23,6 @@ import { $, esc, fmtAgeSecs, renderServerWarnings } from './common.js';
import { el } from '@hive/shared/dom.js';
import '@hive/shared/hive-tab-strip.js';
import { themedConfirm, themedToast } from '@hive/shared/modal.js';
import { readApiError, problemMessage } from '@hive/shared/api-error.js';
let agents = [];
// agent name → container running (bool), from /api/state. Cross-referenced by
@ -66,13 +65,28 @@ function renderAgentPicker() {
for (const a of agents) sel.append(el('option', { value: a }, a));
}
// Shape-agnostic error-body parsing (shared by both tabs' submit handlers)
// lives in `@hive/shared/api-error.js` now — `readApiError` +
// `problemMessage` (this page only needs the one-line message, not the
// full `ApiErrorPanel`; its result lines are single-line `aria-live`
// regions, not a swap-in-a-panel context). Was a local function here
// originally; promoted so swarm-ui shares the same
// shape-agnostic reader instead of each side maintaining its own copy.
// ─── shape-agnostic error-body parsing (shared by both tabs' submit
// handlers) ───────────────────────────────────────────────────────────────
// The BE error-body shape is in transition: today hive-c0re's
// error_response sends a bare plain-text body; the RFC 9457 rework moves it
// to application/problem+json ({ type, title, detail, … }). Read shape-
// agnostically: pull the body once as text, and if it parses as JSON
// surface `detail` (problem+json) → `error`/`title` fallback, else use the
// raw text. A bare HTTP code is the last resort.
async function readErrorBody(resp) {
try {
const raw = (await resp.text()).trim();
if (raw && (raw[0] === '{' || raw[0] === '[')) {
try {
const body = JSON.parse(raw);
return body.detail || body.error || body.title || raw;
} catch { /* not JSON after all — keep the raw text */ }
}
return raw;
} catch {
return '';
}
}
// ─── MATRIX tab ────────────────────────────────────────────────────────────
// Live status dot — the daemon heartbeats every ~30s (advances as_of_unix),
@ -239,7 +253,7 @@ async function submitLogin(e) {
clearSecrets(formEl);
}
} else {
const msg = problemMessage(await readApiError(resp));
const msg = await readErrorBody(resp);
out.className = 'ma-result err';
out.textContent = '✗ ' + (msg || ('login failed (HTTP ' + resp.status + ')'));
clearSecrets(formEl);
@ -323,7 +337,7 @@ async function submitGithub(e) {
clearSecrets(formEl);
}
} else {
const msg = problemMessage(await readApiError(resp));
const msg = await readErrorBody(resp);
out.className = 'ma-result err';
out.textContent = '✗ ' + (msg || ('store failed (HTTP ' + resp.status + ')'));
clearSecrets(formEl);
@ -405,7 +419,7 @@ async function onForgeRemoveClick(agent, forge, btn) {
loadForgeAccounts(agent);
return;
}
const msg = problemMessage(await readApiError(resp));
const msg = await readErrorBody(resp);
btn.textContent = orig;
btn.disabled = false;
themedToast('✗ ' + (msg || ('remove failed (HTTP ' + resp.status + ')')), { type: 'error' });
@ -460,7 +474,7 @@ async function submitForgeAccount(e) {
clearSecrets(formEl);
}
} else {
const msg = problemMessage(await readApiError(resp));
const msg = await readErrorBody(resp);
out.className = 'ma-result err';
out.textContent = '✗ ' + (msg || ('store failed (HTTP ' + resp.status + ')'));
clearSecrets(formEl);

View file

@ -31,12 +31,7 @@
"./jobq-graph.js": "./src/jobq-graph/JobqGraph.tsx",
"./jobq-graph.css": "./src/jobq-graph/jobq-graph.css",
"./jobq-rollup.js": "./src/jobq-rollup/JobqRollup.tsx",
"./jobq-rollup.css": "./src/jobq-rollup/jobq-rollup.css",
"./api-error.js": "./src/api-error.ts",
"./api-error-panel.js": "./src/api-error-panel/ApiErrorPanel.tsx",
"./api-error-panel.css": "./src/api-error-panel/api-error-panel.css",
"./warn-banner.js": "./src/warn-banner/WarnBanner.tsx",
"./warn-banner.css": "./src/warn-banner/WarnBanner.css"
"./jobq-rollup.css": "./src/jobq-rollup/jobq-rollup.css"
},
"files": [
"src/"

View file

@ -1,75 +0,0 @@
// ApiErrorPanel.tsx — <ApiErrorPanel>, the shared "show the operator why
// an API call failed" component (mara, on the issue that asked for this:
// "the error display component is for showing ProblemDetails in a nicer
// way with copy button [...] wherever we want to show an error, this
// component should be used"). Renders a `ProblemDetails` (./api-error.ts)
// — heading + the full `detail` text, unclamped (it can be a raw NATS/
// JetStream error, and on the incident that prompted this component
// *that string was the entire diagnosis* — truncating it defeats the
// point) — plus a copy button for pasting straight into a bug report.
//
// Composed from `WarnBanner` (../warn-banner/WarnBanner.js) rather than
// owning the border/colour/pulse styling itself — mara, on review: "i
// want each component to import its own css file itself[;] if you need
// a bunch of extra css externally, its not a proper component". This
// file's own `.css` only lays out what's specific to it (heading, copy
// button, detail text); the banner "shape" belongs to `WarnBanner` and
// stays there so any future error/warning surface reuses it too, rather
// than every caller growing its own copy of the same rules.
//
// Same shared-component shape as `JobqRollup`/`JobqGraph`:
// `render(h(ApiErrorPanel, { problem }), container)` from vanilla JS, or
// `<ApiErrorPanel problem={...} />` from swarm-ui's JSX.
import { useState } from 'preact/hooks';
import { WarnBanner } from '../warn-banner/WarnBanner.js';
import type { ProblemDetails } from '../api-error.js';
import './api-error-panel.css';
export interface ApiErrorPanelProps {
problem: ProblemDetails;
// Optional short prefix naming what failed ("failed to load the hive
// roster") — the panel is otherwise just the server's own words, which
// don't always say what the caller was trying to do.
context?: string;
}
function formatForCopy(p: ProblemDetails): string {
const lines: string[] = [];
if (p.status !== undefined) lines.push(`status: ${p.status}`);
if (p.title) lines.push(`title: ${p.title}`);
if (p.type) lines.push(`type: ${p.type}`);
if (p.detail) lines.push(`detail: ${p.detail}`);
return lines.length ? lines.join('\n') : 'request failed';
}
export function ApiErrorPanel({ problem, context }: ApiErrorPanelProps) {
const [copied, setCopied] = useState(false);
const heading = problem.title || (problem.status !== undefined ? `http ${problem.status}` : 'request failed');
async function copy() {
try {
await navigator.clipboard.writeText(formatForCopy(problem));
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
// clipboard permission denied / unavailable (e.g. non-secure
// context) — the text is still fully visible to select by hand,
// so this is a silent no-op rather than a second error to show.
}
}
return (
<WarnBanner level="error" class="api-error-panel">
<div class="api-error-heading">
<span class="api-error-title">
{context ? `${context}: ` : ''}
{heading}
</span>
<button type="button" class="api-error-copy" onClick={copy}>
{copied ? 'copied' : 'copy'}
</button>
</div>
{problem.detail ? <p class="api-error-detail">{problem.detail}</p> : null}
</WarnBanner>
);
}

View file

@ -1,40 +0,0 @@
/* api-error-panel.css layout for `<ApiErrorPanel>`'s content, plus the
one spacing concern that's genuinely this component's own (the top
margin `WarnBanner` only sets `margin-bottom`, tuned for a banner
that's the first thing in a page, not a panel that usually follows a
form or a table). Colour/border/pulse belong to `WarnBanner`
(../warn-banner/WarnBanner.css) not duplicated here. */
.api-error-panel {
margin-top: 1em;
}
.api-error-heading {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.8em;
}
.api-error-title {
font-weight: 600;
}
.api-error-copy {
flex: none;
background: transparent;
color: inherit;
border: 1px solid currentColor;
border-radius: 0.3em;
padding: 0.1em 0.6em;
font: inherit;
font-size: 0.85em;
cursor: pointer;
}
.api-error-copy:hover {
background: color-mix(in srgb, currentColor 12%, transparent);
}
.api-error-detail {
margin: 0.5em 0 0;
white-space: pre-wrap;
word-break: break-word;
font-size: 0.9em;
opacity: 0.9;
}

View file

@ -1,63 +0,0 @@
// api-error.ts — reads a failed fetch `Response` into a `ProblemDetails`
// (RFC 9457, `application/problem+json`) object, shape-agnostically.
//
// Promoted from `dashboard/src/credentials.js`'s original `readErrorBody`,
// which returned a flat message string. This returns the structured object
// instead so a caller — chiefly `ApiErrorPanel` (./api-error-panel/) — can
// render title/status/detail separately and build a useful copy-button
// payload, rather than re-parsing a pre-squashed string.
//
// RFC 9457 is this hive's committed error-body contract for first-party
// APIs (mara: "any api of our own responding with error that is not rfc
// shaped is a bug to be filed against backend") — every first-party route
// emits it as of this writing, so the non-JSON fallback below is NOT
// compatibility with an in-flight backend transition (that transition is
// finished). It's for bodies we did not author: a caller can be pointed
// at something that isn't ours, and an nginx-synthesised 502/504 never
// reaches our handlers at all, so it's plain HTML, not problem+json. A
// first-party route landing here anyway is a bug to file, not something
// this reader should quietly paper over.
export interface ProblemDetails {
type?: string;
title?: string;
status?: number;
detail?: string;
}
export async function readApiError(resp: Response): Promise<ProblemDetails> {
const status = resp.status;
let raw: string;
try {
raw = (await resp.text()).trim();
} catch {
return { status };
}
if (raw && (raw[0] === '{' || raw[0] === '[')) {
try {
const body = JSON.parse(raw) as Record<string, unknown>;
return {
type: typeof body.type === 'string' ? body.type : undefined,
title: typeof body.title === 'string' ? body.title : undefined,
status: typeof body.status === 'number' ? body.status : status,
detail:
typeof body.detail === 'string'
? body.detail
: typeof body.error === 'string'
? body.error
: undefined,
};
} catch {
// not JSON after all — fall through to the raw-text path below
}
}
return { status, detail: raw || undefined };
}
// One-line message for a context that only has room for a single string
// (a form's inline result line, a toast) rather than the full panel —
// same fallback order `readErrorBody` used: detail (or its `error` alias,
// already folded in above) → title → a bare status line.
export function problemMessage(p: ProblemDetails): string {
return p.detail || p.title || (p.status !== undefined ? `http ${p.status}` : 'request failed');
}

View file

@ -1,36 +0,0 @@
/* WarnBanner.css three levels, not an open-ended severity+modifier
combination (same rule `hive-warn.css` states, copied faithfully since
this is that component's visual language, just reached through a
class instead of a `:host([level])` attribute selector). Colours reuse
theme.css's own documented semantics: --cyan = "info accents", --amber
= "warnings", --red = "errors, fail state". */
.warn-banner {
display: block;
border: 1px solid var(--amber);
border-radius: 4px;
padding: 0.5em 0.8em;
margin-bottom: 0.6em;
color: var(--amber);
background: color-mix(in srgb, var(--amber) 8%, transparent);
}
.warn-banner-info {
border-color: var(--cyan);
color: var(--cyan);
background: color-mix(in srgb, var(--cyan) 8%, transparent);
}
.warn-banner-error {
border-color: var(--red);
color: var(--red);
background: color-mix(in srgb, var(--red) 8%, transparent);
/* Only `error` pulses — an active incident, not a standing caveat. */
text-shadow: 0 0 6px color-mix(in srgb, currentColor 40%, transparent);
animation: warn-banner-pulse 2.4s ease-in-out infinite;
}
@keyframes warn-banner-pulse {
0%, 100% { box-shadow: 0 0 12px -4px color-mix(in srgb, currentColor 55%, transparent); }
50% { box-shadow: 0 0 22px -2px color-mix(in srgb, currentColor 95%, transparent); }
}
.warn-banner strong {
color: inherit;
}

View file

@ -1,38 +0,0 @@
// WarnBanner.tsx — <WarnBanner>, the Preact-component successor to the
// shadow-DOM <hive-warn> custom element (../hive-warn/hive-warn.js).
// Same three-tier visual language (mara, on `hive-warn`'s own review:
// "there should be distinction between info, warning, error — all
// warnings should be styled identically") — built as a plain Preact
// component instead so it composes cleanly wherever this package's other
// shared components do (JobqRollup, JobqGraph, ApiErrorPanel), rather
// than needing a shadow root + CSS-as-text build step that only resolves
// under one of the two consuming packages' esbuild configs. `ApiErrorPanel`
// is the first consumer — compose from this instead of duplicating the
// banner's own border/colour/pulse rules locally.
//
// `level` defaults to 'warning', matching `hive-warn.js`'s own default.
// Only `error` pulses — an active incident, not a standing caveat, same
// rule `hive-warn.css`'s own comment states.
import type { ComponentChildren } from 'preact';
import './WarnBanner.css';
export type WarnLevel = 'info' | 'warning' | 'error';
export interface WarnBannerProps {
level?: WarnLevel;
// Passed straight through to the root element's `class` list —
// for a caller-specific concern the banner itself has no opinion on
// (e.g. `ApiErrorPanel` adding a top margin for the context it usually
// sits in), not for re-styling the banner's own look.
class?: string;
children?: ComponentChildren;
}
export function WarnBanner({ level = 'warning', class: extraClass, children }: WarnBannerProps) {
const classes = ['warn-banner', `warn-banner-${level}`, extraClass].filter(Boolean).join(' ');
return (
<div class={classes} role={level === 'error' ? 'alert' : undefined}>
{children}
</div>
);
}

View file

@ -49,3 +49,6 @@
.create-agent-result-ok {
color: var(--green);
}
.create-agent-result-error {
color: var(--red);
}

View file

@ -23,8 +23,6 @@
// comment states.
import { useState } from 'preact/hooks';
import { Link } from 'wouter-preact';
import { ApiErrorPanel } from '@hive/shared/api-error-panel.js';
import { readApiError, type ProblemDetails } from '@hive/shared/api-error.js';
import { Panel } from '../ui/panel/Panel.js';
import './CreateAgentPage.css';
@ -36,22 +34,13 @@ type SubmitState =
| { status: 'idle' }
| { status: 'submitting' }
| { status: 'done'; nodeId: number }
| { status: 'error'; problem: ProblemDetails };
| { status: 'error'; message: string };
// Client-side mirror of `hive_types::Ident` (1-63 chars of `[a-z0-9-]`) —
// a `pattern` hint only, not a substitute for the server's own
// validation; a name this rejects would fail server-side anyway, so
// catching it before a round-trip is a pure UX win, not a new gate.
//
// Hyphen escaped (`\-`) rather than left trailing in the class: modern
// browsers validate the `pattern` attribute's regex in Unicode-set (`v`)
// mode, which is far stricter about hyphen placement than classic mode —
// `[a-z0-9-]` throws "Invalid character class" under `v` mode even though
// it's valid classic-mode regex (a trailing `-` is unambiguous there).
// Reproduced directly: `new RegExp('[a-z0-9-]', 'v')` throws,
// `new RegExp('[a-z0-9\\-]', 'v')` doesn't — confirmed via mara's console
// exception report on this page.
const NAME_PATTERN = '[a-z0-9\\-]{1,63}';
const NAME_PATTERN = '[a-z0-9-]{1,63}';
export function CreateAgentPage() {
const [name, setName] = useState('');
@ -66,15 +55,12 @@ export function CreateAgentPage() {
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name }),
});
if (!r.ok) {
setResult({ status: 'error', problem: await readApiError(r) });
return;
}
if (!r.ok) throw new Error((await r.text()) || `http ${r.status}`);
const data = (await r.json()) as CreateAgentResponse;
setResult({ status: 'done', nodeId: data.node_id });
setName('');
} catch (err) {
setResult({ status: 'error', problem: { detail: String(err) } });
setResult({ status: 'error', message: String(err) });
}
}
@ -110,7 +96,9 @@ export function CreateAgentPage() {
</p>
)}
{result.status === 'error' && (
<ApiErrorPanel context="failed to queue" problem={result.problem} />
<p class="create-agent-result create-agent-result-error">
failed to queue: {result.message}
</p>
)}
</Panel>
);

View file

@ -8,8 +8,6 @@
// than living in `App.tsx`, matching `JobsPage`'s shape: `App.tsx` is
// routing, a page owns its own fetch + render.
import { useEffect, useState } from 'preact/hooks';
import { ApiErrorPanel } from '@hive/shared/api-error-panel.js';
import { readApiError, type ProblemDetails } from '@hive/shared/api-error.js';
import { Panel } from '../ui/panel/Panel.js';
import { StatusChip, type ChipTone } from '../ui/status-chip/StatusChip.js';
import { Table, type TableColumn } from '../ui/table/Table.js';
@ -62,22 +60,21 @@ const COLUMNS: TableColumn<HiveStatus>[] = [
export function OverviewPage() {
const [hives, setHives] = useState<HiveStatus[] | null>(null);
const [error, setError] = useState<ProblemDetails | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
(async () => {
const r = await fetch('/api/hives/status');
if (!r.ok) {
setError(await readApiError(r));
return;
}
setHives((await r.json()) as HiveStatus[]);
})().catch((e: unknown) => setError({ detail: String(e) }));
fetch('/api/hives/status')
.then((r) => {
if (!r.ok) throw new Error(`http ${r.status}`);
return r.json() as Promise<HiveStatus[]>;
})
.then(setHives)
.catch((e: unknown) => setError(String(e)));
}, []);
return (
<Panel title="overview">
{error ? <ApiErrorPanel context="failed to load the hive roster" problem={error} /> : null}
{error ? <p>failed to load the hive roster: {error}</p> : null}
{!error && hives === null ? <p>loading</p> : null}
{hives ? <Table columns={COLUMNS} rows={hives} rowKey={(h) => h.name} /> : null}
</Panel>

View file

@ -174,30 +174,6 @@ in
'';
};
};
plugins = lib.mkOption {
type = lib.types.listOf lib.types.package;
default = [ ];
example = lib.literalExpression "[ pkgs.grafanaPlugins.grafana-piechart-panel ]";
description = ''
Grafana plugins to install, as packages. Declarative rather than
installed through the UI, which is the only shape that works here:
plugin management is **server-admin** scoped, and on an SSO hive
nobody holds that role `users.auto_assign_org_role` grants an
*org* role, and the built-in local admin that does hold server
admin cannot log in because the login form is disabled whenever
SSO is configured.
That is a deliberate pair of decisions rather than an oversight,
and this option is the way through it: plugins live in the store
and in git, so they survive a container rebuild and a state reset,
and the container needs no runtime egress to grafana.com.
Empty by default, which leaves grafana's own plugin handling
untouched. Setting it takes over the plugin directory entirely
anything installed by other means stops being visible.
'';
};
};
config = lib.mkIf (hyperhiveCfg.enable && cfg.enable) {
@ -461,25 +437,6 @@ in
enable = true;
package = cfg.package;
# Passed through unconditionally, empty default included.
# Upstream distinguishes `null` from `[ ]`, and both differences
# favour always handing it a list:
#
# - `null` points the plugin path at grafana's mutable
# `<dataDir>/plugins`; any list points it at a store path.
# Switching on the day someone adds their first plugin would
# bury that change inside an unrelated one.
# - upstream defaults its plugin update-check to
# `declarativePlugins == null`, so a list also stops the
# container phoning grafana.com. That is the no-runtime-egress
# property this option exists for — it should not arrive only
# once a plugin happens to be listed.
#
# Nothing is taken over by claiming the directory on a hive with no
# plugins: manual installation is already impossible here (see the
# option's description), so there is nothing in it to lose.
declarativePlugins = cfg.plugins;
settings = {
server = {
# Already upstream's default (measured), but pinned rather

View file

@ -27,10 +27,6 @@ forgejo-api.workspace = true
# raw bytes.
base64.workspace = true
futures-util.workspace = true
# RFC 9457 `application/problem+json` error bodies. Same version + `axum`
# feature as hive-c0re: the two daemons answer the same operator UIs, so a
# reader that handles one's failures has to handle the other's.
problem_details = { version = "0.9.0", features = ["axum"] }
# The graph itself, held directly rather than behind a c0re-style wrapper
# module — that layering (`hive-c0re::job_queue`) is partially legacy (predates
# `hive-jobq`'s extraction into its own crate) and this daemon does not need it

View file

@ -428,29 +428,14 @@ async fn get_links(State(state): State<AppState>) -> Json<Vec<ServiceLink>> {
/// body because a bare 503 on an operator-facing diagnostic is how a
/// misconfiguration costs an afternoon; it is a queue/JetStream error
/// string, and this surface is already behind the swarm's SSO.
///
/// It travels in `detail` of an RFC 9457 `application/problem+json` body
/// rather than as a bare string, so the cause is an addressable field
/// instead of the whole payload — see `error_problem` below.
struct StatusUnavailable(String);
impl axum::response::IntoResponse for StatusUnavailable {
fn into_response(self) -> axum::response::Response {
error_problem(axum::http::StatusCode::SERVICE_UNAVAILABLE, &self.0).into_response()
(axum::http::StatusCode::SERVICE_UNAVAILABLE, self.0).into_response()
}
}
/// Every error this daemon returns, in one shape.
///
/// RFC 9457 `application/problem+json` is the hive-wide contract for HTTP
/// error bodies (`docs/conventions.md`), and the operator UIs read `detail`
/// for display. A bare string forces the reader to treat the entire body as
/// the message, which is the difference between a UI that can offer "copy the
/// cause" and one that can only dump a response.
fn error_problem(status: axum::http::StatusCode, detail: &str) -> problem_details::ProblemDetails {
problem_details::ProblemDetails::from_status_code(status).with_detail(detail)
}
/// What each hive last said about itself, read from the swarm queue at
/// request time.
///
@ -537,17 +522,17 @@ struct CreateAgentResponse {
request_body = CreateAgentRequest,
responses(
(status = 200, description = "job chain queued", body = CreateAgentResponse),
(status = 400, description = "`name` is not a valid identifier (problem+json)", body = String),
(status = 500, description = "the job chain could not be queued (problem+json)", body = String),
(status = 400, description = "`name` is not a valid identifier", body = String),
(status = 500, description = "the job chain could not be queued", body = String),
),
tag = "agents"
)]
async fn create_agent(
State(state): State<AppState>,
Json(req): Json<CreateAgentRequest>,
) -> Result<Json<CreateAgentResponse>, problem_details::ProblemDetails> {
) -> Result<Json<CreateAgentResponse>, (axum::http::StatusCode, String)> {
let agent = hive_types::Ident::parse(&req.name)
.map_err(|reason| error_problem(axum::http::StatusCode::BAD_REQUEST, reason))?
.map_err(|reason| (axum::http::StatusCode::BAD_REQUEST, reason.to_owned()))?
.into_string();
let repo = agent.clone();
@ -577,12 +562,7 @@ async fn create_agent(
.after_ok(create_repo);
vec![create_identity.guid()]
})
.map_err(|e| {
error_problem(
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
&e.to_string(),
)
})?;
.map_err(|e| (axum::http::StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let [id] = ids[..] else {
unreachable!("exactly one handle was asked for");
};
@ -790,46 +770,11 @@ async fn main() -> Result<()> {
#[cfg(test)]
mod tests {
use super::{
DEFAULT_SOCKET, HIVES_ENV, HiveEntry, LINKS_ENV, ServiceLink, StatusUnavailable,
SwarmNodeKind, WorkerDeps, load_hives, load_links, run_swarm_node,
DEFAULT_SOCKET, HIVES_ENV, HiveEntry, LINKS_ENV, ServiceLink, SwarmNodeKind, WorkerDeps,
load_hives, load_links, run_swarm_node,
};
use std::path::Path;
/// The 503 this route returns is what the operator UIs' shared error
/// component renders, so assert the RENDERED response rather than the
/// `problem_details` crate: the contract a UI depends on is the content
/// type plus a `detail` it can address, and a handler that built the
/// value and returned it as a bare string would satisfy any test
/// written against the type alone.
#[tokio::test]
async fn status_unavailable_renders_problem_json_with_the_cause_in_detail() {
use axum::response::IntoResponse as _;
let cause = "listing status bucket keys: timed out";
let resp = StatusUnavailable(cause.to_owned()).into_response();
assert_eq!(resp.status(), axum::http::StatusCode::SERVICE_UNAVAILABLE);
let ct = resp
.headers()
.get(axum::http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_owned();
assert!(
ct.starts_with("application/problem+json"),
"RFC 9457 media type, got {ct:?}"
);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.expect("body reads");
let v: serde_json::Value = serde_json::from_slice(&bytes).expect("problem+json parses");
assert_eq!(v["status"], 503);
// The cause is an addressable field, not the entire payload — that
// distinction is the point of the change, so it is what is asserted.
assert_eq!(v["detail"], cause);
}
/// Drives `SwarmNodeKind::CreateRepo` through the real
/// `hive_jobq::scheduler::Scheduler` claim → run → complete path,
/// rather than only through `create_agent`'s endpoint test (there

View file

@ -122,37 +122,6 @@ fn quote(s: &str) -> String {
out
}
/// Domain for an address this crate invents.
///
/// The same one `hive-c0re` already gives every agent's forge account
/// (`forge::users::agent_email`) and every hyperhive-authored git commit. A
/// deployment-derived domain was considered and rejected: it would have to be
/// passed in from config, and an operator who is already supplying a domain
/// may as well supply the whole address — while a *second* convention for
/// synthetic identities is a thing to keep in sync forever.
///
/// Never routable, and that is correct rather than a compromise. Nothing
/// sends mail here; the address exists so that a relying party asking for an
/// `email` claim gets one.
const SYNTHETIC_EMAIL_DOMAIN: &str = "hyperhive.local";
/// The address a user with no explicit one is rendered as.
///
/// Every user needs an email in the rendered file, because a relying party
/// that asks for the `email` claim and gets nothing does not degrade — it
/// fails. Grafana's OIDC login is the measured case: with no email claim it
/// falls through to `<api_url>/emails`, a GitHub-ism authelia does not
/// implement, and the login dies with `InternalError` rather than anything
/// naming the missing field.
///
/// Synthesised in the **renderer**, never written to the store: `users.json`
/// stays honest that no address was supplied, so an operator who later sets a
/// real one is not fighting a value swarmctl invented, and every existing user
/// is fixed by the next render with no migration step.
fn synthetic_email(username: &str) -> String {
format!("{username}@{SYNTHETIC_EMAIL_DOMAIN}")
}
/// Render the store as authelia's block-style YAML users database.
///
/// Fallible because it re-runs validation over every value it is about to
@ -173,15 +142,10 @@ pub fn render_yaml(store: &UserStore) -> Result<String> {
writeln!(out, " {name}:")?;
writeln!(out, " displayname: {}", quote(&user.displayname))?;
writeln!(out, " password: {}", quote(&user.password))?;
// Unconditional: an absent email is the failure mode, not a valid
// rendering. The synthetic address is validated on the same path as
// a supplied one so neither can smuggle a control character.
let email = match &user.email {
Some(supplied) => supplied.clone(),
None => synthetic_email(name),
};
reject_control_chars("email", &email)?;
writeln!(out, " email: {}", quote(&email))?;
if let Some(email) = &user.email {
reject_control_chars("email", email)?;
writeln!(out, " email: {}", quote(email))?;
}
if !user.groups.is_empty() {
writeln!(out, " groups:")?;
for group in &user.groups {
@ -361,61 +325,21 @@ mod tests {
}
#[test]
fn an_empty_group_list_is_omitted_rather_than_emitted_empty() {
fn optional_fields_are_omitted_rather_than_emitted_empty() {
let mut store = UserStore::default();
store.users.insert("mara".to_owned(), user("$argon2id$x"));
let out = render_yaml(&store).expect("renders");
assert!(
!out.contains("email"),
"absent email must not appear:\n{out}"
);
assert!(
!out.contains("groups"),
"an empty group list must not appear:\n{out}"
);
}
/// Email is deliberately NOT in the test above any more. It used to
/// assert that an absent one is omitted, which pinned the behaviour that
/// broke grafana's login: authelia serves no `email` claim, and a relying
/// party that wants one fails rather than degrading.
#[test]
fn a_user_with_no_email_still_renders_one_from_the_shared_domain() {
let mut store = UserStore::default();
store.users.insert("mara".to_owned(), user("$argon2id$x"));
let out = render_yaml(&store).expect("renders");
assert!(
out.contains(r#"email: "mara@hyperhive.local""#),
"a user with no email must still render one:\n{out}"
);
}
#[test]
fn a_supplied_email_is_never_replaced_by_the_synthetic_one() {
let mut u = user("$argon2id$x");
u.email = Some("real@elsewhere.example".to_owned());
let mut store = UserStore::default();
store.users.insert("mara".to_owned(), u);
let out = render_yaml(&store).expect("renders");
assert!(
out.contains(r#"email: "real@elsewhere.example""#),
"the supplied address must win:\n{out}"
);
assert!(
!out.contains("mara@hyperhive.local"),
"the synthetic address must not also appear:\n{out}"
);
}
/// The synthetic address goes through the same validation as a supplied
/// one. A username is already constrained to `[A-Za-z0-9._-]`, so this
/// cannot currently fail — which is exactly why it is worth pinning: the
/// day username rules loosen, the renderer must still refuse rather than
/// quietly emit whatever it built.
#[test]
fn the_synthetic_address_is_built_from_the_username_and_domain() {
assert_eq!(synthetic_email("mara"), "mara@hyperhive.local");
}
#[test]
fn groups_render_as_a_block_sequence() {
let mut u = user("$argon2id$x");