hivectl: print token to stdout for non-agent users (#662)
This commit is contained in:
parent
a9114342e4
commit
18253bf2f5
3 changed files with 121 additions and 40 deletions
|
|
@ -17,6 +17,7 @@
|
|||
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use clap::{Parser, Subcommand};
|
||||
use hive_c0re::coordinator::Coordinator;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
|
|
@ -58,13 +59,21 @@ enum Cmd {
|
|||
#[derive(Subcommand)]
|
||||
enum ForgeCmd {
|
||||
/// Create or refresh the Forgejo account + token for `<name>`.
|
||||
/// Idempotent: skips user creation when the account exists,
|
||||
/// skips token mint when the token file is already populated.
|
||||
/// To force re-minting, delete the token file at
|
||||
/// `/var/lib/hyperhive/agents/<name>/state/forge-token`.
|
||||
///
|
||||
/// When `<name>` matches an existing agent (i.e. it has a state
|
||||
/// dir under `/var/lib/hyperhive/agents/`), persists the token to
|
||||
/// `<state>/forge-token` (idempotent: re-mints + rewrites every
|
||||
/// call so the on-disk scope matches the current
|
||||
/// `forge::TOKEN_SCOPES`).
|
||||
///
|
||||
/// When `<name>` is **not** an agent (a human or any other
|
||||
/// non-container account), creates the forgejo user and prints the
|
||||
/// freshly-minted token to stdout — no `/var/lib/hyperhive/agents/`
|
||||
/// directory is created for the user (#662).
|
||||
CreateUser {
|
||||
/// Container/agent name (the `<name>` in `h-<name>`; manager
|
||||
/// agent uses the literal `manager`).
|
||||
/// Forgejo username. For agents: the container/agent name
|
||||
/// (`<n>` in `h-<n>`; manager uses the literal `manager`).
|
||||
/// For humans: any forgejo username — `mara`, `damocles`, etc.
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
|
|
@ -72,11 +81,20 @@ enum ForgeCmd {
|
|||
#[derive(Subcommand)]
|
||||
enum MatrixCmd {
|
||||
/// Create or refresh the matrix account + access token for `<name>`.
|
||||
/// Idempotent: skips registration entirely when the token file is
|
||||
/// already populated. To force re-registration, delete the token
|
||||
/// file at `/var/lib/hyperhive/agents/<name>/state/matrix-token`.
|
||||
///
|
||||
/// When `<name>` matches an existing agent (i.e. it has a state
|
||||
/// dir under `/var/lib/hyperhive/agents/`), persists the token to
|
||||
/// `<state>/matrix-token`. Skips registration when the file is
|
||||
/// already populated; delete it to force re-registration.
|
||||
///
|
||||
/// When `<name>` is **not** an agent (a human or any other
|
||||
/// non-container account), registers the matrix user and prints
|
||||
/// the freshly-minted access token to stdout — no
|
||||
/// `/var/lib/hyperhive/agents/` directory is created for the user
|
||||
/// (#662).
|
||||
CreateUser {
|
||||
/// Container/agent name.
|
||||
/// Matrix localpart. For agents: the container/agent name.
|
||||
/// For humans: any matrix localpart — `mara`, `damocles`, etc.
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
|
|
@ -100,16 +118,35 @@ async fn main() -> Result<()> {
|
|||
}
|
||||
}
|
||||
|
||||
/// True when `name` matches an existing hyperhive agent — i.e. it has a
|
||||
/// persistent state dir under `/var/lib/hyperhive/agents/`. We use the
|
||||
/// state dir (not the live container list) so kept-state tombstones
|
||||
/// still resolve as agents — re-provisioning a destroyed-but-kept agent
|
||||
/// should still drop its token in the existing state tree.
|
||||
fn is_agent(name: &str) -> bool {
|
||||
Coordinator::agent_state_root(name).exists()
|
||||
}
|
||||
|
||||
async fn forge_create_user(name: &str) -> Result<()> {
|
||||
if !hive_c0re::forge::is_present().await {
|
||||
bail!(
|
||||
"hive-forge container not running — start it (services.hyperhive.forge.enable = true) before provisioning forge users"
|
||||
);
|
||||
}
|
||||
hive_c0re::forge::ensure_user_for(name)
|
||||
.await
|
||||
.with_context(|| format!("forge create-user {name}"))?;
|
||||
println!("forge: provisioned user '{name}' (idempotent)");
|
||||
if is_agent(name) {
|
||||
hive_c0re::forge::ensure_user_for(name)
|
||||
.await
|
||||
.with_context(|| format!("forge create-user {name}"))?;
|
||||
let path = Coordinator::agent_notes_dir(name).join("forge-token");
|
||||
println!("forge: provisioned agent user '{name}'");
|
||||
println!("token persisted at: {}", path.display());
|
||||
} else {
|
||||
let token = hive_c0re::forge::provision_user_token(name)
|
||||
.await
|
||||
.with_context(|| format!("forge create-user {name}"))?;
|
||||
println!("forge: provisioned user '{name}' (not an agent — token not persisted)");
|
||||
println!("token: {token}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -125,9 +162,19 @@ async fn matrix_create_user(name: &str) -> Result<()> {
|
|||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.context("build reqwest client")?;
|
||||
hive_c0re::matrix::ensure_user_for(&client, name, ®ister_token)
|
||||
.await
|
||||
.with_context(|| format!("matrix create-user {name}"))?;
|
||||
println!("matrix: provisioned user '{name}' (idempotent)");
|
||||
if is_agent(name) {
|
||||
hive_c0re::matrix::ensure_user_for(&client, name, ®ister_token)
|
||||
.await
|
||||
.with_context(|| format!("matrix create-user {name}"))?;
|
||||
let path = Coordinator::agent_notes_dir(name).join("matrix-token");
|
||||
println!("matrix: provisioned agent user '{name}'");
|
||||
println!("token persisted at: {}", path.display());
|
||||
} else {
|
||||
let token = hive_c0re::matrix::provision_user_token(&client, name, ®ister_token)
|
||||
.await
|
||||
.with_context(|| format!("matrix create-user {name}"))?;
|
||||
println!("matrix: provisioned user '{name}' (not an agent — token not persisted)");
|
||||
println!("token: {token}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,8 +75,7 @@ const SEEDED_ORGS: &[&str] = &[CONFIG_ORG];
|
|||
/// `GET /notifications` for unread PR/review events.
|
||||
/// - `write:notification` — required by `forge_notify` to mark
|
||||
/// notifications as read via `PATCH /notifications/threads/{id}`.
|
||||
const TOKEN_SCOPES: &str =
|
||||
"read:user,write:user,read:notification,write:notification,write:repository,write:issue,write:organization,write:misc";
|
||||
const TOKEN_SCOPES: &str = "read:user,write:user,read:notification,write:notification,write:repository,write:issue,write:organization,write:misc";
|
||||
|
||||
/// Scopes for the bootstrap `core` token used by hive-c0re itself.
|
||||
/// Adds `read:admin,write:admin` on top of `TOKEN_SCOPES` so the host
|
||||
|
|
@ -198,13 +197,7 @@ async fn forge_http(
|
|||
/// `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",
|
||||
];
|
||||
let mut args = vec!["user", "create", "--username", name, "--email"];
|
||||
let email = agent_email(name);
|
||||
args.push(&email);
|
||||
args.extend(["--random-password", "--must-change-password=false"]);
|
||||
|
|
@ -275,15 +268,13 @@ async fn ensure_user_email(name: &str) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Mint a fresh access token for `name` and persist it to
|
||||
/// `<state>/forge-token` (0600). Token name is suffixed with a
|
||||
/// monotonic clock so re-issuing doesn't collide with an existing
|
||||
/// Mint a fresh access token for `name`. Token name is suffixed with
|
||||
/// a monotonic clock so re-issuing doesn't collide with an existing
|
||||
/// token of the same name in the DB. `scopes` is the scope string
|
||||
/// passed to `forgejo admin user generate-access-token --scopes`;
|
||||
/// use `TOKEN_SCOPES` for agents, `CORE_TOKEN_SCOPES` for the
|
||||
/// bootstrap `core` user.
|
||||
async fn mint_and_persist_token(name: &str, path: &Path, scopes: &str) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
async fn mint_token(name: &str, scopes: &str) -> Result<String> {
|
||||
let token_name = format!(
|
||||
"{TOKEN_NAME_PREFIX}-{}",
|
||||
std::time::SystemTime::now()
|
||||
|
|
@ -304,13 +295,23 @@ async fn mint_and_persist_token(name: &str, path: &Path, scopes: &str) -> Result
|
|||
.await?;
|
||||
let token = extract_token(&stdout)
|
||||
.with_context(|| format!("parse token from forgejo output: {stdout:?}"))?;
|
||||
tracing::debug!(%name, %token_name, "forge: minted access token");
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/// Mint a fresh access token for `name` and persist it to `path`
|
||||
/// (0600). Wraps [`mint_token`] for callers that want the token on
|
||||
/// disk under an agent state dir.
|
||||
async fn mint_and_persist_token(name: &str, path: &Path, scopes: &str) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let token = mint_token(name, scopes).await?;
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).ok();
|
||||
}
|
||||
std::fs::write(path, format!("{token}\n"))
|
||||
.with_context(|| format!("write token to {}", path.display()))?;
|
||||
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
|
||||
tracing::info!(%name, path = %path.display(), %token_name, "forge: persisted access token");
|
||||
tracing::info!(%name, path = %path.display(), "forge: persisted access token");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -326,6 +327,23 @@ pub async fn ensure_user_for(name: &str) -> Result<()> {
|
|||
mint_and_persist_token(name, &token_path(name), TOKEN_SCOPES).await
|
||||
}
|
||||
|
||||
/// Provision a forgejo user for `name` and return the freshly-minted
|
||||
/// token. Unlike [`ensure_user_for`], the token is **not** persisted to
|
||||
/// disk — the caller is responsible for storing it. Used by `hivectl
|
||||
/// 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> {
|
||||
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_email(name).await;
|
||||
mint_token(name, TOKEN_SCOPES).await
|
||||
}
|
||||
|
||||
/// Set `core`'s Forgejo avatar to the hyperhive logo once, then
|
||||
/// remember it so subsequent startups don't re-upload (issue #320).
|
||||
/// Best-effort — any non-2xx is logged at the caller; the project
|
||||
|
|
@ -383,7 +401,10 @@ async fn ensure_config_org_avatar(token: &str) -> Result<()> {
|
|||
std::fs::create_dir_all(parent).ok();
|
||||
}
|
||||
std::fs::write(marker, "").ok();
|
||||
tracing::info!(org = CONFIG_ORG, "forge: set org avatar to configs-stack logo");
|
||||
tracing::info!(
|
||||
org = CONFIG_ORG,
|
||||
"forge: set org avatar to configs-stack logo"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -538,9 +559,7 @@ pub async fn meta_read_access(name: &str, core_token: &str) -> Result<()> {
|
|||
tracing::info!(%name, "forge: granted meta read access");
|
||||
Ok(())
|
||||
}
|
||||
other => anyhow::bail!(
|
||||
"PUT core/meta/collaborators/{name} returned HTTP {other}"
|
||||
),
|
||||
other => anyhow::bail!("PUT core/meta/collaborators/{name} returned HTTP {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -673,9 +692,10 @@ pub async fn sync_agent(name: &str, core_token: Option<&str>) {
|
|||
// Grant read-only access to core/meta and wire the `meta` remote
|
||||
// into the proposed repo so agents can fetch their deployment context.
|
||||
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");
|
||||
}
|
||||
&& let Err(e) = meta_read_access(name, token).await
|
||||
{
|
||||
tracing::warn!(%name, error = ?e, "forge: ensure_meta_read_access failed");
|
||||
}
|
||||
if let Err(e) = ensure_meta_remote(name).await {
|
||||
tracing::warn!(%name, error = ?e, "forge: ensure_meta_remote failed");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -263,6 +263,20 @@ pub async fn ensure_user_for(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Register a matrix account for `name` and return the freshly-minted
|
||||
/// access token. Unlike [`ensure_user_for`], the token is **not**
|
||||
/// persisted to disk — the caller is responsible for storing it. Used
|
||||
/// by `hivectl matrix 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(
|
||||
client: &reqwest::Client,
|
||||
name: &str,
|
||||
register_token: &str,
|
||||
) -> Result<String> {
|
||||
register_user(client, name, register_token).await
|
||||
}
|
||||
|
||||
/// Per-agent matrix sync: ensure the agent has a matrix account + token.
|
||||
/// All operations are idempotent; failures are logged as warnings but
|
||||
/// don't abort the caller.
|
||||
|
|
|
|||
Loading…
Reference in a new issue