diff --git a/Cargo.lock b/Cargo.lock index 8f7d692d..0fb01af2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2999,6 +2999,7 @@ dependencies = [ "env_logger", "git-proc", "hex", + "http 1.4.0", "humantime-serde", "indexmap 2.14.0", "indoc", @@ -3010,6 +3011,7 @@ dependencies = [ "pg-client", "rand 0.10.1", "rcgen", + "reqwest", "rustix", "semver", "serde", @@ -3019,6 +3021,8 @@ dependencies = [ "time", "tokio", "toml", + "typed-reqwest", + "url", "x509-parser", ] diff --git a/ociman/src/backend.rs b/ociman/src/backend.rs index 3fa825cd..1201c4b5 100644 --- a/ociman/src/backend.rs +++ b/ociman/src/backend.rs @@ -1,5 +1,158 @@ use cmd_proc::*; +/// Substring used to detect the OCI distribution-spec `MANIFEST_UNKNOWN` +/// error code as rendered on stderr by docker, podman, and skopeo when a +/// registry reports that a tag does not exist. +/// +/// **This is load-bearing string matching and we do not like it.** We fall +/// back on it because there is no better option available today: +/// +/// - Neither `docker pull` nor `podman pull` has a `--json` / `--format` +/// flag. The CLIs only expose human-readable stderr. +/// - Exit codes are useless: both tools return `1` (or `125` for podman) +/// for every failure mode — not-found, auth, network, tls — without +/// discrimination. +/// - The docker/podman engine REST APIs do stream NDJSON, but the error +/// `message` / `errorDetail.message` fields contain the same human +/// string (`... manifest unknown`) — the daemons do not surface the +/// registry's structured error code — so switching to the socket would +/// just move the substring match from stderr to a JSON field, at the +/// cost of a ~400KB HTTP client dependency. No actual signal gain. +/// - `docker manifest inspect` still requires `experimental: enabled` as +/// of Docker 28, and `podman manifest inspect` is local-only. `skopeo +/// inspect` is clean but is a separate binary not always installed +/// (Docker Desktop ships without it). +/// - The only path to a clean spec-defined signal is talking to the +/// registry HTTP API directly, which means reimplementing bearer-token +/// auth, cred-helper integration, and auth challenges — substantial +/// work for a library that otherwise just shells out to the CLI. +/// +/// The OCI Distribution Spec v1.1.0 defines the `MANIFEST_UNKNOWN` error +/// code (code-7) that registries MUST return when a manifest is absent: +/// +/// +/// **However the spec does not mandate this stderr string.** The spec only +/// mandates the uppercase `code` field in the registry's JSON response; +/// the human-readable `message` field is OPTIONAL and its content is +/// unspecified. The lowercase `"manifest unknown"` substring this constant +/// matches is a de-facto convention that docker, podman, and skopeo all +/// happen to use when rendering the error to stderr. If a future CLI +/// version changes its wording, this constant must be updated and a +/// corresponding test will break. +const MANIFEST_UNKNOWN_STDERR_SIGNAL: &str = "manifest unknown"; + +// `image::Reference` is ~176 bytes (Name + Vec of PathComponents + Tag + +// Digest), so carrying it inline pushes this enum past the 128-byte +// `clippy::result_large_err` threshold. We carry it inline anyway and allow +// the lint at the call sites: these errors only arise from cold subprocess +// paths (spawning docker/podman, awaiting a registry round-trip), never a +// hot loop where moving a large `Result` by value would matter. Boxing the +// reference would buy nothing here but an allocation and `Box::new`/deref +// ceremony. +#[derive(Debug, thiserror::Error)] +pub enum PullError { + #[error("image not found in registry: {reference}")] + NotFound { reference: crate::image::Reference }, + #[error("pull failed for {reference}: {message}")] + Other { + reference: crate::image::Reference, + message: String, + }, + /// The pull subprocess could not be spawned or failed at the IO layer + /// (e.g. the docker/podman binary is missing, or the daemon socket is + /// unreachable) — distinct from a registry-level pull failure. Surfaced + /// rather than aborting the process so `?`-propagating callers can react. + #[error("pull command failed to run for {reference}")] + Command { + reference: crate::image::Reference, + #[source] + source: cmd_proc::CommandError, + }, + /// `pull_image_if_absent` consults [`Backend::is_image_present`] to + /// decide whether to skip the pull. If that probe itself fails, + /// surface the failure here rather than masking it as a successful + /// "absent → pull". + #[error(transparent)] + ImagePresent(#[from] ImagePresentError), +} + +/// Turn a pull subprocess exit status + captured stderr into a +/// [`PullError`] (or [`Ok`] on success). +/// +/// Split out from [`Backend::pull_image`] so it can be unit-tested with +/// canned stderr bytes — no network, no daemon, no registry. +#[allow( + clippy::result_large_err, + reason = "PullError carries the image Reference inline; only constructed on cold subprocess error paths (see the type's comment)" +)] +fn classify_pull_result( + reference: &crate::image::Reference, + success: bool, + stderr: &[u8], +) -> Result<(), PullError> { + if success { + return Ok(()); + } + + let stderr = String::from_utf8_lossy(stderr); + if stderr.contains(MANIFEST_UNKNOWN_STDERR_SIGNAL) { + Err(PullError::NotFound { + reference: reference.clone(), + }) + } else { + Err(PullError::Other { + reference: reference.clone(), + message: stderr.trim().to_string(), + }) + } +} + +// Carries `image::Reference` inline and is allowed past +// `clippy::result_large_err` for the same reason as [`PullError`] — see +// that type's comment. +#[derive(Debug, thiserror::Error)] +pub enum PushError { + #[error("push failed for {reference}: {message}")] + Failed { + reference: crate::image::Reference, + message: String, + }, + /// The push subprocess could not be spawned or failed at the IO layer + /// (e.g. the docker/podman binary is missing, or the daemon socket is + /// unreachable) — distinct from a registry-level push failure. Surfaced + /// rather than aborting the process so `?`-propagating callers can react. + #[error("push command failed to run for {reference}")] + Command { + reference: crate::image::Reference, + #[source] + source: cmd_proc::CommandError, + }, +} + +/// Turn a push subprocess exit status + captured stderr into a +/// [`PushError`] (or [`Ok`] on success). +/// +/// Split out from [`Backend::push_image`] for the same reason as +/// [`classify_pull_result`] — unit-testable without a network or daemon. +#[allow( + clippy::result_large_err, + reason = "PushError carries the image Reference inline; only constructed on cold subprocess error paths (see the type's comment)" +)] +fn classify_push_result( + reference: &crate::image::Reference, + success: bool, + stderr: &[u8], +) -> Result<(), PushError> { + if success { + return Ok(()); + } + + Err(PushError::Failed { + reference: reference.clone(), + message: String::from_utf8_lossy(stderr).trim().to_string(), + }) +} + #[derive(Copy, Clone, Debug, Eq, PartialEq, serde::Deserialize, clap::ValueEnum)] #[serde(rename_all = "snake_case")] pub enum Selection { @@ -213,33 +366,76 @@ impl Backend { .unwrap(); } - /// Pull an image from a registry - pub async fn pull_image(&self, reference: &crate::image::Reference) { - self.command() + /// Pull an image from a registry. + /// + /// Stdout streams to the parent so users see layer progress. Stderr is + /// captured and parsed to distinguish [`PullError::NotFound`] (registry + /// reports `manifest unknown`) from other failures. + #[allow( + clippy::result_large_err, + reason = "PullError carries the image Reference inline; cold subprocess path (see the type's comment)" + )] + pub async fn pull_image(&self, reference: &crate::image::Reference) -> Result<(), PullError> { + let output = self + .command() .arguments(["image", "pull", &reference.to_string()]) - .status() + .stderr_capture() + .accept_nonzero_exit() + .run() .await - .unwrap(); + .map_err(|source| PullError::Command { + reference: reference.clone(), + source, + })?; + + classify_pull_result(reference, output.status.success(), &output.bytes) } - /// Pull an image only if it's not already present. + /// Pull an image only if it's not already present locally. + /// + /// If the local-presence probe itself fails, the [`ImagePresentError`] + /// is propagated through [`PullError::ImagePresent`] rather than + /// silently falling through to a pull attempt. + #[allow( + clippy::result_large_err, + reason = "PullError carries the image Reference inline; cold subprocess path (see the type's comment)" + )] pub async fn pull_image_if_absent( &self, reference: &crate::image::Reference, - ) -> Result<(), ImagePresentError> { - if !self.is_image_present(reference).await? { - self.pull_image(reference).await; + ) -> Result<(), PullError> { + if self.is_image_present(reference).await? { + Ok(()) + } else { + self.pull_image(reference).await } - Ok(()) } - /// Push an image to a registry - pub async fn push_image(&self, reference: &crate::image::Reference) { - self.command() + /// Push an image to a registry. + /// + /// Stdout streams to the parent so users see upload progress. Stderr + /// is captured and surfaced as [`PushError`] on non-zero exit. Unlike + /// pull, there's no useful sub-discrimination here: every push failure + /// (auth, network, rate limit, missing local image) collapses into the + /// same "it didn't upload" outcome as far as callers are concerned. + #[allow( + clippy::result_large_err, + reason = "PushError carries the image Reference inline; cold subprocess path (see the type's comment)" + )] + pub async fn push_image(&self, reference: &crate::image::Reference) -> Result<(), PushError> { + let output = self + .command() .arguments(["image", "push", &reference.to_string()]) - .status() + .stderr_capture() + .accept_nonzero_exit() + .run() .await - .unwrap(); + .map_err(|source| PushError::Command { + reference: reference.clone(), + source, + })?; + + classify_push_result(reference, output.status.success(), &output.bytes) } pub async fn remove_image(&self, reference: &crate::image::Reference) { @@ -1142,6 +1338,89 @@ pub mod resolve { mod tests { use super::*; + fn pull_test_reference() -> crate::image::Reference { + "ghcr.io/myorg/pg-ephemeral/main:abc123".parse().unwrap() + } + + #[test] + fn test_classify_pull_result_success() { + let reference = pull_test_reference(); + assert!(classify_pull_result(&reference, true, b"").is_ok()); + } + + #[test] + fn test_classify_pull_result_not_found_podman() { + let reference = pull_test_reference(); + // Representative podman stderr for a non-existent tag. + let stderr = b"Error: initializing source docker://ghcr.io/myorg/pg-ephemeral/main:abc123: reading manifest abc123 in ghcr.io/myorg/pg-ephemeral/main: manifest unknown"; + match classify_pull_result(&reference, false, stderr) { + Err(PullError::NotFound { + reference: error_reference, + }) => assert_eq!(error_reference, reference), + other => panic!("expected PullError::NotFound, got {other:?}"), + } + } + + #[test] + fn test_classify_pull_result_not_found_docker() { + let reference = pull_test_reference(); + // Representative docker stderr for a non-existent tag. + let stderr = b"Error response from daemon: manifest for ghcr.io/myorg/pg-ephemeral/main:abc123 not found: manifest unknown: manifest unknown"; + match classify_pull_result(&reference, false, stderr) { + Err(PullError::NotFound { + reference: error_reference, + }) => assert_eq!(error_reference, reference), + other => panic!("expected PullError::NotFound, got {other:?}"), + } + } + + #[test] + fn test_classify_pull_result_auth_failure_is_other() { + let reference = pull_test_reference(); + // Auth failure must NOT be misclassified as NotFound. + let stderr = b"Error response from daemon: pull access denied for ghcr.io/myorg/pg-ephemeral/main, repository does not exist or may require 'docker login': denied: requested access to the resource is denied"; + match classify_pull_result(&reference, false, stderr) { + Err(PullError::Other { + reference: error_reference, + message, + }) => { + assert_eq!(error_reference, reference); + assert!(message.contains("denied")); + } + other => panic!("expected PullError::Other, got {other:?}"), + } + } + + #[test] + fn test_classify_pull_result_network_error_is_other() { + let reference = pull_test_reference(); + let stderr = b"Error response from daemon: Get https://ghcr.io/v2/: dial tcp: lookup ghcr.io: no such host"; + let result = classify_pull_result(&reference, false, stderr); + assert!(matches!(result, Err(PullError::Other { .. }))); + } + + #[test] + fn test_classify_push_result_success() { + let reference = pull_test_reference(); + assert!(classify_push_result(&reference, true, b"").is_ok()); + } + + #[test] + fn test_classify_push_result_failure_captures_stderr() { + let reference = pull_test_reference(); + let stderr = b"unauthorized: authentication required\n"; + match classify_push_result(&reference, false, stderr) { + Err(PushError::Failed { + reference: error_reference, + message, + }) => { + assert_eq!(error_reference, reference); + assert_eq!(message, "unauthorized: authentication required"); + } + other => panic!("expected PushError::Failed, got {other:?}"), + } + } + #[tokio::test] async fn test_container_resolver_localhost() { let backend = crate::test_backend_setup!(); diff --git a/pg-ephemeral/CHANGELOG.md b/pg-ephemeral/CHANGELOG.md index eef66464..10b1348b 100644 --- a/pg-ephemeral/CHANGELOG.md +++ b/pg-ephemeral/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## Unreleased + +### Added + +- `cache_registry` config field and `--cache-registry` CLI flag. When set, + all cache image references are prefixed with the given OCI registry name + (e.g. `ghcr.io/myorg`), so cache images can be pushed and pulled to share + warm cache state across machines. The cache key hash is unaffected — + different registries do not fragment the cache. +- `pg-ephemeral cache pull` subcommand. Walks the seed chain from tip + backwards and pulls the newest stage that exists in the configured + registry, then stops. Requires `cache_registry` to be set. +- `pg-ephemeral cache push` subcommand. Pushes every locally-cached + stage to the configured registry. Requires `cache_registry` to be set. + +### Changed + +- CLI errors are now printed using their user-facing `Display` form + (e.g. "Error: cache_registry must be set …") instead of the internal + `Debug` form (e.g. "CacheSync(RegistryNotSet)"). + ## 0.5.1 ### Added diff --git a/pg-ephemeral/Cargo.toml b/pg-ephemeral/Cargo.toml index c1c27c07..06f92d7c 100644 --- a/pg-ephemeral/Cargo.toml +++ b/pg-ephemeral/Cargo.toml @@ -46,7 +46,11 @@ toml.workspace = true x509-parser = { version = "0.18", features = ["verify"] } [dev-dependencies] +http.workspace = true indoc.workspace = true +reqwest.workspace = true +typed-reqwest.workspace = true +url.workspace = true [[bin]] name = "pg-ephemeral" diff --git a/pg-ephemeral/README.md b/pg-ephemeral/README.md index 12edacb3..ddb98650 100644 --- a/pg-ephemeral/README.md +++ b/pg-ephemeral/README.md @@ -93,6 +93,7 @@ cache = { type = "none" } |--------------------------|----------------------------------------------------------------------| | `image` | PostgreSQL version / image tag (e.g. `"17.1"`) | | `backend` | `"docker"`, `"podman"`, or omit for auto-detection (see below) | +| `cache_registry` | OCI registry prefix for cache images (e.g. `"ghcr.io/myorg"`). See [Sharing cache across machines](#sharing-cache-across-machines). | | `ssl_config` | SSL configuration with `hostname` field ([example](https://github.com/mbj/mrs/tree/main/pg-ephemeral/examples/08-ssl)) | | `wait_available_timeout` | How long to wait for PostgreSQL to accept connections (e.g. `"30s"`) | @@ -232,6 +233,12 @@ pg-ephemeral cache inspect pg-ephemeral/main: # Pre-populate the cache without running an interactive session pg-ephemeral cache populate +# Pull cache images from the configured registry (requires cache_registry) +pg-ephemeral cache pull + +# Push locally-cached stages to the configured registry (requires cache_registry) +pg-ephemeral cache push + # Remove cached images pg-ephemeral cache reset @@ -257,6 +264,57 @@ into the cache key; the strategy layers additional inputs on top: | `{ type = "key-script", script = "..." }` | Run a script; its stdout is folded into the cache key alongside the seed content. | | `{ type = "none" }` | Disable caching. Breaks the cache chain for this and all subsequent seeds. | +### Sharing cache across machines + +By default, cache images are named `pg-ephemeral/:` and live +only in the local Docker/Podman image store. Set `cache_registry` to a remote +OCI registry prefix and every cache image gains that prefix, so references +become push/pullable addresses in a registry you can share across machines +(CI runners, developer laptops, production build hosts): + +```toml +image = "17.1" +cache_registry = "ghcr.io/myorg" + +[instances.main.seeds.schema] +type = "sql-file" +path = "schema.sql" +``` + +The `cache_registry` value can be any valid OCI registry name — just a host +(`ghcr.io`), a host plus namespace (`ghcr.io/myorg`), or a private registry +(`registry.example.com:5000/team`). `pg-ephemeral cache status` will now +report references like `ghcr.io/myorg/pg-ephemeral/main:`. + +**The cache key hash is not affected by `cache_registry`.** Two machines +pointed at different registries still compute the same hex for the same +content, and switching a project from no registry to a registry (or between +registries) does not invalidate any existing cache. + +Once `cache_registry` is set, use the two dedicated subcommands to move +cache images between the local image store and the remote registry: + +```sh +# Pull the newest cached stage from the registry that exists remotely. +# Walks the seed chain from tip backwards and stops on the first hit. +pg-ephemeral cache pull + +# Populate anything still missing locally, then push everything that's +# now cached locally to the registry. Typical CI shape: +pg-ephemeral cache pull && pg-ephemeral cache populate && pg-ephemeral cache push +``` + +Registry authentication is handled entirely by the underlying container +CLI (`docker login`, `podman login`, or cred-helper integration) — no +pg-ephemeral-specific setup required. + +You can override the registry on a single invocation with `--cache-registry` +without editing `database.toml`: + +```sh +pg-ephemeral --cache-registry ghcr.io/myorg cache pull +``` + ## Named Sessions A **session** is a long-running pg-ephemeral container kept alive between CLI @@ -607,7 +665,7 @@ Commands: bin Run an image tool against the cwd, without booting PostgreSQL host Operations executed on the host (psql, run-env, shell, schema-dump) container Operations executed inside the container (psql, run-env, shell, schema-dump) - cache Cache management (status, credentials, inspect, populate, reset) + cache Cache management (status, credentials, inspect, populate, pull, push, reset) session Named long-running sessions (list, start, stop, status, psql/shell/run-env/schema-dump/pgbench, host, container) integration-server Run integration server (pipe-based control protocol) @@ -623,12 +681,13 @@ executes as the host user. Use `host ` for a host-side process, or When invoked with no subcommand, pg-ephemeral defaults to `psql`. Options: - --config-file Config file path (default: database.toml) - --no-config-file Use defaults, ignore any config file - --backend Override backend (docker, podman) - --image Override PostgreSQL image - --ssl-hostname Enable SSL with the specified hostname - --instance Target instance (default: main) + --config-file Config file path (default: database.toml) + --no-config-file Use defaults, ignore any config file + --backend Override backend (docker, podman) + --cache-registry Override cache_registry from config (e.g. ghcr.io/myorg) + --image Override PostgreSQL image + --ssl-hostname Enable SSL with the specified hostname + --instance Target instance (default: main) ``` ## How it compares to testcontainers diff --git a/pg-ephemeral/src/cli.rs b/pg-ephemeral/src/cli.rs index c64e10da..5a41de77 100644 --- a/pg-ephemeral/src/cli.rs +++ b/pg-ephemeral/src/cli.rs @@ -26,6 +26,8 @@ pub enum Error { EnvVariableValue(#[from] cmd_proc::EnvVariableValueError), #[error(transparent)] EnvVariableName(#[from] cmd_proc::EnvVariableNameError), + #[error(transparent)] + CacheSync(#[from] crate::definition::CacheSyncError), #[error("Unknown instance: {0}")] UnknownInstance(InstanceName), #[error("Instance {instance} has no seeds; cache credentials requires a cacheable seed")] @@ -118,6 +120,13 @@ pub struct App { /// If the autodetection fails exits with an error. #[arg(long)] backend: Option, + /// Overwrite cache registry + /// + /// When set, all cache image references are prefixed with this registry + /// name (e.g. `ghcr.io/myorg`), enabling push/pull against a remote + /// registry. Does not affect cache key hashing. + #[arg(long)] + cache_registry: Option, /// Overwrite image #[arg(long)] image: Option, @@ -131,6 +140,7 @@ pub struct App { impl App { pub async fn run(&self) -> Result<(), Error> { let overwrites = crate::config::InstanceDefinition { + cache_registry: self.cache_registry.clone(), image: self.image.clone(), parameters: pg_client::parameter::Map::new(), seeds: indexmap::IndexMap::new(), diff --git a/pg-ephemeral/src/cli/cache.rs b/pg-ephemeral/src/cli/cache.rs index 3b53fa6b..597d1052 100644 --- a/pg-ephemeral/src/cli/cache.rs +++ b/pg-ephemeral/src/cli/cache.rs @@ -39,6 +39,17 @@ pub enum Command { #[arg(long = "seed-name")] seed_name: Option, }, + /// Pull cache images from the configured registry. + /// + /// Walks the seed chain from tip backwards and pulls the newest stage + /// that exists remotely. Requires `cache_registry` to be set. + Pull, + /// Push all locally-cached stages to the configured registry. + /// + /// Pushes every stage currently stored locally (status "hit"). Stages + /// not yet populated locally are skipped. Requires `cache_registry` to + /// be set. + Push, } impl Command { @@ -55,8 +66,14 @@ impl Command { definition.print_cache_status(instance_name, *json).await?; } Self::Reset { force } => { - let name: ociman::reference::Name = - format!("pg-ephemeral/{instance_name}").parse().unwrap(); + // Local image names mirror whatever `cache_registry` is + // set to, so reset must target the same prefixed name — + // otherwise the unprefixed lookup finds nothing and the + // prefixed images survive. + let name = crate::seed::cache_image_name( + definition.cache_registry.as_ref(), + instance_name, + ); let references = definition.backend.image_references_by_name(&name).await; for reference in &references { if *force { @@ -136,6 +153,12 @@ impl Command { .map_err(crate::container::Error::SerializeMetadata)?; println!("{json}"); } + Self::Pull => { + definition.pull_cache(instance_name).await?; + } + Self::Push => { + definition.push_cache(instance_name).await?; + } } Ok(()) } diff --git a/pg-ephemeral/src/config.rs b/pg-ephemeral/src/config.rs index 81e27187..41aca13b 100644 --- a/pg-ephemeral/src/config.rs +++ b/pg-ephemeral/src/config.rs @@ -18,6 +18,7 @@ pub struct Resolved { #[derive(Clone, Debug, PartialEq)] pub struct Instance { pub application_name: Option, + pub cache_registry: Option, pub database: pg_client::Database, pub parameters: pg_client::parameter::Map, pub seeds: indexmap::IndexMap, @@ -33,6 +34,7 @@ impl Instance { pub fn new(image: Image) -> Self { Self { application_name: None, + cache_registry: None, parameters: pg_client::parameter::Map::new(), seeds: indexmap::IndexMap::new(), ssl_config: None, @@ -54,6 +56,7 @@ impl Instance { instance_name: instance_name.clone(), application_name: self.application_name.clone(), backend, + cache_registry: self.cache_registry.clone(), database: self.database.clone(), parameters: self.parameters.clone(), seeds: self.seeds.clone(), @@ -324,6 +327,7 @@ pub struct SslConfigDefinition { #[derive(Debug, serde::Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct InstanceDefinition { + pub cache_registry: Option, pub image: Option, #[serde(default)] pub parameters: pg_client::parameter::Map, @@ -338,6 +342,7 @@ impl InstanceDefinition { #[must_use] pub fn empty() -> Self { Self { + cache_registry: None, image: None, parameters: pg_client::parameter::Map::new(), seeds: indexmap::IndexMap::new(), @@ -367,6 +372,13 @@ impl InstanceDefinition { } }; + let cache_registry = overwrites + .cache_registry + .as_ref() + .or(self.cache_registry.as_ref()) + .or(defaults.cache_registry.as_ref()) + .cloned(); + let seeds = self .seeds .into_iter() @@ -390,6 +402,7 @@ impl InstanceDefinition { Ok(Instance { application_name: None, + cache_registry, database: pg_client::Database::POSTGRES, parameters: self.parameters, seeds, @@ -407,6 +420,7 @@ impl InstanceDefinition { pub struct Config { image: Option, backend: Option, + cache_registry: Option, ssl_config: Option, #[serde(default, with = "humantime_serde")] wait_available_timeout: Option, @@ -418,6 +432,7 @@ impl std::default::Default for Config { Self { image: Some(Image::default()), backend: None, + cache_registry: None, ssl_config: None, wait_available_timeout: None, instances: None, @@ -522,6 +537,7 @@ impl Config { .unwrap_or(ociman::backend::Selection::Auto); let defaults = InstanceDefinition { + cache_registry: self.cache_registry.clone(), image: self.image.clone(), parameters: pg_client::parameter::Map::new(), seeds: indexmap::IndexMap::new(), diff --git a/pg-ephemeral/src/definition.rs b/pg-ephemeral/src/definition.rs index 05092e4d..c9eabe42 100644 --- a/pg-ephemeral/src/definition.rs +++ b/pg-ephemeral/src/definition.rs @@ -15,6 +15,22 @@ pub enum SeedApplyError { EnvVariableValue(#[from] cmd_proc::EnvVariableValueError), } +/// Errors from cache sync operations ([`Definition::pull_cache`] / +/// [`Definition::push_cache`]). +#[derive(Debug, thiserror::Error)] +pub enum CacheSyncError { + #[error( + "cache_registry must be set in database.toml or via --cache-registry to use this command" + )] + RegistryNotSet, + #[error(transparent)] + SeedLoad(#[from] LoadError), + #[error(transparent)] + Pull(#[from] ociman::backend::PullError), + #[error(transparent)] + Push(#[from] ociman::backend::PushError), +} + #[derive(Clone, Debug, PartialEq)] pub enum SslConfig { Generated { @@ -71,6 +87,7 @@ pub struct Definition { pub instance_name: crate::InstanceName, pub application_name: Option, pub backend: ociman::Backend, + pub cache_registry: Option, pub database: pg_client::Database, pub parameters: pg_client::parameter::Map, pub seeds: indexmap::IndexMap, @@ -113,6 +130,7 @@ impl Definition { instance_name, backend, application_name: None, + cache_registry: None, parameters: pg_client::parameter::Map::new(), seeds: indexmap::IndexMap::new(), ssl_config: None, @@ -194,6 +212,7 @@ impl Definition { &self.seeds, &self.backend, instance_name, + self.cache_registry.as_ref(), ) .await } @@ -509,6 +528,106 @@ impl Definition { Ok((previous_cache_reference.cloned(), Vec::new())) } + /// Pull cache images from the configured registry by walking the seed + /// chain from tip backwards. + /// + /// Returns as soon as any cacheable stage lands locally: + /// - Already present locally (Hit) → return immediately, nothing to pull. + /// - Missing locally (Miss) → attempt to pull from the registry; on + /// success return, on [`PullError::NotFound`] walk to the next older + /// stage, on [`PullError::Other`] abort. + /// - Uncacheable → skip and continue walking. + /// + /// Returns `Ok(())` even if no cached stage was found in the registry — + /// the caller can tell the difference via logs. + /// + /// # Errors + /// + /// Returns [`CacheSyncError::RegistryNotSet`] if the definition has no + /// `cache_registry` configured. + pub async fn pull_cache( + &self, + instance_name: &crate::InstanceName, + ) -> Result<(), CacheSyncError> { + if self.cache_registry.is_none() { + return Err(CacheSyncError::RegistryNotSet); + } + + let loaded_seeds = self.load_seeds(instance_name).await?; + let seeds: Vec<&LoadedSeed> = loaded_seeds.iter_seeds().collect(); + + for seed in seeds.iter().rev() { + use crate::seed::CacheStatus; + match seed.cache_status() { + CacheStatus::Uncacheable => { + log::debug!("cache pull: skipping uncacheable seed {}", seed.name()); + continue; + } + CacheStatus::Hit { reference, .. } => { + log::info!( + "cache pull: {} already present locally at {reference}", + seed.name() + ); + return Ok(()); + } + CacheStatus::Miss { reference, .. } => { + log::info!("cache pull: attempting {reference}"); + match self.backend.pull_image(reference).await { + Ok(()) => { + log::info!("cache pull: pulled {reference}"); + return Ok(()); + } + Err(ociman::backend::PullError::NotFound { .. }) => { + log::debug!("cache pull: {reference} not in registry, walking back"); + continue; + } + Err(error) => return Err(error.into()), + } + } + } + } + + log::info!("cache pull: no cached stage found in registry"); + Ok(()) + } + + /// Push all locally-cached stages to the configured registry. + /// + /// Iterates the seed chain in order and pushes every stage whose local + /// cache status is [`CacheStatus::Hit`](crate::seed::CacheStatus::Hit). + /// [`CacheStatus::Miss`](crate::seed::CacheStatus::Miss) and + /// [`CacheStatus::Uncacheable`](crate::seed::CacheStatus::Uncacheable) + /// stages are skipped. Aborts on the first push failure. + /// + /// # Errors + /// + /// Returns [`CacheSyncError::RegistryNotSet`] if the definition has no + /// `cache_registry` configured. + pub async fn push_cache( + &self, + instance_name: &crate::InstanceName, + ) -> Result<(), CacheSyncError> { + if self.cache_registry.is_none() { + return Err(CacheSyncError::RegistryNotSet); + } + + let loaded_seeds = self.load_seeds(instance_name).await?; + let mut pushed_any = false; + + for seed in loaded_seeds.iter_seeds() { + if let crate::seed::CacheStatus::Hit { reference, .. } = seed.cache_status() { + log::info!("cache push: pushing {reference}"); + self.backend.push_image(reference).await?; + pushed_any = true; + } + } + + if !pushed_any { + log::info!("cache push: no locally cached stages to push"); + } + Ok(()) + } + pub async fn run_integration_server( &self, result_fd: std::os::fd::RawFd, diff --git a/pg-ephemeral/src/seed.rs b/pg-ephemeral/src/seed.rs index 4b778617..24d11dd2 100644 --- a/pg-ephemeral/src/seed.rs +++ b/pg-ephemeral/src/seed.rs @@ -181,18 +181,68 @@ pub enum CacheStatus { Uncacheable, } +/// Static `pg-ephemeral` path component used as the second-to-last +/// segment of every cache image reference. Validated at compile time. +const PG_EPHEMERAL_COMPONENT: ociman::reference::PathComponent = + ociman::reference::PathComponent::from_static_or_panic("pg-ephemeral"); + +/// Build the `Name` portion (everything but the tag) of a cache image +/// reference for an instance. +/// +/// - `/pg-ephemeral/` when `cache_registry` is set. +/// - `pg-ephemeral/` otherwise. +/// +/// The registry-prefix piece must be applied identically by every code +/// path that touches cache image references (status, populate, push, +/// pull, reset) — otherwise commands operate on different image-store +/// keys and the cache appears inconsistent. +pub(crate) fn cache_image_name( + cache_registry: Option<&ociman::reference::Name>, + instance_name: &crate::InstanceName, +) -> ociman::reference::Name { + let instance_component: ociman::reference::PathComponent = instance_name + .as_str() + .parse() + .expect("InstanceName grammar is a strict subset of PathComponent grammar"); + let (domain, path) = match cache_registry { + Some(registry) => ( + registry.domain.clone(), + registry + .path + .clone() + .extended(PG_EPHEMERAL_COMPONENT) + .extended(instance_component), + ), + None => ( + None, + ociman::reference::Path::from(PG_EPHEMERAL_COMPONENT).extended(instance_component), + ), + }; + ociman::reference::Name { domain, path } +} + impl CacheStatus { async fn from_cache_key( cache_key: Option, backend: &ociman::Backend, instance_name: &crate::InstanceName, + cache_registry: Option<&ociman::reference::Name>, ) -> Result { let Some(hash) = cache_key else { return Ok(Self::Uncacheable); }; - let reference: ociman::Reference = format!("pg-ephemeral/{instance_name}:{hash}") - .parse() - .unwrap(); + // When `cache_registry` is set, every cache image reference is + // prefixed with that registry so it is push/pullable. The hash is + // unaffected — only the reference's name space changes. + let reference = ociman::Reference { + name: cache_image_name(cache_registry, instance_name), + tag: Some( + hash.to_string() + .parse() + .expect("SeedHash::Display emits valid Tag grammar"), + ), + digest: None, + }; // Single inspect round-trip determines presence and (on Hit) returns // the labels in one call. NotFound from the underlying inspect is the // documented absence signal, so we map it to Miss instead of an error. @@ -522,6 +572,7 @@ impl Seed { hash_chain: &mut HashChain, backend: &ociman::Backend, instance_name: &crate::InstanceName, + cache_registry: Option<&ociman::reference::Name>, ) -> Result { match self { Seed::SqlFile { path } => { @@ -539,6 +590,7 @@ impl Seed { hash_chain.cache_key(), backend, instance_name, + cache_registry, ) .await?, name, @@ -579,6 +631,7 @@ impl Seed { hash_chain.cache_key(), backend, instance_name, + cache_registry, ) .await?, name, @@ -611,6 +664,7 @@ impl Seed { hash_chain.cache_key(), backend, instance_name, + cache_registry, ) .await?, name, @@ -630,6 +684,7 @@ impl Seed { hash_chain.cache_key(), backend, instance_name, + cache_registry, ) .await?, name, @@ -644,6 +699,7 @@ impl Seed { hash_chain.cache_key(), backend, instance_name, + cache_registry, ) .await?, name, @@ -658,6 +714,7 @@ impl Seed { hash_chain.cache_key(), backend, instance_name, + cache_registry, ) .await?, name, @@ -685,6 +742,7 @@ impl Seed { hash_chain.cache_key(), backend, instance_name, + cache_registry, ) .await?, name, @@ -869,6 +927,7 @@ impl<'a> LoadedSeeds<'a> { seeds: &indexmap::IndexMap, backend: &ociman::Backend, instance_name: &crate::InstanceName, + cache_registry: Option<&ociman::reference::Name>, ) -> Result { let mut hash_chain = HashChain::new(); let mut loaded_seeds = Vec::new(); @@ -900,7 +959,13 @@ impl<'a> LoadedSeeds<'a> { for (name, seed) in seeds { let loaded_seed = seed - .load(name.clone(), &mut hash_chain, backend, instance_name) + .load( + name.clone(), + &mut hash_chain, + backend, + instance_name, + cache_registry, + ) .await?; loaded_seeds.push(loaded_seed); } diff --git a/pg-ephemeral/tests/base.rs b/pg-ephemeral/tests/base.rs index 7c992191..ce03ef44 100644 --- a/pg-ephemeral/tests/base.rs +++ b/pg-ephemeral/tests/base.rs @@ -5,15 +5,16 @@ async fn pull_test_images() { let backend = ociman::test_backend_setup!(); let default_image: ociman::image::Reference = (&pg_ephemeral::Image::default()).into(); - backend.pull_image(&default_image).await; + backend.pull_image(&default_image).await.unwrap(); for image in [ &*common::POSTGRES_IMAGE, &*common::RUBY_IMAGE, &*common::NODE_IMAGE, + &*common::REGISTRY_IMAGE, &*ociman::testing::ALPINE_LATEST_IMAGE, ] { - backend.pull_image(image).await; + backend.pull_image(image).await.unwrap(); } } @@ -105,6 +106,7 @@ fn test_config_multi_instance() { pg_ephemeral::InstanceName::from_static_or_panic("a"), pg_ephemeral::Instance { application_name: None, + cache_registry: None, database: pg_client::Database::POSTGRES, parameters: pg_client::parameter::Map::new(), seeds: indexmap::IndexMap::new(), @@ -119,6 +121,7 @@ fn test_config_multi_instance() { pg_ephemeral::InstanceName::from_static_or_panic("b"), pg_ephemeral::Instance { application_name: None, + cache_registry: None, database: pg_client::Database::POSTGRES, parameters: pg_client::parameter::Map::new(), seeds: indexmap::IndexMap::new(), @@ -152,6 +155,7 @@ fn test_config_no_explicit_instance() { pg_ephemeral::InstanceName::MAIN, pg_ephemeral::Instance { application_name: None, + cache_registry: None, database: pg_client::Database::POSTGRES, parameters: pg_client::parameter::Map::new(), seeds: indexmap::IndexMap::new(), @@ -212,6 +216,7 @@ fn test_config_ssl() { pg_ephemeral::InstanceName::MAIN, pg_ephemeral::Instance { application_name: None, + cache_registry: None, database: pg_client::Database::POSTGRES, parameters: pg_client::parameter::Map::new(), seeds: indexmap::IndexMap::new(), @@ -599,6 +604,7 @@ fn test_config_image_with_sha256_digest() { pg_ephemeral::InstanceName::MAIN, pg_ephemeral::Instance { application_name: None, + cache_registry: None, database: pg_client::Database::POSTGRES, parameters: pg_client::parameter::Map::new(), seeds: indexmap::IndexMap::new(), diff --git a/pg-ephemeral/tests/cache.rs b/pg-ephemeral/tests/cache.rs index 4526b21f..b7bbde86 100644 --- a/pg-ephemeral/tests/cache.rs +++ b/pg-ephemeral/tests/cache.rs @@ -1,6 +1,7 @@ mod common; use common::{TestDir, TestGitRepo, run_pg_ephemeral}; +use typed_reqwest::Request; #[tokio::test] async fn test_populate_cache() { @@ -390,6 +391,325 @@ async fn test_cache_status_change_with_image() { assert_ne!(stdout2, stdout1); } +#[tokio::test] +async fn test_cache_registry_prefixes_reference_without_changing_hash() { + let _backend = ociman::test_backend_setup!(); + let dir = TestDir::new("cache-registry-test"); + + dir.write_file("schema.sql", "CREATE TABLE users (id INTEGER PRIMARY KEY);"); + + // Baseline: no cache_registry. + dir.write_file( + "database.toml", + indoc::indoc! {r#" + image = "17.1" + + [instances.main.seeds.schema] + type = "sql-file" + path = "schema.sql" + "#}, + ); + let baseline = run_pg_ephemeral(&["cache", "status", "--json"], &dir.path).await; + let baseline: serde_json::Value = serde_json::from_str(&baseline).unwrap(); + let baseline_reference = baseline["seeds"][0]["cache_image"].as_str().unwrap(); + assert!(baseline_reference.starts_with("pg-ephemeral/main:")); + + // Same config plus cache_registry. + dir.write_file( + "database.toml", + indoc::indoc! {r#" + image = "17.1" + cache_registry = "ghcr.io/mbj" + + [instances.main.seeds.schema] + type = "sql-file" + path = "schema.sql" + "#}, + ); + let prefixed = run_pg_ephemeral(&["cache", "status", "--json"], &dir.path).await; + let prefixed: serde_json::Value = serde_json::from_str(&prefixed).unwrap(); + let prefixed_reference = prefixed["seeds"][0]["cache_image"].as_str().unwrap(); + + // The prefixed reference should be the baseline reference with the registry prepended, + // proving (a) the registry prefix is applied and (b) the hash is unaffected. + assert_eq!( + prefixed_reference, + format!("ghcr.io/mbj/{baseline_reference}") + ); +} + +async fn run_pg_ephemeral_expect_failure( + args: &[&str], + current_dir: &std::path::Path, +) -> (String, String) { + let pg_ephemeral_bin = env!("CARGO_BIN_EXE_pg-ephemeral"); + let output = cmd_proc::Command::new(pg_ephemeral_bin) + .arguments(args) + .working_directory(current_dir) + .stdout_capture() + .stderr_capture() + .accept_nonzero_exit() + .run() + .await + .unwrap(); + + assert!( + !output.status.success(), + "expected pg-ephemeral {} to fail, but it succeeded\nstdout:\n{}", + args.join(" "), + String::from_utf8_lossy(&output.stdout) + ); + + ( + String::from_utf8_lossy(&output.stdout).into_owned(), + String::from_utf8_lossy(&output.stderr).into_owned(), + ) +} + +#[tokio::test] +async fn test_cache_pull_without_registry_errors() { + let _backend = ociman::test_backend_setup!(); + let dir = TestDir::new("cache-pull-no-registry"); + + dir.write_file("schema.sql", "CREATE TABLE users (id INTEGER PRIMARY KEY);"); + dir.write_file( + "database.toml", + indoc::indoc! {r#" + image = "17.1" + + [instances.main.seeds.schema] + type = "sql-file" + path = "schema.sql" + "#}, + ); + + let (_stdout, stderr) = run_pg_ephemeral_expect_failure(&["cache", "pull"], &dir.path).await; + assert!( + stderr.contains("cache_registry must be set"), + "expected registry-not-set error, got stderr:\n{stderr}" + ); +} + +#[tokio::test] +async fn test_cache_push_without_registry_errors() { + let _backend = ociman::test_backend_setup!(); + let dir = TestDir::new("cache-push-no-registry"); + + dir.write_file("schema.sql", "CREATE TABLE users (id INTEGER PRIMARY KEY);"); + dir.write_file( + "database.toml", + indoc::indoc! {r#" + image = "17.1" + + [instances.main.seeds.schema] + type = "sql-file" + path = "schema.sql" + "#}, + ); + + let (_stdout, stderr) = run_pg_ephemeral_expect_failure(&["cache", "push"], &dir.path).await; + assert!( + stderr.contains("cache_registry must be set"), + "expected registry-not-set error, got stderr:\n{stderr}" + ); +} + +/// Marker for the OCI distribution API the readiness probe talks to. +struct RegistryV2Api; + +/// `GET /v2/` — the OCI distribution base endpoint. A `200 OK` means the +/// registry is up and serving; we only care that it answers, so the +/// response body is discarded. +struct RegistryV2Ping; + +impl typed_reqwest::Request for RegistryV2Ping { + type Response = (); + + typed_reqwest::decoder!( + typed_reqwest::decoder::Response::build() + .status_code_constant(http::StatusCode::OK, ()) + .finish() + ); + + fn request_builder( + &self, + client: &reqwest::Client, + base_url: &typed_reqwest::BaseUrl, + ) -> reqwest::RequestBuilder { + // Trailing empty segment preserves the `/v2/` trailing slash the + // distribution spec uses for the base endpoint. + client.get(base_url.set_path_segments(&["v2", ""])) + } +} + +/// Poll the registry's `/v2/` endpoint until it answers, so the round-trip +/// below doesn't race the container's HTTP server coming up. The published +/// host port accepts connections as soon as the runtime sets up its +/// forwarder, which can be before the registry process itself is listening. +async fn wait_registry_ready(port: u16) { + let client = reqwest::Client::new(); + let base_url = typed_reqwest::BaseUrl::new( + typed_reqwest::Scheme::Http, + url::Host::parse("localhost").unwrap(), + Some(port), + ); + // Materialize the `const DECODER` (a `LazyLock`) into a local once; + // borrowing it directly per call trips `borrow_interior_mutable_const`. + let decoder = RegistryV2Ping::DECODER; + + for _ in 0..120 { + if let Ok(response) = RegistryV2Ping + .request_builder(&client, &base_url) + .send() + .await + && decoder.decode(response).await.is_ok() + { + return; + } + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + } + panic!("registry at localhost:{port} did not become ready in time"); +} + +const CONTAINERS_REGISTRIES_CONF: cmd_proc::EnvVariableName = + cmd_proc::EnvVariableName::from_static_or_panic("CONTAINERS_REGISTRIES_CONF"); + +/// Absolute path to the committed `registries.conf` fixture that marks the +/// local test registry (`localhost:5000`) insecure. +fn insecure_registries_conf() -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/registries.conf") +} + +/// Like [`run_pg_ephemeral`] but points podman at the insecure-registry +/// fixture via `CONTAINERS_REGISTRIES_CONF`, for the steps that actually +/// talk to the plain-HTTP local registry (push/pull). Podman otherwise +/// defaults to HTTPS for `localhost:`; docker ignores the var and +/// trusts loopback natively, so this is a no-op there. +async fn run_pg_ephemeral_insecure(args: &[&str], current_dir: &std::path::Path) -> String { + let pg_ephemeral_bin = env!("CARGO_BIN_EXE_pg-ephemeral"); + let conf = insecure_registries_conf(); + let output = cmd_proc::Command::new(pg_ephemeral_bin) + .arguments(args) + .working_directory(current_dir) + .env(&CONTAINERS_REGISTRIES_CONF, &conf) + .stdout_capture() + .stderr_capture() + .accept_nonzero_exit() + .run() + .await + .unwrap(); + + assert!( + output.status.success(), + "pg-ephemeral {} failed:\nstdout:\n{}\nstderr:\n{}", + args.join(" "), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + String::from_utf8(output.stdout).unwrap() +} + +/// End-to-end push/pull round trip against a throwaway local registry. +/// +/// Boots `registry:2` on `localhost:5000`, points `cache_registry` at it, +/// then runs: populate (build local cache) -> push (upload) -> reset +/// --force (clear local) -> assert the stage is a miss -> pull (walk back +/// from the tip) -> assert it's a local hit again. The registry needs no +/// auth; podman is steered to plain HTTP via the insecure-registry fixture +/// (see [`run_pg_ephemeral_insecure`]) and docker trusts loopback natively, +/// so this runs in the normal suite — no credentials, no external +/// dependency. +#[tokio::test] +async fn test_cache_registry_round_trip() { + // Fixed host port so the committed `registries.conf` fixture can name + // it; `registry:2` listens on this port inside the container too. + const REGISTRY_PORT: u16 = 5000; + + let backend = ociman::test_backend_setup!(); + + let registry_image = common::REGISTRY_IMAGE.clone(); + backend.pull_image_if_absent(®istry_image).await.unwrap(); + + let registry_definition = ociman::Definition::new(backend.clone(), registry_image) + .remove() + .publish( + ociman::Publish::tcp(REGISTRY_PORT) + .host_ip_port(std::net::Ipv4Addr::LOCALHOST.into(), REGISTRY_PORT), + ); + + registry_definition + .with_container(async |_container| { + wait_registry_ready(REGISTRY_PORT).await; + + let dir = TestDir::new("cache-registry-round-trip"); + dir.write_file("schema.sql", "CREATE TABLE users (id INTEGER PRIMARY KEY);"); + dir.write_file( + "database.toml", + indoc::indoc! {r#" + image = "17.1" + cache_registry = "localhost:5000/pg-ephemeral-cache-test" + + [instances.main.seeds.schema] + type = "sql-file" + path = "schema.sql" + "#}, + ); + + // populate/reset/status are local-only; push and pull contact + // the registry, so they get the insecure-registry fixture. + run_pg_ephemeral(&["cache", "populate"], &dir.path).await; + run_pg_ephemeral_insecure(&["cache", "push"], &dir.path).await; + run_pg_ephemeral(&["cache", "reset", "--force"], &dir.path).await; + + // Same schema + image as `test_cache_status_deterministic`, and + // `cache_registry` does not feed the cache key, so the hash is + // that test's constant — only the registry prefix differs. + let cache_image = "localhost:5000/pg-ephemeral-cache-test/pg-ephemeral/main:\ + fcda31e16e72128b8f47ac9d155d84d8b28f9b38c939b60a743a30333b8c8af4"; + + let after_reset = run_pg_ephemeral(&["cache", "status", "--json"], &dir.path).await; + let after_reset: serde_json::Value = serde_json::from_str(&after_reset).unwrap(); + assert_eq!( + after_reset, + serde_json::json!({ + "instance": "main", + "base_image": "17.1", + "version": "0.5.1", + "summary": { "total": 1, "hits": 0, "misses": 1, "uncacheable": 0 }, + "seeds": [{ + "name": "schema", + "type": "sql-file", + "status": "miss", + "cache_image": cache_image, + }], + }) + ); + + run_pg_ephemeral_insecure(&["cache", "pull"], &dir.path).await; + + let after_pull = run_pg_ephemeral(&["cache", "status", "--json"], &dir.path).await; + let after_pull: serde_json::Value = serde_json::from_str(&after_pull).unwrap(); + assert_eq!( + after_pull, + serde_json::json!({ + "instance": "main", + "base_image": "17.1", + "version": "0.5.1", + "summary": { "total": 1, "hits": 1, "misses": 0, "uncacheable": 0 }, + "seeds": [{ + "name": "schema", + "type": "sql-file", + "status": "hit", + "cache_image": cache_image, + }], + }) + ); + }) + .await + .unwrap(); +} + #[tokio::test] async fn test_cache_status_chain_propagates() { let _backend = ociman::test_backend_setup!(); diff --git a/pg-ephemeral/tests/common.rs b/pg-ephemeral/tests/common.rs index 0ff38636..1a12b5a0 100644 --- a/pg-ephemeral/tests/common.rs +++ b/pg-ephemeral/tests/common.rs @@ -18,6 +18,11 @@ pub static RUBY_IMAGE: std::sync::LazyLock = pub static NODE_IMAGE: std::sync::LazyLock = std::sync::LazyLock::new(|| "docker.io/node:22-alpine".parse().unwrap()); +/// Throwaway OCI registry image used by the cache-registry round-trip test. +#[allow(dead_code)] +pub static REGISTRY_IMAGE: std::sync::LazyLock = + std::sync::LazyLock::new(|| "docker.io/library/registry:2".parse().unwrap()); + /// Create a test definition with extended timeout. /// /// CI environments may be slow, so we use 30s instead of the default 10s. diff --git a/pg-ephemeral/tests/fixtures/registries.conf b/pg-ephemeral/tests/fixtures/registries.conf new file mode 100644 index 00000000..27f776ca --- /dev/null +++ b/pg-ephemeral/tests/fixtures/registries.conf @@ -0,0 +1,12 @@ +# Test-only containers registries.conf for the cache-registry round-trip +# test. The test boots `registry:2` on localhost:5000 serving plain HTTP; +# podman otherwise defaults to HTTPS for localhost: and fails the +# push with "server gave HTTP response to HTTPS client". Marking the +# registry insecure makes podman use HTTP and skip TLS verification. +# +# Pointed at via the CONTAINERS_REGISTRIES_CONF env var on the pg-ephemeral +# push/pull subprocesses only. Docker ignores this file (it trusts +# loopback registries natively), so the test works under both backends. +[[registry]] +location = "localhost:5000" +insecure = true