OXYGENHUB
# dc71378db1dc oxygenhub / crates / hub / src / main.rs
14.0 KiB · Rust · last changed copy-db --replace: empty a rehearsal's or a fresh hub's rows first 21 days ago source blame history raw
1
//! `oxygenhub`: `init`, `serve`, `admin`, `check` (hub spec §17.3).
2
3
use clap::{Parser, Subcommand};
4
use hub_api::Visibility;
5
use oxygenhub::config::Config;
6
use oxygenhub::repos::RepoName;
7
use oxygenhub::{db, keys};
8
use std::path::PathBuf;
9
10
#[derive(Parser)]
11
#[command(name = "oxygenhub", version, about = "A forge for Oxygen repositories")]
12
struct Cli {
13
    /// The data directory (hub spec §3.2)
14
    #[arg(long, global = true, env = "OXYHUB_DATA", default_value = "data")]
15
    data: PathBuf,
16
    #[command(subcommand)]
17
    cmd: Cmd,
18
}
19
20
#[derive(Subcommand)]
21
enum Cmd {
22
    /// Create the data directory, keys, database and a default hub.toml
23
    Init {
24
        /// What links and clone URLs start with
25
        #[arg(long)]
26
        base_url: Option<String>,
27
        /// Generate a self-signed TLS certificate (developer edition)
28
        #[arg(long)]
29
        self_signed: bool,
30
    },
31
    /// Run the hub
32
    Serve,
33
    /// Offline administration
34
    Admin {
35
        #[command(subcommand)]
36
        cmd: AdminCmd,
37
    },
38
    /// Verify every repository's objects and signatures
39
    Check,
40
    /// Garbage-collect every repository now, as the nightly job does
41
    Gc,
42
}
43
44
#[derive(Subcommand)]
45
enum AdminCmd {
46
    /// Repositories
47
    Repo {
48
        #[command(subcommand)]
49
        cmd: RepoCmd,
50
    },
51
    /// Accounts
52
    User {
53
        #[command(subcommand)]
54
        cmd: UserCmd,
55
    },
56
    /// Backups (docs/backups.md)
57
    Backup {
58
        #[command(subcommand)]
59
        cmd: BackupCmd,
60
    },
61
    /// Copy a SQLite hub's records into this hub's empty PostgreSQL
62
    /// database, to move a hub (docs/postgres.md)
63
    #[cfg(feature = "postgres")]
64
    CopyDb {
65
        /// The old hub's `hub.db`, from a stopped hub (or a copy of one)
66
        #[arg(long)]
67
        from: PathBuf,
68
        /// Empty this database's tables first (a fresh hub's, a rehearsal's)
69
        #[arg(long)]
70
        replace: bool,
71
    },
72
}
73
74
#[derive(Subcommand)]
75
enum BackupCmd {
76
    /// Back up a user's or organisation's repositories now, as its settings say
77
    Run { owner: String },
78
    /// Restore every repository of a whole run from a backup directory
79
    /// (the one holding LATEST, runs/ and blobs/) into this hub
80
    Restore {
81
        #[arg(long)]
82
        from: std::path::PathBuf,
83
        /// The run (runs/<RUN>); the latest whole one when not given
84
        #[arg(long)]
85
        run: Option<String>,
86
    },
87
}
88
89
#[derive(Subcommand)]
90
enum UserCmd {
91
    /// Create an account with a generated password, printed once
92
    Create {
93
        username: String,
94
        #[arg(long, default_value = "")]
95
        email: String,
96
        /// Make it a site administrator
97
        #[arg(long)]
98
        admin: bool,
99
    },
100
    /// Reset an account's password to a new generated one, printed once
101
    ResetPassword { username: String },
102
    /// List accounts
103
    List,
104
}
105
106
#[derive(Subcommand)]
107
enum RepoCmd {
108
    /// Create an empty repository OWNER/NAME
109
    Create {
110
        slug: String,
111
        #[arg(long, default_value = "")]
112
        description: String,
113
        #[arg(long, default_value = "public")]
114
        visibility: String,
115
        /// Bind to this repository id now (32 hex characters, from `.oxy/id`);
116
        /// otherwise the first push binds it
117
        #[arg(long)]
118
        id: Option<String>,
119
    },
120
    /// List repositories
121
    List,
122
    /// Move a repository to the trash
123
    Delete { slug: String },
124
    /// Change the description, default bookmark or visibility
125
    Set {
126
        slug: String,
127
        #[arg(long)]
128
        description: Option<String>,
129
        #[arg(long)]
130
        default_bookmark: Option<String>,
131
        #[arg(long)]
132
        visibility: Option<String>,
133
    },
134
}
135
136
#[tokio::main]
137
async fn main() -> anyhow::Result<()> {
138
    tracing_subscriber::fmt()
139
        .with_env_filter(tracing_subscriber::EnvFilter::from_default_env().add_directive("oxygenhub=info".parse()?))
140
        .init();
141
    let cli = Cli::parse();
142
    match cli.cmd {
143
        Cmd::Init { base_url, self_signed } => init(&cli.data, base_url, self_signed).await,
144
        Cmd::Serve => {
145
            let config = Config::load(&cli.data)?;
146
            let app = oxygenhub::build(config).await?;
147
            oxygenhub::serve(app).await
148
        }
149
        Cmd::Admin { cmd: AdminCmd::Repo { cmd } } => {
150
            let config = Config::load(&cli.data)?;
151
            let app = oxygenhub::build(config).await?;
152
            repo_admin(&app, cmd).await
153
        }
154
        Cmd::Admin { cmd: AdminCmd::Backup { cmd } } => {
155
            let config = Config::load(&cli.data)?;
156
            let app = oxygenhub::build(config).await?;
157
            match cmd {
158
                BackupCmd::Run { owner } => {
159
                    let r = oxygenhub::backups::run(&app, &owner, "now").await?;
160
                    println!(
161
                        "{} {}: {} repositories, {} of {} files sent ({} KiB){}",
162
                        r.status,
163
                        r.at,
164
                        r.repos,
165
                        r.sent,
166
                        r.files,
167
                        r.bytes / 1024,
168
                        if r.detail.is_empty() { String::new() } else { format!(": {}", r.detail) }
169
                    );
170
                    if r.status == "failed" {
171
                        std::process::exit(1);
172
                    }
173
                    Ok(())
174
                }
175
                BackupCmd::Restore { from, run } => {
176
                    let dest: std::sync::Arc<dyn oxygenhub::backups::Destination> =
177
                        std::sync::Arc::new(oxygenhub::backups::Directory { root: from });
178
                    for d in oxygenhub::backups::restore_into_hub(&app, dest, run).await? {
179
                        println!("restored {d}");
180
                    }
181
                    Ok(())
182
                }
183
            }
184
        }
185
        #[cfg(feature = "postgres")]
186
        Cmd::Admin { cmd: AdminCmd::CopyDb { from, replace } } => {
187
            let config = Config::load(&cli.data)?;
188
            let pool = db::open(&config).await?;
189
            let copied = db::copy::from_sqlite(&pool, &from, replace).await?;
190
            for (table, n) in &copied {
191
                println!("{n:>9}  {table}");
192
            }
193
            println!("copied {} rows in {} tables", copied.iter().map(|(_, n)| n).sum::<i64>(), copied.len());
194
            Ok(())
195
        }
196
        Cmd::Admin { cmd: AdminCmd::User { cmd } } => {
197
            let config = Config::load(&cli.data)?;
198
            let app = oxygenhub::build(config).await?;
199
            user_admin(&app, cmd).await
200
        }
201
        Cmd::Check => {
202
            let config = Config::load(&cli.data)?;
203
            let app = oxygenhub::build(config).await?;
204
            check(&app).await
205
        }
206
        Cmd::Gc => {
207
            let config = Config::load(&cli.data)?;
208
            let app = oxygenhub::build(config).await?;
209
            let done = oxygenhub::maintenance::gc_all(&app).await;
210
            for d in &done {
211
                println!("{d}");
212
            }
213
            if done.iter().any(|d| d.failed) {
214
                anyhow::bail!("a repository could not be garbage collected");
215
            }
216
            Ok(())
217
        }
218
    }
219
}
220
221
async fn init(data: &PathBuf, base_url: Option<String>, self_signed: bool) -> anyhow::Result<()> {
222
    std::fs::create_dir_all(data)?;
223
    let toml_path = data.join("hub.toml");
224
    if !toml_path.exists() {
225
        let base = base_url.clone().unwrap_or_else(|| "http://127.0.0.1:7300".into());
226
        let mut text = String::from("# OxygenHub configuration (docs/spec.md section 17.1)\n\n[site]\nname = \"OxygenHub\"\n\n[server]\nbind = \"127.0.0.1:7300\"\n");
227
        if self_signed {
228
            text.push_str("tls_bind = \"0.0.0.0:7343\"\n");
229
        }
230
        text.push_str(&format!("base_url = \"{base}\"\n\n[tls]\nself_signed = {self_signed}\n\n[auth]\n# Bearer tokens allowed to push until accounts exist.\npush_tokens = []\n"));
231
        std::fs::write(&toml_path, text)?;
232
        println!("wrote {}", toml_path.display());
233
    }
234
    let mut config = Config::load(data)?;
235
    if let Some(b) = base_url {
236
        config.server.base_url = b;
237
    }
238
    let app = oxygenhub::build(config).await?;
239
    println!("hub key {}", app.hub_key.key.to_hex());
240
    if (self_signed || app.config.tls.self_signed)
241
        && keys::ensure_self_signed(&app.config.tls_cert(), &app.config.tls_key(), app.config.base_url())?
242
    {
243
        println!("wrote self-signed certificate {}", app.config.tls_cert().display());
244
    }
245
    println!("data directory {} is ready", data.display());
246
    Ok(())
247
}
248
249
async fn repo_admin(app: &oxygenhub::Shared, cmd: RepoCmd) -> anyhow::Result<()> {
250
    match cmd {
251
        RepoCmd::Create { slug, description, visibility, id } => {
252
            let name = RepoName::parse(&slug).ok_or_else(|| anyhow::anyhow!("{slug:?} is not a valid owner/name"))?;
253
            let visibility = Visibility::parse(&visibility).ok_or_else(|| anyhow::anyhow!("visibility is public, internal or private"))?;
254
            let id = match id {
255
                Some(h) => {
256
                    let bytes = hex::decode(h.trim()).ok().and_then(|v| <[u8; 16]>::try_from(v).ok());
257
                    Some(bytes.ok_or_else(|| anyhow::anyhow!("--id takes 32 hex characters"))?)
258
                }
259
                None => None,
260
            };
261
            if db::repos::get(&app.db, &name.owner, &name.name).await?.is_some() {
262
                anyhow::bail!("{name} already exists");
263
            }
264
            app.repos.create(&name, id)?;
265
            db::repos::create(&app.db, &name.owner, &name.name, &description, visibility).await?;
266
            println!("created {name}: oxy clone {}/{name}", app.config.base_url());
267
        }
268
        RepoCmd::List => {
269
            for r in db::repos::list(&app.db).await? {
270
                println!("{:<40} {:<8} {}", r.slug(), r.visibility, r.description);
271
            }
272
        }
273
        RepoCmd::Delete { slug } => {
274
            let name = RepoName::parse(&slug).ok_or_else(|| anyhow::anyhow!("{slug:?} is not a valid owner/name"))?;
275
            let row = db::repos::get(&app.db, &name.owner, &name.name).await?.ok_or_else(|| anyhow::anyhow!("{name} does not exist"))?;
276
            let dest = app.repos.trash(&name)?;
277
            db::repos::delete(&app.db, row.id).await?;
278
            println!("moved {name} to {}", dest.display());
279
        }
280
        RepoCmd::Set { slug, description, default_bookmark, visibility } => {
281
            let name = RepoName::parse(&slug).ok_or_else(|| anyhow::anyhow!("{slug:?} is not a valid owner/name"))?;
282
            let row = db::repos::get(&app.db, &name.owner, &name.name).await?.ok_or_else(|| anyhow::anyhow!("{name} does not exist"))?;
283
            if let Some(d) = description {
284
                db::repos::set_description(&app.db, row.id, &d).await?;
285
            }
286
            if let Some(b) = default_bookmark {
287
                db::repos::set_default_bookmark(&app.db, row.id, &b).await?;
288
            }
289
            if let Some(v) = visibility {
290
                let v = Visibility::parse(&v).ok_or_else(|| anyhow::anyhow!("visibility is public, internal or private"))?;
291
                db::repos::set_visibility(&app.db, row.id, v).await?;
292
            }
293
            println!("updated {name}");
294
        }
295
    }
296
    Ok(())
297
}
298
299
async fn user_admin(app: &oxygenhub::Shared, cmd: UserCmd) -> anyhow::Result<()> {
300
    use oxygenhub::auth;
301
    use oxygenhub::db::accounts;
302
    match cmd {
303
        UserCmd::Create { username, email, admin } => {
304
            if !oxygenhub::repos::names::valid_owner(&username) {
305
                anyhow::bail!("{username:?} is not a valid username");
306
            }
307
            if accounts::account_by_name(&app.db, &username).await?.is_some() || accounts::org_by_name(&app.db, &username).await?.is_some()
308
            {
309
                anyhow::bail!("{username} is taken");
310
            }
311
            let password = auth::random_hex(12);
312
            let hash = auth::hash_password(&password)?;
313
            accounts::create_account(&app.db, &username, &email, &hash, &auth::random_hex(16), admin).await?;
314
            accounts::audit(&app.db, None, "admin.user.create", &username, if admin { "administrator" } else { "" }).await?;
315
            println!("created {username}{}; one-time password: {password}", if admin { " (administrator)" } else { "" });
316
            println!("sign in at {}/login and change it under Settings", app.config.base_url());
317
        }
318
        UserCmd::ResetPassword { username } => {
319
            let account = accounts::account_by_name(&app.db, &username).await?.ok_or_else(|| anyhow::anyhow!("no such account"))?;
320
            let password = auth::random_hex(12);
321
            let hash = auth::hash_password(&password)?;
322
            accounts::set_password(&app.db, account.id, &hash, &auth::random_hex(16)).await?;
323
            accounts::audit(&app.db, None, "admin.user.reset_password", &username, "").await?;
324
            println!("new one-time password for {username}: {password}");
325
        }
326
        UserCmd::List => {
327
            for a in accounts::accounts(&app.db).await? {
328
                println!(
329
                    "{:<24} {:<32} {}{}{}",
330
                    a.username,
331
                    a.email,
332
                    if a.is_admin { "admin " } else { "" },
333
                    if a.banned { "banned " } else { "" },
334
                    if a.disabled { "disabled" } else { "" }
335
                );
336
            }
337
        }
338
    }
339
    Ok(())
340
}
341
342
async fn check(app: &oxygenhub::Shared) -> anyhow::Result<()> {
343
    let mut problems = 0;
344
    for r in db::repos::list(&app.db).await? {
345
        let name = RepoName::new(&r.owner, &r.name).unwrap();
346
        let report = app.repos.read(&name, |r, cache| oxygenhub::repos::view::verification(r, cache)).await;
347
        match report {
348
            Ok(v) => {
349
                println!(
350
                    "{:<40} {} changes, {} signed, {} sealed, {} unsigned, {} invalid",
351
                    name,
352
                    v.total,
353
                    v.signed,
354
                    v.sealed,
355
                    v.unsigned,
356
                    v.invalid.len()
357
                );
358
                problems += v.invalid.len();
359
            }
360
            Err(e) => {
361
                println!("{name:<40} ERROR {e}");
362
                problems += 1;
363
            }
364
        }
365
    }
366
    if problems > 0 {
367
        anyhow::bail!("{problems} problem(s)");
368
    }
369
    Ok(())
370
}