deploy the closure the swarm declares, read no forge head, drop the update badge
This commit is contained in:
parent
279428a66f
commit
47c2acb640
3 changed files with 647 additions and 2001 deletions
|
|
@ -1,104 +0,0 @@
|
|||
//! This hive's reads of `internal/meta` and of an agent's config head, as
|
||||
//! [`crate::meta_rev::MetaSource`], under the hive's forge read token.
|
||||
|
||||
use forgejo_api::structs::RepoGetRawFileQuery;
|
||||
use forgejo_api::{ApiErrorKind, Forgejo, ForgejoError};
|
||||
use reqwest::StatusCode;
|
||||
|
||||
use super::reader::ReaderToken;
|
||||
use super::{CONFIG_ORG, bounded};
|
||||
use crate::meta_rev::MetaSource;
|
||||
|
||||
/// The org and repo the swarm commits its meta flake to.
|
||||
const META_OWNER: &str = "internal";
|
||||
const META_REPO: &str = "meta";
|
||||
|
||||
/// The branch an agent's config is deployed from, which `fetch_forge_main`
|
||||
/// and the swarm controller's pins both read.
|
||||
const CONFIG_BRANCH: &str = "main";
|
||||
|
||||
/// [`MetaSource`] over the forge API.
|
||||
pub(crate) struct ForgeSource {
|
||||
api: Forgejo,
|
||||
}
|
||||
|
||||
/// A source reading as this hive, or `None` when it holds no forge token.
|
||||
pub(crate) async fn internal_meta_source() -> Option<ForgeSource> {
|
||||
let token: ReaderToken = super::reader_token().await?;
|
||||
match token.api() {
|
||||
Ok(api) => Some(ForgeSource { api }),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, "internal/meta: cannot build a forge client");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_not_found(e: &ForgejoError) -> bool {
|
||||
match e {
|
||||
ForgejoError::ApiError(api) => match api.error_kind() {
|
||||
ApiErrorKind::NotFound { .. } => true,
|
||||
ApiErrorKind::Other(s) => *s == StatusCode::NOT_FOUND,
|
||||
_ => false,
|
||||
},
|
||||
ForgejoError::UnexpectedStatusCode(s) => *s == StatusCode::NOT_FOUND,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
impl ForgeSource {
|
||||
/// The head commit of `branch` in `owner/repo`; `None` when the repo or
|
||||
/// the branch is not there.
|
||||
async fn branch_head(
|
||||
&self,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
branch: &str,
|
||||
) -> anyhow::Result<Option<String>> {
|
||||
let op = format!("read branch {branch} of {owner}/{repo}");
|
||||
match bounded(&op, self.api.repo_get_branch(owner, repo, branch)).await? {
|
||||
Ok(branch) => Ok(branch.commit.and_then(|c| c.id)),
|
||||
Err(e) if is_not_found(&e) => Ok(None),
|
||||
Err(e) => Err(anyhow::Error::new(e).context(op)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MetaSource for ForgeSource {
|
||||
async fn meta_head(&self) -> anyhow::Result<Option<String>> {
|
||||
use anyhow::Context as _;
|
||||
|
||||
let op = format!("read repo {META_OWNER}/{META_REPO}");
|
||||
let repo = match bounded(&op, self.api.repo_get(META_OWNER, META_REPO)).await? {
|
||||
Ok(repo) => repo,
|
||||
Err(e) if is_not_found(&e) => return Ok(None),
|
||||
Err(e) => return Err(anyhow::Error::new(e).context(op)),
|
||||
};
|
||||
if repo.empty.unwrap_or(false) {
|
||||
return Ok(None);
|
||||
}
|
||||
let branch = repo
|
||||
.default_branch
|
||||
.with_context(|| format!("{META_OWNER}/{META_REPO} names no default branch"))?;
|
||||
self.branch_head(META_OWNER, META_REPO, &branch).await
|
||||
}
|
||||
|
||||
async fn flake_at(&self, rev: &str) -> anyhow::Result<String> {
|
||||
let op = format!("read flake.nix of {META_OWNER}/{META_REPO} at {rev}");
|
||||
let query = RepoGetRawFileQuery {
|
||||
r#ref: Some(rev.to_owned()),
|
||||
};
|
||||
let raw = bounded(
|
||||
&op,
|
||||
self.api
|
||||
.repo_get_raw_file(META_OWNER, META_REPO, "flake.nix", query),
|
||||
)
|
||||
.await?
|
||||
.map_err(|e| anyhow::Error::new(e).context(op))?;
|
||||
Ok(String::from_utf8_lossy(&raw).into_owned())
|
||||
}
|
||||
|
||||
async fn config_head(&self, agent: &str) -> anyhow::Result<Option<String>> {
|
||||
self.branch_head(CONFIG_ORG, agent, CONFIG_BRANCH).await
|
||||
}
|
||||
}
|
||||
|
|
@ -4,18 +4,13 @@
|
|||
//! `internal/docs`, are swarm-controller's. Full design:
|
||||
//! `docs/integrations/forge.md`.
|
||||
|
||||
mod internal_meta;
|
||||
mod reader;
|
||||
|
||||
pub(crate) use internal_meta::internal_meta_source;
|
||||
pub use reader::reader_token;
|
||||
|
||||
use std::future::IntoFuture;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use forgejo_api::{Auth, Forgejo};
|
||||
use url::Url;
|
||||
|
||||
const FORGE_CONTAINER: &str = "hive-forge";
|
||||
|
|
@ -88,15 +83,6 @@ fn basic_auth_header(login: &str, token: &str) -> String {
|
|||
format!("Authorization: Basic {basic}")
|
||||
}
|
||||
|
||||
/// Forgejo org grouping every agent's config repo. Each agent is a **write collaborator
|
||||
/// on its own** `agent-configs/<name>` repo — the editable PR surface it
|
||||
/// pushes config-change branches to. `main`'s branch protection is
|
||||
/// swarm-controller's: merge and approval allowlisted to the `operators` team,
|
||||
/// so the agent can't push `main` or self-merge, and an operator's merge
|
||||
/// deploys through swarm-controller. The repos remain private, so an agent
|
||||
/// still can't reach *another* agent's config. hive-c0re only reads from it.
|
||||
pub(crate) const CONFIG_ORG: &str = "agent-configs";
|
||||
|
||||
/// Probe whether `hive-forge` exists as a nixos-container. Cheap —
|
||||
/// `nixos-container list` is just a directory scan in /etc. Routed
|
||||
/// through hive-priv: `nixos-container` needs root, and hive-c0re runs
|
||||
|
|
@ -108,70 +94,9 @@ pub async fn is_present() -> bool {
|
|||
stdout.lines().any(|l| l.trim() == FORGE_CONTAINER)
|
||||
}
|
||||
|
||||
/// Typed Forgejo API client for the local forge ([`forge_http_base()`]),
|
||||
/// authenticated as `token`. All Forgejo API calls go through clients built
|
||||
/// here — one place for the base URL and auth. The token is passed per call;
|
||||
/// the base URL is parsed once. Failures surface as
|
||||
/// `forgejo_api::ForgejoError`, whose Display carries the HTTP status
|
||||
/// and the API's error message (e.g. the validation reason on a 422).
|
||||
pub(crate) fn api(token: &str) -> Result<Forgejo> {
|
||||
static URL: OnceLock<Url> = OnceLock::new();
|
||||
let url = URL
|
||||
.get_or_init(|| Url::parse(forge_http_base()).expect("forge_http_base() is a valid URL"))
|
||||
.clone();
|
||||
Forgejo::new(Auth::Token(token), url).context("build forgejo api client")
|
||||
}
|
||||
|
||||
/// Upper bound on one Forgejo API request. `forgejo-api` takes no
|
||||
/// caller-built client, so the request itself has no timeout; every request
|
||||
/// in this module is wrapped in [`bounded`].
|
||||
const API_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Await one Forgejo API request for at most [`API_TIMEOUT`].
|
||||
///
|
||||
/// The outer `Err` is the deadline, naming `op`; the inner `Result` is the
|
||||
/// request's own outcome, left to the caller so it can still match on the
|
||||
/// error (`409 already exists`, `401`, ...).
|
||||
async fn bounded<F, T, E>(op: &str, request: F) -> Result<std::result::Result<T, E>>
|
||||
where
|
||||
F: IntoFuture<Output = std::result::Result<T, E>>,
|
||||
{
|
||||
tokio::time::timeout(API_TIMEOUT, request)
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("{op}: no answer from the forge within {API_TIMEOUT:?}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{API_TIMEOUT, bounded, git_url_with_base};
|
||||
|
||||
/// A forge that never answers surfaces as an error naming the operation,
|
||||
/// at exactly [`API_TIMEOUT`] — not as a hang.
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn a_request_the_forge_never_answers_errors_at_the_deadline() {
|
||||
let started = tokio::time::Instant::now();
|
||||
let err = bounded(
|
||||
"pending request",
|
||||
std::future::pending::<Result<(), forgejo_api::ForgejoError>>(),
|
||||
)
|
||||
.await
|
||||
.expect_err("a pending request must hit the deadline");
|
||||
assert_eq!(started.elapsed(), API_TIMEOUT);
|
||||
assert!(err.to_string().contains("pending request"), "{err}");
|
||||
}
|
||||
|
||||
/// The request's own result passes through for the caller to classify.
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn an_answered_request_keeps_its_own_result() {
|
||||
let ok = bounded("op", async { Ok::<_, ()>(7) })
|
||||
.await
|
||||
.expect("in time");
|
||||
assert_eq!(ok, Ok(7));
|
||||
let failed = bounded("op", async { Err::<(), _>("409") })
|
||||
.await
|
||||
.expect("in time");
|
||||
assert_eq!(failed, Err("409"));
|
||||
}
|
||||
use super::git_url_with_base;
|
||||
|
||||
/// The remote carries **no credential**. `argv` is world-readable through
|
||||
/// `/proc/<pid>/cmdline`, so a token spliced in here would be published to
|
||||
|
|
|
|||
|
|
@ -26,11 +26,6 @@ impl ReaderToken {
|
|||
pub fn auth_header(&self) -> String {
|
||||
basic_auth_header(&self.login, &self.value)
|
||||
}
|
||||
|
||||
/// A forge API client acting as this token's account.
|
||||
pub(crate) fn api(&self) -> anyhow::Result<forgejo_api::Forgejo> {
|
||||
super::api(&self.value)
|
||||
}
|
||||
}
|
||||
|
||||
/// The token to read the forge with, or `None` when the store holds none for
|
||||
|
|
|
|||
Loading…
Reference in a new issue