drop the applied repo: the hive keeps no copy of an agent's config, only the deploy markers. Refs #5913
This commit is contained in:
parent
deeb5ecd86
commit
862495530c
39 changed files with 190 additions and 1634 deletions
|
|
@ -41,22 +41,17 @@ No MCP tool exists for config changes: opening the PR IS the request.
|
|||
and publishes a deploy request carrying that commit on that hive's
|
||||
deploy subject. With no such hive, or more than one, it deploys
|
||||
nothing and logs a warning naming them.
|
||||
5. If the hive's `applied/main` already is that commit, it does nothing.
|
||||
Otherwise it fetches the config repo's `main`. A commit that
|
||||
`applied/main` already contains is a no-op too: requests for two merges
|
||||
can arrive in either order. A commit that descends from `applied/main`
|
||||
is fast-forwarded to and a rebuild queued. A failure before the rebuild
|
||||
(the fetch, or a commit that has diverged from `applied/main`) deploys
|
||||
nothing and logs `swarm events: rev not
|
||||
applied; nothing queued` at error, which fires the Grafana alert
|
||||
`config rev refused`. An agent with no container on that hive yet ignores
|
||||
the commit; its first deploy builds what it seeds.
|
||||
5. If the commit already is the one the agent's last successful build pinned
|
||||
(the `applied/.<n>.config-rev` marker), the hive does nothing. Otherwise it
|
||||
queues a rebuild, which resolves its pins from `internal/meta` like any
|
||||
rebuild. The request's commit only feeds that check: the hive keeps no copy
|
||||
of the config. An agent with no container on that hive yet ignores the
|
||||
commit; its first deploy builds whatever `internal/meta` pins.
|
||||
|
||||
This path runs **no eval-verify**. A merged config that doesn't
|
||||
evaluate or build fails the rebuild, and `applied/main` stays at that
|
||||
commit, so the agent's rebuilds keep failing until a fix merges. A
|
||||
failed rebuild shows on the hive like any other. A merge whose webhook delivery never reaches the controller
|
||||
deploys nothing.
|
||||
evaluate or build fails the rebuild, so the agent's rebuilds keep failing
|
||||
until a fix merges. A failed rebuild shows on the hive like any other. A merge
|
||||
whose webhook delivery never reaches the controller deploys nothing.
|
||||
|
||||
Creating a brand-new agent is swarm-level (`swarmctl agent create`,
|
||||
`POST /api/agents` on the swarm controller), and no hive can originate
|
||||
|
|
@ -100,12 +95,11 @@ body strings before calling it.
|
|||
|
||||
`HostRequest::Destroy { name, purge }` is the lifecycle tear-down.
|
||||
Stops + removes the nspawn container and drops the systemd drop-in.
|
||||
Persistent state (the applied repo, claude credentials, `/state/`
|
||||
notes) is
|
||||
Persistent state (claude credentials, `/state/` notes) is
|
||||
**kept by default** — recreating the agent with the same name
|
||||
reuses prior config + login. With `purge = true` the agent's
|
||||
`/var/lib/hyperhive/{agents,applied}/<name>/` trees are also
|
||||
wiped (config history + creds + notes gone forever). The
|
||||
reuses prior notes + login. With `purge = true` the agent's
|
||||
`/var/lib/hyperhive/agents/<name>/` tree is also
|
||||
wiped (creds + notes gone forever). The
|
||||
root/bootstrap container is destroyable like any other, and stays gone
|
||||
until you create ruth again from the swarm dashboard.
|
||||
|
||||
|
|
@ -123,8 +117,8 @@ agent's forge config repo, the one config PRs merge into) and one
|
|||
address) aren't in it:
|
||||
`hive-priv` writes them to the container's `/etc/hyperhive-hive-facts` at
|
||||
start (see `docs/process/conventions.md`).
|
||||
A config that sits only in the hive's `applied/<n>` mirror, not on forge
|
||||
`main`, never deploys.
|
||||
A config that sits only in a local checkout, not on forge `main`, never
|
||||
deploys.
|
||||
|
||||
What `hive-c0re` still keeps under `/var/lib/hyperhive/meta/` is plain
|
||||
state, bound read-only into the root agent's container as `/meta`:
|
||||
|
|
@ -184,59 +178,41 @@ the swarm builder built the agent from the updated repo (the
|
|||
reconcile ([below](#autoupdate-on-startup)) and the dashboard's update chip
|
||||
read them.
|
||||
|
||||
## The applied repo per agent
|
||||
## Deploy markers per agent
|
||||
|
||||
```
|
||||
/var/lib/hyperhive/applied/<name>/ applied - core-only
|
||||
├── .git/ # tag-rich history
|
||||
├── flake.nix # tracked, fixed
|
||||
│ # boilerplate exporting
|
||||
│ # nixosModules.default
|
||||
├── agent.nix # working tree of main
|
||||
└── <other committed files> # also tracked
|
||||
/var/lib/hyperhive/applied/ deploy markers - core-only
|
||||
├── .<name>.hyperhive-rev # hyperhive commit of the last good build
|
||||
└── .<name>.config-rev # config commit of the last good build
|
||||
|
||||
/var/lib/hyperhive/meta/ plain hive state - core
|
||||
├── topology.json # the live agent set
|
||||
└── swarm-resource-limits.json # the swarm's per-agent caps
|
||||
```
|
||||
|
||||
hive-c0re seeds the applied repo at first spawn from the forge's
|
||||
`agent-configs/<name>` `main`, or from a template when the forge has none yet,
|
||||
and fast-forwards it to each merged config commit that deploys. An agent never
|
||||
edits it: it isn't mounted into any container, and a config change is a PR
|
||||
against the agent's forge repo.
|
||||
Older hives also kept a *proposed* clone at `agents/<name>/config`. Nothing
|
||||
reads it any more, and you can delete it.
|
||||
hive-c0re keeps no copy of an agent's config: the forge's `agent-configs/<name>`
|
||||
`main` is the only one, and a config change is a PR against it. The two markers
|
||||
record what the agent's last successful build pinned, and nothing else.
|
||||
|
||||
Older hives also kept an *applied* repo at `applied/<name>` and a *proposed*
|
||||
clone at `agents/<name>/config`. The startup migration removes the applied
|
||||
repos. Nothing reads the proposed clone any more, and you can delete it.
|
||||
|
||||
The container's `--flake` ref is `internal/meta` (see "Meta flake" above).
|
||||
The agent's own `applied/<n>/flake.nix`
|
||||
The agent's `agent-configs/<n>/flake.nix`
|
||||
is a fixed boilerplate that exports `nixosModules.default =
|
||||
import ./agent.nix`; the meta flake imports that module and
|
||||
wraps it with identity + `HIVE_PORT` / `HIVE_LABEL`.
|
||||
|
||||
### Tag state machine
|
||||
|
||||
hive-c0re plants `deployed/0` on the seed commit at first spawn. A
|
||||
merged config commit that deploys plants no tag: the merged PR on the
|
||||
forge and `internal/meta`'s commits record it. A config PR nobody merges
|
||||
carries no extra state on the forge side: the PR stays open, and
|
||||
the submitter pushes again (or closes it) to retry.
|
||||
|
||||
### Forge config repos
|
||||
|
||||
The bundled `hive-forge` container runs on the swarm's forge host
|
||||
(`deploy.forgejo.enable`, see [`../swarm/services.md`](../swarm/services.md)).
|
||||
Every agent created through the swarm 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
|
||||
and seeding `applied` at first spawn (`forge::fetch_config_main_into_applied`).
|
||||
|
||||
A legacy agent spawned by the hive alone has no swarm-seeded repo; if its
|
||||
`agent-configs/<name>` has no `main`, a first spawn falls back to the local
|
||||
template, and reseeding a purged `applied` fails with a `destroy --purge`
|
||||
and re-spawn instruction.
|
||||
hive-c0re pushes nothing to it and keeps no clone: forge `main` advances only
|
||||
by an operator's merge. The hive reads the head of `main`, to resolve the
|
||||
commit `internal/meta` pins.
|
||||
|
||||
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
|
||||
|
|
@ -245,7 +221,7 @@ 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 credential to
|
||||
each `git` call inline, never writing it into `applied/<n>/.git/config`, so
|
||||
each `git` call inline, never writing it into a repo's config, so
|
||||
no stored token sits in a repo an agent might one day read.
|
||||
|
||||
The dashboard deep-links into this org — a `config repo` link
|
||||
|
|
@ -291,15 +267,14 @@ it never touches a key that's already set.
|
|||
## Startup migrations (older hosts)
|
||||
|
||||
hive-c0re runs a couple of idempotent migrations on every startup so a
|
||||
host set up before the tag-driven-deploy scheme (described above)
|
||||
host set up before the current deploy scheme (described above)
|
||||
converges to it automatically. Each phase is a no-op
|
||||
once already applied:
|
||||
|
||||
- **Tags**: hive-c0re tags agents from before the tag-driven scheme
|
||||
`deployed/0` on `main` once. Non-destructive — it doesn't touch live
|
||||
containers, state dirs, or claude creds.
|
||||
- **Agent files**: rewrites each `applied/<n>/flake.nix` to the
|
||||
module-only boilerplate and refreshes the files that follow the agent set
|
||||
- **Applied repos**: deletes the per-agent `applied/<n>` repos an earlier hive
|
||||
kept. The deploy markers beside them stay. Non-destructive for everything
|
||||
else — it doesn't touch live containers, state dirs, or claude creds.
|
||||
- **Agent files**: refreshes the files that follow the agent set
|
||||
(`agent_set::refresh`) from the current agent list. Set
|
||||
`HIVE_SKIP_META_MIGRATION=1` on the service to defer this phase.
|
||||
- **Legacy meta repo**: deletes the leftover `.git` in
|
||||
|
|
@ -308,7 +283,7 @@ once already applied:
|
|||
stay. The forge-side `core/meta` repo and `core` account aren't touched.
|
||||
|
||||
No state loss in either migration: claude creds, `/state/` notes, the
|
||||
events DB, and the applied history all survive. The root
|
||||
events DB all survive. The root
|
||||
agent keeps its session; sub-agents stay logged in.
|
||||
|
||||
## The root/bootstrap container
|
||||
|
|
@ -316,8 +291,7 @@ agent keeps its session; sub-agents stay logged in.
|
|||
The root agent container runs through the **same lifecycle as
|
||||
sub-agents**. Nothing creates it at hive boot: you create ruth from the
|
||||
swarm dashboard like any agent, and an existing ruth container starts with
|
||||
the hive like every other. The root agent's flake lives at
|
||||
`/var/lib/hyperhive/applied/ruth/`. The root agent can clone
|
||||
the hive like every other. The root agent can clone
|
||||
`agent-configs/ruth` to read its own `agent.nix` and open a config PR there
|
||||
for operator review, same as any other agent.
|
||||
|
||||
|
|
@ -414,8 +388,8 @@ config, that rebuild fails with the error that names the agent, and the
|
|||
agent stays stopped until the update lands and you ask for the start again.
|
||||
|
||||
The dashboard surfaces pending config changes per agent: a clickable
|
||||
"needs update ↻" badge appears whenever the agent's applied repo has a
|
||||
commit that its last build didn't pin. The badge POSTs `/api/rebuild/<name>`, which inserts the
|
||||
"needs update ↻" badge appears whenever the config commit that `internal/meta`
|
||||
pins for the agent's next build differs from the one its last build pinned. The badge POSTs `/api/rebuild/<name>`, which inserts the
|
||||
same `job_queue::templates::rebuild` DAG so manual triggers and the
|
||||
startup scan can't drift. When at least one container is stale, a
|
||||
top-level `↻ UPD4TE 4LL` button appears that loops over every
|
||||
|
|
|
|||
|
|
@ -543,12 +543,12 @@ whenever the forge has moved on.
|
|||
Isolation still holds by default: a container has its *own* dirs and,
|
||||
unless it holds the permission, nothing else.
|
||||
|
||||
Under `/var/lib/hyperhive/applied/<name>/` — the hive-c0re-only
|
||||
applied repo. Tracks `flake.nix` (module-only boilerplate; never
|
||||
edited after first spawn) + `agent.nix` (the actual config; edits land
|
||||
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/applied/` — the hive-c0re-only deploy markers
|
||||
`.<name>.hyperhive-rev` and `.<name>.config-rev`: the hyperhive and config
|
||||
commits the agent's last successful build pinned. No config lives here; the
|
||||
forge's `agent-configs/<name>` is the only copy. A hive that predates this
|
||||
finds per-agent `applied/<name>` repos here; `migrate::run` deletes them at
|
||||
startup.
|
||||
|
||||
Under `/var/lib/hyperhive/meta/` — system-level config files, plain state
|
||||
with no git history. The flake agents build from is the
|
||||
|
|
@ -594,7 +594,7 @@ reads it at every container start for `manage_root_agent`.
|
|||
`/var/lib/hyperhive/swarm-agent-archetypes.json` holds the archetype each of
|
||||
this hive's archetype agents runs, as last read from the swarm's
|
||||
`agent-archetype` bucket at the agent's provision and each rebuild. An agent
|
||||
with an entry has no `applied/<n>` repo and no `agent-<n>` meta input; see
|
||||
with an entry has no config repo and no `agent-<n>` meta input; see
|
||||
[archetypes](../swarm/archetypes.md).
|
||||
|
||||
hive-c0re creates containers from `internal/meta` (`nixosConfigurations.<n>`).
|
||||
|
|
@ -607,7 +607,7 @@ an agent's state. The mechanics, for completeness:
|
|||
- `DESTR0Y` also drops the systemd drop-in; reviving the agent is
|
||||
`swarmctl agent create` with the same name and hive, which reuses the
|
||||
kept state.
|
||||
- `PURG3` wipes `/var/lib/hyperhive/{agents,applied}/<name>/` — the
|
||||
- `PURG3` wipes `/var/lib/hyperhive/agents/<name>/` — the
|
||||
union of everything `DESTR0Y` left behind.
|
||||
|
||||
`actions::destroy` has no guard for the root/bootstrap agent: it destroys ruth
|
||||
|
|
@ -636,7 +636,7 @@ until an explicit opt-in upgrade.
|
|||
a subvolume root, so purge first calls hive-priv
|
||||
(`DeleteAgentSubvolume`) which `btrfs subvolume delete`s it iff it's
|
||||
actually a subvolume, then the normal `remove_dir_all` sweep covers
|
||||
plain-dir agents + the applied dir.
|
||||
plain-dir agents.
|
||||
|
||||
Per-subvolume disk-usage accounting and optional quotas have since
|
||||
landed as the qgroup work: `hivectl quota-enable` turns on btrfs
|
||||
|
|
|
|||
|
|
@ -63,10 +63,7 @@ store certificate, every time it reads the forge. A hive whose store holds
|
|||
none reads nothing. These reads go through it, and none of them needs a local
|
||||
forge container:
|
||||
|
||||
- fetching a merged config commit for a swarm deploy, and
|
||||
`hivectl forge reconcile-config`
|
||||
- reseeding a purged `applied` repo
|
||||
- seeding `applied` from `agent-configs/<agent>` on first deploy
|
||||
- resolving the config commit `internal/meta` pins for a build
|
||||
- cloning and pulling `/knowledge`
|
||||
|
||||
The hive builds nothing, so no nix process reads the forge as the hive's
|
||||
|
|
|
|||
|
|
@ -206,7 +206,7 @@ means anything that has ever leaked into a task's captured output is
|
|||
in the retained snapshots as well.
|
||||
<!-- vale write-good.Passive = YES -->
|
||||
|
||||
It does **not** cover the agent's applied config (`/applied/<name>/`) or
|
||||
It does **not** cover the agent's config (the forge's `agent-configs/<name>`) or
|
||||
its topology entry, both of which live outside the subvolume. A restore
|
||||
therefore yields an agent's memory without its definition.
|
||||
|
||||
|
|
|
|||
|
|
@ -402,8 +402,8 @@ sets `EXTRA_NSPAWN_FLAGS` — plus the systemd resource-limits drop-in)
|
|||
into the `Swap` node, then runs `nixos-container update` + stop +
|
||||
start across the `StopForUpdate → Swap → RebuildBookkeeping`
|
||||
brace and the tail `Reconcile` node. `flake.nix` itself lives in the agent's
|
||||
the applied repo and rides along on every fetch (see
|
||||
`docs/agent-lifecycle/config-changes.md::The applied repo per agent`).
|
||||
`agent-configs/<name>` repo and rides along with its pinned commit (see
|
||||
`docs/agent-lifecycle/config-changes.md::Deploy markers per agent`).
|
||||
|
||||
Anything that changes per-container state on the host should be
|
||||
re-applied here so a manual `↻ R3BU1LD` from the dashboard is
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ Cheap — no build slot:
|
|||
| Node | Behavior |
|
||||
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `MetaSync` | the rebuild's preamble — rebuild-dir prep, the agent-set refresh (`agent_set::refresh`), and the `internal/meta` commit `Prebuild` and `Swap` expect the swarm's closure to be built from, resolved once and recorded; fails when the repo doesn't pin the agent's current config. Declares no resources |
|
||||
| `Provision` | first-spawn pre-create provisioning — the applied repo, state subvolume, agent-set refresh (`agent_set::refresh`); runs ahead of `Create`. Store-only, no container yet |
|
||||
| `Provision` | first-spawn pre-create provisioning — the state subvolume, agent-set refresh (`agent_set::refresh`); runs ahead of `Create`. Store-only, no container yet |
|
||||
| `Reconcile` | idempotent power converge: read `wanted` (below) + observed state; start if `Up` & down (cold-start fallback included), stop if `Offline` & up, else noop |
|
||||
| `Start` | mechanical container start — runtime dir + drop-ins, `start_with_fallback`, MCP listener registration, the manager kick. Fanned out by a `Reconcile` that observed `wanted = Up` and the container down |
|
||||
| `Stop` | mechanical container stop — `nixos-container` kill, MCP listener unregister, the `Killed` manager notify. Fanned out by a `Reconcile` that observed `wanted = Offline` and up |
|
||||
|
|
@ -67,7 +67,7 @@ Cheap — no build slot:
|
|||
| `PauseSignal` | write the pause marker + mark `pause_pending`. No kick, unlike `Signal` — the harness's between-turns poll is already responsive enough, and `Signal`'s kick-message body ("you were just (re)started") would be actively misleading here |
|
||||
| `PauseDrain` | await the harness reporting `PauseAcknowledged`, bounded timeout; best-effort like `Drain` |
|
||||
| `DestroyContainer` | `nixos-container destroy` + un-registration (drop from the roster, clear the ephemeral runtime dir). Runs downstream of a `Stop`, so deliberately excluded from `takes_container_down` — the container is already down by the time it claims |
|
||||
| `PurgeState` | the `purge = true` half of a destroy: delete the agent's state subvolume (via hive-priv) plus its state/applied dirs. Own node because it's conditional and the irreversible step |
|
||||
| `PurgeState` | the `purge = true` half of a destroy: delete the agent's state subvolume (via hive-priv) plus its state dir. Own node because it's conditional and the irreversible step |
|
||||
| `DestroyBookkeeping` | the post-destroy tail — meta sync, drop the power intent, notify the manager, rescan, re-emit the tombstone. Same split rationale as `RebuildBookkeeping`/`Swap`. The destructive work is `PurgeState`'s |
|
||||
| `SetWanted` | write the durable power intent (`wanted = Up`/`Offline`) as the head node of a power-op DAG. Takes the agent lease even though it's a store write, so the intent write and the tail `Reconcile` are atomic per-agent — two racing power ops can't clobber each other's intent before either reconciles |
|
||||
| `EmitRebuilt` | tail of a rebuild — emit the agent's `Rebuilt` manager event (ok/fail per outcome, nothing on cancel). One node per agent _and_ per outcome |
|
||||
|
|
|
|||
|
|
@ -130,9 +130,7 @@ provisions the agent and on every rebuild, and keeps the last value read in
|
|||
it has now. When the read fails, a rebuild keeps the stored value. For an
|
||||
archetype agent the hive also:
|
||||
|
||||
- seeds no `proposed` or `applied` repo;
|
||||
- ignores the `rev` of a swarm deploy request;
|
||||
- mounts an empty `/agents/<name>/config/`.
|
||||
- ignores the `rev` of a swarm deploy request.
|
||||
|
||||
The agent's system prompt says it has no config repo and names its
|
||||
archetype.
|
||||
|
|
|
|||
|
|
@ -5,8 +5,6 @@ This document contains the help content for the `hivectl` command-line program.
|
|||
**Command Overview:**
|
||||
|
||||
* [`hivectl`↴](#hivectl)
|
||||
* [`hivectl forge`↴](#hivectl-forge)
|
||||
* [`hivectl forge reconcile-config`↴](#hivectl-forge-reconcile-config)
|
||||
* [`hivectl agent`↴](#hivectl-agent)
|
||||
* [`hivectl agent restart`↴](#hivectl-agent-restart)
|
||||
* [`hivectl agent pause`↴](#hivectl-agent-pause)
|
||||
|
|
@ -49,7 +47,6 @@ Sibling to the `hive-c0re` daemon binary. Covers host-side admin operations that
|
|||
|
||||
###### **Subcommands:**
|
||||
|
||||
* `forge` — Reconcile an agent's config between this hive and the forge
|
||||
* `agent` — Lifecycle actions on ONE managed agent container. Needs the hive-c0re daemon running
|
||||
* `list-agents` — Show all managed agents with their status and technical state
|
||||
* `quota-enable` — Enable btrfs qgroup accounting on the agent-state filesystem
|
||||
|
|
@ -69,46 +66,6 @@ Sibling to the `hive-c0re` daemon binary. Covers host-side admin operations that
|
|||
|
||||
|
||||
|
||||
## `hivectl forge`
|
||||
|
||||
Reconcile an agent's config between this hive and the forge.
|
||||
|
||||
A human's first SSO login to the forge makes their forge account.
|
||||
|
||||
**Usage:** `hivectl forge <COMMAND>`
|
||||
|
||||
###### **Subcommands:**
|
||||
|
||||
* `reconcile-config` — Show + reconcile the divergence between an agent's local applied config checkout and its forge `agent-configs/<agent>` main
|
||||
|
||||
|
||||
|
||||
## `hivectl forge reconcile-config`
|
||||
|
||||
Show + reconcile the divergence between an agent's local applied config checkout and its forge `agent-configs/<agent>` main.
|
||||
|
||||
Always prints the diff first. `--from forge` resets the local checkout to forge main (effective on the next deploy); `--from local` isn't supported yet. With no `--from`, prompts for the direction.
|
||||
|
||||
**Usage:** `hivectl forge reconcile-config [OPTIONS] <AGENT>`
|
||||
|
||||
###### **Arguments:**
|
||||
|
||||
* `<AGENT>` — Agent whose config branches to reconcile
|
||||
|
||||
###### **Options:**
|
||||
|
||||
* `--from <FROM>` — Which side to reconcile from. Omit it and hivectl prompts you after the diff
|
||||
|
||||
Possible values:
|
||||
- `forge`:
|
||||
Reset the local applied checkout to forge main
|
||||
- `local`:
|
||||
Advance forge main from local — not supported yet
|
||||
|
||||
* `--verbose` — Include the full diff (not just `--stat`) in the report
|
||||
|
||||
|
||||
|
||||
## `hivectl agent`
|
||||
|
||||
Lifecycle actions on ONE managed agent container. Needs the hive-c0re daemon running.
|
||||
|
|
|
|||
|
|
@ -23,24 +23,10 @@ markdown-docs > docs/tools/hivectl-cli.md`.
|
|||
|
||||
## Forge
|
||||
|
||||
Reconciles an agent's config between this hive and the forge. `hivectl`
|
||||
makes no forge accounts: a human's first SSO login to the forge makes
|
||||
theirs, and `swarmctl forge make-admin <name>` makes it a site admin.
|
||||
swarm-controller makes an agent's, and `swarmctl agent mint-forge-token
|
||||
<agent>` checks and, if needed, re-mints its token.
|
||||
|
||||
```bash
|
||||
hivectl forge reconcile-config iris # show local-applied <-> forge config divergence, then prompt
|
||||
hivectl forge reconcile-config iris --from forge # reset local applied checkout to forge main (effective next deploy)
|
||||
hivectl forge reconcile-config iris --verbose # include the full diff, not just --stat
|
||||
```
|
||||
|
||||
- `reconcile-config <agent>` shows the divergence between the agent's local
|
||||
applied config checkout and its forge `agent-configs/<agent>` `main`, then
|
||||
reconciles. `--from forge` resets the local checkout to forge `main` (takes
|
||||
effect on the next deploy — it doesn't autorebuild). `--from local` isn't
|
||||
supported yet (forge `main` is branch-protected; resolve via a config
|
||||
PR). With no `--from` it prompts for the direction after the diff.
|
||||
`hivectl` has no forge verbs and makes no forge accounts: a human's first SSO
|
||||
login to the forge makes theirs, and `swarmctl forge make-admin <name>` makes it
|
||||
a site admin. swarm-controller makes an agent's, and `swarmctl agent
|
||||
mint-forge-token <agent>` checks and, if needed, re-mints its token.
|
||||
|
||||
## Agents
|
||||
|
||||
|
|
|
|||
|
|
@ -81,12 +81,12 @@ same figures go out as OTEL metrics and appear in Grafana (see
|
|||
caps live per agent in the swarm UI.
|
||||
|
||||
**K3PT ST4T3** — agents with kept state and **no container** (size + age +
|
||||
claude-creds badge). One action: `PURG3` (wipes state + applied
|
||||
dirs; `POST /api/purge-tombstone/{name}`).
|
||||
claude-creds badge). One action: `PURG3` (wipes the state
|
||||
dir; `POST /api/purge-tombstone/{name}`).
|
||||
|
||||
⚠️ **Not only *destroyed* agents.** Nothing records a destroy: `Provision`
|
||||
writes every definition-side artifact (state subvolume, applied
|
||||
repo, meta registration, topology entry) *before* the container exists,
|
||||
writes every definition-side artifact (state subvolume,
|
||||
meta registration, topology entry) *before* the container exists,
|
||||
and that artifact set survives `lifecycle::destroy`. An agent
|
||||
part-way through a spawn is byte-identical on disk to a tombstone, and
|
||||
the pane lists both. The pane carries a standing warning to that effect, and
|
||||
|
|
|
|||
|
|
@ -5,127 +5,16 @@
|
|||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
|
||||
use crate::coordinator::Coordinator;
|
||||
use crate::lifecycle;
|
||||
|
||||
/// `applied/main`, and whether `target` descends from it. Fast-forwarding
|
||||
/// `main` to a `target` that does not is a rewind: it drops every commit on
|
||||
/// `main` that `target` lacks.
|
||||
async fn applied_main_under(applied_dir: &std::path::Path, target: &str) -> Result<(String, bool)> {
|
||||
let current_main = lifecycle::git_rev_parse(applied_dir, "refs/heads/main")
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("read applied/main: {e:#}"))?;
|
||||
let descends = lifecycle::git_is_ancestor(applied_dir, ¤t_main, target)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("ancestry check {current_main}..{target}: {e:#}"))?;
|
||||
Ok((current_main, descends))
|
||||
}
|
||||
|
||||
/// What [`advance_applied_to_rev`] did to `applied/main`.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(crate) enum RevAdvance {
|
||||
/// `applied/main` already was the rev, so there is nothing to deploy.
|
||||
AlreadyApplied,
|
||||
/// `applied/main` was fast-forwarded to the rev.
|
||||
Advanced,
|
||||
/// The rev is an ancestor of `applied/main`, so a later commit already
|
||||
/// carries it and there is nothing to deploy. Deploy requests are not
|
||||
/// ordered: two merges' requests can arrive in either order, and a
|
||||
/// forge fetch fast-forwards `main` to the forge's on its own.
|
||||
Superseded,
|
||||
}
|
||||
|
||||
/// Fast-forward `agent`'s `applied/main` to `rev`, a commit the swarm asks
|
||||
/// this hive to deploy, so the dashboard's update chip compares against it.
|
||||
///
|
||||
/// No eval-verify on this path: a config that does not evaluate fails the
|
||||
/// rebuild instead, with `applied/main` already at `rev`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the forge fetch fails, if `rev` has diverged from
|
||||
/// `applied/main` (neither descendant nor ancestor), or if `main` moved during the fast-forward. `main` does not
|
||||
/// move in any of these.
|
||||
pub(crate) async fn advance_applied_to_rev(agent: &str, rev: &str) -> Result<RevAdvance> {
|
||||
advance_applied_main(
|
||||
&crate::paths::applied_dir(agent),
|
||||
rev,
|
||||
crate::forge::fetch_forge_main(agent),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// [`advance_applied_to_rev`] with the forge fetch passed in, so it is
|
||||
/// testable against a local repo.
|
||||
///
|
||||
/// `applied/main` is compared to `rev` twice: before the fetch, so an applied
|
||||
/// rev costs no forge round-trip, and after it, because a forge fetch
|
||||
/// fast-forwards `main` to the forge's and may do so meanwhile.
|
||||
async fn advance_applied_main<T>(
|
||||
applied_dir: &std::path::Path,
|
||||
rev: &str,
|
||||
fetch: impl std::future::Future<Output = Result<T>>,
|
||||
) -> Result<RevAdvance> {
|
||||
let main = lifecycle::git_rev_parse(applied_dir, "refs/heads/main")
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("read applied/main: {e:#}"))?;
|
||||
if main == rev {
|
||||
return Ok(RevAdvance::AlreadyApplied);
|
||||
}
|
||||
fetch.await.context("fetch the forge config main")?;
|
||||
let (main, descends) = applied_main_under(applied_dir, rev).await?;
|
||||
if main == rev {
|
||||
return Ok(RevAdvance::AlreadyApplied);
|
||||
}
|
||||
if !descends {
|
||||
let contained = lifecycle::git_is_ancestor(applied_dir, rev, &main)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("ancestry check {rev}..{main}: {e:#}"))?;
|
||||
if contained {
|
||||
return Ok(RevAdvance::Superseded);
|
||||
}
|
||||
bail!(
|
||||
"{rev} does not descend from applied/main ({main}); fast-forwarding to it would \
|
||||
discard commits"
|
||||
);
|
||||
}
|
||||
ff_applied_main(applied_dir, rev, &main).await?;
|
||||
Ok(RevAdvance::Advanced)
|
||||
}
|
||||
|
||||
/// Fast-forward `applied/main` to `target` and sync the working tree.
|
||||
///
|
||||
/// Compare-and-swap, not a bare set: `main` must still be `expected_main`, the
|
||||
/// sha the caller's ancestry gate ([`applied_main_under`]) ran against. A plain
|
||||
/// `update-ref` moves the branch to `target` whatever it currently points at,
|
||||
/// which turns "fast-forward" into "discard anything that landed in the
|
||||
/// meantime" — the gate only proves `target` safe against the `main` observed
|
||||
/// *then*, so this is what makes that proof still true *now*.
|
||||
async fn ff_applied_main(
|
||||
applied_dir: &std::path::Path,
|
||||
target: &str,
|
||||
expected_main: &str,
|
||||
) -> Result<()> {
|
||||
lifecycle::git_update_ref_cas(applied_dir, "refs/heads/main", target, expected_main)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
anyhow::anyhow!("ff main {expected_main} -> {target} (concurrent move?): {e:#}")
|
||||
})?;
|
||||
lifecycle::git_read_tree_reset(applied_dir, "refs/heads/main")
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("read-tree to main: {e:#}"))
|
||||
}
|
||||
|
||||
/// Tear down a sub-agent container. By default this is non-destructive to
|
||||
/// persistent state: the applied config repo and the Claude
|
||||
/// credentials dir under `/var/lib/hyperhive/{agents,applied}/<name>/` are
|
||||
/// kept, so recreating an agent of the same name reuses prior config + creds
|
||||
/// persistent state: the state dir and the Claude
|
||||
/// credentials dir under `/var/lib/hyperhive/agents/<name>/` are
|
||||
/// kept, so recreating an agent of the same name reuses prior notes + creds
|
||||
/// (no re-login). The ephemeral runtime dir under `/run/hyperhive/agents/`
|
||||
/// is cleared because its contents (the mcp socket) don't survive restarts
|
||||
/// anyway. With `purge=true` the persistent trees are also wiped — config
|
||||
/// history, claude creds, notes — there is no undo.
|
||||
/// anyway. With `purge=true` the persistent trees are also wiped — claude
|
||||
/// creds, notes — there is no undo.
|
||||
/// The bootstrap/root container is destroyable like any other. Nothing
|
||||
/// recreates it afterwards: ruth stays gone until she is created again from
|
||||
/// the swarm dashboard.
|
||||
|
|
@ -144,113 +33,3 @@ pub fn destroy(coord: &Arc<Coordinator>, name: &str, purge: bool) {
|
|||
}
|
||||
coord.emit_rebuild_queue_snapshot();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{RevAdvance, advance_applied_main};
|
||||
use crate::lifecycle;
|
||||
|
||||
/// A repo with `main` at `b`, child of `a`, and `c`, a sibling of `b` off
|
||||
/// `a`. Returns the dir and the shas `[a, b, c]`.
|
||||
async fn repo() -> (tempfile::TempDir, [String; 3]) {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path();
|
||||
let commit = |msg: &'static str| async move {
|
||||
lifecycle::git(
|
||||
path,
|
||||
&[
|
||||
"-c",
|
||||
"user.name=t",
|
||||
"-c",
|
||||
"user.email=t@t",
|
||||
"commit",
|
||||
"-q",
|
||||
"--allow-empty",
|
||||
"-m",
|
||||
msg,
|
||||
],
|
||||
)
|
||||
.await
|
||||
.expect("commit");
|
||||
lifecycle::git_rev_parse(path, "HEAD").await.expect("HEAD")
|
||||
};
|
||||
lifecycle::git(path, &["init", "-q", "--initial-branch=main"])
|
||||
.await
|
||||
.expect("init");
|
||||
let a = commit("a").await;
|
||||
let b = commit("b").await;
|
||||
lifecycle::git(path, &["checkout", "-q", "-b", "side", &a])
|
||||
.await
|
||||
.expect("branch");
|
||||
let c = commit("c").await;
|
||||
lifecycle::git(path, &["checkout", "-q", "main"])
|
||||
.await
|
||||
.expect("checkout main");
|
||||
(dir, [a, b, c])
|
||||
}
|
||||
|
||||
async fn main_of(dir: &tempfile::TempDir) -> String {
|
||||
lifecycle::git_rev_parse(dir.path(), "refs/heads/main")
|
||||
.await
|
||||
.expect("main")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_applied_rev_is_a_no_op_without_a_fetch() {
|
||||
let (dir, [_, b, _]) = repo().await;
|
||||
let fetched = std::cell::Cell::new(false);
|
||||
let fetch = async {
|
||||
fetched.set(true);
|
||||
anyhow::Ok(())
|
||||
};
|
||||
let outcome = advance_applied_main(dir.path(), &b, fetch).await;
|
||||
assert_eq!(outcome.expect("no-op"), RevAdvance::AlreadyApplied);
|
||||
assert!(!fetched.get(), "an applied rev must not reach the forge");
|
||||
assert_eq!(main_of(&dir).await, b);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_rev_not_descending_from_main_is_refused_and_main_stays() {
|
||||
let (dir, [_, b, c]) = repo().await;
|
||||
let outcome = advance_applied_main(dir.path(), &c, async { anyhow::Ok(()) }).await;
|
||||
let err = outcome.expect_err("a sibling of main is not a fast-forward");
|
||||
assert!(
|
||||
format!("{err:#}").contains("does not descend from applied/main"),
|
||||
"{err:#}"
|
||||
);
|
||||
assert_eq!(main_of(&dir).await, b);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_ancestor_of_main_is_superseded_not_refused() {
|
||||
let (dir, [a, b, _]) = repo().await;
|
||||
let outcome = advance_applied_main(dir.path(), &a, async { anyhow::Ok(()) }).await;
|
||||
assert_eq!(outcome.expect("no-op"), RevAdvance::Superseded);
|
||||
assert_eq!(main_of(&dir).await, b);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_descendant_rev_fast_forwards_main() {
|
||||
let (dir, [a, b, _]) = repo().await;
|
||||
lifecycle::git_update_ref(dir.path(), "refs/heads/main", &a)
|
||||
.await
|
||||
.expect("rewind main");
|
||||
let outcome = advance_applied_main(dir.path(), &b, async { anyhow::Ok(()) }).await;
|
||||
assert_eq!(outcome.expect("fast-forward"), RevAdvance::Advanced);
|
||||
assert_eq!(main_of(&dir).await, b);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_failed_fetch_leaves_main_alone() {
|
||||
let (dir, [a, b, _]) = repo().await;
|
||||
lifecycle::git_update_ref(dir.path(), "refs/heads/main", &a)
|
||||
.await
|
||||
.expect("rewind main");
|
||||
let outcome = advance_applied_main(dir.path(), &b, async {
|
||||
Err::<(), _>(anyhow::anyhow!("forge down"))
|
||||
})
|
||||
.await;
|
||||
assert!(outcome.is_err());
|
||||
assert_eq!(main_of(&dir).await, a);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -156,8 +156,7 @@ pub async fn build_all() -> anyhow::Result<Vec<ContainerView>> {
|
|||
let deployed_full =
|
||||
std::fs::read_to_string(crate::paths::applied_config_marker(logical.as_str())).ok();
|
||||
let needs_update =
|
||||
crate::auto_update::agent_config_pending(logical.as_str(), deployed_full.as_deref())
|
||||
.await;
|
||||
crate::auto_update::agent_config_pending(logical.as_str(), deployed_full.as_deref());
|
||||
let deployed_sha = deployed_full.as_deref().map(short_rev);
|
||||
// One `systemctl` call for both facts: `unit_state` is the same
|
||||
// shell-out `is_running` makes, minus `--quiet`. Asking twice would
|
||||
|
|
|
|||
|
|
@ -286,8 +286,6 @@ mod serve_config_tests {
|
|||
pub struct AgentPaths {
|
||||
/// Runtime socket dir (tmpfs, recreated per boot).
|
||||
pub agent: PathBuf,
|
||||
/// Hive-c0re-authoritative applied config repo.
|
||||
pub applied: PathBuf,
|
||||
/// Claude OAuth credentials (survives purge boundary).
|
||||
pub claude: PathBuf,
|
||||
/// Agent durable notes + forge token (survives purge boundary).
|
||||
|
|
@ -436,7 +434,6 @@ impl Coordinator {
|
|||
.map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?;
|
||||
Ok(AgentPaths {
|
||||
agent: agent_dir,
|
||||
applied: crate::paths::applied_dir(name.as_str()),
|
||||
claude: Self::agent_claude_dir(&name),
|
||||
notes: Self::agent_notes_dir(&name),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -175,16 +175,12 @@ pub(super) async fn post_purge_tombstone(
|
|||
return error_response(&reason);
|
||||
}
|
||||
let mut errors = Vec::new();
|
||||
for dir in [
|
||||
crate::paths::agent_state_dir(&name),
|
||||
crate::paths::applied_dir(name.as_str()),
|
||||
] {
|
||||
let dir = crate::paths::agent_state_dir(&name);
|
||||
if dir.exists()
|
||||
&& let Err(e) = std::fs::remove_dir_all(&dir)
|
||||
{
|
||||
errors.push(format!("{}: {e}", dir.display()));
|
||||
}
|
||||
}
|
||||
if errors.is_empty() {
|
||||
tracing::info!(%name, "tombstone purged");
|
||||
// Fire the post-purge tombstones snapshot so dashboards
|
||||
|
|
|
|||
|
|
@ -6,13 +6,9 @@
|
|||
|
||||
mod internal_meta;
|
||||
mod reader;
|
||||
mod reconcile;
|
||||
mod repos;
|
||||
|
||||
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::fetch_config_main_into_applied;
|
||||
|
||||
use std::future::IntoFuture;
|
||||
use std::sync::OnceLock;
|
||||
|
|
|
|||
|
|
@ -1,158 +0,0 @@
|
|||
//! Reconcile an agent's local applied config checkout against its forge
|
||||
//! `agent-configs/<agent>` `main`. Backs `hivectl forge reconcile-config`
|
||||
//! via the host-socket `ReconcileConfig{Status,Apply}` requests. hive-c0re
|
||||
//! owns the applied checkout + the forge credential, so the reconcile op
|
||||
//! lives here rather than in the CLI.
|
||||
//!
|
||||
//! `Status` is read-only (fetches forge `main` into a scratch ref, reports
|
||||
//! the divergence). `Apply(Forge)` resets the local applied checkout to
|
||||
//! forge `main`; `internal/meta` pins forge `main` itself, not this tree. It
|
||||
//! does NOT auto-deploy. `Apply(Local)` is not supported: forge `main` is
|
||||
//! branch-protected, so it only advances through a config-PR merge.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use hive_host_sock::{HostResponse, ReconcileDirection};
|
||||
|
||||
use super::{CONFIG_ORG, forge_git_url, reader_token};
|
||||
|
||||
/// Scratch ref the forge `main` is fetched into — outside the normal
|
||||
/// branch/tag namespace so it never collides with real refs.
|
||||
pub(crate) const FORGE_MAIN_REF: &str = "refs/hyperhive/forge-config-main";
|
||||
|
||||
/// Fetch `agent-configs/<agent>` `main` into the applied repo's scratch
|
||||
/// ref (read-only, no working-tree change) and return the applied dir.
|
||||
/// Also how a swarm deploy of a merged commit gets that commit's objects.
|
||||
pub async fn fetch_forge_main(agent: &str) -> Result<PathBuf> {
|
||||
let Some(reader) = reader_token().await else {
|
||||
anyhow::bail!(
|
||||
"no forge read token: the swarm store holds none for this hive and there is no core \
|
||||
token file"
|
||||
);
|
||||
};
|
||||
let dir = crate::paths::applied_dir(agent);
|
||||
if !dir.join(".git").exists() {
|
||||
anyhow::bail!("agent `{agent}` has no applied config checkout");
|
||||
}
|
||||
let url = forge_git_url(&format!("{CONFIG_ORG}/{agent}"));
|
||||
crate::lifecycle::git_authed(
|
||||
&dir,
|
||||
&["fetch", "--force", &url, &format!("main:{FORGE_MAIN_REF}")],
|
||||
&reader.auth_header(),
|
||||
)
|
||||
.await
|
||||
.context("fetch forge config main (does forge main exist yet?)")?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// Run a git command in `dir` and capture stdout, erroring on non-zero.
|
||||
async fn git_out(dir: &Path, args: &[&str]) -> Result<String> {
|
||||
let out = crate::lifecycle::git_command()
|
||||
.current_dir(dir)
|
||||
.args(args)
|
||||
.output()
|
||||
.await
|
||||
.with_context(|| format!("invoke git {args:?}"))?;
|
||||
if !out.status.success() {
|
||||
anyhow::bail!(
|
||||
"git {args:?} failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
||||
}
|
||||
|
||||
/// Append `lines` (from a git text block) to `messages`, each indented.
|
||||
fn push_indented(messages: &mut Vec<String>, block: &str) {
|
||||
messages.extend(block.lines().map(|l| format!(" {l}")));
|
||||
}
|
||||
|
||||
/// Report the divergence between the local applied checkout and forge
|
||||
/// `main` (host request `ReconcileConfigStatus`). Read-only.
|
||||
pub async fn reconcile_config_status(agent: &str, verbose: bool) -> Result<HostResponse> {
|
||||
let dir = fetch_forge_main(agent).await?;
|
||||
let counts = git_out(
|
||||
&dir,
|
||||
&[
|
||||
"rev-list",
|
||||
"--left-right",
|
||||
"--count",
|
||||
&format!("HEAD...{FORGE_MAIN_REF}"),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
let mut parts = counts.split_whitespace();
|
||||
let local_ahead = parts.next().unwrap_or("0");
|
||||
let forge_ahead = parts.next().unwrap_or("0");
|
||||
let mut messages = vec![
|
||||
format!("agent {agent}: local applied main <-> forge agent-configs/{agent} main"),
|
||||
format!(" local ahead by {local_ahead}, forge ahead by {forge_ahead} commit(s)"),
|
||||
];
|
||||
if local_ahead == "0" && forge_ahead == "0" {
|
||||
messages.push(" in sync — nothing to reconcile".to_owned());
|
||||
return Ok(HostResponse::messages(messages));
|
||||
}
|
||||
if forge_ahead != "0" {
|
||||
messages.push(" forge-only commits (applied by --from forge):".to_owned());
|
||||
push_indented(
|
||||
&mut messages,
|
||||
&git_out(
|
||||
&dir,
|
||||
&["log", "--oneline", &format!("HEAD..{FORGE_MAIN_REF}")],
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
if local_ahead != "0" {
|
||||
messages.push(" local-only commits (discarded by --from forge):".to_owned());
|
||||
push_indented(
|
||||
&mut messages,
|
||||
&git_out(
|
||||
&dir,
|
||||
&["log", "--oneline", &format!("{FORGE_MAIN_REF}..HEAD")],
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
messages.push(" diff --stat (local HEAD -> forge main):".to_owned());
|
||||
push_indented(
|
||||
&mut messages,
|
||||
&git_out(&dir, &["diff", "--stat", "HEAD", FORGE_MAIN_REF]).await?,
|
||||
);
|
||||
if verbose {
|
||||
messages.push(" full diff:".to_owned());
|
||||
push_indented(
|
||||
&mut messages,
|
||||
&git_out(&dir, &["diff", "HEAD", FORGE_MAIN_REF]).await?,
|
||||
);
|
||||
}
|
||||
Ok(HostResponse::messages(messages))
|
||||
}
|
||||
|
||||
/// Apply a reconcile in `direction` (host request `ReconcileConfigApply`).
|
||||
/// `Forge` resets the local applied checkout to forge `main`; `Local` is
|
||||
/// unsupported and returns a clear error.
|
||||
pub async fn reconcile_config_apply(
|
||||
agent: &str,
|
||||
direction: ReconcileDirection,
|
||||
) -> Result<HostResponse> {
|
||||
match direction {
|
||||
ReconcileDirection::Local => Ok(HostResponse::error(
|
||||
"reconciling forge from local is not supported yet: forge main is \
|
||||
branch-protected. Resolve via a config PR, or use \
|
||||
`--from forge` to reset the local checkout to forge main.",
|
||||
)),
|
||||
ReconcileDirection::Forge => {
|
||||
let dir = fetch_forge_main(agent).await?;
|
||||
crate::lifecycle::git(&dir, &["reset", "--hard", FORGE_MAIN_REF])
|
||||
.await
|
||||
.context("reset applied main to forge main")?;
|
||||
Ok(HostResponse::messages(vec![
|
||||
format!("reconciled applied/{agent} to forge agent-configs/{agent} main"),
|
||||
format!("effective on next deploy — rebuild {agent} to apply now"),
|
||||
]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,122 +0,0 @@
|
|||
//! Repo plumbing on the local Forgejo: seeding an agent's `applied` repo from
|
||||
//! `agent-configs/<name>`. The typed API-client constructor + org-name
|
||||
//! constants live in the module root (`super`).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use super::{CONFIG_ORG, forge_git_url, reader_token};
|
||||
|
||||
/// Seed an agent's `applied` repo, missing its `.git` entirely (first spawn,
|
||||
/// or a revival after `destroy --purge`), by fetching `agent-configs/<name>`'s
|
||||
/// `main` from the forge. Forge `main` advances with every operator-merged
|
||||
/// config PR, so this reflects the last merged config. A config deployed
|
||||
/// through hive-c0re without a forge merge never reaches it.
|
||||
///
|
||||
/// Best-effort: returns `false` (not an error) rather than bailing when there
|
||||
/// is no [`reader_token`], `applied` already has a `.git` (nothing to do), the
|
||||
/// forge has no `main` for the agent yet (the normal case right after
|
||||
/// `swarm-controller` creates it, before it has pushed anything), or any git
|
||||
/// step fails. Callers fall back to the template or the original hard error. A
|
||||
/// failed seed leaves the directory empty, so a later "is it seeded" check reads
|
||||
/// the same as before the attempt. Only `true` once `applied` is seeded and
|
||||
/// tagged `deployed/0`.
|
||||
pub async fn fetch_config_main_into_applied(name: &str) -> bool {
|
||||
let Some(reader) = reader_token().await else {
|
||||
return false;
|
||||
};
|
||||
let applied = crate::paths::applied_dir(name);
|
||||
if applied.join(".git").exists() {
|
||||
return false;
|
||||
}
|
||||
let seeded = async {
|
||||
if let Err(e) = std::fs::create_dir_all(&applied) {
|
||||
tracing::warn!(%name, error = ?e, "forge: applied reseed mkdir failed");
|
||||
return false;
|
||||
}
|
||||
if let Err(e) = crate::lifecycle::git(&applied, &["init", "--initial-branch=main"]).await {
|
||||
tracing::warn!(%name, error = ?e, "forge: applied reseed init failed");
|
||||
return false;
|
||||
}
|
||||
let url = forge_git_url(&format!("{CONFIG_ORG}/{name}"));
|
||||
let auth = reader.auth_header();
|
||||
let out = crate::lifecycle::git_command_authed(&auth)
|
||||
.current_dir(&applied)
|
||||
.args([
|
||||
"fetch",
|
||||
"--no-tags",
|
||||
"--update-head-ok",
|
||||
&url,
|
||||
"refs/heads/main:refs/heads/main",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
match out {
|
||||
Ok(o) if o.status.success() => {}
|
||||
Ok(o) => {
|
||||
// `info`, not `warn`: the normal case right after
|
||||
// `swarm-controller` creates the agent, before it has pushed
|
||||
// anything into the repo it just made, and nothing is wrong.
|
||||
tracing::info!(
|
||||
%name,
|
||||
stderr = %String::from_utf8_lossy(&o.stderr).trim(),
|
||||
"forge: no config to fetch for this agent yet"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(%name, error = ?e, "forge: applied reseed fetch failed");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Err(e) = crate::lifecycle::git_read_tree_reset(&applied, "refs/heads/main").await {
|
||||
tracing::warn!(%name, error = ?e, "forge: applied reseed read-tree failed");
|
||||
return false;
|
||||
}
|
||||
if let Err(e) = crate::lifecycle::git_tag(&applied, "deployed/0", "refs/heads/main").await {
|
||||
tracing::warn!(%name, error = ?e, "forge: applied reseed tag failed");
|
||||
return false;
|
||||
}
|
||||
tracing::info!(%name, "forge: seeded applied repo from agent-configs main");
|
||||
true
|
||||
}
|
||||
.await;
|
||||
if !seeded {
|
||||
clear_dir(&applied);
|
||||
}
|
||||
seeded
|
||||
}
|
||||
|
||||
/// Empty `dir`, keeping the directory itself: a failed seed may have written
|
||||
/// the working tree as well as the `.git`, and the template fallback must start
|
||||
/// from nothing rather than adopt files that were meant for the forge's commit.
|
||||
fn clear_dir(dir: &Path) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let _ = if path.is_dir() {
|
||||
std::fs::remove_dir_all(path)
|
||||
} else {
|
||||
std::fs::remove_file(path)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::clear_dir;
|
||||
|
||||
#[test]
|
||||
fn clear_dir_empties_a_dir_but_keeps_it() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
std::fs::create_dir(dir.path().join(".git")).expect("mkdir .git");
|
||||
std::fs::write(dir.path().join(".git/HEAD"), "ref").expect("write HEAD");
|
||||
std::fs::write(dir.path().join("agent.nix"), "{}").expect("write agent.nix");
|
||||
|
||||
clear_dir(dir.path());
|
||||
|
||||
assert!(dir.path().is_dir(), "the directory itself stays");
|
||||
assert_eq!(std::fs::read_dir(dir.path()).expect("read_dir").count(), 0);
|
||||
}
|
||||
}
|
||||
|
|
@ -68,7 +68,7 @@ pub(super) trait Host: Sync {
|
|||
async fn notify(&self, key: String, summary: String);
|
||||
/// Re-read the agent's archetype from the swarm.
|
||||
async fn refresh_archetype(&self, name: &str) -> Result<()>;
|
||||
/// Rebuild preamble: port check, applied repo and state dirs.
|
||||
/// Rebuild preamble: port check and state dirs.
|
||||
async fn prepare_rebuild_dirs(&self, name: &str, paths: &AgentPaths) -> Result<()>;
|
||||
/// Rewrite the host-side files that follow the agent set.
|
||||
async fn refresh_agent_set(&self) -> Result<()>;
|
||||
|
|
@ -216,13 +216,11 @@ impl Host for RealHost {
|
|||
|
||||
fn purge_dirs(&self, name: &str) -> Vec<PathBuf> {
|
||||
// A malformed name can't have a persistent state tree (the state dir is
|
||||
// only ever created under a validated Ident), so only the applied dir
|
||||
// is left to clear.
|
||||
// only ever created under a validated Ident), so there is nothing to clear.
|
||||
hive_types::Ident::parse(name)
|
||||
.ok()
|
||||
.map(|id| crate::paths::agent_state_dir(&id))
|
||||
.into_iter()
|
||||
.chain([crate::paths::applied_dir(name)])
|
||||
.collect()
|
||||
}
|
||||
|
||||
|
|
@ -426,7 +424,7 @@ async fn run_purge_state(host: &impl Host, agent: &str) {
|
|||
// The state root may be a btrfs subvolume: a subvolume root can't be
|
||||
// removed with rmdir/`remove_dir_all`, so delete it via hive-priv (root)
|
||||
// first. No-op for plain-dir agents — the loop below then handles the
|
||||
// plain-dir state root plus the applied dir.
|
||||
// plain-dir state root.
|
||||
if let Err(e) = host.delete_state_subvolume(agent).await {
|
||||
tracing::warn!(error = ?e, %agent, "purge: delete state subvolume failed");
|
||||
}
|
||||
|
|
@ -731,8 +729,8 @@ async fn run_rebuild_bookkeeping(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// First-spawn pre-create provisioning: the applied repo, state
|
||||
/// subvolume, and the agent-set refresh.
|
||||
/// First-spawn pre-create provisioning: the state subvolume and the
|
||||
/// agent-set refresh.
|
||||
async fn run_provision(host: &impl Host, name: &str) -> Result<()> {
|
||||
let agent_dir = crate::paths::agent_runtime_dir(name);
|
||||
let paths = Coordinator::agent_paths(name, agent_dir)?;
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ pub enum NodeKind {
|
|||
DestroyContainer { agent: String },
|
||||
/// The `purge = true` half of a destroy: delete the agent's state
|
||||
/// subvolume (via hive-priv, since a subvolume root defeats
|
||||
/// `remove_dir_all`) plus its state and applied dirs.
|
||||
/// `remove_dir_all`) plus its state dir.
|
||||
PurgeState { agent: String },
|
||||
/// The post-destroy bookkeeping tail.
|
||||
DestroyBookkeeping { agent: String },
|
||||
|
|
|
|||
|
|
@ -282,7 +282,7 @@ pub fn rebuild(builder: &JobBuilder, agent: &str) -> Vec<hive_jobq::NodeGuid> {
|
|||
}
|
||||
|
||||
/// First deploy of an agent this hive has never seen, asked for by the swarm:
|
||||
/// `Provision` (applied repo, state subvolume, agent-set registration)
|
||||
/// `Provision` (state subvolume, agent-set registration)
|
||||
/// then `Create` (`nixos-container create`), drop-in write, then `Reconcile`
|
||||
/// starts the container (`wanted = Up`, seeded by
|
||||
/// `swarm_status::queue_first_deploy`). All-or-nothing: `Provision`
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue