hivectl: extend --password to forge; clarify matrix M_USER_IN_USE asymmetry (#663 / argus)

This commit is contained in:
damocles 2026-05-30 21:55:25 +02:00 committed by Mara
commit 97797cf790
3 changed files with 100 additions and 13 deletions

View file

@ -195,12 +195,19 @@ async fn forge_http(
/// Ensure a forgejo user named `name` exists. Idempotent: forgejo
/// returns a "user already exists" error which we treat as success.
/// `admin` adds `--admin` (site admin) — used for the bootstrap
/// `core` user that drives the API.
async fn ensure_user_exists(name: &str, admin: bool) -> Result<()> {
let mut args = vec!["user", "create", "--username", name, "--email"];
/// `core` user that drives the API. `password` picks the initial
/// account password: `None` uses `--random-password` (the existing
/// agent provisioning shape — the password is never read, agents auth
/// by token); `Some(pw)` uses `--password <pw>` so the operator path
/// in `hivectl` can set a real password for matrix-style web-UI login
/// (#663).
async fn ensure_user_exists(name: &str, admin: bool, password: Option<&str>) -> Result<()> {
let email = agent_email(name);
args.push(&email);
args.extend(["--random-password", "--must-change-password=false"]);
let mut args = vec!["user", "create", "--username", name, "--email", &email];
match password {
Some(pw) => args.extend(["--password", pw, "--must-change-password=false"]),
None => args.extend(["--random-password", "--must-change-password=false"]),
}
if admin {
args.push("--admin");
}
@ -226,6 +233,23 @@ async fn ensure_user_exists(name: &str, admin: bool) -> Result<()> {
}
}
/// Set the forgejo password for an existing user. Used by the operator
/// path in `hivectl forge create-user --password` so re-running on an
/// already-created account still updates the password (covers the
/// "I forgot the password I set last week" case + the "argus retried
/// the verb to verify the fix" case — `forgejo admin user create`
/// silently skips a password change once the account exists). Idempotent
/// from the operator's point of view: same password input → same final
/// account state.
async fn change_user_password(name: &str, password: &str) -> Result<()> {
let args = ["user", "change-password", "--username", name, "--password", password];
forge_admin(&args)
.await
.with_context(|| format!("forgejo admin user change-password {name}"))?;
tracing::info!(%name, "forge: changed user password");
Ok(())
}
/// Idempotently align the Forgejo account email to `agent_email(name)`.
/// Existing agents were created with `{name}@hive.local`; this corrects
/// that so git commits (which use `{name}@hyperhive`) link to profiles.
@ -322,7 +346,7 @@ pub async fn ensure_user_for(name: &str) -> Result<()> {
if !is_present().await {
return Ok(());
}
ensure_user_exists(name, false).await?;
ensure_user_exists(name, false, None).await?;
ensure_user_email(name).await;
mint_and_persist_token(name, &token_path(name), TOKEN_SCOPES).await
}
@ -333,13 +357,28 @@ pub async fn ensure_user_for(name: &str) -> Result<()> {
/// forge create-user` for human (non-agent) accounts so we don't create
/// stray `/var/lib/hyperhive/agents/<name>/` directories for users that
/// aren't agents (#662).
pub async fn provision_user_token(name: &str) -> Result<String> {
///
/// `password` picks the account password. `None` keeps the existing
/// random-throwaway shape (caller doesn't need web UI access — token
/// alone is enough). `Some(pw)` sets `pw` as the password, including
/// running `forgejo admin user change-password` if the account already
/// exists, so the operator can log into the forge web UI afterwards
/// (#663). Idempotent: re-running with the same `Some(pw)` lands on
/// the same final state.
pub async fn provision_user_token(name: &str, password: Option<&str>) -> Result<String> {
if !is_present().await {
anyhow::bail!(
"hive-forge container not running — start it (services.hyperhive.forge.enable = true) before provisioning forge users"
);
}
ensure_user_exists(name, false).await?;
ensure_user_exists(name, false, password).await?;
if let Some(pw) = password {
// `user create` silently no-ops on an existing account, so
// we run change-password unconditionally when the caller
// asked for a specific password — keeps the verb idempotent
// for "set or reset" use.
change_user_password(name, pw).await?;
}
ensure_user_email(name).await;
mint_token(name, TOKEN_SCOPES).await
}
@ -421,7 +460,7 @@ async fn ensure_core_user_and_token() -> Result<String> {
return Ok(trimmed);
}
}
ensure_user_exists("core", true).await?;
ensure_user_exists("core", true, None).await?;
mint_and_persist_token("core", path, CORE_TOKEN_SCOPES).await?;
let raw = std::fs::read_to_string(path)
.with_context(|| format!("read {CORE_TOKEN_PATH} after mint"))?;