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
|
### Forge mirror
|
||||||
|
|
||||||
The bundled `hive-forge` container runs on the swarm's forge host
|
The bundled `hive-forge` container runs on the swarm's forge host
|
||||||
(`deploy.forgejo.enable`, see [`../swarm/services.md`](../swarm/services.md)),
|
(`deploy.forgejo.enable`, see [`../swarm/services.md`](../swarm/services.md)).
|
||||||
and hive-c0re mirrors every agent's applied repo into a
|
Every agent has a repo in the private `agent-configs` Forgejo org, created
|
||||||
private `agent-configs` Forgejo org. `forge::push_config(<name>)` pushes
|
and seeded by swarm-controller. hive-c0re pushes nothing to it: the
|
||||||
every tag, then `applied/main`, to `agent-configs/<name>` on every
|
applied repo's tags (`deployed/0`) exist only locally, and forge `main`
|
||||||
startup sweep and every rebuild. Forge `main` is branch-protected, so
|
advances only by an operator's merge. hive-c0re
|
||||||
the forge routinely refuses a push of an established `main`, which
|
reads from it — reseeding a purged `applied`, catching `applied` up, and
|
||||||
hive-c0re expects. Pushes are best-effort — a missing or stopped forge never
|
seeding `proposed` at first spawn.
|
||||||
blocks a deploy.
|
|
||||||
|
|
||||||
Each agent is a **write collaborator on its own** `agent-configs/<name>`
|
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
|
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
|
the forge. Branch protection keeps the agent off the `main` push
|
||||||
allowlist and allowlists merging to the `operators` team only, so an
|
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
|
agent can't fast-forward its own config or self-merge its PR (see
|
||||||
[Config changes](#config-changes) above). hive-c0re passes the tokenised
|
[Config changes](#config-changes) above). hive-c0re passes the credential to
|
||||||
push URL inline to `git push`, never writing it into
|
each `git` call inline, never writing it into
|
||||||
`applied/<n>/.git/config`; that repo is RO-bind-mounted into the root
|
`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, and a stored token would leak core's admin credential to an
|
||||||
agent.
|
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
|
`main` directly nor self-merge. An operator's merge in the Forgejo UI
|
||||||
deploys the merged commit (see
|
deploys the merged commit (see
|
||||||
[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 never force-pushes: the `push_config` mirror pushes the
|
hive-c0re pushes nothing to this org; it only reads from it.
|
||||||
add-only status tags and `main` without force, and treats a refused
|
|
||||||
`main` push as expected.
|
|
||||||
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 `core/meta`.)
|
agent's config. (Agents remain read-only collaborators on `core/meta`.)
|
||||||
hive-c0re also references this repo as the agent's **persistent 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 reconcile::{fetch_forge_main, reconcile_config_apply, reconcile_config_status};
|
||||||
pub use repos::{
|
pub use repos::{
|
||||||
clone_config_into_proposed, ensure_meta_remote, ensure_repo, fast_forward_applied_main,
|
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;
|
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,
|
/// 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
|
/// 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
|
/// deploys through swarm-controller. The repos remain private, so an agent
|
||||||
/// still can't reach *another* agent's config. hive-c0re never force-pushes;
|
/// still can't reach *another* agent's config. hive-c0re only reads from it.
|
||||||
/// the `push_config` mirror runs best-effort.
|
|
||||||
pub(crate) const CONFIG_ORG: &str = "agent-configs";
|
pub(crate) const CONFIG_ORG: &str = "agent-configs";
|
||||||
/// Forgejo org hosting the operator-curated shared docs/skills repo
|
/// Forgejo org hosting the operator-curated shared docs/skills repo
|
||||||
/// that every agent gets read-only access to. Agents use it as a
|
/// 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")
|
Forgejo::new(Auth::Token(token), url).context("build forgejo api client")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-agent forge sync: mirror the agent's config repo, grant read access
|
/// Per-agent forge sync: grant read access to `core/meta`, and wire the
|
||||||
/// to `core/meta`, and wire the `meta` remote in its proposed repo. All operations are idempotent; failures
|
/// `meta` remote in its proposed repo. All operations are idempotent;
|
||||||
/// are logged as warnings but don't abort the caller.
|
/// failures are logged as warnings but don't abort the caller.
|
||||||
///
|
///
|
||||||
/// `core_token` is `core_token()` — passed in so callers that already
|
/// `core_token` is `core_token()` — passed in so callers that already
|
||||||
/// fetched it don't re-read the file. Pass `None` to skip the
|
/// 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).
|
/// 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 {
|
pub async fn sync_agent(name: &str, core_token: Option<&str>) -> bool {
|
||||||
// The agent's forge account, token and `agent-configs/<name>` repo are
|
// 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.
|
// to the repo.
|
||||||
let mut ok = true;
|
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
|
// Grant read-only access to core/meta and wire the `meta` remote
|
||||||
// into the proposed repo so agents can fetch their deployment context.
|
// into the proposed repo so agents can fetch their deployment context.
|
||||||
if let Some(token) = core_token
|
if let Some(token) = core_token
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
//! Repo + org plumbing on the local Forgejo: the meta + shared-docs +
|
//! Repo + org plumbing on the local Forgejo: the meta + shared-docs +
|
||||||
//! knowledge repos, per-agent config-repo
|
//! knowledge repos, meta mirroring (`push_meta`), collaborator grants,
|
||||||
//! mirroring (`push_config` / `push_meta`), collaborator grants,
|
|
||||||
//! pull-mirrors, and branch-protection rules. The typed API-client
|
//! pull-mirrors, and branch-protection rules. The typed API-client
|
||||||
//! constructor + org-name constants live in the module root (`super`).
|
//! 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
|
// operator directive, hive-c0re must not force-push anywhere. If
|
||||||
// the remote is somehow ahead (e.g. split-brain or manual push) we
|
// the remote is somehow ahead (e.g. split-brain or manual push) we
|
||||||
// raise a dashboard warning and leave the remote intact rather than
|
// raise a dashboard warning and leave the remote intact rather than
|
||||||
// silently erasing its history — consistent with `push_config`'s
|
// silently erasing its history.
|
||||||
// non-fast-forward handling.
|
|
||||||
let url = forge_git_url("core/meta");
|
let url = forge_git_url("core/meta");
|
||||||
let out = crate::lifecycle::git_command_authed(&core_auth_header(&token))
|
let out = crate::lifecycle::git_command_authed(&core_auth_header(&token))
|
||||||
.current_dir(dir)
|
.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,
|
/// 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
|
||||||
/// `main` from the forge, [`push_config`]'s mirror target. Unlike
|
/// `main` from the forge. Unlike `proposed` (seeded once at first spawn and
|
||||||
/// `proposed` (seeded once at first spawn and never touched again — see
|
/// never touched again — see `lifecycle::setup_proposed`'s own doc
|
||||||
/// `lifecycle::setup_proposed`'s own doc comment), the forge mirror is
|
/// comment), forge `main` advances with every operator-merged config PR, so
|
||||||
/// kept current after every deploy, so this reflects the agent's actual
|
/// this reflects the last merged config rather than a stale creation-time
|
||||||
/// last-deployed state rather than a stale creation-time snapshot.
|
/// snapshot. A config deployed through hive-c0re without a forge merge never
|
||||||
|
/// reaches it.
|
||||||
///
|
///
|
||||||
/// Best-effort, mirroring [`push_config`]'s own shape: returns `false`
|
/// Best-effort: returns `false` (not an error) rather than bailing when the
|
||||||
/// (not an error) rather than bailing when the forge is absent, the core
|
/// forge is absent, the core token isn't minted yet, `applied` already has a
|
||||||
/// token isn't minted yet, `applied` already has a `.git` (nothing to
|
/// `.git` (nothing to do), or any git step fails — callers fall back to a
|
||||||
/// do), or any git step fails — callers fall back to a less-current
|
/// less-current source or the original hard error. Only `true` once `applied` is
|
||||||
/// source or the original hard error. Only `true` once `applied` is
|
|
||||||
/// actually seeded and tagged `deployed/0`, mirroring first-spawn's own
|
/// actually seeded and tagged `deployed/0`, mirroring first-spawn's own
|
||||||
/// `setup_applied` seed shape exactly.
|
/// `setup_applied` seed shape exactly.
|
||||||
pub async fn fetch_config_main_into_applied(name: &str) -> bool {
|
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`.
|
/// Add `user` as a collaborator on `owner/repo` at `permission`.
|
||||||
/// Idempotent: adding an existing collaborator just updates its
|
/// Idempotent: adding an existing collaborator just updates its
|
||||||
/// permission (Forgejo answers 204 either way; a 201 from older
|
/// 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*
|
/// (`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
|
/// 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
|
/// in `provision_container`. Recover from `agent-configs/<name>` on the
|
||||||
/// forge when that's the case (kept current after every deploy by
|
/// forge when that's the case (forge `main` is the last operator-merged
|
||||||
/// `forge::push_config` — see `forge::fetch_config_main_into_applied`'s own
|
/// config — see `forge::fetch_config_main_into_applied`'s own doc comment)
|
||||||
/// doc comment) rather than bailing outright. Deliberately **no fallback to
|
/// rather than bailing outright. Deliberately **no fallback to
|
||||||
/// `proposed`**: that repo is seeded once at first spawn and never touched
|
/// `proposed`**: that repo is seeded once at first spawn and never touched
|
||||||
/// again (`setup_proposed`'s own doc comment), so it can be arbitrarily
|
/// again (`setup_proposed`'s own doc comment), so it can be arbitrarily
|
||||||
/// stale for a long-lived agent — restoring from it would silently revive
|
/// 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
|
// Rewrite any credentialed `origin` left by an older clone, which spliced
|
||||||
// `core:<token>@` into the URL and persisted it in `.git/config` — the one
|
// `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
|
// place this repo stored a token in a named remote. Harmless to repeat
|
||||||
// `forge::repos::push_config` states. Harmless to repeat once the URL is
|
// once the URL is already clean.
|
||||||
// already clean.
|
|
||||||
let plain = crate::forge::forge_git_url(&format!("{ORG}/{REPO}"));
|
let plain = crate::forge::forge_git_url(&format!("{ORG}/{REPO}"));
|
||||||
let _ = crate::lifecycle::git_command()
|
let _ = crate::lifecycle::git_command()
|
||||||
.args(["-C", LOCAL_DIR, "remote", "set-url", "origin", &plain])
|
.args(["-C", LOCAL_DIR, "remote", "set-url", "origin", &plain])
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue