swarm-controller: own the swarm-wide forge objects; hive-c0re stops creating them
The orgs agent-configs/internal/agents (plus mirror owners), the operators team in agents and agent-configs, the pull-mirrors, internal/docs, internal/knowledge (public, README-seeded) and the agent-configs org avatar are one set per forge. hive-c0re ensured them in its boot sweep, as the core admin, and only on the hive co-located with the forge container. swarm-controller now reconciles them at start and every 5 minutes (forge/objects.rs: observe -> pure plan -> apply). A failed object logs a warn line plus a pass summary and is retried next tick. create_repo ensures the agent-configs org and its operators team first, so a config repo's merge gate never depends on the periodic pass having run. hive-c0re drops ensure_org, SEEDED_ORGS, ensure_mirrors/ensure_mirror_repo, ensure_operators_team, ensure_shared_docs_repo, ensure_knowledge_repo/ set_repo_public, seed_readme, ensure_config_org_avatar and the one-shot knowledge::remove_webhook cleanup, with their now-unused helpers. nix: the mirror list moves from the hive-c0re unit (HYPERHIVE_FORGE_MIRRORS) to the swarm-controller unit (SWARM_CONTROLLER_FORGE_MIRRORS), with an eval warning when mirrors are declared on a host that runs no controller. c0re.orgAvatarPng is renamed to deploy.swarm-controller.configOrgAvatarPng. Refs #3782
This commit is contained in:
parent
85ba45b2de
commit
20419ccd41
18 changed files with 1456 additions and 822 deletions
|
|
@ -15,7 +15,9 @@
|
|||
//! `/webhook/knowledge`. A webhook has exactly one target URL, so with
|
||||
//! more than one hive that was last-writer-wins rather than idempotent —
|
||||
//! every hive but the most recent silently stopped receiving deliveries.
|
||||
//! [`remove_webhook`] is the migration off it.
|
||||
//!
|
||||
//! The repo itself (public, README-seeded) is the swarm controller's too;
|
||||
//! this module only clones and pulls it.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
|
|
@ -35,38 +37,12 @@ pub use crate::paths::KNOWLEDGE_DIR as LOCAL_DIR;
|
|||
/// read-only from [`LOCAL_DIR`] into every agent container.
|
||||
pub const CONTAINER_MOUNT: &str = "/knowledge";
|
||||
|
||||
/// Default README pushed to a freshly created `internal/knowledge` repo.
|
||||
/// Short explanation + empty table-of-contents with an HTML comment instructing contributors
|
||||
/// to add entries when they create new files.
|
||||
const README_CONTENT: &str = "\
|
||||
# knowledge
|
||||
|
||||
Hive-wide reference documents: conventions, runbooks, and anything that \
|
||||
every agent should know.
|
||||
|
||||
## How to contribute
|
||||
|
||||
1. Fork this repo into your own namespace on the forge.
|
||||
2. Create a branch, add or update a document.
|
||||
3. Open a pull request — the operator reviews and merges.
|
||||
4. Every agent container updates automatically on merge.
|
||||
|
||||
Do **not** push directly to `main` — agents have read-only access.
|
||||
|
||||
## Contents
|
||||
|
||||
<!-- Add an entry here each time you create a new document:
|
||||
- [Title](path/to/file.md) - one-line description
|
||||
-->
|
||||
";
|
||||
|
||||
/// Clone `internal/knowledge` to [`LOCAL_DIR`] if it is not already a git
|
||||
/// repository. `core_token` authenticates the HTTPS clone so private repos
|
||||
/// work. Idempotent — skips if `LOCAL_DIR/.git` exists.
|
||||
///
|
||||
/// When the upstream repo is empty (freshly created), seeds it with a
|
||||
/// README.md before returning so the local clone is always non-empty and
|
||||
/// agents see a useful starting document.
|
||||
/// The swarm controller seeds a README into a freshly created repo. A clone
|
||||
/// taken before that lands is empty; the next [`pull`] fills it.
|
||||
///
|
||||
/// Called once at hive-c0re startup after `forge::ensure_all`.
|
||||
pub async fn ensure_local_clone(core_token: &str) -> Result<()> {
|
||||
|
|
@ -87,129 +63,6 @@ pub async fn ensure_local_clone(core_token: &str) -> Result<()> {
|
|||
anyhow::bail!("git clone {ORG}/{REPO} failed: {stderr}");
|
||||
}
|
||||
tracing::info!("knowledge: cloned {ORG}/{REPO} to {LOCAL_DIR}");
|
||||
// If the repo is brand new (no commits), seed it with a README.
|
||||
let head_out = tokio::process::Command::new("git")
|
||||
.args(["-C", LOCAL_DIR, "rev-parse", "HEAD"])
|
||||
.output()
|
||||
.await
|
||||
.context("git rev-parse HEAD")?;
|
||||
if !head_out.status.success() {
|
||||
seed_readme(core_token).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write the initial README.md, commit, and push to `internal/knowledge`.
|
||||
/// Called only when the upstream repo is empty.
|
||||
async fn seed_readme(core_token: &str) -> Result<()> {
|
||||
let readme = std::path::Path::new(LOCAL_DIR).join("README.md");
|
||||
std::fs::write(&readme, README_CONTENT).context("write README.md")?;
|
||||
// Set a minimal git identity for the seed commit.
|
||||
for (k, v) in [("user.email", "core@hive"), ("user.name", "hive-c0re")] {
|
||||
let out = tokio::process::Command::new("git")
|
||||
.args(["-C", LOCAL_DIR, "config", k, v])
|
||||
.output()
|
||||
.await
|
||||
.with_context(|| format!("git config {k}"))?;
|
||||
if !out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_owned();
|
||||
anyhow::bail!("git config {k} failed: {stderr}");
|
||||
}
|
||||
}
|
||||
for args in [
|
||||
vec!["add", "README.md"],
|
||||
vec!["commit", "-m", "init: seed README"],
|
||||
] {
|
||||
let out = tokio::process::Command::new("git")
|
||||
.args(["-C", LOCAL_DIR].iter().chain(args.iter()))
|
||||
.output()
|
||||
.await
|
||||
.with_context(|| format!("git {args:?}"))?;
|
||||
if !out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_owned();
|
||||
anyhow::bail!("git {args:?} failed: {stderr}");
|
||||
}
|
||||
}
|
||||
let url = forge_git_url(&format!("{ORG}/{REPO}"));
|
||||
let out = crate::lifecycle::git_command_authed(&core_auth_header(core_token))
|
||||
.args(["-C", LOCAL_DIR, "push", &url, "HEAD:main"])
|
||||
.output()
|
||||
.await
|
||||
.context("git push knowledge README")?;
|
||||
if out.status.success() {
|
||||
tracing::info!("knowledge: seeded README.md and pushed to {ORG}/{REPO}");
|
||||
Ok(())
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_owned();
|
||||
anyhow::bail!("git push {ORG}/{REPO} failed: {stderr}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete this hive's own `internal/knowledge` push webhook if it is
|
||||
/// still registered, so the swarm controller is the only party holding
|
||||
/// one.
|
||||
///
|
||||
/// # Why this is a migration and not just a deletion
|
||||
///
|
||||
/// Not registering any more fixes nothing on a hive that has already
|
||||
/// run: the hook it created persists on the forge, so the contention
|
||||
/// this removes would survive on exactly the deployments that have it
|
||||
/// while fresh installs looked fixed. The hive that created a hook is
|
||||
/// the one that removes it.
|
||||
///
|
||||
/// # It removes only its OWN hook, never a neighbour's
|
||||
///
|
||||
/// The match is the full URL, not the `/webhook/knowledge` suffix. A
|
||||
/// hook with that suffix and a different base belongs to *another hive* —
|
||||
/// one that may not have been upgraded yet — and deleting it would break
|
||||
/// its knowledge sync until it was. Reaping a neighbour's registration is
|
||||
/// the very behaviour this issue is about; doing it in the name of fixing
|
||||
/// it would just invert the direction.
|
||||
///
|
||||
/// (The predecessor did reap by suffix, to clear loopback hooks left by
|
||||
/// an older single-hive layout. That was safe when a hive was alone on
|
||||
/// its forge and is not safe now.)
|
||||
///
|
||||
/// A listing failure is an error rather than a silent skip: there is no
|
||||
/// create attempt left to fall through to, so swallowing it would leave
|
||||
/// the hook in place with nothing said. The caller logs and continues —
|
||||
/// boot does not depend on this.
|
||||
pub async fn remove_webhook(core_token: &str, hive_domain: &str) -> Result<()> {
|
||||
// The typed client carries no per-request timeout, so each call is
|
||||
// wrapped in one: this runs as a detached startup task, and a forge
|
||||
// that accepts connections but never answers would otherwise hang it
|
||||
// forever.
|
||||
const HTTP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
let own_url = format!("https://{hive_domain}/webhook/knowledge");
|
||||
let client = crate::forge::api(core_token)?;
|
||||
|
||||
let hooks = tokio::time::timeout(HTTP_TIMEOUT, client.repo_list_hooks(ORG, REPO).all())
|
||||
.await
|
||||
.map_err(anyhow::Error::from)
|
||||
.and_then(|r| r.map_err(anyhow::Error::from))
|
||||
.with_context(|| format!("list webhooks for {ORG}/{REPO}"))?;
|
||||
|
||||
for h in &hooks {
|
||||
let hook_url = h
|
||||
.config
|
||||
.as_ref()
|
||||
.and_then(|c| c.get("url"))
|
||||
.map_or("", String::as_str);
|
||||
if hook_url == own_url
|
||||
&& let Some(id) = h.id
|
||||
{
|
||||
tokio::time::timeout(HTTP_TIMEOUT, client.repo_delete_hook(ORG, REPO, id).send())
|
||||
.await
|
||||
.map_err(anyhow::Error::from)
|
||||
.and_then(|r| r.map_err(anyhow::Error::from))
|
||||
.with_context(|| format!("delete webhook {id} for {ORG}/{REPO}"))?;
|
||||
tracing::info!(
|
||||
%own_url,
|
||||
"knowledge: removed this hive's push webhook — the swarm controller owns it now"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -256,8 +109,7 @@ pub async fn pull(coord: &Coordinator) -> Result<()> {
|
|||
.await;
|
||||
// Discard any local drift before pulling — both tracked (`reset --hard`)
|
||||
// and untracked (`clean -fd`). Nothing in this codebase writes to
|
||||
// `LOCAL_DIR` after the initial clone (`seed_readme` runs once, at
|
||||
// creation, before any pull) — this working tree exists to mirror
|
||||
// `LOCAL_DIR` after the initial clone — this working tree exists to mirror
|
||||
// `origin/main`, not to be edited in place. A tracked file left dirty
|
||||
// by any other means (a stray manual edit on the host, an interrupted
|
||||
// prior operation, or — the actual root cause here — two unsynchronized
|
||||
|
|
@ -280,7 +132,7 @@ pub async fn pull(coord: &Coordinator) -> Result<()> {
|
|||
.await;
|
||||
|
||||
let before = head_sha().await;
|
||||
// The repo is public (`ensure_knowledge_repo` makes it so), so an
|
||||
// The repo is public (the swarm controller makes it so), so an
|
||||
// unauthenticated pull is enough — but authenticate when a token is around,
|
||||
// which keeps this working if the repo is ever made private again.
|
||||
let auth = crate::forge::core_token().map(|t| core_auth_header(&t));
|
||||
|
|
|
|||
Loading…
Reference in a new issue