Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

bench-scenarios/src/artifactory.rs

//! Artifactory-vs-Holger comparison scenario — the heavy, *fixture-gated*
//! sibling of [`crate::nexus`].
//!
//! Mirrors `nexus::nexus_load`: it pushes the **same cached corpus** (crate /
//! jar / wheel znippy archives) through a real JFrog **Artifactory OSS**
//! container, then drives a served-FETCH-over-HTTP head-to-head — the SAME
//! pure-Rust parallel GET pool ([`crate::served::bombard_http_get`]) against both
//! a real Holger HTTP server and Artifactory — producing the paired
//! `holger_served_mbs` + `artifactory_served_mbs` (+ `ops_per_sec`) metrics the
//! README `benches` mashup bolds head-to-head. The competitor upload throughput
//! is kept as a separate `artifactory_upload_mbs` publish-phase number, and the
//! old in-process `get_file()` figure as `holger_inprocess_read_ceiling_mbs` (an
//! internal Holger-vs-Holger regression ceiling, never beside the competitor).
//!
//! ## Fixture gate (NOT `--heavy`)
//!
//! Exactly like the Nexus scenario, the Artifactory testcontainer is a
//! **fixture gate**: any failure to reach a container runtime / boot the server
//! becomes an `is_skip` error which the bench harness records as a SKIP row, so
//! this runs in the normal (non-`--heavy`) pass and no-ops gracefully on a box
//! with no Docker/Podman or where the (heavy) image cannot boot in time.
//!
//! Compiled only under the `artifactory` cargo feature so the heavy deps
//! (`testcontainers`, `tokio`, `reqwest`, `agent-lib`) never reach the light
//! default build of `holger-bench-scenarios`.

use std::time::{Duration, Instant};

use anyhow::Result;

use agent_lib::operations;
use async_trait::async_trait;
use repository_znippy_rust::RustRepoZnippy;
use testcontainers::{
    core::{ContainerPort, WaitFor},
    runners::AsyncRunner,
    ContainerAsync, GenericImage, ImageExt,
};
use traits::{ConnectorTrait, RemoteAsset, RemoteRepository};

use crate::{
    build_crate_archive, build_jar_archive, build_pip_archive, served, FairResources, Scale,
};

/// Marker that a scenario was skipped because no container runtime / Artifactory
/// was reachable. Callers stringify this into a `skipped: ...` reason.
const SKIP_PREFIX: &str = "skipped: no Docker/Artifactory";

/// Default admin credentials of the pinned Artifactory OSS image. The same pair
/// gates create/discovery, upload **and** the authenticated head-to-head GETs
/// (OSS rejects anonymous reads with 401, see [`artifactory_load`]).
const ART_USER: &str = "admin";
const ART_PASS: &str = "password";

/// The **historical** Artifactory pin, released 2021-02-21. Kept as its own arm
/// for series continuity only — as a comparison it is not defensible, and that
/// must be said wherever the number is used:
///
/// * 6.x is long end-of-life. The line itself ended at 6.23.42 (2022-03-28), so
///   this tag is **29 patches behind inside its own dead line**, five years old,
///   and one whole major version behind current.
/// * The justification written into this file for not upgrading — *"7.x OSS
///   won't bootstrap in a throwaway container, its Access/router/event
///   microservices crash-loop"* — was **FALSE**. 7.x bundles those services in
///   the single image; JFrog's documented install is one `docker run`, and
///   PlaytikaOSS/testcontainers-spring-boot ships a live `embedded-artifactory`
///   module on 7.161.26. See [`ART_TAG_CURRENT`] for what the real blocker was.
pub const ART_TAG_PINNED: &str = "6.23.13";

/// The **current** Artifactory OSS line (7.161.26). Runs beside
/// [`ART_TAG_PINNED`], never instead of it.
///
/// ## What actually blocked this for years, MEASURED
///
/// The container did exit in under 10 seconds on oden — but not on anything to
/// do with microservices. The log said:
///
/// ```text
/// /opt/jfrog/artifactory/var is running with 2% free storage.
/// Free up space or increase volume size and try again. Exiting
/// ```
///
/// That is a **percentage-based** free-space guard reading podman's graphroot.
/// oden's `/home` was 98 % full — 168 GB free, which is plenty of bytes, but the
/// guard counts percent. It was a housekeeping problem on the bench host, and it
/// had been written down as a property of Artifactory. Moving `/home` to 87 %
/// free (792 GB) is what unblocked this arm.
///
/// ## The format ceiling, which no version fixes
///
/// Artifactory **OSS serves Maven/Gradle/Ivy/SBT and generic repositories, and
/// nothing else** — no Cargo, no PyPI, no npm, in any free JFrog edition, 6.x or
/// 7.x. So this rival can only ever be a maven/generic data point against a
/// Rust-first product. Every use of these numbers must say so.
pub const ART_TAG_CURRENT: &str = "7.161.26";

