Service Descriptor
.spi/service.yaml tells the engineering system which test suites a service repository has and what each suite needs from a stack environment. The acceptance image includes every suite the file declares, and the deploy lane runs each one against the stack environment. Developers resolve the same file against their own stack. The repository owns the file: template sync never changes it, and changes to it are reviewed with the code.
This page covers writing a descriptor, checking it, and running its suites. The resolver's README beside .github/actions/acceptance-resolver/resolve.py is the contract the resolver checks, and ADR-040 records the decision behind it.
Prerequisites
- A local clone of the service repository. Run every command on this page from its root. Template sync delivers the resolver at
.github/actions/acceptance-resolver/resolve.py. - Python 3. The resolver uses only the standard library.
jq.- From step 5 on, the
spiCLI connected to a stack environment: runspi connect --resource-group <rg> --cluster <cluster>, thenspi status. - For running suites locally, Docker. The acceptance image is built for
linux/amd64only; on an Apple silicon Mac it runs under emulation.
Descriptor format
schemaVersion: 3
service:
name: <service>
archetype: java-maven-azure
tests:
acceptance:
type: maven
path: <service>-acceptance-test
mavenArguments: [test]
timeoutMinutes: 15
bindings:
HOST: { source: gateway }
DATA_PARTITION_ID: { source: partition }
PRIVILEGED_USER_TOKEN: { source: token }
integration:
type: maven
path: testing
mavenArguments: [-pl, <service>-test-azure, -am, test]
timeoutMinutes: 20
bindings:
ENVIRONMENT: { source: static, value: dev }
SERVICE_BASE_URL: { source: gateway, suffix: / }
MY_TENANT: { source: partition }
INTEGRATION_TESTER_ACCESS_TOKEN: { source: token }
<service> stands for the service's name; the suite paths, module names, and variable names come from the suites in the repository, not from the descriptor. The sources are the part that's fixed.
service.name is the name the stack uses for the service: the <service> argument given to spi onboard, such as partition. The deploy lane pins, verifies, and restores under this name. It is independent of the image and GHCR package name, which is the SERVICE_NAME repository variable when set and the repository name otherwise, so Azure/osdu-spi-partition and danielscholl-osdu/partition both declare partition. archetype is always java-maven-azure.
tests is a map of suites. acceptance is required, and it's the suite the image runs when no other is selected. Suite names are lowercase slugs. Every suite has the same fields:
| Field | Meaning |
|---|---|
type | Always maven |
path | The directory holding the suite's pom.xml, relative to the repository root. The image includes only the directories the suites name |
mavenArguments | Maven arguments as an array of tokens, never as one shell string. Default [verify] |
timeoutMinutes | The lane stops the suite after this many minutes. Default 25, maximum 180 |
bindings | The environment variables the suite reads, each bound to a source from the table below |
keyVaultBindings | Variable name to Key Vault secret name, for values that must never be in the file. The resolver validates it, but the lane doesn't supply these values yet |
requires, dependencies | Seeded data loads, entitlement groups, and other services the suite depends on. The resolver validates them, but the gate doesn't enforce them yet |
Single-module and reactor suites
A single module has its pom.xml at the path and runs with [test] or [verify]. Upstream's <service>-acceptance-test is laid out this way.
A reactor has a parent pom.xml at the path, with the Azure module beneath it. Set the path to the parent directory and select the module in the arguments: [-pl, <service>-test-azure, -am, test]. The image installs the reactor before it downloads dependencies, so the modules the Azure module depends on resolve from the image's local Maven repository. Upstream's testing/ tree is laid out this way.
Bindings
A binding maps an environment variable the suite reads to a source that supplies its value. Name the variable whatever the suite reads; the stack never sees these names. Two suites can bind the same source under different names, as the example does for the token.
| Source | Value | Use it for |
|---|---|---|
gateway | The stack's base URL, with suffix appended if given | Service URLs. Add suffix: / when the suite appends paths to it |
partition | The primary data partition's name | Data partition ids and tenant names |
openid | The OIDC issuer the stack publishes | Suites that discover the token endpoint |
tenant | The Entra tenant id | Suites that build authority URLs themselves |
legalTag | The primary data partition's seeded legal tag | Storage and legal suites |
domain | The entitlements domain the stack deployed (entitlements_domain) | Entitlements suites |
token | The bearer token the caller supplies as RESOLVER_TOKEN: minted per run by the lane, or from spi token on a laptop | Access-token variables. No default allowed |
memberToken | The bearer for the stack's member identity, a plain user seeded into users and each service's user group with no admin rights, supplied as RESOLVER_MEMBER_TOKEN: minted per run by the lane, or from spi token --member on a laptop | A caller with user-level entitlements only, such as NO_ACCESS_USER_TOKEN for the NO_ACCESS_USER in a suite's required-roles.json. No default allowed |
noAccessToken | The bearer for the stack's no-access identity, which holds no entitlements, supplied as RESOLVER_NO_ACCESS_TOKEN: minted per run by the lane, or from spi token --no-access on a laptop | A caller with no entitlements at all, one the service should refuse outright. No default allowed |
static | The literal value | Fixed settings such as an environment label |
template | The value with ${OTHER} references to the suite's other bindings, rendered last; never to another template or a keyvault: binding | A URL built from the gateway and a fixed path |
user | Nothing from the stack; the caller's shell supplies it, or the declared default | A setting only a developer changes |
keyvault:<name> | The named secret, from a secrets file the caller passes to the resolver | Secrets other than access tokens. The lane doesn't supply these yet; for an access token, use token |
Values and defaults
- Values from the stack, the caller, or a vault never appear in the file.
staticandtemplatebindings carry avalue.userand the six sources the stack publishes (gateway,partition,openid,tenant,legalTag,domain) accept adefault, used when nothing else supplies a value.token,memberToken,noAccessToken,keyvault:<name>,static, andtemplatedon't.
A nonempty value in the caller's environment takes precedence over the descriptor; an empty value counts as unset. That's how a developer points a suite at a service running on their laptop without editing the descriptor.
System properties
Bindings reach the suite as environment variables, and mavenArguments reach Maven unchanged, so ${env.NAME} in an argument is never expanded. A suite that reads a value only with System.getProperty needs the property mapped from the environment in its pom.xml:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<systemPropertyVariables>
<NAME>${env.NAME}</NAME>
</systemPropertyVariables>
</configuration>
</plugin>
A suite that runs its tests through Failsafe takes the same systemPropertyVariables block under maven-failsafe-plugin. Upstream owns the <service>-acceptance-test module, so a mapping it lacks belongs in an upstream merge request, not in the fork.
Create and validate a descriptor
- Find the variables each suite reads. Upstream suites read them with
System.getenvorSystem.getProperty; search the suite'ssrc/testfor both. The Azure module's README undertesting/usually lists them. A variable read withSystem.getPropertyalso needs the mapping in System properties. - Bind each variable. Use
gatewayfor the stack base URL,partitionfor the primary data partition name,tokenfor access tokens,memberTokenfor a caller with user-level entitlements only, andnoAccessTokenfor a caller with none; the table covers the rest. If no source fits, the suite needs something the stack doesn't publish. Open an issue on the stack instead of adding auserbinding with a default, because a default stays in the file after the reason for it is gone. - Set
timeoutMinutesfrom a real run's duration, plus margin. -
Check the contract:
python3 .github/actions/acceptance-resolver/resolve.py --contract-only \ --descriptor .spi/service.yaml --report /dev/stdoutExit 0 prints the suites the image will include. Exit 2 names the problem.
-
Resolve the descriptor against a stack environment:
spi info --json > facts.json export RESOLVER_TOKEN=$(spi token) export RESOLVER_MEMBER_TOKEN=$(spi token --member) # only if a suite binds memberToken export RESOLVER_NO_ACCESS_TOKEN=$(spi token --no-access) # only if a suite binds noAccessToken ( set -euo pipefail python3 .github/actions/acceptance-resolver/resolve.py --contract-only \ --descriptor .spi/service.yaml --report suites.json > /dev/null for suite in $(jq -r '.contract.suites | keys[]' suites.json); do python3 .github/actions/acceptance-resolver/resolve.py --mode run --suite "$suite" \ --descriptor .spi/service.yaml --facts facts.json \ --env-file "$suite.env" --report "$suite-report.json" done )The suite names come from the contract report, the same source the lane uses. Each suite gets an env file,
<suite>.env, and a report,<suite>-report.json. The subshell stops at the first resolver failure and returns its exit code without closing your terminal. A descriptor withkeyvault:<name>orkeyVaultBindingsalso needs--secrets <file>, a JSON object of secret name to value that you fetch from the vault yourself; without it, run mode exits 3 for those bindings.Run mode is what the lane uses: when a binding has no value, it exits 3 and names every such binding. While iterating against a personal stack,
--mode bindwarns instead.An env file can contain a token. Pass it to
docker run --env-fileand neversourceit, which would run the token as shell. -
Commit the descriptor and open a pull request.
A pull request that changes only the descriptor runs the full build, because Check Paths treats
.spi/as build-relevant. The acceptance image is built on that pull request.Expected result: in that build, the Docker Build job's "Acceptance Image" step passes, which confirms every declared path exists. Once the repository is onboarded, the Deploy and Test job runs each suite, and the Validation Summary comment reports one result line per suite.
Run suites locally
Run the suites through the acceptance image, the same way the lane does. You need:
- An acceptance image built from a commit that includes the descriptor and every suite you want to run. The Docker Push job publishes one for pushes and same-repository pull requests that trigger a build, and prints its tag:
sha-followed by the first twelve characters of the commit hash. The digest from the same job works too. - The env files and reports from step 5, resolved from the same descriptor.
image="ghcr.io/<org>/<service>-acceptance:sha-<short-sha>"
for suite in $(jq -r '.contract.suites | keys[]' suites.json); do
maven_args=()
while IFS= read -r arg; do maven_args+=("$arg"); done < <(jq -r '.contract.maven_arguments[]' "$suite-report.json")
docker run --rm --env-file "$suite.env" -e SUITE_DIR="$(jq -r .contract.test_dir "$suite-report.json")" \
"$image" "${maven_args[@]}" || { echo "suite $suite failed"; break; }
done
Each suite runs from its declared path with its declared Maven arguments, both read from its report. The arguments go through an array, as they do in the lane, so an argument such as -Dtest=*Test reaches Maven as one token instead of being expanded by the shell. The loop stops at the first failing suite, as the lane fails when any suite fails, and runs in Bash 3.2 and zsh alike.
Common mistakes
- A
userbinding with a default token. A default is stored in the repository, so a default token is a secret committed to the file. Usetoken. mavenArgumentsas one string."-pl x -am test"reaches Maven as one token and fails. Write the array.- A path outside the suites. The image contains only the declared directories plus
.mvnand.spi. A suite that reads../sharedpasses on a laptop and fails in the image. - A suite name with capitals or underscores. Names must match
^[a-z][a-z0-9-]{0,31}$.