# check=skip=FromPlatformFlagConstDisallowed
# (The constant --platform below is the point of this image, not an oversight
# -- see the FROM comment. Parser directives must lead the file, so this one
# sits above the header rather than next to what it excuses.)

# Full Virtual Accelerator image: PyAT physics + the LUME serving stack +
# osprey (the VA service ships as osprey.services.virtual_accelerator, part of
# the osprey package itself). entrypoint.py assembles it in dependency order --
# manifest -> serving database -> physics bridge -> runner -> engine source --
# and one runner then serves the facility's whole channel namespace on Channel
# Access with the physics model's own variables on PVAccess.
#
# Build context MUST be a staging directory containing exactly:
#   pyproject.toml, README.md, src/, docker/virtual-accelerator/Containerfile
# (see scripts/va/run_va.sh / build_and_boot_check.sh, which stage it) --
# NOT the repo root, which also contains .venv/.git/worktrees and would
# make every build re-tar gigabytes of unrelated content.
#
# linux/amd64, pinned -- deliberately single-arch; arm64 is not built, and
# there is no source-build path for it. pcaspy publishes no linux/aarch64
# wheel at any interpreter, so an arm64 image would have to compile EPICS base
# -> epics-modules/pcas -> pcaspy from source on every cold build. amd64 is
# also what CI runs and the only architecture on which a client importing this
# stack's CA server extension alongside pyepics has been measured to work.
# The accepted cost: on an Apple Silicon host the image runs emulated.
FROM --platform=linux/amd64 python:3.11-slim

# Refuse to build anywhere but amd64. The pin above should make this
# unreachable, but a `--platform` override on the command line, or someone
# lifting this recipe into another file, would otherwise produce an image that
# BUILDS CLEANLY AND CANNOT SERVE: there is no pcaspy wheel for linux/aarch64
# at any interpreter, and osprey's `virtual-accelerator` extra marks pcaspy
# `sys_platform == 'linux' and platform_machine == 'x86_64'` -- so on aarch64
# the Channel Access server is simply not installed, silently, because an
# unmatched environment marker is not an error. The first sign would be a
# runtime ImportError on `import pcaspy` in serving/runner.py, long after the
# build reported success. Fail here instead, where the message can say why.
RUN arch="$(dpkg --print-architecture)"; [ "$arch" = "amd64" ] \
    || { echo "ERROR: the virtual accelerator image is linux/amd64 only (this build is $arch). No pcaspy wheel exists for linux/aarch64 at any interpreter, so an aarch64 image would carry no Channel Access server." >&2; exit 1; }

# osprey itself -- manifest/lattice/ioc/serving/entrypoint live under
# src/osprey/services/virtual_accelerator/ and install as part of the
# package, so no separate COPY of a docker/virtual-accelerator source tree
# is needed. Built from the source copied in below -- never from PyPI -- so
# the image always matches whatever checkout produced it (this feature may
# not be released yet). manifest/paths.py locates the channel-finder DB JSON
# files via the installed ``osprey.templates`` package location, so this
# works with this plain (non-editable) install.
WORKDIR /opt/osprey
COPY pyproject.toml README.md /opt/osprey/
COPY src/ /opt/osprey/src/
# The osprey-connectors workspace member rides along and installs from source
# below, for the same reason osprey itself does: pip cannot see a uv workspace,
# so without this the framework's `osprey-connectors` requirement would resolve
# from PyPI -- a released snapshot beside checkout framework code, exactly the
# version skew one image built from one checkout exists to rule out.
COPY packages/ /opt/osprey/packages/

# osprey's version comes from the git tag (hatch-vcs), and the staged build
# context has no .git -- deliberately, since staging one would mean copying the
# repository history into every build. Without a version the build backend
# refuses to produce metadata at all, so the host passes the version it
# resolved and setuptools-scm is told to use it verbatim. The un-suffixed
# SETUPTOOLS_SCM_PRETEND_VERSION is the one that works here: the
# ``_FOR_<DIST_NAME>`` form needs the backend to hand setuptools-scm a dist
# name, which hatch-vcs does not, so that form is silently ignored and the
# build fails as if nothing had been set. The un-scoped variable reaches both
# source builds in this layer -- osprey and its osprey-connectors workspace
# sibling -- and that is the point: the two version with the same calendar
# stream, so one number is the correct answer for both. The build hook stamps
# whatever lands here into src/osprey/_version.py, which is what
# ``osprey.__version__`` reports at runtime -- so a build that does not pass
# OSPREY_VERSION yields an image that honestly reports an unknown version
# rather than a plausible wrong one.
ARG OSPREY_VERSION=0.0.0.dev0+unknown

