Packyard Manual Test Plan¶
A hands-on walkthrough for learning the stack and verifying all key scenarios. Run each section in order — later sections depend on state created earlier.
Prerequisites¶
Stack running with CI override (HTTP only, no TLS):
# x86-64
docker compose -f compose.yml -f compose.override.ci.yml up -d
# Apple Silicon (arm64)
docker compose -f compose.yml \
-f compose.override.ci.yml \
-f compose.override.arm64.yml \
up -d
Tools needed: docker, curl, jq
Port map in local mode:
- :80 — Traefik (all subscriber-facing routes)
- :8080 — auth admin API (host-published by ci override)
- :9090 — auth Prometheus metrics (host-published by ci override)
1. Stack Health¶
What this teaches: how to verify all services are running and how health checks work.
Expected: all services running or healthy. rustfs-init should show exited (0) — it is a one-shot init container.
# Traefik is ready when this returns 200 (static file served by the `static` service)
curl -v http://localhost/gpg/lts.asc
Expected: {"status":"ok"} or similar. HTTP 200.
2. Unauthenticated Routes¶
What this teaches: /gpg/ is served by the static nginx container and is deliberately outside Traefik's forwardAuth middleware.
# Both files are public — no credentials required
curl -o /dev/null -w "%{http_code}\n" http://localhost/gpg/lts.asc
curl -o /dev/null -w "%{http_code}\n" http://localhost/gpg/cosign.pub
Expected: 200 for both.
# Confirm no Authorization header is needed (explicitly provide none)
curl -s http://localhost/gpg/lts.asc | head -3
Expected: ASCII-armored GPG key (-----BEGIN PGP PUBLIC KEY BLOCK-----).
Negative: authenticated routes without credentials return 401.
Expected: 401 — Traefik called /auth, auth rejected the missing credentials.
3. Subscription Key Lifecycle¶
What this teaches: the admin API (:8080) is the control plane. Keys are 64-char hex IDs used as HTTP Basic passwords.
3.1 Create keys¶
The examples below use core, minion, and sentinel — adjust to match the components defined in config/packyard.yml.
# core key
CORE_KEY=$(curl -s -X POST http://localhost:8080/api/v1/keys \
-H 'Content-Type: application/json' \
-d '{"component": "core", "label": "test-core"}' | jq -r '.id')
echo "core key: $CORE_KEY"
# minion key
MINION_KEY=$(curl -s -X POST http://localhost:8080/api/v1/keys \
-H 'Content-Type: application/json' \
-d '{"component": "minion", "label": "test-minion"}' | jq -r '.id')
echo "minion key: $MINION_KEY"
Expected: 64-character hex strings. HTTP 201.
# Check the full response shape
curl -s -X POST http://localhost:8080/api/v1/keys \
-H 'Content-Type: application/json' \
-d '{"component": "sentinel", "label": "test-sentinel"}' | jq .
Expected fields: id, component, label, active (true), created_at.
3.2 List and filter¶
# All keys
curl -s http://localhost:8080/api/v1/keys | jq 'length'
# Filter by component
curl -s "http://localhost:8080/api/v1/keys?component=core" | jq '.[].label'
# Invalid component — should return 400
curl -s -o /dev/null -w "%{http_code}\n" \
"http://localhost:8080/api/v1/keys?component=invalid"
Expected: 400 with {"code":"INVALID_COMPONENT","message":"..."}.
3.3 Inspect a key¶
Expected: full key object including active: true.
# Non-existent key
curl -s -o /dev/null -w "%{http_code}\n" \
"http://localhost:8080/api/v1/keys/$(printf '%064s' | tr ' ' '0')"
Expected: 404 with {"code":"KEY_NOT_FOUND","message":"..."}.
3.4 Create with expiry¶
# Key expires in the past — valid to create, will it be accepted at auth time?
curl -s -X POST http://localhost:8080/api/v1/keys \
-H 'Content-Type: application/json' \
-d '{"component": "core", "label": "expired-key", "expires_at": "2020-01-01T00:00:00Z"}' | jq .
Note the expires_at field in the response. Test whether this key passes forwardAuth (expected: 401 — store rejects expired keys).
3.5 Invalid component¶
curl -s -X POST http://localhost:8080/api/v1/keys \
-H 'Content-Type: application/json' \
-d '{"component": "unknown", "label": "bad"}' | jq .
Expected: HTTP 400, {"code":"INVALID_COMPONENT","message":"..."}.
4. ForwardAuth — Allow / Deny¶
What this teaches: every request to /rpm/, /deb/, /oci/ goes through Traefik → GET /auth → auth service. The auth service reads the X-Forwarded-Uri header to extract the component and compares it against the key's scope.
4.1 Valid key — allowed¶
# RPM: /rpm/{component}/{year}/{os-arch}/
curl -u "subscriber:${CORE_KEY}" \
-o /dev/null -w "%{http_code}\n" \
http://localhost/rpm/core/2025/el9-x86_64/
Expected: 200 — auth allowed, nginx served the directory (empty in local dev).
# DEB: /deb/{component}/{year}/
curl -u "subscriber:${CORE_KEY}" \
-o /dev/null -w "%{http_code}\n" \
http://localhost/deb/core/2025/
Expected: 200 or 404 — either means auth passed (404 = no published content yet).
4.2 Wrong password — denied¶
BAD_KEY=$(printf '%064s' | tr ' ' 'x')
curl -u "subscriber:${BAD_KEY}" \
-o /dev/null -w "%{http_code}\n" \
http://localhost/rpm/core/2025/el9-x86_64/
Expected: 401.
4.3 Short password — denied immediately¶
The auth service rejects passwords that are not exactly 64 characters before hitting the database.
curl -u "subscriber:tooshort" \
-o /dev/null -w "%{http_code}\n" \
http://localhost/rpm/core/2025/el9-x86_64/
Expected: 401 (no DB lookup happens — length check fails first).
4.4 Missing credentials entirely — denied¶
Expected: 401.
5. Component Scope Enforcement¶
What this teaches: a key scoped to core cannot access minion or sentinel repos, even with a valid key value.
The examples below assume core, minion, and sentinel are all configured in config/packyard.yml. Adjust the component names and paths to match your deployment.
# core key vs minion path — denied
curl -u "subscriber:${CORE_KEY}" \
-o /dev/null -w "%{http_code}\n" \
http://localhost/rpm/minion/2025/el9-x86_64/
# core key vs sentinel path — denied
curl -u "subscriber:${CORE_KEY}" \
-o /dev/null -w "%{http_code}\n" \
http://localhost/rpm/sentinel/2025/el9-x86_64/
# minion key vs core path — denied
curl -u "subscriber:${MINION_KEY}" \
-o /dev/null -w "%{http_code}\n" \
http://localhost/rpm/core/2025/el9-x86_64/
# minion key vs its own path — allowed
curl -u "subscriber:${MINION_KEY}" \
-o /dev/null -w "%{http_code}\n" \
http://localhost/rpm/minion/2025/el9-x86_64/
Expected: first three 401, last one 200.
5.1 OCI component extraction¶
OCI paths use the format /oci/v2/lts-{component}/.... The auth service strips the lts- prefix.
# core key on OCI core repo
curl -u "subscriber:${CORE_KEY}" \
-o /dev/null -w "%{http_code}\n" \
"http://localhost/oci/v2/lts-core/tags/list"
# core key on OCI minion repo — denied
curl -u "subscriber:${CORE_KEY}" \
-o /dev/null -w "%{http_code}\n" \
"http://localhost/oci/v2/lts-minion/tags/list"
Expected: first 200 or 404 (Zot has no images pushed yet), second 401.
# Path without lts- prefix — should be denied (unrecognised format)
curl -u "subscriber:${CORE_KEY}" \
-o /dev/null -w "%{http_code}\n" \
"http://localhost/oci/v2/someother-repo/tags/list"
Expected: 401.
6. Key Revocation¶
What this teaches: revocation is immediate — the next forwardAuth call will reject the key. Revocation is idempotent.
# Confirm key works before revocation
curl -u "subscriber:${CORE_KEY}" \
-o /dev/null -w "%{http_code}\n" \
http://localhost/rpm/core/2025/el9-x86_64/
Expected: 200.
# Revoke
curl -s -X DELETE "http://localhost:8080/api/v1/keys/${CORE_KEY}" \
-o /dev/null -w "%{http_code}\n"
Expected: 204.
# Same request immediately after — denied
curl -u "subscriber:${CORE_KEY}" \
-o /dev/null -w "%{http_code}\n" \
http://localhost/rpm/core/2025/el9-x86_64/
Expected: 401.
# Revoke again — idempotent 204 (key exists but is already inactive)
curl -s -X DELETE "http://localhost:8080/api/v1/keys/${CORE_KEY}" \
-o /dev/null -w "%{http_code}\n"
Expected: 204 (not 404).
# Revoke completely non-existent key — 404
curl -s -X DELETE \
"http://localhost:8080/api/v1/keys/$(printf '%064s' | tr ' ' '0')" \
-o /dev/null -w "%{http_code}\n"
Expected: 404.
# Inspect revoked key — still visible in admin API, active=false
curl -s "http://localhost:8080/api/v1/keys/${CORE_KEY}" | jq '{id, active}'
Expected: {"id": "...", "active": false}.
7. Prometheus Metrics¶
What this teaches: the auth service exposes counters and histograms that track every forwardAuth decision.
Expected lines:
packyard_auth_requests_total{status="allowed"} N
packyard_auth_requests_total{status="denied"} N
packyard_auth_duration_seconds_bucket{...}
packyard_auth_duration_seconds_sum N
packyard_auth_duration_seconds_count N
Counters increase with each request:
# Snapshot current allowed count
BEFORE=$(curl -s http://localhost:9090/metrics \
| grep 'packyard_auth_requests_total{status="allowed"}' | awk '{print $2}')
# Make one allowed request (use MINION_KEY which is still active)
curl -u "subscriber:${MINION_KEY}" \
-o /dev/null http://localhost/rpm/minion/2025/el9-x86_64/
# Check counter incremented
AFTER=$(curl -s http://localhost:9090/metrics \
| grep 'packyard_auth_requests_total{status="allowed"}' | awk '{print $2}')
echo "allowed: before=$BEFORE after=$AFTER"
Expected: after = before + 1.
8. Log Redaction¶
What this teaches: the auth service must never log credential values (NFR5). The Authorization header is redacted; the ClientUsername field is dropped.
Make a request that hits the auth path, then inspect the container logs:
curl -u "subscriber:${MINION_KEY}" \
http://localhost/rpm/minion/2025/el9-x86_64/ > /dev/null
docker compose -f compose.yml -f compose.override.ci.yml \
logs --no-log-prefix auth | tail -20
Expected: logs contain key_id (the ID, not the value), request method and path — but no raw Authorization header value and no subscriber username. The key value itself (MINION_KEY) must not appear in any log line.
9. Backup Verification¶
What this teaches: the backup service writes a daily sqlite3 .backup to the auth-backup volume. The backup is transaction-safe and integrity-checked.
# Trigger an on-demand backup (exec into the backup container)
docker compose -f compose.yml -f compose.override.ci.yml \
exec backup /scripts/backup-keystore.sh
# List backup files in the volume
docker compose -f compose.yml -f compose.override.ci.yml \
exec backup ls -lh /backup/
Expected: at least one file named keystore-YYYYMMDD-HHMMSS.db.
# Verify integrity of the latest backup
LATEST=$(docker compose -f compose.yml -f compose.override.ci.yml \
exec backup ls -t /backup/ | head -1 | tr -d '\r')
docker compose -f compose.yml -f compose.override.ci.yml \
exec backup sqlite3 /backup/${LATEST} "PRAGMA integrity_check; SELECT count(*) FROM subscription_key;"
Expected: ok from integrity check, then a row count matching the number of keys you created.
10. Cleanup¶
# Revoke remaining test keys
curl -s -X DELETE "http://localhost:8080/api/v1/keys/${MINION_KEY}" \
-o /dev/null -w "minion key revoked: %{http_code}\n"
# Confirm no active keys remain
curl -s http://localhost:8080/api/v1/keys | jq '[.[] | select(.active)] | length'
Expected: 0.
# Tear down everything (add -v to also remove volumes)
docker compose -f compose.yml -f compose.override.ci.yml down
What verify.sh Covers vs This Plan¶
verify.sh is a fast automated smoke test (12 checks, ~60s). This plan goes deeper:
| Scenario | verify.sh | This plan |
|---|---|---|
| Stack startup | yes | yes |
| Traefik readiness | yes | yes |
| Auth health | yes | yes |
| GPG unauthenticated | yes | yes |
| RPM valid key | yes | yes |
| RPM bad key | yes | yes |
| RPM wrong scope | yes | yes |
| DEB valid key | yes | yes |
| Metrics presence | yes | yes |
| Key revocation | revoke only | full lifecycle (idempotent + inspect) |
| OCI scope | no | yes (§5.1) |
| Short password fast-reject | no | yes (§4.3) |
| Invalid component on create | no | yes (§3.5) |
| Expired key | no | yes (§3.4) |
| Scope cross-check (minion↔core) | no | yes (§5) |
| Metric counter increment | no | yes (§7) |
| Log redaction | no | yes (§8) |
| Backup integrity | no | yes (§9) |