hive-c0re: stop mirroring deploy tags to the forge (drop push_config)
Per the operator's ruling on #4851 (option (a)): the hive holds no write identity on the forge, so the `push_config` mirror of an applied repo's tags and `main` to `agent-configs/<name>` is removed, along with its `run_config_push` helper and its call in `sync_agent`. Docs and comments that described the mirror now say that forge `main` advances only by an operator's merge. Refs #4851
This commit is contained in:
parent
c1672218b1
commit
456a4511ab
6 changed files with 33 additions and 137 deletions
|
|
@ -207,14 +207,13 @@ the submitter pushes again (or closes it) to retry.
|
|||
### Forge mirror
|
||||
|
||||
The bundled `hive-forge` container runs on the swarm's forge host
|
||||
(`deploy.forgejo.enable`, see [`../swarm/services.md`](../swarm/services.md)),
|
||||
and hive-c0re mirrors every agent's applied repo into a
|
||||
private `agent-configs` Forgejo org. `forge::push_config(<name>)` pushes
|
||||
every tag, then `applied/main`, to `agent-configs/<name>` on every
|
||||
startup sweep and every rebuild. Forge `main` is branch-protected, so
|
||||
the forge routinely refuses a push of an established `main`, which
|
||||
hive-c0re expects. Pushes are best-effort — a missing or stopped forge never
|
||||
blocks a deploy.
|
||||
(`deploy.forgejo.enable`, see [`../swarm/services.md`](../swarm/services.md)).
|
||||
Every agent has a repo in the private `agent-configs` Forgejo org, created
|
||||
and seeded by swarm-controller. hive-c0re pushes nothing to it: the
|
||||
applied repo's tags (`deployed/0`) exist only locally, and forge `main`
|
||||
advances only by an operator's merge. hive-c0re
|
||||
reads from it — reseeding a purged `applied`, catching `applied` up, and
|
||||
seeding `proposed` at first spawn.
|
||||
|
||||
Each agent is a **write collaborator on its own** `agent-configs/<name>`
|
||||
repo — so it can push a branch and open a config PR — but not a member
|
||||
|
|
@ -222,8 +221,8 @@ of any other agent's, so it can't reach another agent's config through
|
|||
the forge. Branch protection keeps the agent off the `main` push
|
||||
allowlist and allowlists merging to the `operators` team only, so an
|
||||
agent can't fast-forward its own config or self-merge its PR (see
|
||||
[Config changes](#config-changes) above). hive-c0re passes the tokenised
|
||||
push URL inline to `git push`, never writing it into
|
||||
[Config changes](#config-changes) above). hive-c0re passes the credential to
|
||||
each `git` call inline, never writing it into
|
||||
`applied/<n>/.git/config`; that repo is RO-bind-mounted into the root
|
||||
agent, and a stored token would leak core's admin credential to an
|
||||
agent.
|
||||
|
|
|
|||
|
|
@ -70,9 +70,7 @@ Two things live in the `agent-configs` Forgejo organization:
|
|||
`main` directly nor self-merge. An operator's merge in the Forgejo UI
|
||||
deploys the merged commit (see
|
||||
[config-changes.md § Config changes](../agent-lifecycle/config-changes.md#config-changes)).
|
||||
hive-c0re never force-pushes: the `push_config` mirror pushes the
|
||||
add-only status tags and `main` without force, and treats a refused
|
||||
`main` push as expected.
|
||||
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 `core/meta`.)
|
||||
hive-c0re also references this repo as the agent's **persistent meta
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ pub use pr_comment::{config_repo, merged_pr_for_commit, post_pr_comment};
|
|||
pub use reconcile::{fetch_forge_main, reconcile_config_apply, reconcile_config_status};
|
||||
pub use repos::{
|
||||
clone_config_into_proposed, ensure_meta_remote, ensure_repo, fast_forward_applied_main,
|
||||
fetch_config_main_into_applied, meta_read_access, push_config, push_meta, shared_docs_access,
|
||||
fetch_config_main_into_applied, meta_read_access, push_meta, shared_docs_access,
|
||||
};
|
||||
pub use users::core_token;
|
||||
|
||||
|
|
@ -89,8 +89,7 @@ pub(crate) fn core_auth_header(token: &str) -> String {
|
|||
/// swarm-controller's: merge and approval allowlisted to the `operators` team,
|
||||
/// so the agent can't push `main` or self-merge, and an operator's merge
|
||||
/// deploys through swarm-controller. The repos remain private, so an agent
|
||||
/// still can't reach *another* agent's config. hive-c0re never force-pushes;
|
||||
/// the `push_config` mirror runs best-effort.
|
||||
/// still can't reach *another* agent's config. hive-c0re only reads from it.
|
||||
pub(crate) const CONFIG_ORG: &str = "agent-configs";
|
||||
/// Forgejo org hosting the operator-curated shared docs/skills repo
|
||||
/// that every agent gets read-only access to. Agents use it as a
|
||||
|
|
@ -180,9 +179,9 @@ pub(crate) fn api(token: &str) -> Result<Forgejo> {
|
|||
Forgejo::new(Auth::Token(token), url).context("build forgejo api client")
|
||||
}
|
||||
|
||||
/// Per-agent forge sync: mirror the agent's config repo, grant read access
|
||||
/// to `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.
|
||||
/// Per-agent forge sync: grant read access to `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
|
||||
|
|
@ -196,17 +195,9 @@ pub(crate) fn api(token: &str) -> Result<Forgejo> {
|
|||
/// 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 only pushes
|
||||
// swarm-controller's; this hive creates none of them and pushes nothing
|
||||
// to the repo.
|
||||
let mut ok = true;
|
||||
// Mirror the agent's applied config repo into agent-configs.
|
||||
// push_config catches any drift since the last run — e.g. the
|
||||
// startup migration just relocated `deployed/0`, or a deploy landed
|
||||
// while the forge was down.
|
||||
if let Err(e) = push_config(name).await {
|
||||
tracing::warn!(%name, error = ?e, "forge: push_config failed");
|
||||
ok = false;
|
||||
}
|
||||
// Grant read-only access to core/meta and wire the `meta` remote
|
||||
// into the proposed repo so agents can fetch their deployment context.
|
||||
if let Some(token) = core_token
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
//! Repo + org plumbing on the local Forgejo: the meta + shared-docs +
|
||||
//! knowledge repos, per-agent config-repo
|
||||
//! mirroring (`push_config` / `push_meta`), collaborator grants,
|
||||
//! 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`).
|
||||
|
||||
|
|
@ -107,8 +106,7 @@ pub async fn push_meta(dir: &Path) -> Result<()> {
|
|||
// 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 — consistent with `push_config`'s
|
||||
// non-fast-forward handling.
|
||||
// silently erasing its history.
|
||||
let url = forge_git_url("core/meta");
|
||||
let out = crate::lifecycle::git_command_authed(&core_auth_header(&token))
|
||||
.current_dir(dir)
|
||||
|
|
@ -216,91 +214,19 @@ pub async fn ensure_meta_remote(name: &str) -> Result<()> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Mirror agent `name`'s applied config repo — every status tag
|
||||
/// (`proposal` / `approved` / `building` / `deployed` / `failed` /
|
||||
/// `denied`) plus a best-effort `main` — to `agent-configs/<name>` on
|
||||
/// the local forge. Best-effort: returns Err which callers log + ignore.
|
||||
/// No-op when the forge isn't seeded or the applied repo doesn't exist.
|
||||
///
|
||||
/// Call this after every hive-c0re mutation of an applied repo's refs
|
||||
/// so the forge copy always reflects what core actually did.
|
||||
///
|
||||
/// **Two separate pushes, not one.** The status tags are id-suffixed
|
||||
/// (`deployed/<id>`, …) and add-only, so they must always land. `main`,
|
||||
/// by contrast, is branch-protected and advances through an operator's
|
||||
/// merge on the forge — so a mirror push of an already-established `main`
|
||||
/// is routinely rejected (protected-branch, or non-fast-forward). Git's pre-receive
|
||||
/// hook is all-or-nothing: bundling both refspecs in one push means that
|
||||
/// `main` reject declines the whole push, dropping the tags too.
|
||||
/// So we push the tags on their own first, then attempt `main`
|
||||
/// separately and swallow the expected reject. The `main` push still
|
||||
/// matters for the initial seed of a fresh (empty) config repo, where it
|
||||
/// creates `main`. Operator-driven divergence fix: the `hivectl forge
|
||||
/// reconcile-config` command.
|
||||
///
|
||||
/// The tokenised URL is passed straight to `git push` and deliberately
|
||||
/// never stored as a named remote: the applied repo is bind-mounted
|
||||
/// READ-ONLY into the manager container (`/applied`), so a token in
|
||||
/// `.git/config` would leak core's admin credential to an agent.
|
||||
pub async fn push_config(name: &str) -> Result<()> {
|
||||
let Some(token) = core_token() else {
|
||||
return Ok(());
|
||||
};
|
||||
let dir = crate::paths::applied_dir(name);
|
||||
if !dir.join(".git").exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let url = forge_git_url(&format!("{CONFIG_ORG}/{name}"));
|
||||
let auth = core_auth_header(&token);
|
||||
// Tags first, in their own push, so they land regardless of main's fate.
|
||||
let out = run_config_push(&dir, &url, &auth, "refs/tags/*:refs/tags/*").await?;
|
||||
if !out.status.success() {
|
||||
anyhow::bail!(
|
||||
"git push tags {CONFIG_ORG}/{name} failed ({}): {}",
|
||||
out.status,
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
// Then main on its own — a protected-branch / non-ff reject of an
|
||||
// established main is expected (an operator's forge merge advances it);
|
||||
// only the initial empty-repo seed actually advances it here.
|
||||
let out = run_config_push(&dir, &url, &auth, "refs/heads/main:refs/heads/main").await?;
|
||||
if !out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
if stderr.contains("non-fast-forward")
|
||||
|| stderr.contains("protected branch")
|
||||
|| stderr.contains("pre-receive hook declined")
|
||||
{
|
||||
tracing::info!(
|
||||
%name,
|
||||
"forge: mirror push of main skipped — forge main is protected \
|
||||
and advances by an operator merge (expected); tags mirrored"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
anyhow::bail!(
|
||||
"git push main {CONFIG_ORG}/{name} failed ({}): {}",
|
||||
out.status,
|
||||
stderr.trim()
|
||||
);
|
||||
}
|
||||
tracing::info!(%name, "forge: mirrored applied config to agent-configs");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reseed a revived agent's `applied` repo — missing its `.git` entirely,
|
||||
/// e.g. after `destroy --purge` — by fetching `agent-configs/<name>`'s
|
||||
/// `main` from the forge, [`push_config`]'s mirror target. Unlike
|
||||
/// `proposed` (seeded once at first spawn and never touched again — see
|
||||
/// `lifecycle::setup_proposed`'s own doc comment), the forge mirror is
|
||||
/// kept current after every deploy, so this reflects the agent's actual
|
||||
/// last-deployed state rather than a stale creation-time snapshot.
|
||||
/// `main` from the forge. Unlike `proposed` (seeded once at first spawn and
|
||||
/// never touched again — see `lifecycle::setup_proposed`'s own doc
|
||||
/// comment), forge `main` advances with every operator-merged config PR, so
|
||||
/// this reflects the last merged config rather than a stale creation-time
|
||||
/// snapshot. A config deployed through hive-c0re without a forge merge never
|
||||
/// reaches it.
|
||||
///
|
||||
/// Best-effort, mirroring [`push_config`]'s own shape: returns `false`
|
||||
/// (not an error) rather than bailing when the forge is absent, the core
|
||||
/// token isn't minted yet, `applied` already has a `.git` (nothing to
|
||||
/// do), or any git step fails — callers fall back to a less-current
|
||||
/// source or the original hard error. Only `true` once `applied` is
|
||||
/// Best-effort: returns `false` (not an error) rather than bailing when the
|
||||
/// forge is absent, the core token isn't minted yet, `applied` already has a
|
||||
/// `.git` (nothing to do), or any git step fails — callers fall back to a
|
||||
/// less-current source or the original hard error. Only `true` once `applied` is
|
||||
/// actually seeded and tagged `deployed/0`, mirroring first-spawn's own
|
||||
/// `setup_applied` seed shape exactly.
|
||||
pub async fn fetch_config_main_into_applied(name: &str) -> bool {
|
||||
|
|
@ -486,23 +412,6 @@ pub async fn clone_config_into_proposed(dir: &Path, name: &str) -> bool {
|
|||
}
|
||||
}
|
||||
|
||||
/// Run a single `git push <url> <refspec>` in the applied repo `dir` and
|
||||
/// return the raw output for the caller to classify. Split out so
|
||||
/// [`push_config`] can push tags and `main` as independent pushes.
|
||||
async fn run_config_push(
|
||||
dir: &std::path::Path,
|
||||
url: &str,
|
||||
auth: &str,
|
||||
refspec: &str,
|
||||
) -> Result<std::process::Output> {
|
||||
crate::lifecycle::git_command_authed(auth)
|
||||
.current_dir(dir)
|
||||
.args(["push", url, refspec])
|
||||
.output()
|
||||
.await
|
||||
.context("invoke git push agent-configs")
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
|
|
|||
|
|
@ -274,9 +274,9 @@ pub async fn create_container(name: &str, hive: &HiveEnv, paths: &AgentPaths) ->
|
|||
/// (`job_queue::exec::run_purge_state`) — a later revive of the *same*
|
||||
/// agent name is still a rebuild, not a first-spawn, so it lands here, not
|
||||
/// in `provision_container`. Recover from `agent-configs/<name>` on the
|
||||
/// forge when that's the case (kept current after every deploy by
|
||||
/// `forge::push_config` — see `forge::fetch_config_main_into_applied`'s own
|
||||
/// doc comment) rather than bailing outright. Deliberately **no fallback to
|
||||
/// forge when that's the case (forge `main` is the last operator-merged
|
||||
/// config — see `forge::fetch_config_main_into_applied`'s own doc comment)
|
||||
/// rather than bailing outright. Deliberately **no fallback to
|
||||
/// `proposed`**: that repo is seeded once at first spawn and never touched
|
||||
/// again (`setup_proposed`'s own doc comment), so it can be arbitrarily
|
||||
/// stale for a long-lived agent — restoring from it would silently revive
|
||||
|
|
|
|||
|
|
@ -98,9 +98,8 @@ pub async fn pull(coord: &Coordinator) -> Result<()> {
|
|||
}
|
||||
// Rewrite any credentialed `origin` left by an older clone, which spliced
|
||||
// `core:<token>@` into the URL and persisted it in `.git/config` — the one
|
||||
// place this repo stored a token in a named remote, against the rule
|
||||
// `forge::repos::push_config` states. Harmless to repeat once the URL is
|
||||
// already clean.
|
||||
// place this repo stored a token in a named remote. Harmless to repeat
|
||||
// once the URL is already clean.
|
||||
let plain = crate::forge::forge_git_url(&format!("{ORG}/{REPO}"));
|
||||
let _ = crate::lifecycle::git_command()
|
||||
.args(["-C", LOCAL_DIR, "remote", "set-url", "origin", &plain])
|
||||
|
|
|
|||
Loading…
Reference in a new issue