# The `virtual-accelerator` extra rather than a bare install, matching the
# service Dockerfile, so both images resolve the serving dependency set the
# same way. That extra is where the whole serving stack now comes from:
# `lume-pva-apg[ca,pva]`, exact-pinned, carrying pcaspy for Channel Access and
# p4p for PVAccess -- the two transports the runner serves. None of the three
# is declared here as an install target or version constraint, deliberately,
# so this image cannot pin a serving dependency pyproject.toml disagrees with;
# the --only-binary guard below names pcaspy but pins nothing. lume-base and
# lume-pyat need no mention
# either -- they are exact-pinned core dependencies of osprey, so the resolve
# can only produce one answer for them.
#
# --only-binary pcaspy is a guard, not an optimisation. pcaspy's sdist needs a
# full EPICS base + epics-modules/pcas build -- minutes, not seconds -- and on
# amd64 a wheel always exists, so if pip ever reaches for that sdist here
# something is wrong and the build should say so rather than stall.
#
# PyAT is the exception, and the pin is not redundant. pyproject.toml declares
# `accelerator-toolbox[plot]>=0.7.1` and uv.lock resolves that to 0.7.1 --
# which is what the dev venv and every unit test run against -- but this image
# installs with pip, and pip does not read uv.lock. Without the pin the image
# silently takes the newest release satisfying the floor, so the physics
# library the container serves from would drift away from the one the lattice
# code is tested against, with no signal. This line is what keeps the two in
# step, and it must be bumped together with uv.lock. A lockfile-driven install
# would make it unnecessary, but that is a change to how the whole image
# resolves, not a change to this pin.
#
# Everything here installs from prebuilt manylinux_x86_64 wheels, so the image
# carries no C toolchain at all.
RUN pip install --no-cache-dir "accelerator-toolbox[plot]==0.7.1" \
    && SETUPTOOLS_SCM_PRETEND_VERSION="${OSPREY_VERSION}" \
       pip install --no-cache-dir ./packages/osprey-connectors \
    && SETUPTOOLS_SCM_PRETEND_VERSION="${OSPREY_VERSION}" \
       pip install --no-cache-dir ".[virtual-accelerator]" --only-binary pcaspy

# Channel Access server port. TCP only is required -- CA name-server mode
# (EPICS_CA_NAME_SERVERS=<host>:5064, EPICS_CA_AUTO_ADDR_LIST=NO on the
# client side) is the one host<->container configuration proven to work
# across container runtimes (see
# scripts/va/probe_pcaspy/README.md); UDP broadcast
# discovery is not published because it is not relied upon. 5064 matches the
# "Local Simulation" gateway preset
# (src/osprey/templates/data/facility_gateways.py) exactly, so a preset
# project needs no config changes beyond selecting control_system.type:
# virtual_accelerator.
#
# The published port and the port the server binds must be the SAME number:
# a CA search reply carries the server's own port, so a remap like
# -p 5164:5064 hands every client a port it cannot reach, with no useful
# error. The PVAccess server's port (5075) is deliberately not exposed --
# PVA is served inside the container only.
EXPOSE 5064/tcp
ENV EPICS_CA_SERVER_PORT=5064

# The entrypoint module is configuration, not a bake-time constant: a facility
# may supply its own entrypoint module (e.g. one serving a file-backed
# manifest with no lattice) without rebuilding the image. `exec` replaces the
# shell so SIGTERM reaches python directly and entrypoint.py's shutdown
# handlers still run on `docker stop`. `:-` treats the compose passthrough's
# empty string the same as unset.
#
# EPICS_CAS_SERVER_PORT is derived here rather than baked as an ENV, because
# it has to track EPICS_CA_SERVER_PORT: the CA *server* library reads the CAS
# variable and does not fall back to the client-side one, so an image whose
# CAS port were frozen at build time would keep binding 5064 while a
# `-e EPICS_CA_SERVER_PORT=...` run told its clients some other port.
CMD ["/bin/sh", "-c", "export EPICS_CAS_SERVER_PORT=\"${EPICS_CAS_SERVER_PORT:-${EPICS_CA_SERVER_PORT:-5064}}\"; exec python -u -m ${VA_ENTRYPOINT_MODULE:-osprey.services.virtual_accelerator.entrypoint}"]
