forge: revalidate the core token against the live forge before trusting it
This commit is contained in:
parent
2a840c14a2
commit
3cac374c60
1 changed files with 120 additions and 4 deletions
|
|
@ -414,17 +414,82 @@ async fn ensure_config_org_avatar(token: &str) -> Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Outcome of probing whether the persisted core token still works
|
||||||
|
/// against the *current* forge. Existence on disk is not validity: a
|
||||||
|
/// token minted before a forge rebuild / re-provision is unknown to the
|
||||||
|
/// new forge's DB and 401s on every call — which silently breaks the
|
||||||
|
/// hive-ci runner-registration prefetch (it reads this same token to
|
||||||
|
/// fetch a runner registration token).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum CoreTokenCheck {
|
||||||
|
/// Token authenticated successfully — keep using it.
|
||||||
|
Valid,
|
||||||
|
/// Forge explicitly rejected the token (401/403) — re-mint.
|
||||||
|
Invalid,
|
||||||
|
/// Couldn't determine (forge unreachable / 5xx). Don't re-mint on a
|
||||||
|
/// transient: keep the existing token and let a later ensure pass
|
||||||
|
/// re-check once the forge is responsive. Re-minting here would both
|
||||||
|
/// fail (mint needs the forge too) and churn tokens needlessly.
|
||||||
|
Indeterminate,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map the HTTP status of the token-probe call to a [`CoreTokenCheck`].
|
||||||
|
/// Pure so the decision logic is unit-testable without a live forge.
|
||||||
|
fn classify_core_token_status(status: StatusCode) -> CoreTokenCheck {
|
||||||
|
if status.is_success() {
|
||||||
|
CoreTokenCheck::Valid
|
||||||
|
} else if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
|
||||||
|
CoreTokenCheck::Invalid
|
||||||
|
} else {
|
||||||
|
CoreTokenCheck::Indeterminate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Probe whether `token` is still accepted by the current forge with a
|
||||||
|
/// cheap authenticated `GET /api/v1/user` (covered by the core token's
|
||||||
|
/// `read:user` scope). See [`CoreTokenCheck`] for how the outcome is
|
||||||
|
/// interpreted.
|
||||||
|
async fn check_core_token(token: &str) -> CoreTokenCheck {
|
||||||
|
let url = format!("{FORGE_HTTP}/api/v1/user");
|
||||||
|
match forge_http(reqwest::Method::GET, &url, token, "").await {
|
||||||
|
Ok(status) => classify_core_token_status(status),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::debug!(
|
||||||
|
error = %e,
|
||||||
|
"forge: core-token probe could not reach forge; treating as indeterminate"
|
||||||
|
);
|
||||||
|
CoreTokenCheck::Indeterminate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Ensure the bootstrap `core` admin user + a token at
|
/// Ensure the bootstrap `core` admin user + a token at
|
||||||
/// `CORE_TOKEN_PATH`. The token is what hive-c0re uses for forgejo
|
/// `CORE_TOKEN_PATH`. The token is what hive-c0re uses for forgejo
|
||||||
/// API calls (org creation now, meta-repo push later). Returns the
|
/// API calls (org creation, meta-repo push, and the hive-ci
|
||||||
/// token. Idempotent: skips creation when user exists, skips token
|
/// runner-registration prefetch). Returns the token.
|
||||||
/// when the file is present.
|
///
|
||||||
|
/// Idempotent, but validity-aware: when a token file is already present
|
||||||
|
/// it is **probed against the current forge** before being trusted. A
|
||||||
|
/// token persisted before a forge rebuild / re-provision is stale (the
|
||||||
|
/// new forge DB doesn't know it) and would 401 every caller — so on a
|
||||||
|
/// definitive rejection the token is re-minted. A merely-unreachable
|
||||||
|
/// forge leaves the existing token in place (a later ensure pass
|
||||||
|
/// re-checks) rather than churning tokens on a transient.
|
||||||
async fn ensure_core_user_and_token() -> Result<String> {
|
async fn ensure_core_user_and_token() -> Result<String> {
|
||||||
let path = std::path::Path::new(CORE_TOKEN_PATH);
|
let path = std::path::Path::new(CORE_TOKEN_PATH);
|
||||||
if let Ok(existing) = std::fs::read_to_string(path) {
|
if let Ok(existing) = std::fs::read_to_string(path) {
|
||||||
let trimmed = existing.trim().to_owned();
|
let trimmed = existing.trim().to_owned();
|
||||||
if !trimmed.is_empty() {
|
if !trimmed.is_empty() {
|
||||||
return Ok(trimmed);
|
match check_core_token(&trimmed).await {
|
||||||
|
CoreTokenCheck::Valid | CoreTokenCheck::Indeterminate => return Ok(trimmed),
|
||||||
|
CoreTokenCheck::Invalid => {
|
||||||
|
tracing::warn!(
|
||||||
|
path = %path.display(),
|
||||||
|
"forge: persisted core token rejected by forge (stale after rebuild?); \
|
||||||
|
re-minting"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ensure_user_exists("core", true, None).await?;
|
ensure_user_exists("core", true, None).await?;
|
||||||
|
|
@ -868,3 +933,54 @@ pub async fn ensure_all() {
|
||||||
sync_agent(name, core_token.as_deref()).await;
|
sync_agent(name, core_token.as_deref()).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{CoreTokenCheck, classify_core_token_status};
|
||||||
|
use reqwest::StatusCode;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn success_statuses_are_valid() {
|
||||||
|
assert_eq!(
|
||||||
|
classify_core_token_status(StatusCode::OK),
|
||||||
|
CoreTokenCheck::Valid
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_core_token_status(StatusCode::NO_CONTENT),
|
||||||
|
CoreTokenCheck::Valid
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auth_rejection_statuses_are_invalid() {
|
||||||
|
// The whole point: a stale token (forge rebuilt out from under it)
|
||||||
|
// 401s, and 401/403 are the only outcomes that trigger a re-mint.
|
||||||
|
assert_eq!(
|
||||||
|
classify_core_token_status(StatusCode::UNAUTHORIZED),
|
||||||
|
CoreTokenCheck::Invalid
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_core_token_status(StatusCode::FORBIDDEN),
|
||||||
|
CoreTokenCheck::Invalid
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transient_and_unexpected_statuses_are_indeterminate() {
|
||||||
|
// Never re-mint on a transient — minting needs the forge too, and
|
||||||
|
// churning tokens on a blip is worse than keeping the existing one.
|
||||||
|
for s in [
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
StatusCode::BAD_GATEWAY,
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
StatusCode::GATEWAY_TIMEOUT,
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
classify_core_token_status(s),
|
||||||
|
CoreTokenCheck::Indeterminate,
|
||||||
|
"status {s} should be indeterminate"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue