feat(compose): gate registry-cache to local dev only via Profile class

Move the registry-cache pull-through proxy out of the always-on `ci`
profile into its own `cache` profile, and select it based on the runtime
environment instead of statically.

Why
   The proxy only earns its keep when image pulls amortize across many
   runs on the same host. That holds on developer machines (cross-run
   image dedup, persistent host disk) but not on GitHub-hosted runners
   (fresh disk per job), where the proxy startup overhead is net loss
   without the corresponding payoff.

How
* Compose: split `registry-cache` into its own `cache` profile;
  `infinito.depends_on.registry-cache` gets `required: false` so Compose
  does not auto-start the proxy via the dependency when the profile is
  inactive; the systemd drop-in bind-mount source is gated by the new
  `INFINITO_REGISTRY_CACHE_PROXY_CONF` env var with `/dev/null` default,
  so dockerd loads an empty drop-in (no `HTTP_PROXY`, no `ExecStartPre`)
  when the proxy is not running and pulls images directly.
* New `cli/deploy/development/profile.py` `Profile` class is the SPOT
  for which compose profiles activate. KISS + SRP: `is_ci()` reads the
  three CI signals (`GITHUB_ACTIONS`, `RUNNING_ON_GITHUB`, `CI`),
  `registry_cache_active()` is its strict inverse, and `args()` returns
  the matching `--profile ...` flag list.
* `Compose` and `down.py` consume the same `Profile` so up/down cannot
  drift on which services are part of the stack.
* Documentation under `compose/registry-cache/README.md`,
  `docs/contributing/artefact/files/compose.yml.md` and
  `docs/contributing/environment/variables.md` describes the new toggle
  and the `INFINITO_REGISTRY_CACHE_PROXY_CONF` env var. Pre-existing
  documentation.md violations in those files (one SPOT self-label, one
  em dash, two path-as-link-text instances) corrected in the same pass
  per the fix-nearby rule.
* Tests: 14 new `Profile` unit tests in `test_profile.py` plus two
  scenario tests in `test_compose.py` that lock down the
  `--profile ci` (CI runner) vs `--profile ci --profile cache` (local)
  argument shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 4.7 committed 2026-04-28 00:25:44 +02:00
1 parent 40093c89fe
commit 94936f198b
9 files changed
+317 -50

No files matched your search

