Jump to…
snowinitial commitqoxwzsukwmkx1mo
Matt W1//! `dogfood-admin` — operational CLI.
Matt W2//!
Matt W3//! Spec §4 requires `dogfood-admin reindex --repo <id>`, safe to run against a
Matt W4//! live repository, "because you will need it every time indexing logic
Matt W5//! changes". Spec §10 adds `reindex --all` as the recovery path when the
Matt W6//! database and the repository volume drift apart after a restore.
Matt W7
Matt W8use anyhow::{Context, Result};
Matt W9use clap::{Parser, Subcommand};
Matt W10use df_db::ids::new_id;
Matt W11use uuid::Uuid;
Matt W12
Matt W13#[derive(Parser)]
Matt W14#[command(name = "dogfood-admin", about = "Dogfood operational commands")]
Matt W15struct Cli {
Matt W16 #[arg(long, env = "DATABASE_URL")]
Matt W17 database_url: String,
Matt W18
Matt W19 #[command(subcommand)]
Matt W20 command: Command,
Matt W21}
Matt W22
Matt W23#[derive(Subcommand)]
Matt W24enum Command {
Matt W25 /// Re-run indexing. Safe against a live repository.
Matt W26 Reindex {
Matt W27 /// Repository UUID. Omit with --all.
Matt W28 #[arg(long)]
Matt W29 repo: Option<Uuid>,
Matt W30 /// Reindex every repository.
Matt W31 #[arg(long)]
Matt W32 all: bool,
Matt W33 },
Matt W34 /// Manage personal access tokens.
Matt W35 Token {
Matt W36 #[command(subcommand)]
Matt W37 command: TokenCommand,
Matt W38 },
Matt W39 /// Manage users.
Matt W40 User {
Matt W41 #[command(subcommand)]
Matt W42 command: UserCommand,
Matt W43 },
Matt W44 /// Invite an email address to a closed instance.
Matt W45 Invite {
Matt W46 #[arg(long)]
Matt W47 email: String,
Matt W48 },
Matt W49}
Matt W50
Matt W51#[derive(Subcommand)]
Matt W52enum TokenCommand {
Matt W53 /// Mint a token. The plaintext is printed once and never stored.
Matt W54 Create {
Matt W55 #[arg(long)]
Matt W56 handle: String,
Matt W57 #[arg(long, default_value = "admin-cli")]
Matt W58 name: String,
Matt W59 },
Matt W60 List {
Matt W61 #[arg(long)]
Matt W62 handle: String,
Matt W63 },
Matt W64}
Matt W65
Matt W66#[derive(Subcommand)]
Matt W67enum UserCommand {
Matt W68 List,
Matt W69 /// Grant or revoke site admin.
Matt W70 Admin {
Matt W71 #[arg(long)]
Matt W72 handle: String,
Matt W73 #[arg(long)]
Matt W74 revoke: bool,
Matt W75 },
Matt W76}
Matt W77
Matt W78#[tokio::main]
Matt W79async fn main() -> Result<()> {
Matt W80 let _ = dotenvy::dotenv();
Matt W81 let cli = Cli::parse();
Matt W82
Matt W83 let db = df_db::connect(&cli.database_url, 4)
Matt W84 .await
Matt W85 .context("connecting to the database")?;
Matt W86
Matt W87 match cli.command {
Matt W88 Command::Reindex { repo, all } => reindex(&db, repo, all).await,
Matt W89 Command::Token { command } => match command {
Matt W90 TokenCommand::Create { handle, name } => create_token(&db, &handle, &name).await,
Matt W91 TokenCommand::List { handle } => list_tokens(&db, &handle).await,
Matt W92 },
Matt W93 Command::User { command } => match command {
Matt W94 UserCommand::List => list_users(&db).await,
Matt W95 UserCommand::Admin { handle, revoke } => set_admin(&db, &handle, !revoke).await,
Matt W96 },
Matt W97 Command::Invite { email } => invite(&db, &email).await,
Matt W98 }
Matt W99}
Matt W100
Matt W101/// Enqueue indexing rather than doing it inline.
Matt W102///
Matt W103/// The worker owns indexing, so routing through the queue means the CLI and a
Matt W104/// push take exactly the same code path — there is no second implementation to
Matt W105/// drift.
Matt W106async fn reindex(db: &df_db::PgPool, repo: Option<Uuid>, all: bool) -> Result<()> {
Matt W107 let repos: Vec<(Uuid,)> = if all {
Matt W108 sqlx::query_as("SELECT id FROM repos ORDER BY created_at")
Matt W109 .fetch_all(db)
Matt W110 .await?
Matt W111 } else {
Matt W112 let id = repo.context("pass --repo <uuid> or --all")?;
Matt W113 sqlx::query_as("SELECT id FROM repos WHERE id = $1")
Matt W114 .bind(id)
Matt W115 .fetch_all(db)
Matt W116 .await?
Matt W117 };
Matt W118
Matt W119 if repos.is_empty() {
Matt W120 println!("no matching repositories");
Matt W121 return Ok(());
Matt W122 }
Matt W123
Matt W124 for (id,) in &repos {
Matt W125 sqlx::query("INSERT INTO jobs (id, kind, payload) VALUES ($1, 'index_push', $2)")
Matt W126 .bind(new_id())
Matt W127 .bind(serde_json::json!({ "repo_id": id }))
Matt W128 .execute(db)
Matt W129 .await?;
Matt W130 println!("queued reindex for {id}");
Matt W131 }
Matt W132
Matt W133 println!("\n{} job(s) queued; the worker will pick them up.", repos.len());
Matt W134 Ok(())
Matt W135}
Matt W136
Matt W137async fn create_token(db: &df_db::PgPool, handle: &str, name: &str) -> Result<()> {
Matt W138 let (user_id,): (Uuid,) = sqlx::query_as("SELECT id FROM users WHERE handle = $1")
Matt W139 .bind(handle)
Matt W140 .fetch_optional(db)
Matt W141 .await?
Matt W142 .with_context(|| format!("no user with handle {handle}"))?;
Matt W143
Matt W144 let token = df_auth::tokens::create(db, user_id, name, &[], None).await?;
Matt W145
Matt W146 println!("Token created for {handle}.");
Matt W147 println!("\n {}\n", token.plaintext);
Matt W148 println!("This is shown once — only an Argon2id hash is stored.");
Matt W149 println!("Use it as the password for git over HTTPS, with {handle} as the username.");
Matt W150 Ok(())
Matt W151}
Matt W152
Matt W153async fn list_tokens(db: &df_db::PgPool, handle: &str) -> Result<()> {
Matt W154 let rows: Vec<(String, String, Option<chrono::DateTime<chrono::Utc>>)> = sqlx::query_as(
Matt W155 "SELECT t.name, t.prefix, t.last_used_at
Matt W156 FROM access_tokens t JOIN users u ON u.id = t.user_id
Matt W157 WHERE u.handle = $1 ORDER BY t.created_at DESC",
Matt W158 )
Matt W159 .bind(handle)
Matt W160 .fetch_all(db)
Matt W161 .await?;
Matt W162
Matt W163 if rows.is_empty() {
Matt W164 println!("no tokens for {handle}");
Matt W165 return Ok(());
Matt W166 }
Matt W167 for (name, prefix, used) in rows {
Matt W168 println!(
Matt W169 "{prefix}… {name:<24} last used: {}",
Matt W170 used.map(|t| t.to_rfc3339()).unwrap_or_else(|| "never".into())
Matt W171 );
Matt W172 }
Matt W173 Ok(())
Matt W174}
Matt W175
Matt W176async fn list_users(db: &df_db::PgPool) -> Result<()> {
Matt W177 let rows: Vec<(String, Option<String>, bool)> =
Matt W178 sqlx::query_as("SELECT handle::text, email::text, is_admin FROM users ORDER BY created_at")
Matt W179 .fetch_all(db)
Matt W180 .await?;
Matt W181 for (handle, email, admin) in rows {
Matt W182 println!(
Matt W183 "{handle:<20} {:<32} {}",
Matt W184 email.unwrap_or_else(|| "(no email)".into()),
Matt W185 if admin { "admin" } else { "" }
Matt W186 );
Matt W187 }
Matt W188 Ok(())
Matt W189}
Matt W190
Matt W191async fn set_admin(db: &df_db::PgPool, handle: &str, grant: bool) -> Result<()> {
Matt W192 let r = sqlx::query("UPDATE users SET is_admin = $2 WHERE handle = $1")
Matt W193 .bind(handle)
Matt W194 .bind(grant)
Matt W195 .execute(db)
Matt W196 .await?;
Matt W197 if r.rows_affected() == 0 {
Matt W198 anyhow::bail!("no user with handle {handle}");
Matt W199 }
Matt W200 println!(
Matt W201 "{handle} is {} a site administrator",
Matt W202 if grant { "now" } else { "no longer" }
Matt W203 );
Matt W204 Ok(())
Matt W205}
Matt W206
Matt W207async fn invite(db: &df_db::PgPool, email: &str) -> Result<()> {
Matt W208 sqlx::query(
Matt W209 "INSERT INTO invitations (id, email) VALUES ($1, $2)
Matt W210 ON CONFLICT (email) DO NOTHING",
Matt W211 )
Matt W212 .bind(new_id())
Matt W213 .bind(email)
Matt W214 .execute(db)
Matt W215 .await?;
Matt W216 println!("{email} may now sign in.");
Matt W217 Ok(())
Matt W218}

218 lines · Rust