Skip to content

Commit 6845b83

Browse files
committed
feat: provide integration tests scaffolding
1 parent aa9a660 commit 6845b83

28 files changed

Lines changed: 1028 additions & 66 deletions
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
name: Set up pixi
2+
description: >
3+
Install pixi and the requested environment(s), applying the editable-install workaround for
4+
prefix-dev/pixi#3762 before the environment is solved. Shared by ci.yml and integration.yml so
5+
the workaround lives in one place.
6+
7+
inputs:
8+
environments:
9+
description: Space-separated pixi environment name(s) to install (e.g. "py313").
10+
required: true
11+
12+
runs:
13+
using: composite
14+
steps:
15+
- uses: prefix-dev/setup-pixi@v0.9.3
16+
with:
17+
run-install: false
18+
post-cleanup: false
19+
- name: Workaround for prefix-dev/pixi#3762
20+
shell: bash
21+
run: |
22+
# Editable installs break under pixi in CI; pin to non-editable before the env is solved.
23+
sed -i.bak 's@editable = true@editable = false@g' pyproject.toml
24+
rm pyproject.toml.bak
25+
git diff --stat # show what the workaround changed
26+
- uses: prefix-dev/setup-pixi@v0.9.3
27+
with:
28+
cache: false
29+
environments: ${{ inputs.environments }}

.github/workflows/ci.yml

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -38,20 +38,8 @@ jobs:
3838
steps:
3939
- name: Checkout code
4040
uses: actions/checkout@v6
41-
- uses: prefix-dev/setup-pixi@v0.9.3
41+
- uses: ./.github/actions/pixi-setup
4242
with:
43-
run-install: false
44-
post-cleanup: false
45-
- name: Apply workarounds
46-
run: |
47-
# Workaround for https://github.com/prefix-dev/pixi/issues/3762
48-
sed -i.bak 's@editable = true@editable = false@g' pyproject.toml
49-
rm pyproject.toml.bak
50-
# Show any changes
51-
git diff
52-
- uses: prefix-dev/setup-pixi@v0.9.3
53-
with:
54-
cache: false
5543
environments: ${{ matrix.python-version }}
5644
- name: Run pytest
5745
run: pixi run -e ${{ matrix.python-version }} pytest

.github/workflows/integration.yml

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# Skeleton of the manifest-driven integration workflow (IC-ADR-002 §5; scaffold issue #9).
2+
#
3+
# The matrix is generated from tests/integration/stacks.yml — adding a stack, configuration or
4+
# version is a manifest entry plus files, never a workflow edit. generate_matrix.py emits jobs only
5+
# for *harmonized* stacks (those with a stacks/<id>/compose.yml); stacks that are seeded in the
6+
# manifest but not yet harmonized are announced with a `::notice` in the matrix job and produce no
7+
# job here (so this workflow has zero jobs until the first stack lands).
8+
#
9+
# Triggers on any PR that touches the integration suite (so a stack PR self-tests the moment it
10+
# lands files — no workflow edit), plus manual/reusable dispatch. It is NOT yet gating: until issue
11+
# #14 wires it into ci.yml, has CI pull prebuilt images from GHCR (instead of building on the
12+
# runner), and makes it a required check, a red run here does not block merges.
13+
name: Integration tests
14+
15+
on:
16+
workflow_dispatch:
17+
workflow_call:
18+
pull_request:
19+
paths:
20+
- "tests/integration/**"
21+
- ".github/workflows/integration.yml"
22+
- ".github/actions/pixi-setup/**"
23+
24+
permissions:
25+
contents: read
26+
27+
jobs:
28+
matrix:
29+
runs-on: ubuntu-latest
30+
outputs:
31+
matrix: ${{ steps.gen.outputs.matrix }}
32+
steps:
33+
- uses: actions/checkout@v6
34+
- name: Generate {stack x config x version} matrix from stacks.yml
35+
id: gen
36+
# Same script developers run locally (without --github-output) to preview the matrix. It
37+
# also filters to harmonized stacks and prints a notice for the seeded-only ones.
38+
# PyYAML ships with the runner's system python; `pip install pyyaml` here if that changes.
39+
run: python3 tests/integration/generate_matrix.py --github-output
40+
41+
integration:
42+
needs: matrix
43+
if: needs.matrix.outputs.matrix != '[]'
44+
runs-on: ubuntu-latest
45+
strategy:
46+
fail-fast: false
47+
matrix:
48+
include: ${{ fromJSON(needs.matrix.outputs.matrix) }}
49+
name: ${{ matrix.stack }} / ${{ matrix.config }} / ${{ matrix.version }}
50+
env:
51+
COMPOSE_FILE: tests/integration/stacks/${{ matrix.compose_stack }}/compose.yml
52+
BACKEND_VERSION: ${{ matrix.version }}
53+
steps:
54+
- uses: actions/checkout@v6
55+
56+
# Every job in the matrix is a harmonized stack (the matrix job filtered out the rest), so
57+
# compose.yml is guaranteed present — no per-step "has this stack landed?" guard is needed.
58+
- name: Start the stack (healthchecks gate readiness)
59+
run: docker compose -f "$COMPOSE_FILE" up --wait
60+
61+
- uses: ./.github/actions/pixi-setup
62+
if: matrix.exec_in_container == false
63+
with:
64+
environments: py313
65+
66+
- name: Run the contract suite
67+
run: |
68+
if [ "${{ matrix.exec_in_container }}" = "true" ]; then
69+
# local-* stacks: run pytest inside the batch container (IC-ADR-002 §1). The batch
70+
# scheduler typically refuses to run as root (HTCondor, Slurm), so the image must ship
71+
# an unprivileged test user and the suite + pytest must be mounted or baked in.
72+
# TODO(#13/#14): mount/install the suite and pin the exec user (compose `user:` or
73+
# `exec --user <test-user>`); see docs/dev/reference/integration-stacks.md.
74+
runner=(docker compose -f "$COMPOSE_FILE" exec -T backend pytest)
75+
else
76+
runner=(pixi run -e py313 pytest)
77+
fi
78+
set +e # keep going so exit 5 (no tests selected) can be handled below
79+
"${runner[@]}" tests/integration -m "${{ matrix.markers }}" \
80+
--stack="${{ matrix.stack }}" --config="${{ matrix.config }}"
81+
rc=$?
82+
# pytest exits 5 when the stack's markers select no tests. Phase 0 ships only the
83+
# manifest tests, so the first harmonized stack would otherwise go red for "no tests
84+
# ran"; tolerate exit 5 with a notice until the Phase-2 contract suite lands.
85+
if [ "$rc" -eq 5 ]; then
86+
echo "::notice::stack '${{ matrix.stack }}': no tests matched markers '${{ matrix.markers }}' (pytest exit 5) - passing until the contract suite lands"
87+
rc=0
88+
fi
89+
exit "$rc"
90+
91+
- name: Collect daemon logs
92+
if: failure()
93+
# Daemons run in the foreground and log to stdout/stderr (IC-ADR-002 §3), so compose logs
94+
# are the whole failure story — a file-logging daemon redirects its log to /dev/stdout.
95+
run: docker compose -f "$COMPOSE_FILE" logs --no-color > compose-logs.txt || true
96+
97+
- name: Upload logs as artifacts
98+
if: failure()
99+
uses: actions/upload-artifact@v4
100+
with:
101+
name: logs-${{ matrix.stack }}-${{ matrix.config }}-${{ matrix.version }}
102+
path: compose-logs.txt

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,9 @@ htmlcov/
7676
#remove in case we want a specific LHCb one
7777
.pylintrc
7878

