Compare commits
3 changed files with 12 additions and 96 deletions
|
|
@ -45,15 +45,8 @@ import { createTabStrip } from '@hive/shared/tabs.js';
|
||||||
return 'fetched ' + (ageSecs < 5 ? 'just now' : fmtAgeSecs(ageSecs) + ' ago');
|
return 'fetched ' + (ageSecs < 5 ? 'just now' : fmtAgeSecs(ageSecs) + ' ago');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Names from `state.infra_containers` (hive-ci / hive-forge / hive-gateway
|
// Populate agent selector from /api/state, then honour any `?agent=` /
|
||||||
// / hive-matrix) — kept in sync with the fetched list so fetchAgent() can
|
// `?unit=` URL params (used by the per-agent ⋮ menu's deep-link).
|
||||||
// tell an infra container apart from an agent and skip the (inapplicable)
|
|
||||||
// unit filter for it.
|
|
||||||
let infraContainerNames = new Set();
|
|
||||||
|
|
||||||
// Populate agent selector from /api/state (agents, then the four infra
|
|
||||||
// containers in their own optgroup), then honour any `?agent=` / `?unit=`
|
|
||||||
// URL params (used by the per-agent ⋮ menu's deep-link).
|
|
||||||
async function loadAgentList() {
|
async function loadAgentList() {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/state');
|
const resp = await fetch('/api/state');
|
||||||
|
|
@ -65,14 +58,6 @@ import { createTabStrip } from '@hive/shared/tabs.js';
|
||||||
for (const c of (state.containers || [])) {
|
for (const c of (state.containers || [])) {
|
||||||
agentSelect.append(el('option', { value: c.name }, c.name));
|
agentSelect.append(el('option', { value: c.name }, c.name));
|
||||||
}
|
}
|
||||||
infraContainerNames = new Set((state.infra_containers || []).map((c) => c.name));
|
|
||||||
if (infraContainerNames.size) {
|
|
||||||
const infraGroup = el('optgroup', { label: 'infra' });
|
|
||||||
for (const name of infraContainerNames) {
|
|
||||||
infraGroup.append(el('option', { value: name }, name));
|
|
||||||
}
|
|
||||||
agentSelect.append(infraGroup);
|
|
||||||
}
|
|
||||||
// Deep-link: honour ?agent= and ?unit= URL params.
|
// Deep-link: honour ?agent= and ?unit= URL params.
|
||||||
const urlAgent = new URLSearchParams(location.search).get('agent');
|
const urlAgent = new URLSearchParams(location.search).get('agent');
|
||||||
const urlUnit = new URLSearchParams(location.search).get('unit');
|
const urlUnit = new URLSearchParams(location.search).get('unit');
|
||||||
|
|
@ -85,24 +70,12 @@ import { createTabStrip } from '@hive/shared/tabs.js';
|
||||||
.some((o) => o.value === urlUnit);
|
.some((o) => o.value === urlUnit);
|
||||||
if (unitFound) agentUnitSelect.value = urlUnit;
|
if (unitFound) agentUnitSelect.value = urlUnit;
|
||||||
}
|
}
|
||||||
syncUnitSelectForSelection();
|
|
||||||
fetchAgent();
|
fetchAgent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch { /**/ }
|
} catch { /**/ }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Infra containers don't run the per-agent hive daemons, so the unit
|
|
||||||
// filter is meaningless for them — disable the selector and always fetch
|
|
||||||
// the full machine journal to avoid a picked unit silently doing nothing.
|
|
||||||
function syncUnitSelectForSelection() {
|
|
||||||
if (!agentUnitSelect) return;
|
|
||||||
const isInfra = infraContainerNames.has(agentSelect ? agentSelect.value : '');
|
|
||||||
agentUnitSelect.disabled = isInfra;
|
|
||||||
if (isInfra) agentUnitSelect.value = '';
|
|
||||||
}
|
|
||||||
if (agentSelect) agentSelect.addEventListener('change', syncUnitSelectForSelection);
|
|
||||||
|
|
||||||
let agentFetching = false;
|
let agentFetching = false;
|
||||||
let agentLastFetch = 0;
|
let agentLastFetch = 0;
|
||||||
async function fetchAgent() {
|
async function fetchAgent() {
|
||||||
|
|
@ -113,8 +86,7 @@ import { createTabStrip } from '@hive/shared/tabs.js';
|
||||||
agentFetching = true;
|
agentFetching = true;
|
||||||
agentOutput.textContent = 'fetching…';
|
agentOutput.textContent = 'fetching…';
|
||||||
if (agentFetchTs) agentFetchTs.hidden = true;
|
if (agentFetchTs) agentFetchTs.hidden = true;
|
||||||
const isInfra = infraContainerNames.has(name);
|
const unit = agentUnitSelect ? agentUnitSelect.value : '';
|
||||||
const unit = (!isInfra && agentUnitSelect) ? agentUnitSelect.value : '';
|
|
||||||
const params = new URLSearchParams({ lines: '500' });
|
const params = new URLSearchParams({ lines: '500' });
|
||||||
if (unit) params.set('unit', unit);
|
if (unit) params.set('unit', unit);
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,10 @@
|
||||||
//! Journal-read endpoints for the dashboard.
|
//! Journal-read endpoints for the dashboard.
|
||||||
//!
|
//!
|
||||||
//! `GET /api/journal/{name}` reads a managed agent container's journal, OR
|
//! `GET /api/journal/{name}` reads a managed container's journal via the
|
||||||
//! one of the four hive infra containers (`hive-ci`, `hive-forge`,
|
//! root helper (`journalctl -M`, delegated to hive-priv since hive-c0re is
|
||||||
//! `hive-gateway`, `hive-matrix` — [`hive_priv_sock::InfraContainer`] is the
|
//! unprivileged). `GET /api/journal-host` reads host-side journald, both
|
||||||
//! allowlist), via the root helper (`journalctl -M`, delegated to hive-priv
|
//! gated by an allow-list of known units so arbitrary unit names can't be
|
||||||
//! since hive-c0re is unprivileged). `GET /api/journal-host` reads
|
//! probed. Operator-only by virtue of the dashboard binding host-only.
|
||||||
//! host-side journald, both gated by an allow-list of known units so
|
|
||||||
//! arbitrary unit names can't be probed. Operator-only by virtue of the
|
|
||||||
//! dashboard binding host-only.
|
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::Path as AxumPath,
|
extract::Path as AxumPath,
|
||||||
|
|
@ -36,23 +33,10 @@ pub(super) struct JournalQuery {
|
||||||
/// Operator-only by virtue of the dashboard being host-bound. hive-c0re
|
/// Operator-only by virtue of the dashboard being host-bound. hive-c0re
|
||||||
/// runs unprivileged (privsep), so the `-M` read — which enters the
|
/// runs unprivileged (privsep), so the `-M` read — which enters the
|
||||||
/// container namespace and needs root — is delegated to hive-priv.
|
/// container namespace and needs root — is delegated to hive-priv.
|
||||||
///
|
|
||||||
/// `name` is either a managed agent name (`iris`, optionally already
|
|
||||||
/// carrying the `h-` prefix) or one of the four infra container names
|
|
||||||
/// (`hive-ci` / `hive-forge` / `hive-gateway` / `hive-matrix` — see
|
|
||||||
/// [`hive_priv_sock::InfraContainer`]). Infra containers don't run the
|
|
||||||
/// per-agent hive daemons, so `unit` is ignored for them — always the
|
|
||||||
/// full machine journal.
|
|
||||||
pub(super) async fn get_journal(
|
pub(super) async fn get_journal(
|
||||||
AxumPath(name): AxumPath<String>,
|
AxumPath(name): AxumPath<String>,
|
||||||
axum::extract::Query(q): axum::extract::Query<JournalQuery>,
|
axum::extract::Query(q): axum::extract::Query<JournalQuery>,
|
||||||
) -> Result<Response, ProblemDetails> {
|
) -> Result<Response, ProblemDetails> {
|
||||||
let lines = q.lines.unwrap_or(500).min(5000);
|
|
||||||
|
|
||||||
if let Ok(infra) = name.parse::<hive_priv_sock::InfraContainer>() {
|
|
||||||
return read_journal_response(infra.unit_name(), None, lines).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Defense-in-depth format check so weird chars never reach the
|
// Defense-in-depth format check so weird chars never reach the
|
||||||
// shellout below — the `lifecycle::list()` existence check would
|
// shellout below — the `lifecycle::list()` existence check would
|
||||||
// catch them anyway, but rejecting at the boundary keeps the
|
// catch them anyway, but rejecting at the boundary keeps the
|
||||||
|
|
@ -70,6 +54,7 @@ pub(super) async fn get_journal(
|
||||||
return Err(ProblemDetails::from_status_code(StatusCode::NOT_FOUND)
|
return Err(ProblemDetails::from_status_code(StatusCode::NOT_FOUND)
|
||||||
.with_detail(format!("journal: no managed container {prefixed:?}")));
|
.with_detail(format!("journal: no managed container {prefixed:?}")));
|
||||||
}
|
}
|
||||||
|
let lines = q.lines.unwrap_or(500).min(5000);
|
||||||
let unit = match q.unit.as_deref().filter(|s| !s.is_empty()) {
|
let unit = match q.unit.as_deref().filter(|s| !s.is_empty()) {
|
||||||
Some(u) => {
|
Some(u) => {
|
||||||
// accept any of the per-container hive daemons [.service] —
|
// accept any of the per-container hive daemons [.service] —
|
||||||
|
|
@ -93,21 +78,8 @@ pub(super) async fn get_journal(
|
||||||
}
|
}
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
read_journal_response(&prefixed, unit, lines).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Shared `journalctl -M <machine> [-u <unit>]` shellout + response
|
|
||||||
/// formatting for [`get_journal`], factored out so the infra-container
|
|
||||||
/// branch (no `unit` filtering) and the agent-container branch (allow-listed
|
|
||||||
/// `unit` filtering) don't duplicate the priv-client call + stdout/stderr
|
|
||||||
/// combining.
|
|
||||||
async fn read_journal_response(
|
|
||||||
machine: &str,
|
|
||||||
unit: Option<String>,
|
|
||||||
lines: u32,
|
|
||||||
) -> Result<Response, ProblemDetails> {
|
|
||||||
match crate::priv_client::read_container_journal(
|
match crate::priv_client::read_container_journal(
|
||||||
machine,
|
&prefixed,
|
||||||
hive_priv_sock::JournalQuery {
|
hive_priv_sock::JournalQuery {
|
||||||
lines,
|
lines,
|
||||||
boot: true,
|
boot: true,
|
||||||
|
|
|
||||||
|
|
@ -169,15 +169,9 @@ pub(crate) fn api(token: &str) -> Result<Forgejo> {
|
||||||
///
|
///
|
||||||
/// Called by both `ensure_all()` (startup sweep) and `rebuild_agent`
|
/// Called by both `ensure_all()` (startup sweep) and `rebuild_agent`
|
||||||
/// (per-rebuild) so the two paths stay equivalent.
|
/// (per-rebuild) so the two paths stay equivalent.
|
||||||
/// Returns `true` if all steps succeeded, `false` if any step failed. The
|
pub async fn sync_agent(name: &str, core_token: Option<&str>) {
|
||||||
/// caller can use the return value to aggregate per-agent failures into a
|
|
||||||
/// dashboard warning (see [`ensure_all`]); the rebuild path ignores it and
|
|
||||||
/// relies on the journal `warn!` lines alone (a rebuild is its own retry).
|
|
||||||
pub async fn sync_agent(name: &str, core_token: Option<&str>) -> bool {
|
|
||||||
let mut ok = true;
|
|
||||||
if let Err(e) = ensure_user_for(name).await {
|
if let Err(e) = ensure_user_for(name).await {
|
||||||
tracing::warn!(%name, error = ?e, "forge: ensure_user failed");
|
tracing::warn!(%name, error = ?e, "forge: ensure_user failed");
|
||||||
ok = false;
|
|
||||||
}
|
}
|
||||||
// Align email to match the git user.email set by meta::render_flake
|
// Align email to match the git user.email set by meta::render_flake
|
||||||
// so commits link to the agent's Forgejo profile. Best-effort;
|
// so commits link to the agent's Forgejo profile. Best-effort;
|
||||||
|
|
@ -194,11 +188,9 @@ pub async fn sync_agent(name: &str, core_token: Option<&str>) -> bool {
|
||||||
// was down.
|
// was down.
|
||||||
if let Err(e) = ensure_config_repo(name).await {
|
if let Err(e) = ensure_config_repo(name).await {
|
||||||
tracing::warn!(%name, error = ?e, "forge: ensure_config_repo failed");
|
tracing::warn!(%name, error = ?e, "forge: ensure_config_repo failed");
|
||||||
ok = false;
|
|
||||||
}
|
}
|
||||||
if let Err(e) = push_config(name).await {
|
if let Err(e) = push_config(name).await {
|
||||||
tracing::warn!(%name, error = ?e, "forge: push_config failed");
|
tracing::warn!(%name, error = ?e, "forge: push_config failed");
|
||||||
ok = false;
|
|
||||||
}
|
}
|
||||||
// Grant read-only access to core/meta and wire the `meta` remote
|
// Grant read-only access to core/meta and wire the `meta` remote
|
||||||
// into the proposed repo so agents can fetch their deployment context.
|
// into the proposed repo so agents can fetch their deployment context.
|
||||||
|
|
@ -206,11 +198,9 @@ pub async fn sync_agent(name: &str, core_token: Option<&str>) -> bool {
|
||||||
&& let Err(e) = meta_read_access(name, token).await
|
&& let Err(e) = meta_read_access(name, token).await
|
||||||
{
|
{
|
||||||
tracing::warn!(%name, error = ?e, "forge: ensure_meta_read_access failed");
|
tracing::warn!(%name, error = ?e, "forge: ensure_meta_read_access failed");
|
||||||
ok = false;
|
|
||||||
}
|
}
|
||||||
if let Err(e) = ensure_meta_remote(name).await {
|
if let Err(e) = ensure_meta_remote(name).await {
|
||||||
tracing::warn!(%name, error = ?e, "forge: ensure_meta_remote failed");
|
tracing::warn!(%name, error = ?e, "forge: ensure_meta_remote failed");
|
||||||
ok = false;
|
|
||||||
}
|
}
|
||||||
// Grant read-only access to internal/docs so the agent can clone
|
// Grant read-only access to internal/docs so the agent can clone
|
||||||
// the operator-curated shared skills/runbook repo. Best-effort.
|
// the operator-curated shared skills/runbook repo. Best-effort.
|
||||||
|
|
@ -218,10 +208,8 @@ pub async fn sync_agent(name: &str, core_token: Option<&str>) -> bool {
|
||||||
&& let Err(e) = shared_docs_access(name, token).await
|
&& let Err(e) = shared_docs_access(name, token).await
|
||||||
{
|
{
|
||||||
tracing::warn!(%name, error = ?e, "forge: shared_docs_access failed");
|
tracing::warn!(%name, error = ?e, "forge: shared_docs_access failed");
|
||||||
ok = false;
|
|
||||||
}
|
}
|
||||||
// internal/knowledge is public — no per-agent collaborator grant needed.
|
// internal/knowledge is public — no per-agent collaborator grant needed.
|
||||||
ok
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The `core_token.is_some()` half of [`ensure_all`]: orgs, teams, the meta
|
/// The `core_token.is_some()` half of [`ensure_all`]: orgs, teams, the meta
|
||||||
|
|
@ -358,27 +346,11 @@ pub async fn ensure_all() {
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let mut sync_failed: Vec<String> = Vec::new();
|
|
||||||
for c in containers {
|
for c in containers {
|
||||||
let Some(name) = c.strip_prefix(crate::lifecycle::AGENT_PREFIX) else {
|
let Some(name) = c.strip_prefix(crate::lifecycle::AGENT_PREFIX) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if !sync_agent(name, core_token.as_deref()).await {
|
sync_agent(name, core_token.as_deref()).await;
|
||||||
sync_failed.push(name.to_owned());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !sync_failed.is_empty() {
|
|
||||||
// Use static_kind to mint a `&'static str` key from the failed-agent
|
|
||||||
// list (bounded leak: one per hive-c0re boot, not per request).
|
|
||||||
let key = static_kind(format!("forge_sync_agent_{}", sync_failed.join("_")));
|
|
||||||
crate::warnings::set_boot_warning(
|
|
||||||
key,
|
|
||||||
"warn",
|
|
||||||
format!(
|
|
||||||
"forge: per-agent sync failed for: {} (see journal for per-step detail)",
|
|
||||||
sync_failed.join(", ")
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue