Local development¶
Get a gateway running on your machine, with or without an identity provider.
Prerequisites¶
- JDK 25 (Temurin; GraalVM CE 25 only for native builds)
- A container runtime — Testcontainers and the Arconia dev services for the build, Compose for running. Docker works out of the box; Podman works too, with the setup in Running the gates on Podman
git
Node and pnpm are provisioned by the Maven build. You do not need them to build or run the gateway; they matter only for UI development loops.
Run with Compose¶
The quickest path. Starts the gateway and PostgreSQL on port 8080:
Run from source¶
The development loop. Nothing to configure — the PostgreSQL dev service starts with the application, so none of the datasource variables below are needed:
The portal is built into target/classes/static before the application starts,
so this serves the portal at / as well as the API.
To run what actually ships instead:
The build packages the React portal into the jar, so the running application
serves the portal at /.
Why the dev services are not in the jar
The Arconia dev services and the OpenTelemetry starter are declared
<optional>true</optional>. Spring Boot's repackage leaves optional
dependencies out of the jar, and the native profile pins them to test
scope, so they reach neither the jar, the container image nor the native
binary — while spring-boot:run still sees them. Maven has no
environment-oriented scope, so this is the closest equivalent to Gradle's
testAndDevelopmentOnly.
Database¶
The datasource is supplied entirely by environment — there is nothing in
application.yaml:
$ export SPRING_DATASOURCE_URL=jdbc:postgresql://localhost:5432/skillsgateway
$ export SPRING_DATASOURCE_USERNAME=skillsgateway
$ export SPRING_DATASOURCE_PASSWORD=skillsgateway
Flyway builds the schema from a single consolidated migration on startup. There is nothing to configure and nothing to run by hand.
Authentication¶
With an identity provider¶
Point the OIDC variables at your IdP:
$ export SGW_OIDC_CLIENT_ID=skills-gateway
$ export SGW_OIDC_CLIENT_SECRET=...
$ export SGW_OIDC_AUTHORIZATION_URI=https://idp.example.com/authorize
$ export SGW_OIDC_TOKEN_URI=https://idp.example.com/token
$ export SGW_OIDC_JWK_SET_URI=https://idp.example.com/jwks
The client registration id is idp and must exist at build time for
native-image, which is why the defaults are the placeholders change-me and
idp.invalid. A gateway started without these will not complete a login.
Without one — the escape hatch¶
$ ./mvnw spring-boot:run -Dspring-boot.run.arguments=--skills-gateway.dev-insecure-auth=true
$ java -jar target/skills-gateway-server-*.jar --skills-gateway.dev-insecure-auth=true
Never outside a development loop
This makes the entire web surface unauthenticated — all of /api/**,
/actuator/** and /docs — and injects a synthetic principal dev, which
is then what appears in every audit entry. The application logs a loud
warning at startup.
It does not affect the git facade: /git/** still requires a valid
personal access token.
It refuses to start once an identity provider is configured
The escape hatch is for a loop that has no identity provider. Set it
alongside a configured one — a real SGW_OIDC_CLIENT_ID, an SGW_OIDC_*
endpoint pointing anywhere other than idp.invalid, or a pinned
skills-gateway.oidc.issuer — and the gateway refuses to start, naming
what tripped it. Leave the OIDC variables unset and the placeholders shipped
in application.yaml keep the local loop working. The full rule is in
Configuration.
Verify it is up¶
/actuator/health is the only unauthenticated endpoint:
Then open http://localhost:8080/ and log in.
Probe the bare path
Use /actuator/health exactly. Health subpaths such as
/actuator/health/liveness are not permitted by the security chain and
redirect to the identity provider — which is why the Kubernetes manifests
point both probes at the bare path.
Storage¶
Git repositories live under skills-gateway.data-dir, which defaults to data
relative to the working directory. In the container image it is /data.
Storage durability is a decision, and the chart makes you state it
The chart has no default persistence.mode. ephemeral is an emptyDir
and loses the quarantine, published and hosted repositories on every pod
restart; existingClaim binds a durable volume; none keeps no volume at
all and is accepted only on the object-store backend. See
Choosing and migrating the storage backend.
Running against object storage¶
The default is the filesystem backend, which needs nothing. To develop against
the object-store backend instead, start the Compose profile that brings up a
local S3-compatible store — Floci, the same emulator the build's object-store
suites run against:
The bucket must exist before the gateway starts; create skills-gateway once
with any S3 client pointed at http://localhost:4566. The gateway probes the
bucket at startup and refuses to run if conditional writes are not honoured, so
a store that cannot serialize reference transitions is a failed start rather
than a corruption found later.
The object-store suites in ./mvnw verify do not use this Compose service at
all — they take their store from the Arconia Floci dev service, so one
container serves both bootRun through the dev service and the test suite.
Observability¶
Traces, metrics and logs can be sent to a local Grafana LGTM stack — the
grafana/otel-lgtm image, which bundles an OpenTelemetry Collector with Loki
(logs), Tempo (traces), Prometheus (metrics) and Grafana. It is provisioned as
an Arconia dev service, so there is nothing to install and
no compose file to start.
It is opt-in through the observability Spring profile and off in every
other run: ./mvnw clean verify and the e2e suite start no LGTM container and
attempt no OTLP export, which keeps the test loop fast and the logs quiet.
The stack takes a moment to pull on first use. Watch the log for the Grafana URL, which is on a random port:
Log in is not required (anonymous admin). Explore traces in Tempo, metrics in Prometheus and logs in Loki; all three are pre-provisioned as Grafana data sources by the image.
| Property | Default here | Purpose |
|---|---|---|
arconia.dev.services.lgtm.enabled |
false, true under observability |
Starts the LGTM container |
arconia.otel.enabled |
false, true under observability |
Enables the OpenTelemetry SDK and OTLP export |
Ports are random by default; pin them with
arconia.dev.services.lgtm.grafana-port and friends if you want stable
bookmarks. See the
Arconia dev services documentation
for the full property list.
Development only
The gateway ships no OpenTelemetry export in production builds. The
observability profile exists for the local development loop; wiring a
deployed gateway to a collector is a separate, not-yet-implemented concern.
UI development loop¶
Only needed when changing the portal:
$ cd src/main/frontend
$ corepack enable pnpm
$ pnpm install
$ pnpm dev # proxies /api and /actuator to localhost:8080
$ pnpm test # jsdom unit tests
$ pnpm test:stories # Storybook story tests in real chromium
$ pnpm storybook
The gates¶
All must pass before any pull request:
$ ./mvnw clean verify # Java + UI gates, packaged jar
$ (cd src/main/frontend && pnpm test:stories) # Storybook story tests in real chromium
$ (cd src/main/frontend && pnpm e2e) # real-browser e2e vs a mock OIDC IdP
$ reqstool status local -p docs/reqstool # must end "PASS"
$ openspec validate --all --strict
$ mkdocs build --strict # documentation
Use clean for the reqstool gate — incremental compilation truncates the
generated annotation files.
That list is an ordered sequence, not a set. reqstool status reads the
portal suites' JUnit output from src/main/frontend/test-results/, so running it
before pnpm e2e reports requirements as unverified that are in fact covered.
reqstool status exits 0 even when it prints FAIL
Its exit code is not a gate. Read the last line of its output — the gate
passes only when it ends PASS, and a script that checks $? will call a
failing run green.
For the documentation gate:
Running the gates on Podman¶
Podman is a supported way to run the container-backed gates, but not an unconfigured one. Both of the problems below present as a broken change rather than a broken environment — a wall of test errors, with nothing pointing at the container runtime — so they are worth setting up before the first run rather than diagnosing during it.
Export both of these; the rest of this section is why.
$ export TESTCONTAINERS_RYUK_DISABLED=true
$ export TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE=/run/user/501/podman/podman.sock
Ryuk cannot start on rootless Podman¶
Ryuk is the Testcontainers resource reaper. Under a rootless Podman machine it fails to start and every container-backed test errors:
ERROR tc.testcontainers/ryuk:0.14.0 : Could not start container
Tests run: 3, Failures: 0, Errors: 3
TESTCONTAINERS_RYUK_DISABLED=true is the fix, and the consequence is that
nothing reaps test containers. They accumulate across runs and each
published port stays forwarded by gvproxy, so prune periodically:
The alternative is a rootful machine (podman machine stop && podman machine set
--rootful && podman machine start), where Ryuk works. Note that rootless and
rootful use separate image and container storage, so everything already
pulled is pulled again.
The Floci dev service needs the in-VM socket path¶
io.floci:testcontainers-floci — which arconia-dev-services-floci wraps, and
which the object-store storage suites depend on — bind-mounts the container
runtime's socket unconditionally. Without an override it resolves to the
macOS-side path, which does not exist inside the machine VM, and the failure
reads as a container-creation fault rather than a missing socket:
TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE must name the socket inside the VM,
not on the host. Confirm it rather than copying it:
The override is unnecessary on Linux, where the host path is the VM path; CI sets neither variable.
If Testcontainers cannot find a container runtime at all¶
Testcontainers resolves the socket itself, so the docker CLI working proves
nothing — under Podman docker is an alias, and docker info succeeds against
a socket Testcontainers never consults. What it wants is /var/run/docker.sock,
which podman machine start creates as a symlink into the machine's socket:
$ ls -la /var/run/docker.sock
lrwxr-xr-x 1 root daemon ... /var/run/docker.sock -> ~/.local/share/containers/podman/machine/podman.sock
If it is missing, restart the machine. Exporting DOCKER_HOST from podman
machine inspect also works.
The symptom can look inconsistent between suites, which is what makes it slow to diagnose: the Arconia dev services resolve the runtime differently and can keep working while tests that use Testcontainers directly fail in the same build.
Give the machine enough memory¶
The default machine is small. Under a parallel build the PostgreSQL, Floci and LGTM containers plus the native-image and CycloneDX steps hit container-startup timeouts and OOM kills. Raise it in place — stop, set, start; storage is preserved:
A failed run is not always a failed change
Under load the machine intermittently stops answering mid-build — a connection refused, or a container dying during startup — and the whole suite errors on context load. The same command re-run unchanged is green. Retry once before debugging the code, and record the retry in the change's evidence rather than quietly re-running: a genuinely flaky test looks identical from here.