Git hosting and a container registry in one Rust binary (axum + Astro)
CLI: install.sh saves its server as the default host, built-in default host, automatic versions per CLI change, daily update notice; ig config default-host
8 files changed, +119 -16
+1-1Cargo.lock
| @@ -1873,7 +1873,7 @@ dependencies = [ | ||
| 1873 | 1873 | |
| 1874 | 1874 | [[package]] |
| 1875 | 1875 | name = "ig" |
| 1876 | -version = "0.1.0" | |
| 1876 | +version = "0.2.0" | |
| 1877 | 1877 | dependencies = [ |
| 1878 | 1878 | "anyhow", |
| 1879 | 1879 | "chrono", |
+7-1Dockerfile
| @@ -25,12 +25,18 @@ COPY backend/ backend/ | ||
| 25 | 25 | COPY cli/ cli/ |
| 26 | 26 | COPY shared/ shared/ |
| 27 | 27 | COPY --from=frontend /src/frontend/dist frontend/dist |
| 28 | +# deploy.sh passes the CLI version (bumped automatically when cli/ or shared/ | |
| 29 | +# change) and the server URL ig defaults to. Empty values fall back to the | |
| 30 | +# crate version and to no built-in server. | |
| 31 | +ARG IG_BUILD_VERSION="" | |
| 32 | +ARG IG_DEFAULT_HOST="" | |
| 28 | 33 | # Cache mounts keep the registry and build artifacts between image builds; |
| 29 | 34 | # the binaries are copied out of the cache within the same step. |
| 30 | 35 | RUN --mount=type=cache,target=/usr/local/cargo/registry \ |
| 31 | 36 | --mount=type=cache,target=/src/target \ |
| 32 | 37 | cargo build --release --locked -p irongit --no-default-features \ |
| 33 | - && cargo build --release --locked -p ig --target x86_64-unknown-linux-musl \ | |
| 38 | + && IG_BUILD_VERSION="$IG_BUILD_VERSION" IG_DEFAULT_HOST="$IG_DEFAULT_HOST" \ | |
| 39 | + cargo build --release --locked -p ig --target x86_64-unknown-linux-musl \ | |
| 34 | 40 | && mkdir -p /out \ |
| 35 | 41 | && cp target/release/irongit /out/irongit \ |
| 36 | 42 | && cp target/x86_64-unknown-linux-musl/release/ig /out/ig |
+3-1backend/src/api/release.rs
| @@ -218,13 +218,15 @@ trap - EXIT INT TERM | ||
| 218 | 218 | ln -sf ig "$DIR/docker-credential-ig" |
| 219 | 219 | |
| 220 | 220 | say "Installed ig $VERSION to $DIR/ig" |
| 221 | +# Remember where ig came from, so plain `ig login` talks to this server. | |
| 222 | +"$DIR/ig" config default-host "$HOST" --if-unset >/dev/null 2>&1 || true | |
| 221 | 223 | case ":$PATH:" in |
| 222 | 224 | *":$DIR:"*) ;; |
| 223 | 225 | *) say "Note: $DIR is not on your PATH. Add it, for example:" |
| 224 | 226 | say " echo 'export PATH=\"$DIR:\$PATH\"' >> ~/.profile" ;; |
| 225 | 227 | esac |
| 226 | 228 | say "" |
| 227 | -say "Next: ig login --host $HOST" | |
| 229 | +say "Next: ig login" | |
| 228 | 230 | "#; |
| 229 | 231 | |
| 230 | 232 | #[cfg(test)] |
+1-1cli/Cargo.toml
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | [package] |
| 2 | 2 | name = "ig" |
| 3 | -version = "0.1.0" | |
| 3 | +version = "0.2.0" | |
| 4 | 4 | edition = "2024" |
| 5 | 5 | |
| 6 | 6 | [dependencies] |
+8-3cli/src/config.rs
| @@ -9,6 +9,10 @@ | ||
| 9 | 9 | |
| 10 | 10 | use std::{collections::BTreeMap, path::PathBuf}; |
| 11 | 11 | |
| 12 | +/// The server a release build was made for (IG_DEFAULT_HOST at build time), | |
| 13 | +/// used when the config names none. install.sh also saves its server. | |
| 14 | +pub const BUILT_IN_HOST: Option<&str> = option_env!("IG_DEFAULT_HOST"); | |
| 15 | + | |
| 12 | 16 | use anyhow::{Context, bail}; |
| 13 | 17 | use serde::{Deserialize, Serialize}; |
| 14 | 18 | |
| @@ -74,9 +78,10 @@ impl Config { | ||
| 74 | 78 | pub fn resolve_host(&self, flag: Option<&str>) -> anyhow::Result<String> { |
| 75 | 79 | match flag.filter(|h| !h.trim().is_empty()) { |
| 76 | 80 | Some(host) => normalize_host(host), |
| 77 | - None => match &self.default_host { | |
| 78 | - Some(host) => Ok(host.clone()), | |
| 79 | - None => bail!("no server configured. Run: ig login --host https://your-irongit-server"), | |
| 81 | + None => match (&self.default_host, BUILT_IN_HOST.filter(|h| !h.is_empty())) { | |
| 82 | + (Some(host), _) => Ok(host.clone()), | |
| 83 | + (None, Some(host)) => normalize_host(host), | |
| 84 | + (None, None) => bail!("no server configured. Run: ig login --host https://your-irongit-server"), | |
| 80 | 85 | }, |
| 81 | 86 | } |
| 82 | 87 | } |
+49-3cli/src/main.rs
| @@ -19,8 +19,8 @@ use crate::cmds::Session; | ||
| 19 | 19 | |
| 20 | 20 | /// Release builds set IG_BUILD_VERSION; otherwise the crate version. |
| 21 | 21 | pub const VERSION: &str = match option_env!("IG_BUILD_VERSION") { |
| 22 | - Some(v) => v, | |
| 23 | - None => env!("CARGO_PKG_VERSION"), | |
| 22 | + Some(v) if !v.is_empty() => v, | |
| 23 | + _ => env!("CARGO_PKG_VERSION"), | |
| 24 | 24 | }; |
| 25 | 25 | |
| 26 | 26 | #[derive(Parser)] |
| @@ -78,6 +78,9 @@ enum Cmd { | ||
| 78 | 78 | #[arg(short = 'd', long = "data")] |
| 79 | 79 | data: Option<String>, |
| 80 | 80 | }, |
| 81 | + /// Show or change ig's settings. | |
| 82 | + #[command(subcommand)] | |
| 83 | + Config(ConfigCmd), | |
| 81 | 84 | /// Replace ig with the newest release from the server. |
| 82 | 85 | Upgrade { |
| 83 | 86 | /// Reinstall even if this version is current. |
| @@ -89,6 +92,18 @@ enum Cmd { | ||
| 89 | 92 | }, |
| 90 | 93 | } |
| 91 | 94 | |
| 95 | +#[derive(Subcommand)] | |
| 96 | +enum ConfigCmd { | |
| 97 | + /// Print the default server, or set it. | |
| 98 | + DefaultHost { | |
| 99 | + /// Server URL, e.g. https://git.example.com | |
| 100 | + url: Option<String>, | |
| 101 | + /// Only set it when no default exists yet (what install.sh uses). | |
| 102 | + #[arg(long)] | |
| 103 | + if_unset: bool, | |
| 104 | + }, | |
| 105 | +} | |
| 106 | + | |
| 92 | 107 | #[derive(Subcommand)] |
| 93 | 108 | enum ImportCmd { |
| 94 | 109 | /// Import a GitHub user's or organization's repositories. |
| @@ -297,7 +312,15 @@ fn main() { | ||
| 297 | 312 | let result = if invoked_as.starts_with("docker-credential-") { |
| 298 | 313 | auth::docker_credential_helper(&args) |
| 299 | 314 | } else { |
| 300 | - run(Cli::parse()) | |
| 315 | + let cli = Cli::parse(); | |
| 316 | + // Not for upgrade itself, and not under git or docker (helpers) or scripts. | |
| 317 | + let notify = !matches!(cli.command, Cmd::Upgrade { .. } | Cmd::Auth(AuthCmd::GitCredential { .. }) | Cmd::Config(_)); | |
| 318 | + let host = cli.host.clone(); | |
| 319 | + let result = run(cli); | |
| 320 | + if notify && result.is_ok() { | |
| 321 | + upgrade::notify_if_outdated(host.as_deref()); | |
| 322 | + } | |
| 323 | + result | |
| 301 | 324 | }; |
| 302 | 325 | if let Err(error) = result { |
| 303 | 326 | eprintln!("ig: {error:#}"); |
| @@ -404,5 +427,28 @@ fn run(cli: Cli) -> anyhow::Result<()> { | ||
| 404 | 427 | } |
| 405 | 428 | Cmd::Api { method, path, data } => cmds::api(&session()?, &method, &path, data.as_deref()), |
| 406 | 429 | Cmd::Upgrade { force, check } => upgrade::upgrade(host, force, check), |
| 430 | + Cmd::Config(ConfigCmd::DefaultHost { url, if_unset }) => { | |
| 431 | + let mut config = config::Config::load()?; | |
| 432 | + match url { | |
| 433 | + None => match config.resolve_host(None) { | |
| 434 | + Ok(host) => println!("{host}"), | |
| 435 | + Err(_) => println!("(none)"), | |
| 436 | + }, | |
| 437 | + Some(url) => { | |
| 438 | + let url = config::normalize_host(&url)?; | |
| 439 | + match &config.default_host { | |
| 440 | + Some(current) if if_unset && *current != url => { | |
| 441 | + println!("default server stays {current} (switch with: ig config default-host {url})"); | |
| 442 | + } | |
| 443 | + _ => { | |
| 444 | + config.default_host = Some(url.clone()); | |
| 445 | + config.save()?; | |
| 446 | + println!("default server: {url}"); | |
| 447 | + } | |
| 448 | + } | |
| 449 | + } | |
| 450 | + } | |
| 451 | + Ok(()) | |
| 452 | + } | |
| 407 | 453 | } |
| 408 | 454 | } |
+41-2cli/src/upgrade.rs
| @@ -1,6 +1,9 @@ | ||
| 1 | 1 | //! `ig upgrade`: replace this executable with the server's newest release. |
| 2 | 2 | |
| 3 | -use std::io::Write; | |
| 3 | +use std::{ | |
| 4 | + io::{IsTerminal, Write}, | |
| 5 | + time::Duration, | |
| 6 | +}; | |
| 4 | 7 | |
| 5 | 8 | use anyhow::{Context, bail}; |
| 6 | 9 | use irongit_shared::{self as shared, version_newer}; |
| @@ -10,7 +13,7 @@ use crate::{ | ||
| 10 | 13 | VERSION, |
| 11 | 14 | client::{Client, http_client}, |
| 12 | 15 | config::Config, |
| 13 | - output::{bold, green}, | |
| 16 | + output::{bold, green, yellow}, | |
| 14 | 17 | }; |
| 15 | 18 | |
| 16 | 19 | pub fn upgrade(host_flag: Option<&str>, force: bool, check_only: bool) -> anyhow::Result<()> { |
| @@ -60,3 +63,39 @@ pub fn upgrade(host_flag: Option<&str>, force: bool, check_only: bool) -> anyhow | ||
| 60 | 63 | println!("{} Upgraded ig {VERSION} -> {} ({})", green("✓"), bold(&latest.version), exe.display()); |
| 61 | 64 | Ok(()) |
| 62 | 65 | } |
| 66 | + | |
| 67 | +#[derive(serde::Serialize, serde::Deserialize, Default)] | |
| 68 | +struct UpdateState { | |
| 69 | + checked_at: i64, | |
| 70 | + latest: Option<String>, | |
| 71 | +} | |
| 72 | + | |
| 73 | +fn state_path() -> Option<std::path::PathBuf> { | |
| 74 | + crate::config::path().ok().map(|p| p.with_file_name("update-check.toml")) | |
| 75 | +} | |
| 76 | + | |
| 77 | +/// At most once a day, asks the server for the newest ig and, if it is newer, | |
| 78 | +/// prints one line to stderr. Never fails a command: any error is ignored. | |
| 79 | +/// Silent when stderr is not a terminal or IG_NO_UPDATE_NOTIFIER is set. | |
| 80 | +pub fn notify_if_outdated(host_flag: Option<&str>) { | |
| 81 | + if std::env::var_os("IG_NO_UPDATE_NOTIFIER").is_some() || !std::io::stderr().is_terminal() { | |
| 82 | + return; | |
| 83 | + } | |
| 84 | + let Some(path) = state_path() else { return }; | |
| 85 | + let mut state: UpdateState = std::fs::read_to_string(&path).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default(); | |
| 86 | + let now = chrono::Utc::now().timestamp(); | |
| 87 | + if now - state.checked_at > 24 * 3600 { | |
| 88 | + let latest = Config::load().ok().and_then(|config| config.resolve_host(host_flag).ok()).and_then(|host| { | |
| 89 | + let http = reqwest::blocking::Client::builder().timeout(Duration::from_millis(1500)).build().ok()?; | |
| 90 | + let release: shared::CliRelease = http.get(format!("{host}/api/v1/cli/latest")).send().ok()?.error_for_status().ok()?.json().ok()?; | |
| 91 | + Some(release.version) | |
| 92 | + }); | |
| 93 | + state = UpdateState { checked_at: now, latest: latest.or(state.latest) }; | |
| 94 | + if let Ok(text) = toml::to_string(&state) { | |
| 95 | + let _ = std::fs::write(&path, text); | |
| 96 | + } | |
| 97 | + } | |
| 98 | + if let Some(latest) = state.latest.filter(|l| version_newer(l, VERSION)) { | |
| 99 | + eprintln!("{}", yellow(&format!("A new ig is available: {latest} (you have {VERSION}). Run: ig upgrade"))); | |
| 100 | + } | |
| 101 | +} |
+9-4deploy.sh
| @@ -15,10 +15,15 @@ TAG=$(git rev-parse --short HEAD) | ||
| 15 | 15 | if ! git diff --quiet HEAD; then |
| 16 | 16 | TAG="$TAG-dirty-$(date +%Y%m%d%H%M%S)" |
| 17 | 17 | fi |
| 18 | -IG_VERSION=$(sed -n 's/^version = "\(.*\)"/\1/p' cli/Cargo.toml | head -1) | |
| 18 | +PUBLIC_URL=${DEPLOY_PUBLIC_URL:-https://git.hygo.ai} | |
| 19 | +# ig's version is major.minor from cli/Cargo.toml plus the number of commits | |
| 20 | +# that touched the CLI, so any CLI change publishes a newer release that | |
| 21 | +# `ig upgrade` (and its daily notice) picks up. No manual bumps. | |
| 22 | +IG_BASE=$(sed -n 's/^version = "\([0-9]*\.[0-9]*\)\..*"/\1/p' cli/Cargo.toml | head -1) | |
| 23 | +IG_VERSION="$IG_BASE.$(git rev-list --count HEAD -- cli shared)" | |
| 19 | 24 | |
| 20 | -echo "==> building irongit:$TAG (ig $IG_VERSION)" | |
| 21 | -docker build -t "irongit:$TAG" . | |
| 25 | +echo "==> building irongit:$TAG (ig $IG_VERSION, default host $PUBLIC_URL)" | |
| 26 | +docker build --build-arg "IG_BUILD_VERSION=$IG_VERSION" --build-arg "IG_DEFAULT_HOST=$PUBLIC_URL" -t "irongit:$TAG" . | |
| 22 | 27 | |
| 23 | 28 | echo "==> shipping to $REMOTE" |
| 24 | 29 | docker save "irongit:$TAG" | gzip -1 | ssh "$REMOTE" 'gunzip | docker load' |
| @@ -59,4 +64,4 @@ docker images irongit --format '{{.Tag}} {{.CreatedAt}}' | grep -v '^latest ' | | ||
| 59 | 64 | | while read -r old; do [ "$old" = "$tag" ] || docker rmi "irongit:$old" >/dev/null 2>&1 || true; done |
| 60 | 65 | REMOTE_SCRIPT |
| 61 | 66 | |
| 62 | -echo "==> done: https://git.hygo.ai" | |
| 67 | +echo "==> done: $PUBLIC_URL" |