From 7a13847c70454d15f443f889e0f88601c8670ac2 Mon Sep 17 00:00:00 2001 From: Nat Manley Date: Thu, 20 Aug 2026 14:16:24 +0100 Subject: [PATCH] Add --validate-config so the image can check its own config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit manifest-validator is growing a check that runs an application's own image over the ConfigMap a manifest tree carries, because the application is the only thing that can say whether its config means anything. idcat already had the deciding code — Config::load then validate, both pure and offline — with no way to reach it but starting the service. --disable-auth composes with the flag rather than being ignored under it: a config that only validates with auth disabled must not pass a check run without. --- README.md | 12 ++++++++ src/main.rs | 81 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a1daf26..49072a6 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,18 @@ Use `--debug` to log detailed installation-token flow steps: cargo run -- --config-file idcat.toml --debug ``` +`--validate-config` checks the config and exits, without binding a socket, +reaching KMS or contacting GitHub. It exits 0 for a valid config and non-zero +with the reason otherwise, which is what manifest-validator runs this image for +before a config change reaches a cluster: + +```sh +cargo run -- --config-file idcat.toml --validate-config +``` + +`--disable-auth` composes with it, because a config that is only valid with auth +disabled must not pass a check run without it. + GitHub App installation IDs are cached in memory after the first lookup. Installation tokens are cached in memory for 50 minutes per GitHub app and repo. diff --git a/src/main.rs b/src/main.rs index 3c15177..cb0f5ce 100644 --- a/src/main.rs +++ b/src/main.rs @@ -50,6 +50,9 @@ struct Cli { config_path: String, #[arg(long = "disable-auth", default_value_t = false)] disable_auth: bool, + /// Check the config and exit, without binding a socket or reaching KMS. + #[arg(long = "validate-config", default_value_t = false)] + validate_config: bool, #[arg(long = "debug", default_value_t = false)] debug: bool, } @@ -75,7 +78,17 @@ async fn main() -> anyhow::Result<()> { Ok(()) } +fn validate_config(path: &str, disable_auth: bool) -> anyhow::Result<()> { + Config::load(path)?.validate(disable_auth) +} + async fn run(cli: Cli) -> anyhow::Result<()> { + if cli.validate_config { + validate_config(&cli.config_path, cli.disable_auth)?; + info!(config_path = %cli.config_path, "config is valid"); + return Ok(()); + } + let config = Config::load(&cli.config_path)?; config.validate(cli.disable_auth)?; let bind_address: SocketAddr = config.bind_address.parse()?; @@ -471,7 +484,7 @@ fn should_return_proxy_header(name: &HeaderName) -> bool { #[cfg(test)] mod tests { - use super::webhook; + use super::{validate_config, webhook}; use crate::config::{GithubAppConfig, KeySource}; use crate::error::AppError; use crate::github::GithubClient; @@ -480,6 +493,7 @@ mod tests { use axum::body::Bytes; use axum::extract::{Path, State}; use axum::http::{HeaderMap, StatusCode}; + use std::path::PathBuf; use std::sync::Arc; fn test_state(github_apps: Vec) -> AppState { @@ -585,4 +599,69 @@ mod tests { std::fs::remove_file(&secret_file).ok(); assert_eq!(status, StatusCode::ACCEPTED); } + + const VALID: &str = r#" +bind-address = "0.0.0.0:8080" + +[[role]] +name = "github-workflow" +audience = "idcat" +issuer = "https://token.actions.githubusercontent.com" +validation-key = "shared-secret" +algorithms = ["HS256"] + +[[github-app]] +name = "deployments" +app-id = 42 +secret-key = "private-key.pem" +allowed-roles = ["github-workflow"] +"#; + + fn write_config(name: &str, content: &str) -> PathBuf { + let path = std::env::temp_dir().join(format!("idcat-{name}.toml")); + std::fs::write(&path, content).unwrap(); + path + } + + #[test] + fn validate_config_accepts_a_valid_config() { + let path = write_config("valid", VALID); + + validate_config(path.to_str().unwrap(), false).unwrap(); + } + + #[test] + fn validate_config_rejects_a_config_with_no_github_app() { + let content = VALID.split("[[github-app]]").next().unwrap(); + let path = write_config("no-github-app", content); + + let error = validate_config(path.to_str().unwrap(), false).unwrap_err(); + + assert!( + error.to_string().contains("github-app"), + "unexpected error: {error}" + ); + } + + #[test] + fn validate_config_reports_where_a_missing_file_was_looked_for() { + let error = validate_config("/nonexistent/idcat.toml", false).unwrap_err(); + + assert!( + error.to_string().contains("/nonexistent/idcat.toml"), + "unexpected error: {error}" + ); + } + + #[test] + fn validate_config_accepts_a_roleless_config_only_with_auth_disabled() { + let content = VALID.split("[[role]]").next().unwrap().to_string() + + VALID.split_once("[[github-app]]").unwrap().1; + let content = format!("[[github-app]]{content}"); + let path = write_config("no-roles", &content); + let path = path.to_str().unwrap(); + + validate_config(path, false).unwrap_err(); + validate_config(path, true).unwrap(); + } }