79+
# Integration-stack credentials: minted per run into a bind mount, never committed (IC-ADR-002 §4)
80+
tests/integration/stacks/*/credentials/
81+
7982
# docs (auto-generated build output only; docs/assets is tracked)
8083
docs/_build/
8184
site/

RENOVATE.md

Lines changed: 0 additions & 52 deletions
This file was deleted.

docs/dev/explanations/index.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Explanations
2+
3+
- [Why integration stacks — testing against real backend daemons](integration-testing.md)
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Why integration stacks — testing against real backend daemons
2+
3+
The full reasoning is [IC-ADR-002](../../adr/IC-ADR-002_integration_tests.md); this page is the
4+
short version plus the phasing plan.
5+
6+
## Why real daemons
7+
8+
interCEde's architecture is deliberately mock-friendly — everything is a `Protocol`, and the unit
9+
suite exercises contracts against fakes. That is also its greatest testing risk: a structural
10+
protocol and a fake will happily agree with each other forever while the real ARC REST endpoint
11+
or the real `condor_submit -spool` handshake drift away underneath. The bugs interCEde exists to
12+
absorb (version-specific daemon behaviour, destructive output retrieval, status-mapping quirks)
13+
are exactly the bugs unit tests structurally cannot see — HTCondor ≥ 25.8 silently turning
14+
spooled-output retrieval into a one-shot operation is the canonical example.
15+
16+
So the integration suite runs **real backend daemons** in containerised **stacks**, on every pull
17+
request, and every backend version is pinned and tracked by Renovate so an upstream release that
18+
breaks a contract surfaces as a red, bisectable PR.
19+
20+
## Capability symmetry
21+
22+
The integration suite is the executable form of the contract: it narrows to optional capabilities
23+
exactly the way the library's consumers do (`isinstance` against a `runtime_checkable` protocol)
24+
and skips where a backend genuinely lacks a capability. If a capability check works in the test
25+
harness, it works for DiracX; if a backend's structural typing lies, a real daemon is where the lie
26+
is caught. The skip helper that does this lands with the Phase-2 contract suite that calls it —
27+
its signature would otherwise be guessed ahead of its callers.
28+
29+
## Phase 0 vs Phase 2
30+
31+
**Phase 0 (now):** the `intercede` package's protocols don't exist yet, so stack PRs validate
32+
their stacks with **native client tools** (`arcsub`/`arcstat`/`arcget`, `condor_submit`,
33+
`sbatch`) in marker-selected test modules.
34+
35+
**Phase 2:** a single backend-agnostic **contract suite** (`test_submit.py`, `test_status.py`,
36+
`test_output.py`, `test_kill.py`) written against the interCEde protocols replaces the
37+
native-tool tests. Backend-specific semantics (e.g. HTCondor's destructive fetch) get dedicated
38+
marker-selected tests (`destructive_fetch`) rather than conditionals inside generic ones.
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# Add an integration stack
2+
3+
A *stack* is a named, self-contained backend environment (one `docker compose` file plus
4+
versioned configuration) that the integration suite runs against. Adding one requires **no
5+
workflow edits** — the CI matrix is generated from the manifest.
6+
7+
Before starting, read the [stack contract](../reference/integration-stacks.md) — the rules below
8+
reference it.
9+
10+
## Steps
11+
12+
1. **Copy the template**: `tests/integration/stacks/_template/`
13+
`tests/integration/stacks/<id>/`. Fill in `compose.yml` (real healthcheck, host-readable
14+
`./credentials` bind mount) and `config/basic/` (backend config + `intercede-client.toml`,
15+
which change together as one atomic configuration). An **aliased** stack (`stack:` reusing
16+
another's compose) ships only `config/` under its own id — no `compose.yml` of its own — and its
17+
`markers` exclude the borrowed transport (e.g. `and not ssh`).
18+
2. **Add the image** under `tests/integration/stacks/_images/<name>/` following the
19+
[image conventions](../reference/integration-images.md) (EL9, pinned Renovate-annotated
20+
`ARG`s, foreground daemons, ephemeral credentials at start).
21+
3. **Add the manifest entry** in `tests/integration/stacks.yml` (`id`, `configs`, `markers`,
22+
optionally `versions` / `stack:` alias / `exec_in_container`).
23+
4. **Verify locally**:
24+
25+
```sh
26+
docker compose -f tests/integration/stacks/<id>/compose.yml up --wait
27+
pixi run -e py313 pytest tests/integration -m "<markers>" --stack=<id> --config=basic
28+
pixi run -e py313 pytest tests/integration/test_manifest.py # layout checks
29+
```
30+
31+
## Harmonization checklist
32+
33+
For a stack PR (including the in-flight prototype stacks converging onto this layout):
34+
35+
- [ ] Files under `stacks/<id>/` following `_template/` (compose.yml + `config/basic/`)
36+
- [ ] `docker compose … up --wait` succeeds locally; healthchecks probe real readiness
37+
- [ ] Daemons foregrounded (no systemd), logging to stdout/stderr; Docker (not Podman) on
38+
EL9-based images
39+
- [ ] Dockerfile under `stacks/_images/<name>/` with pinned, `# renovate:`-annotated `ARG`s
40+
- [ ] Credentials generated at start, world-readable, into the host-readable `./credentials` bind
41+
mount under `credentials/<id>/…` (gitignored)
42+
- [ ] `intercede-client.toml` filled in (own `id`, endpoint, credential type/path, queue); a
43+
host-run stack publishes its port on localhost with a matching Test-CA SAN
44+
- [ ] `stacks.yml` entry present; `pytest tests/integration/test_manifest.py` green
45+
- [ ] At least one marker-selected Phase-0 smoke test ships with the stack (so its job isn't just
46+
"no tests ran" — the workflow tolerates that, but a smoke test makes the job meaningful)
47+
- [ ] Bespoke per-PR workflow removed: the manifest-driven `integration.yml` now runs
48+
automatically on any PR touching `tests/integration/**`, so the stack self-tests. It stays
49+
**non-blocking** until issue #14 (GHCR image pulls + `ci.yml` invocation + required check),
50+
so keep a bespoke workflow only if you need a *gating* check in the interim
51+
- [ ] `docker compose logs` captures the daemons' output on failure (daemons log to stdout; a
52+
file-logging daemon redirects to `/dev/stdout`)

docs/dev/how-to/index.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# How-to guides
2+
3+
- [Run the integration tests locally](run-integration-tests-locally.md)
4+
- [Add an integration stack](add-an-integration-stack.md)
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Run the integration tests locally
2+
3+
Everything CI does is reproducible on a laptop — a design rule of
4+
[IC-ADR-002](../../adr/IC-ADR-002_integration_tests.md): CI does nothing a developer cannot do
5+
locally.
6+
7+
## Run one stack
8+
9+
1. Start the stack; healthchecks gate readiness, so when `up --wait` returns the backend is
10+
usable:
11+
12+
```sh
13+
docker compose -f tests/integration/stacks/<id>/compose.yml up --wait
14+
```
15+
16+
2. Run the applicable test subset — the stack's `markers:` expression is in
17+
`tests/integration/stacks.yml`:
18+
19+
```sh
20+
pixi run -e py313 pytest tests/integration -m "<markers>" --stack=<id> --config=basic
21+
```
22+
23+
For `local-*` stacks (`exec_in_container: true`) the tests run *inside* the batch container
24+
instead:
25+
26+
```sh
27+
docker compose -f tests/integration/stacks/<compose-stack>/compose.yml exec --user <test-user> backend \
28+
pytest tests/integration -m "<markers>" --stack=<id> --config=basic
29+
```
30+
31+
Two conditions this recipe depends on (both open TODOs, #13/#14): the image must ship the suite
32+
and pytest (mounted or baked in), and it must run as an **unprivileged** user — HTCondor and Slurm
33+
refuse to run jobs as root, hence `--user <test-user>` (or a compose `user:`). Note `--stack`
34+
still names the aliased entry (e.g. `local-slurm`) so its own `intercede-client.toml` is used.
35+
36+
Stacks double as development environments: leave the stack up and point your client config at
37+
`localhost` to develop against a real backend.
38+
39+
## Preview the CI matrix
40+
41+
The CI matrix is generated from `stacks.yml` by a plain script — run it locally to see exactly
42+
which `{stack × config × version}` jobs CI would create:
43+
44+
```sh
45+
pixi run -e py313 python tests/integration/generate_matrix.py
46+
```
47+
48+
(The workflow calls the same script with `--github-output`.)
49+
50+
## Manifest-only checks (no Docker needed)
51+
52+
The manifest schema and stack-layout checks run as ordinary unit tests:
53+
54+
```sh
55+
pixi run -e py313 pytest tests/integration/test_manifest.py
56+
```

0 commit comments

Comments
 (0)