| 1 | //! `dogfood-admin` — operational CLI. | |
| 2 | //! | |
| 3 | //! Spec §4 requires `dogfood-admin reindex --repo <id>`, safe to run against a | |
| 4 | //! live repository, "because you will need it every time indexing logic | |
| 5 | //! changes". Spec §10 adds `reindex --all` as the recovery path when the | |
| 6 | //! database and the repository volume drift apart after a restore. | |
| 7 | ||
| 8 | use anyhow::{Context, Result}; | |
| 9 | use clap::{Parser, Subcommand}; | |
| 10 | use df_db::ids::new_id; | |
| 11 | use uuid::Uuid; | |
| 12 | ||
| 13 | #[derive(Parser)] | |
| 14 | #[command(name = "dogfood-admin", about = "Dogfood operational commands")] | |
| 15 | struct Cli { | |
| 16 | #[arg(long, env = "DATABASE_URL")] | |
| 17 | database_url: String, | |
| 18 | ||
| 19 | #[command(subcommand)] | |
| 20 | command: Command, | |
| 21 | } | |
| 22 | ||
| 23 | #[derive(Subcommand)] | |
| 24 | enum Command { | |
| 25 | /// Re-run indexing. Safe against a live repository. | |
| 26 | Reindex { | |
| 27 | /// Repository UUID. Omit with --all. | |
| 28 | #[arg(long)] | |
| 29 | repo: Option<Uuid>, | |
| 30 | /// Reindex every repository. | |
| 31 | #[arg(long)] | |
| 32 | all: bool, | |
| 33 | }, | |
| 34 | /// Manage personal access tokens. | |
| 35 | Token { | |
| 36 | #[command(subcommand)] | |
| 37 | command: TokenCommand, | |
| 38 | }, | |
| 39 | /// Manage users. | |
| 40 | User { | |
| 41 | #[command(subcommand)] | |
| 42 | command: UserCommand, | |
| 43 | }, | |
| 44 | /// Invite an email address to a closed instance. | |
| 45 | Invite { | |
| 46 | #[arg(long)] | |
| 47 | email: String, | |
| 48 | }, | |
| 49 | } | |
| 50 | ||
| 51 | #[derive(Subcommand)] | |
| 52 | enum TokenCommand { | |
| 53 | /// Mint a token. The plaintext is printed once and never stored. | |
| 54 | Create { | |
| 55 | #[arg(long)] | |
| 56 | handle: String, | |
| 57 | #[arg(long, default_value = "admin-cli")] | |
| 58 | name: String, | |
| 59 | }, | |
| 60 | List { | |
| 61 | #[arg(long)] | |
| 62 | handle: String, | |
| 63 | }, | |
| 64 | } | |
| 65 | ||
| 66 | #[derive(Subcommand)] | |
| 67 | enum UserCommand { | |
| 68 | List, | |
| 69 | /// Grant or revoke site admin. | |
| 70 | Admin { | |
| 71 | #[arg(long)] | |
| 72 | handle: String, | |
| 73 | #[arg(long)] | |
| 74 | revoke: bool, | |
| 75 | }, | |
| 76 | } | |
| 77 | ||
| 78 | #[tokio::main] | |
| 79 | async fn main() -> Result<()> { | |
| 80 | let _ = dotenvy::dotenv(); | |
| 81 | let cli = Cli::parse(); | |
| 82 | ||
| 83 | let db = df_db::connect(&cli.database_url, 4) | |
| 84 | .await | |
| 85 | .context("connecting to the database")?; | |
| 86 | ||
| 87 | match cli.command { | |
| 88 | Command::Reindex { repo, all } => reindex(&db, repo, all).await, | |
| 89 | Command::Token { command } => match command { | |
| 90 | TokenCommand::Create { handle, name } => create_token(&db, &handle, &name).await, | |
| 91 | TokenCommand::List { handle } => list_tokens(&db, &handle).await, | |
| 92 | }, | |
| 93 | Command::User { command } => match command { | |
| 94 | UserCommand::List => list_users(&db).await, | |
| 95 | UserCommand::Admin { handle, revoke } => set_admin(&db, &handle, !revoke).await, | |
| 96 | }, | |
| 97 | Command::Invite { email } => invite(&db, &email).await, | |
| 98 | } | |
| 99 | } | |
| 100 | ||
| 101 | /// Enqueue indexing rather than doing it inline. | |
| 102 | /// | |
| 103 | /// The worker owns indexing, so routing through the queue means the CLI and a | |
| 104 | /// push take exactly the same code path — there is no second implementation to | |
| 105 | /// drift. | |
| 106 | async fn reindex(db: &df_db::PgPool, repo: Option<Uuid>, all: bool) -> Result<()> { | |
| 107 | let repos: Vec<(Uuid,)> = if all { | |
| 108 | sqlx::query_as("SELECT id FROM repos ORDER BY created_at") | |
| 109 | .fetch_all(db) | |
| 110 | .await? | |
| 111 | } else { | |
| 112 | let id = repo.context("pass --repo <uuid> or --all")?; | |
| 113 | sqlx::query_as("SELECT id FROM repos WHERE id = $1") | |
| 114 | .bind(id) | |
| 115 | .fetch_all(db) | |
| 116 | .await? | |
| 117 | }; | |
| 118 | ||
| 119 | if repos.is_empty() { | |
| 120 | println!("no matching repositories"); | |
| 121 | return Ok(()); | |
| 122 | } | |
| 123 | ||
| 124 | for (id,) in &repos { | |
| 125 | sqlx::query("INSERT INTO jobs (id, kind, payload) VALUES ($1, 'index_push', $2)") | |
| 126 | .bind(new_id()) | |
| 127 | .bind(serde_json::json!({ "repo_id": id })) | |
| 128 | .execute(db) | |
| 129 | .await?; | |
| 130 | println!("queued reindex for {id}"); | |
| 131 | } | |
| 132 | ||
| 133 | println!("\n{} job(s) queued; the worker will pick them up.", repos.len()); | |
| 134 | Ok(()) | |
| 135 | } | |
| 136 | ||
| 137 | async fn create_token(db: &df_db::PgPool, handle: &str, name: &str) -> Result<()> { | |
| 138 | let (user_id,): (Uuid,) = sqlx::query_as("SELECT id FROM users WHERE handle = $1") | |
| 139 | .bind(handle) | |
| 140 | .fetch_optional(db) | |
| 141 | .await? | |
| 142 | .with_context(|| format!("no user with handle {handle}"))?; | |
| 143 | ||
| 144 | let token = df_auth::tokens::create(db, user_id, name, &[], None).await?; | |
| 145 | ||
| 146 | println!("Token created for {handle}."); | |
| 147 | println!("\n {}\n", token.plaintext); | |
| 148 | println!("This is shown once — only an Argon2id hash is stored."); | |
| 149 | println!("Use it as the password for git over HTTPS, with {handle} as the username."); | |
| 150 | Ok(()) | |
| 151 | } | |
| 152 | ||
| 153 | async fn list_tokens(db: &df_db::PgPool, handle: &str) -> Result<()> { | |
| 154 | let rows: Vec<(String, String, Option<chrono::DateTime<chrono::Utc>>)> = sqlx::query_as( | |
| 155 | "SELECT t.name, t.prefix, t.last_used_at | |
| 156 | FROM access_tokens t JOIN users u ON u.id = t.user_id | |
| 157 | WHERE u.handle = $1 ORDER BY t.created_at DESC", | |
| 158 | ) | |
| 159 | .bind(handle) | |
| 160 | .fetch_all(db) | |
| 161 | .await?; | |
| 162 | ||
| 163 | if rows.is_empty() { | |
| 164 | println!("no tokens for {handle}"); | |
| 165 | return Ok(()); | |
| 166 | } | |
| 167 | for (name, prefix, used) in rows { | |
| 168 | println!( | |
| 169 | "{prefix}… {name:<24} last used: {}", | |
| 170 | used.map(|t| t.to_rfc3339()).unwrap_or_else(|| "never".into()) | |
| 171 | ); | |
| 172 | } | |
| 173 | Ok(()) | |
| 174 | } | |
| 175 | ||
| 176 | async fn list_users(db: &df_db::PgPool) -> Result<()> { | |
| 177 | let rows: Vec<(String, Option<String>, bool)> = | |
| 178 | sqlx::query_as("SELECT handle::text, email::text, is_admin FROM users ORDER BY created_at") | |
| 179 | .fetch_all(db) | |
| 180 | .await?; | |
| 181 | for (handle, email, admin) in rows { | |
| 182 | println!( | |
| 183 | "{handle:<20} {:<32} {}", | |
| 184 | email.unwrap_or_else(|| "(no email)".into()), | |
| 185 | if admin { "admin" } else { "" } | |
| 186 | ); | |
| 187 | } | |
| 188 | Ok(()) | |
| 189 | } | |
| 190 | ||
| 191 | async fn set_admin(db: &df_db::PgPool, handle: &str, grant: bool) -> Result<()> { | |
| 192 | let r = sqlx::query("UPDATE users SET is_admin = $2 WHERE handle = $1") | |
| 193 | .bind(handle) | |
| 194 | .bind(grant) | |
| 195 | .execute(db) | |
| 196 | .await?; | |
| 197 | if r.rows_affected() == 0 { | |
| 198 | anyhow::bail!("no user with handle {handle}"); | |
| 199 | } | |
| 200 | println!( | |
| 201 | "{handle} is {} a site administrator", | |
| 202 | if grant { "now" } else { "no longer" } | |
| 203 | ); | |
| 204 | Ok(()) | |
| 205 | } | |
| 206 | ||
| 207 | async fn invite(db: &df_db::PgPool, email: &str) -> Result<()> { | |
| 208 | sqlx::query( | |
| 209 | "INSERT INTO invitations (id, email) VALUES ($1, $2) | |
| 210 | ON CONFLICT (email) DO NOTHING", | |
| 211 | ) | |
| 212 | .bind(new_id()) | |
| 213 | .bind(email) | |
| 214 | .execute(db) | |
| 215 | .await?; | |
| 216 | println!("{email} may now sign in."); | |
| 217 | Ok(()) | |
| 218 | } |
218 lines · Rust