Watch
0
0
Fork
You've already forked hyperhive
0

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.
This commit is contained in:
atlas 2026-10-25 16:35:46 +01:00 • committed by mara
commit b6b568e97e
15 changed files with 81 additions and 518 deletions

View file

@ -289,8 +289,8 @@ mounts via `set_nspawn_flags`:
manage any agent", so the mounted set is every agent. manage any agent", so the mounted set is every agent.
- `/var/lib/hyperhive/applied/` → `/applied/` (RO) — every agent's - `/var/lib/hyperhive/applied/` → `/applied/` (RO) — every agent's
authoritative applied repo, including `.git`. authoritative applied repo, including `.git`.
- `/var/lib/hyperhive/meta/` → `/meta/` (RO) — the swarm-wide - `/var/lib/hyperhive/meta/` → `/meta/` (RO) — the hive's plain state
deploy flake. (topology, swarm limits).
An agent without the permission only has its direct children's config An agent without the permission only has its direct children's config
dirs. dirs.

View file

@ -387,17 +387,12 @@ here from merged config PRs) + any other files those PRs commit.
`.git/` carries the proposal / approved / building / deployed / failed / `.git/` carries the proposal / approved / building / deployed / failed /
denied tag history. denied tag history.
Under `/var/lib/hyperhive/meta/` — the swarm-wide deploy flake plus Under `/var/lib/hyperhive/meta/` — system-level config files, plain state
system-level config files. Single git repo for the whole host; hive-c0re with no git history that anything reads. The flake agents build from is the
commits every mutation that should survive a restart here. swarm's `internal/meta`, not here. Contents:
Contents:
- `flake.nix` — declares one `nixpkgs` input per agent + one
`nixosConfigurations.<n>` 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"]`). - `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` `topology::all_agents`, which is the set the `manage_root_agent`
permission grants mounts over. The reader also accepts a map-shaped permission grants mounts over. The reader also accepts a map-shaped
file carrying a `parent` per agent, and keeps its keys. 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 hive-wide only and come straight off `HiveEnv`, so this file has no field
for them. for them.
- `resource-limits.json` — retired. Per-agent overrides set on the hive used - `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. is harmless and you can delete it.
An agent holding `manage_root_agent`, normally ruth, has the meta dir An agent holding `manage_root_agent`, normally ruth, has the meta dir
RO-mounted at `/meta/`. 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 holds the permissions the swarm grants this hive's agents, as
`workers::swarm_permissions` last read them from the swarm's `workers::swarm_permissions` last read them from the swarm's
`agent-permissions` bucket (every 30 seconds): a copy, not the `agent-permissions` bucket (every 30 seconds): a copy, not the

View file

@ -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)). [config-changes.md § Config changes](../agent-lifecycle/config-changes.md#config-changes)).
hive-c0re pushes nothing to this org; it only reads from it. hive-c0re pushes nothing to this org; it only reads from it.
Repos stay private, so an agent can't read another Repos stay private, so an agent can't read another
agent's config. (Agents remain read-only collaborators on the legacy agent's config. The agent's **persistent meta flake input** is this repo's `main` head,
`core/meta`, the hive's old permission-seed repo.)
The agent's **persistent meta flake input** is this repo's `main` head,
pinned by sha in `internal/meta` (`agent-<n>.url = pinned by sha in `internal/meta` (`agent-<n>.url =
git+<forge>/agent-configs/<n>.git?rev=<sha>`; see git+<forge>/agent-configs/<n>.git?rev=<sha>`; see
[config-changes.md § Meta flake](../agent-lifecycle/config-changes.md#meta-flake)). [config-changes.md § Meta flake](../agent-lifecycle/config-changes.md#meta-flake)).

View file

@ -233,10 +233,10 @@ enough — wiping and rebuilding still destroys the state even if
`JSON.stringify({...visible fields...})`. On each render, the pattern `JSON.stringify({...visible fields...})`. On each render, the pattern
reuses cache-hit rows verbatim (preserving textarea draft, checkbox reuses cache-hit rows verbatim (preserving textarea draft, checkbox
state, and event listeners) and rebuilds only cache-miss rows. Used for: containers (`containerRowCache`), 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 rebuild-queue entries (`rebuildQueueRowCache`). The spawn-form input+focus
checkboxes use a lighter snapshot-then-restore pattern (snapshot uses a lighter snapshot-then-restore pattern (snapshot
before `replaceChildren`, restore after) since they're single before `replaceChildren`, restore after) since it's a single
values rather than per-row caches. value rather than a per-row cache.
<!-- vale write-good.Passive = NO --> <!-- vale write-good.Passive = NO -->

View file

@ -172,7 +172,7 @@ import { renderUserMenu } from "./user-menu.js";
// broker traffic flows through the SSE channel. The only // broker traffic flows through the SSE channel. The only
// /api/state fetches are the initial cold load and the // /api/state fetches are the initial cold load and the
// post-submit refetch on forms without `data-no-refresh` // post-submit refetch on forms without `data-no-refresh`
// (tombstones, meta-input updates). // (tombstones).
if (pollTimer) { if (pollTimer) {
clearTimeout(pollTimer); clearTimeout(pollTimer);
pollTimer = null; pollTimer = null;

View file

@ -1,6 +1,5 @@
//! Optional Forgejo wiring — the legacy `core/meta` repo (the hive's old //! Optional Forgejo wiring — the `core` admin user and token and its avatar.
//! permission-seed repo, which `meta::remove_permission_files` still commits //! Those writes, as `core`, run only beside a local `hive-forge`
//! to) and its read-access grants. Those writes, as `core`, run only beside a local `hive-forge`
//! container; reads of the forge go through [`reader_token`] on every hive. //! 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 //! The swarm-wide orgs, teams and repos, and each agent's read grant on
//! `internal/docs`, are swarm-controller's. Full design: //! `internal/docs`, are swarm-controller's. Full design:
@ -15,10 +14,7 @@ mod users;
pub(crate) use internal_meta::internal_meta_source; pub(crate) use internal_meta::internal_meta_source;
pub use reader::reader_token; pub use reader::reader_token;
pub use reconcile::{fetch_forge_main, reconcile_config_apply, reconcile_config_status}; pub use reconcile::{fetch_forge_main, reconcile_config_apply, reconcile_config_status};
pub use repos::{ pub use repos::{clone_config_into_proposed, fetch_config_main_into_applied};
clone_config_into_proposed, ensure_meta_remote, ensure_repo, fetch_config_main_into_applied,
meta_read_access, push_meta,
};
pub use users::core_token; pub use users::core_token;
use std::future::IntoFuture; use std::future::IntoFuture;
@ -74,13 +70,13 @@ pub(crate) fn validate_env() -> Result<()> {
Ok(()) 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 /// The token does not go here. A URL is a process argument, and `argv` is
/// world-readable through `/proc/<pid>/cmdline` for as long as the git child /// world-readable through `/proc/<pid>/cmdline` for as long as the git child
/// lives, so a credentialed remote publishes the core admin token to every /// lives, so a credentialed remote publishes the core admin token to every
/// local user on the host. Credentials travel in the environment instead, via /// 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/<pid>/environ` is owner-only. /// `/proc/<pid>/environ` is owner-only.
pub(crate) fn forge_git_url(repo: &str) -> String { pub(crate) fn forge_git_url(repo: &str) -> String {
git_url_with_base(forge_http_base(), repo) 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") 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 /// The `http.extraHeader` value carrying `token`. Forgejo takes the account
/// from the token, so `login` only labels the request. /// 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. /// still can't reach *another* agent's config. hive-c0re only reads from it.
pub(crate) const CONFIG_ORG: &str = "agent-configs"; 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 — /// Probe whether `hive-forge` exists as a nixos-container. Cheap —
/// `nixos-container list` is just a directory scan in /etc. Routed /// `nixos-container list` is just a directory scan in /etc. Routed
/// through hive-priv: `nixos-container` needs root, and hive-c0re runs /// 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:?}")) .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 `core_token.is_some()` half of [`ensure_all`]: the
/// 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/<name>` 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
/// core avatar, and removing this hive's own config-PR hook — /// core avatar, and removing this hive's own config-PR hook —
/// every step that needs an authenticated forge client. The swarm-wide /// every step that needs an authenticated forge client. The swarm-wide
/// objects (orgs, the `operators` team, mirrors, `internal/docs`, /// 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 /// `ensure_all` under clippy's function-length limit; not meant to be called
/// from anywhere else. /// from anywhere else.
async fn ensure_all_orgs_and_repos(token: &str) { 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 { if let Err(e) = ensure_core_avatar(token).await {
tracing::warn!(error = ?e, "forge: ensure_core_avatar failed"); tracing::warn!(error = ?e, "forge: ensure_core_avatar failed");
crate::warnings::set_boot_warning( 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 /// 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 /// identity for driving the API, and the one nix's git reads as) and its token,
/// `core/meta` repo and the local knowledge clone, then run [`sync_agent`] /// then its avatar. Agent accounts,
/// over every existing container (manager + sub-agents). Agent accounts,
/// tokens and `agent-configs/<name>` repos are the swarm-controller's, as is /// tokens and `agent-configs/<name>` 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 /// Called once at hive-c0re startup. Per-step failures are logged
/// but don't abort the sweep. /// but don't abort the sweep.
pub async fn ensure_all() { pub async fn ensure_all() {
@ -413,37 +347,6 @@ pub async fn ensure_all() {
if let Some(token) = core_token.as_deref() { if let Some(token) = core_token.as_deref() {
ensure_all_orgs_and_repos(token).await; 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<String> = 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)] #[cfg(test)]
@ -526,9 +429,9 @@ mod tests {
/// back to `core:<token>`, so the swap is genuinely equivalent auth and /// back to `core:<token>`, so the swap is genuinely equivalent auth and
/// not a silent downgrade to anonymous. /// not a silent downgrade to anonymous.
#[test] #[test]
fn core_auth_header_is_basic_core_token() { fn basic_auth_header_is_basic_core_token() {
use base64::Engine as _; use base64::Engine as _;
let header = super::core_auth_header("s3cret"); let header = super::basic_auth_header("core", "s3cret");
let b64 = header let b64 = header
.strip_prefix("Authorization: Basic ") .strip_prefix("Authorization: Basic ")
.expect("basic auth header"); .expect("basic auth header");

View file

@ -1,207 +1,10 @@
//! Repo + org plumbing on the local Forgejo: the meta + knowledge repos, //! Repo plumbing on the local Forgejo: reseeding an agent's `applied` and
//! meta mirroring (`push_meta`), collaborator grants, pull-mirrors, and //! `proposed` repos from `agent-configs/<name>`. The typed API-client
//! branch-protection rules. The typed API-client constructor + org-name //! constructor + org-name constants live in the module root (`super`).
//! constants live in the module root (`super`).
use std::path::Path; use std::path::Path;
use std::sync::Mutex;
use anyhow::{Context, Result}; use super::{CONFIG_ORG, forge_git_url, reader_token};
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 `<owner>/<name>` — purely for log + error
/// context.
fn created_or_exists(res: Result<Repository, ForgejoError>, 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<Option<crate::warnings::WarningGuard>> = 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:<token>` or just
// any-username:<token>; 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
}
}
/// Reseed a revived agent's `applied` repo — missing its `.git` entirely, /// Reseed a revived agent's `applied` repo — missing its `.git` entirely,
/// e.g. after `destroy --purge` — by fetching `agent-configs/<name>`'s /// e.g. after `destroy --purge` — by fetching `agent-configs/<name>`'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(())
}

View file

@ -14,7 +14,7 @@ use super::{api, bounded, forge_admin};
const TOKEN_NAME_PREFIX: &str = "hyperhive"; const TOKEN_NAME_PREFIX: &str = "hyperhive";
// Where the host-side `core` admin token lives. Used by hive-c0re itself // 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. // in `crate::paths`; aliased here under the long-standing name.
use crate::paths::FORGE_CORE_TOKEN as CORE_TOKEN_PATH; use crate::paths::FORGE_CORE_TOKEN as CORE_TOKEN_PATH;
// The `forge/core-avatar-set` marker lives in `crate::paths` — a one-shot // The `forge/core-avatar-set` marker lives in `crate::paths` — a one-shot

View file

@ -446,11 +446,7 @@ async fn run_rebuild_bookkeeping(coord: &Arc<Coordinator>, name: &str) -> Result
// The `Rebuilt` manager event is emitted exactly once per agent by the DAG's // 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` // `EmitRebuilt` tail — emitting ok here and letting a failed tail `Reconcile`
// add a contradictory !ok would double-report the same rebuild. // 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 // No `kick_agent` here any more — reporting from `hive-agent`'s own
// boot instead avoids the duplicate-restart-message problem this used // 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 // to cause: the container is still stopped at this point (`swap_update` requires it stopped and

View file

@ -125,7 +125,7 @@ pub fn git_command() -> Command {
/// [`git_command`] carrying HTTP credentials **in its environment**. /// [`git_command`] carrying HTTP credentials **in its environment**.
/// ///
/// `auth_header` is an `Authorization:` line (see /// `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 /// through `GIT_CONFIG_*`, which git reads exactly like a config file. The
/// alternative — userinfo in the remote URL — puts the secret in `argv`, and /// alternative — userinfo in the remote URL — puts the secret in `argv`, and
/// `/proc/<pid>/cmdline` is world-readable while `/proc/<pid>/environ` is /// `/proc/<pid>/cmdline` is world-readable while `/proc/<pid>/environ` is

View file

@ -9,10 +9,11 @@ mod tests;
#[cfg(test)] #[cfg(test)]
pub use git::git_update_ref; pub use git::git_update_ref;
#[cfg(test)]
pub use git::run_with_deadline;
pub use git::{ pub use git::{
FETCH_DEADLINE, GIT_LOCAL_DEADLINE, git, git_authed, git_command, git_command_authed, git, git_authed, git_command, git_command_authed, git_is_ancestor, git_read_tree_reset,
git_is_ancestor, git_read_tree_reset, git_rev_parse, git_tag, git_update_ref_cas, git_rev_parse, git_tag, git_update_ref_cas,
run_with_deadline,
}; };
pub use host_config::write_dropins; pub use host_config::write_dropins;
pub use setup::{ pub use setup::{
@ -372,7 +373,7 @@ fn stop_failed_guards()
/// Leak a per-agent warning `kind`. Agent names are a small, bounded set — /// 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 /// 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 { fn static_stop_kind(name: &str) -> &'static str {
Box::leak(format!("agent_stop_failed_{name}").into_boxed_str()) Box::leak(format!("agent_stop_failed_{name}").into_boxed_str())
} }

View file

@ -1,149 +1,69 @@
//! The hive's `meta/` directory: plain state beside the hive's other files //! The hive's `meta/` directory: plain state beside the hive's other files
//! (`topology.json`, the swarm limits, the permission seed files) that the //! (`topology.json`, the swarm limits) that the manager container sees
//! manager container sees read-only. Agents are built from the swarm's //! read-only. Agents are built from the swarm's `internal/meta`
//! `internal/meta` ([`crate::meta_rev`]), not from anything here; a hive that //! ([`crate::meta_rev`]), not from anything here.
//! predates it also has a git repo in this directory, which
//! [`remove_permission_files`] still commits to.
use std::path::Path; use anyhow::{Context, Result};
use anyhow::{Context, Result, bail};
use crate::lifecycle;
const GIT_NAME: &str = "c0re";
const GIT_EMAIL: &str = "c0re@hyperhive.local";
/// Where the manager sees this directory inside its container (RO bind). /// Where the manager sees this directory inside its container (RO bind).
pub const CONTAINER_MANAGER_META_MOUNT: &str = "/meta"; pub const CONTAINER_MANAGER_META_MOUNT: &str = "/meta";
/// Delete the meta repo's permission files and commit the removal. Their /// Delete the permission seed files a hive that predates the swarm's
/// grants live in the swarm's `agent-permissions` bucket once /// permissions left in the meta directory. Their grants live in the swarm's
/// `workers::permissions_import` has sent them. /// `agent-permissions` bucket once `workers::permissions_import` has sent
/// them. A file already gone is fine.
/// ///
/// # Errors /// # Errors
/// ///
/// Removing a file or a git stage/commit step fails. /// Removing a file fails.
pub async fn remove_permission_files() -> Result<()> { pub fn remove_permission_files() -> Result<()> {
let dir = crate::paths::meta_root(); remove_in(
let mut removed = Vec::new(); &crate::paths::meta_root(),
for file in crate::workers::permissions_import::META_FILES { 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)) { match std::fs::remove_file(dir.join(file)) {
Ok(()) => {} Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e).with_context(|| format!("removing {file}")), 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 -- <path>` 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(()) Ok(())
} }
/// Run `git <args>` in `dir` within [`lifecycle::GIT_LOCAL_DEADLINE`]; the caller
/// reads the exit status.
async fn git_output(dir: &Path, args: &[&str]) -> Result<std::process::Output> {
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<bool> {
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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::remove_in;
/// A path-limited commit must leave an unrelated staged file untouched. #[test]
#[tokio::test] fn the_listed_files_go_and_nothing_else() {
async fn path_limited_commit_leaves_unrelated_staged_file_alone() { let dir = tempfile::tempdir().unwrap();
let tmp = tempfile::tempdir().expect("tempdir"); for name in ["capabilities.json", "tool-groups.json", "topology.json"] {
let dir = tmp.path(); std::fs::write(dir.path().join(name), "{}").unwrap();
git(dir, &["init", "--initial-branch=main"]) }
.await remove_in(dir.path(), &["capabilities.json", "tool-groups.json"]).unwrap();
.expect("git init"); assert!(!dir.path().join("capabilities.json").exists());
std::fs::write(dir.join("resource-limits.json"), "{}").expect("write"); assert!(!dir.path().join("tool-groups.json").exists());
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.
assert!( assert!(
!paths_dirty(dir, &["resource-limits.json"]) dir.path().join("topology.json").exists(),
.await "other state stays"
.expect("check"),
"limits file must be committed"
); );
// 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!( assert!(
paths_dirty(dir, &["other.json"]).await.expect("check"), format!("{err:#}").contains("removing capabilities.json"),
"the staged file must survive the limits commit" "{err:#}"
); );
} }
} }

View file

@ -50,17 +50,7 @@ pub async fn run() -> Result<()> {
tracing::info!("migration: {KILL_SWITCH} set — skipping"); tracing::info!("migration: {KILL_SWITCH} set — skipping");
return Ok(()); 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 { let names = match enumerate_agents().await {
Ok(names) => names, Ok(names) => names,
Err(e) => { Err(e) => {

View file

@ -193,13 +193,6 @@ pub fn meta_root() -> PathBuf {
state_root().join("meta") 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 /// `meta-rev/` — one file per agent naming the `internal/meta` commit its
/// rebuild builds from (`crate::meta_rev`). /// rebuild builds from (`crate::meta_rev`).
/// ///

View file

@ -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" "import: the controller took the meta repo's permissions"
); );
crate::meta::remove_permission_files() crate::meta::remove_permission_files()
.await
.context("deleting the imported permission files")?; .context("deleting the imported permission files")?;
Ok(ImportOutcome::Imported) Ok(ImportOutcome::Imported)
} }