Files
ngit-grasp/src/main.rs
T
DanConwayDev 75da9a8446 test: preserve subprocess and recovery fixture listeners
Releasing a reservation before spawn or recovery allowed unrelated tests to
take the same port. Transfer the socket across exec through a private Unix
test-only protocol, retaining a parent copy across Grasp restarts. Validate
the inherited descriptor and keep it out of Git and SSH descendants.

Offline identity fixtures retain an accept-and-close endpoint until recovery.
Mock relay listeners and upgraded connections remain owned until shutdown.
Use real HTTP or SDK readiness instead of grace sleeps; startup errors in
the owned-listener path are not retried.

Normal server startup and deployment configuration are unchanged. The
capability probe permits older external harnesses to remain compatible.
Validation: listener adoption unit tests passed; relay_identity passed with
recovery coverage, MockRelay shutdown passed, and a real subprocess restart
plus a cross-repository ngit harness smoke test passed. Full platform builds
remain host validation.

Assisted-by: Codex (GPT-6)
2026-09-12 14:48:16 +00:00

179 lines
6.3 KiB
Rust

use std::{ffi::OsStr, io::IsTerminal};
use anyhow::Result;
use clap::Parser;
use tokio::signal;
use tracing::info;
use tracing_subscriber::{filter::FilterExt, layer::SubscriberExt, EnvFilter, Layer};
use ngit_grasp::{
cleanup_empty_repos,
config::Config,
logging::{effective_log_filter, SuppressRoutineDisconnects},
nostr,
server::RelayServer,
};
mod docs_export;
#[cfg(unix)]
mod test_listener;
/// Top-level CLI dispatcher.
///
/// With no subcommand the binary runs the relay (all relay flags apply).
/// With a subcommand it runs the requested maintenance tool instead.
#[derive(Debug, Parser)]
#[command(author, version, about = "ngit-grasp GRASP relay", long_about = None)]
#[command(propagate_version = true)]
enum Cli {
/// Run the GRASP relay server (default when no subcommand is given).
#[command(name = "serve")]
Serve(Box<Config>),
/// Remove kind 30617/30618 events whose bare git repository is empty or missing.
///
/// Runs in dry-run mode by default. Pass --execute to make changes.
/// Stop the relay service before running with --execute.
CleanupEmptyRepos(cleanup_empty_repos::CleanupArgs),
/// Permanently eject deleted repository data from holding/archive stores.
///
/// This is an operator/admin maintenance command and is idempotent.
HoldingEject(nostr::lifecycle::HoldingEjectArgs),
/// Queue storage- and event-authorization integrity checks in the running relay.
IntegrityCheck(ngit_grasp::git::integrity::IntegrityCheckArgs),
}
#[tokio::main]
async fn main() -> Result<()> {
// Harnesses probe this before handing a reserved Unix listener to the child.
if std::env::args_os().nth(1).as_deref() == Some(OsStr::new("--internal-test-listener-support"))
{
#[cfg(unix)]
println!("ngit-test-listener-v1");
return Ok(());
}
// Documentation builds need only the static command model. Dispatch before
// dotenv, clap parsing, secret discovery, filesystem access, or relay
// startup so exporting metadata is safe in an isolated build environment.
if std::env::args_os().nth(1).as_deref() == Some(OsStr::new(docs_export::INTERNAL_COMMAND)) {
docs_export::write_stdout()?;
return Ok(());
}
// Load .env file before clap parses, so env vars are available.
dotenvy::dotenv().ok();
// Peek at argv[1] to decide whether a subcommand was explicitly provided.
// If not, prepend the implicit "serve" subcommand so that clap routes to Cli::Serve
// and all relay flags are parsed normally (preserving backward compatibility).
let mut args: Vec<String> = std::env::args().collect();
let known_subcommands = [
"serve",
"cleanup-empty-repos",
"holding-eject",
"integrity-check",
"help",
];
let has_subcommand = args.get(1).is_some_and(|a| {
known_subcommands.contains(&a.as_str())
|| matches!(a.as_str(), "-h" | "--help" | "-V" | "--version")
});
if !has_subcommand {
args.insert(1, "serve".to_string());
}
match Cli::parse_from(args) {
Cli::CleanupEmptyRepos(cleanup_args) => cleanup_empty_repos::run(&cleanup_args).await,
Cli::HoldingEject(eject_args) => nostr::lifecycle::run_holding_eject(eject_args).await,
Cli::IntegrityCheck(integrity_args) => {
let path = ngit_grasp::git::integrity::enqueue_manual_check(&integrity_args)?;
println!(
"Queued {} for identifier '{}' at {}",
if integrity_args.repair {
"storage and authorization-integrity check and repair"
} else {
"storage and authorization-integrity check"
},
integrity_args.identifier,
path.display()
);
Ok(())
}
Cli::Serve(config) => {
let mut config = *config;
config.relay_owner_nsec = Some(Config::load_relay_owner_key()?);
run_relay(config).await
}
}
}
/// Run the relay until an OS shutdown signal arrives.
///
/// All relay wiring lives in [`ngit_grasp::server::RelayServer`];
/// this function only owns concerns specific to the standalone binary:
/// the global tracing subscriber and signal handling.
async fn run_relay(config: Config) -> Result<()> {
let effective_filter = effective_log_filter(&config.log_level);
let filter = EnvFilter::new(&effective_filter).and(SuppressRoutineDisconnects);
// Only colorize when stdout is a terminal: ANSI escape codes in redirected
// logs corrupt journald/file output and break log-scraping consumers.
let subscriber = tracing_subscriber::registry().with(
tracing_subscriber::fmt::layer()
.with_ansi(std::io::stdout().is_terminal())
.with_filter(filter),
);
tracing::subscriber::set_global_default(subscriber)?;
info!(
configured_log_level = %config.log_level,
effective_log_filter = %effective_filter,
"Starting ngit-grasp"
);
#[cfg(unix)]
let server = match test_listener::from_env()? {
Some(listener) => RelayServer::start_with_listener(config, listener).await?,
None => RelayServer::start(config).await?,
};
#[cfg(not(unix))]
let server = RelayServer::start(config).await?;
server.run_until(shutdown_signal()).await
}
/// Resolves when the process receives SIGINT (Ctrl+C) or, on unix, SIGTERM.
async fn shutdown_signal() {
#[cfg(unix)]
{
use tokio::signal::unix::{signal as unix_signal, SignalKind};
let mut sigterm = match unix_signal(SignalKind::terminate()) {
Ok(sigterm) => sigterm,
Err(e) => {
tracing::error!("Failed to install SIGTERM handler: {}", e);
// Fall back to Ctrl+C only.
let _ = signal::ctrl_c().await;
info!("Received SIGINT (Ctrl+C), cleaning up...");
return;
}
};
tokio::select! {
_ = signal::ctrl_c() => {
info!("Received SIGINT (Ctrl+C), cleaning up...");
}
_ = sigterm.recv() => {
info!("Received SIGTERM, cleaning up...");
}
}
}
#[cfg(not(unix))]
{
let _ = signal::ctrl_c().await;
info!("Received SIGINT (Ctrl+C), cleaning up...");
}
}