diff --git a/fluxer_media_proxy/Dockerfile b/fluxer_media_proxy/Dockerfile index 1464bd0b6..f6c87e188 100644 --- a/fluxer_media_proxy/Dockerfile +++ b/fluxer_media_proxy/Dockerfile @@ -80,7 +80,7 @@ WORKDIR /var/lib/fluxer EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD ["sh", "-c", "exec 3<>/dev/tcp/127.0.0.1/${FLUXER_MEDIA_PROXY_PORT:-8080} && printf 'GET /_health HTTP/1.0\\r\\n\\r\\n' >&3 && grep -q '200' <&3"] + CMD ["/usr/local/bin/fluxer-media-proxy", "healthcheck"] ENTRYPOINT ["/usr/bin/tini", "--"] CMD ["/usr/local/bin/fluxer-media-proxy"] diff --git a/fluxer_media_proxy/src/cli.rs b/fluxer_media_proxy/src/cli.rs index 0d920a88c..54750b50e 100644 --- a/fluxer_media_proxy/src/cli.rs +++ b/fluxer_media_proxy/src/cli.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later use crate::config::{Config, DeploymentMode, StorageBackend}; -use clap::{ArgAction, Parser, ValueEnum}; +use clap::{ArgAction, Parser, Subcommand, ValueEnum}; #[derive(Debug, Parser)] #[command(name = "fluxer-media-proxy", disable_help_subcommand = true)] @@ -23,6 +23,14 @@ pub struct Args { #[arg(long = "read-only", action = ArgAction::SetTrue)] pub read_only: bool, + + #[command(subcommand)] + pub command: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Subcommand)] +pub enum Command { + Healthcheck, } #[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] @@ -137,6 +145,22 @@ mod tests { assert!(cfg.read_only); } + #[test] + fn cli_parses_healthcheck_subcommand() { + assert_eq!( + Some(Command::Healthcheck), + Args::try_parse_from(["fluxer-media-proxy", "healthcheck"]) + .unwrap() + .command + ); + assert!( + Args::try_parse_from(["fluxer-media-proxy"]) + .unwrap() + .command + .is_none() + ); + } + #[test] fn cli_rejects_empty_bind_host() { let args = Args::try_parse_from(["fluxer-media-proxy", "--bind-host", ""]).unwrap(); diff --git a/fluxer_media_proxy/src/healthcheck.rs b/fluxer_media_proxy/src/healthcheck.rs new file mode 100644 index 000000000..41d10ce5a --- /dev/null +++ b/fluxer_media_proxy/src/healthcheck.rs @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use anyhow::Context as _; +use std::{ + env, + net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, + time::Duration, +}; + +pub async fn run() -> anyhow::Result<()> { + let addr = target( + env::var("FLUXER_MEDIA_PROXY_HOST").ok().as_deref(), + env::var("FLUXER_MEDIA_PROXY_PORT").ok().as_deref(), + )?; + probe(addr).await +} + +fn target(host: Option<&str>, port: Option<&str>) -> anyhow::Result { + let host = host + .map(str::trim) + .filter(|host| !host.is_empty()) + .unwrap_or("127.0.0.1"); + let ip = host + .parse::() + .with_context(|| format!("FLUXER_MEDIA_PROXY_HOST is not an IP address: {host}"))?; + let ip = match ip { + IpAddr::V4(ip) if ip.is_unspecified() => IpAddr::V4(Ipv4Addr::LOCALHOST), + IpAddr::V6(ip) if ip.is_unspecified() => IpAddr::V6(Ipv6Addr::LOCALHOST), + ip => ip, + }; + let port = match port.map(str::trim).filter(|port| !port.is_empty()) { + Some(port) => port + .parse::() + .with_context(|| format!("FLUXER_MEDIA_PROXY_PORT is not a port number: {port}"))?, + None => 8080, + }; + Ok(SocketAddr::new(ip, port)) +} + +async fn probe(addr: SocketAddr) -> anyhow::Result<()> { + let client = reqwest::Client::builder() + .connect_timeout(Duration::from_millis(500)) + .timeout(Duration::from_millis(2_000)) + .no_proxy() + .build()?; + let status = client + .get(format!("http://{addr}/_health")) + .send() + .await + .with_context(|| format!("health request to {addr} failed"))? + .status(); + anyhow::ensure!( + status == reqwest::StatusCode::OK, + "health returned {status}" + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn target_defaults_to_loopback() { + assert_eq!( + SocketAddr::from(([127, 0, 0, 1], 8080)), + target(Some("0.0.0.0"), None).unwrap() + ); + assert_eq!( + SocketAddr::from(([127, 0, 0, 1], 9000)), + target(None, Some("9000")).unwrap() + ); + assert_eq!( + "[::1]:8080".parse::().unwrap(), + target(Some("::"), Some("")).unwrap() + ); + assert!(target(Some("0.0.0.0"), Some("nope")).is_err()); + } + + #[tokio::test] + async fn probe_accepts_ok() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let app = axum::Router::new().route("/_health", axum::routing::get(async || "OK")); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + probe(addr).await.unwrap(); + } + + #[tokio::test] + async fn probe_rejects_non_ok() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let app = axum::Router::new().route( + "/_health", + axum::routing::get(async || axum::http::StatusCode::SERVICE_UNAVAILABLE), + ); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + assert!(probe(addr).await.is_err()); + } +} diff --git a/fluxer_media_proxy/src/lib.rs b/fluxer_media_proxy/src/lib.rs index 4a604b96b..21ac408ae 100644 --- a/fluxer_media_proxy/src/lib.rs +++ b/fluxer_media_proxy/src/lib.rs @@ -11,6 +11,7 @@ pub mod config; pub mod constants; pub mod disposition; pub mod external_path; +pub mod healthcheck; pub mod http_client; pub mod http_headers; pub mod media_process; diff --git a/fluxer_media_proxy/src/main.rs b/fluxer_media_proxy/src/main.rs index 79e0edeba..87f73f50f 100644 --- a/fluxer_media_proxy/src/main.rs +++ b/fluxer_media_proxy/src/main.rs @@ -1,17 +1,21 @@ // SPDX-License-Identifier: AGPL-3.0-or-later use clap::Parser; -use fluxer_media_proxy::{cli, run}; +use fluxer_media_proxy::{cli, healthcheck, run}; use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt}; #[tokio::main(flavor = "multi_thread")] async fn main() -> anyhow::Result<()> { + let args = cli::Args::parse(); + if matches!(args.command, Some(cli::Command::Healthcheck)) { + return healthcheck::run().await; + } + tracing_subscriber::registry() .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"))) .with(tracing_subscriber::fmt::layer().json()) .init(); - let args = cli::Args::parse(); let cfg = cli::load_config(&args)?; run(cfg).await }