Skip to content
Merged
12 changes: 12 additions & 0 deletions crates/walgit-config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1085,6 +1085,10 @@ impl Config {
pub fn validate(&self) -> Result<()> {
self.refs.validate()?;
self.packs.validate()?;
anyhow::ensure!(
self.server.max_concurrent_per_repo > 0,
"server.max_concurrent_per_repo must be positive"
);
anyhow::ensure!(
self.packfile_uri.uri_min_bytes.as_u64() > 0,
"packfile_uri.uri_min_bytes must be positive"
Expand Down Expand Up @@ -1360,6 +1364,14 @@ mod tests {
assert_eq!(back.store.bucket, c.store.bucket);
}

#[test]
fn zero_per_repo_concurrency_is_rejected() {
let mut c = Config::default();
c.server.max_concurrent_per_repo = 0;
let err = c.validate().unwrap_err().to_string();
assert!(err.contains("server.max_concurrent_per_repo must be positive"));
}

#[test]
fn example_configurations_parse_and_validate() {
Config::parse(include_str!("../../../walgit.example.toml")).unwrap();
Expand Down
23 changes: 13 additions & 10 deletions crates/walgit-git/src/pack_segments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -637,16 +637,19 @@ fn append_index(
}

fn sort(input: &Path, output: &Path, work: &Path, resources: Resources) -> Result<(), GitError> {
run(
Command::new("sort")
.args(["--stable", "--unique", "--key=1,1", "--parallel=1"])
.arg(format!("--buffer-size={}", resources.sort_memory_bytes))
.arg("--temporary-directory")
.arg(work),
input,
output,
work,
)
run(&mut sort_command(work, resources), input, output, work)
}

/// GNU and BSD `sort` both read a bare `--buffer-size` number as KiB; the `b`
/// suffix keeps the budget in bytes instead of 1024 times larger.
fn sort_command(work: &Path, resources: Resources) -> Command {
let mut command = Command::new("sort");
command
.args(["--stable", "--unique", "--key=1,1", "--parallel=1"])
.arg(format!("--buffer-size={}b", resources.sort_memory_bytes))
.arg("--temporary-directory")
.arg(work);
command
}

fn difference(a: &Path, b: &Path, output: &Path, work: &Path) -> Result<(), GitError> {
Expand Down
9 changes: 9 additions & 0 deletions crates/walgit-git/src/pack_segments_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,15 @@ async fn empty_groups_conserve_everything_and_invalid_budgets_fail() {
);
}

#[test]
fn sort_budget_is_passed_in_bytes() {
let command = sort_command(Path::new("work"), resources());
assert!(
command.get_args().any(|a| a == "--buffer-size=1048576b"),
"{command:?}"
);
}

#[test]
fn type_split_keeps_hint_bytes_and_rejects_missing_objects() {
let root = tempfile::tempdir().unwrap();
Expand Down
31 changes: 26 additions & 5 deletions crates/walgit-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,12 @@ use std::future::Future;
use std::net::SocketAddr;
use std::sync::Arc;

use axum::Router;
use axum::body::{Body, to_bytes};
use axum::extract::ConnectInfo;
use axum::http::{Method, Request};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::{Extension, Router};
use bytes::Bytes;
use metrics_exporter_prometheus::PrometheusHandle;
use walgit_store::DynStore;
Expand All @@ -89,7 +89,8 @@ pub struct AppState {
/// HTTP requests in flight (counted until the response body is done); on the watchdog line.
pub inflight: Arc<middleware::Inflight>,
pub caches: cache::ServerCaches,
pub metrics_handle: Arc<PrometheusHandle>,
/// Prometheus recorder handle. Absent when `telemetry.metrics` is disabled.
pub metrics_handle: Option<Arc<PrometheusHandle>>,
/// Read-through LFS upstream client (`upstream.lfs`).
pub lfs_upstream: lfs_upstream::Upstream,
/// Startup prewarm state (gates /readyz when configured).
Expand All @@ -112,7 +113,11 @@ impl AppState {
) -> anyhow::Result<Arc<Self>> {
let registry = walgit_wal::Registry::new(store.clone(), cfg.clone());
let bridge = bridge::Bridge::new(&cfg, registry.clone());
let metrics_handle = metrics::install()?;
let metrics_handle = if cfg.telemetry.metrics {
Some(metrics::install()?)
} else {
None
};
let tls = tls::load(&cfg)?;
if let Some(t) = &tls {
tracing::info!(fingerprint = %t.fingerprint, mode = ?cfg.server.tls.mode, "TLS terminated in-process");
Expand Down Expand Up @@ -148,7 +153,7 @@ pub fn router(state: Arc<AppState>) -> Router {
.gzip(true)
.quality(tower_http::CompressionLevel::Fastest);
// Nothing with content is public: the SPA shell/assets, installer, credential
// helper and metrics all sit behind `web::require_auth` (identity from a
// helper and enabled metrics all sit behind `web::require_auth` (identity from a
// bearer token or a session cookie). The JSON API and git
// endpoints do their own `require_read`/`require_write`; `/_auth/*` is the
// login flow itself; `/services/public/*` is the installer a not-yet-signed-in
Expand All @@ -162,11 +167,26 @@ pub fn router(state: Arc<AppState>) -> Router {
.with_state(())
.layer(web_compression.clone()),
)
.route("/metrics", get(metrics::metrics_route))
.layer(axum::middleware::from_fn_with_state(
state.clone(),
web::require_auth,
));
let metrics = if let Some(metrics_handle) = &state.metrics_handle {
Router::new()
.route("/metrics", get(metrics::metrics_route))
.layer(Extension(metrics_handle.clone()))
.layer(axum::middleware::from_fn_with_state(
state.clone(),
web::require_auth,
))
} else {
// Reserve the top-level name so the SPA's `/{owner}` route cannot turn a
// disabled scrape endpoint into a successful HTML response.
Router::new().route(
"/metrics",
get(|| async { axum::http::StatusCode::NOT_FOUND }),
)
};

Router::new()
.merge(
Expand All @@ -180,6 +200,7 @@ pub fn router(state: Arc<AppState>) -> Router {
.layer(web_compression),
)
.merge(gated)
.merge(metrics)
.route("/healthz", get(health::healthz))
.route("/readyz", get(health::readyz))
// The SDK is a static artefact with no data in it; it must load from a
Expand Down
16 changes: 8 additions & 8 deletions crates/walgit-server/src/metrics.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
//! Prometheus metrics exporter. Installs a recorder up front (once per process)
//! and exposes the rendered scrape via `/metrics`.
//! Prometheus metrics exporter. When enabled, installs a recorder once per
//! process and exposes the rendered scrape via `/metrics`.

use std::sync::{Arc, OnceLock};

use axum::extract::State;
use axum::Extension;
use axum::response::IntoResponse;
use metrics_exporter_prometheus::PrometheusHandle;

use crate::AppState;

static HANDLE: OnceLock<Arc<PrometheusHandle>> = OnceLock::new();

/// Install the Prometheus recorder once per process and return a shared handle.
Expand All @@ -32,13 +30,15 @@ pub fn install() -> anyhow::Result<Arc<PrometheusHandle>> {
}

/// `GET /metrics`
pub async fn metrics_route(State(st): State<Arc<AppState>>) -> impl IntoResponse {
let body = st.metrics_handle.render();
pub async fn metrics_route(
Extension(handle): Extension<Arc<PrometheusHandle>>,
) -> impl IntoResponse {
(
[(
axum::http::header::CONTENT_TYPE,
"text/plain; version=0.0.4",
)],
body,
handle.render(),
)
.into_response()
}
22 changes: 22 additions & 0 deletions crates/walgit-server/tests/metrics.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
mod harness;

use axum::http::StatusCode;

use harness::Server;

type TestResult = anyhow::Result<()>;

#[tokio::test]
async fn metrics_config_controls_scrape_endpoint() -> TestResult {
let disabled = Server::start_with_tweak(|cfg| cfg.telemetry.metrics = false).await?;
assert!(disabled.state.metrics_handle.is_none());
let response = reqwest::get(format!("{}/metrics", disabled.base_url)).await?;
assert_eq!(response.status(), StatusCode::NOT_FOUND);

let enabled = Server::start_with_tweak(|cfg| cfg.telemetry.metrics = true).await?;
assert!(enabled.state.metrics_handle.is_some());
let response = reqwest::get(format!("{}/metrics", enabled.base_url)).await?;
assert_eq!(response.status(), StatusCode::OK);

Ok(())
}
34 changes: 32 additions & 2 deletions crates/walgit-store/src/gcs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1201,9 +1201,13 @@ fn is_retryable(e: &google_cloud_storage::Error) -> bool {
);
}
if let Some(code) = e.http_status_code() {
return matches!(code, 503 | 504 | 429 | 500);
// The statuses GCS documents as retryable. The client's own policy
// retries all of 5xx; 501 is left out because an unimplemented
// request will not start working on a second try. Same set as
// `s3::is_transient_status`.
return matches!(code, 408 | 429 | 500 | 502 | 503 | 504);
}
e.is_connect() || e.is_io() || e.is_transient_and_before_rpc()
e.is_connect() || e.is_io() || e.is_timeout() || e.is_transient_and_before_rpc()
}

fn map_error(key: &str, e: google_cloud_storage::Error) -> StoreError {
Expand Down Expand Up @@ -1384,6 +1388,32 @@ mod tests {
assert!(is_retryable(&e));
}

fn http_error(status: u16) -> google_cloud_storage::Error {
google_cloud_gax::error::Error::http(status, http::HeaderMap::new(), bytes::Bytes::new())
}

#[test]
fn transient_http_statuses_are_retryable() {
for status in [408, 429, 500, 502, 503, 504] {
assert!(
is_retryable(&http_error(status)),
"{status} should be retryable"
);
}
for status in [400, 403, 404, 409, 412, 501] {
assert!(
!is_retryable(&http_error(status)),
"{status} should be permanent"
);
}
}

#[test]
fn is_retryable_timeout() {
let e = google_cloud_gax::error::Error::timeout("attempt timed out");
assert!(is_retryable(&e));
}

#[test]
fn is_not_retryable_not_found() {
let e = google_cloud_gax::error::Error::service(
Expand Down
Loading
Loading