drop the hive's push of the legacy core/meta repo
Nothing reads core/meta: builds come from internal/meta. Remove push_meta, ensure_meta_remote, meta_read_access, ensure_repo, the per-agent forge sync that only served them, and the git commit path in meta.rs; remove_permission_files now just deletes the seed files.
This commit is contained in:
parent
dd2f0d3ece
commit
b6b568e97e
15 changed files with 81 additions and 518 deletions
|
|
@ -289,8 +289,8 @@ mounts via `set_nspawn_flags`:
|
||||||
manage any agent", so the mounted set is every agent.
|
manage any agent", so the mounted set is every agent.
|
||||||
- `/var/lib/hyperhive/applied/` → `/applied/` (RO) — every agent's
|
- `/var/lib/hyperhive/applied/` → `/applied/` (RO) — every agent's
|
||||||
authoritative applied repo, including `.git`.
|
authoritative applied repo, including `.git`.
|
||||||
- `/var/lib/hyperhive/meta/` → `/meta/` (RO) — the swarm-wide
|
- `/var/lib/hyperhive/meta/` → `/meta/` (RO) — the hive's plain state
|
||||||
deploy flake.
|
(topology, swarm limits).
|
||||||
|
|
||||||
An agent without the permission only has its direct children's config
|
An agent without the permission only has its direct children's config
|
||||||
dirs.
|
dirs.
|
||||||
|
|
|
||||||
|
|
@ -387,17 +387,12 @@ here from merged config PRs) + any other files those PRs commit.
|
||||||
`.git/` carries the proposal / approved / building / deployed / failed /
|
`.git/` carries the proposal / approved / building / deployed / failed /
|
||||||
denied tag history.
|
denied tag history.
|
||||||
|
|
||||||
Under `/var/lib/hyperhive/meta/` — the swarm-wide deploy flake plus
|
Under `/var/lib/hyperhive/meta/` — system-level config files, plain state
|
||||||
system-level config files. Single git repo for the whole host; hive-c0re
|
with no git history that anything reads. The flake agents build from is the
|
||||||
commits every mutation that should survive a restart here.
|
swarm's `internal/meta`, not here. Contents:
|
||||||
Contents:
|
|
||||||
|
|
||||||
- `flake.nix` — declares one `nixpkgs` input per agent + one
|
|
||||||
`nixosConfigurations.<n>` output per agent. `flake.lock` is the
|
|
||||||
canonical "what's deployed where." The git log is the deploy
|
|
||||||
audit trail (one commit per successful deploy or hyperhive bump).
|
|
||||||
- `topology.json` — the agent roster (`["alice", "bob", "ruth"]`).
|
- `topology.json` — the agent roster (`["alice", "bob", "ruth"]`).
|
||||||
Written by `topology::reconcile` on every meta sync; read by
|
Written by `topology::reconcile` from `agent_set::refresh`; read by
|
||||||
`topology::all_agents`, which is the set the `manage_root_agent`
|
`topology::all_agents`, which is the set the `manage_root_agent`
|
||||||
permission grants mounts over. The reader also accepts a map-shaped
|
permission grants mounts over. The reader also accepts a map-shaped
|
||||||
file carrying a `parent` per agent, and keeps its keys.
|
file carrying a `parent` per agent, and keeps its keys.
|
||||||
|
|
@ -419,13 +414,13 @@ Contents:
|
||||||
hive-wide only and come straight off `HiveEnv`, so this file has no field
|
hive-wide only and come straight off `HiveEnv`, so this file has no field
|
||||||
for them.
|
for them.
|
||||||
- `resource-limits.json` — retired. Per-agent overrides set on the hive used
|
- `resource-limits.json` — retired. Per-agent overrides set on the hive used
|
||||||
to live here; nothing reads or writes it now. An old copy in the meta repo
|
to live here; nothing reads or writes it now. An old copy in this directory
|
||||||
is harmless and you can delete it.
|
is harmless and you can delete it.
|
||||||
|
|
||||||
An agent holding `manage_root_agent`, normally ruth, has the meta dir
|
An agent holding `manage_root_agent`, normally ruth, has the meta dir
|
||||||
RO-mounted at `/meta/`.
|
RO-mounted at `/meta/`.
|
||||||
|
|
||||||
Outside the meta repo, `/var/lib/hyperhive/swarm-agent-permissions.json`
|
Outside this directory, `/var/lib/hyperhive/swarm-agent-permissions.json`
|
||||||
holds the permissions the swarm grants this hive's agents, as
|
holds the permissions the swarm grants this hive's agents, as
|
||||||
`workers::swarm_permissions` last read them from the swarm's
|
`workers::swarm_permissions` last read them from the swarm's
|
||||||
`agent-permissions` bucket (every 30 seconds): a copy, not the
|
`agent-permissions` bucket (every 30 seconds): a copy, not the
|
||||||
|
|
|
||||||
|
|
@ -122,9 +122,7 @@ Two things live in the `agent-configs` Forgejo organization:
|
||||||
[config-changes.md § Config changes](../agent-lifecycle/config-changes.md#config-changes)).
|
[config-changes.md § Config changes](../agent-lifecycle/config-changes.md#config-changes)).
|
||||||
hive-c0re pushes nothing to this org; it only reads from it.
|
hive-c0re pushes nothing to this org; it only reads from it.
|
||||||
Repos stay private, so an agent can't read another
|
Repos stay private, so an agent can't read another
|
||||||
agent's config. (Agents remain read-only collaborators on the legacy
|
agent's config. The agent's **persistent meta flake input** is this repo's `main` head,
|
||||||
`core/meta`, the hive's old permission-seed repo.)
|
|
||||||
The agent's **persistent meta flake input** is this repo's `main` head,
|
|
||||||
pinned by sha in `internal/meta` (`agent-<n>.url =
|
pinned by sha in `internal/meta` (`agent-<n>.url =
|
||||||
git+<forge>/agent-configs/<n>.git?rev=<sha>`; see
|
git+<forge>/agent-configs/<n>.git?rev=<sha>`; see
|
||||||
[config-changes.md § Meta flake](../agent-lifecycle/config-changes.md#meta-flake)).
|
[config-changes.md § Meta flake](../agent-lifecycle/config-changes.md#meta-flake)).
|
||||||
|
|
|
||||||
|
|
@ -233,10 +233,10 @@ enough — wiping and rebuilding still destroys the state even if
|
||||||
`JSON.stringify({...visible fields...})`. On each render, the pattern
|
`JSON.stringify({...visible fields...})`. On each render, the pattern
|
||||||
reuses cache-hit rows verbatim (preserving textarea draft, checkbox
|
reuses cache-hit rows verbatim (preserving textarea draft, checkbox
|
||||||
state, and event listeners) and rebuilds only cache-miss rows. Used for: containers (`containerRowCache`),
|
state, and event listeners) and rebuilds only cache-miss rows. Used for: containers (`containerRowCache`),
|
||||||
rebuild-queue entries (`rebuildQueueRowCache`). The spawn-form input+focus and meta-input
|
rebuild-queue entries (`rebuildQueueRowCache`). The spawn-form input+focus
|
||||||
checkboxes use a lighter snapshot-then-restore pattern (snapshot
|
uses a lighter snapshot-then-restore pattern (snapshot
|
||||||
before `replaceChildren`, restore after) since they're single
|
before `replaceChildren`, restore after) since it's a single
|
||||||
values rather than per-row caches.
|
value rather than a per-row cache.
|
||||||
|
|
||||||
<!-- vale write-good.Passive = NO -->
|
<!-- vale write-good.Passive = NO -->
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -172,7 +172,7 @@ import { renderUserMenu } from "./user-menu.js";
|
||||||
// broker traffic flows through the SSE channel. The only
|
// broker traffic flows through the SSE channel. The only
|
||||||
// /api/state fetches are the initial cold load and the
|
// /api/state fetches are the initial cold load and the
|
||||||
// post-submit refetch on forms without `data-no-refresh`
|
// post-submit refetch on forms without `data-no-refresh`
|
||||||
// (tombstones, meta-input updates).
|
// (tombstones).
|
||||||
if (pollTimer) {
|
if (pollTimer) {
|
||||||
clearTimeout(pollTimer);
|
clearTimeout(pollTimer);
|
||||||
pollTimer = null;
|
pollTimer = null;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
//! Optional Forgejo wiring — the legacy `core/meta` repo (the hive's old
|
//! Optional Forgejo wiring — the `core` admin user and token and its avatar.
|
||||||
//! permission-seed repo, which `meta::remove_permission_files` still commits
|
//! Those writes, as `core`, run only beside a local `hive-forge`
|
||||||
//! to) and its read-access grants. Those writes, as `core`, run only beside a local `hive-forge`
|
|
||||||
//! container; reads of the forge go through [`reader_token`] on every hive.
|
//! container; reads of the forge go through [`reader_token`] on every hive.
|
||||||
//! The swarm-wide orgs, teams and repos, and each agent's read grant on
|
//! The swarm-wide orgs, teams and repos, and each agent's read grant on
|
||||||
//! `internal/docs`, are swarm-controller's. Full design:
|
//! `internal/docs`, are swarm-controller's. Full design:
|
||||||
|
|
@ -15,10 +14,7 @@ mod users;
|
||||||
pub(crate) use internal_meta::internal_meta_source;
|
pub(crate) use internal_meta::internal_meta_source;
|
||||||
pub use reader::reader_token;
|
pub use reader::reader_token;
|
||||||
pub use reconcile::{fetch_forge_main, reconcile_config_apply, reconcile_config_status};
|
pub use reconcile::{fetch_forge_main, reconcile_config_apply, reconcile_config_status};
|
||||||
pub use repos::{
|
pub use repos::{clone_config_into_proposed, fetch_config_main_into_applied};
|
||||||
clone_config_into_proposed, ensure_meta_remote, ensure_repo, fetch_config_main_into_applied,
|
|
||||||
meta_read_access, push_meta,
|
|
||||||
};
|
|
||||||
pub use users::core_token;
|
pub use users::core_token;
|
||||||
|
|
||||||
use std::future::IntoFuture;
|
use std::future::IntoFuture;
|
||||||
|
|
@ -74,13 +70,13 @@ pub(crate) fn validate_env() -> Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Git remote for `repo` (e.g. `"core/meta"`) — **credential-free**.
|
/// Git remote for `repo` (e.g. `"hyperhive/hyperhive"`) — **credential-free**.
|
||||||
///
|
///
|
||||||
/// The token does not go here. A URL is a process argument, and `argv` is
|
/// The token does not go here. A URL is a process argument, and `argv` is
|
||||||
/// world-readable through `/proc/<pid>/cmdline` for as long as the git child
|
/// world-readable through `/proc/<pid>/cmdline` for as long as the git child
|
||||||
/// lives, so a credentialed remote publishes the core admin token to every
|
/// lives, so a credentialed remote publishes the core admin token to every
|
||||||
/// local user on the host. Credentials travel in the environment instead, via
|
/// local user on the host. Credentials travel in the environment instead, via
|
||||||
/// [`core_auth_header`] and [`crate::lifecycle::git_command_authed`] —
|
/// [`basic_auth_header`] and [`crate::lifecycle::git_command_authed`] —
|
||||||
/// `/proc/<pid>/environ` is owner-only.
|
/// `/proc/<pid>/environ` is owner-only.
|
||||||
pub(crate) fn forge_git_url(repo: &str) -> String {
|
pub(crate) fn forge_git_url(repo: &str) -> String {
|
||||||
git_url_with_base(forge_http_base(), repo)
|
git_url_with_base(forge_http_base(), repo)
|
||||||
|
|
@ -92,11 +88,6 @@ fn git_url_with_base(base: &str, repo: &str) -> String {
|
||||||
format!("{base}/{repo}.git")
|
format!("{base}/{repo}.git")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The `http.extraHeader` value authenticating as the forge core user.
|
|
||||||
pub(crate) fn core_auth_header(token: &str) -> String {
|
|
||||||
basic_auth_header("core", token)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The `http.extraHeader` value carrying `token`. Forgejo takes the account
|
/// The `http.extraHeader` value carrying `token`. Forgejo takes the account
|
||||||
/// from the token, so `login` only labels the request.
|
/// from the token, so `login` only labels the request.
|
||||||
///
|
///
|
||||||
|
|
@ -120,16 +111,6 @@ fn basic_auth_header(login: &str, token: &str) -> String {
|
||||||
/// still can't reach *another* agent's config. hive-c0re only reads from it.
|
/// still can't reach *another* agent's config. hive-c0re only reads from it.
|
||||||
pub(crate) const CONFIG_ORG: &str = "agent-configs";
|
pub(crate) const CONFIG_ORG: &str = "agent-configs";
|
||||||
|
|
||||||
/// Leak `s` to get a `&'static str` warning `kind` for the small, bounded
|
|
||||||
/// set of boot warnings in [`ensure_all`] keyed by a runtime name (at
|
|
||||||
/// most a handful per process). [`crate::warnings::set_boot_warning`]
|
|
||||||
/// requires a `'static` kind so distinct orgs/repos don't clobber each
|
|
||||||
/// other's warning entry; leaking a few short strings once per boot is
|
|
||||||
/// cheap and bounded, unlike a per-request or per-loop-iteration leak.
|
|
||||||
fn static_kind(s: String) -> &'static str {
|
|
||||||
Box::leak(s.into_boxed_str())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Probe whether `hive-forge` exists as a nixos-container. Cheap —
|
/// Probe whether `hive-forge` exists as a nixos-container. Cheap —
|
||||||
/// `nixos-container list` is just a directory scan in /etc. Routed
|
/// `nixos-container list` is just a directory scan in /etc. Routed
|
||||||
/// through hive-priv: `nixos-container` needs root, and hive-c0re runs
|
/// through hive-priv: `nixos-container` needs root, and hive-c0re runs
|
||||||
|
|
@ -216,42 +197,7 @@ where
|
||||||
.map_err(|_| anyhow::anyhow!("{op}: no answer from the forge within {API_TIMEOUT:?}"))
|
.map_err(|_| anyhow::anyhow!("{op}: no answer from the forge within {API_TIMEOUT:?}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-agent forge sync: grant read access to the legacy `core/meta`, and wire
|
/// The `core_token.is_some()` half of [`ensure_all`]: the
|
||||||
/// the `meta` remote in its proposed repo. All operations are idempotent;
|
|
||||||
/// failures are logged as warnings but don't abort the caller.
|
|
||||||
///
|
|
||||||
/// `core_token` is `core_token()` — passed in so callers that already
|
|
||||||
/// fetched it don't re-read the file. Pass `None` to skip the
|
|
||||||
/// `meta_read_access` step (safe: the access grant is best-effort).
|
|
||||||
///
|
|
||||||
/// Called by both `ensure_all()` (startup sweep) and `rebuild_agent`
|
|
||||||
/// (per-rebuild) so the two paths stay equivalent.
|
|
||||||
/// Returns `true` if all steps succeeded, `false` if any step failed. The
|
|
||||||
/// caller can use the return value to aggregate per-agent failures into a
|
|
||||||
/// dashboard warning (see [`ensure_all`]); the rebuild path ignores it and
|
|
||||||
/// relies on the journal `warn!` lines alone (a rebuild is its own retry).
|
|
||||||
pub async fn sync_agent(name: &str, core_token: Option<&str>) -> bool {
|
|
||||||
// The agent's forge account, token and `agent-configs/<name>` repo are
|
|
||||||
// swarm-controller's; this hive creates none of them and pushes nothing
|
|
||||||
// to the repo.
|
|
||||||
let mut ok = true;
|
|
||||||
// Grant read-only access to the legacy core/meta and wire the `meta`
|
|
||||||
// remote into the proposed repo. Agents build from `internal/meta`, not
|
|
||||||
// from this repo.
|
|
||||||
if let Some(token) = core_token
|
|
||||||
&& let Err(e) = meta_read_access(name, token).await
|
|
||||||
{
|
|
||||||
tracing::warn!(%name, error = ?e, "forge: ensure_meta_read_access failed");
|
|
||||||
ok = false;
|
|
||||||
}
|
|
||||||
if let Err(e) = ensure_meta_remote(name).await {
|
|
||||||
tracing::warn!(%name, error = ?e, "forge: ensure_meta_remote failed");
|
|
||||||
ok = false;
|
|
||||||
}
|
|
||||||
ok
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The `core_token.is_some()` half of [`ensure_all`]: the legacy meta repo, the
|
|
||||||
/// core avatar, and removing this hive's own config-PR hook —
|
/// core avatar, and removing this hive's own config-PR hook —
|
||||||
/// every step that needs an authenticated forge client. The swarm-wide
|
/// every step that needs an authenticated forge client. The swarm-wide
|
||||||
/// objects (orgs, the `operators` team, mirrors, `internal/docs`,
|
/// objects (orgs, the `operators` team, mirrors, `internal/docs`,
|
||||||
|
|
@ -260,17 +206,6 @@ pub async fn sync_agent(name: &str, core_token: Option<&str>) -> bool {
|
||||||
/// `ensure_all` under clippy's function-length limit; not meant to be called
|
/// `ensure_all` under clippy's function-length limit; not meant to be called
|
||||||
/// from anywhere else.
|
/// from anywhere else.
|
||||||
async fn ensure_all_orgs_and_repos(token: &str) {
|
async fn ensure_all_orgs_and_repos(token: &str) {
|
||||||
// The legacy meta repo lives at core/meta. The only push to it is
|
|
||||||
// `meta::remove_permission_files`' commit; make sure the repo exists
|
|
||||||
// before that push hits a 404.
|
|
||||||
if let Err(e) = ensure_repo("meta", token).await {
|
|
||||||
tracing::warn!(error = ?e, "forge: ensure_repo core/meta failed");
|
|
||||||
crate::warnings::set_boot_warning(
|
|
||||||
"forge_ensure_meta_repo",
|
|
||||||
"crit",
|
|
||||||
format!("forge: core/meta repo provisioning failed: {e}"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if let Err(e) = ensure_core_avatar(token).await {
|
if let Err(e) = ensure_core_avatar(token).await {
|
||||||
tracing::warn!(error = ?e, "forge: ensure_core_avatar failed");
|
tracing::warn!(error = ?e, "forge: ensure_core_avatar failed");
|
||||||
crate::warnings::set_boot_warning(
|
crate::warnings::set_boot_warning(
|
||||||
|
|
@ -382,11 +317,10 @@ async fn wait_until_ready() -> bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sweep the forge at startup: seed the `core` admin user (hive-c0re's own
|
/// Sweep the forge at startup: seed the `core` admin user (hive-c0re's own
|
||||||
/// identity for pushing the legacy meta repo + driving the API) and its token, the
|
/// identity for driving the API, and the one nix's git reads as) and its token,
|
||||||
/// `core/meta` repo and the local knowledge clone, then run [`sync_agent`]
|
/// then its avatar. Agent accounts,
|
||||||
/// over every existing container (manager + sub-agents). Agent accounts,
|
|
||||||
/// tokens and `agent-configs/<name>` repos are the swarm-controller's, as is
|
/// tokens and `agent-configs/<name>` repos are the swarm-controller's, as is
|
||||||
/// the `agent-configs` org; this sweep pushes nothing to them.
|
/// the `agent-configs` org; this sweep touches none of them.
|
||||||
/// Called once at hive-c0re startup. Per-step failures are logged
|
/// Called once at hive-c0re startup. Per-step failures are logged
|
||||||
/// but don't abort the sweep.
|
/// but don't abort the sweep.
|
||||||
pub async fn ensure_all() {
|
pub async fn ensure_all() {
|
||||||
|
|
@ -413,37 +347,6 @@ pub async fn ensure_all() {
|
||||||
if let Some(token) = core_token.as_deref() {
|
if let Some(token) = core_token.as_deref() {
|
||||||
ensure_all_orgs_and_repos(token).await;
|
ensure_all_orgs_and_repos(token).await;
|
||||||
}
|
}
|
||||||
let Ok(containers) = crate::lifecycle::list().await else {
|
|
||||||
tracing::warn!("forge: nixos-container list failed; skipping user sweep");
|
|
||||||
crate::warnings::set_boot_warning(
|
|
||||||
"forge_container_list",
|
|
||||||
"crit",
|
|
||||||
"forge: nixos-container list failed; per-agent forge sync skipped this boot",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let mut sync_failed: Vec<String> = Vec::new();
|
|
||||||
for c in containers {
|
|
||||||
let Some(name) = c.strip_prefix(crate::lifecycle::AGENT_PREFIX) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if !sync_agent(name, core_token.as_deref()).await {
|
|
||||||
sync_failed.push(name.to_owned());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !sync_failed.is_empty() {
|
|
||||||
// Use static_kind to mint a `&'static str` key from the failed-agent
|
|
||||||
// list (bounded leak: one per hive-c0re boot, not per request).
|
|
||||||
let key = static_kind(format!("forge_sync_agent_{}", sync_failed.join("_")));
|
|
||||||
crate::warnings::set_boot_warning(
|
|
||||||
key,
|
|
||||||
"warn",
|
|
||||||
format!(
|
|
||||||
"forge: per-agent sync failed for: {} (see journal for per-step detail)",
|
|
||||||
sync_failed.join(", ")
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -526,9 +429,9 @@ mod tests {
|
||||||
/// back to `core:<token>`, so the swap is genuinely equivalent auth and
|
/// back to `core:<token>`, so the swap is genuinely equivalent auth and
|
||||||
/// not a silent downgrade to anonymous.
|
/// not a silent downgrade to anonymous.
|
||||||
#[test]
|
#[test]
|
||||||
fn core_auth_header_is_basic_core_token() {
|
fn basic_auth_header_is_basic_core_token() {
|
||||||
use base64::Engine as _;
|
use base64::Engine as _;
|
||||||
let header = super::core_auth_header("s3cret");
|
let header = super::basic_auth_header("core", "s3cret");
|
||||||
let b64 = header
|
let b64 = header
|
||||||
.strip_prefix("Authorization: Basic ")
|
.strip_prefix("Authorization: Basic ")
|
||||||
.expect("basic auth header");
|
.expect("basic auth header");
|
||||||
|
|
|
||||||
|
|
@ -1,207 +1,10 @@
|
||||||
//! Repo + org plumbing on the local Forgejo: the meta + knowledge repos,
|
//! Repo plumbing on the local Forgejo: reseeding an agent's `applied` and
|
||||||
//! meta mirroring (`push_meta`), collaborator grants, pull-mirrors, and
|
//! `proposed` repos from `agent-configs/<name>`. The typed API-client
|
||||||
//! branch-protection rules. The typed API-client constructor + org-name
|
//! constructor + org-name constants live in the module root (`super`).
|
||||||
//! constants live in the module root (`super`).
|
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::Mutex;
|
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use super::{CONFIG_ORG, forge_git_url, reader_token};
|
||||||
use forgejo_api::structs::{
|
|
||||||
AddCollaboratorOption, AddCollaboratorOptionPermission, CreateRepoOption, Repository,
|
|
||||||
};
|
|
||||||
use forgejo_api::{ApiErrorKind, ForgejoError};
|
|
||||||
use reqwest::StatusCode;
|
|
||||||
|
|
||||||
use crate::coordinator::Coordinator;
|
|
||||||
|
|
||||||
use super::{
|
|
||||||
CONFIG_ORG, api, bounded, core_auth_header, core_token, forge_git_url, forge_http_base,
|
|
||||||
is_present, reader_token,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Creation options for an empty repo defaulting to `main`.
|
|
||||||
fn repo_option(name: &str, private: bool) -> CreateRepoOption {
|
|
||||||
CreateRepoOption {
|
|
||||||
auto_init: Some(false),
|
|
||||||
default_branch: Some("main".to_owned()),
|
|
||||||
description: None,
|
|
||||||
gitignores: None,
|
|
||||||
issue_labels: None,
|
|
||||||
license: None,
|
|
||||||
name: name.to_owned(),
|
|
||||||
object_format_name: None,
|
|
||||||
private: Some(private),
|
|
||||||
readme: None,
|
|
||||||
template: None,
|
|
||||||
trust_model: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether a create-style call failed because the object already
|
|
||||||
/// exists. Forgejo signals this as HTTP 409 (conflict) or 422
|
|
||||||
/// (validation). The typed client surfaces those as
|
|
||||||
/// `ApiErrorKind::Other(409)` / `ValidationFailed` when the endpoint
|
|
||||||
/// spec lists the status, or as a bare `UnexpectedStatusCode`
|
|
||||||
/// otherwise — match all shapes defensively.
|
|
||||||
fn is_already_exists(e: &ForgejoError) -> bool {
|
|
||||||
match e {
|
|
||||||
ForgejoError::ApiError(api) => match api.error_kind() {
|
|
||||||
ApiErrorKind::ValidationFailed => true,
|
|
||||||
ApiErrorKind::Other(s) => *s == StatusCode::CONFLICT,
|
|
||||||
_ => false,
|
|
||||||
},
|
|
||||||
ForgejoError::UnexpectedStatusCode(s) => {
|
|
||||||
*s == StatusCode::CONFLICT || *s == StatusCode::UNPROCESSABLE_ENTITY
|
|
||||||
}
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fold a repo-creation result's "already exists" (409 / 422) into
|
|
||||||
/// success. `label` is `<owner>/<name>` — purely for log + error
|
|
||||||
/// context.
|
|
||||||
fn created_or_exists(res: Result<Repository, ForgejoError>, label: &str) -> Result<()> {
|
|
||||||
match res {
|
|
||||||
Ok(_) => {
|
|
||||||
tracing::info!(%label, "forge: created repo");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Err(e) if is_already_exists(&e) => {
|
|
||||||
tracing::debug!(%label, "forge: repo already exists");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Err(e) => Err(e).with_context(|| format!("create repo {label}")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a repo in the token-owner's own namespace. `token` belongs
|
|
||||||
/// to the user we want the repo owned by (we use `core`'s token for
|
|
||||||
/// `core/meta`). Idempotent.
|
|
||||||
pub async fn ensure_repo(name: &str, token: &str) -> Result<()> {
|
|
||||||
let label = format!("core/{name}");
|
|
||||||
let res = bounded(
|
|
||||||
&format!("create repo {label}"),
|
|
||||||
api(token)?.create_current_user_repo(repo_option(name, true)),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
created_or_exists(res, &label)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Dashboard-warning guard for the meta non-fast-forward condition.
|
|
||||||
/// Held while the `core/meta` remote is ahead of our local mirror; cleared
|
|
||||||
/// automatically when the next push succeeds.
|
|
||||||
static META_NON_FF_GUARD: Mutex<Option<crate::warnings::WarningGuard>> = Mutex::new(None);
|
|
||||||
|
|
||||||
/// Push `dir` (the legacy hive meta repo) to `core/meta` on the local forge.
|
|
||||||
/// Its one caller is `meta::remove_permission_files`' commit.
|
|
||||||
/// Best-effort: returns Err which callers log + ignore. No-op when
|
|
||||||
/// the core token isn't present yet (forge container not provisioned).
|
|
||||||
pub async fn push_meta(dir: &Path) -> Result<()> {
|
|
||||||
let Some(token) = core_token() else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
// Token-in-URL push. Forgejo accepts `oauth2:<token>` or just
|
|
||||||
// any-username:<token>; using `core` matches the owner so the
|
|
||||||
// remote name is self-describing.
|
|
||||||
//
|
|
||||||
// No --force: `meta.rs` uses regular `git commit` (append-only),
|
|
||||||
// so the push is always fast-forward in normal operation. Per
|
|
||||||
// operator directive, hive-c0re must not force-push anywhere. If
|
|
||||||
// the remote is somehow ahead (e.g. split-brain or manual push) we
|
|
||||||
// raise a dashboard warning and leave the remote intact rather than
|
|
||||||
// silently erasing its history.
|
|
||||||
let url = forge_git_url("core/meta");
|
|
||||||
let out = crate::lifecycle::run_with_deadline(
|
|
||||||
crate::lifecycle::git_command_authed(&core_auth_header(&token))
|
|
||||||
.current_dir(dir)
|
|
||||||
.args(["push", &url, "HEAD:main"]),
|
|
||||||
crate::lifecycle::FETCH_DEADLINE,
|
|
||||||
"git push core/meta",
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
if !out.status.success() {
|
|
||||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
|
||||||
if stderr.contains("non-fast-forward") || stderr.contains("fetch first") {
|
|
||||||
let msg = "forge/core/meta push rejected (non-fast-forward): \
|
|
||||||
remote is ahead of local; leaving remote history intact";
|
|
||||||
tracing::warn!("{msg}");
|
|
||||||
// Raise a persistent hive warning; cleared on next successful push.
|
|
||||||
if let Ok(mut g) = META_NON_FF_GUARD.lock() {
|
|
||||||
*g = Some(crate::warnings::set_warning(
|
|
||||||
"forge-meta-non-ff",
|
|
||||||
"warn",
|
|
||||||
msg,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
anyhow::bail!(
|
|
||||||
"git push core/meta failed ({}): {}",
|
|
||||||
out.status,
|
|
||||||
stderr.trim()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Successful push: clear any outstanding non-ff warning.
|
|
||||||
if let Ok(mut g) = META_NON_FF_GUARD.lock() {
|
|
||||||
*g = None;
|
|
||||||
}
|
|
||||||
tracing::info!("forge: pushed meta to core/meta");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Grant agent `name` read-only collaborator access to `core/meta` on
|
|
||||||
/// the forge so the agent can clone/fetch that legacy repo. Idempotent:
|
|
||||||
/// re-adding an existing collaborator succeeds (Forgejo answers 204
|
|
||||||
/// either way).
|
|
||||||
pub async fn meta_read_access(name: &str, core_token: &str) -> Result<()> {
|
|
||||||
add_collaborator(
|
|
||||||
"core",
|
|
||||||
"meta",
|
|
||||||
name,
|
|
||||||
AddCollaboratorOptionPermission::Read,
|
|
||||||
core_token,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
tracing::info!(%name, "forge: granted meta read access");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add the forge `core/meta.git` URL as the `meta` remote in the
|
|
||||||
/// agent's proposed config repo so the agent (and the manager) can
|
|
||||||
/// fetch that legacy repo from the forge. Idempotent: no-op when the
|
|
||||||
/// remote already points at the right URL, or when the proposed repo
|
|
||||||
/// does not exist yet. No-op when the forge is not running.
|
|
||||||
pub async fn ensure_meta_remote(name: &str) -> Result<()> {
|
|
||||||
if !is_present().await {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
// A malformed name has no proposed config repo (repos are only created
|
|
||||||
// under a validated Ident), so there's nothing to wire — no-op.
|
|
||||||
let Ok(agent) = hive_types::Ident::parse(name) else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
let proposed_dir = Coordinator::agent_proposed_dir(&agent);
|
|
||||||
if !proposed_dir.join(".git").exists() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let want = format!("{}/core/meta.git", forge_http_base());
|
|
||||||
let existing = crate::lifecycle::git_command()
|
|
||||||
.current_dir(&proposed_dir)
|
|
||||||
.args(["remote", "get-url", "meta"])
|
|
||||||
.output()
|
|
||||||
.await
|
|
||||||
.context("git remote get-url meta")?;
|
|
||||||
if existing.status.success() {
|
|
||||||
let current = String::from_utf8_lossy(&existing.stdout).trim().to_owned();
|
|
||||||
if current == want {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
crate::lifecycle::git(&proposed_dir, &["remote", "set-url", "meta", &want]).await
|
|
||||||
} else {
|
|
||||||
crate::lifecycle::git(&proposed_dir, &["remote", "add", "meta", &want]).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reseed a revived agent's `applied` repo — missing its `.git` entirely,
|
/// Reseed a revived agent's `applied` repo — missing its `.git` entirely,
|
||||||
/// e.g. after `destroy --purge` — by fetching `agent-configs/<name>`'s
|
/// e.g. after `destroy --purge` — by fetching `agent-configs/<name>`'s
|
||||||
|
|
@ -334,38 +137,3 @@ pub async fn clone_config_into_proposed(dir: &Path, name: &str) -> bool {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add `user` as a collaborator on `owner/repo` at `permission`.
|
|
||||||
/// Idempotent: adding an existing collaborator just updates its
|
|
||||||
/// permission (Forgejo answers 204 either way; a 201 from older
|
|
||||||
/// versions is tolerated defensively).
|
|
||||||
async fn add_collaborator(
|
|
||||||
owner: &str,
|
|
||||||
repo: &str,
|
|
||||||
user: &str,
|
|
||||||
permission: AddCollaboratorOptionPermission,
|
|
||||||
token: &str,
|
|
||||||
) -> Result<()> {
|
|
||||||
let op = format!("add collaborator {user} to {owner}/{repo}");
|
|
||||||
let res = bounded(
|
|
||||||
&op,
|
|
||||||
api(token)?.repo_add_collaborator(
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
user,
|
|
||||||
AddCollaboratorOption {
|
|
||||||
permission: Some(permission),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
match res {
|
|
||||||
Ok(()) => {}
|
|
||||||
Err(ForgejoError::UnexpectedStatusCode(s)) if s == StatusCode::CREATED => {}
|
|
||||||
Err(e) => {
|
|
||||||
return Err(e).context(op);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tracing::debug!(%owner, %repo, %user, ?permission, "forge: collaborator set");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ use super::{api, bounded, forge_admin};
|
||||||
|
|
||||||
const TOKEN_NAME_PREFIX: &str = "hyperhive";
|
const TOKEN_NAME_PREFIX: &str = "hyperhive";
|
||||||
// Where the host-side `core` admin token lives. Used by hive-c0re itself
|
// Where the host-side `core` admin token lives. Used by hive-c0re itself
|
||||||
// to push the meta repo + drive admin API calls. Root-only. Literal lives
|
// to drive admin API calls and as the credential nix's git reads. Root-only. Literal lives
|
||||||
// in `crate::paths`; aliased here under the long-standing name.
|
// in `crate::paths`; aliased here under the long-standing name.
|
||||||
use crate::paths::FORGE_CORE_TOKEN as CORE_TOKEN_PATH;
|
use crate::paths::FORGE_CORE_TOKEN as CORE_TOKEN_PATH;
|
||||||
// The `forge/core-avatar-set` marker lives in `crate::paths` — a one-shot
|
// The `forge/core-avatar-set` marker lives in `crate::paths` — a one-shot
|
||||||
|
|
|
||||||
|
|
@ -446,11 +446,7 @@ async fn run_rebuild_bookkeeping(coord: &Arc<Coordinator>, name: &str) -> Result
|
||||||
// The `Rebuilt` manager event is emitted exactly once per agent by the DAG's
|
// The `Rebuilt` manager event is emitted exactly once per agent by the DAG's
|
||||||
// `EmitRebuilt` tail — emitting ok here and letting a failed tail `Reconcile`
|
// `EmitRebuilt` tail — emitting ok here and letting a failed tail `Reconcile`
|
||||||
// add a contradictory !ok would double-report the same rebuild.
|
// add a contradictory !ok would double-report the same rebuild.
|
||||||
// Full forge sync on every successful rebuild so the rebuild path is
|
|
||||||
// equivalent to the startup sweep: meta access, the `meta` remote and the
|
|
||||||
// `internal/docs` grant all recover without a hive-c0re restart. (No
|
|
||||||
// matrix step: the swarm mints an agent's matrix account, not this hive.)
|
|
||||||
crate::forge::sync_agent(name, crate::forge::core_token().as_deref()).await;
|
|
||||||
// No `kick_agent` here any more — reporting from `hive-agent`'s own
|
// No `kick_agent` here any more — reporting from `hive-agent`'s own
|
||||||
// boot instead avoids the duplicate-restart-message problem this used
|
// boot instead avoids the duplicate-restart-message problem this used
|
||||||
// to cause: the container is still stopped at this point (`swap_update` requires it stopped and
|
// to cause: the container is still stopped at this point (`swap_update` requires it stopped and
|
||||||
|
|
|
||||||
|
|
@ -125,7 +125,7 @@ pub fn git_command() -> Command {
|
||||||
/// [`git_command`] carrying HTTP credentials **in its environment**.
|
/// [`git_command`] carrying HTTP credentials **in its environment**.
|
||||||
///
|
///
|
||||||
/// `auth_header` is an `Authorization:` line (see
|
/// `auth_header` is an `Authorization:` line (see
|
||||||
/// [`crate::forge::core_auth_header`]) handed to git as `http.extraHeader`
|
/// `forge::basic_auth_header`) handed to git as `http.extraHeader`
|
||||||
/// through `GIT_CONFIG_*`, which git reads exactly like a config file. The
|
/// through `GIT_CONFIG_*`, which git reads exactly like a config file. The
|
||||||
/// alternative — userinfo in the remote URL — puts the secret in `argv`, and
|
/// alternative — userinfo in the remote URL — puts the secret in `argv`, and
|
||||||
/// `/proc/<pid>/cmdline` is world-readable while `/proc/<pid>/environ` is
|
/// `/proc/<pid>/cmdline` is world-readable while `/proc/<pid>/environ` is
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,11 @@ mod tests;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub use git::git_update_ref;
|
pub use git::git_update_ref;
|
||||||
|
#[cfg(test)]
|
||||||
|
pub use git::run_with_deadline;
|
||||||
pub use git::{
|
pub use git::{
|
||||||
FETCH_DEADLINE, GIT_LOCAL_DEADLINE, git, git_authed, git_command, git_command_authed,
|
git, git_authed, git_command, git_command_authed, git_is_ancestor, git_read_tree_reset,
|
||||||
git_is_ancestor, git_read_tree_reset, git_rev_parse, git_tag, git_update_ref_cas,
|
git_rev_parse, git_tag, git_update_ref_cas,
|
||||||
run_with_deadline,
|
|
||||||
};
|
};
|
||||||
pub use host_config::write_dropins;
|
pub use host_config::write_dropins;
|
||||||
pub use setup::{
|
pub use setup::{
|
||||||
|
|
@ -372,7 +373,7 @@ fn stop_failed_guards()
|
||||||
|
|
||||||
/// Leak a per-agent warning `kind`. Agent names are a small, bounded set —
|
/// Leak a per-agent warning `kind`. Agent names are a small, bounded set —
|
||||||
/// leaking one short string per distinct agent that ever hits a stop
|
/// leaking one short string per distinct agent that ever hits a stop
|
||||||
/// failure is cheap, same reasoning as `forge::static_kind`.
|
/// failure is cheap.
|
||||||
fn static_stop_kind(name: &str) -> &'static str {
|
fn static_stop_kind(name: &str) -> &'static str {
|
||||||
Box::leak(format!("agent_stop_failed_{name}").into_boxed_str())
|
Box::leak(format!("agent_stop_failed_{name}").into_boxed_str())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,149 +1,69 @@
|
||||||
//! The hive's `meta/` directory: plain state beside the hive's other files
|
//! The hive's `meta/` directory: plain state beside the hive's other files
|
||||||
//! (`topology.json`, the swarm limits, the permission seed files) that the
|
//! (`topology.json`, the swarm limits) that the manager container sees
|
||||||
//! manager container sees read-only. Agents are built from the swarm's
|
//! read-only. Agents are built from the swarm's `internal/meta`
|
||||||
//! `internal/meta` ([`crate::meta_rev`]), not from anything here; a hive that
|
//! ([`crate::meta_rev`]), not from anything here.
|
||||||
//! predates it also has a git repo in this directory, which
|
|
||||||
//! [`remove_permission_files`] still commits to.
|
|
||||||
|
|
||||||
use std::path::Path;
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
use anyhow::{Context, Result, bail};
|
|
||||||
|
|
||||||
use crate::lifecycle;
|
|
||||||
|
|
||||||
const GIT_NAME: &str = "c0re";
|
|
||||||
const GIT_EMAIL: &str = "c0re@hyperhive.local";
|
|
||||||
|
|
||||||
/// Where the manager sees this directory inside its container (RO bind).
|
/// Where the manager sees this directory inside its container (RO bind).
|
||||||
pub const CONTAINER_MANAGER_META_MOUNT: &str = "/meta";
|
pub const CONTAINER_MANAGER_META_MOUNT: &str = "/meta";
|
||||||
|
|
||||||
/// Delete the meta repo's permission files and commit the removal. Their
|
/// Delete the permission seed files a hive that predates the swarm's
|
||||||
/// grants live in the swarm's `agent-permissions` bucket once
|
/// permissions left in the meta directory. Their grants live in the swarm's
|
||||||
/// `workers::permissions_import` has sent them.
|
/// `agent-permissions` bucket once `workers::permissions_import` has sent
|
||||||
|
/// them. A file already gone is fine.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// Removing a file or a git stage/commit step fails.
|
/// Removing a file fails.
|
||||||
pub async fn remove_permission_files() -> Result<()> {
|
pub fn remove_permission_files() -> Result<()> {
|
||||||
let dir = crate::paths::meta_root();
|
remove_in(
|
||||||
let mut removed = Vec::new();
|
&crate::paths::meta_root(),
|
||||||
for file in crate::workers::permissions_import::META_FILES {
|
crate::workers::permissions_import::META_FILES,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_in(dir: &std::path::Path, files: &[&str]) -> Result<()> {
|
||||||
|
for file in files {
|
||||||
match std::fs::remove_file(dir.join(file)) {
|
match std::fs::remove_file(dir.join(file)) {
|
||||||
Ok(()) => {}
|
Ok(()) => {}
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||||
Err(e) => return Err(e).with_context(|| format!("removing {file}")),
|
Err(e) => return Err(e).with_context(|| format!("removing {file}")),
|
||||||
}
|
}
|
||||||
git(&dir, &["rm", "--cached", "--ignore-unmatch", "-q", file]).await?;
|
|
||||||
// Only a tracked file has a removal to commit; naming an untracked
|
|
||||||
// one makes `git commit -- <path>` fail.
|
|
||||||
if paths_dirty(&dir, &[file]).await? {
|
|
||||||
removed.push(*file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !removed.is_empty() {
|
|
||||||
git_commit_paths(
|
|
||||||
&dir,
|
|
||||||
"permissions moved to the swarm's agent-permissions",
|
|
||||||
&removed,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run `git <args>` in `dir` within [`lifecycle::GIT_LOCAL_DEADLINE`]; the caller
|
|
||||||
/// reads the exit status.
|
|
||||||
async fn git_output(dir: &Path, args: &[&str]) -> Result<std::process::Output> {
|
|
||||||
lifecycle::run_with_deadline(
|
|
||||||
lifecycle::git_command().current_dir(dir).args(args),
|
|
||||||
lifecycle::GIT_LOCAL_DEADLINE,
|
|
||||||
&format!("git {} in {}", args.join(" "), dir.display()),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn git(dir: &Path, args: &[&str]) -> Result<()> {
|
|
||||||
let out = git_output(dir, args).await?;
|
|
||||||
if !out.status.success() {
|
|
||||||
bail!(
|
|
||||||
"git {} failed ({}): {}",
|
|
||||||
args.join(" "),
|
|
||||||
out.status,
|
|
||||||
String::from_utf8_lossy(&out.stderr).trim()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Path-limited commit: commits ONLY the given paths, so unrelated staged
|
|
||||||
/// content can never be swept into the commit.
|
|
||||||
async fn git_commit_paths(dir: &Path, message: &str, paths: &[&str]) -> Result<()> {
|
|
||||||
let name = format!("user.name={GIT_NAME}");
|
|
||||||
let email = format!("user.email={GIT_EMAIL}");
|
|
||||||
let mut args = vec!["-c", &name, "-c", &email, "commit", "-m", message, "--"];
|
|
||||||
args.extend_from_slice(paths);
|
|
||||||
git(dir, &args).await?;
|
|
||||||
if let Err(e) = crate::forge::push_meta(dir).await {
|
|
||||||
tracing::warn!(error = ?e, "forge: meta push after commit failed (non-fatal)");
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// True when any of `paths` differs between HEAD and the index or working
|
|
||||||
/// tree.
|
|
||||||
async fn paths_dirty(dir: &Path, paths: &[&str]) -> Result<bool> {
|
|
||||||
let mut args = vec!["diff", "--quiet", "HEAD", "--"];
|
|
||||||
args.extend_from_slice(paths);
|
|
||||||
let out = git_output(dir, &args).await?;
|
|
||||||
// Exit 0 = no differences; 1 = differences; anything else (e.g.
|
|
||||||
// no HEAD yet on a fresh repo) → treat as dirty so the commit
|
|
||||||
// path runs and surfaces real errors loudly.
|
|
||||||
Ok(!out.status.success())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::remove_in;
|
||||||
|
|
||||||
/// A path-limited commit must leave an unrelated staged file untouched.
|
#[test]
|
||||||
#[tokio::test]
|
fn the_listed_files_go_and_nothing_else() {
|
||||||
async fn path_limited_commit_leaves_unrelated_staged_file_alone() {
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let tmp = tempfile::tempdir().expect("tempdir");
|
for name in ["capabilities.json", "tool-groups.json", "topology.json"] {
|
||||||
let dir = tmp.path();
|
std::fs::write(dir.path().join(name), "{}").unwrap();
|
||||||
git(dir, &["init", "--initial-branch=main"])
|
}
|
||||||
.await
|
remove_in(dir.path(), &["capabilities.json", "tool-groups.json"]).unwrap();
|
||||||
.expect("git init");
|
assert!(!dir.path().join("capabilities.json").exists());
|
||||||
std::fs::write(dir.join("resource-limits.json"), "{}").expect("write");
|
assert!(!dir.path().join("tool-groups.json").exists());
|
||||||
std::fs::write(dir.join("other.json"), "v1").expect("write");
|
|
||||||
git(dir, &["add", "-A"]).await.expect("add");
|
|
||||||
git_commit_paths(dir, "seed", &["resource-limits.json", "other.json"])
|
|
||||||
.await
|
|
||||||
.expect("seed commit");
|
|
||||||
// Something stages a change (uncommitted)…
|
|
||||||
std::fs::write(dir.join("other.json"), "v2-staged").expect("write");
|
|
||||||
git(dir, &["add", "other.json"]).await.expect("stage");
|
|
||||||
// …and a limits change commits, path-limited.
|
|
||||||
std::fs::write(dir.join("resource-limits.json"), r#"{"alice":[]}"#).expect("write");
|
|
||||||
git(dir, &["add", "resource-limits.json"])
|
|
||||||
.await
|
|
||||||
.expect("add");
|
|
||||||
git_commit_paths(
|
|
||||||
dir,
|
|
||||||
"set resource limits for alice",
|
|
||||||
&["resource-limits.json"],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("path-limited commit");
|
|
||||||
// The limits file is committed; the staged one is not.
|
|
||||||
assert!(
|
assert!(
|
||||||
!paths_dirty(dir, &["resource-limits.json"])
|
dir.path().join("topology.json").exists(),
|
||||||
.await
|
"other state stays"
|
||||||
.expect("check"),
|
|
||||||
"limits file must be committed"
|
|
||||||
);
|
);
|
||||||
|
// A file already gone is fine.
|
||||||
|
remove_in(dir.path(), &["capabilities.json"]).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_file_that_cannot_be_removed_is_an_error_naming_it() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
// A directory where the file belongs fails with a non-NotFound error.
|
||||||
|
std::fs::create_dir(dir.path().join("capabilities.json")).unwrap();
|
||||||
|
let err = remove_in(dir.path(), &["capabilities.json"]).unwrap_err();
|
||||||
assert!(
|
assert!(
|
||||||
paths_dirty(dir, &["other.json"]).await.expect("check"),
|
format!("{err:#}").contains("removing capabilities.json"),
|
||||||
"the staged file must survive the limits commit"
|
"{err:#}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,17 +50,7 @@ pub async fn run() -> Result<()> {
|
||||||
tracing::info!("migration: {KILL_SWITCH} set — skipping");
|
tracing::info!("migration: {KILL_SWITCH} set — skipping");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
// Stale meta index lock: a previous hive-c0re crash mid-`git add`
|
|
||||||
// can leave `.git/index.lock` behind, which blocks every
|
|
||||||
// subsequent meta op until somebody `rm`s it manually. We just
|
|
||||||
// booted so nothing of ours is holding it; safe to clear.
|
|
||||||
let meta_lock = crate::paths::meta_git_index_lock();
|
|
||||||
if meta_lock.exists() {
|
|
||||||
match std::fs::remove_file(&meta_lock) {
|
|
||||||
Ok(()) => tracing::warn!("cleared stale meta/.git/index.lock"),
|
|
||||||
Err(e) => tracing::warn!(error = ?e, "clear stale meta lock failed"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let names = match enumerate_agents().await {
|
let names = match enumerate_agents().await {
|
||||||
Ok(names) => names,
|
Ok(names) => names,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|
|
||||||
|
|
@ -193,13 +193,6 @@ pub fn meta_root() -> PathBuf {
|
||||||
state_root().join("meta")
|
state_root().join("meta")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `meta/.git/index.lock` — git index lock; checked before meta git ops
|
|
||||||
/// so a stale lock from a crashed process can be cleared.
|
|
||||||
#[must_use]
|
|
||||||
pub fn meta_git_index_lock() -> PathBuf {
|
|
||||||
meta_root().join(".git/index.lock")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `meta-rev/` — one file per agent naming the `internal/meta` commit its
|
/// `meta-rev/` — one file per agent naming the `internal/meta` commit its
|
||||||
/// rebuild builds from (`crate::meta_rev`).
|
/// rebuild builds from (`crate::meta_rev`).
|
||||||
///
|
///
|
||||||
|
|
|
||||||
|
|
@ -222,7 +222,6 @@ async fn send_at(client: &async_nats::Client, hive: &str, meta: &Path) -> Result
|
||||||
"import: the controller took the meta repo's permissions"
|
"import: the controller took the meta repo's permissions"
|
||||||
);
|
);
|
||||||
crate::meta::remove_permission_files()
|
crate::meta::remove_permission_files()
|
||||||
.await
|
|
||||||
.context("deleting the imported permission files")?;
|
.context("deleting the imported permission files")?;
|
||||||
Ok(ImportOutcome::Imported)
|
Ok(ImportOutcome::Imported)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue