Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e71e4ef432 | ||
|
|
62d1c6224b | ||
|
|
ca84079a21 | ||
|
|
f60aab4717 | ||
|
|
bfe921c254 | ||
|
|
346a6b1b4c | ||
|
|
58588a6866 | ||
|
|
1d9a06482a |
17 changed files with 535 additions and 75 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -4591,6 +4591,7 @@ dependencies = [
|
||||||
"hive-jobq",
|
"hive-jobq",
|
||||||
"hive-jobq-wire",
|
"hive-jobq-wire",
|
||||||
"hive-types",
|
"hive-types",
|
||||||
|
"problem_details",
|
||||||
"reqwest 0.13.1",
|
"reqwest 0.13.1",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|
|
||||||
|
|
@ -318,6 +318,28 @@ refactor's concern. The dashboard frontend parses via
|
||||||
`util.js::epochSec` wherever it needs arithmetic and feeds the string
|
`util.js::epochSec` wherever it needs arithmetic and feeds the string
|
||||||
straight to `new Date(s)` for display.
|
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
|
## Tool groups
|
||||||
|
|
||||||
The MCP tool surface an agent receives is derived from a set of named
|
The MCP tool surface an agent receives is derived from a set of named
|
||||||
|
|
|
||||||
|
|
@ -21,16 +21,19 @@ operator has to place, and where.
|
||||||
### 1 · Forge
|
### 1 · Forge
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Provision (or refresh) ruth's own forge account — do this first
|
# 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.
|
||||||
hivectl forge create-user ruth
|
hivectl forge create-user ruth
|
||||||
|
|
||||||
# Create a human operator account (prints the token to stdout)
|
# Sub-agents spawned later (via the approval flow in step 6) get their
|
||||||
hivectl forge create-user mara --password hunter2
|
# forge accounts auto-provisioned — nothing to run here for them.
|
||||||
|
|
||||||
# Provision forge accounts for any sub-agents spawned later
|
|
||||||
hivectl forge create-user <agent>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The human operator's own forge account is created via swarm SSO instead of
|
||||||
|
a manual `hivectl` step — see step 3 (`swarmctl user add`).
|
||||||
|
|
||||||
### 2 · Gateway (HTTP Basic auth)
|
### 2 · Gateway (HTTP Basic auth)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -89,20 +92,21 @@ control: [`swarm/ui.md`](swarm/ui.md).
|
||||||
# 5a. Ensure the hive-internal admin account exists first
|
# 5a. Ensure the hive-internal admin account exists first
|
||||||
hivectl matrix sync-admin
|
hivectl matrix sync-admin
|
||||||
|
|
||||||
# 5b. Provision ruth's own matrix account
|
# 5b. Provision ruth's own matrix account — same bootstrap-bypass reasoning
|
||||||
|
# as forge above, still a required manual step.
|
||||||
hivectl matrix create-user ruth
|
hivectl matrix create-user ruth
|
||||||
|
|
||||||
# 5c. Create a human matrix account
|
# 5c. Invite the operator to the hive Space (and optionally to rooms)
|
||||||
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
|
||||||
hivectl matrix invite @mara:yourserver --room '#hive-chat:yourserver'
|
hivectl matrix invite @mara:yourserver --room '#hive-chat:yourserver'
|
||||||
|
|
||||||
# 5e. Promote the operator to homeserver admin if needed
|
# 5d. Promote the operator to homeserver admin if needed
|
||||||
hivectl matrix promote-user mara
|
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
|
### 6 · Spawn sub-agents
|
||||||
|
|
||||||
Sub-agent creation goes through the approval queue — ruth proposes, the
|
Sub-agent creation goes through the approval queue — ruth proposes, the
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import { $, esc, fmtAgeSecs, renderServerWarnings } from './common.js';
|
||||||
import { el } from '@hive/shared/dom.js';
|
import { el } from '@hive/shared/dom.js';
|
||||||
import '@hive/shared/hive-tab-strip.js';
|
import '@hive/shared/hive-tab-strip.js';
|
||||||
import { themedConfirm, themedToast } from '@hive/shared/modal.js';
|
import { themedConfirm, themedToast } from '@hive/shared/modal.js';
|
||||||
|
import { readApiError, problemMessage } from '@hive/shared/api-error.js';
|
||||||
|
|
||||||
let agents = [];
|
let agents = [];
|
||||||
// agent name → container running (bool), from /api/state. Cross-referenced by
|
// agent name → container running (bool), from /api/state. Cross-referenced by
|
||||||
|
|
@ -65,28 +66,13 @@ function renderAgentPicker() {
|
||||||
for (const a of agents) sel.append(el('option', { value: a }, a));
|
for (const a of agents) sel.append(el('option', { value: a }, a));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── shape-agnostic error-body parsing (shared by both tabs' submit
|
// Shape-agnostic error-body parsing (shared by both tabs' submit handlers)
|
||||||
// handlers) ───────────────────────────────────────────────────────────────
|
// lives in `@hive/shared/api-error.js` now — `readApiError` +
|
||||||
// The BE error-body shape is in transition: today hive-c0re's
|
// `problemMessage` (this page only needs the one-line message, not the
|
||||||
// error_response sends a bare plain-text body; the RFC 9457 rework moves it
|
// full `ApiErrorPanel`; its result lines are single-line `aria-live`
|
||||||
// to application/problem+json ({ type, title, detail, … }). Read shape-
|
// regions, not a swap-in-a-panel context). Was a local function here
|
||||||
// agnostically: pull the body once as text, and if it parses as JSON
|
// originally; promoted so swarm-ui shares the same
|
||||||
// surface `detail` (problem+json) → `error`/`title` fallback, else use the
|
// shape-agnostic reader instead of each side maintaining its own copy.
|
||||||
// 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 ────────────────────────────────────────────────────────────
|
// ─── MATRIX tab ────────────────────────────────────────────────────────────
|
||||||
// Live status dot — the daemon heartbeats every ~30s (advances as_of_unix),
|
// Live status dot — the daemon heartbeats every ~30s (advances as_of_unix),
|
||||||
|
|
@ -253,7 +239,7 @@ async function submitLogin(e) {
|
||||||
clearSecrets(formEl);
|
clearSecrets(formEl);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const msg = await readErrorBody(resp);
|
const msg = problemMessage(await readApiError(resp));
|
||||||
out.className = 'ma-result err';
|
out.className = 'ma-result err';
|
||||||
out.textContent = '✗ ' + (msg || ('login failed (HTTP ' + resp.status + ')'));
|
out.textContent = '✗ ' + (msg || ('login failed (HTTP ' + resp.status + ')'));
|
||||||
clearSecrets(formEl);
|
clearSecrets(formEl);
|
||||||
|
|
@ -337,7 +323,7 @@ async function submitGithub(e) {
|
||||||
clearSecrets(formEl);
|
clearSecrets(formEl);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const msg = await readErrorBody(resp);
|
const msg = problemMessage(await readApiError(resp));
|
||||||
out.className = 'ma-result err';
|
out.className = 'ma-result err';
|
||||||
out.textContent = '✗ ' + (msg || ('store failed (HTTP ' + resp.status + ')'));
|
out.textContent = '✗ ' + (msg || ('store failed (HTTP ' + resp.status + ')'));
|
||||||
clearSecrets(formEl);
|
clearSecrets(formEl);
|
||||||
|
|
@ -419,7 +405,7 @@ async function onForgeRemoveClick(agent, forge, btn) {
|
||||||
loadForgeAccounts(agent);
|
loadForgeAccounts(agent);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const msg = await readErrorBody(resp);
|
const msg = problemMessage(await readApiError(resp));
|
||||||
btn.textContent = orig;
|
btn.textContent = orig;
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
themedToast('✗ ' + (msg || ('remove failed (HTTP ' + resp.status + ')')), { type: 'error' });
|
themedToast('✗ ' + (msg || ('remove failed (HTTP ' + resp.status + ')')), { type: 'error' });
|
||||||
|
|
@ -474,7 +460,7 @@ async function submitForgeAccount(e) {
|
||||||
clearSecrets(formEl);
|
clearSecrets(formEl);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const msg = await readErrorBody(resp);
|
const msg = problemMessage(await readApiError(resp));
|
||||||
out.className = 'ma-result err';
|
out.className = 'ma-result err';
|
||||||
out.textContent = '✗ ' + (msg || ('store failed (HTTP ' + resp.status + ')'));
|
out.textContent = '✗ ' + (msg || ('store failed (HTTP ' + resp.status + ')'));
|
||||||
clearSecrets(formEl);
|
clearSecrets(formEl);
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,12 @@
|
||||||
"./jobq-graph.js": "./src/jobq-graph/JobqGraph.tsx",
|
"./jobq-graph.js": "./src/jobq-graph/JobqGraph.tsx",
|
||||||
"./jobq-graph.css": "./src/jobq-graph/jobq-graph.css",
|
"./jobq-graph.css": "./src/jobq-graph/jobq-graph.css",
|
||||||
"./jobq-rollup.js": "./src/jobq-rollup/JobqRollup.tsx",
|
"./jobq-rollup.js": "./src/jobq-rollup/JobqRollup.tsx",
|
||||||
"./jobq-rollup.css": "./src/jobq-rollup/jobq-rollup.css"
|
"./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"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"src/"
|
"src/"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
// 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
63
frontend/packages/shared/src/api-error.ts
Normal file
63
frontend/packages/shared/src/api-error.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
// 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');
|
||||||
|
}
|
||||||
36
frontend/packages/shared/src/warn-banner/WarnBanner.css
Normal file
36
frontend/packages/shared/src/warn-banner/WarnBanner.css
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
38
frontend/packages/shared/src/warn-banner/WarnBanner.tsx
Normal file
38
frontend/packages/shared/src/warn-banner/WarnBanner.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
// 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -49,6 +49,3 @@
|
||||||
.create-agent-result-ok {
|
.create-agent-result-ok {
|
||||||
color: var(--green);
|
color: var(--green);
|
||||||
}
|
}
|
||||||
.create-agent-result-error {
|
|
||||||
color: var(--red);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@
|
||||||
// comment states.
|
// comment states.
|
||||||
import { useState } from 'preact/hooks';
|
import { useState } from 'preact/hooks';
|
||||||
import { Link } from 'wouter-preact';
|
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 { Panel } from '../ui/panel/Panel.js';
|
||||||
import './CreateAgentPage.css';
|
import './CreateAgentPage.css';
|
||||||
|
|
||||||
|
|
@ -34,13 +36,22 @@ type SubmitState =
|
||||||
| { status: 'idle' }
|
| { status: 'idle' }
|
||||||
| { status: 'submitting' }
|
| { status: 'submitting' }
|
||||||
| { status: 'done'; nodeId: number }
|
| { status: 'done'; nodeId: number }
|
||||||
| { status: 'error'; message: string };
|
| { status: 'error'; problem: ProblemDetails };
|
||||||
|
|
||||||
// Client-side mirror of `hive_types::Ident` (1-63 chars of `[a-z0-9-]`) —
|
// 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
|
// a `pattern` hint only, not a substitute for the server's own
|
||||||
// validation; a name this rejects would fail server-side anyway, so
|
// 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.
|
// catching it before a round-trip is a pure UX win, not a new gate.
|
||||||
const NAME_PATTERN = '[a-z0-9-]{1,63}';
|
//
|
||||||
|
// 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}';
|
||||||
|
|
||||||
export function CreateAgentPage() {
|
export function CreateAgentPage() {
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
|
|
@ -55,12 +66,15 @@ export function CreateAgentPage() {
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({ name }),
|
body: JSON.stringify({ name }),
|
||||||
});
|
});
|
||||||
if (!r.ok) throw new Error((await r.text()) || `http ${r.status}`);
|
if (!r.ok) {
|
||||||
|
setResult({ status: 'error', problem: await readApiError(r) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const data = (await r.json()) as CreateAgentResponse;
|
const data = (await r.json()) as CreateAgentResponse;
|
||||||
setResult({ status: 'done', nodeId: data.node_id });
|
setResult({ status: 'done', nodeId: data.node_id });
|
||||||
setName('');
|
setName('');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setResult({ status: 'error', message: String(err) });
|
setResult({ status: 'error', problem: { detail: String(err) } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -96,9 +110,7 @@ export function CreateAgentPage() {
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{result.status === 'error' && (
|
{result.status === 'error' && (
|
||||||
<p class="create-agent-result create-agent-result-error">
|
<ApiErrorPanel context="failed to queue" problem={result.problem} />
|
||||||
failed to queue: {result.message}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@
|
||||||
// than living in `App.tsx`, matching `JobsPage`'s shape: `App.tsx` is
|
// than living in `App.tsx`, matching `JobsPage`'s shape: `App.tsx` is
|
||||||
// routing, a page owns its own fetch + render.
|
// routing, a page owns its own fetch + render.
|
||||||
import { useEffect, useState } from 'preact/hooks';
|
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 { Panel } from '../ui/panel/Panel.js';
|
||||||
import { StatusChip, type ChipTone } from '../ui/status-chip/StatusChip.js';
|
import { StatusChip, type ChipTone } from '../ui/status-chip/StatusChip.js';
|
||||||
import { Table, type TableColumn } from '../ui/table/Table.js';
|
import { Table, type TableColumn } from '../ui/table/Table.js';
|
||||||
|
|
@ -60,21 +62,22 @@ const COLUMNS: TableColumn<HiveStatus>[] = [
|
||||||
|
|
||||||
export function OverviewPage() {
|
export function OverviewPage() {
|
||||||
const [hives, setHives] = useState<HiveStatus[] | null>(null);
|
const [hives, setHives] = useState<HiveStatus[] | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<ProblemDetails | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/hives/status')
|
(async () => {
|
||||||
.then((r) => {
|
const r = await fetch('/api/hives/status');
|
||||||
if (!r.ok) throw new Error(`http ${r.status}`);
|
if (!r.ok) {
|
||||||
return r.json() as Promise<HiveStatus[]>;
|
setError(await readApiError(r));
|
||||||
})
|
return;
|
||||||
.then(setHives)
|
}
|
||||||
.catch((e: unknown) => setError(String(e)));
|
setHives((await r.json()) as HiveStatus[]);
|
||||||
|
})().catch((e: unknown) => setError({ detail: String(e) }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel title="overview">
|
<Panel title="overview">
|
||||||
{error ? <p>failed to load the hive roster: {error}</p> : null}
|
{error ? <ApiErrorPanel context="failed to load the hive roster" problem={error} /> : null}
|
||||||
{!error && hives === null ? <p>loading…</p> : null}
|
{!error && hives === null ? <p>loading…</p> : null}
|
||||||
{hives ? <Table columns={COLUMNS} rows={hives} rowKey={(h) => h.name} /> : null}
|
{hives ? <Table columns={COLUMNS} rows={hives} rowKey={(h) => h.name} /> : null}
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
|
||||||
|
|
@ -174,6 +174,30 @@ 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) {
|
config = lib.mkIf (hyperhiveCfg.enable && cfg.enable) {
|
||||||
|
|
@ -437,6 +461,25 @@ in
|
||||||
enable = true;
|
enable = true;
|
||||||
package = cfg.package;
|
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 = {
|
settings = {
|
||||||
server = {
|
server = {
|
||||||
# Already upstream's default (measured), but pinned rather
|
# Already upstream's default (measured), but pinned rather
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,10 @@ forgejo-api.workspace = true
|
||||||
# raw bytes.
|
# raw bytes.
|
||||||
base64.workspace = true
|
base64.workspace = true
|
||||||
futures-util.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
|
# The graph itself, held directly rather than behind a c0re-style wrapper
|
||||||
# module — that layering (`hive-c0re::job_queue`) is partially legacy (predates
|
# 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
|
# `hive-jobq`'s extraction into its own crate) and this daemon does not need it
|
||||||
|
|
|
||||||
|
|
@ -428,14 +428,29 @@ async fn get_links(State(state): State<AppState>) -> Json<Vec<ServiceLink>> {
|
||||||
/// body because a bare 503 on an operator-facing diagnostic is how a
|
/// body because a bare 503 on an operator-facing diagnostic is how a
|
||||||
/// misconfiguration costs an afternoon; it is a queue/JetStream error
|
/// misconfiguration costs an afternoon; it is a queue/JetStream error
|
||||||
/// string, and this surface is already behind the swarm's SSO.
|
/// 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);
|
struct StatusUnavailable(String);
|
||||||
|
|
||||||
impl axum::response::IntoResponse for StatusUnavailable {
|
impl axum::response::IntoResponse for StatusUnavailable {
|
||||||
fn into_response(self) -> axum::response::Response {
|
fn into_response(self) -> axum::response::Response {
|
||||||
(axum::http::StatusCode::SERVICE_UNAVAILABLE, self.0).into_response()
|
error_problem(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
|
/// What each hive last said about itself, read from the swarm queue at
|
||||||
/// request time.
|
/// request time.
|
||||||
///
|
///
|
||||||
|
|
@ -522,17 +537,17 @@ struct CreateAgentResponse {
|
||||||
request_body = CreateAgentRequest,
|
request_body = CreateAgentRequest,
|
||||||
responses(
|
responses(
|
||||||
(status = 200, description = "job chain queued", body = CreateAgentResponse),
|
(status = 200, description = "job chain queued", body = CreateAgentResponse),
|
||||||
(status = 400, description = "`name` is not a valid identifier", body = String),
|
(status = 400, description = "`name` is not a valid identifier (problem+json)", body = String),
|
||||||
(status = 500, description = "the job chain could not be queued", body = String),
|
(status = 500, description = "the job chain could not be queued (problem+json)", body = String),
|
||||||
),
|
),
|
||||||
tag = "agents"
|
tag = "agents"
|
||||||
)]
|
)]
|
||||||
async fn create_agent(
|
async fn create_agent(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(req): Json<CreateAgentRequest>,
|
Json(req): Json<CreateAgentRequest>,
|
||||||
) -> Result<Json<CreateAgentResponse>, (axum::http::StatusCode, String)> {
|
) -> Result<Json<CreateAgentResponse>, problem_details::ProblemDetails> {
|
||||||
let agent = hive_types::Ident::parse(&req.name)
|
let agent = hive_types::Ident::parse(&req.name)
|
||||||
.map_err(|reason| (axum::http::StatusCode::BAD_REQUEST, reason.to_owned()))?
|
.map_err(|reason| error_problem(axum::http::StatusCode::BAD_REQUEST, reason))?
|
||||||
.into_string();
|
.into_string();
|
||||||
let repo = agent.clone();
|
let repo = agent.clone();
|
||||||
|
|
||||||
|
|
@ -562,7 +577,12 @@ async fn create_agent(
|
||||||
.after_ok(create_repo);
|
.after_ok(create_repo);
|
||||||
vec![create_identity.guid()]
|
vec![create_identity.guid()]
|
||||||
})
|
})
|
||||||
.map_err(|e| (axum::http::StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| {
|
||||||
|
error_problem(
|
||||||
|
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
&e.to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
let [id] = ids[..] else {
|
let [id] = ids[..] else {
|
||||||
unreachable!("exactly one handle was asked for");
|
unreachable!("exactly one handle was asked for");
|
||||||
};
|
};
|
||||||
|
|
@ -770,11 +790,46 @@ async fn main() -> Result<()> {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
DEFAULT_SOCKET, HIVES_ENV, HiveEntry, LINKS_ENV, ServiceLink, SwarmNodeKind, WorkerDeps,
|
DEFAULT_SOCKET, HIVES_ENV, HiveEntry, LINKS_ENV, ServiceLink, StatusUnavailable,
|
||||||
load_hives, load_links, run_swarm_node,
|
SwarmNodeKind, WorkerDeps, load_hives, load_links, run_swarm_node,
|
||||||
};
|
};
|
||||||
use std::path::Path;
|
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
|
/// Drives `SwarmNodeKind::CreateRepo` through the real
|
||||||
/// `hive_jobq::scheduler::Scheduler` claim → run → complete path,
|
/// `hive_jobq::scheduler::Scheduler` claim → run → complete path,
|
||||||
/// rather than only through `create_agent`'s endpoint test (there
|
/// rather than only through `create_agent`'s endpoint test (there
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,37 @@ fn quote(s: &str) -> String {
|
||||||
out
|
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.
|
/// Render the store as authelia's block-style YAML users database.
|
||||||
///
|
///
|
||||||
/// Fallible because it re-runs validation over every value it is about to
|
/// Fallible because it re-runs validation over every value it is about to
|
||||||
|
|
@ -142,10 +173,15 @@ pub fn render_yaml(store: &UserStore) -> Result<String> {
|
||||||
writeln!(out, " {name}:")?;
|
writeln!(out, " {name}:")?;
|
||||||
writeln!(out, " displayname: {}", quote(&user.displayname))?;
|
writeln!(out, " displayname: {}", quote(&user.displayname))?;
|
||||||
writeln!(out, " password: {}", quote(&user.password))?;
|
writeln!(out, " password: {}", quote(&user.password))?;
|
||||||
if let Some(email) = &user.email {
|
// Unconditional: an absent email is the failure mode, not a valid
|
||||||
reject_control_chars("email", email)?;
|
// rendering. The synthetic address is validated on the same path as
|
||||||
writeln!(out, " email: {}", quote(email))?;
|
// 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 !user.groups.is_empty() {
|
if !user.groups.is_empty() {
|
||||||
writeln!(out, " groups:")?;
|
writeln!(out, " groups:")?;
|
||||||
for group in &user.groups {
|
for group in &user.groups {
|
||||||
|
|
@ -325,21 +361,61 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn optional_fields_are_omitted_rather_than_emitted_empty() {
|
fn an_empty_group_list_is_omitted_rather_than_emitted_empty() {
|
||||||
let mut store = UserStore::default();
|
let mut store = UserStore::default();
|
||||||
store.users.insert("mara".to_owned(), user("$argon2id$x"));
|
store.users.insert("mara".to_owned(), user("$argon2id$x"));
|
||||||
|
|
||||||
let out = render_yaml(&store).expect("renders");
|
let out = render_yaml(&store).expect("renders");
|
||||||
assert!(
|
|
||||||
!out.contains("email"),
|
|
||||||
"absent email must not appear:\n{out}"
|
|
||||||
);
|
|
||||||
assert!(
|
assert!(
|
||||||
!out.contains("groups"),
|
!out.contains("groups"),
|
||||||
"an empty group list must not appear:\n{out}"
|
"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]
|
#[test]
|
||||||
fn groups_render_as_a_block_sequence() {
|
fn groups_render_as_a_block_sequence() {
|
||||||
let mut u = user("$argon2id$x");
|
let mut u = user("$argon2id$x");
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue