FROM oven/bun:1.4.2@sha256:9114c058aeae42162ee16dd5084b95fe9473970bb6bcb5b232ab1630f0546895

# This Dockerfile produces a *vanilla* management image — bun + unzip +
# bunfig, no celilo. `cele2e build-infra` runs install.sh inside a
# container started from this image and `docker commit`s the result onto
# the `celilo-e2e/management:latest` tag, so every downstream test boots
# a management container that was itself provisioned by install.sh.
#
# The vanilla tag survives the bake — the install-sh regression test
# (packages/e2e/tests/install-sh.test.ts) uses it to exercise install.sh
# end-to-end on every run.
#
# Source-mount dev loop (mount /celilo into a running container to use
# workspace source instead of the baked celilo) is provided by the
# shim that the bake step writes — see bin/e2e-bake-management.

# Make apt resilient to flaky upstream (no apt-cacher-ng masking transient errors)
COPY config/apt/99retries.conf /etc/apt/apt.conf.d/99retries.conf

RUN apt-get update && apt-get install -y \
    openssh-client \
    # openssh-server: the celilo-bootstrap deb Depends on it (remote-API server).
    # Pre-caching it here keeps the bootstrap-apt foreground `apt install` fast
    # (no download over the bumped Squid path) and mirrors reality — a real
    # management box already runs sshd. The deb still owns the celilo-api account
    # + forced-command authorized_keys; this only pre-stages the package.
    openssh-server \
    ansible \
    iproute2 \
    iputils-ping \
    net-tools \
    dnsutils \
    curl \
    unzip \
    procps \
    zsh \
    gnupg \
    # The hook jail (openspec/changes/hook-process-boundary, stage 2) runs every
    # hook under bubblewrap. Without it the management box runs hooks unjailed,
    # which is a valid mode but not the one the e2e suite is there to exercise.
    # The container needs security_opt relaxations to let it build a namespace —
    # see the measured ladder in docker-compose-generator.ts.
    bubblewrap \
    && rm -rf /var/lib/apt/lists/*

# Pre-stage what the celilo-mgmt bootstrap deploy would otherwise install on the
# clock (celilo#1076; the same move as celilo#560 for the target-machine image).
#
# `migration-restore` went red on main with a symptom that moved with machine
# load. It was not lock contention. It was the deferred celilo-mgmt deploy on
# the source box, 106s on a slow run against 56s on a fast one inside a 300s
# block budget, and 13 to 19 seconds of each was apt fetching these two over
# the simulated internet:
#   python3-pip  the role's "Install base packages" task names it
#   python3-apt  Ansible's apt module needs it on the target and installs it
#                for itself when it is missing
# Present in the image, both tasks are a no-op at deploy time.
#
# `--no-install-recommends` on purpose, and a separate RUN because of it:
# python3-pip Recommends a compiler toolchain, which is what made the deploy-time
# install slow, and nothing in the role uses pip to build anything. Already
# installed, the role's `state: present` does not go back for the Recommends.
RUN apt-get update && apt-get install -y --no-install-recommends \
    python3-apt \
    python3-pip \
    && rm -rf /var/lib/apt/lists/*

# --- Terraform, plus an OFFLINE provider mirror -------------------------------
#
# celilo runs `terraform apply` on the management box for any module placed on a
# container_service. Until the Proxmox simulator existed, no suite could reach
# that path — every test used the machine pool, where there is no infrastructure
# to create — so the rig never needed terraform at all, and the deploy failed
# with a bare `/bin/sh: 1: terraform: not found`.
#
# The provider is MIRRORED into the image rather than fetched at deploy time.
# `terraform init` would otherwise reach registry.terraform.io, and the rig
# simulates the whole internet: that name does not resolve inside the topology,
# and making it resolve would mean pointing a public name at a private address —
# the exact model violation the e2e rules prohibit. A filesystem mirror is how
# real airgapped installs do this, so the rig is if anything more faithful.
#
# Version pinned to what the module templates declare. If a module ever moves off
# `telmate/proxmox@3.0.2-rc07`, `terraform init` fails in the rig with a clear
# "provider not available in mirror" rather than silently reaching outward.
ARG TERRAFORM_VERSION=1.7.1
ARG PROXMOX_PROVIDER_VERSION=3.0.2-rc07
RUN set -eux; \
    arch="$(dpkg --print-architecture)"; \
    case "$arch" in \
      amd64) tf_arch=amd64 ;; \
      arm64) tf_arch=arm64 ;; \
      *) echo "unsupported arch: $arch" >&2; exit 1 ;; \
    esac; \
    curl -fsSL "https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_${tf_arch}.zip" -o /tmp/terraform.zip; \
    unzip -q /tmp/terraform.zip -d /usr/local/bin; \
    rm /tmp/terraform.zip; \
    terraform version

# Populate the mirror for the platform this image runs on.
RUN set -eux; \
    mkdir -p /opt/terraform-mirror /tmp/tfmirror; \
    cd /tmp/tfmirror; \
    printf 'terraform {\n  required_providers {\n    proxmox = {\n      source  = "telmate/proxmox"\n      version = "%s"\n    }\n  }\n}\n' "${PROXMOX_PROVIDER_VERSION}" > main.tf; \
    terraform providers mirror /opt/terraform-mirror; \
    rm -rf /tmp/tfmirror

# `direct` is excluded on purpose: if the mirror is missing a provider we want a
# loud failure, not a silent reach for the real internet from inside a simulated
# one.
RUN printf 'provider_installation {\n  filesystem_mirror {\n    path    = "/opt/terraform-mirror"\n    include = ["registry.terraform.io/*/*"]\n  }\n  direct {\n    exclude = ["registry.terraform.io/*/*"]\n  }\n}\n' > /etc/terraform.rc
ENV TF_CLI_CONFIG_FILE=/etc/terraform.rc

# Set zsh as default shell
RUN chsh -s /bin/zsh root

# Trust the E2E forward proxy CA (for transparent HTTPS interception)
COPY config/proxy/squid-ca.crt /usr/local/share/ca-certificates/e2e-proxy-ca.crt
# Trust Pebble's test CA (so bun fetch() trusts certs issued by the simulated Let's Encrypt)
COPY config/pebble/pebble-ca.crt /usr/local/share/ca-certificates/pebble-ca.crt
# Trust the OFF-FLEET cPanel host sim's CA. That host's cert is PRE-INSTALLED
# (as a real shared host's AutoSSL cert is) rather than issued by Pebble —
# external_web never owns TLS — so its CA is a separate, static trust anchor.
# external_web's authoritative post-publish check HTTPS-GETs the route from
# here, which is why mgmt in particular must trust it.
COPY config/cpanel-host/cpanel-host-ca.crt /usr/local/share/ca-certificates/cpanel-host-ca.crt
# Trust the Namecheap DDNS sim's CA. Its cert is likewise PRE-INSTALLED rather
# than Pebble-issued: celilo does not own a third-party API's TLS. This is what
# lets namecheap's hooks call the REAL endpoint URL over real TLS, instead of
# being redirected at an http://ip:port by an environment variable that an
# allow-listed hook environment no longer carries.
COPY config/namecheap/namecheap-ca.crt /usr/local/share/ca-certificates/namecheap-ca.crt
RUN update-ca-certificates

# Tell bun (and Node-compat code) to merge the system CA bundle with
# its built-in Mozilla bundle. Without this, `bun fetch()` ignores
# /etc/ssl/certs/ca-certificates.crt (and therefore the Pebble runtime
# root that management-routes.sh installs at startup) and rejects any
# certs Pebble issues — including those for the test domains caddy
# serves. Confirmed bitten 2026-05-06: curl worked but bun didn't.
ENV NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt

# Bun scope override: route `@celilo/*` to the simulated npm registry
# (npm-registry-sim at npm-registry.lab) so install.sh's `bun add -g
# @celilo/cli` step works without reaching real npmjs.org. Other scopes
# fall through to Bun's normal resolution.
RUN printf '[install.scopes]\n"@celilo" = "http://npm-registry.lab"\n' > /root/.bunfig.toml

# --- The fleet browser runtime (managed-browser-runtime) ----------------------
#
# Browser-driven health checks (celilo-web-console is the first) resolve
# `/var/lib/celilo/browsers/current/chrome` and a descriptor describing what
# was installed. This image provisions that runtime the way the celilo-mgmt
# role does on a real management host (`modules/celilo-mgmt/ansible/roles/
# celilo-mgmt/tasks/debian.yml`), pinned to the SAME playwright version that
# role declares — the fleet's single browser-version declaration. A module
# bundling a different client is a pin-drift finding, not a failure: the
# consumer passes the stable path explicitly.
#
# Revision comes from the pinned client itself (`install --dry-run`), never
# from a hand-maintained version→revision table — same reasoning as the role.
ARG PLAYWRIGHT_VERSION=1.60.0
RUN set -eux; \
    REVISION="$(PLAYWRIGHT_BROWSERS_PATH=/var/lib/celilo/browsers bun x "playwright-core@${PLAYWRIGHT_VERSION}" install --dry-run chromium-headless-shell \
      | grep -oE 'chromium_headless_shell-[0-9]+' | head -1 | cut -d- -f2)"; \
    PLAYWRIGHT_BROWSERS_PATH=/var/lib/celilo/browsers bun x "playwright-core@${PLAYWRIGHT_VERSION}" install chromium-headless-shell; \
    PLAYWRIGHT_BROWSERS_PATH=/var/lib/celilo/browsers bun x "playwright-core@${PLAYWRIGHT_VERSION}" install-deps chromium-headless-shell; \
    BIN="$(find /var/lib/celilo/browsers/chromium_headless_shell-"${REVISION}" -type f \
      \( -name chrome-headless-shell -o -name headless_shell \) | head -1)"; \
    test -x "${BIN}"; \
    mkdir -p /var/lib/celilo/browsers/current; \
    ln -sf "${BIN}" /var/lib/celilo/browsers/current/chrome; \
    BROWSER_VERSION="$(/var/lib/celilo/browsers/current/chrome --version | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | head -1)"; \
    printf '{"schemaVersion":1,"browser":"chromium","flavor":"headless-shell","browserVersion":"%s","playwrightVersion":"%s","revision":"%s","writtenBy":"e2e management image"}' \
      "${BROWSER_VERSION}" "${PLAYWRIGHT_VERSION}" "${REVISION}" \
      > /var/lib/celilo/browsers/current/celilo-browser.json; \
    cat /var/lib/celilo/browsers/current/celilo-browser.json

# Standard modules pre-packaged as .netapp files — built by e2e-build.
# Tests import from /netapps/<module>.netapp instead of the monorepo mount.
COPY netapps/ /netapps/

COPY config/ssh/generate-keys.sh /config/ssh/generate-keys.sh

# The remote API's sshd landing config. `celilo --remote` and the web console
# both reach this box as an ssh forced command, and the keys that may do so
# come from celilo's own api_principals table AT LOGIN -- never from a static
# authorized_keys somebody has to keep in sync. A module enrols its principal
# during its deploy, which is always after this image was built, so a file
# written at build time could not contain it (celilo#1442).
#
# Production lands on the unprivileged `celilo-api` account and the deb's
# wrapper sudo-drops it into the `celilo` user; the celilo-bootstrap postinst
# writes the same two directives for it. Here the box was provisioned by
# install.sh, which installs celilo for root and keeps its database under
# /root -- there is no `celilo` user to drop into, so root IS the privileged
# account and the landing account too. The mechanism under test is identical;
# only the account name differs, which is why `api_dest` is module config.
COPY config/ssh/sshd-celilo-api.conf /etc/ssh/sshd_config.d/50-celilo-api.conf

COPY config/routing/management-routes.sh /startup.sh
RUN chmod +x /startup.sh /config/ssh/generate-keys.sh

WORKDIR /root
CMD ["/startup.sh"]