/// The PostgreSQL image Artifactory 7.x is given as its database.
///
/// ## Why a second container exists at all — MEASURED, and it corrects TWO claims
///
/// The source used to say 7.x "won't bootstrap … Access/router/event
/// microservices crash-loop on missing master.key/join.key". That reason was
/// false. But so was the assumption that removing the free-space blocker would
/// be enough. With `/home` freed to 87 %, the container stopped exiting in ten
/// seconds and instead sat in `Cluster join: Retry 740: Service registry ping
/// failed` for a quarter of an hour. Its own log says exactly why:
///
/// ```text
/// DbTypeNotAllowedException: DB Type derby is not allowed:
/// Cannot start the application with a database other than PostgreSQL.
/// ```
///
/// **Artifactory 7.x refuses to run on the bundled Derby that 6.x used.** So the
/// honest statement is neither "7.x cannot be containerised" nor "7.x is a
/// drop-in tag bump": 7.x is a **two-container arm**. That is a wait strategy
/// and one more image — still not a different major version of the harness — but
/// it is a real, specific requirement, now measured instead of guessed.
const ART_PG_IMAGE: &str = "docker.io/library/postgres";
const ART_PG_TAG: &str = "16-alpine";
const ART_PG_DB: &str = "artifactory";
const ART_PG_USER: &str = "artifactory";
const ART_PG_PASS: &str = "artifactory";

/// True when `tag` is a 7.x (or later) Artifactory that requires PostgreSQL.
fn needs_postgres(tag: &str) -> bool {
    !tag.starts_with("6.")
}

/// The HTTP ports to probe for a given Artifactory tag, in preference order.
///
/// 6.x is a single Tomcat on **8081**. 7.x splits the platform: **8082** is the
/// router / JFrog Platform entry point and **8081** still reaches the artifactory
/// service directly. Both accept `admin:password` basic auth against
/// `/artifactory/api/...`, but which one comes up first varies with boot order,
/// so the boot probes them in turn rather than assuming.
fn candidate_ports(tag: &str) -> &'static [u16] {
    if tag.starts_with("6.") {
        &[8081]
    } else {
        &[8082, 8081]
    }
}

/// Build an `anyhow::Error` whose message starts with `skipped: no
/// Docker/Artifactory` so the bench harness records a SKIP rather than a hard
/// failure.
fn skip(detail: impl std::fmt::Display) -> anyhow::Error {
    anyhow::anyhow!("{SKIP_PREFIX} — {detail}")
}

/// True iff `e` is one of our graceful skips (vs a genuine workload failure).
pub fn is_skip(e: &anyhow::Error) -> bool {
    e.to_string().starts_with(SKIP_PREFIX)
}

// ── result shape (mirrors `nexus::LoadOutcome`) ─────────────────────────────

/// Outcome of [`artifactory_load`]: the **honest, like-for-like**
/// Holger-vs-Artifactory throughput. Same shape + semantics as
/// [`crate::nexus::LoadOutcome`].
///
/// HEAD-TO-HEAD (both measured the SAME way — concurrent GETs over HTTP):
///   * `holger_served_mbs`      — fetched from a real Holger HTTP server.
///   * `artifactory_served_mbs` — fetched from the Artifactory HTTP server.
///
/// `artifactory_upload_mbs` is the publish phase (separate, not the head-to-head);
/// `holger_inprocess_read_ceiling_mbs` is the internal in-process regression
/// ceiling — NEVER placed beside the competitor.
#[derive(Debug, Clone)]
pub struct LoadOutcome {
    pub concurrency: usize,
    /// Head-to-head: Holger served over HTTP (MB/s).
    pub holger_served_mbs: f64,
    /// Head-to-head: Artifactory served over HTTP (MB/s).
    pub artifactory_served_mbs: f64,
    /// Holger served HTTP GET ops/sec (head-to-head transport).
    pub ops_per_sec: f64,
    /// Head-to-head: Artifactory served HTTP GET ops/sec (same transport).
    pub artifactory_ops_per_sec: f64,
    /// Head-to-head served-GET latency (ms), measured identically for both sides
    /// over the same pure-Rust client pool. Lower is better.
    pub holger_p50_ms: f64,
    pub holger_p99_ms: f64,
    pub holger_p999_ms: f64,
    pub holger_mean_ms: f64,
    pub artifactory_p50_ms: f64,
    pub artifactory_p99_ms: f64,
    pub artifactory_p999_ms: f64,
    pub artifactory_mean_ms: f64,
    /// Number of Artifactory GETs that returned 2xx during the head-to-head. A
    /// healthy/serving Artifactory is `> 0`; a `0` here means the rival cell is
    /// empty (the bug this scenario guards against) and [`artifactory_load`]
    /// turns it into a hard error rather than a misleading ~0 MB/s.
    pub artifactory_ok_ops: u64,
    /// The pre-seeded Artifactory generic-local repo the corpus was deployed
    /// into (OSS cannot create repos over REST — see [`discover_generic_repo`]).
    pub artifactory_repo: String,
    /// Publish phase only: Artifactory upload throughput (MB/s). Not the head-to-head.
    pub artifactory_upload_mbs: f64,
    /// Internal Holger-vs-Holger regression ceiling: in-process `get_file()` MB/s.
    /// NOT comparable to Artifactory — never place beside the served numbers.
    pub holger_inprocess_read_ceiling_mbs: f64,
    pub elapsed_ms: f64,
    pub holger_files: usize,
    /// The Artifactory image tag this row was measured against. Two arms now run
    /// (the EOL 6.x pin and the current 7.x line); a served MB/s that does not
    /// carry its rival's version is not a comparison.
    pub artifactory_tag: String,
    /// Seconds from `start()` to an authenticated 200 on `/api/system/ping`.
    /// Recorded because the 7.x boot cost is the ONLY real difference from 6.x,
    /// and the stale pin was justified by a guess about exactly this number.
    pub artifactory_boot_secs: f64,
}

// ── a minimal Artifactory connector ────────────────────────────────────────
//
// Artifactory's generic-repo deploy API is `PUT /artifactory/<repo>/<path>`
// (basic auth). We only need `upload_asset` for the throughput measurement;
// the other `ConnectorTrait` methods are unused by the bench and stubbed.

struct ArtifactoryConnector {
    base_url: String,
    client: reqwest::Client,
    user: String,
    pass: String,
}

#[async_trait]
impl ConnectorTrait for ArtifactoryConnector {
    fn name(&self) -> &str {
        "artifactory"
    }

    async fn list_repositories(&self) -> Result<Vec<RemoteRepository>> {
        Ok(Vec::new())
    }

    async fn list_assets(&self, _repository: &str) -> Result<Vec<RemoteAsset>> {
        Ok(Vec::new())
    }

    async fn download_asset(&self, _asset: &RemoteAsset) -> Result<Vec<u8>> {
        anyhow::bail!("download_asset unused in the Artifactory bench")
    }

    async fn upload_asset(&self, repository: &str, path: &str, data: &[u8]) -> Result<()> {
        let url = format!("{}/artifactory/{}/{}", self.base_url, repository, path);
        self.client
            .put(&url)
            .basic_auth(&self.user, Some(&self.pass))
            .body(data.to_vec())
            .send()
            .await?
            .error_for_status()?;
        Ok(())
    }
}

// ── container helpers (mirror the Nexus boot path) ──────────────────────────

/// Two-phase readiness. Anonymous "HTTP up" is NOT enough: Artifactory 7.x boots
/// the Router first, but the **Access** service (which bootstraps the default
/// `admin` user) warms up after — so an early anonymous 403 on /ping fires while
/// `admin:password` is still rejected. We gate on an **authenticated** 200, which
/// is the only signal that authenticated writes (create-repo, upload) will work.
/// Poll every candidate base URL until one answers an **authenticated** 200 on
/// `/artifactory/api/system/ping`, and return it.
///
/// Anonymous "HTTP up" is NOT enough: Artifactory 7.x boots the Router first and
/// the **Access** service (which bootstraps the default `admin` user) warms up
/// after, so an early anonymous answer fires while `admin:password` is still
/// rejected. The authenticated 200 is the only signal that authenticated writes
/// (repo discovery, upload) will work.
///
/// Several candidates are polled because 7.x exposes the platform on 8082 and
/// the artifactory service on 8081 (see [`candidate_ports`]); whichever is ready
/// first wins, and the winner is what every later request uses.
async fn wait_for_artifactory(
    bases: &[String],
    user: &str,
    pass: &str,
    timeout: Duration,
) -> Option<String> {
    let client = tls_roots(reqwest::Client::builder())
        .timeout(Duration::from_secs(5))
        .build()
        .unwrap();
    let start = Instant::now();
    while start.elapsed() < timeout {
        for base_url in bases {
            if let Ok(resp) = client
                .get(format!("{base_url}/artifactory/api/system/ping"))
                .basic_auth(user, Some(pass))
                .send()
                .await
            {
                if resp.status().is_success() {
                    return Some(base_url.clone());
                }
            }
        }
        tokio::time::sleep(Duration::from_secs(3)).await;
    }
    None
}

/// Discover a **pre-seeded generic-local** repository to deploy the bench corpus
/// into, and return its key.
///
/// ## Why discover instead of create (THE central Artifactory-OSS fix)
///
/// The original scenario tried `PUT /api/repositories/<key>` to create
/// `crates-raw`/`jars-raw`/`wheels-raw`. Artifactory **OSS** does not expose the
/// repository-management REST API — that PUT returns `400 "This REST API is
/// available only in Artifactory Pro"` (confirmed live + via the `art_probe`
/// instrumentation). The previous code turned that 400 into a **skip**, so the
/// Artifactory leg *always* no-op'd on OSS → the rival cell was forever empty.
///
/// The fix: OSS ships a default **generic LOCAL** repo (`example-repo-local`).
/// We `GET /api/repositories` (which OSS *does* allow), pick a `LOCAL` +
/// `Generic` repo, and deploy the corpus under sub-paths of it
/// (`<repo>/crates-raw/…`). `PUT /artifactory/<repo>/<path>` (the generic deploy
/// API) is fully available in OSS, so uploads + reads work without a Pro license.
async fn discover_generic_repo(
    client: &reqwest::Client,
    base_url: &str,
    user: &str,
    pass: &str,
) -> Result<String> {
    // Access can flip to ready mid-loop: a 401/403/503 right after the readiness
    // gate is a transient warmup race, not a fatal config error. Back off + retry
    // within a deadline before giving up.
    let deadline = Instant::now() + Duration::from_secs(60);
    let repos: Vec<serde_json::Value> = loop {
        let resp = client
            .get(format!("{base_url}/artifactory/api/repositories"))
            .basic_auth(user, Some(pass))
            .send()
            .await
            .map_err(|e| skip(format!("list repositories: {e}")))?;
        let s = resp.status().as_u16();
        if (s == 401 || s == 403 || s == 503) && Instant::now() < deadline {
            tokio::time::sleep(Duration::from_secs(2)).await;
            continue;
        }
        anyhow::ensure!(resp.status().is_success(), "list repositories: status {s}");
        break resp
            .json()
            .await
            .map_err(|e| skip(format!("parse repositories: {e}")))?;
    };

    // Prefer a LOCAL + Generic repo (deployable, layout-free). `type`/`packageType`
    // are reported case-insensitively across versions ("LOCAL"/"Generic").
    let is_generic_local = |r: &serde_json::Value| {
        let t = r.get("type").and_then(|v| v.as_str()).unwrap_or_default();
        let p = r
            .get("packageType")
            .and_then(|v| v.as_str())
            .unwrap_or_default();
        t.eq_ignore_ascii_case("local") && p.eq_ignore_ascii_case("generic")
    };
    if let Some(key) = repos
        .iter()
        .find(|r| is_generic_local(r))
        .and_then(|r| r.get("key").and_then(|v| v.as_str()))
    {
        return Ok(key.to_string());
    }
    // Fall back to any LOCAL repo (still deployable via the generic PUT path).
    if let Some(key) = repos
        .iter()
        .find(|r| {
            r.get("type")
                .and_then(|v| v.as_str())
                .map(|t| t.eq_ignore_ascii_case("local"))
                .unwrap_or(false)
        })
        .and_then(|r| r.get("key").and_then(|v| v.as_str()))
    {
        return Ok(key.to_string());
    }
    Err(skip(
        "Artifactory OSS exposes no pre-seeded LOCAL repo to deploy into (repo-creation REST is Pro-only). Seed a generic-local repo (e.g. example-repo-local) into the image.".to_string(),
    ))
}

/// Start an Artifactory OSS container, bring it to a ready state, discover a
/// deployable generic-local repo, and return the container (kept alive by the
/// caller), the base URL, a connector, and the repo key. Any failure to reach a
/// runtime becomes a graceful skip.
/// Start the PostgreSQL that Artifactory 7.x insists on, on a shared user-defined
/// network, and return the container plus the hostname Artifactory should use.
/// The container is kept alive by the caller for the whole arm.
async fn boot_postgres(network: &str, host_alias: &str) -> Result<ContainerAsync<GenericImage>> {
    let image = GenericImage::new(ART_PG_IMAGE, ART_PG_TAG)
        .with_exposed_port(ContainerPort::Tcp(5432))
        .with_wait_for(WaitFor::message_on_stderr(
            "database system is ready to accept connections",
        ))
        .with_env_var("POSTGRES_DB", ART_PG_DB)
        .with_env_var("POSTGRES_USER", ART_PG_USER)
        .with_env_var("POSTGRES_PASSWORD", ART_PG_PASS)
        .with_network(network.to_string())
        .with_container_name(host_alias.to_string());
    image.start().await.map_err(|e| {
        skip(format!(
            "PostgreSQL for Artifactory {ART_PG_TAG} failed to start: {e}"
        ))
    })
}

async fn boot_artifactory(
    res: &FairResources,
    tag: &str,
    boot_timeout: Duration,
) -> Result<(
    ContainerAsync<GenericImage>,
    Option<ContainerAsync<GenericImage>>,
    String,
    ArtifactoryConnector,
    String,
    f64,
)> {
    // First reason the container "couldn't be brought up": testcontainers looked
    // for a docker socket that does not exist on this rootless-Podman host. Point
    // it at the live Podman API socket before the first start().
    crate::ensure_container_host();

    let user = ART_USER.to_string();
    let pass = ART_PASS.to_string();

    let nano_cpus = res.cores as i64 * 1_000_000_000;
    let ports: &'static [u16] = candidate_ports(tag);
    let mem_bytes = res.container_mem_bytes as i64;
    // CORRECTION, 2026-09-20. The comment that used to sit here said 7.x OSS
    // "won't bootstrap in a throwaway container — its Access/router/event
    // microservices crash-loop on missing master.key/join.key". That was wrong,
    // and it froze this arm on a five-year-old EOL image. 7.x bundles those
    // services inside the one image; what actually killed the container in under
    // 10 s was a percentage-based free-space guard on `/opt/jfrog/artifactory/var`
    // reading a 98 %-full podman graphroot. See [`ART_TAG_CURRENT`].
    //
    // With that blocker gone, the REAL 7.x requirement became visible and is now
    // handled below: it refuses every database except PostgreSQL, so 7.x is a
    // two-container arm. See [`ART_PG_IMAGE`].
    let boot_start = Instant::now();
    let (pg, pg_env): (Option<ContainerAsync<GenericImage>>, Vec<(String, String)>) =
        if needs_postgres(tag) {
            // A per-run network + hostname so two concurrent bench runs on the
            // same box cannot collide on a container name.
            let uniq = std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .map(|d| d.as_nanos())
                .unwrap_or(0);
            let network = format!("holger-bench-art-{uniq}");
            let pg_host = format!("holger-bench-pg-{uniq}");
            let pg = boot_postgres(&network, &pg_host).await?;
            let env = vec![
                (
                    "JF_SHARED_DATABASE_TYPE".to_string(),
                    "postgresql".to_string(),
                ),
                (
                    "JF_SHARED_DATABASE_DRIVER".to_string(),
                    "org.postgresql.Driver".to_string(),
                ),
                (
                    "JF_SHARED_DATABASE_URL".to_string(),
                    format!("jdbc:postgresql://{pg_host}:5432/{ART_PG_DB}"),
                ),
                (
                    "JF_SHARED_DATABASE_USERNAME".to_string(),
                    ART_PG_USER.to_string(),
                ),
                (
                    "JF_SHARED_DATABASE_PASSWORD".to_string(),
                    ART_PG_PASS.to_string(),
                ),
                ("JF_SHARED_NETWORK".to_string(), network.clone()),
            ];
            (Some(pg), env)
        } else {
            (None, Vec::new())
        };
    let art_network: Option<String> = pg_env
        .iter()
        .find(|(k, _)| k == "JF_SHARED_NETWORK")
        .map(|(_, v)| v.clone());

    let mut base_image = GenericImage::new("releases-docker.jfrog.io/jfrog/artifactory-oss", tag)
        .with_wait_for(WaitFor::message_on_stdout("Artifactory")); // best-effort; the authed gate below is authoritative
                                                                   // 6.x listens on 8081 only; 7.x adds the platform router on 8082. Expose
                                                                   // whichever this tag uses so the readiness probe has something to reach.
    for p in candidate_ports(tag) {
        base_image = base_image.with_exposed_port(ContainerPort::Tcp(*p));
    }
    let mut image = base_image
        // FAIRNESS: Artifactory's JVM (Tomcat) reads EXTRA_JAVA_OPTIONS; without
        // it the OSS image boots a cramped default heap. Give it the same fair
        // heap (~50 % of the container cap) Nexus/Holger get, plus all cores +
        // ~75 % RAM via the host-config modifier.
        .with_env_var("EXTRA_JAVA_OPTIONS", res.jvm_args(""))
        .with_host_config_modifier(move |hc| {
            hc.nano_cpus = Some(nano_cpus);
            hc.memory = Some(mem_bytes);
            hc.memory_swap = Some(mem_bytes);
            // Rootless Podman reports PublishAllPorts with an empty HostIp,
            // which testcontainers drops (see `publish_on_ipv4`).
            crate::publish_on_ipv4(hc, ports);
        });
    // 7.x only: point it at the PostgreSQL container and join the same network,
    // so `jdbc:postgresql://<alias>:5432/...` resolves.
    for (k, v) in pg_env.iter().filter(|(k, _)| k != "JF_SHARED_NETWORK") {
        image = image.with_env_var(k.clone(), v.clone());
    }
    if let Some(net) = &art_network {
        image = image.with_network(net.clone());
    }

    // The most common skip path: no Docker/Podman socket -> start() errors.
    let container = image
        .start()
        .await
        .map_err(|e| skip(format!("container start failed: {e}")))?;
    // Perfect instrumentation: stream Artifactory's container logs to a host file.
    // For 7.x this log is where the free-space guard's exit message appears, so it
    // is the difference between a diagnosis and a guess.
    crate::served::capture_container_logs(&container, &format!("artifactory-{tag}"));

    let mut bases = Vec::new();
    for p in candidate_ports(tag) {
        if let Ok(port) = container.get_host_port_ipv4(ContainerPort::Tcp(*p)).await {
            bases.push(format!("http://127.0.0.1:{port}"));
        }
    }
    if bases.is_empty() {
        return Err(skip(format!("no mapped host port for Artifactory {tag}")));
    }

    let base_url = match wait_for_artifactory(&bases, &user, &pass, boot_timeout).await {
        Some(b) => b,
        None => {
            return Err(skip(format!(
                "Artifactory {tag} did not become ready for authenticated writes within {}s \
                 (tried {}); its container log is in $HOLGER_BENCH_DIR/artifactory-{tag}-container.log",
                boot_timeout.as_secs(),
                bases.join(", ")
            )))
        }
    };
    let artifactory_boot_secs = boot_start.elapsed().as_secs_f64();

    let client = tls_roots(reqwest::Client::builder())
        .build()
        .expect("reqwest client");
    // OSS can't create repos over REST (Pro-only); deploy into a pre-seeded
    // generic-local repo discovered from the running server instead.
    let repo_key = discover_generic_repo(&client, &base_url, &user, &pass).await?;

    let connector = ArtifactoryConnector {
        base_url: base_url.clone(),
        client,
        user,
        pass,
    };

    Ok((
        container,
        pg,
        base_url,
        connector,
        repo_key,
        artifactory_boot_secs,
    ))
}

// ── scenario: Artifactory load (MB/s + ops/s at a concurrency sweep) ────────

/// Honest, like-for-like Holger-vs-Artifactory throughput — the sibling of
/// [`crate::nexus::nexus_load`]. Upload the corpus to Artifactory (publish phase),
/// stand up a real Holger HTTP server over the same corpus
/// ([`crate::served::start_holger_gateway`]), then drive the SAME pure-Rust
/// parallel GET pool at each over HTTP (the download-heavy head-to-head).
///
/// Returns `holger_served_mbs` + `artifactory_served_mbs` (head-to-head),
/// `artifactory_upload_mbs` (publish phase, separate) and the in-process
/// `holger_inprocess_read_ceiling_mbs` (internal ceiling only). Fixture-gated.
pub async fn artifactory_load(scale: &Scale) -> Result<LoadOutcome> {
    artifactory_load_tag(scale, ART_TAG_PINNED).await
}

/// As [`artifactory_load`], but against an explicit Artifactory image `tag`.
/// Two arms run: [`ART_TAG_PINNED`] for `bench_runs` series continuity and
/// [`ART_TAG_CURRENT`] because a five-year-old EOL rival is not a comparison.
/// Every row carries `artifactory_tag` and `artifactory_boot_secs`.
pub async fn artifactory_load_tag(scale: &Scale, tag: &str) -> Result<LoadOutcome> {
    let started = Instant::now();
    let concurrency = scale.threads_mt;
    let bench = Duration::from_secs(scale.bench_secs);

    // Build self-contained archives — the SAME corpus the Nexus scenario pushes.
    let crate_path = build_crate_archive(scale.entries)?;
    let jar_path = build_jar_archive(scale.entries)?;
    let pip_path = build_pip_archive(scale.entries)?;

    // Sub-path prefixes inside the pre-seeded Artifactory repo (OSS can't create
    // repos over REST). `prefix` doubles as both the deploy sub-path and, for the
    // crate corpus, the fetch sub-path.
    let archives = [
        (crate_path.clone(), "crates-raw"),
        (jar_path.clone(), "jars-raw"),
        (pip_path.clone(), "wheels-raw"),
    ];

    // Boot Artifactory with FAIR resources detected from this machine (all
    // cores, ~75 % RAM, ~50 %-of-container heap) — not the cramped JVM default.
    // OSS is a heavy boot, so a generous window — a too-slow box degrades to a
    // recorded skip, not a fail.
    let res = FairResources::detect();
    // 7.x is a multi-service platform inside one image and takes minutes, not
    // seconds, to reach an authenticated ping. Giving it 6.x's window is how a
    // healthy modern rival gets recorded as a skip.
    let default_boot = if tag.starts_with("6.") { 300 } else { 900 };
    let (container, pg_container, base_url, connector, art_repo, artifactory_boot_secs) =
        boot_artifactory(
            &res,
            tag,
            Duration::from_secs(crate::boot_timeout_secs(default_boot)),
        )
        .await?;

    // ── PUBLISH PHASE (separate from the head-to-head): upload the corpus to
    // Artifactory and measure its push throughput → `artifactory_upload_mbs`.
    // Deploy under `<art_repo>/<prefix>/…` via the generic PUT API (OSS-OK). ──
    let mut art_bytes = 0u64;
    let art_up_start = Instant::now();
    for (path, prefix) in &archives {
        let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
        let dest = format!("{art_repo}/{prefix}");
        operations::push_znippy_to_connector(&connector, path.clone(), &dest)
            .await
            .map_err(|e| anyhow::anyhow!("push {dest}: {e}"))?;
        art_bytes += size;
    }
    let art_up = art_up_start.elapsed().as_secs_f64().max(1e-6);
    let artifactory_upload_mbs = (art_bytes as f64 / 1_048_576.0) / art_up;

    // ── Stand up a REAL Holger HTTP server over the same crate corpus. ──
    //
    // FAIRNESS FIX (årsstädning 2026-06-28, part 2): Holger is exercised over its
    // real native HTTP door (`handle_http2_request` via `start_http_gateway` — the
    // path stock cargo hits), symmetric with Artifactory's HTTP. The old in-process
    // `get_file()` cheat is demoted to `holger_inprocess_read_ceiling_mbs` (a
    // Holger-vs-Holger regression ceiling, NOT the competitor number).
    let rust_repo: std::sync::Arc<RustRepoZnippy> = std::sync::Arc::new(
        RustRepoZnippy::with_archive("rust".into(), crate_path)
            .map_err(|e| anyhow::anyhow!("open rust archive: {e:?}"))?,
    );
    let rust_files = rust_repo.list_files();
    anyhow::ensure!(!rust_files.is_empty(), "no Holger files");
    let holger_files = rust_files.len();

    let repo_backend: std::sync::Arc<dyn traits::RepositoryBackendTrait> =
        std::sync::Arc::clone(&rust_repo) as _;
    let holger_addr = served::start_holger_gateway(vec![("rust".into(), repo_backend)]).await?;
    let holger_base = format!("http://{holger_addr}");
    let holger_urls = served::crate_download_urls(&holger_base, "rust", &rust_files);
    anyhow::ensure!(!holger_urls.is_empty(), "no Holger crate-download URLs");

    // Artifactory fetch URLs: the crate corpus was pushed to `<art_repo>/crates-raw`
    // under each archive entry path, so `GET /artifactory/<art_repo>/crates-raw/{path}`
    // retrieves it. `rust_files` comes from the SAME archive's `list_files()` that the
    // push iterated, so the reconstructed paths match the deployed ones exactly.
    let artifactory_urls: Vec<String> = rust_files
        .iter()
        .map(|f| format!("{base_url}/artifactory/{art_repo}/crates-raw/{f}"))
        .collect();

    // ── HEAD-TO-HEAD: SAME pure-Rust parallel GET pool against each, over HTTP. ──
    // Holger's bench gateway is open-auth; Artifactory OSS rejects anonymous GETs
    // with 401, so its leg authenticates (admin:password) — same concurrent-HTTP
    // transport, just a constant auth header. Without this every GET 401'd and the
    // rival cell read as a misleading ~0 MB/s.
    let holger_fetch = served::bombard_http_get(&holger_urls, concurrency, bench);
    let artifactory_fetch = served::bombard_http_get_auth(
        &artifactory_urls,
        Some((ART_USER, ART_PASS)),
        concurrency,
        bench,
    );
    let holger_served_mbs = holger_fetch.mbs;
    let artifactory_served_mbs = artifactory_fetch.mbs;
    let ops_per_sec = holger_fetch.ops_per_sec;
    let artifactory_ops_per_sec = artifactory_fetch.ops_per_sec;

    // GUARD AGAINST THE EMPTY RIVAL CELL: a booted, healthy Artifactory that served
    // zero successful fetches is a real failure (wrong repo/path/auth), NOT a slow
    // run and NOT a fixture skip — surface it loudly instead of reporting ~0 MB/s.
    anyhow::ensure!(
        artifactory_fetch.ok_ops > 0,
        "Artifactory served 0/{} GETs successfully from repo '{art_repo}' \
         (corpus deployed but not retrievable — check repo/path/auth); refusing to \
         report an empty rival cell",
        artifactory_fetch.ops
    );

    // ── INTERNAL CEILING ONLY: in-process `get_file()` — Holger-vs-Holger. ──
    let ceiling_repo = std::sync::Arc::clone(&rust_repo);
    let ceiling_files = &rust_files;
    let holger_inprocess_read_ceiling_mbs =
        served::inprocess_read_ceiling(concurrency, bench, move |n, tid| {
            let idx = ((n as usize) * 7 + tid * 1337) % ceiling_files.len();
            ceiling_repo
                .get_file(&ceiling_files[idx])
                .map(|d| d.len())
                .unwrap_or(0)
        });

    drop(container);
    // 7.x only: the PostgreSQL companion goes down with the arm it served.
    drop(pg_container);

    Ok(LoadOutcome {
        concurrency,
        holger_served_mbs,
        artifactory_served_mbs,
        ops_per_sec,
        artifactory_ops_per_sec,
        holger_p50_ms: holger_fetch.p50_ms,
        holger_p99_ms: holger_fetch.p99_ms,
        holger_p999_ms: holger_fetch.p999_ms,
        holger_mean_ms: holger_fetch.mean_ms,
        artifactory_p50_ms: artifactory_fetch.p50_ms,
        artifactory_p99_ms: artifactory_fetch.p99_ms,
        artifactory_p999_ms: artifactory_fetch.p999_ms,
        artifactory_mean_ms: artifactory_fetch.mean_ms,
        artifactory_ok_ops: artifactory_fetch.ok_ops,
        artifactory_repo: art_repo,
        artifactory_upload_mbs,
        holger_inprocess_read_ceiling_mbs,
        elapsed_ms: started.elapsed().as_secs_f64() * 1000.0,
        holger_files,
        artifactory_tag: tag.to_string(),
        artifactory_boot_secs,
    })
}

/// reqwest 0.13 with reqwest 0.12 `rustls-tls` behaviour (2026-10-02): the ring
/// provider and the bundled Mozilla roots (webpki), not 0.13's aws-lc-rs + OS
/// trust store. Roots added later on the builder are merged in, as in 0.12.
#[allow(dead_code)]
fn tls_roots(b: reqwest::ClientBuilder) -> reqwest::ClientBuilder {
    let _ = rustls::crypto::ring::default_provider().install_default();
    b.tls_certs_only(
        webpki_root_certs::TLS_SERVER_ROOT_CERTS
            .iter()
            .filter_map(|c| reqwest::Certificate::from_der(c).ok()),
    )
}