···11-use clap::{ArgAction, Args, CommandFactory, Parser, Subcommand, ValueEnum, ValueHint};22-use clap_complete::Shell;33-use core::fmt;44-use gix::bstr::BString;55-use gordian_identity::HttpClient;66-use gordian_knot::model::config::{DEFAULT_READMES, KnotConfiguration, RepoCacheConfig};77-use gordian_types::OwnedDid;88-use std::{env, path::PathBuf, time::Duration};99-use url::Url;11+pub mod generate;22+pub mod hook;33+pub mod serve;1041111-pub fn parse() -> KnotCommand {1212- match Arguments::parse().command {1313- KnotCommand::Generate(arguments) => {1414- let mut command = Arguments::command();1515- let name = command.get_name().to_string();1616- clap_complete::generate(arguments.shell, &mut command, name, &mut std::io::stdout());1717- std::process::exit(0);1818- }1919- KnotCommand::Serve(mut arguments) => {2020- if let Some("") = arguments.archive_bz2_command.as_deref() {2121- arguments.archive_bz2_command = None;2222- }55+use clap::{Parser, Subcommand};2362424- if let Some("") = arguments.archive_xz_command.as_deref() {2525- arguments.archive_xz_command = None;2626- }77+pub trait RunCommand {88+ type Error;2792828- KnotCommand::Serve(arguments)2929- }3030- hook @ KnotCommand::Hook(_) => hook,3131- }1010+ fn run(self) -> Result<(), Self::Error>;3211}33123413#[derive(Debug, Parser)]3514#[command(about, author, version)]3615pub struct Arguments {3716 #[clap(subcommand)]3838- command: KnotCommand,1717+ pub command: KnotCommand,3918}40194120#[derive(Debug, Subcommand, Clone)]4221pub enum KnotCommand {4343- Generate(GenerateArguments),4444- Serve(ServeArguments),4545- Hook(HookArguments),2222+ Generate(generate::Generate),2323+ Serve(serve::Serve),2424+ Hook(hook::Hook),4625}47264848-/// Generate shell completions.4949-#[derive(Clone, Debug, Args)]5050-pub struct GenerateArguments {5151- shell: Shell,5252-}2727+pub fn parse() -> Arguments {2828+ let mut arguments = Arguments::parse();53295454-/// Serve the tangled knot.5555-#[derive(Clone, Debug, Args)]5656-pub struct ServeArguments {5757- /// FQDN of the knot.5858- #[arg(long, short, value_hint = ValueHint::Hostname, env = "KNOT_NAME")]5959- #[cfg_attr(debug_assertions, arg(default_value = "localhost:5555"))]6060- pub name: String,3030+ // Fix some of the arguments to 'serve'.3131+ if let KnotCommand::Serve(arguments) = &mut arguments.command {3232+ let is_empty = |val: &mut String| val.is_empty();3333+ arguments.archive_bz2_command.take_if(is_empty);3434+ arguments.archive_xz_command.take_if(is_empty);61356262- /// Handle or DID of the knot owner.6363- #[arg(long, short, env = "KNOT_OWNER")]6464- pub owner: OwnedDid,6565-6666- /// Base path for repositories.6767- #[arg(long, short, value_hint = ValueHint::DirPath, env = "KNOT_REPO_BASE")]6868- #[arg(default_value = default_repository_base().into_os_string())]6969- pub repos: PathBuf,7070-7171- /// Path to knot-level git hooks.7272- #[arg(long, short = 'H', value_hint = ValueHint::DirPath, env = "KNOT_HOOKS_PATH")]7373- pub hooks: Option<PathBuf>,7474-7575- /// Path to knot-level git config.7676- #[arg(long, value_hint = ValueHint::FilePath, env = "KNOT_GIT_CONFIG_PATH")]7777- #[arg(default_value = default_repository_base().join("git_config").into_os_string())]7878- pub git_config: PathBuf,7979-8080- /// Address to bind the the public knot API.8181- #[arg(long, value_delimiter = ',', env = "KNOT_ADDR")]8282- #[arg(default_value = "localhost:5555")]8383- pub bind: Vec<String>,8484-8585- /// Path to the knot sqlite database.8686- #[arg(long, env = "KNOT_DATABASE_PATH", default_value = "knot.db")]8787- pub db: PathBuf,8888-8989- /// PLC directory for DID resolution.9090- #[arg(long, value_hint = ValueHint::Url, env = "KNOT_PLC_DIRECTORY")]9191- #[arg(default_value = "https://plc.directory")]9292- pub plc_directory: String,9393-9494- #[arg(long, short, value_delimiter = ',', value_hint = ValueHint::Url, env = "KNOT_JETSTREAM")]9595- #[arg(default_value = default_jetstream_instances())]9696- pub jetstream: Vec<String>,9797-9898- /// Acceptable authorization methods for git pushes over http.9999- #[arg(hide = true, long, require_equals = true, value_delimiter = ',')]100100- #[arg(env = "KNOT_AUTH_METHODS")]101101- #[arg(default_value = "service-auth,public-key")]102102- pub auth_methods: Vec<AuthenticationMethods>,103103-104104- /// Require git pushes to be signed by a public key from a 'sh.tangled.publicKey'.105105- ///106106- /// See: <https://git-scm.com/docs/git-push#Documentation/git-push.txt---signed>107107- #[arg(long, action = ArgAction::Set, require_equals = true)]108108- #[arg(default_value_t = true)]109109- pub require_signed_push: bool,110110-111111- /// Number of open repository handles to cache.112112- ///113113- /// Keeping open handles reduces the overhead of opening a repository at the114114- /// expense of increased memory usage.115115- #[arg(long, env = "KNOT_REPO_CACHE_SIZE", default_value_t = 0)]116116- pub repo_cache_size: u64,117117-118118- /// Seconds to retain an idle repository handle in cache.119119- #[arg(long, env = "KNOT_REPO_CACHE_IDLE", default_value_t = 60)]120120- pub repo_cache_idle: u64,121121-122122- /// Seconds to retain a repository handle in cache.123123- #[arg(long, env = "KNOT_REPO_CACHE_LIVE", default_value_t = 600)]124124- pub repo_cache_live: u64,125125-126126- /// Command to use to compress bzip2 archives.127127- #[arg(long, env = "KNOT_ARCHIVE_BZ2", default_value = find_command("bzip2").unwrap_or_default())]128128- pub archive_bz2_command: Option<String>,129129-130130- /// Command to use to compress xz archives.131131- #[arg(long, env = "KNOT_ARCHIVE_XZ", default_value = find_command("xz").unwrap_or_default())]132132- pub archive_xz_command: Option<String>,133133-}134134-135135-fn find_command(name: &str) -> Option<String> {136136- use std::process::Command;137137-138138- let output = Command::new("which").arg(name).output().ok()?;139139- if !output.status.success() {140140- return None;3636+ arguments.jetstream.retain(|val| !val.is_empty());14137 }14238143143- let full_path = String::from_utf8(output.stdout).ok()?;144144- Some(full_path.trim().to_string())3939+ arguments14540}14641147147-impl ServeArguments {148148- pub fn to_knot_config(&self) -> Result<KnotConfiguration, Error> {149149- let Self {150150- name,151151- owner,152152- repos: repo_path,153153- hooks: _,154154- git_config,155155- bind: _,156156- db: _,157157- plc_directory: _,158158- jetstream: _,159159- auth_methods: _,160160- require_signed_push: _,161161- repo_cache_size,162162- repo_cache_idle,163163- repo_cache_live,164164- archive_bz2_command: _,165165- archive_xz_command: _,166166- } = self.clone();4242+pub fn run() {4343+ let arguments = parse();16744168168- // @TODO Validate?169169-170170- let instance = format!("did:web:{name}").parse()?;171171-172172- Ok(KnotConfiguration {173173- owner,174174- instance,175175- repo_path,176176- git_config,177177- readmes: DEFAULT_READMES178178- .iter()179179- .map(|v| BString::new(v.to_vec()))180180- .collect(),181181- repo_cache: RepoCacheConfig {182182- size: repo_cache_size,183183- idle: Duration::from_secs(repo_cache_idle),184184- live: Duration::from_secs(repo_cache_live),185185- },186186- })187187- }188188-189189- pub fn init_resolver(&self, http: HttpClient) -> gordian_identity::Resolver {190190- let plc_url = Url::parse(&self.plc_directory).expect("PLC directory should be a valid URL");191191- assert!(["http", "https"].contains(&plc_url.scheme()));192192-193193- gordian_identity::Resolver::builder()194194- .plc_directory(self.plc_directory.clone())195195- .build_with(http)196196- }197197-}198198-199199-#[derive(Debug, thiserror::Error)]200200-pub enum Error {201201- #[error("unable to build 'did:web:{{name}}' from knot fqdn: {0}")]202202- Name(#[from] gordian_types::did::Error),203203-}204204-205205-#[derive(Clone, Debug, ValueEnum)]206206-pub enum AuthenticationMethods {207207- ServiceAuth,208208- PublicKey,209209-}210210-211211-fn default_repository_base() -> PathBuf {212212- env::current_dir().expect("current working directory should be readable")213213-}214214-215215-fn default_jetstream_instances() -> String {216216- gordian_jetstream::PUBLIC_JETSTREAM_INSTANCES.join(",")217217-}218218-219219-/// Forward a git hook to the internal API.220220-///221221-/// This command is expected to be invoked by git during operations via222222-/// the global hook shims.223223-#[derive(Clone, Args)]224224-pub struct HookArguments {225225- /// Internal API endpoints.226226- #[arg(long, value_delimiter = ',', env = gordian_knot::private::ENV_PRIVATE_ENDPOINTS)]227227- pub api: Vec<Url>,228228-229229- /// DID of the repository owner.230230- #[arg(long, env = gordian_knot::private::ENV_REPO_DID)]231231- pub repo_did: OwnedDid,232232-233233- /// Record key of the repository.234234- #[arg(long, env = gordian_knot::private::ENV_REPO_RKEY)]235235- pub repo_rkey: String,236236-237237- /// Name of the hook to forward.238238- pub hook: HookName,239239-}240240-241241-impl fmt::Debug for HookArguments {242242- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {243243- f.debug_struct("HookArguments")244244- // Suppress `url::Url`'s god-awful debug output.245245- .field("api", &self.api.iter().map(Url::as_str).collect::<Vec<_>>())246246- .field("repo_did", &self.repo_did)247247- .field("repo_rkey", &self.repo_rkey)248248- .field("hook", &self.hook)249249- .finish()250250- }251251-}252252-253253-#[derive(Clone, Copy, Debug, ValueEnum)]254254-#[clap(rename_all = "kebab-case")]255255-pub enum HookName {256256- PreReceive,257257- PostReceive,258258- PostUpdate,259259-}260260-261261-impl fmt::Display for HookName {262262- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {263263- f.write_str(match self {264264- Self::PreReceive => "pre-receive",265265- Self::PostReceive => "post-receive",266266- Self::PostUpdate => "post-update",267267- })268268- }269269-}270270-271271-impl AsRef<std::path::Path> for HookName {272272- fn as_ref(&self) -> &std::path::Path {273273- std::path::Path::new(match self {274274- Self::PreReceive => "pre-receive",275275- Self::PostReceive => "post-receive",276276- Self::PostUpdate => "post-update",277277- })278278- }279279-}280280-281281-impl HookName {282282- pub fn iter_variants() -> impl Iterator<Item = HookName> {283283- [Self::PreReceive, Self::PostReceive, Self::PostUpdate].into_iter()4545+ match arguments.command {4646+ KnotCommand::Generate(generate) => generate.run().unwrap(),4747+ KnotCommand::Serve(serve) => serve.run().unwrap(),4848+ KnotCommand::Hook(hook) => hook.run().unwrap(),28449 }28550}
+23
crates/gordian-knot/src/cli/generate.rs
···11+use clap::{Args, CommandFactory as _};22+use clap_complete::Shell;33+44+/// Generate shell completions.55+#[derive(Clone, Debug, Args)]66+pub struct Generate {77+ /// Shell to generate a completion script for.88+ pub shell: Shell,99+}1010+1111+impl super::RunCommand for Generate {1212+ type Error = std::convert::Infallible;1313+1414+ fn run(self) -> Result<(), Self::Error> {1515+ use super::Arguments;1616+1717+ let mut command = Arguments::command();1818+ let name = command.get_name().to_string();1919+ clap_complete::generate(self.shell, &mut command, name, &mut std::io::stdout());2020+2121+ Ok(())2222+ }2323+}
+199
crates/gordian-knot/src/cli/hook.rs
···11+use core::fmt;22+use std::{33+ collections::HashMap,44+ env,55+ io::{self, Write as _},66+ path,77+};88+99+use axum::http::{HeaderMap, HeaderName, HeaderValue};1010+use bytes::Bytes;1111+use gordian_types::OwnedDid;1212+use reqwest::header::InvalidHeaderName;1313+use url::Url;1414+1515+/// Forward a git hook to the internal API.1616+///1717+/// This command is expected to be invoked by git during operations via1818+/// the global hook shims.1919+#[derive(Clone, clap::Args)]2020+pub struct Hook {2121+ /// Internal API endpoints.2222+ #[arg(long, value_delimiter = ',', env = gordian_knot::private::ENV_PRIVATE_ENDPOINTS)]2323+ pub api: Vec<Url>,2424+2525+ /// DID of the repository owner.2626+ #[arg(long, env = gordian_knot::private::ENV_REPO_DID)]2727+ pub repo_did: OwnedDid,2828+2929+ /// Record key of the repository.3030+ #[arg(long, env = gordian_knot::private::ENV_REPO_RKEY)]3131+ pub repo_rkey: String,3232+3333+ /// Name of the hook to forward.3434+ pub hook: HookName,3535+}3636+3737+impl fmt::Debug for Hook {3838+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {3939+ f.debug_struct("HookArguments")4040+ // Suppress `url::Url`'s god-awful debug output.4141+ .field("api", &self.api.iter().map(Url::as_str).collect::<Vec<_>>())4242+ .field("repo_did", &self.repo_did)4343+ .field("repo_rkey", &self.repo_rkey)4444+ .field("hook", &self.hook)4545+ .finish()4646+ }4747+}4848+4949+#[derive(Clone, Copy, Debug, clap::ValueEnum)]5050+#[clap(rename_all = "kebab-case")]5151+pub enum HookName {5252+ PreReceive,5353+ PostReceive,5454+ PostUpdate,5555+}5656+5757+impl fmt::Display for HookName {5858+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {5959+ f.write_str(match self {6060+ Self::PreReceive => "pre-receive",6161+ Self::PostReceive => "post-receive",6262+ Self::PostUpdate => "post-update",6363+ })6464+ }6565+}6666+6767+impl AsRef<path::Path> for HookName {6868+ fn as_ref(&self) -> &path::Path {6969+ std::path::Path::new(match self {7070+ Self::PreReceive => "pre-receive",7171+ Self::PostReceive => "post-receive",7272+ Self::PostUpdate => "post-update",7373+ })7474+ }7575+}7676+7777+impl super::RunCommand for Hook {7878+ type Error = anyhow::Error;7979+8080+ fn run(self) -> Result<(), Self::Error> {8181+ use tokio::runtime::Builder;8282+8383+ let runtime = Builder::new_current_thread()8484+ .enable_all()8585+ .build()8686+ .expect("Failed to build runtime");8787+8888+ runtime.block_on(forward_hook(self))8989+ }9090+}9191+9292+/// [`core::fmt::Debug`] an [`url::Url`] without causing eye-cancer.9393+#[repr(transparent)]9494+struct DebugUrl(url::Url);9595+9696+impl core::fmt::Debug for DebugUrl {9797+ fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {9898+ core::fmt::Display::fmt(&self.0, f)9999+ }100100+}101101+102102+/// [`core::fmt::Debug`] a slice [`url::Url`] without causing eye-cancer.103103+pub struct DebugUrls<'a>(pub &'a [url::Url]);104104+105105+impl<'a> core::fmt::Debug for DebugUrls<'a> {106106+ fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {107107+ let urls = unsafe {108108+ // SAFETY: #[repr(transparent)] on DebugUrl guarantees Url === DebugUrl.109109+ &*(self.0 as *const [url::Url] as *const [DebugUrl])110110+ };111111+ core::fmt::Debug::fmt(&urls, f)112112+ }113113+}114114+115115+#[tracing::instrument(fields(api = ?DebugUrls(&api)))]116116+pub async fn forward_hook(117117+ Hook {118118+ api,119119+ repo_did,120120+ repo_rkey,121121+ hook,122122+ }: Hook,123123+) -> anyhow::Result<()> {124124+ if api.is_empty() {125125+ tracing::warn!("internal API not specified, skipping hook");126126+ return Ok(());127127+ };128128+129129+ let mut environment_vars: HashMap<_, _> = env::vars()130130+ .filter(|(key, _)| !key.trim().is_empty())131131+ .collect();132132+133133+ let request_id = take_var(&mut environment_vars, "X_REQUEST_ID").ok();134134+135135+ // Build a header map with the remaining environment variables.136136+ let mut headers = HeaderMap::with_capacity(environment_vars.len());137137+ if let Some(request_id) = request_id {138138+ headers.insert("X-Request-ID", HeaderValue::from_str(&request_id)?);139139+ }140140+141141+ for (key, value) in environment_vars {142142+ match (variable_to_header_name(&key), HeaderValue::try_from(&value)) {143143+ (Ok(key), Ok(value)) => _ = headers.insert(key, value),144144+ (Err(error), _) => tracing::warn!(?error, ?key, ?value, "ignoring header"),145145+ (_, Err(error)) => tracing::warn!(?error, ?key, ?value, "ignoring header"),146146+ }147147+ }148148+149149+ let stdin = Bytes::from(io::read_to_string(io::stdin())?);150150+151151+ let client = reqwest::Client::new();152152+ let url_path = format!("/hook/{repo_did}/{repo_rkey}/{hook}");153153+ for mut hook_url in api {154154+ hook_url.set_path(&url_path);155155+ let response = client156156+ .post(hook_url)157157+ .headers(headers.clone())158158+ .body(stdin.clone())159159+ .send()160160+ .await;161161+162162+ match response {163163+ Ok(response) if response.status().is_success() => {164164+ let body = response.bytes().await?;165165+ io::stdout().write_all(&body)?;166166+ return Ok(());167167+ }168168+ Ok(response) => {169169+ let status = response.status();170170+ let body = response.bytes().await?;171171+ io::stdout().write_all(&body)?;172172+ return Err(anyhow::anyhow!("Knot returned error status {status}"));173173+ }174174+ Err(error) => {175175+ tracing::error!(?error, "failed to post hook to internal API");176176+ continue;177177+ }178178+ }179179+ }180180+181181+ Err(anyhow::anyhow!("Failed to find a valid internal endpoint"))182182+}183183+184184+fn take_var(vars: &mut HashMap<String, String>, name: &str) -> anyhow::Result<String> {185185+ vars.remove(name).ok_or(anyhow::anyhow!(186186+ "Expected environment variable {name:?} to be set",187187+ ))188188+}189189+190190+fn variable_to_header_name(name: &str) -> Result<HeaderName, InvalidHeaderName> {191191+ format!(192192+ "{}-{}",193193+ gordian_knot::private::ENV_HEADER_PREFIX,194194+ name.trim_start_matches("GORDIAN_")195195+ )196196+ .replace('_', "-")197197+ .to_lowercase()198198+ .try_into()199199+}
+444
crates/gordian-knot/src/cli/serve.rs
···11+use std::{env, ffi, net::ToSocketAddrs as _, path, time::Duration};22+33+use anyhow::Context as _;44+use axum::http::{Request, Response};55+use clap::{ArgAction, Args, ValueEnum, ValueHint};66+use futures_util::FutureExt as _;77+use gix::bstr::BString;88+use gordian_identity::HttpClient;99+use gordian_knot::{1010+ model::{1111+ Knot, KnotState,1212+ config::{self, KnotConfiguration},1313+ },1414+ services::database::DataStore,1515+};1616+use gordian_types::OwnedDid;1717+use tokio::{net::TcpListener, signal, task::JoinSet};1818+use tokio_util::sync::CancellationToken;1919+use tower::ServiceBuilder;2020+use tower_http::{2121+ ServiceBuilderExt as _,2222+ decompression::RequestDecompressionLayer,2323+ request_id::{MakeRequestUuid, RequestId},2424+ trace::{MakeSpan, OnResponse, TraceLayer},2525+};2626+use tracing::{Span, field::Empty};2727+use url::Url;2828+2929+const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"));3030+3131+/// Serve the tangled knot.3232+#[derive(Clone, Debug, Args)]3333+pub struct Serve {3434+ /// FQDN of the knot.3535+ #[arg(long, short, value_hint = ValueHint::Hostname, env = "KNOT_NAME")]3636+ #[cfg_attr(debug_assertions, arg(default_value = "localhost:5555"))]3737+ pub name: String,3838+3939+ /// Handle or DID of the knot owner.4040+ #[arg(long, short, env = "KNOT_OWNER")]4141+ pub owner: OwnedDid,4242+4343+ /// Base path for repositories.4444+ #[arg(long, short, value_hint = ValueHint::DirPath, env = "KNOT_REPO_BASE")]4545+ #[arg(default_value = default_repository_base().into_os_string())]4646+ pub repos: path::PathBuf,4747+4848+ /// Path to knot-level git hooks.4949+ #[arg(long, short = 'H', value_hint = ValueHint::DirPath, env = "KNOT_HOOKS_PATH")]5050+ pub hooks: Option<path::PathBuf>,5151+5252+ /// Path to knot-level git config.5353+ #[arg(long, value_hint = ValueHint::FilePath, env = "KNOT_GIT_CONFIG_PATH")]5454+ #[arg(default_value = default_repository_base().join("git_config").into_os_string())]5555+ pub git_config: path::PathBuf,5656+5757+ /// Address to bind the the public knot API.5858+ #[arg(long, value_delimiter = ',', env = "KNOT_ADDR")]5959+ #[arg(default_value = "localhost:5555")]6060+ pub bind: Vec<String>,6161+6262+ /// Path to the knot sqlite database.6363+ #[arg(long, env = "KNOT_DATABASE_PATH", default_value = "knot.db")]6464+ pub db: path::PathBuf,6565+6666+ /// PLC directory for DID resolution.6767+ #[arg(long, value_hint = ValueHint::Url, env = "KNOT_PLC_DIRECTORY")]6868+ #[arg(default_value = "https://plc.directory")]6969+ pub plc_directory: String,7070+7171+ #[arg(long, short, value_delimiter = ',', value_hint = ValueHint::Url, env = "KNOT_JETSTREAM")]7272+ #[arg(default_value = default_jetstream_instances())]7373+ pub jetstream: Vec<String>,7474+7575+ /// Acceptable authorization methods for git pushes over http.7676+ #[arg(hide = true, long, require_equals = true, value_delimiter = ',')]7777+ #[arg(env = "KNOT_AUTH_METHODS")]7878+ #[arg(default_value = "service-auth,public-key")]7979+ pub auth_methods: Vec<AuthenticationMethods>,8080+8181+ /// Require git pushes to be signed by a public key from a 'sh.tangled.publicKey'.8282+ ///8383+ /// See: <https://git-scm.com/docs/git-push#Documentation/git-push.txt---signed>8484+ #[arg(long, action = ArgAction::Set, require_equals = true)]8585+ #[arg(default_value_t = true)]8686+ pub require_signed_push: bool,8787+8888+ /// Number of open repository handles to cache.8989+ ///9090+ /// Keeping open handles reduces the overhead of opening a repository at the9191+ /// expense of increased memory usage.9292+ #[arg(long, env = "KNOT_REPO_CACHE_SIZE", default_value_t = 0)]9393+ pub repo_cache_size: u64,9494+9595+ /// Seconds to retain an idle repository handle in cache.9696+ #[arg(long, env = "KNOT_REPO_CACHE_IDLE", default_value_t = 60)]9797+ pub repo_cache_idle: u64,9898+9999+ /// Seconds to retain a repository handle in cache.100100+ #[arg(long, env = "KNOT_REPO_CACHE_LIVE", default_value_t = 600)]101101+ pub repo_cache_live: u64,102102+103103+ /// Command to use to compress bzip2 archives.104104+ #[arg(long, env = "KNOT_ARCHIVE_BZ2", default_value = find_command("bzip2").unwrap_or_default())]105105+ pub archive_bz2_command: Option<String>,106106+107107+ /// Command to use to compress xz archives.108108+ #[arg(long, env = "KNOT_ARCHIVE_XZ", default_value = find_command("xz").unwrap_or_default())]109109+ pub archive_xz_command: Option<String>,110110+}111111+112112+impl Serve {113113+ pub fn to_knot_config(&self) -> Result<KnotConfiguration, Error> {114114+ let Self {115115+ name,116116+ owner,117117+ repos: repo_path,118118+ hooks: _,119119+ git_config,120120+ bind: _,121121+ db: _,122122+ plc_directory: _,123123+ jetstream: _,124124+ auth_methods: _,125125+ require_signed_push: _,126126+ repo_cache_size,127127+ repo_cache_idle,128128+ repo_cache_live,129129+ archive_bz2_command: _,130130+ archive_xz_command: _,131131+ } = self.clone();132132+133133+ // @TODO Validate?134134+135135+ let instance = format!("did:web:{name}").parse()?;136136+137137+ Ok(KnotConfiguration {138138+ owner,139139+ instance,140140+ repo_path,141141+ git_config,142142+ readmes: config::DEFAULT_READMES143143+ .iter()144144+ .map(|v| BString::new(v.to_vec()))145145+ .collect(),146146+ repo_cache: config::RepoCacheConfig {147147+ size: repo_cache_size,148148+ idle: Duration::from_secs(repo_cache_idle),149149+ live: Duration::from_secs(repo_cache_live),150150+ },151151+ })152152+ }153153+154154+ pub fn init_resolver(&self, http: HttpClient) -> gordian_identity::Resolver {155155+ let plc_url = Url::parse(&self.plc_directory).expect("PLC directory should be a valid URL");156156+ assert!(["http", "https"].contains(&plc_url.scheme()));157157+158158+ gordian_identity::Resolver::builder()159159+ .plc_directory(self.plc_directory.clone())160160+ .build_with(http)161161+ }162162+}163163+164164+fn find_command(name: &str) -> Option<String> {165165+ use std::process::Command;166166+167167+ let output = Command::new("which").arg(name).output().ok()?;168168+ if !output.status.success() {169169+ return None;170170+ }171171+172172+ let full_path = String::from_utf8(output.stdout).ok()?;173173+ Some(full_path.trim().to_string())174174+}175175+176176+#[derive(Debug, thiserror::Error)]177177+pub enum Error {178178+ #[error("unable to build 'did:web:{{name}}' from knot fqdn: {0}")]179179+ Name(#[from] gordian_types::did::Error),180180+}181181+182182+#[derive(Clone, Debug, ValueEnum)]183183+pub enum AuthenticationMethods {184184+ ServiceAuth,185185+ PublicKey,186186+}187187+188188+fn default_repository_base() -> path::PathBuf {189189+ env::current_dir().expect("current working directory should be readable")190190+}191191+192192+fn default_jetstream_instances() -> String {193193+ gordian_jetstream::PUBLIC_JETSTREAM_INSTANCES.join(",")194194+}195195+196196+impl super::RunCommand for Serve {197197+ type Error = anyhow::Error;198198+199199+ fn run(self) -> Result<(), Self::Error> {200200+ use tokio::runtime::Builder;201201+202202+ let runtime = Builder::new_current_thread()203203+ .enable_all()204204+ .build()205205+ .expect("Failed to build runtime");206206+207207+ runtime.block_on(serve_knot(self))208208+ }209209+}210210+211211+pub async fn serve_knot(arguments: Serve) -> anyhow::Result<()> {212212+ unsafe { env::set_var("GIT_CONFIG_GLOBAL", &arguments.git_config) };213213+214214+ let tempdir = tempfile::TempDir::with_prefix("gordian-knot-")?;215215+ let hooks_path = if let Some(path) = &arguments.hooks {216216+ // @TODO Verify hooks exist in the specified path.217217+ tracing::warn!(?path, "assuming existence of hooks at path");218218+ path.to_path_buf()219219+ } else {220220+ let path = tempdir.path().join("hooks");221221+ crate::hooks::install_global_hooks(&path)?;222222+ path223223+ };224224+225225+ assert!(git_config_global("core.hooksPath", &hooks_path)?);226226+ assert!(git_config_global("receive.advertisePushOptions", "true")?);227227+ if let Some(command) = &arguments.archive_bz2_command {228228+ assert!(git_config_global("tar.tar.bz2.command", command)?);229229+ }230230+ if let Some(command) = &arguments.archive_xz_command {231231+ assert!(git_config_global("tar.tar.xz.command", command)?);232232+ }233233+234234+ let database = {235235+ use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};236236+237237+ let pool = {238238+ let connect_options = SqliteConnectOptions::new()239239+ .filename(&arguments.db)240240+ .create_if_missing(true)241241+ .foreign_keys(true)242242+ .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal);243243+244244+ SqlitePoolOptions::new()245245+ .connect_with(connect_options)246246+ .await?247247+ };248248+249249+ sqlx::migrate!().run(&pool).await?;250250+ DataStore::new(pool)251251+ };252252+253253+ let public_http = reqwest::ClientBuilder::new()254254+ .timeout(Duration::from_secs(2))255255+ .user_agent(USER_AGENT)256256+ .http2_keep_alive_while_idle(true)257257+ .https_only(true)258258+ .build()259259+ .context("Failed to build public HTTP client")?;260260+261261+ let resolver = arguments.init_resolver(public_http.clone());262262+263263+ // Bind listeners for the public API.264264+ let mut public_listeners = Vec::with_capacity(arguments.bind.len());265265+ for addr in &arguments.bind {266266+ for socket in addr.to_socket_addrs()? {267267+ let listener = TcpListener::bind(socket).await?;268268+ public_listeners.push(listener);269269+ }270270+ }271271+272272+ // Bind listeners for the private API.273273+ let mut private_listeners = Vec::with_capacity(2);274274+ for socket in "localhost:0".to_socket_addrs()? {275275+ let listener = TcpListener::bind(socket).await?;276276+ private_listeners.push(listener);277277+ }278278+279279+ // The knot needs to know the sockets we've bound the private API.280280+ let private_addrs = private_listeners281281+ .iter()282282+ .map(tokio::net::TcpListener::local_addr)283283+ .collect::<Result<Vec<_>, std::io::Error>>()?;284284+285285+ tracing::info!(?private_addrs, "bound internal API");286286+287287+ let config: KnotConfiguration = arguments.to_knot_config()?;288288+ let knot_state = KnotState::new(config, resolver, public_http, database, &private_addrs)?;289289+ let knot = Knot::from(knot_state);290290+291291+ // Ensure the knot owner's records are seeded.292292+ knot.seed_owner()293293+ .await294294+ .context("seeding knot owner's records")?;295295+296296+ let mut tasks = JoinSet::new();297297+ let shutdown = CancellationToken::new();298298+299299+ // Spawn the internal API.300300+ tasks.spawn(gordian_knot::serve_all(301301+ gordian_knot::private::router()302302+ .layer(303303+ ServiceBuilder::new()304304+ .set_x_request_id(MakeRequestUuid)305305+ .layer(306306+ TraceLayer::new_for_http()307307+ .make_span_with(PrivateHttpSpan)308308+ .on_request(|_: &Request<_>, _: &Span| {})309309+ .on_response(TraceResponse),310310+ )311311+ .propagate_x_request_id(),312312+ )313313+ .with_state(knot.clone()),314314+ private_listeners,315315+ shutdown.child_token(),316316+ ));317317+318318+ // Spawn the jetstream consumer.319319+ tasks.spawn(320320+ gordian_knot::services::jetstream::init_consumer(321321+ &knot,322322+ arguments.jetstream.as_slice(),323323+ shutdown.child_token(),324324+ )325325+ .map(|_| Ok(())),326326+ );327327+328328+ // Build the public API.329329+ let router = gordian_knot::public::router()330330+ .layer(RequestDecompressionLayer::new())331331+ .layer(332332+ ServiceBuilder::new()333333+ .set_x_request_id(MakeRequestUuid)334334+ .layer(335335+ TraceLayer::new_for_http()336336+ .make_span_with(PublicHttpSpan)337337+ .on_request(|_: &Request<_>, _: &Span| {})338338+ .on_response(TraceResponse),339339+ )340340+ .propagate_x_request_id(),341341+ )342342+ .with_state(knot);343343+344344+ tasks.spawn(gordian_knot::serve_all(345345+ router,346346+ public_listeners,347347+ shutdown.child_token(),348348+ ));349349+350350+ tasks.spawn(wait_for_shutdown(shutdown));351351+352352+ for task in tasks.join_all().await {353353+ if let Err(error) = task {354354+ tracing::error!(?error, "knot task completed with error");355355+ }356356+ }357357+358358+ Ok(())359359+}360360+361361+async fn wait_for_shutdown(shutdown: CancellationToken) -> std::io::Result<()> {362362+ use tokio::signal::unix::SignalKind;363363+364364+ let mut sigterm = signal::unix::signal(SignalKind::terminate())?;365365+366366+ tokio::select! {367367+ Ok(()) = signal::ctrl_c() => {368368+ eprintln!();369369+ tracing::info!("ctrl+c received, shutting down ...");370370+ },371371+ Some(()) = sigterm.recv() => {372372+ tracing::info!("SIGTERM received, shutting down ...");373373+ }374374+ }375375+376376+ shutdown.cancel();377377+378378+ Ok(())379379+}380380+381381+fn git_config_global<K, V>(key: K, value: V) -> std::io::Result<bool>382382+where383383+ K: AsRef<ffi::OsStr>,384384+ V: AsRef<ffi::OsStr>,385385+{386386+ use std::process::Stdio;387387+388388+ let success = std::process::Command::new("/usr/bin/git")389389+ .args(["config", "set", "--global"])390390+ .arg(key)391391+ .arg(value)392392+ .stdout(Stdio::inherit())393393+ .stderr(Stdio::inherit())394394+ .spawn()?395395+ .wait()?396396+ .success();397397+398398+ Ok(success)399399+}400400+401401+macro_rules! make_span {402402+ ($name:ident, $label:literal) => {403403+ #[derive(Clone)]404404+ struct $name;405405+406406+ impl<B> MakeSpan<B> for $name {407407+ fn make_span(&mut self, request: &axum::http::Request<B>) -> tracing::Span {408408+ let method = request.method();409409+ let path = request.uri().path();410410+411411+ let span = tracing::error_span!($label, id = Empty, method = Empty, path = Empty);412412+ if let Some(id) = request413413+ .extensions()414414+ .get::<RequestId>()415415+ .and_then(|request_id| request_id.header_value().to_str().ok())416416+ {417417+ span.record("id", &id);418418+ }419419+420420+ span.record("method", tracing::field::debug(&method));421421+ span.record("path", tracing::field::debug(&path));422422+423423+ span424424+ }425425+ }426426+ };427427+}428428+429429+make_span!(PublicHttpSpan, "public");430430+make_span!(PrivateHttpSpan, "private");431431+432432+#[derive(Clone)]433433+pub struct TraceResponse;434434+435435+impl<B> OnResponse<B> for TraceResponse {436436+ fn on_response(self, response: &Response<B>, latency: Duration, _: &Span) {437437+ match response.status() {438438+ status if status.is_success() => tracing::trace!(?status, ?latency),439439+ status if status.is_client_error() => tracing::warn!(?status, ?latency),440440+ status if status.is_server_error() => tracing::error!(?status, ?latency),441441+ status => tracing::info!(?status, ?latency),442442+ }443443+ }444444+}
+36-128
crates/gordian-knot/src/hooks.rs
···11-use std::{22- collections::HashMap,33- env,44- fs::{self, Permissions},55- io::{self, Write},66- os::unix::fs::PermissionsExt,77- path::Path,88-};11+use std::{env, fs, io, path};921010-use axum::http::{HeaderMap, HeaderName, HeaderValue, header::InvalidHeaderName};1111-use bytes::Bytes;1212-use gordian_knot::private;33+use crate::cli::hook::HookName;1341414-use crate::cli::{HookArguments, HookName};55+/// Install the knot-global git-hooks in `path`. If `path` does not exist it will be created.66+///77+/// # Panics88+///99+/// Panics if the path to the currently running executable is not utf8.1010+///1111+#[cfg(unix)]1212+pub fn install_global_hooks<P: AsRef<path::Path>>(path: P) -> io::Result<()> {1313+ use std::os::unix::fs::PermissionsExt as _;15141616-/// Setup the global hooks directory at `path`.1717-pub fn setup_global_hooks<P: AsRef<Path>>(path: P) -> io::Result<()> {1515+ use clap::ValueEnum as _;1616+1817 let executable = env::current_exe()1919- .map(|path| path.to_str().map(ToOwned::to_owned))2020- .expect("Current executable must be defined")2121- .expect("Current executable must be valid utf8");1818+ .map(|path| path.to_str().map(ToOwned::to_owned))?1919+ .expect("executable path must be valid utf8");22202323- let _ = fs::create_dir_all(&path);2424- for hook_name in HookName::iter_variants() {2525- let hook_path = path.as_ref().join(hook_name);2121+ ensure_hooks_dir_exists(&path)?;2222+2323+ for hook_name in HookName::value_variants() {2624 let script = format!(2727- "#!/usr/bin/sh\n# This file is generated by gordian-knot. Do not modify.\n{executable} hook {hook_name}\n"2525+ "#!/usr/bin/sh\n\2626+ # This file is generated by gordian-knot. Do not modify.\n\2727+ {executable} hook {hook_name}\n"2828 );2929- std::fs::write(&hook_path, script)?;30293131- let permissions = Permissions::from_mode(0o755);3232- std::fs::set_permissions(&hook_path, permissions)?;3333- tracing::info!(?executable, ?hook_path, "git hook installed");3030+ let hook_path = path.as_ref().join(hook_name);3131+ fs::write(&hook_path, script)?;3232+ fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755))?;3433 }3434+3535 Ok(())3636}37373838-/// [`core::fmt::Debug`] an [`url::Url`] without causing eye-cancer.3939-#[repr(transparent)]4040-struct DebugUrl(url::Url);4141-4242-impl core::fmt::Debug for DebugUrl {4343- fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {4444- core::fmt::Display::fmt(&self.0, f)4545- }4646-}4747-4848-/// [`core::fmt::Debug`] a slice [`url::Url`] without causing eye-cancer.4949-pub struct DebugUrls<'a>(pub &'a [url::Url]);5050-5151-impl<'a> core::fmt::Debug for DebugUrls<'a> {5252- fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {5353- let urls = unsafe {5454- // SAFETY: Close your eyes an pray!5555- &*(self.0 as *const [url::Url] as *const [DebugUrl])5656- };5757- core::fmt::Debug::fmt(&urls, f)5858- }5959-}6060-6161-#[tracing::instrument(fields(api = ?DebugUrls(&api)))]6262-pub async fn run_hook(6363- HookArguments {6464- api,6565- repo_did,6666- repo_rkey,6767- hook,6868- }: HookArguments,6969-) -> anyhow::Result<()> {7070- if api.is_empty() {7171- tracing::warn!("internal API not specified, skipping hook");7272- return Ok(());7373- };7474-7575- let mut environment_vars: HashMap<_, _> = env::vars()7676- .filter(|(key, _)| !key.trim().is_empty())7777- .collect();7878-7979- let request_id = take_var(&mut environment_vars, "X_REQUEST_ID").ok();8080-8181- // Build a header map with the remaining environment variables.8282- let mut headers = HeaderMap::with_capacity(environment_vars.len());8383- if let Some(request_id) = request_id {8484- headers.insert("X-Request-ID", HeaderValue::from_str(&request_id)?);3838+fn ensure_hooks_dir_exists<P: AsRef<path::Path>>(path: P) -> io::Result<()> {3939+ if let Err(error) = fs::create_dir_all(&path)4040+ && error.kind() == io::ErrorKind::AlreadyExists4141+ {4242+ return Err(error);8543 }86448787- for (key, value) in environment_vars {8888- match (variable_to_header_name(&key), HeaderValue::try_from(&value)) {8989- (Ok(key), Ok(value)) => _ = headers.insert(key, value),9090- (Err(error), _) => tracing::warn!(?error, ?key, ?value, "ignoring header"),9191- (_, Err(error)) => tracing::warn!(?error, ?key, ?value, "ignoring header"),9292- }4545+ if !fs::metadata(path)?.is_dir() {4646+ return Err(io::Error::new(4747+ io::ErrorKind::NotADirectory,4848+ "global hook path is not a directory",4949+ ));9350 }94519595- let stdin = Bytes::from(io::read_to_string(io::stdin())?);9696-9797- let client = reqwest::Client::new();9898- let url_path = format!("/hook/{repo_did}/{repo_rkey}/{hook}");9999- for mut hook_url in api {100100- hook_url.set_path(&url_path);101101- let response = client102102- .post(hook_url)103103- .headers(headers.clone())104104- .body(stdin.clone())105105- .send()106106- .await;107107-108108- match response {109109- Ok(response) if response.status().is_success() => {110110- let body = response.bytes().await?;111111- io::stdout().write_all(&body)?;112112- return Ok(());113113- }114114- Ok(response) => {115115- let status = response.status();116116- let body = response.bytes().await?;117117- io::stdout().write_all(&body)?;118118- return Err(anyhow::anyhow!("Knot returned error status {status}"));119119- }120120- Err(error) => {121121- tracing::error!(?error, "failed to post hook to internal API");122122- continue;123123- }124124- }125125- }126126-127127- Err(anyhow::anyhow!("Failed to find a valid internal endpoint"))128128-}129129-130130-fn take_var(vars: &mut HashMap<String, String>, name: &str) -> anyhow::Result<String> {131131- vars.remove(name).ok_or(anyhow::anyhow!(132132- "Expected environment variable {name:?} to be set",133133- ))134134-}135135-136136-fn variable_to_header_name(name: &str) -> Result<HeaderName, InvalidHeaderName> {137137- format!(138138- "{}-{}",139139- private::ENV_HEADER_PREFIX,140140- name.trim_start_matches("GORDIAN_")141141- )142142- .replace('_', "-")143143- .to_lowercase()144144- .try_into()5252+ Ok(())14553}
+16-277
crates/gordian-knot/src/main.rs
···11mod cli;22mod hooks;3344-use anyhow::Context as _;55-use axum::http::{Request, Response};66-use futures_util::FutureExt as _;77-use gordian_knot::{88- model::{Knot, KnotState, config::KnotConfiguration},99- services::database::DataStore,1010-};1111-use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};1212-use std::{env, ffi::OsStr, net::ToSocketAddrs as _, time::Duration};1313-use tokio::{net::TcpListener, signal::unix::SignalKind, task::JoinSet};1414-use tokio::{runtime::Builder, signal};1515-use tokio_util::sync::CancellationToken;1616-use tower::ServiceBuilder;1717-use tower_http::{1818- ServiceBuilderExt as _,1919- decompression::RequestDecompressionLayer,2020- request_id::{MakeRequestUuid, RequestId},2121- trace::{MakeSpan, OnResponse, TraceLayer},2222-};2323-use tracing::{Span, field::Empty, level_filters::LevelFilter};44+use tracing::level_filters::LevelFilter;245use tracing_subscriber::{EnvFilter, layer::SubscriberExt as _, util::SubscriberInitExt as _};256267#[cfg(all(not(target_env = "msvc"), feature = "jemalloc"))]2727-use tikv_jemallocator::Jemalloc;2828-2929-#[cfg(all(not(target_env = "msvc"), feature = "jemalloc"))]308#[global_allocator]3131-static GLOBAL: Jemalloc = Jemalloc;99+static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;32103333-const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"));1111+fn main() {1212+ let filter = EnvFilter::builder()1313+ .with_default_directive(LevelFilter::INFO.into())1414+ .from_env_lossy();34153535-fn main() -> anyhow::Result<()> {1616+ let stderr = tracing_subscriber::fmt::layer()1717+ .with_writer(std::io::stderr)1818+ .without_time();1919+2020+ #[cfg(debug_assertions)]2121+ let stderr = stderr.pretty();2222+3623 tracing_subscriber::registry()3737- .with(3838- EnvFilter::builder()3939- .with_default_directive(LevelFilter::INFO.into())4040- .from_env_lossy(),4141- )4242- .with(4343- tracing_subscriber::fmt::layer()4444- .with_writer(std::io::stderr)4545- .without_time(),4646- )2424+ .with(filter)2525+ .with(stderr)4726 .init();48274949- let runtime = Builder::new_current_thread()5050- .enable_all()5151- .build()5252- .expect("Failed to build runtime");5353-5454- match cli::parse() {5555- cli::KnotCommand::Generate(_) => unreachable!("Handled by cli module"),5656- cli::KnotCommand::Serve(arguments) => runtime.block_on(knot_main(arguments)),5757- cli::KnotCommand::Hook(arguments) => runtime.block_on(hooks::run_hook(arguments)),5858- }5959-}6060-6161-pub async fn knot_main(arguments: cli::ServeArguments) -> anyhow::Result<()> {6262- unsafe { env::set_var("GIT_CONFIG_GLOBAL", &arguments.git_config) };6363-6464- let tempdir = tempfile::TempDir::with_prefix("gordian-knot-")?;6565- let hooks_path = if let Some(path) = &arguments.hooks {6666- // @TODO Verify hooks exist in the specified path.6767- tracing::warn!(?path, "assuming existence of hooks at path");6868- path.to_path_buf()6969- } else {7070- let path = tempdir.path().join("hooks");7171- hooks::setup_global_hooks(&path)?;7272- path7373- };7474-7575- assert!(git_config_global("core.hooksPath", &hooks_path)?);7676- assert!(git_config_global("receive.advertisePushOptions", "true")?);7777- if let Some(command) = &arguments.archive_bz2_command {7878- assert!(git_config_global("tar.tar.bz2.command", command)?);7979- }8080- if let Some(command) = &arguments.archive_xz_command {8181- assert!(git_config_global("tar.tar.xz.command", command)?);8282- }8383-8484- let database = {8585- let pool = {8686- let connect_options = SqliteConnectOptions::new()8787- .filename(&arguments.db)8888- .create_if_missing(true)8989- .foreign_keys(true)9090- .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal);9191-9292- SqlitePoolOptions::new()9393- .connect_with(connect_options)9494- .await?9595- };9696-9797- sqlx::migrate!().run(&pool).await?;9898- DataStore::new(pool)9999- };100100-101101- let public_http = reqwest::ClientBuilder::new()102102- .timeout(Duration::from_secs(2))103103- .user_agent(USER_AGENT)104104- .http2_keep_alive_while_idle(true)105105- .https_only(true)106106- .build()107107- .context("Failed to build public HTTP client")?;108108-109109- let resolver = arguments.init_resolver(public_http.clone());110110-111111- // Bind listeners for the public API.112112- let mut public_listeners = Vec::with_capacity(arguments.bind.len());113113- for addr in &arguments.bind {114114- for socket in addr.to_socket_addrs()? {115115- let listener = TcpListener::bind(socket).await?;116116- public_listeners.push(listener);117117- }118118- }119119-120120- // Bind listeners for the private API.121121- let mut private_listeners = Vec::with_capacity(2);122122- for socket in "localhost:0".to_socket_addrs()? {123123- let listener = TcpListener::bind(socket).await?;124124- private_listeners.push(listener);125125- }126126-127127- // The knot needs to know the sockets we've bound the private API.128128- let private_addrs = private_listeners129129- .iter()130130- .map(tokio::net::TcpListener::local_addr)131131- .collect::<Result<Vec<_>, std::io::Error>>()?;132132-133133- tracing::info!(?private_addrs, "bound internal API");134134-135135- let config: KnotConfiguration = arguments.to_knot_config()?;136136- let knot_state = KnotState::new(config, resolver, public_http, database, &private_addrs)?;137137- let knot = Knot::from(knot_state);138138-139139- // Ensure the knot owner's records are seeded.140140- knot.seed_owner()141141- .await142142- .context("seeding knot owner's records")?;143143-144144- let mut tasks = JoinSet::new();145145- let shutdown = CancellationToken::new();146146-147147- // Spawn the internal API.148148- tasks.spawn(gordian_knot::serve_all(149149- gordian_knot::private::router()150150- .layer(151151- ServiceBuilder::new()152152- .set_x_request_id(MakeRequestUuid)153153- .layer(154154- TraceLayer::new_for_http()155155- .make_span_with(PrivateHttpSpan)156156- .on_request(|_: &Request<_>, _: &Span| {})157157- .on_response(TraceResponse),158158- )159159- .propagate_x_request_id(),160160- )161161- .with_state(knot.clone()),162162- private_listeners,163163- shutdown.child_token(),164164- ));165165-166166- // Spawn the jetstream consumer.167167- tasks.spawn(168168- gordian_knot::services::jetstream::init_consumer(169169- &knot,170170- arguments.jetstream.as_slice(),171171- shutdown.child_token(),172172- )173173- .map(|_| Ok(())),174174- );175175-176176- // Build the public API.177177- let router = gordian_knot::public::router()178178- .layer(RequestDecompressionLayer::new())179179- .layer(180180- ServiceBuilder::new()181181- .set_x_request_id(MakeRequestUuid)182182- .layer(183183- TraceLayer::new_for_http()184184- .make_span_with(PublicHttpSpan)185185- .on_request(|_: &Request<_>, _: &Span| {})186186- .on_response(TraceResponse),187187- )188188- .propagate_x_request_id(),189189- )190190- .with_state(knot);191191-192192- tasks.spawn(gordian_knot::serve_all(193193- router,194194- public_listeners,195195- shutdown.child_token(),196196- ));197197-198198- tasks.spawn(wait_for_shutdown(shutdown));199199-200200- for task in tasks.join_all().await {201201- if let Err(error) = task {202202- tracing::error!(?error, "knot task completed with error");203203- }204204- }205205-206206- Ok(())207207-}208208-209209-async fn wait_for_shutdown(shutdown: CancellationToken) -> std::io::Result<()> {210210- let mut sigterm = signal::unix::signal(SignalKind::terminate())?;211211-212212- tokio::select! {213213- Ok(()) = signal::ctrl_c() => {214214- eprintln!();215215- tracing::info!("ctrl+c received, shutting down ...");216216- },217217- Some(()) = sigterm.recv() => {218218- tracing::info!("SIGTERM received, shutting down ...");219219- }220220- }221221-222222- shutdown.cancel();223223-224224- Ok(())225225-}226226-227227-fn git_config_global<K, V>(key: K, value: V) -> std::io::Result<bool>228228-where229229- K: AsRef<OsStr>,230230- V: AsRef<OsStr>,231231-{232232- use std::process::Stdio;233233-234234- let success = std::process::Command::new("/usr/bin/git")235235- .args(["config", "set", "--global"])236236- .arg(key)237237- .arg(value)238238- .stdout(Stdio::inherit())239239- .stderr(Stdio::inherit())240240- .spawn()?241241- .wait()?242242- .success();243243-244244- Ok(success)245245-}246246-247247-macro_rules! make_span {248248- ($name:ident, $label:literal) => {249249- #[derive(Clone)]250250- struct $name;251251-252252- impl<B> MakeSpan<B> for $name {253253- fn make_span(&mut self, request: &axum::http::Request<B>) -> tracing::Span {254254- let method = request.method();255255- let path = request.uri().path();256256-257257- let span = tracing::error_span!($label, id = Empty, method = Empty, path = Empty);258258- if let Some(id) = request259259- .extensions()260260- .get::<RequestId>()261261- .and_then(|request_id| request_id.header_value().to_str().ok())262262- {263263- span.record("id", &id);264264- }265265-266266- span.record("method", tracing::field::debug(&method));267267- span.record("path", tracing::field::debug(&path));268268-269269- span270270- }271271- }272272- };273273-}274274-275275-make_span!(PublicHttpSpan, "public");276276-make_span!(PrivateHttpSpan, "private");277277-278278-#[derive(Clone)]279279-pub struct TraceResponse;280280-281281-impl<B> OnResponse<B> for TraceResponse {282282- fn on_response(self, response: &Response<B>, latency: Duration, _: &Span) {283283- match response.status() {284284- status if status.is_success() => tracing::trace!(?status, ?latency),285285- status if status.is_client_error() => tracing::warn!(?status, ?latency),286286- status if status.is_server_error() => tracing::error!(?status, ?latency),287287- status => tracing::info!(?status, ?latency),288288- }289289- }2828+ cli::run();29029}