From b6b568e97ecfe212b3a90ab63a5fb59b9804d4b5 Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 25 Oct 2026 16:35:46 +0100 Subject: [PATCH] drop the hive's push of the legacy core/meta repo Nothing reads core/meta: builds come from internal/meta. Remove push_meta, ensure_meta_remote, meta_read_access, ensure_repo, the per-agent forge sync that only served them, and the git commit path in meta.rs; remove_permission_files now just deletes the seed files. --- docs/agent-lifecycle/config-changes.md | 4 +- docs/agent-lifecycle/persistence.md | 17 +- docs/integrations/forge.md | 4 +- docs/web-ui/shape.md | 8 +- frontend/packages/dashboard/src/tabs.js | 2 +- hive-c0re/src/forge/mod.rs | 119 +--------- hive-c0re/src/forge/repos.rs | 240 +------------------- hive-c0re/src/forge/users.rs | 2 +- hive-c0re/src/job_queue/exec.rs | 6 +- hive-c0re/src/lifecycle/git.rs | 2 +- hive-c0re/src/lifecycle/mod.rs | 9 +- hive-c0re/src/meta.rs | 164 ++++--------- hive-c0re/src/migrate.rs | 12 +- hive-c0re/src/paths.rs | 7 - hive-c0re/src/workers/permissions_import.rs | 1 - 15 files changed, 80 insertions(+), 517 deletions(-) diff --git a/docs/agent-lifecycle/config-changes.md b/docs/agent-lifecycle/config-changes.md index d063f4a0..8b96c593 100644 --- a/docs/agent-lifecycle/config-changes.md +++ b/docs/agent-lifecycle/config-changes.md @@ -289,8 +289,8 @@ mounts via `set_nspawn_flags`: manage any agent", so the mounted set is every agent. - `/var/lib/hyperhive/applied/` → `/applied/` (RO) — every agent's authoritative applied repo, including `.git`. -- `/var/lib/hyperhive/meta/` → `/meta/` (RO) — the swarm-wide - deploy flake. +- `/var/lib/hyperhive/meta/` → `/meta/` (RO) — the hive's plain state + (topology, swarm limits). An agent without the permission only has its direct children's config dirs. diff --git a/docs/agent-lifecycle/persistence.md b/docs/agent-lifecycle/persistence.md index b7758ac1..ecb27ad1 100644 --- a/docs/agent-lifecycle/persistence.md +++ b/docs/agent-lifecycle/persistence.md @@ -387,17 +387,12 @@ here from merged config PRs) + any other files those PRs commit. `.git/` carries the proposal / approved / building / deployed / failed / denied tag history. -Under `/var/lib/hyperhive/meta/` — the swarm-wide deploy flake plus -system-level config files. Single git repo for the whole host; hive-c0re -commits every mutation that should survive a restart here. -Contents: +Under `/var/lib/hyperhive/meta/` — system-level config files, plain state +with no git history that anything reads. The flake agents build from is the +swarm's `internal/meta`, not here. Contents: -- `flake.nix` — declares one `nixpkgs` input per agent + one - `nixosConfigurations.` output per agent. `flake.lock` is the - canonical "what's deployed where." The git log is the deploy - audit trail (one commit per successful deploy or hyperhive bump). - `topology.json` — the agent roster (`["alice", "bob", "ruth"]`). - Written by `topology::reconcile` on every meta sync; read by + Written by `topology::reconcile` from `agent_set::refresh`; read by `topology::all_agents`, which is the set the `manage_root_agent` permission grants mounts over. The reader also accepts a map-shaped file carrying a `parent` per agent, and keeps its keys. @@ -419,13 +414,13 @@ Contents: hive-wide only and come straight off `HiveEnv`, so this file has no field for them. - `resource-limits.json` — retired. Per-agent overrides set on the hive used - to live here; nothing reads or writes it now. An old copy in the meta repo + to live here; nothing reads or writes it now. An old copy in this directory is harmless and you can delete it. An agent holding `manage_root_agent`, normally ruth, has the meta dir RO-mounted at `/meta/`. -Outside the meta repo, `/var/lib/hyperhive/swarm-agent-permissions.json` +Outside this directory, `/var/lib/hyperhive/swarm-agent-permissions.json` holds the permissions the swarm grants this hive's agents, as `workers::swarm_permissions` last read them from the swarm's `agent-permissions` bucket (every 30 seconds): a copy, not the diff --git a/docs/integrations/forge.md b/docs/integrations/forge.md index c34e4ae5..82b15d67 100644 --- a/docs/integrations/forge.md +++ b/docs/integrations/forge.md @@ -122,9 +122,7 @@ Two things live in the `agent-configs` Forgejo organization: [config-changes.md § Config changes](../agent-lifecycle/config-changes.md#config-changes)). hive-c0re pushes nothing to this org; it only reads from it. Repos stay private, so an agent can't read another - agent's config. (Agents remain read-only collaborators on the legacy - `core/meta`, the hive's old permission-seed repo.) - The agent's **persistent meta flake input** is this repo's `main` head, + agent's config. The agent's **persistent meta flake input** is this repo's `main` head, pinned by sha in `internal/meta` (`agent-.url = git+/agent-configs/.git?rev=`; see [config-changes.md § Meta flake](../agent-lifecycle/config-changes.md#meta-flake)). diff --git a/docs/web-ui/shape.md b/docs/web-ui/shape.md index a79560c5..bf1f75ae 100644 --- a/docs/web-ui/shape.md +++ b/docs/web-ui/shape.md @@ -233,10 +233,10 @@ enough — wiping and rebuilding still destroys the state even if `JSON.stringify({...visible fields...})`. On each render, the pattern reuses cache-hit rows verbatim (preserving textarea draft, checkbox state, and event listeners) and rebuilds only cache-miss rows. Used for: containers (`containerRowCache`), -rebuild-queue entries (`rebuildQueueRowCache`). The spawn-form input+focus and meta-input -checkboxes use a lighter snapshot-then-restore pattern (snapshot -before `replaceChildren`, restore after) since they're single -values rather than per-row caches. +rebuild-queue entries (`rebuildQueueRowCache`). The spawn-form input+focus +uses a lighter snapshot-then-restore pattern (snapshot +before `replaceChildren`, restore after) since it's a single +value rather than a per-row cache. diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js index db47da95..6bb4a8f1 100644 --- a/frontend/packages/dashboard/src/tabs.js +++ b/frontend/packages/dashboard/src/tabs.js @@ -172,7 +172,7 @@ import { renderUserMenu } from "./user-menu.js"; // broker traffic flows through the SSE channel. The only // /api/state fetches are the initial cold load and the // post-submit refetch on forms without `data-no-refresh` - // (tombstones, meta-input updates). + // (tombstones). if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; diff --git a/hive-c0re/src/forge/mod.rs b/hive-c0re/src/forge/mod.rs index e54f6303..51ded490 100644 --- a/hive-c0re/src/forge/mod.rs +++ b/hive-c0re/src/forge/mod.rs @@ -1,6 +1,5 @@ -//! Optional Forgejo wiring — the legacy `core/meta` repo (the hive's old -//! permission-seed repo, which `meta::remove_permission_files` still commits -//! to) and its read-access grants. Those writes, as `core`, run only beside a local `hive-forge` +//! Optional Forgejo wiring — the `core` admin user and token and its avatar. +//! Those writes, as `core`, run only beside a local `hive-forge` //! container; reads of the forge go through [`reader_token`] on every hive. //! The swarm-wide orgs, teams and repos, and each agent's read grant on //! `internal/docs`, are swarm-controller's. Full design: @@ -15,10 +14,7 @@ mod users; pub(crate) use internal_meta::internal_meta_source; pub use reader::reader_token; pub use reconcile::{fetch_forge_main, reconcile_config_apply, reconcile_config_status}; -pub use repos::{ - clone_config_into_proposed, ensure_meta_remote, ensure_repo, fetch_config_main_into_applied, - meta_read_access, push_meta, -}; +pub use repos::{clone_config_into_proposed, fetch_config_main_into_applied}; pub use users::core_token; use std::future::IntoFuture; @@ -74,13 +70,13 @@ pub(crate) fn validate_env() -> Result<()> { Ok(()) } -/// Git remote for `repo` (e.g. `"core/meta"`) — **credential-free**. +/// Git remote for `repo` (e.g. `"hyperhive/hyperhive"`) — **credential-free**. /// /// The token does not go here. A URL is a process argument, and `argv` is /// world-readable through `/proc//cmdline` for as long as the git child /// lives, so a credentialed remote publishes the core admin token to every /// local user on the host. Credentials travel in the environment instead, via -/// [`core_auth_header`] and [`crate::lifecycle::git_command_authed`] — +/// [`basic_auth_header`] and [`crate::lifecycle::git_command_authed`] — /// `/proc//environ` is owner-only. pub(crate) fn forge_git_url(repo: &str) -> String { git_url_with_base(forge_http_base(), repo) @@ -92,11 +88,6 @@ fn git_url_with_base(base: &str, repo: &str) -> String { format!("{base}/{repo}.git") } -/// The `http.extraHeader` value authenticating as the forge core user. -pub(crate) fn core_auth_header(token: &str) -> String { - basic_auth_header("core", token) -} - /// The `http.extraHeader` value carrying `token`. Forgejo takes the account /// from the token, so `login` only labels the request. /// @@ -120,16 +111,6 @@ fn basic_auth_header(login: &str, token: &str) -> String { /// still can't reach *another* agent's config. hive-c0re only reads from it. pub(crate) const CONFIG_ORG: &str = "agent-configs"; -/// Leak `s` to get a `&'static str` warning `kind` for the small, bounded -/// set of boot warnings in [`ensure_all`] keyed by a runtime name (at -/// most a handful per process). [`crate::warnings::set_boot_warning`] -/// requires a `'static` kind so distinct orgs/repos don't clobber each -/// other's warning entry; leaking a few short strings once per boot is -/// cheap and bounded, unlike a per-request or per-loop-iteration leak. -fn static_kind(s: String) -> &'static str { - Box::leak(s.into_boxed_str()) -} - /// Probe whether `hive-forge` exists as a nixos-container. Cheap — /// `nixos-container list` is just a directory scan in /etc. Routed /// through hive-priv: `nixos-container` needs root, and hive-c0re runs @@ -216,42 +197,7 @@ where .map_err(|_| anyhow::anyhow!("{op}: no answer from the forge within {API_TIMEOUT:?}")) } -/// Per-agent forge sync: grant read access to the legacy `core/meta`, and wire -/// the `meta` remote in its proposed repo. All operations are idempotent; -/// failures are logged as warnings but don't abort the caller. -/// -/// `core_token` is `core_token()` — passed in so callers that already -/// fetched it don't re-read the file. Pass `None` to skip the -/// `meta_read_access` step (safe: the access grant is best-effort). -/// -/// Called by both `ensure_all()` (startup sweep) and `rebuild_agent` -/// (per-rebuild) so the two paths stay equivalent. -/// Returns `true` if all steps succeeded, `false` if any step failed. The -/// 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 { - // The agent's forge account, token and `agent-configs/` repo are - // swarm-controller's; this hive creates none of them and pushes nothing - // to the repo. - let mut ok = true; - // Grant read-only access to the legacy core/meta and wire the `meta` - // remote into the proposed repo. Agents build from `internal/meta`, not - // from this repo. - if let Some(token) = core_token - && let Err(e) = meta_read_access(name, token).await - { - tracing::warn!(%name, error = ?e, "forge: ensure_meta_read_access failed"); - ok = false; - } - if let Err(e) = ensure_meta_remote(name).await { - tracing::warn!(%name, error = ?e, "forge: ensure_meta_remote failed"); - ok = false; - } - ok -} - -/// The `core_token.is_some()` half of [`ensure_all`]: the legacy meta repo, the +/// The `core_token.is_some()` half of [`ensure_all`]: the /// core avatar, and removing this hive's own config-PR hook — /// every step that needs an authenticated forge client. The swarm-wide /// objects (orgs, the `operators` team, mirrors, `internal/docs`, @@ -260,17 +206,6 @@ pub async fn sync_agent(name: &str, core_token: Option<&str>) -> bool { /// `ensure_all` under clippy's function-length limit; not meant to be called /// from anywhere else. async fn ensure_all_orgs_and_repos(token: &str) { - // The legacy meta repo lives at core/meta. The only push to it is - // `meta::remove_permission_files`' commit; make sure the repo exists - // before that push hits a 404. - if let Err(e) = ensure_repo("meta", token).await { - tracing::warn!(error = ?e, "forge: ensure_repo core/meta failed"); - crate::warnings::set_boot_warning( - "forge_ensure_meta_repo", - "crit", - format!("forge: core/meta repo provisioning failed: {e}"), - ); - } if let Err(e) = ensure_core_avatar(token).await { tracing::warn!(error = ?e, "forge: ensure_core_avatar failed"); crate::warnings::set_boot_warning( @@ -382,11 +317,10 @@ async fn wait_until_ready() -> bool { } /// Sweep the forge at startup: seed the `core` admin user (hive-c0re's own -/// identity for pushing the legacy meta repo + driving the API) and its token, the -/// `core/meta` repo and the local knowledge clone, then run [`sync_agent`] -/// over every existing container (manager + sub-agents). Agent accounts, +/// identity for driving the API, and the one nix's git reads as) and its token, +/// then its avatar. Agent accounts, /// tokens and `agent-configs/` repos are the swarm-controller's, as is -/// the `agent-configs` org; this sweep pushes nothing to them. +/// the `agent-configs` org; this sweep touches none of them. /// Called once at hive-c0re startup. Per-step failures are logged /// but don't abort the sweep. pub async fn ensure_all() { @@ -413,37 +347,6 @@ pub async fn ensure_all() { if let Some(token) = core_token.as_deref() { ensure_all_orgs_and_repos(token).await; } - let Ok(containers) = crate::lifecycle::list().await else { - tracing::warn!("forge: nixos-container list failed; skipping user sweep"); - crate::warnings::set_boot_warning( - "forge_container_list", - "crit", - "forge: nixos-container list failed; per-agent forge sync skipped this boot", - ); - return; - }; - let mut sync_failed: Vec = Vec::new(); - for c in containers { - let Some(name) = c.strip_prefix(crate::lifecycle::AGENT_PREFIX) else { - continue; - }; - if !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(", ") - ), - ); - } } #[cfg(test)] @@ -526,9 +429,9 @@ mod tests { /// back to `core:`, so the swap is genuinely equivalent auth and /// not a silent downgrade to anonymous. #[test] - fn core_auth_header_is_basic_core_token() { + fn basic_auth_header_is_basic_core_token() { use base64::Engine as _; - let header = super::core_auth_header("s3cret"); + let header = super::basic_auth_header("core", "s3cret"); let b64 = header .strip_prefix("Authorization: Basic ") .expect("basic auth header"); diff --git a/hive-c0re/src/forge/repos.rs b/hive-c0re/src/forge/repos.rs index d434b8a4..df288a7b 100644 --- a/hive-c0re/src/forge/repos.rs +++ b/hive-c0re/src/forge/repos.rs @@ -1,207 +1,10 @@ -//! Repo + org plumbing on the local Forgejo: the meta + knowledge repos, -//! meta mirroring (`push_meta`), collaborator grants, pull-mirrors, and -//! branch-protection rules. The typed API-client constructor + org-name -//! constants live in the module root (`super`). +//! Repo plumbing on the local Forgejo: reseeding an agent's `applied` and +//! `proposed` repos from `agent-configs/`. The typed API-client +//! constructor + org-name constants live in the module root (`super`). use std::path::Path; -use std::sync::Mutex; -use anyhow::{Context, Result}; -use forgejo_api::structs::{ - AddCollaboratorOption, AddCollaboratorOptionPermission, CreateRepoOption, Repository, -}; -use forgejo_api::{ApiErrorKind, ForgejoError}; -use reqwest::StatusCode; - -use crate::coordinator::Coordinator; - -use super::{ - CONFIG_ORG, api, bounded, core_auth_header, core_token, forge_git_url, forge_http_base, - is_present, reader_token, -}; - -/// Creation options for an empty repo defaulting to `main`. -fn repo_option(name: &str, private: bool) -> CreateRepoOption { - CreateRepoOption { - auto_init: Some(false), - default_branch: Some("main".to_owned()), - description: None, - gitignores: None, - issue_labels: None, - license: None, - name: name.to_owned(), - object_format_name: None, - private: Some(private), - readme: None, - template: None, - trust_model: None, - } -} - -/// Whether a create-style call failed because the object already -/// exists. Forgejo signals this as HTTP 409 (conflict) or 422 -/// (validation). The typed client surfaces those as -/// `ApiErrorKind::Other(409)` / `ValidationFailed` when the endpoint -/// spec lists the status, or as a bare `UnexpectedStatusCode` -/// otherwise — match all shapes defensively. -fn is_already_exists(e: &ForgejoError) -> bool { - match e { - ForgejoError::ApiError(api) => match api.error_kind() { - ApiErrorKind::ValidationFailed => true, - ApiErrorKind::Other(s) => *s == StatusCode::CONFLICT, - _ => false, - }, - ForgejoError::UnexpectedStatusCode(s) => { - *s == StatusCode::CONFLICT || *s == StatusCode::UNPROCESSABLE_ENTITY - } - _ => false, - } -} - -/// Fold a repo-creation result's "already exists" (409 / 422) into -/// success. `label` is `/` — purely for log + error -/// context. -fn created_or_exists(res: Result, label: &str) -> Result<()> { - match res { - Ok(_) => { - tracing::info!(%label, "forge: created repo"); - Ok(()) - } - Err(e) if is_already_exists(&e) => { - tracing::debug!(%label, "forge: repo already exists"); - Ok(()) - } - Err(e) => Err(e).with_context(|| format!("create repo {label}")), - } -} - -/// Create a repo in the token-owner's own namespace. `token` belongs -/// to the user we want the repo owned by (we use `core`'s token for -/// `core/meta`). Idempotent. -pub async fn ensure_repo(name: &str, token: &str) -> Result<()> { - let label = format!("core/{name}"); - let res = bounded( - &format!("create repo {label}"), - api(token)?.create_current_user_repo(repo_option(name, true)), - ) - .await?; - created_or_exists(res, &label) -} - -/// Dashboard-warning guard for the meta non-fast-forward condition. -/// Held while the `core/meta` remote is ahead of our local mirror; cleared -/// automatically when the next push succeeds. -static META_NON_FF_GUARD: Mutex> = Mutex::new(None); - -/// Push `dir` (the legacy hive meta repo) to `core/meta` on the local forge. -/// Its one caller is `meta::remove_permission_files`' commit. -/// Best-effort: returns Err which callers log + ignore. No-op when -/// the core token isn't present yet (forge container not provisioned). -pub async fn push_meta(dir: &Path) -> Result<()> { - let Some(token) = core_token() else { - return Ok(()); - }; - // Token-in-URL push. Forgejo accepts `oauth2:` or just - // any-username:; using `core` matches the owner so the - // remote name is self-describing. - // - // No --force: `meta.rs` uses regular `git commit` (append-only), - // so the push is always fast-forward in normal operation. Per - // operator directive, hive-c0re must not force-push anywhere. If - // the remote is somehow ahead (e.g. split-brain or manual push) we - // raise a dashboard warning and leave the remote intact rather than - // silently erasing its history. - let url = forge_git_url("core/meta"); - let out = crate::lifecycle::run_with_deadline( - crate::lifecycle::git_command_authed(&core_auth_header(&token)) - .current_dir(dir) - .args(["push", &url, "HEAD:main"]), - crate::lifecycle::FETCH_DEADLINE, - "git push core/meta", - ) - .await?; - if !out.status.success() { - let stderr = String::from_utf8_lossy(&out.stderr); - if stderr.contains("non-fast-forward") || stderr.contains("fetch first") { - let msg = "forge/core/meta push rejected (non-fast-forward): \ - remote is ahead of local; leaving remote history intact"; - tracing::warn!("{msg}"); - // Raise a persistent hive warning; cleared on next successful push. - if let Ok(mut g) = META_NON_FF_GUARD.lock() { - *g = Some(crate::warnings::set_warning( - "forge-meta-non-ff", - "warn", - msg, - )); - } - return Ok(()); - } - anyhow::bail!( - "git push core/meta failed ({}): {}", - out.status, - stderr.trim() - ); - } - // Successful push: clear any outstanding non-ff warning. - if let Ok(mut g) = META_NON_FF_GUARD.lock() { - *g = None; - } - tracing::info!("forge: pushed meta to core/meta"); - Ok(()) -} - -/// Grant agent `name` read-only collaborator access to `core/meta` on -/// the forge so the agent can clone/fetch that legacy repo. Idempotent: -/// re-adding an existing collaborator succeeds (Forgejo answers 204 -/// either way). -pub async fn meta_read_access(name: &str, core_token: &str) -> Result<()> { - add_collaborator( - "core", - "meta", - name, - AddCollaboratorOptionPermission::Read, - core_token, - ) - .await?; - tracing::info!(%name, "forge: granted meta read access"); - Ok(()) -} - -/// Add the forge `core/meta.git` URL as the `meta` remote in the -/// agent's proposed config repo so the agent (and the manager) can -/// fetch that legacy repo from the forge. Idempotent: no-op when the -/// remote already points at the right URL, or when the proposed repo -/// does not exist yet. No-op when the forge is not running. -pub async fn ensure_meta_remote(name: &str) -> Result<()> { - if !is_present().await { - return Ok(()); - } - // A malformed name has no proposed config repo (repos are only created - // under a validated Ident), so there's nothing to wire — no-op. - let Ok(agent) = hive_types::Ident::parse(name) else { - return Ok(()); - }; - let proposed_dir = Coordinator::agent_proposed_dir(&agent); - if !proposed_dir.join(".git").exists() { - return Ok(()); - } - let want = format!("{}/core/meta.git", forge_http_base()); - let existing = crate::lifecycle::git_command() - .current_dir(&proposed_dir) - .args(["remote", "get-url", "meta"]) - .output() - .await - .context("git remote get-url meta")?; - if existing.status.success() { - let current = String::from_utf8_lossy(&existing.stdout).trim().to_owned(); - if current == want { - return Ok(()); - } - crate::lifecycle::git(&proposed_dir, &["remote", "set-url", "meta", &want]).await - } else { - crate::lifecycle::git(&proposed_dir, &["remote", "add", "meta", &want]).await - } -} +use super::{CONFIG_ORG, forge_git_url, reader_token}; /// Reseed a revived agent's `applied` repo — missing its `.git` entirely, /// e.g. after `destroy --purge` — by fetching `agent-configs/`'s @@ -334,38 +137,3 @@ pub async fn clone_config_into_proposed(dir: &Path, name: &str) -> bool { } } } - -/// Add `user` as a collaborator on `owner/repo` at `permission`. -/// Idempotent: adding an existing collaborator just updates its -/// permission (Forgejo answers 204 either way; a 201 from older -/// versions is tolerated defensively). -async fn add_collaborator( - owner: &str, - repo: &str, - user: &str, - permission: AddCollaboratorOptionPermission, - token: &str, -) -> Result<()> { - let op = format!("add collaborator {user} to {owner}/{repo}"); - let res = bounded( - &op, - api(token)?.repo_add_collaborator( - owner, - repo, - user, - AddCollaboratorOption { - permission: Some(permission), - }, - ), - ) - .await?; - match res { - Ok(()) => {} - Err(ForgejoError::UnexpectedStatusCode(s)) if s == StatusCode::CREATED => {} - Err(e) => { - return Err(e).context(op); - } - } - tracing::debug!(%owner, %repo, %user, ?permission, "forge: collaborator set"); - Ok(()) -} diff --git a/hive-c0re/src/forge/users.rs b/hive-c0re/src/forge/users.rs index 979ba4e3..b59f63de 100644 --- a/hive-c0re/src/forge/users.rs +++ b/hive-c0re/src/forge/users.rs @@ -14,7 +14,7 @@ use super::{api, bounded, forge_admin}; const TOKEN_NAME_PREFIX: &str = "hyperhive"; // Where the host-side `core` admin token lives. Used by hive-c0re itself -// to push the meta repo + drive admin API calls. Root-only. Literal lives +// to drive admin API calls and as the credential nix's git reads. Root-only. Literal lives // in `crate::paths`; aliased here under the long-standing name. use crate::paths::FORGE_CORE_TOKEN as CORE_TOKEN_PATH; // The `forge/core-avatar-set` marker lives in `crate::paths` — a one-shot diff --git a/hive-c0re/src/job_queue/exec.rs b/hive-c0re/src/job_queue/exec.rs index 04de3243..bf1cae4f 100644 --- a/hive-c0re/src/job_queue/exec.rs +++ b/hive-c0re/src/job_queue/exec.rs @@ -446,11 +446,7 @@ async fn run_rebuild_bookkeeping(coord: &Arc, name: &str) -> Result // The `Rebuilt` manager event is emitted exactly once per agent by the DAG's // `EmitRebuilt` tail — emitting ok here and letting a failed tail `Reconcile` // add a contradictory !ok would double-report the same rebuild. - // Full forge sync on every successful rebuild so the rebuild path is - // equivalent to the startup sweep: meta access, the `meta` remote and the - // `internal/docs` grant all recover without a hive-c0re restart. (No - // matrix step: the swarm mints an agent's matrix account, not this hive.) - crate::forge::sync_agent(name, crate::forge::core_token().as_deref()).await; + // No `kick_agent` here any more — reporting from `hive-agent`'s own // boot instead avoids the duplicate-restart-message problem this used // to cause: the container is still stopped at this point (`swap_update` requires it stopped and diff --git a/hive-c0re/src/lifecycle/git.rs b/hive-c0re/src/lifecycle/git.rs index 7c5f1caa..7d97cfef 100644 --- a/hive-c0re/src/lifecycle/git.rs +++ b/hive-c0re/src/lifecycle/git.rs @@ -125,7 +125,7 @@ pub fn git_command() -> Command { /// [`git_command`] carrying HTTP credentials **in its environment**. /// /// `auth_header` is an `Authorization:` line (see -/// [`crate::forge::core_auth_header`]) handed to git as `http.extraHeader` +/// `forge::basic_auth_header`) handed to git as `http.extraHeader` /// through `GIT_CONFIG_*`, which git reads exactly like a config file. The /// alternative — userinfo in the remote URL — puts the secret in `argv`, and /// `/proc//cmdline` is world-readable while `/proc//environ` is diff --git a/hive-c0re/src/lifecycle/mod.rs b/hive-c0re/src/lifecycle/mod.rs index c39cf8ff..e3d7bdb1 100644 --- a/hive-c0re/src/lifecycle/mod.rs +++ b/hive-c0re/src/lifecycle/mod.rs @@ -9,10 +9,11 @@ mod tests; #[cfg(test)] pub use git::git_update_ref; +#[cfg(test)] +pub use git::run_with_deadline; pub use git::{ - FETCH_DEADLINE, GIT_LOCAL_DEADLINE, git, git_authed, git_command, git_command_authed, - git_is_ancestor, git_read_tree_reset, git_rev_parse, git_tag, git_update_ref_cas, - run_with_deadline, + git, git_authed, git_command, git_command_authed, git_is_ancestor, git_read_tree_reset, + git_rev_parse, git_tag, git_update_ref_cas, }; pub use host_config::write_dropins; pub use setup::{ @@ -372,7 +373,7 @@ fn stop_failed_guards() /// Leak a per-agent warning `kind`. Agent names are a small, bounded set — /// leaking one short string per distinct agent that ever hits a stop -/// failure is cheap, same reasoning as `forge::static_kind`. +/// failure is cheap. fn static_stop_kind(name: &str) -> &'static str { Box::leak(format!("agent_stop_failed_{name}").into_boxed_str()) } diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index 1f2002f5..4dcc9fb4 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -1,149 +1,69 @@ //! The hive's `meta/` directory: plain state beside the hive's other files -//! (`topology.json`, the swarm limits, the permission seed files) that the -//! manager container sees read-only. Agents are built from the swarm's -//! `internal/meta` ([`crate::meta_rev`]), not from anything here; a hive that -//! predates it also has a git repo in this directory, which -//! [`remove_permission_files`] still commits to. +//! (`topology.json`, the swarm limits) that the manager container sees +//! read-only. Agents are built from the swarm's `internal/meta` +//! ([`crate::meta_rev`]), not from anything here. -use std::path::Path; - -use anyhow::{Context, Result, bail}; - -use crate::lifecycle; - -const GIT_NAME: &str = "c0re"; -const GIT_EMAIL: &str = "c0re@hyperhive.local"; +use anyhow::{Context, Result}; /// Where the manager sees this directory inside its container (RO bind). pub const CONTAINER_MANAGER_META_MOUNT: &str = "/meta"; -/// Delete the meta repo's permission files and commit the removal. Their -/// grants live in the swarm's `agent-permissions` bucket once -/// `workers::permissions_import` has sent them. +/// Delete the permission seed files a hive that predates the swarm's +/// permissions left in the meta directory. Their grants live in the swarm's +/// `agent-permissions` bucket once `workers::permissions_import` has sent +/// them. A file already gone is fine. /// /// # Errors /// -/// Removing a file or a git stage/commit step fails. -pub async fn remove_permission_files() -> Result<()> { - let dir = crate::paths::meta_root(); - let mut removed = Vec::new(); - for file in crate::workers::permissions_import::META_FILES { +/// Removing a file fails. +pub fn remove_permission_files() -> Result<()> { + remove_in( + &crate::paths::meta_root(), + crate::workers::permissions_import::META_FILES, + ) +} + +fn remove_in(dir: &std::path::Path, files: &[&str]) -> Result<()> { + for file in files { match std::fs::remove_file(dir.join(file)) { Ok(()) => {} Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) => return Err(e).with_context(|| format!("removing {file}")), } - git(&dir, &["rm", "--cached", "--ignore-unmatch", "-q", file]).await?; - // Only a tracked file has a removal to commit; naming an untracked - // one makes `git commit -- ` fail. - if paths_dirty(&dir, &[file]).await? { - removed.push(*file); - } - } - if !removed.is_empty() { - git_commit_paths( - &dir, - "permissions moved to the swarm's agent-permissions", - &removed, - ) - .await?; } Ok(()) } -/// Run `git ` in `dir` within [`lifecycle::GIT_LOCAL_DEADLINE`]; the caller -/// reads the exit status. -async fn git_output(dir: &Path, args: &[&str]) -> Result { - lifecycle::run_with_deadline( - lifecycle::git_command().current_dir(dir).args(args), - lifecycle::GIT_LOCAL_DEADLINE, - &format!("git {} in {}", args.join(" "), dir.display()), - ) - .await -} - -async fn git(dir: &Path, args: &[&str]) -> Result<()> { - let out = git_output(dir, args).await?; - if !out.status.success() { - bail!( - "git {} failed ({}): {}", - args.join(" "), - out.status, - String::from_utf8_lossy(&out.stderr).trim() - ); - } - Ok(()) -} - -/// Path-limited commit: commits ONLY the given paths, so unrelated staged -/// content can never be swept into the commit. -async fn git_commit_paths(dir: &Path, message: &str, paths: &[&str]) -> Result<()> { - let name = format!("user.name={GIT_NAME}"); - let email = format!("user.email={GIT_EMAIL}"); - let mut args = vec!["-c", &name, "-c", &email, "commit", "-m", message, "--"]; - args.extend_from_slice(paths); - git(dir, &args).await?; - if let Err(e) = crate::forge::push_meta(dir).await { - tracing::warn!(error = ?e, "forge: meta push after commit failed (non-fatal)"); - } - Ok(()) -} - -/// True when any of `paths` differs between HEAD and the index or working -/// tree. -async fn paths_dirty(dir: &Path, paths: &[&str]) -> Result { - let mut args = vec!["diff", "--quiet", "HEAD", "--"]; - args.extend_from_slice(paths); - let out = git_output(dir, &args).await?; - // Exit 0 = no differences; 1 = differences; anything else (e.g. - // no HEAD yet on a fresh repo) → treat as dirty so the commit - // path runs and surfaces real errors loudly. - Ok(!out.status.success()) -} - #[cfg(test)] mod tests { - use super::*; + use super::remove_in; - /// A path-limited commit must leave an unrelated staged file untouched. - #[tokio::test] - async fn path_limited_commit_leaves_unrelated_staged_file_alone() { - let tmp = tempfile::tempdir().expect("tempdir"); - let dir = tmp.path(); - git(dir, &["init", "--initial-branch=main"]) - .await - .expect("git init"); - std::fs::write(dir.join("resource-limits.json"), "{}").expect("write"); - std::fs::write(dir.join("other.json"), "v1").expect("write"); - git(dir, &["add", "-A"]).await.expect("add"); - git_commit_paths(dir, "seed", &["resource-limits.json", "other.json"]) - .await - .expect("seed commit"); - // Something stages a change (uncommitted)… - std::fs::write(dir.join("other.json"), "v2-staged").expect("write"); - git(dir, &["add", "other.json"]).await.expect("stage"); - // …and a limits change commits, path-limited. - std::fs::write(dir.join("resource-limits.json"), r#"{"alice":[]}"#).expect("write"); - git(dir, &["add", "resource-limits.json"]) - .await - .expect("add"); - git_commit_paths( - dir, - "set resource limits for alice", - &["resource-limits.json"], - ) - .await - .expect("path-limited commit"); - // The limits file is committed; the staged one is not. + #[test] + fn the_listed_files_go_and_nothing_else() { + let dir = tempfile::tempdir().unwrap(); + for name in ["capabilities.json", "tool-groups.json", "topology.json"] { + std::fs::write(dir.path().join(name), "{}").unwrap(); + } + remove_in(dir.path(), &["capabilities.json", "tool-groups.json"]).unwrap(); + assert!(!dir.path().join("capabilities.json").exists()); + assert!(!dir.path().join("tool-groups.json").exists()); assert!( - !paths_dirty(dir, &["resource-limits.json"]) - .await - .expect("check"), - "limits file must be committed" + dir.path().join("topology.json").exists(), + "other state stays" ); + // A file already gone is fine. + remove_in(dir.path(), &["capabilities.json"]).unwrap(); + } + + #[test] + fn a_file_that_cannot_be_removed_is_an_error_naming_it() { + let dir = tempfile::tempdir().unwrap(); + // A directory where the file belongs fails with a non-NotFound error. + std::fs::create_dir(dir.path().join("capabilities.json")).unwrap(); + let err = remove_in(dir.path(), &["capabilities.json"]).unwrap_err(); assert!( - paths_dirty(dir, &["other.json"]).await.expect("check"), - "the staged file must survive the limits commit" + format!("{err:#}").contains("removing capabilities.json"), + "{err:#}" ); } } diff --git a/hive-c0re/src/migrate.rs b/hive-c0re/src/migrate.rs index 4a2506ef..3beb067c 100644 --- a/hive-c0re/src/migrate.rs +++ b/hive-c0re/src/migrate.rs @@ -50,17 +50,7 @@ pub async fn run() -> Result<()> { tracing::info!("migration: {KILL_SWITCH} set — skipping"); return Ok(()); } - // Stale meta index lock: a previous hive-c0re crash mid-`git add` - // can leave `.git/index.lock` behind, which blocks every - // subsequent meta op until somebody `rm`s it manually. We just - // booted so nothing of ours is holding it; safe to clear. - let meta_lock = crate::paths::meta_git_index_lock(); - if meta_lock.exists() { - match std::fs::remove_file(&meta_lock) { - Ok(()) => tracing::warn!("cleared stale meta/.git/index.lock"), - Err(e) => tracing::warn!(error = ?e, "clear stale meta lock failed"), - } - } + let names = match enumerate_agents().await { Ok(names) => names, Err(e) => { diff --git a/hive-c0re/src/paths.rs b/hive-c0re/src/paths.rs index 772a3771..c56e51d5 100644 --- a/hive-c0re/src/paths.rs +++ b/hive-c0re/src/paths.rs @@ -193,13 +193,6 @@ pub fn meta_root() -> PathBuf { state_root().join("meta") } -/// `meta/.git/index.lock` — git index lock; checked before meta git ops -/// so a stale lock from a crashed process can be cleared. -#[must_use] -pub fn meta_git_index_lock() -> PathBuf { - meta_root().join(".git/index.lock") -} - /// `meta-rev/` — one file per agent naming the `internal/meta` commit its /// rebuild builds from (`crate::meta_rev`). /// diff --git a/hive-c0re/src/workers/permissions_import.rs b/hive-c0re/src/workers/permissions_import.rs index 3834467a..bacf8a37 100644 --- a/hive-c0re/src/workers/permissions_import.rs +++ b/hive-c0re/src/workers/permissions_import.rs @@ -222,7 +222,6 @@ async fn send_at(client: &async_nats::Client, hive: &str, meta: &Path) -> Result "import: the controller took the meta repo's permissions" ); crate::meta::remove_permission_files() - .await .context("deleting the imported permission files")?; Ok(ImportOutcome::Imported) }