Skip to content

Commit e7087a3

Browse files
committed
Add script for building images locally
1 parent c72ba03 commit e7087a3

3 files changed

Lines changed: 246 additions & 25 deletions

File tree

README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,39 @@ You may now run the tests with
8383
```bash
8484
uv run pytest -s .
8585
```
86+
87+
### Running against locally built images
88+
89+
By default the tests pull `ghcr.io/equinor/{flotilla-backend,sara,isar-robot}`. A change that
90+
spans armada *and* one of those services therefore cannot be verified until the service change
91+
is merged and an image published — even though the armada side is what proves the service side
92+
works.
93+
94+
To close that gap, build the images from your local working copies:
95+
96+
```bash
97+
scripts/build_local_images.sh # build and verify
98+
scripts/build_local_images.sh --run # ... and run the full suite against them
99+
```
100+
101+
The script expects the sibling checkouts of the superrepo (`../isar`, `../isar-robot`,
102+
`../flotilla`, `../sara`); override with `ISAR_DIR`, `ISAR_ROBOT_DIR`, `FLOTILLA_DIR`,
103+
`SARA_DIR`. It verifies each image before handing back, because a subtly broken build otherwise
104+
shows up only as an unexplained timeout several minutes into the suite.
105+
106+
The database schema is taken from the same local checkouts, via `FLOTILLA_MIGRATIONS_SOURCE_DIR`
107+
and `SARA_MIGRATIONS_SOURCE_DIR`, so application code and schema always agree. The directory is
108+
mounted read-only and copied into the migrations container, which means **uncommitted and
109+
untracked migrations are picked up**. Set either variable on its own if you want to mix a local
110+
schema with published images.
111+
112+
Two things worth knowing:
113+
114+
- **`flotilla`, `sara` and `isar` are built from the working tree**, so uncommitted changes are
115+
included. **`isar-robot` is cloned**, so only committed changes are — the script warns if that
116+
checkout is dirty. It has to be cloned because its Dockerfile bind-mounts `.git`, and in the
117+
superrepo that is a submodule *file* rather than a directory.
118+
- `isar-robot`'s `uv.lock` pins `isar` from PyPI, so the locally built `isar` wheel is installed
119+
over the released one.
120+
121+
The mosquitto broker is always the published image.

robotics_integration_tests/custom_containers/image_builder.py

Lines changed: 0 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,31 +7,6 @@
77

88

99
def build_image_once(path: str, tag: str) -> str:
10-
"""Build a local image, serialising concurrent builds of the same tag.
11-
12-
Several fixtures build their image from a local Dockerfile, and each of them
13-
does so per test. Under ``pytest -n auto`` that means a dozen worker
14-
*processes* can invoke ``docker build`` for the same tag at the same moment.
15-
Docker does not serialise that, and the losers fail with:
16-
17-
BuildError: creating image <tag> failed because it already exists, but
18-
accessing it also failed: No such image: <tag>
19-
20-
The race is normally hidden because every worker after the first gets a full
21-
layer-cache hit and finishes before anyone else starts. It surfaces as soon as
22-
the build context changes -- exactly when someone edits one of these images --
23-
which makes it a confusing failure to meet.
24-
25-
Session-scoped fixtures do not help here: with xdist each worker is its own
26-
process and runs its own session. A file lock is what actually serialises
27-
across processes. Once the first worker has built, the rest hit the cache and
28-
return almost immediately.
29-
30-
Returns
31-
-------
32-
str
33-
The image reference to run.
34-
"""
3510
lock_path: Path = (
3611
Path(tempfile.gettempdir()) / f"armada-build-{tag.replace('/', '_')}.lock"
3712
)