+9 -11
View File
@@ -8,17 +8,19 @@ from pathlib import Path
from .coredns import CoreDNSCorefileRenderer
from .network import detect_outer_network_mtu
from .proc import run_streaming
from .profile import Profile
class Compose:
"""
Small wrapper around:
INFINITO_DISTRO=<distro> docker compose --profile ci ...
INFINITO_DISTRO=<distro> docker compose --profile ci [--profile cache] ...
"""
def __init__(self, repo_root: Path, distro: str) -> None:
self.repo_root = repo_root
self.distro = distro
self.profile = Profile()
def _base_env(self) -> dict[str, str]:
env = dict(os.environ)
@@ -39,16 +41,12 @@ class Compose:
check=True,
)
env["INFINITO_IMAGE"] = result.stdout.strip()
if self.profile.registry_cache_active():
env["INFINITO_REGISTRY_CACHE_PROXY_CONF"] = (
"./compose/registry-cache/proxy.conf"
)
return env
def _is_ci(self) -> bool:
# keep this conservative (CI -> no TTY by default)
return (
os.environ.get("GITHUB_ACTIONS") == "true"
or os.environ.get("RUNNING_ON_GITHUB") == "true"
or os.environ.get("CI") == "true"
)
def run(
self,
args: list[str],
@@ -59,7 +57,7 @@ class Compose:
text: bool = True,
extra_env: dict[str, str] | None = None,
) -> subprocess.CompletedProcess:
cmd = ["docker", "compose", "--profile", "ci", *args]
cmd = ["docker", "compose", *self.profile.args(), *args]
env = self._base_env()
if extra_env:
env.update({k: str(v) for k, v in extra_env.items()})
@@ -217,7 +215,7 @@ class Compose:
- live=True streams stdout/stderr to terminal.
"""
if tty is None:
tty = not self._is_ci()
tty = not self.profile.is_ci()
args = ["exec"]
if not tty:
+6 -1
View File
@@ -7,6 +7,7 @@ import subprocess
from pathlib import Path
from .common import resolve_distro
from .profile import Profile
def _repo_root_from_here() -> Path:
@@ -31,7 +32,11 @@ def _compose_run(*, repo_root: Path, distro: str, args: list[str]) -> None:
if env_development.exists():
cmd += ["--env-file", "env.development"]
cmd += ["--profile", "ci", *args]
# Tear the registry-cache down too if it was activated for the
# corresponding `up`. Reuse the Profile decision so up/down cannot
# disagree about which services are part of the stack.
cmd += Profile().args()
cmd += list(args)
env = _base_env(distro=distro)
env.setdefault("NIX_CONFIG", "")
subprocess.run(cmd, cwd=repo_root, env=env, check=True, text=True)
+55
View File
@@ -0,0 +1,55 @@
"""Compose-profile selection for the dev/CI stack.
Single source of truth for which `docker compose --profile ...` flags
the dev tooling passes through. Lives in its own module so callers
(`Compose` in compose.py, `_compose_run` in down.py) cannot drift on
which services belong to a given run.
The decision is based on the runtime environment, not on the call
site:
* ``ci`` - always active. Carries `coredns`, `infinito` and any
other future "always present" service.
* ``cache``- active on developer machines, inactive on CI runners.
Carries `registry-cache`. Local hosts amortize the proxy
across many runs (cross-run image dedup); CI runners
get a fresh disk per job, so the proxy adds startup
latency without payoff.
"""
from __future__ import annotations
import os
class Profile:
"""Decide which docker compose profiles to activate."""
def is_ci(self) -> bool:
"""True when running on a CI runner (GitHub-hosted or generic).
Conservative on purpose: if any of the standard CI signals are
set, treat the environment as CI. ``act`` runs locally but
sets ``GITHUB_ACTIONS=true`` to mimic the runner; for the
registry-cache decision that is the same answer either way.
"""
return (
os.environ.get("GITHUB_ACTIONS") == "true"
or os.environ.get("RUNNING_ON_GITHUB") == "true"
or os.environ.get("CI") == "true"
)
def registry_cache_active(self) -> bool:
"""True iff the `cache` profile should be activated.
Inverse of ``is_ci``: developer machines benefit from the
proxy's cross-run image dedup, CI runners do not.
"""
return not self.is_ci()
def args(self) -> list[str]:
"""The ``--profile ...`` flag list to pass to docker compose."""
out = ["--profile", "ci"]
if self.registry_cache_active():
out += ["--profile", "cache"]
return out
+7 -14
View File
@@ -25,8 +25,9 @@ services:
# The CA cert volume is also bind-mounted into the `infinito`
# container so its dockerd can install the proxy CA via
# /usr/local/bin/registry-cache-ca.sh on every restart.
# `cache` profile gated; see compose/registry-cache/README.md.
registry-cache:
profiles: ["ci"]
profiles: ["cache"]
image: rpardini/docker-registry-proxy:0.6.5
container_name: infinito-registry-cache
environment:
@@ -90,17 +91,13 @@ services:
NIX_CONFIG: ${NIX_CONFIG}
image: "${INFINITO_IMAGE}"
pull_policy: "${INFINITO_PULL_POLICY:-never}"
# Gate `infinito` on `registry-cache` being healthy (not just started),
# otherwise dockerd's ExecStartPre runs registry-cache-ca.sh
# before rpardini has written /ca/ca.crt → script silently skips, dockerd
# boots with HTTP_PROXY pointing at the proxy but no MITM CA in trust →
# every pull fails with x509 unknown-authority. The healthcheck at
# `wget http://127.0.0.1:3128/ca.crt` only succeeds once the CA is on disk.
# service_healthy + required:false: see compose/registry-cache/README.md.
depends_on:
coredns:
condition: service_started
registry-cache:
condition: service_healthy
required: false
# Run systemd as PID 1
command:
@@ -160,13 +157,9 @@ services:
read_only: true
bind:
create_host_path: true
# systemd drop-in: routes inner dockerd through the registry-cache
# MITM proxy and runs registry-cache-ca.sh as ExecStartPre.
# Bind-mounted from the repo so edits take effect on the next
# `make up` without an image rebuild.
- ./compose/registry-cache/proxy.conf:/etc/systemd/system/docker.service.d/registry-cache-proxy.conf:ro
# Companion install script invoked by the drop-in above. Must be
# executable in the repo (git +x bit); bind-mount preserves perms.
# /dev/null when cache profile inactive; INFINITO_REGISTRY_CACHE_PROXY_CONF
# is set by Profile in cli/deploy/development/profile.py.
- ${INFINITO_REGISTRY_CACHE_PROXY_CONF:-/dev/null}:/etc/systemd/system/docker.service.d/registry-cache-proxy.conf:ro
- ./scripts/docker/registry-cache-ca.sh:/usr/local/bin/registry-cache-ca.sh:ro
environment:
+23 -6
View File
@@ -5,19 +5,36 @@ Static configuration that wires the `infinito` runner container to the
[compose.yml](../../compose.yml). The proxy
(`rpardini/docker-registry-proxy`) intercepts every outbound docker
registry pull from `infinito`'s inner dockerd and caches blobs and
manifests so re-deploys, parallel CI shards, and local fresh-purges
do not trigger live registry round-trips.
manifests so re-deploys and local fresh-purges do not trigger live
registry round-trips.
## Activation 🎚️
The proxy lives in its own compose profile (`cache`) and is selected
by [profile.py](../../cli/deploy/development/profile.py):
- Active on developer machines, where the host disk persists between
deploys and cross-run dedup pays off.
- Inactive in CI runs (`GITHUB_ACTIONS=true`, `RUNNING_ON_GITHUB=true`,
or `CI=true`); fresh runner disks would not benefit from cross-run
dedup and the proxy startup is net loss.
When inactive, `infinito.depends_on.registry-cache` is `required: false`
so Compose does not pull the proxy in via the dependency, and the
systemd drop-in bind-mount falls back to `/dev/null` so dockerd boots
without `HTTP_PROXY`.
## Scope 📋
- This directory MUST contain only files consumed by the
`registry-cache` compose service or its peer `infinito` container.
- The systemd drop-in `proxy.conf` is bind-mounted directly to
- The systemd drop-in `proxy.conf` is bind-mounted to
`/etc/systemd/system/docker.service.d/registry-cache-proxy.conf` via
[compose.yml](../../compose.yml), so changes take effect on the next
container start without an image rebuild.
[compose.yml](../../compose.yml) when the `cache` profile is active.
Changes take effect on the next container start without an image
rebuild.
- The companion CA-install script lives at
[scripts/docker/registry-cache-ca.sh](../../scripts/docker/registry-cache-ca.sh)
[registry-cache-ca.sh](../../scripts/docker/registry-cache-ca.sh)
because it is called by dockerd's `ExecStartPre`, which expects the
binary on the runner's `$PATH`. It is bind-mounted to
`/usr/local/bin/registry-cache-ca.sh`; the file MUST keep its
+27 -12
View File
@@ -1,6 +1,6 @@
# `compose.yml` 🐳
This page is the SPOT for rules that govern the top-level [compose.yml](../../../../compose.yml) and for the environment variables it consumes.
This page documents the rules that govern the top-level [compose.yml](../../../../compose.yml) and the environment variables it consumes.
For general documentation rules (links, writing, RFC 2119 keywords), see [documentation.md](../../documentation.md).
For CI debugging workflows that use these variables, see [ci.md](../../actions/debugging/ci.md).
@@ -31,7 +31,7 @@ All variables consumed by [compose.yml](../../../../compose.yml). Variables with
|--------------------------|-----------------|-------------------------------------------------------------------------|
| `INFINITO_IMAGE` | none (required) | Image reference used by the `infinito` service. |
| `INFINITO_PULL_POLICY` | `never` | Compose `pull_policy`. Keep `never` for local builds, `always` for CI. |
| `INFINITO_CONTAINER` | none (required) | Used directly as `container_name`. SPOT-derived from `INFINITO_DISTRO` by [defaults.sh](../../../../scripts/meta/env/defaults.sh); compose.yml reads it strictly via `${INFINITO_CONTAINER:?...}`. |
| `INFINITO_CONTAINER` | none (required) | Used directly as `container_name`. Derived from `INFINITO_DISTRO` by [defaults.sh](../../../../scripts/meta/env/defaults.sh); compose.yml reads it strictly via `${INFINITO_CONTAINER:?...}`. |
| `INFINITO_COMPILE` | `1` | Passed into the container; toggles in-container compilation steps. |
| `NIX_CONFIG` | none | Build-arg forwarded to the Dockerfile for Nix configuration. |
@@ -63,33 +63,48 @@ The `registry-cache` service runs `rpardini/docker-registry-proxy` as a
pull-through MITM proxy in front of every outbound docker registry pull
made by `infinito`'s inner dockerd. Catch-all caching: first request to
any registry (`docker.io`, `ghcr.io`, `quay.io`, `registry.k8s.io`, …)
is fetched and persisted; re-requests are served from cache. The
service sits in the `ci` profile alongside `coredns` and `infinito`, so
it starts and stops with the dev/CI stack.
is fetched and persisted; re-requests are served from cache.
The service sits in its own `cache` profile, separate from the always-on
`ci` profile that carries `coredns` and `infinito`. Profile activation
is decided by [Profile](../../../../cli/deploy/development/profile.py):
- **Local dev**: `cache` profile active, `registry-cache` runs, `infinito`
routes pulls through the proxy. Cross-run image dedup pays off here
because the host disk persists between deploys.
- **CI runs** (`GITHUB_ACTIONS=true`, `RUNNING_ON_GITHUB=true`, or
`CI=true`): `cache` profile inactive, `registry-cache` does not start,
`infinito.depends_on.registry-cache` is `required: false` so Compose
does not pull it in via the dependency. Fresh runner disks would not
benefit from cross-run dedup; the proxy startup overhead is net loss.
Wiring inside `infinito` (all bind-mounted directly via `compose.yml`,
no Dockerfile or entry.sh staging, so edits take effect on the next
container start without an image rebuild):
- the registry-cache CA bundle at `/opt/registry-cache-ca` (read-only),
- the systemd drop-in
[proxy.conf](../../../../compose/registry-cache/proxy.conf) at
`/etc/systemd/system/docker.service.d/registry-cache-proxy.conf`,
- the systemd drop-in source path is gated by
`INFINITO_REGISTRY_CACHE_PROXY_CONF`. The dev tooling sets it to
[proxy.conf](../../../../compose/registry-cache/proxy.conf) when the
`cache` profile is active and leaves it at `/dev/null` otherwise, so
systemd reads an empty drop-in when the proxy is not running and
dockerd boots without `HTTP_PROXY`,
- the install script
[registry-cache-ca.sh](../../../../scripts/docker/registry-cache-ca.sh)
at `/usr/local/bin/registry-cache-ca.sh` (must keep its git
+x bit; bind-mounts preserve host permissions).
The drop-in registers the install script as `ExecStartPre`. dockerd is
gated on `registry-cache` health (`condition: service_healthy`) so the
CA is on disk before `ExecStartPre` runs. Pulls only. Pushes are not
intercepted.
When the proxy is active, the drop-in registers the install script as
`ExecStartPre`. dockerd is gated on `registry-cache` health
(`condition: service_healthy`) so the CA is on disk before
`ExecStartPre` runs. Pulls only. Pushes are not intercepted.
| Variable | Default | Purpose |
|-----------------------------------------|----------------------------------------|----------------------------------------------------------------------------------------------------------------------|
| `INFINITO_REGISTRY_CACHE_HOST_PATH` | `/tmp/infinito/core/registry-cache/mirror` | Host path bind-mounted into `registry-cache` for blob/manifest persistence. |
| `INFINITO_REGISTRY_CACHE_CA_HOST_PATH` | `/tmp/infinito/core/registry-cache/ca` | Host path holding the proxy MITM CA bundle. Mounted writable into `registry-cache`, read-only into `infinito`. |
| `INFINITO_REGISTRY_CACHE_MAX_SIZE` | `32g` | Maximum on-disk size of the cache. Older entries are evicted when this is reached. |
| `INFINITO_REGISTRY_CACHE_PROXY_CONF` | `/dev/null` | Bind-mount source for the systemd drop-in inside `infinito`. Set by the dev tooling to the real `proxy.conf` only when the `cache` profile is active; the `/dev/null` default makes systemd load an empty drop-in so dockerd boots without `HTTP_PROXY`. |
### Networking
+5 -4
View File
@@ -13,12 +13,12 @@ App-specific container variables live next to the role that owns them, usually i
| `CMD` | One-off shell command passed to `make exec` to run non-interactively inside the dev container. When unset, `make exec` opens an interactive shell. | Optional; no default. | [container.sh](../../../scripts/tests/deploy/local/exec/container.sh) |
| `DEBUG` | Enables debug behavior in local deploy flows. | Boolean; defaults vary by helper. | [fresh-kept-app.sh](../../../scripts/tests/deploy/local/deploy/fresh-kept-app.sh), [reuse-kept-app.sh](../../../scripts/tests/deploy/local/deploy/reuse-kept-app.sh), [reuse-kept-all.sh](../../../scripts/tests/deploy/local/deploy/reuse-kept-all.sh) |
| `DISTROS` | Space-separated distro matrix used by CI input resolution. | `arch debian ubuntu fedora centos` | [defaults.sh](../../../scripts/meta/env/defaults.sh), [input.sh](../../../scripts/meta/resolve/input.sh) |
| `INFINITO_CONTAINER` | Name of the running dev container. **SPOT-derived from `INFINITO_DISTRO`** — set automatically by [defaults.sh](../../../scripts/meta/env/defaults.sh); never set manually, never re-derived elsewhere. Consumers (compose.yml, deploy CLI, purge/exec scripts) read this env strictly. Callers that change `INFINITO_DISTRO` mid-process MUST re-source `defaults.sh` to refresh. | Always `infinito_nexus_${INFINITO_DISTRO}` (derived). | [defaults.sh](../../../scripts/meta/env/defaults.sh) (SPOT) |
| `INFINITO_CONTAINER` | Name of the running dev container. **Derived from `INFINITO_DISTRO`**, set automatically by [defaults.sh](../../../scripts/meta/env/defaults.sh); never set manually, never re-derived elsewhere. Consumers (compose.yml, deploy CLI, purge/exec scripts) read this env strictly. Callers that change `INFINITO_DISTRO` mid-process MUST re-source `defaults.sh` to refresh. | Always `infinito_nexus_${INFINITO_DISTRO}` (derived). | [defaults.sh](../../../scripts/meta/env/defaults.sh) |
| `INFINITO_DISTRO` | Selected distro flavor for the dev stack and image tags. | `debian`; accepted values are `arch`, `debian`, `ubuntu`, `fedora`, `centos`. | [defaults.sh](../../../scripts/meta/env/defaults.sh), [fresh-kept-all.sh](../../../scripts/tests/deploy/local/deploy/fresh-kept-all.sh), [fresh-kept-app.sh](../../../scripts/tests/deploy/local/deploy/fresh-kept-app.sh), [fresh-purged-app.sh](../../../scripts/tests/deploy/local/deploy/fresh-purged-app.sh), [reuse-kept-all.sh](../../../scripts/tests/deploy/local/deploy/reuse-kept-all.sh) |
| `INVENTORY_DIR` | Inventory directory used by local deploy helpers. The canonical inventory file inside it is `devices.yml`. | `$HOME/inventories/localhost` when resolved automatically. | [inventory.sh](../../../scripts/meta/env/inventory.sh), [resolve.sh](../../../scripts/inventory/resolve.sh), [fresh-kept-all.sh](../../../scripts/tests/deploy/local/deploy/fresh-kept-all.sh), [fresh-kept-app.sh](../../../scripts/tests/deploy/local/deploy/fresh-kept-app.sh), [fresh-purged-app.sh](../../../scripts/tests/deploy/local/deploy/fresh-purged-app.sh), [reuse-kept-app.sh](../../../scripts/tests/deploy/local/deploy/reuse-kept-app.sh), [reset/inventory.sh](../../../scripts/tests/deploy/local/reset/inventory.sh), [reuse-kept-all.sh](../../../scripts/tests/deploy/local/deploy/reuse-kept-all.sh) |
| `INVENTORY_DIR` | Inventory directory used by local deploy helpers. The canonical inventory file inside it is `devices.yml`. | `$HOME/inventories/localhost` when resolved automatically. | [inventory.sh](../../../scripts/meta/env/inventory.sh), [resolve.sh](../../../scripts/inventory/resolve.sh), [fresh-kept-all.sh](../../../scripts/tests/deploy/local/deploy/fresh-kept-all.sh), [fresh-kept-app.sh](../../../scripts/tests/deploy/local/deploy/fresh-kept-app.sh), [fresh-purged-app.sh](../../../scripts/tests/deploy/local/deploy/fresh-purged-app.sh), [reuse-kept-app.sh](../../../scripts/tests/deploy/local/deploy/reuse-kept-app.sh), [reset inventory script](../../../scripts/tests/deploy/local/reset/inventory.sh), [reuse-kept-all.sh](../../../scripts/tests/deploy/local/deploy/reuse-kept-all.sh) |
| `LIMIT_HOST` | Ansible limit pattern for local deploys. | `localhost` | [fresh-kept-all.sh](../../../scripts/tests/deploy/local/deploy/fresh-kept-all.sh), [fresh-kept-app.sh](../../../scripts/tests/deploy/local/deploy/fresh-kept-app.sh), [fresh-purged-app.sh](../../../scripts/tests/deploy/local/deploy/fresh-purged-app.sh), [reuse-kept-app.sh](../../../scripts/tests/deploy/local/deploy/reuse-kept-app.sh), [reuse-kept-all.sh](../../../scripts/tests/deploy/local/deploy/reuse-kept-all.sh) |
| `PYTHON` | Python executable used by helper scripts. | `python3` or the active venv python. | [python.sh](../../../scripts/meta/env/python.sh), [fresh-kept-all.sh](../../../scripts/tests/deploy/local/deploy/fresh-kept-all.sh), [fresh-kept-app.sh](../../../scripts/tests/deploy/local/deploy/fresh-kept-app.sh), [fresh-purged-app.sh](../../../scripts/tests/deploy/local/deploy/fresh-purged-app.sh), [reuse-kept-app.sh](../../../scripts/tests/deploy/local/deploy/reuse-kept-app.sh), [reuse-kept-all.sh](../../../scripts/tests/deploy/local/deploy/reuse-kept-all.sh) |
| `TEST_DEPLOY_TYPE` | Inventory and deploy flavor. | `server`; accepted values are `server`, `workstation`, `universal`. | [defaults.sh](../../../scripts/meta/env/defaults.sh), [input.sh](../../../scripts/meta/resolve/input.sh), [fresh-kept-all.sh](../../../scripts/tests/deploy/local/deploy/fresh-kept-all.sh), [fresh-purged-app.sh](../../../scripts/tests/deploy/local/deploy/fresh-purged-app.sh), [reuse-kept-app.sh](../../../scripts/tests/deploy/local/deploy/reuse-kept-app.sh), [reset/inventory.sh](../../../scripts/tests/deploy/local/reset/inventory.sh), [reuse-kept-all.sh](../../../scripts/tests/deploy/local/deploy/reuse-kept-all.sh) |
| `TEST_DEPLOY_TYPE` | Inventory and deploy flavor. | `server`; accepted values are `server`, `workstation`, `universal`. | [defaults.sh](../../../scripts/meta/env/defaults.sh), [input.sh](../../../scripts/meta/resolve/input.sh), [fresh-kept-all.sh](../../../scripts/tests/deploy/local/deploy/fresh-kept-all.sh), [fresh-purged-app.sh](../../../scripts/tests/deploy/local/deploy/fresh-purged-app.sh), [reuse-kept-app.sh](../../../scripts/tests/deploy/local/deploy/reuse-kept-app.sh), [reset inventory script](../../../scripts/tests/deploy/local/reset/inventory.sh), [reuse-kept-all.sh](../../../scripts/tests/deploy/local/deploy/reuse-kept-all.sh) |
| `TEST_PATTERN` | Glob for test discovery. | `test*.py` | [defaults.sh](../../../scripts/meta/env/defaults.sh) |
| `WHITELIST` | Optional app-discovery filter. | Empty by default. | [input.sh](../../../scripts/meta/resolve/input.sh), [fresh-kept-all.sh](../../../scripts/tests/deploy/local/deploy/fresh-kept-all.sh) |
@@ -63,9 +63,10 @@ App-specific container variables live next to the role that owns them, usually i
| `INFINITO_OUTER_NETWORK_MTU` | Overrides the Docker bridge MTU used by the local compose stack. The dev tooling SHOULD auto-detect this from `/etc/docker/daemon.json`; set it explicitly only when auto-detection is unavailable or incorrect. | Unset by default; accepted values are integer MTUs in the valid Docker host range. | [compose.yml](../../../compose.yml), [compose.py](../../../cli/deploy/development/compose.py), [network.py](../../../cli/deploy/development/network.py) |
| `INFINITO_PATH` | Working directory selected inside the `infinito` container. | Computed at container start. | [entry.sh](../../../scripts/docker/entry.sh) |
| `INFINITO_PULL_POLICY` | Docker Compose pull policy. | `always` on GitHub; Compose default elsewhere. | [github.sh](../../../scripts/meta/env/github.sh), [compose.yml](../../../compose.yml) |
| `INFINITO_REGISTRY_CACHE_HOST_PATH` | Host-side path bind-mounted into the always-on `registry-cache` service for blob/manifest persistence. | `/tmp/infinito/core/registry-cache/mirror` | [compose.yml](../../../compose.yml) |
| `INFINITO_REGISTRY_CACHE_HOST_PATH` | Host-side path bind-mounted into the `registry-cache` service for blob/manifest persistence. The service is gated by the `cache` compose profile (active locally, inactive in CI; see [profile.py](../../../cli/deploy/development/profile.py)). | `/tmp/infinito/core/registry-cache/mirror` | [compose.yml](../../../compose.yml) |
| `INFINITO_REGISTRY_CACHE_CA_HOST_PATH` | Host-side path holding the registry-cache MITM CA bundle. Bind-mounted into `registry-cache` (writable) and into `infinito` (read-only) so its dockerd can install the CA via [registry-cache-ca.sh](../../../scripts/docker/registry-cache-ca.sh) before pulling. | `/tmp/infinito/core/registry-cache/ca` | [compose.yml](../../../compose.yml) |
| `INFINITO_REGISTRY_CACHE_MAX_SIZE` | Maximum on-disk size of the rpardini/docker-registry-proxy cache. | `32g` | [compose.yml](../../../compose.yml) |
| `INFINITO_REGISTRY_CACHE_PROXY_CONF` | Bind-mount source for `infinito`'s systemd drop-in at `/etc/systemd/system/docker.service.d/registry-cache-proxy.conf`. Pointed at the real `proxy.conf` only when the `cache` profile is active; otherwise defaults to `/dev/null` so dockerd boots without `HTTP_PROXY` and pulls images directly. Set automatically by [compose.py](../../../cli/deploy/development/compose.py) based on `Profile.registry_cache_active()`. | `/dev/null` | [compose.yml](../../../compose.yml) |
| `INFINITO_SRC_DIR` | Source tree path inside the container. | `/opt/src/infinito` | [Dockerfile](../../../Dockerfile), [compose.yml](../../../compose.yml), [entry.sh](../../../scripts/docker/entry.sh) |
| `INFINITO_WAIT_HEALTH_TIMEOUT_S` | Timeout while waiting for the `infinito` container to become healthy. | `600` seconds. | [compose.py](../../../cli/deploy/development/compose.py) |
| `IP4` | Static IPv4 address assigned to the `infinito` container. | Provided by the compose environment. | [compose.yml](../../../compose.yml) |
@@ -88,9 +88,18 @@ class TestComposeUpRetries(unittest.TestCase):
self.assertEqual(compose.run.call_count, 1)
sleep_mock.assert_not_called()
@patch.dict(os.environ, {"INFINITO_IMAGE": "test-image/arch"}, clear=False)
@patch.dict(
os.environ,
{
"INFINITO_IMAGE": "test-image/arch",
"GITHUB_ACTIONS": "true",
"RUNNING_ON_GITHUB": "true",
"CI": "true",
},
clear=False,
)
@patch("subprocess.run", autospec=True)
def test_run_passes_ci_profile_flag_to_docker_compose(
def test_run_uses_only_ci_profile_on_github_runner(
self, run_mock: MagicMock
) -> None:
compose = self._compose()
@@ -110,12 +119,66 @@ class TestComposeUpRetries(unittest.TestCase):
cmd = run_mock.call_args.args[0]
env = run_mock.call_args.kwargs["env"]
# On GitHub-hosted runners the registry-cache stays inactive:
# fresh disk per job means no cross-run amortization, so the
# proxy adds startup latency without a payoff. Only the `ci`
# profile fires.
self.assertEqual(
cmd,
["docker", "compose", "--profile", "ci", "ps", "-q", "infinito"],
)
self.assertEqual(env["INFINITO_DISTRO"], "arch")
self.assertNotIn("COMPOSE_PROFILES", env)
# And the proxy.conf bind-mount stays at /dev/null (the default
# baked into compose.yml), so dockerd does direct pulls.
self.assertNotIn("INFINITO_REGISTRY_CACHE_PROXY_CONF", env)
@patch.dict(
os.environ,
{
"INFINITO_IMAGE": "test-image/arch",
"GITHUB_ACTIONS": "",
"RUNNING_ON_GITHUB": "",
"CI": "",
},
clear=False,
)
@patch("subprocess.run", autospec=True)
def test_run_activates_cache_profile_locally(self, run_mock: MagicMock) -> None:
compose = self._compose()
run_mock.return_value = subprocess.CompletedProcess(
["docker", "compose"], 0, stdout="", stderr=""
)
compose.run(["ps", "-q", "infinito"], check=False, capture=True)
cmd = run_mock.call_args.args[0]
env = run_mock.call_args.kwargs["env"]
# On a developer machine the cache profile activates so the
# registry-cache joins the stack and infinito's depends_on
# gates it via service_healthy.
self.assertEqual(
cmd,
[
"docker",
"compose",
"--profile",
"ci",
"--profile",
"cache",
"ps",
"-q",
"infinito",
],
)
# And the proxy.conf bind-mount points at the real file so
# dockerd loads the systemd drop-in and routes pulls.
self.assertEqual(
env["INFINITO_REGISTRY_CACHE_PROXY_CONF"],
"./compose/registry-cache/proxy.conf",
)
@patch.dict(
os.environ,
@@ -0,0 +1,120 @@
"""Unit tests for cli.deploy.development.profile.Profile.
Covers the three public methods (`is_ci`, `registry_cache_active`,
`args`) in isolation, with each case patching `os.environ` so the
host environment cannot leak into the assertions.
"""
from __future__ import annotations
import os
import unittest
from unittest.mock import patch
from cli.deploy.development.profile import Profile
# Reset every CI signal Profile reads. Individual tests overlay the
# specific signal they want active. ``clear=True`` is too aggressive —
# it would unset PATH and break import-time machinery — so blank the
# signals explicitly instead.
_BLANK_CI_ENV = {
"GITHUB_ACTIONS": "",
"RUNNING_ON_GITHUB": "",
"CI": "",
}
class TestProfileIsCI(unittest.TestCase):
@patch.dict(os.environ, _BLANK_CI_ENV, clear=False)
def test_is_ci_false_when_no_signals_set(self) -> None:
self.assertFalse(Profile().is_ci())
@patch.dict(os.environ, {**_BLANK_CI_ENV, "GITHUB_ACTIONS": "true"}, clear=False)
def test_is_ci_true_when_github_actions_signal_set(self) -> None:
self.assertTrue(Profile().is_ci())
@patch.dict(os.environ, {**_BLANK_CI_ENV, "RUNNING_ON_GITHUB": "true"}, clear=False)
def test_is_ci_true_when_running_on_github_signal_set(self) -> None:
self.assertTrue(Profile().is_ci())
@patch.dict(os.environ, {**_BLANK_CI_ENV, "CI": "true"}, clear=False)
def test_is_ci_true_when_generic_ci_signal_set(self) -> None:
self.assertTrue(Profile().is_ci())
@patch.dict(
os.environ,
{
**_BLANK_CI_ENV,
# Match has to be exactly the literal "true" — Profile is
# conservative on purpose so a leftover "1" / "false" /
# "yes" / quoted-empty value cannot accidentally flip the
# CI gate. Each variant gets its own assertion below to
# make the contract explicit.
"GITHUB_ACTIONS": "1",
},
clear=False,
)
def test_is_ci_false_when_signal_value_is_one(self) -> None:
self.assertFalse(Profile().is_ci())
@patch.dict(os.environ, {**_BLANK_CI_ENV, "GITHUB_ACTIONS": "false"}, clear=False)
def test_is_ci_false_when_signal_value_is_explicit_false(self) -> None:
self.assertFalse(Profile().is_ci())
@patch.dict(os.environ, {**_BLANK_CI_ENV, "GITHUB_ACTIONS": "yes"}, clear=False)
def test_is_ci_false_when_signal_value_is_yes(self) -> None:
self.assertFalse(Profile().is_ci())
class TestProfileRegistryCacheActive(unittest.TestCase):
@patch.dict(os.environ, _BLANK_CI_ENV, clear=False)
def test_active_locally_when_no_ci_signal_set(self) -> None:
self.assertTrue(Profile().registry_cache_active())
@patch.dict(os.environ, {**_BLANK_CI_ENV, "GITHUB_ACTIONS": "true"}, clear=False)
def test_inactive_under_github_actions(self) -> None:
self.assertFalse(Profile().registry_cache_active())
@patch.dict(os.environ, {**_BLANK_CI_ENV, "CI": "true"}, clear=False)
def test_inactive_under_generic_ci_signal(self) -> None:
self.assertFalse(Profile().registry_cache_active())
@patch.dict(os.environ, {**_BLANK_CI_ENV, "RUNNING_ON_GITHUB": "true"}, clear=False)
def test_is_strict_inverse_of_is_ci(self) -> None:
# Sanity check the documented contract: registry_cache_active
# is the negation of is_ci, no other inputs influence it.
p = Profile()
self.assertEqual(p.registry_cache_active(), not p.is_ci())
class TestProfileArgs(unittest.TestCase):
@patch.dict(os.environ, {**_BLANK_CI_ENV, "CI": "true"}, clear=False)
def test_args_ci_only_on_runner(self) -> None:
# On CI the cache profile stays inactive (fresh disk per job
# gives no cross-run amortization) so docker compose only sees
# the ci profile.
self.assertEqual(Profile().args(), ["--profile", "ci"])
@patch.dict(os.environ, _BLANK_CI_ENV, clear=False)
def test_args_includes_cache_profile_locally(self) -> None:
# Locally the cache profile activates so the registry-cache
# joins the stack and infinito's depends_on gates it via
# service_healthy.
self.assertEqual(
Profile().args(),
["--profile", "ci", "--profile", "cache"],
)
@patch.dict(os.environ, _BLANK_CI_ENV, clear=False)
def test_args_returns_a_fresh_list_each_call(self) -> None:
# Callers may mutate the returned list (e.g. append further
# docker compose flags). Two calls must not share state.
p = Profile()
first = p.args()
first.append("--mutated")
self.assertNotIn("--mutated", p.args())
if __name__ == "__main__": # pragma: no cover
unittest.main()