OXYGENHUB

Coverage / crates/hub/src/main.rs 0.0%

0 of 217 lines run, 0 of 54 functions · run #498 on main · run missed

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
        /// What this process does: web (pages, API, wire protocol, runner
34
        /// API), worker (the background tasks), or both, comma-separated.
35
        /// Overrides [server] roles; every role when neither is given
36
        /// (docs/roles.md)
37
        #[arg(long = "role", value_name = "ROLE", value_delimiter = ',')]
38
        roles: Vec<oxygenhub::config::Role>,
39
    },
40
    /// Offline administration
41
    Admin {
42
        #[command(subcommand)]
43
        cmd: AdminCmd,
44
    },
45
    /// Verify every repository's objects and signatures
46
    Check,
47
    /// Garbage-collect every repository now, as the nightly job does
48
    Gc,
49
}
50
51
#[derive(Subcommand)]
52
enum AdminCmd {
53
    /// Repositories
54
    Repo {
55
        #[command(subcommand)]
56
        cmd: RepoCmd,
57
    },
58
    /// Accounts
59
    User {
60
        #[command(subcommand)]
61
        cmd: UserCmd,
62
    },
63
    /// Backups (docs/backups.md)
64
    Backup {
65
        #[command(subcommand)]
66
        cmd: BackupCmd,
67
    },
68
    /// Copy a SQLite hub's records into this hub's empty PostgreSQL
69
    /// database, to move a hub (docs/postgres.md)
70
    #[cfg(feature = "postgres")]
71
    CopyDb {
72
        /// The old hub's `hub.db`, from a stopped hub (or a copy of one)
73
        #[arg(long)]
74
        from: PathBuf,
75
        /// Empty this database's tables first (a fresh hub's, a rehearsal's)
76
        #[arg(long)]
77
        replace: bool,
78
    },
79
}
80
81
#[derive(Subcommand)]
82
enum BackupCmd {
83
    /// Back up a user's or organisation's repositories now, as its settings say
84
    Run { owner: String },
85
    /// Restore every repository of a whole run from a backup directory
86
    /// (the one holding LATEST, runs/ and blobs/) into this hub
87
    Restore {
88
        #[arg(long)]
89
        from: std::path::PathBuf,
90
        /// The run (runs/<RUN>); the latest whole one when not given
91
        #[arg(long)]
92
        run: Option<String>,
93
    },
94
}
95
96
#[derive(Subcommand)]
97
enum UserCmd {
98
    /// Create an account with a generated password, printed once
99
    Create {
100
        username: String,
101
        #[arg(long, default_value = "")]
102
        email: String,
103
        /// Make it a site administrator
104
        #[arg(long)]
105
        admin: bool,
106
    },
107
    /// Reset an account's password to a new generated one, printed once
108
    ResetPassword { username: String },
109
    /// List accounts
110
    List,
111
}
112
113
#[derive(Subcommand)]
114
enum RepoCmd {
115
    /// Create an empty repository OWNER/NAME
116
    Create {
117
        slug: String,
118
        #[arg(long, default_value = "")]
119
        description: String,
120
        #[arg(long, default_value = "public")]
121
        visibility: String,
122
        /// Bind to this repository id now (32 hex characters, from `.oxy/id`);
123
        /// otherwise the first push binds it
124
        #[arg(long)]
125
        id: Option<String>,
126
    },
127
    /// List repositories
128
    List,
129
    /// Move a repository to the trash
130
    Delete { slug: String },
131
    /// Change the description, default bookmark or visibility
132
    Set {
133
        slug: String,
134
        #[arg(long)]
135
        description: Option<String>,
136
        #[arg(long)]
137
        default_bookmark: Option<String>,
138
        #[arg(long)]
139
        visibility: Option<String>,
140
    },
141
}
142
1430×
fn main() -> anyhow::Result<()> {
1440×
    tune_allocator();
1450×
    let rt = tokio::runtime::Builder::new_multi_thread().enable_all().build()?;
1460×
    rt.spawn(return_freed_memory());
1470×
    let out = rt.block_on(run());
1480×
    oxygenhub::shutdown::finish(rt);
1490×
    out
1500×
}
151
152
/// glibc's allocator gives each thread that allocates an arena of its own
153
/// and keeps what is freed in it: under the hub's worker threads, loaded
154
/// states that the cache had let go still held their pages, and the hub
155
/// grew to 12 GB with its state cache capped at 3 GiB (oxygenhub#117).
156
/// Two arenas, as oxy found for its own threads; MALLOC_ARENA_MAX in the
157
/// environment still has the last word. Called before any thread starts.
1580×
fn tune_allocator() {
159
    #[cfg(all(target_os = "linux", target_env = "gnu"))]
1600×
    if std::env::var_os("MALLOC_ARENA_MAX").is_none() {
161
        // SAFETY: mallopt is called once, before any other thread exists.
1620×
        unsafe {
1630×
            libc::mallopt(libc::M_ARENA_MAX, 2);
1640×
        }
1650×
    }
1660×
}
167
168
/// Hand freed memory back to the system every minute, so the hub's size
169
/// follows what it holds rather than its peak (oxygenhub#117).
1700×
async fn return_freed_memory() {
171
    #[cfg(all(target_os = "linux", target_env = "gnu"))]
172
    loop {
1730×
        tokio::time::sleep(std::time::Duration::from_secs(60)).await;
174
        // SAFETY: malloc_trim only releases free pages.
1750×
        let _ = tokio::task::spawn_blocking(|| unsafe { libc::malloc_trim(0) }).await;
176
    }
177
}
178
1790×
async fn run() -> anyhow::Result<()> {
1800×
    tracing_subscriber::fmt()
1810×
        .with_env_filter(tracing_subscriber::EnvFilter::from_default_env().add_directive("oxygenhub=info".parse()?))
1820×
        .init();
1830×
    let cli = Cli::parse();
1840×
    match cli.cmd {
1850×
        Cmd::Init { base_url, self_signed } => init(&cli.data, base_url, self_signed).await,
1860×
        Cmd::Serve { roles } => {
1870×
            let mut config = Config::load(&cli.data)?;
1880×
            if !roles.is_empty() {
1890×
                config.server.roles = roles.iter().map(|r| r.name().to_string()).collect();
1900×
            }
1910×
            let app = oxygenhub::build(config).await?;
1920×
            oxygenhub::serve(app).await
193
        }
1940×
        Cmd::Admin { cmd: AdminCmd::Repo { cmd } } => {
1950×
            let config = Config::load(&cli.data)?;
1960×
            let app = oxygenhub::build(config).await?;
1970×
            repo_admin(&app, cmd).await
198
        }
1990×
        Cmd::Admin { cmd: AdminCmd::Backup { cmd } } => {
2000×
            let config = Config::load(&cli.data)?;
2010×
            let app = oxygenhub::build(config).await?;
2020×
            match cmd {
2030×
                BackupCmd::Run { owner } => {
2040×
                    let r = oxygenhub::backups::run(&app, &owner, "now").await?;
2050×
                    println!(
206
                        "{} {}: {} repositories, {} of {} files sent ({} KiB){}",
207
                        r.status,
208
                        r.at,
209
                        r.repos,
210
                        r.sent,
211
                        r.files,
2120×
                        r.bytes / 1024,
2130×
                        if r.detail.is_empty() { String::new() } else { format!(": {}", r.detail) }
214
                    );
2150×
                    if r.status == "failed" {
2160×
                        std::process::exit(1);
2170×
                    }
2180×
                    Ok(())
219
                }
2200×
                BackupCmd::Restore { from, run } => {
2210×
                    let dest: std::sync::Arc<dyn oxygenhub::backups::Destination> =
2220×
                        std::sync::Arc::new(oxygenhub::backups::Directory { root: from });
2230×
                    for d in oxygenhub::backups::restore_into_hub(&app, dest, run).await? {
2240×
                        println!("restored {d}");
2250×
                    }
2260×
                    Ok(())
227
                }
228
            }
229
        }
230
        #[cfg(feature = "postgres")]
231
        Cmd::Admin { cmd: AdminCmd::CopyDb { from, replace } } => {
232
            let config = Config::load(&cli.data)?;
233
            let pool = db::open(&config).await?;
234
            let copied = db::copy::from_sqlite(&pool, &from, replace).await?;
235
            for (table, n) in &copied {
236
                println!("{n:>9}  {table}");
237
            }
238
            println!("copied {} rows in {} tables", copied.iter().map(|(_, n)| n).sum::<i64>(), copied.len());
239
            Ok(())
240
        }
2410×
        Cmd::Admin { cmd: AdminCmd::User { cmd } } => {
2420×
            let config = Config::load(&cli.data)?;
2430×
            let app = oxygenhub::build(config).await?;
2440×
            user_admin(&app, cmd).await
245
        }
246
        Cmd::Check => {
2470×
            let config = Config::load(&cli.data)?;
2480×
            let app = oxygenhub::build(config).await?;
2490×
            check(&app).await
250
        }
251
        Cmd::Gc => {
2520×
            let config = Config::load(&cli.data)?;
2530×
            let app = oxygenhub::build(config).await?;
2540×
            let done = oxygenhub::maintenance::gc_all(&app).await;
2550×
            for d in &done {
2560×
                println!("{d}");
2570×
            }
2580×
            if done.iter().any(|d| d.failed) {
2590×
                anyhow::bail!("a repository could not be garbage collected");
2600×
            }
2610×
            Ok(())
262
        }
263
    }
2640×
}
265
2660×
async fn init(data: &PathBuf, base_url: Option<String>, self_signed: bool) -> anyhow::Result<()> {
2670×
    std::fs::create_dir_all(data)?;
2680×
    let toml_path = data.join("hub.toml");
2690×
    if !toml_path.exists() {
2700×
        let base = base_url.clone().unwrap_or_else(|| "http://127.0.0.1:7300".into());
2710×
        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");
2720×
        if self_signed {
2730×
            text.push_str("tls_bind = \"0.0.0.0:7343\"\n");
2740×
        }
2750×
        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"));
2760×
        std::fs::write(&toml_path, text)?;
2770×
        println!("wrote {}", toml_path.display());
2780×
    }
2790×
    let mut config = Config::load(data)?;
2800×
    if let Some(b) = base_url {
2810×
        config.server.base_url = b;
2820×
    }
2830×
    let app = oxygenhub::build(config).await?;
2840×
    println!("hub key {}", app.hub_key.key.to_hex());
2850×
    if (self_signed || app.config.tls.self_signed)
2860×
        && keys::ensure_self_signed(&app.config.tls_cert(), &app.config.tls_key(), app.config.base_url())?
2870×
    {
2880×
        println!("wrote self-signed certificate {}", app.config.tls_cert().display());
2890×
    }
2900×
    println!("data directory {} is ready", data.display());
2910×
    Ok(())
2920×
}
293
2940×
async fn repo_admin(app: &oxygenhub::Shared, cmd: RepoCmd) -> anyhow::Result<()> {
2950×
    match cmd {
2960×
        RepoCmd::Create { slug, description, visibility, id } => {
2970×
            let name = RepoName::parse(&slug).ok_or_else(|| anyhow::anyhow!("{slug:?} is not a valid owner/name"))?;
2980×
            let visibility = Visibility::parse(&visibility).ok_or_else(|| anyhow::anyhow!("visibility is public, internal or private"))?;
2990×
            let id = match id {
3000×
                Some(h) => {
3010×
                    let bytes = hex::decode(h.trim()).ok().and_then(|v| <[u8; 16]>::try_from(v).ok());
3020×
                    Some(bytes.ok_or_else(|| anyhow::anyhow!("--id takes 32 hex characters"))?)
303
                }
3040×
                None => None,
305
            };
3060×
            if db::repos::get(&app.db, &name.owner, &name.name).await?.is_some() {
3070×
                anyhow::bail!("{name} already exists");
3080×
            }
3090×
            app.repos.create(&name, id)?;
3100×
            db::repos::create(&app.db, &name.owner, &name.name, &description, visibility).await?;
3110×
            println!("created {name}: oxy clone {}/{name}", app.config.base_url());
312
        }
313
        RepoCmd::List => {
3140×
            for r in db::repos::list_with_drops(&app.db).await? {
3150×
                let what = if r.is_drop() { format!("drop: {}", r.title) } else { r.description.clone() };
3160×
                println!("{:<40} {:<8} {what}", r.slug(), r.visibility);
317
            }
318
        }
3190×
        RepoCmd::Delete { slug } => {
3200×
            let name = RepoName::parse(&slug).ok_or_else(|| anyhow::anyhow!("{slug:?} is not a valid owner/name"))?;
3210×
            let row = db::repos::get(&app.db, &name.owner, &name.name).await?.ok_or_else(|| anyhow::anyhow!("{name} does not exist"))?;
3220×
            let dest = app.repos.trash(&name)?;
3230×
            db::repos::delete(&app.db, row.id).await?;
3240×
            println!("moved {name} to {}", dest.display());
325
        }
3260×
        RepoCmd::Set { slug, description, default_bookmark, visibility } => {
3270×
            let name = RepoName::parse(&slug).ok_or_else(|| anyhow::anyhow!("{slug:?} is not a valid owner/name"))?;
3280×
            let row = db::repos::get(&app.db, &name.owner, &name.name).await?.ok_or_else(|| anyhow::anyhow!("{name} does not exist"))?;
3290×
            if let Some(d) = description {
3300×
                db::repos::set_description(&app.db, row.id, &d).await?;
3310×
            }
3320×
            if let Some(b) = default_bookmark {
3330×
                db::repos::set_default_bookmark(&app.db, row.id, &b).await?;
3340×
            }
3350×
            if let Some(v) = visibility {
3360×
                let v = Visibility::parse(&v).ok_or_else(|| anyhow::anyhow!("visibility is public, internal or private"))?;
3370×
                db::repos::set_visibility(&app.db, row.id, v).await?;
3380×
            }
3390×
            println!("updated {name}");
340
        }
341
    }
3420×
    Ok(())
3430×
}
344
3450×
async fn user_admin(app: &oxygenhub::Shared, cmd: UserCmd) -> anyhow::Result<()> {
346
    use oxygenhub::auth;
347
    use oxygenhub::db::accounts;
3480×
    match cmd {
3490×
        UserCmd::Create { username, email, admin } => {
3500×
            if !oxygenhub::repos::names::valid_owner(&username) {
3510×
                anyhow::bail!("{username:?} is not a valid username");
3520×
            }
3530×
            if accounts::account_by_name(&app.db, &username).await?.is_some() || accounts::org_by_name(&app.db, &username).await?.is_some()
354
            {
3550×
                anyhow::bail!("{username} is taken");
3560×
            }
3570×
            let password = auth::random_hex(12);
3580×
            let hash = auth::hash_password(&password)?;
3590×
            accounts::create_account(&app.db, &username, &email, &hash, &auth::random_hex(16), admin).await?;
3600×
            accounts::audit(&app.db, None, "admin.user.create", &username, if admin { "administrator" } else { "" }).await?;
3610×
            println!("created {username}{}; one-time password: {password}", if admin { " (administrator)" } else { "" });
3620×
            println!("sign in at {}/login and change it under Settings", app.config.base_url());
363
        }
3640×
        UserCmd::ResetPassword { username } => {
3650×
            let account = accounts::account_by_name(&app.db, &username).await?.ok_or_else(|| anyhow::anyhow!("no such account"))?;
3660×
            let password = auth::random_hex(12);
3670×
            let hash = auth::hash_password(&password)?;
3680×
            accounts::set_password(&app.db, account.id, &hash, &auth::random_hex(16)).await?;
3690×
            accounts::audit(&app.db, None, "admin.user.reset_password", &username, "").await?;
3700×
            println!("new one-time password for {username}: {password}");
371
        }
372
        UserCmd::List => {
3730×
            for a in accounts::accounts(&app.db).await? {
3740×
                println!(
375
                    "{:<24} {:<32} {}{}{}",
376
                    a.username,
377
                    a.email,
3780×
                    if a.is_admin { "admin " } else { "" },
3790×
                    if a.banned { "banned " } else { "" },
3800×
                    if a.disabled { "disabled" } else { "" }
381
                );
382
            }
383
        }
384
    }
3850×
    Ok(())
3860×
}
387
3880×
async fn check(app: &oxygenhub::Shared) -> anyhow::Result<()> {
3890×
    let mut problems = 0;
3900×
    for r in db::repos::list_with_drops(&app.db).await? {
3910×
        let name = RepoName::new(&r.owner, &r.name).unwrap();
3920×
        let report = app.repos.read(&name, oxygenhub::repos::view::verification).await;
3930×
        match report {
3940×
            Ok(v) => {
3950×
                println!(
3960×
                    "{:<40} {} changes, {} signed, {} sealed, {} unsigned, {} invalid",
3970×
                    name,
3980×
                    v.total,
3990×
                    v.signed,
4000×
                    v.sealed,
4010×
                    v.unsigned,
4020×
                    v.invalid.len()
4030×
                );
4040×
                problems += v.invalid.len();
4050×
            }
4060×
            Err(e) => {
4070×
                println!("{name:<40} ERROR {e}");
4080×
                problems += 1;
4090×
            }
410
        }
411
    }
4120×
    if problems > 0 {
4130×
        anyhow::bail!("{problems} problem(s)");
4140×
    }
4150×
    Ok(())
4160×
}