scripts/build_local_images.sh

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
#!/usr/bin/env bash
2+
#
3+
# Build the service images from local working copies and point the integration
4+
# tests at them, instead of the published :dev / :latest images.
5+
#
6+
# Why this exists
7+
# ---------------
8+
# The integration tests normally pull ghcr.io/equinor/{flotilla-backend,sara,
9+
# isar-robot}. That means a change which spans armada *and* one of the services
10+
# cannot be validated until the service change has been merged and an image
11+
# published -- but the armada side of the change is what proves the service side
12+
# works. This script closes that gap: build everything locally, run the suite,
13+
# then merge in confidence.
14+
#
15+
# Usage
16+
# -----
17+
# scripts/build_local_images.sh # build and verify the images
18+
# scripts/build_local_images.sh --run # ... and then run the full suite
19+
# scripts/build_local_images.sh --help
20+
#
21+
# Repository locations default to the superrepo sibling layout and can each be
22+
# overridden: ISAR_DIR ISAR_ROBOT_DIR FLOTILLA_DIR SARA_DIR
23+
24+
set -euo pipefail
25+
26+
TAG="${LOCAL_IMAGE_TAG:-local}"
27+
PLATFORM="linux/amd64"
28+
RUN_TESTS=false
29+
30+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
31+
ARMADA_DIR="$(dirname "$SCRIPT_DIR")"
32+
SIBLING_ROOT="$(dirname "$ARMADA_DIR")"
33+
34+
ISAR_DIR="${ISAR_DIR:-$SIBLING_ROOT/isar}"
35+
ISAR_ROBOT_DIR="${ISAR_ROBOT_DIR:-$SIBLING_ROOT/isar-robot}"
36+
FLOTILLA_DIR="${FLOTILLA_DIR:-$SIBLING_ROOT/flotilla}"
37+
SARA_DIR="${SARA_DIR:-$SIBLING_ROOT/sara}"
38+
39+
FLOTILLA_IMAGE="flotilla-backend:$TAG"
40+
SARA_IMAGE="sara:$TAG"
41+
ISAR_ROBOT_IMAGE="isar-robot:$TAG"
42+
ISAR_ROBOT_BASE_IMAGE="isar-robot:$TAG-base"
43+
44+
for arg in "$@"; do
45+
case "$arg" in
46+
--run) RUN_TESTS=true ;;
47+
--help|-h)
48+
# Print the header comment block, stopping at the first non-comment line.
49+
awk 'NR==1{next} /^#/{sub(/^# ?/,""); print; next} {exit}' "${BASH_SOURCE[0]}"
50+
exit 0 ;;
51+
*) echo "Unknown argument: $arg (try --help)" >&2; exit 2 ;;
52+
esac
53+
done
54+
55+
log() { printf '\n\033[1;34m==> %s\033[0m\n' "$*"; }
56+
warn() { printf '\033[1;33mWARNING: %s\033[0m\n' "$*" >&2; }
57+
die() { printf '\033[1;31mERROR: %s\033[0m\n' "$*" >&2; exit 1; }
58+
59+
require_dir() {
60+
[ -d "$1" ] || die "$2 not found at '$1'. Set $3 to override."
61+
}
62+
63+
require_dir "$ISAR_DIR" "isar repository" ISAR_DIR
64+
require_dir "$ISAR_ROBOT_DIR" "isar-robot repository" ISAR_ROBOT_DIR
65+
require_dir "$FLOTILLA_DIR" "flotilla repository" FLOTILLA_DIR
66+
require_dir "$SARA_DIR" "sara repository" SARA_DIR
67+
68+
docker info >/dev/null 2>&1 || die "Docker does not appear to be running."
69+
70+
TMP_DIR="$(mktemp -d)"
71+
cleanup() { rm -rf "$TMP_DIR"; }
72+
trap cleanup EXIT
73+
74+
# ---------------------------------------------------------------------------
75+
# flotilla-backend and sara are built straight from the working tree, so any
76+
# uncommitted changes are included.
77+
# ---------------------------------------------------------------------------
78+
79+
log "Building $FLOTILLA_IMAGE from $FLOTILLA_DIR"
80+
docker build --platform "$PLATFORM" \
81+
-f "$FLOTILLA_DIR/backend/Dockerfile" \
82+
-t "$FLOTILLA_IMAGE" \
83+
"$FLOTILLA_DIR/backend"
84+
85+
log "Building $SARA_IMAGE from $SARA_DIR"
86+
docker build --platform "$PLATFORM" -t "$SARA_IMAGE" "$SARA_DIR"
87+
88+
# ---------------------------------------------------------------------------
89+
# isar-robot needs two steps.
90+
#
91+
# 1. Its Dockerfile does `RUN --mount=source=.git,target=.git,type=bind`, and
92+
# setuptools_scm needs that git directory both to derive a version *and* to
93+
# discover package data such as src/isar_robot/example_data/. In the superrepo
94+
# the checkout is a submodule, so `.git` is a FILE ("gitdir: ...") which the
95+
# `!.git/` allowlist entry in .dockerignore does not match. Building directly
96+
# from the working tree therefore fails with "unable to detect version", and
97+
# forcing SETUPTOOLS_SCM_PRETEND_VERSION instead produces a wheel that is
98+
# missing example_data -- which only shows up much later as
99+
# RobotRetrieveInspectionException during a mission. Cloning into a temporary
100+
# directory yields a real .git directory with history and tags, so the stock
101+
# Dockerfile works unmodified.
102+
#
103+
# 2. isar-robot's uv.lock pins `isar` from PyPI (the lock is generated with
104+
# --no-sources, so the `[tool.uv.sources] isar = { path = "../isar" }` entry in
105+
# pyproject.toml is ignored). To test local isar changes, the locally built
106+
# wheel is installed over the released one.
107+
# ---------------------------------------------------------------------------
108+
109+
if [ -n "$(git -C "$ISAR_ROBOT_DIR" status --porcelain)" ]; then
110+
warn "$ISAR_ROBOT_DIR has uncommitted changes."
111+
warn "isar-robot is CLONED rather than built from the working tree, so those"
112+
warn "changes will NOT be in the image. Commit them first if they matter."
113+
fi
114+
115+
log "Cloning isar-robot into a temporary directory (needs a real .git)"
116+
git clone --quiet "$ISAR_ROBOT_DIR" "$TMP_DIR/isar-robot" \
117+
|| die "Failed to clone $ISAR_ROBOT_DIR"
118+
119+
log "Building $ISAR_ROBOT_BASE_IMAGE"
120+
docker build --platform "$PLATFORM" -t "$ISAR_ROBOT_BASE_IMAGE" "$TMP_DIR/isar-robot"
121+
122+
log "Building the isar wheel from $ISAR_DIR (working tree, uncommitted changes included)"
123+
mkdir -p "$TMP_DIR/wheels"
124+
if ! ( cd "$ISAR_DIR" && uv build --wheel -o "$TMP_DIR/wheels" ) >"$TMP_DIR/uv-build.log" 2>&1; then
125+
cat "$TMP_DIR/uv-build.log" >&2
126+
die "Failed to build the isar wheel"
127+
fi
128+
ISAR_WHEEL="$(ls "$TMP_DIR"/wheels/isar-*.whl 2>/dev/null | head -1)"
129+
[ -n "$ISAR_WHEEL" ] || die "No isar wheel was produced in $TMP_DIR/wheels"
130+
echo "Built $(basename "$ISAR_WHEEL")"
131+
132+
log "Overlaying the local isar onto $ISAR_ROBOT_IMAGE"
133+
# The wheel keeps its original filename: uv rejects anything that is not a valid
134+
# PEP 427 wheel name ("Must have a Python tag").
135+
mkdir -p "$TMP_DIR/overlay/wheels"
136+
cp "$ISAR_WHEEL" "$TMP_DIR/overlay/wheels/"
137+
cat > "$TMP_DIR/overlay/Dockerfile" <<OVERLAY
138+
FROM $ISAR_ROBOT_BASE_IMAGE
139+
USER root
140+
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
141+
COPY wheels /tmp/wheels
142+
# --no-deps keeps the resolved dependency set from the base image. If isar ever
143+
# gains a new dependency this will need revisiting.
144+
RUN /bin/uv pip install --python /app/.venv/bin/python --no-deps --reinstall /tmp/wheels/*.whl \
145+
&& rm -rf /tmp/wheels
146+
USER 1000
147+
CMD ["isar-start"]
148+
OVERLAY
149+
docker build --platform "$PLATFORM" -t "$ISAR_ROBOT_IMAGE" "$TMP_DIR/overlay"
150+
151+
docker run --rm --platform "$PLATFORM" \
152+
--entrypoint /app/.venv/bin/python "$ISAR_ROBOT_IMAGE" -c '
153+
import pathlib, sys
154+
import isar_robot
155+
from isar.config.settings import settings
156+
157+
problems = []
158+
159+
example_data = pathlib.Path(isar_robot.__file__).parent / "example_data"
160+
count = len(list(example_data.iterdir())) if example_data.is_dir() else 0
161+
if count == 0:
162+
problems.append(
163+
"isar_robot/example_data is missing or empty; the wheel was built without a "
164+
"usable git directory, and missions will fail with "
165+
"RobotRetrieveInspectionException"
166+
)
167+
168+
if "OPENID_CONFIG_URL" not in type(settings).model_fields:
169+
problems.append(
170+
"isar does not expose OPENID_CONFIG_URL; the local isar overlay did not take"
171+
)
172+
173+
for problem in problems:
174+
print(" FAIL " + problem)
175+
if problems:
176+
sys.exit(1)
177+
178+
print(f" OK isar-robot has {count} example_data files")
179+
print(" OK isar exposes OPENID_CONFIG_URL")
180+
' || die "isar-robot image verification failed"
181+
182+
# ---------------------------------------------------------------------------
183+
184+
PYTEST_ENV=(
185+
"FLOTILLA_BACKEND_IMAGE=$FLOTILLA_IMAGE"
186+
"SARA_IMAGE=$SARA_IMAGE"
187+
"ISAR_ROBOT_IMAGE=$ISAR_ROBOT_IMAGE"
188+
# Take the database schema from the same checkouts the images were built
189+
# from, rather than cloning the app repositories from GitHub. Without this
190+
# you would run local application code against a remote schema, and any
191+
# migration that is unpushed or uncommitted would be missed entirely.
192+
"FLOTILLA_MIGRATIONS_SOURCE_DIR=$FLOTILLA_DIR"
193+
"SARA_MIGRATIONS_SOURCE_DIR=$SARA_DIR"
194+
)
195+
196+
log "Images ready"
197+
printf ' %s\n' "$FLOTILLA_IMAGE" "$SARA_IMAGE" "$ISAR_ROBOT_IMAGE"
198+
199+
if [ "$RUN_TESTS" = true ]; then
200+
log "Running the integration tests against the local images"
201+
cd "$ARMADA_DIR"
202+
env "${PYTEST_ENV[@]}" uv run --frozen pytest -n auto robotics_integration_tests
203+
else
204+
log "Run the integration tests with:"
205+
echo
206+
printf ' cd %s\n' "$ARMADA_DIR"
207+
for pair in "${PYTEST_ENV[@]}"; do printf ' %s \\\n' "$pair"; done
208+
printf ' uv run --frozen pytest -n auto robotics_integration_tests\n\n'
209+
printf 'Or re-run this script with --run.\n\n'
210+
fi

0 commit comments

Comments
 (0)