Compare commits
8
Commits
v0.4.0
..
a5d0d1a070
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5d0d1a070 | ||
|
|
c9cb1103ad | ||
|
|
ec28d1e027 | ||
|
|
919ad91710 | ||
|
|
307e46e4c7 | ||
|
|
f95e5319a8 | ||
|
|
1664e47183 | ||
|
|
41a619b316 |
@@ -22,7 +22,7 @@ DATABASE_URL=postgres://... go test -tags integration ./internal/master/store/
|
|||||||
|
|
||||||
## Architecture (one-line per package)
|
## Architecture (one-line per package)
|
||||||
|
|
||||||
- `cmd/master` — wiring only: config → store → hub → jobs → web, two listeners (UI, agents over pinned TLS)
|
- `cmd/master` — wiring only: config → store → hub → jobs → web + webhook, two listeners (UI, agents over pinned TLS)
|
||||||
- `cmd/agent` — wiring only: config → docker client → inventory → job runner → master connection
|
- `cmd/agent` — wiring only: config → docker client → inventory → job runner → master connection
|
||||||
- `internal/protocol` — the WebSocket messages both sides speak (`Version`, `JobStart`, `JobResult`, ...)
|
- `internal/protocol` — the WebSocket messages both sides speak (`Version`, `JobStart`, `JobResult`, ...)
|
||||||
- `internal/pin` — master key pair, certificate and fingerprint pinning
|
- `internal/pin` — master key pair, certificate and fingerprint pinning
|
||||||
@@ -36,7 +36,12 @@ DATABASE_URL=postgres://... go test -tags integration ./internal/master/store/
|
|||||||
- `internal/master/config` — env-var loading
|
- `internal/master/config` — env-var loading
|
||||||
- `internal/master/store` — Postgres access and migrations
|
- `internal/master/store` — Postgres access and migrations
|
||||||
- `internal/master/hub` — agent connections: auth, per-connection writer, routing, job reconcile on hello
|
- `internal/master/hub` — agent connections: auth, per-connection writer, routing, job reconcile on hello
|
||||||
|
- `internal/master/registry` — HEAD-only digest lookup and tag listing against a registry (go-containerregistry, `DOCKER_CONFIG` credentials)
|
||||||
|
- `internal/master/tagver` — which tag is a newer version of a followed tag (pure functions)
|
||||||
|
- `internal/master/poller` — every `POLL_INTERVAL`: one lookup per followed `repo:tag`, sets the available version, starts auto jobs; one tag list per repo, sets the newer tag suggestion
|
||||||
- `internal/master/jobs` — decides whether a service may be updated and starts a job on the right agent
|
- `internal/master/jobs` — decides whether a service may be updated and starts a job on the right agent
|
||||||
|
- `internal/master/webhook` — `POST /update`: records the available version, starts jobs for auto services, waits for them
|
||||||
|
- `internal/master/web/icons` — embedded Simple Icons path data; slug derived from the image ref; regenerate with `go generate ./internal/master/web/icons`
|
||||||
- `internal/master/web`, `webauth` — HTMX UI behind OIDC login, SSE signals
|
- `internal/master/web`, `webauth` — HTMX UI behind OIDC login, SSE signals
|
||||||
- `internal/master/events` — in-process publish/subscribe for UI refresh signals
|
- `internal/master/events` — in-process publish/subscribe for UI refresh signals
|
||||||
- `internal/master/model` — domain types shared by the above
|
- `internal/master/model` — domain types shared by the above
|
||||||
@@ -59,11 +64,47 @@ DATABASE_URL=postgres://... go test -tags integration ./internal/master/store/
|
|||||||
- **Unknown message types are logged and ignored; malformed messages close the connection.**
|
- **Unknown message types are logged and ignored; malformed messages close the connection.**
|
||||||
- **Reconcile before routable**: the hub fails a reconnecting agent's jobs it does not
|
- **Reconcile before routable**: the hub fails a reconnecting agent's jobs it does not
|
||||||
list in `known_jobs`, and only then accepts jobs for that connection.
|
list in `known_jobs`, and only then accepts jobs for that connection.
|
||||||
- **Auth**: agents by token over pinned TLS; people by OIDC. Every state-changing UI
|
- **The webhook never creates services; new services always start `manual`.** The upsert
|
||||||
request is a POST with a CSRF token.
|
does not touch `update_mode`.
|
||||||
|
- **Drift is never corrected automatically; poll/webhook jobs are refused for a drifted
|
||||||
|
service** (`jobs.Start`, `ReasonDrifted`). Only a manual job, `RestoreDesired` or an
|
||||||
|
accept gets it out.
|
||||||
|
- **Merging is never automatic; merge is the only path that deletes a `services` row**
|
||||||
|
(`store.MergeService`). The old row keeps its id and takes the duplicate's location.
|
||||||
|
- **Auth**: agents by token over pinned TLS; people by OIDC; CI by the `UPDATER_API_KEY`
|
||||||
|
bearer token on `POST /update` only. Every state-changing UI request is a POST with a
|
||||||
|
CSRF token.
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
|
- **Registry manifest access is HEAD only** (`internal/master/registry`). Docker Hub counts a
|
||||||
|
manifest GET as a pull; the client never GETs `/manifests/`, and a test asserts this. The
|
||||||
|
only GETs are the `/v2/` ping and `tags/list` (newer-tag detection, once per repo per cycle).
|
||||||
|
- **`services.newer_ref` is a suggestion only**: no auto jobs, not even for auto services. The
|
||||||
|
poller writes it only on change; a failed tag list keeps the old value and never sets
|
||||||
|
`last_poll_error`. The UI re-checks it at render time with `tagver.Newer`, so a stale value
|
||||||
|
is hidden until the next cycle; it is also hidden when its repo is not the one the service
|
||||||
|
follows. The suggestion is computed from the service list read at the start of the cycle, so
|
||||||
|
if the service moved on meanwhile, the render-time check hides it. Tags with one numeric component or no digits (`7`, `pg16`,
|
||||||
|
`latest`) never get suggestions.
|
||||||
|
- **`image_versions` uses `''` instead of NULL** for a missing tag or digest, so
|
||||||
|
`UNIQUE (repo, tag, digest)` deduplicates. The webhook records `(repo, tag, '')`; that
|
||||||
|
row is hidden from `RecentVersions` when a row with the same tag and a digest was first
|
||||||
|
seen at or after it.
|
||||||
|
- **The UI picks a version by `image_versions.id`, never by a ref from the form**, and the row
|
||||||
|
must belong to the service's `image_repo`. A digest-pinned or tagless pick needs agent
|
||||||
|
protocol 3 (`protocol.MinPinnedRefVersion`); `Hub.Route` takes the minimum version.
|
||||||
|
- **Picking a tagless version (`repo@digest`) leaves the service following no tag:** it is no
|
||||||
|
longer polled and the plain Update button is refused.
|
||||||
|
- **`services.last_tag_digest`** is set by the poller when it starts an auto job and when it
|
||||||
|
sees the service already running the tag's digest, and by `FinishJob` after any successful
|
||||||
|
plain tag job (not pinned/tagless picks; the digest is the one the agent pinned). The poller
|
||||||
|
starts no job for that digest, nor for one `desired_ref` (`ref@digest`) already names, and
|
||||||
|
re-reads the service before `Start`; so a rollback of an auto service holds until the
|
||||||
|
registry shows a different digest. `ErrJobActive` and `NotUpdatableError` leave it unset so
|
||||||
|
the next cycle retries.
|
||||||
|
- **A bare image's running version is recorded with tag `latest`** (like `jobs.FollowedRef`);
|
||||||
|
`repo@digest` is recorded with tag `''`. Only `sha256:<64 hex>` digests are recorded.
|
||||||
- **Image matching is case-sensitive.** The tricky case is `localhost:5000/foo:v1` where
|
- **Image matching is case-sensitive.** The tricky case is `localhost:5000/foo:v1` where
|
||||||
the first colon is a port, not a tag. See `internal/imageref`.
|
the first colon is a port, not a tag. See `internal/imageref`.
|
||||||
- **Path safety uses `filepath.Rel` + ".." prefix check**, NOT `strings.HasPrefix`.
|
- **Path safety uses `filepath.Rel` + ".." prefix check**, NOT `strings.HasPrefix`.
|
||||||
@@ -79,6 +120,12 @@ DATABASE_URL=postgres://... go test -tags integration ./internal/master/store/
|
|||||||
agent's own jobs always pass it explicitly.
|
agent's own jobs always pass it explicitly.
|
||||||
- **Override entries are never pruned.** A service deleted from the stack file keeps its
|
- **Override entries are never pruned.** A service deleted from the stack file keeps its
|
||||||
`image:` line in `compose.override.yaml`.
|
`image:` line in `compose.override.yaml`.
|
||||||
|
- **The override file gets the owner of the existing override, else of the stack's first
|
||||||
|
compose file.** The exception is a root-owned existing override (written before this
|
||||||
|
fix): then the compose file's owner is used, so it stops being root-owned. The agent runs
|
||||||
|
as root, so `WriteOverride` chowns the open temp file before the rename (never the path
|
||||||
|
afterwards: a symlink could be swapped in). A failure is a `warning:` line in the job
|
||||||
|
output, never a failed job.
|
||||||
- **There is no `STACKS_ROOT`.** Stack directories must be mounted read-write at the host
|
- **There is no `STACKS_ROOT`.** Stack directories must be mounted read-write at the host
|
||||||
path (`/path:/path`), and `SELF_ID` must resolve to the agent's own container. On Docker
|
path (`/path:/path`), and `SELF_ID` must resolve to the agent's own container. On Docker
|
||||||
Desktop/OrbStack bind sources are translated, so Compose jobs fail there.
|
Desktop/OrbStack bind sources are translated, so Compose jobs fail there.
|
||||||
@@ -89,19 +136,56 @@ DATABASE_URL=postgres://... go test -tags integration ./internal/master/store/
|
|||||||
- **Swarm "succeeded" means the daemon accepted the new spec**, not that tasks converged.
|
- **Swarm "succeeded" means the daemon accepted the new spec**, not that tasks converged.
|
||||||
- **Swarm updates need manager-node API access** for the agent doing the update.
|
- **Swarm updates need manager-node API access** for the agent doing the update.
|
||||||
- **The image's default entrypoint is `updater-agent`.** The master overrides it with
|
- **The image's default entrypoint is `updater-agent`.** The master overrides it with
|
||||||
`updater-master`. There is no `POST /update` until phase 3.
|
`updater-master`.
|
||||||
|
- **The example stack runs the master as nobody (`user: "65534:65534"`).** Never add `USER`
|
||||||
|
to the Dockerfile: the agent uses the same image and needs root for the Docker socket.
|
||||||
|
- **The webhook waits via a broker signal plus a store re-read, with a fallback poll**
|
||||||
|
(the broker drops signals for slow subscribers). Marking and starting jobs runs on
|
||||||
|
`context.WithoutCancel`, so a client that disconnects cannot leave a queued job no agent
|
||||||
|
knows about; only the waiting stops. The master never fails or cancels a timed-out job.
|
||||||
|
- **A webhook job's target is `<image_repo>:<request tag>`**, built from the service's own
|
||||||
|
repo, never from the request's image string. With an explicit target the "follows no tag"
|
||||||
|
refusal does not apply; removed, self, built and container still do.
|
||||||
|
- **`available_*` is cleared by `FinishJob`** on success only when the job's `to_ref`
|
||||||
|
(as created, without `@digest`) equals `available_ref`, `available_at <= job.created_at`
|
||||||
|
(that version is now running) and, when the job's `to_ref` carries a digest, that digest
|
||||||
|
equals `available_digest`. A rollback to an older digest of the tag therefore keeps the
|
||||||
|
newer suggestion, and a digest-pinned pick does not clear a digest-less (webhook) one.
|
||||||
- **Job output is capped at `protocol.MaxJobOutput` (128 KiB, tail).** NUL bytes are
|
- **Job output is capped at `protocol.MaxJobOutput` (128 KiB, tail).** NUL bytes are
|
||||||
stripped by the hub because Postgres text rejects them.
|
stripped by the hub because Postgres text rejects them.
|
||||||
|
- **Drift compares by digest, not by string** (`model.RunsRef`): `repo:tag@D` is run by
|
||||||
|
`repo:tag` reporting digest D. Same digest under another tag is not drift. Different
|
||||||
|
repo strings (`nginx` vs `docker.io/library/nginx`) are.
|
||||||
|
- **`services.awaiting_inventory`** is true from a successful `FinishJob` until the next
|
||||||
|
inventory write for the row; `Service.Drifted()` is false meanwhile, since `desired_ref`
|
||||||
|
is ahead of `actual_ref` for a debounce interval after every job.
|
||||||
|
- **Accept never touches `last_tag_digest`, `available_*` or `update_mode`**, and the
|
||||||
|
`actual` form field (`Service.RunningRef()`, ref plus digest) is only a guard against a
|
||||||
|
changed service; the value written comes from the row. `RestoreDesired` sets
|
||||||
|
`last_tag_digest` to the drifted digest when it is another digest of the desired tag and
|
||||||
|
`last_tag_digest` is empty or equals the desired digest (otherwise it holds back a newer
|
||||||
|
digest and stays).
|
||||||
|
- **`awaiting_inventory` can hide real drift** until the next inventory change when a job
|
||||||
|
result arrives after the snapshot that shows the drift (an agent reconnect resends its
|
||||||
|
outbox after the snapshot), or when Swarm coalesces an update and a rollback into one
|
||||||
|
debounce interval. This is accepted.
|
||||||
|
- **Merge lock order is agent, then service rows by id** (as in inventory writes), and the
|
||||||
|
duplicate row is deleted before the old row is updated, because `location_key` is unique
|
||||||
|
and not deferrable. A removal racing a merge can be lost; the next snapshot fixes it.
|
||||||
|
- **The SSE stream sends a named `ping` event every 25 s**; the sidebar Live indicator relies on it.
|
||||||
|
- **Agents persist `swarm_cluster_id`.** Only managers report it, so workers show no role.
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
- Master/agent spec: `docs/superpowers/specs/2026-10-12-master-agent-design.md` (authoritative)
|
- Master/agent spec: `docs/superpowers/specs/2026-10-12-master-agent-design.md` (authoritative)
|
||||||
- Phase 1 plan: `docs/superpowers/plans/2026-10-12-master-agent-phase-1-inventory.md`
|
- Phase 1 plan: `docs/superpowers/plans/2026-10-12-master-agent-phase-1-inventory.md`
|
||||||
- Phase 2 plan: `docs/superpowers/plans/2026-10-12-master-agent-phase-2-jobs.md`
|
- Phase 2 plan: `docs/superpowers/plans/2026-10-12-master-agent-phase-2-jobs.md`
|
||||||
- Original single-instance design (historical, its webhook is gone until phase 3):
|
- Phase 3 plan: `docs/superpowers/plans/2026-10-12-master-agent-phase-3-webhook.md`
|
||||||
|
- Phase 4 plan: `docs/superpowers/plans/2026-10-13-master-agent-phase-4-polling.md`
|
||||||
|
- Phase 5 plan: `docs/superpowers/plans/2026-10-13-master-agent-phase-5-drift-merge.md`
|
||||||
|
- Original single-instance design (historical):
|
||||||
`docs/superpowers/specs/2026-05-22-package-updater-design.md`
|
`docs/superpowers/specs/2026-05-22-package-updater-design.md`
|
||||||
- Consumer-side CI integration: `gitea-action/` (calls `POST /update`, which no longer
|
- Consumer-side CI integration: `gitea-action/` (calls the master's `POST /update`)
|
||||||
exists until phase 3)
|
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ the same image: `updater-master` and `updater-agent` (the default entrypoint).
|
|||||||
|
|
||||||
Design: [master/agent spec](docs/superpowers/specs/2026-10-12-master-agent-design.md).
|
Design: [master/agent spec](docs/superpowers/specs/2026-10-12-master-agent-design.md).
|
||||||
Phase 2 plan: [jobs and manual update](docs/superpowers/plans/2026-10-12-master-agent-phase-2-jobs.md).
|
Phase 2 plan: [jobs and manual update](docs/superpowers/plans/2026-10-12-master-agent-phase-2-jobs.md).
|
||||||
|
Phase 3 plan: [webhook and auto mode](docs/superpowers/plans/2026-10-12-master-agent-phase-3-webhook.md).
|
||||||
> **Webhook:** the old `POST /update` endpoint is gone. It returns in phase 3 on the
|
Phase 4 plan: [registry polling and version history](docs/superpowers/plans/2026-10-13-master-agent-phase-4-polling.md).
|
||||||
> master; until then the Gitea action in `gitea-action/` has nothing to call.
|
Phase 5 plan: [drift and merge](docs/superpowers/plans/2026-10-13-master-agent-phase-5-drift-merge.md).
|
||||||
|
|
||||||
## What an update does
|
## What an update does
|
||||||
|
|
||||||
@@ -20,16 +20,225 @@ runs is exactly what was pulled. The log streams to the browser while it runs; j
|
|||||||
history is kept in Postgres. A service without a tag (`repo@digest`), a plain container,
|
history is kept in Postgres. A service without a tag (`repo@digest`), a plain container,
|
||||||
and the updater's own services cannot be updated, and the UI says why. A bare image
|
and the updater's own services cannot be updated, and the UI says why. A bare image
|
||||||
(`image: postgres`, no tag and no digest) counts as `postgres:latest`; only a reference
|
(`image: postgres`, no tag and no digest) counts as `postgres:latest`; only a reference
|
||||||
with a digest and no tag (`repo@sha256:...`) "follows no tag". Compose services with a `build:` section are shown but never updated
|
with a digest and no tag (`repo@sha256:...`) "follows no tag". Compose services with a
|
||||||
("built from source (build:)"). One update at a
|
`build:` section are shown but never updated ("built from source (build:)"). One update at
|
||||||
time per service, and one job at a time per agent.
|
a time per service, and one job at a time per agent.
|
||||||
|
|
||||||
## Deploying the master
|
## Deploying the master
|
||||||
|
|
||||||
Use [`deploy/master.stack.yml`](deploy/master.stack.yml) as a starting point (Swarm stack;
|
Use [`deploy/master.stack.yml`](deploy/master.stack.yml) as a starting point (Swarm stack;
|
||||||
needs Postgres and an OIDC provider). The master listens for the UI on `PORT` (default
|
needs Postgres and an OIDC provider). The master listens for the UI on `PORT` (default
|
||||||
`8080`, put it behind your reverse proxy) and for agents on `AGENT_PORT` (default `8090`,
|
`8080`, put it behind your reverse proxy) and for agents on `AGENT_PORT` (default `8090`,
|
||||||
pinned TLS, reached directly on the internal network).
|
pinned TLS, reached directly on the internal network). The example runs the master as
|
||||||
|
`nobody` (`65534`); it needs no root. The agent runs as root because access to the Docker
|
||||||
|
socket is root on the host anyway.
|
||||||
|
|
||||||
|
| Variable | Required | Default | Purpose |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `UPDATER_API_KEY` | no | — | Bearer token for `POST /update`. Without it the webhook answers `503` and the master logs `webhook_disabled`. |
|
||||||
|
| `UPDATE_TIMEOUT` | no | `5m` | How long one `POST /update` waits for its auto jobs (Go duration). |
|
||||||
|
| `POLL_INTERVAL` | no | `6h` | Time between registry polling cycles (Go duration, must be positive). The first cycle runs one minute after start. |
|
||||||
|
| `DOCKER_CONFIG` | no | `~/.docker` | Directory with a `config.json` holding registry credentials for polling, readable by the master's user. Only inline `auths` entries work; see [Registry polling](#registry-polling). |
|
||||||
|
|
||||||
|
The master's other settings (`DATABASE_URL`, `PUBLIC_URL`, `AGENT_URL`, `OIDC_*`,
|
||||||
|
`ALLOWED_GROUP`, `AGENT_IMAGE`, `PORT`, `AGENT_PORT`, `LOG_LEVEL`) are shown in
|
||||||
|
[`deploy/master.stack.yml`](deploy/master.stack.yml).
|
||||||
|
|
||||||
|
## Webhook (CI)
|
||||||
|
|
||||||
|
CI calls the master after pushing an image. The Gitea action in
|
||||||
|
[`gitea-action/`](gitea-action/README.md) does this; any HTTP client works.
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /update
|
||||||
|
Authorization: Bearer <UPDATER_API_KEY>
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{"image": "registry.example.com/myapp", "tag": "1.5.0"}
|
||||||
|
```
|
||||||
|
|
||||||
|
`image` is matched against the image repo of every service on every agent (tag and digest
|
||||||
|
in the request are ignored for matching; matching is case-sensitive). `tag` is optional.
|
||||||
|
For every match the master records the version as *available* (the overview shows an
|
||||||
|
"update available" badge), including services that cannot be updated automatically
|
||||||
|
(updater's own, `build:` and plain containers), so the badge shows for them too. Services
|
||||||
|
in **manual** mode stop there. Services in **auto**
|
||||||
|
mode also get an update job, and the request waits for those jobs to finish, at most
|
||||||
|
`UPDATE_TIMEOUT`.
|
||||||
|
|
||||||
|
**`tag` semantics.** The target of an auto job is `<service's image repo>:<tag>`, built
|
||||||
|
from the service's own repo and never from the request's `image` string. The tag must be
|
||||||
|
one that has been pushed. Compose services get pinned to it through
|
||||||
|
`compose.override.yaml`, as in any update. Omit `tag` to re-pull the tag the service
|
||||||
|
already follows (a bare image counts as `:latest`); a service that follows no tag
|
||||||
|
(`repo@digest`) is then not touched and is reported as `refused` ("no tag given and the
|
||||||
|
service follows no tag"), manual or auto.
|
||||||
|
With a `tag`, the "follows no tag" refusal does not apply; the other refusals (the
|
||||||
|
updater's own services, `build:` services, plain containers) still do.
|
||||||
|
|
||||||
|
Response (`200` or `500`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"request_id": "…", "image": "registry.example.com/myapp", "tag": "1.5.0", "matched": 2,
|
||||||
|
"results": [
|
||||||
|
{"service_id": 12, "service": "myapp/web", "kind": "compose", "mode": "auto",
|
||||||
|
"status": "succeeded", "job_id": 42, "job_url": "https://updater.example.com/jobs/42",
|
||||||
|
"to_ref": "registry.example.com/myapp:1.5.0", "duration_ms": 8210},
|
||||||
|
{"service_id": 15, "service": "myapp/worker", "kind": "compose", "mode": "manual",
|
||||||
|
"status": "marked", "to_ref": "registry.example.com/myapp:1.5.0", "duration_ms": 3}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
| `status` | Meaning | Counts as failure |
|
||||||
|
|---|---|---|
|
||||||
|
| `succeeded` | Auto job finished successfully. | no |
|
||||||
|
| `failed` | Auto job failed (for example agent offline), or the version could not be recorded. `error` says why. | yes |
|
||||||
|
| `timeout` | Job still running when `UPDATE_TIMEOUT` passed. The master leaves it running. | yes |
|
||||||
|
| `marked` | Manual service; the version was recorded only. | no |
|
||||||
|
| `busy` | The service already has a queued or running job; `job_id` is that job, which is not waited for. | no |
|
||||||
|
| `refused` | The service may not be updated, or there is no target (no `tag` given and the service follows no tag). `error` has the reason. | no |
|
||||||
|
|
||||||
|
| Code | When |
|
||||||
|
|---|---|
|
||||||
|
| `200` | No result is `failed` or `timeout`. |
|
||||||
|
| `400` | Body is not JSON, `image` is missing, or `tag` is not a valid Docker tag. |
|
||||||
|
| `401` | Missing or wrong bearer token. |
|
||||||
|
| `404` | No service uses the image (catches typos in CI configuration). |
|
||||||
|
| `500` | At least one result is `failed` or `timeout`. |
|
||||||
|
| `503` | `UPDATER_API_KEY` is not set on the master. |
|
||||||
|
|
||||||
|
A service that has **drifted** (see [Drift and moved services](#drift-and-moved-services))
|
||||||
|
is refused with its reason, in auto mode too, until someone restores or accepts it.
|
||||||
|
|
||||||
|
The webhook never creates services: the first deployment of a stack is done by hand. A
|
||||||
|
new service always starts in **manual** mode; switch it with the *Make auto* button on the
|
||||||
|
overview or the service page. Auto is refused for the updater's own services, `build:`
|
||||||
|
services and plain containers.
|
||||||
|
|
||||||
|
> **Warning:** auto mode deploys whatever CI pushes, without anyone looking at it. Using
|
||||||
|
> it on critical services is your own risk.
|
||||||
|
|
||||||
|
The request is open for as long as the jobs run, so the reverse proxy in front of the
|
||||||
|
master must allow a read timeout longer than `UPDATE_TIMEOUT`. If the client disconnects,
|
||||||
|
marking and starting jobs still complete; only the waiting stops.
|
||||||
|
|
||||||
|
## Registry polling
|
||||||
|
|
||||||
|
Besides the webhook, the master asks each registry whether the tag a service follows has
|
||||||
|
a new digest. Every `POLL_INTERVAL` it sends one `HEAD /v2/<repo>/manifests/<tag>` per
|
||||||
|
distinct followed `repo:tag` (a bare image counts as `:latest`; `repo:tag@digest` is polled
|
||||||
|
as `repo:tag`). It never downloads a manifest: Docker Hub counts a `GET` as a pull against
|
||||||
|
the rate limit and a `HEAD` as nothing
|
||||||
|
([source](https://docs.docker.com/docker-hub/usage/pulls/)), and Docker Hub also has a
|
||||||
|
separate abuse limit for many pulls from one IP address, so the poller stays HEAD-only. For a
|
||||||
|
multi-arch image the digest is the index digest, the same one `docker inspect` shows in
|
||||||
|
`RepoDigests`.
|
||||||
|
|
||||||
|
- **What counts as new.** A digest different from the one the service runs becomes the
|
||||||
|
service's *available* version (the overview badge, source `poll`). A digest the master
|
||||||
|
already suggested is not suggested again. A service in **auto** mode also gets an update
|
||||||
|
job to `repo:tag` (the agent resolves the digest itself, so agents on protocol version 2
|
||||||
|
keep working). The master remembers the newest digest it started or deployed for the tag
|
||||||
|
in `services.last_tag_digest` (set when polling starts a job, when polling sees the
|
||||||
|
service already running that digest, and when any plain tag job succeeds, whoever started
|
||||||
|
it) and starts no job for that digest again, whether the job
|
||||||
|
failed or the user rolled back afterwards. If the service already had a job when the
|
||||||
|
digest was found, the next cycle tries again.
|
||||||
|
- **Newer tags.** For a service that follows a version-like tag (`1.27.3`, `v1.2.3`,
|
||||||
|
`11.0-latest`; at least two numeric components), the master also lists the repo's tags
|
||||||
|
once per cycle (`GET /v2/<repo>/tags/list`, which Docker Hub does not count as a pull) and
|
||||||
|
suggests the highest tag with the same prefix, suffix and number of components and the
|
||||||
|
same major number, for example `gitea:1.27.3` gives `1.28.0` but never `2.0.0`. The
|
||||||
|
overview shows a "Newer tag" badge and the service page lists the tag under Versions, from
|
||||||
|
where you deploy it. It is a suggestion only: nothing is deployed automatically, not even
|
||||||
|
for **auto** services. Tags such as `latest`, `7` or `pg16` get no suggestion. A failed tag
|
||||||
|
list keeps the old suggestion and is only logged (`poll_tags_failed`).
|
||||||
|
- **What is not polled.** Services that follow no tag (`repo@digest`), `build:` services, and
|
||||||
|
services without a registry digest (locally built images). The service page says which.
|
||||||
|
The updater's own services and plain containers are polled, so their badge works, but
|
||||||
|
jobs for them are still refused.
|
||||||
|
- **Errors.** A failed lookup is stored for the services that follow that tag and shown as a
|
||||||
|
"poll failed" badge on the overview and in the "Polling" row of the service page; other
|
||||||
|
tags continue. A later success clears it.
|
||||||
|
- **Credentials.** The master reads `$DOCKER_CONFIG/config.json` (a file written by
|
||||||
|
`docker login`), mounted read-only into the container; without it registries are accessed
|
||||||
|
anonymously. The directory and file must be readable by the master's user (`65534` in the
|
||||||
|
example stack) without being world-readable: `chown -R 65534:65534` the directory and keep
|
||||||
|
`0700`/`0600`. Only inline `auths` entries are supported. A `credsStore` or `credHelpers`
|
||||||
|
entry makes every lookup fail with `error getting credentials ... executable file not found
|
||||||
|
in $PATH`, because the container has no credential helper binaries. The master logs
|
||||||
|
`registry_credentials` at startup with the directory, `config_json_found` and
|
||||||
|
`config_json_readable`, never the contents. If the file exists but cannot be opened it also
|
||||||
|
logs a `registry_credentials_unreadable` warning with the error.
|
||||||
|
- **Requests go wherever the image name points.** The master sends the lookup to the
|
||||||
|
registry host in the image name, including a `GET /v2/` ping that follows redirects.
|
||||||
|
Anyone who can start containers on a managed host can therefore make the master send
|
||||||
|
blind requests to internal hosts. The registry's answer is not shown: an error keeps only
|
||||||
|
the HTTP status and the registry's error codes; any other failure shows only "timeout",
|
||||||
|
"cannot reach registry" or "lookup failed". The full error goes to the master's log
|
||||||
|
(`registry_lookup_failed`).
|
||||||
|
|
||||||
|
### Version history and rollback
|
||||||
|
|
||||||
|
The master records every version it sees (webhook, polling, and what agents report as
|
||||||
|
running) in `image_versions`. The service page lists the 10 most recently first seen
|
||||||
|
versions of the service's image repo, marks the one that is running, and has a *Deploy*
|
||||||
|
button on each row. A webhook announces a tag but not its digest, so such a row has no
|
||||||
|
digest until polling or an agent reports one; it is hidden when a row with the same tag
|
||||||
|
and a digest was first seen at or after it. A bare image (`postgres`) is recorded as tag
|
||||||
|
`latest`.
|
||||||
|
|
||||||
|
Picking a version starts a manual job to exactly that reference. Picking an older one is a
|
||||||
|
rollback, including to a digest-pinned reference. A version without a tag deploys
|
||||||
|
`repo@digest` and the service then follows no tag, so it is no longer polled or updatable
|
||||||
|
with the plain *Update* button until it is moved to a tagged version again. Picks of
|
||||||
|
digest-pinned or tagless versions need an agent speaking protocol version 3 (this
|
||||||
|
release); on an older agent the job fails with "agent too old to deploy a pinned version
|
||||||
|
(needs protocol 3)".
|
||||||
|
|
||||||
|
A rollback made before the first poll cycle after the upgrade has seen the service is not
|
||||||
|
protected, because the master has not yet recorded the digest it was running.
|
||||||
|
|
||||||
|
A service in **auto** mode stays auto after a rollback. Polling does not undo it: the
|
||||||
|
service stays on the older version until the registry shows a digest of the followed tag
|
||||||
|
that differs from the newest one the master deployed. The newer version it was rolled back
|
||||||
|
from stays marked as available. A webhook that names a tag still starts a job for an auto
|
||||||
|
service every time, so a CI push moves it forward again.
|
||||||
|
|
||||||
|
## Drift and moved services
|
||||||
|
|
||||||
|
A service has **drifted** when it runs something other than the version the last update
|
||||||
|
job deployed. A service the updater never touched has nothing to drift from. Drift is
|
||||||
|
decided by what runs, not by how the reference is written: the digest decides when both
|
||||||
|
sides know one, so `repo:tag@sha256:…` is run by a service reporting `repo:tag` with that
|
||||||
|
digest, and a different tag with the same digest is not drift. (The design spec compares
|
||||||
|
the two reference strings; this is deliberately looser.) Between a successful job and the
|
||||||
|
agent's next inventory report the service is not counted as drifted, because the report
|
||||||
|
has not arrived yet.
|
||||||
|
|
||||||
|
The master never corrects drift on its own, because a manual `docker stack deploy` of a
|
||||||
|
newer tag may be intentional. Polling and the webhook start no job for a drifted
|
||||||
|
service, in auto mode too; a person can still start one by hand. There are two ways out:
|
||||||
|
|
||||||
|
- **Restore desired** deploys the recorded version again (a manual job to the exact
|
||||||
|
`repo:tag@digest`; an agent speaking protocol 3 is needed). If the service had drifted
|
||||||
|
to another digest of the same tag, that digest is remembered, so polling does not
|
||||||
|
deploy it again until the registry shows a newer one (unless `last_tag_digest` already
|
||||||
|
holds back a newer digest, which then stays).
|
||||||
|
- **Accept running** records what runs now as the desired version. It starts no job and
|
||||||
|
leaves the available suggestion and the update mode alone.
|
||||||
|
|
||||||
|
When a Compose service disappears from one agent and exactly one service with the same
|
||||||
|
project and service name appears on another, and the removed one has history (a desired
|
||||||
|
version, auto mode or jobs) while the new one has none, the service page suggests that it
|
||||||
|
moved. **Merge** moves the old row, with its history, mode and desired version, to the new
|
||||||
|
location and deletes the duplicate. Merging is never automatic. It can also be done by
|
||||||
|
hand for any removed service of the same kind (Compose or Swarm, names may differ), but
|
||||||
|
not for plain containers, the updater itself, or a service with a queued or running job.
|
||||||
|
A merge keeps the old row's mode and desired version, so a manually merged target with its
|
||||||
|
own history may show drift right after the merge and can be accepted.
|
||||||
|
|
||||||
|
The restore, accept and merge buttons arrive with the UI follow-up (issue #28); the endpoints
|
||||||
|
(`POST /services/{id}/restore`, `/accept`, `/merge`) and the data for them are in place.
|
||||||
|
|
||||||
## Adding agents
|
## Adding agents
|
||||||
|
|
||||||
@@ -115,8 +324,9 @@ crash-loop the next time its image is pulled. Anything that pulls `:latest` for
|
|||||||
instance triggers this: its own `/update` webhook, Watchtower or Shepherd.
|
instance triggers this: its own `/update` webhook, Watchtower or Shepherd.
|
||||||
|
|
||||||
Pinning means two things: pin the old instance to the last pre-phase-2 tag **and** remove
|
Pinning means two things: pin the old instance to the last pre-phase-2 tag **and** remove
|
||||||
its `se.shcizo.auto-update` label, so nothing updates it to the new image. Or do not tag a
|
its `se.shcizo.auto-update` label, so nothing updates it to the new image. The master
|
||||||
release until phase 3 moves `POST /update` to the master.
|
serves `POST /update` again (see [Webhook](#webhook-ci)); point the Gitea action's
|
||||||
|
`endpoint` there and set `UPDATER_API_KEY` on the master.
|
||||||
|
|
||||||
Master and agents must be on this release together: a phase-1 agent connects but every
|
Master and agents must be on this release together: a phase-1 agent connects but every
|
||||||
job sent to it fails "agent too old for updates".
|
job sent to it fails "agent too old for updates".
|
||||||
|
|||||||
+71
-4
@@ -11,6 +11,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
@@ -20,9 +21,12 @@ import (
|
|||||||
"github.com/shcizo/package-updater/internal/master/events"
|
"github.com/shcizo/package-updater/internal/master/events"
|
||||||
"github.com/shcizo/package-updater/internal/master/hub"
|
"github.com/shcizo/package-updater/internal/master/hub"
|
||||||
"github.com/shcizo/package-updater/internal/master/jobs"
|
"github.com/shcizo/package-updater/internal/master/jobs"
|
||||||
|
"github.com/shcizo/package-updater/internal/master/poller"
|
||||||
|
"github.com/shcizo/package-updater/internal/master/registry"
|
||||||
"github.com/shcizo/package-updater/internal/master/store"
|
"github.com/shcizo/package-updater/internal/master/store"
|
||||||
"github.com/shcizo/package-updater/internal/master/web"
|
"github.com/shcizo/package-updater/internal/master/web"
|
||||||
"github.com/shcizo/package-updater/internal/master/webauth"
|
"github.com/shcizo/package-updater/internal/master/webauth"
|
||||||
|
"github.com/shcizo/package-updater/internal/master/webhook"
|
||||||
"github.com/shcizo/package-updater/internal/pin"
|
"github.com/shcizo/package-updater/internal/pin"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -36,6 +40,39 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pollFirstDelay is how long the master runs before its first registry
|
||||||
|
// polling cycle, so agents have reported their inventory.
|
||||||
|
const pollFirstDelay = time.Minute
|
||||||
|
|
||||||
|
// pollWorkers is how many registry lookups run at once.
|
||||||
|
const pollWorkers = 4
|
||||||
|
|
||||||
|
// registryCredentialsDir returns the directory the registry client reads
|
||||||
|
// credentials from (DOCKER_CONFIG, else ~/.docker), whether a config.json is
|
||||||
|
// in it, and the error from opening it when it exists but cannot be read (for
|
||||||
|
// example because the master runs as another user than the file owner). The
|
||||||
|
// file is opened and closed, never read; only the path is logged.
|
||||||
|
func registryCredentialsDir() (dir string, found bool, readErr error) {
|
||||||
|
dir = os.Getenv("DOCKER_CONFIG")
|
||||||
|
if dir == "" {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
dir = filepath.Join(home, ".docker")
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, "config.json")
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
return dir, false, nil
|
||||||
|
}
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return dir, true, err
|
||||||
|
}
|
||||||
|
_ = f.Close()
|
||||||
|
return dir, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
func run() error {
|
func run() error {
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -80,6 +117,7 @@ func run() error {
|
|||||||
|
|
||||||
ui, err := web.New(st, agentHub, jobService, broker, web.Config{
|
ui, err := web.New(st, agentHub, jobService, broker, web.Config{
|
||||||
AgentURL: cfg.AgentURL, MasterKey: fingerprint, AgentImage: cfg.AgentImage,
|
AgentURL: cfg.AgentURL, MasterKey: fingerprint, AgentImage: cfg.AgentImage,
|
||||||
|
PollInterval: cfg.PollInterval, Version: version,
|
||||||
}, logger)
|
}, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -93,6 +131,14 @@ func run() error {
|
|||||||
}
|
}
|
||||||
_, _ = w.Write([]byte("ok"))
|
_, _ = w.Write([]byte("ok"))
|
||||||
})
|
})
|
||||||
|
// The webhook does its own bearer-token check, so it sits outside the
|
||||||
|
// OIDC login like /healthz.
|
||||||
|
mux.Handle("POST /update", webhook.New(st, jobService, broker, webhook.Config{
|
||||||
|
Token: cfg.UpdaterAPIKey, Timeout: cfg.UpdateTimeout, PublicURL: cfg.PublicURL,
|
||||||
|
}, logger))
|
||||||
|
if cfg.UpdaterAPIKey == "" {
|
||||||
|
logger.Warn("webhook_disabled", "reason", "UPDATER_API_KEY not set")
|
||||||
|
}
|
||||||
mux.HandleFunc("GET /auth/login", auth.Login)
|
mux.HandleFunc("GET /auth/login", auth.Login)
|
||||||
mux.HandleFunc("GET /auth/callback", auth.Callback)
|
mux.HandleFunc("GET /auth/callback", auth.Callback)
|
||||||
mux.Handle("POST /auth/logout", auth.Require(http.HandlerFunc(auth.Logout)))
|
mux.Handle("POST /auth/logout", auth.Require(http.HandlerFunc(auth.Logout)))
|
||||||
@@ -103,8 +149,8 @@ func run() error {
|
|||||||
// instead of holding Shutdown until its timeout.
|
// instead of holding Shutdown until its timeout.
|
||||||
baseContext := func(net.Listener) context.Context { return ctx }
|
baseContext := func(net.Listener) context.Context { return ctx }
|
||||||
|
|
||||||
// No WriteTimeout on either server: SSE streams and agent connections
|
// No WriteTimeout on either server: SSE streams, agent connections and
|
||||||
// are long-lived by design.
|
// POST /update (which waits up to UPDATE_TIMEOUT) are long-lived by design.
|
||||||
webSrv := &http.Server{
|
webSrv := &http.Server{
|
||||||
Addr: ":" + cfg.Port, Handler: mux, ReadHeaderTimeout: 10 * time.Second,
|
Addr: ":" + cfg.Port, Handler: mux, ReadHeaderTimeout: 10 * time.Second,
|
||||||
BaseContext: baseContext,
|
BaseContext: baseContext,
|
||||||
@@ -118,22 +164,43 @@ func run() error {
|
|||||||
BaseContext: baseContext,
|
BaseContext: baseContext,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
credDir, credFound, credReadErr := registryCredentialsDir()
|
||||||
|
logger.Info("registry_credentials", "docker_config", credDir, "config_json_found", credFound,
|
||||||
|
"config_json_readable", credFound && credReadErr == nil)
|
||||||
|
if credReadErr != nil {
|
||||||
|
logger.Warn("registry_credentials_unreadable", "docker_config", credDir, "error", credReadErr)
|
||||||
|
}
|
||||||
|
pollCtx, stopPolling := context.WithCancel(ctx)
|
||||||
|
defer stopPolling()
|
||||||
|
registryPoller := poller.New(st, jobService, registry.New("package-updater-master/"+version, logger), broker, poller.Config{
|
||||||
|
Interval: cfg.PollInterval, FirstDelay: pollFirstDelay, Workers: pollWorkers,
|
||||||
|
}, logger)
|
||||||
|
pollDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(pollDone)
|
||||||
|
registryPoller.Run(pollCtx)
|
||||||
|
}()
|
||||||
|
|
||||||
errCh := make(chan error, 2)
|
errCh := make(chan error, 2)
|
||||||
go func() { errCh <- webSrv.ListenAndServe() }()
|
go func() { errCh <- webSrv.ListenAndServe() }()
|
||||||
go func() { errCh <- agentSrv.ListenAndServeTLS("", "") }()
|
go func() { errCh <- agentSrv.ListenAndServeTLS("", "") }()
|
||||||
logger.Info("listening", "web", webSrv.Addr, "agents", agentSrv.Addr)
|
logger.Info("listening", "web", webSrv.Addr, "agents", agentSrv.Addr)
|
||||||
|
|
||||||
|
var serveErr error
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
logger.Info("shutdown_signal")
|
logger.Info("shutdown_signal")
|
||||||
case err := <-errCh:
|
case err := <-errCh:
|
||||||
if !errors.Is(err, http.ErrServerClosed) {
|
if !errors.Is(err, http.ErrServerClosed) {
|
||||||
return err
|
serveErr = err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
_ = webSrv.Shutdown(shutdownCtx)
|
_ = webSrv.Shutdown(shutdownCtx)
|
||||||
_ = agentSrv.Shutdown(shutdownCtx)
|
_ = agentSrv.Shutdown(shutdownCtx)
|
||||||
return nil
|
// The poller writes to the store, which is closed when run returns.
|
||||||
|
stopPolling()
|
||||||
|
<-pollDone
|
||||||
|
return serveErr
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRegistryCredentialsDir(t *testing.T) {
|
||||||
|
t.Run("missing file", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("DOCKER_CONFIG", dir)
|
||||||
|
got, found, readErr := registryCredentialsDir()
|
||||||
|
assert.Equal(t, dir, got)
|
||||||
|
assert.False(t, found)
|
||||||
|
assert.NoError(t, readErr)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("readable file", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "config.json"), []byte("{}"), 0o600))
|
||||||
|
t.Setenv("DOCKER_CONFIG", dir)
|
||||||
|
_, found, readErr := registryCredentialsDir()
|
||||||
|
assert.True(t, found)
|
||||||
|
assert.NoError(t, readErr)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unreadable file", func(t *testing.T) {
|
||||||
|
if os.Geteuid() == 0 {
|
||||||
|
t.Skip("root can read any file")
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "config.json"), []byte("{}"), 0o000))
|
||||||
|
t.Setenv("DOCKER_CONFIG", dir)
|
||||||
|
_, found, readErr := registryCredentialsDir()
|
||||||
|
assert.True(t, found)
|
||||||
|
assert.Error(t, readErr)
|
||||||
|
})
|
||||||
|
}
|
||||||
+24
-2
@@ -1,4 +1,4 @@
|
|||||||
# Example Swarm stack for the package-updater master (phase 2: inventory and manual updates).
|
# Example Swarm stack for the package-updater master (inventory, updates and the CI webhook).
|
||||||
# Deploy on the manager: docker stack deploy -c master.stack.yml package-updater-master
|
# Deploy on the manager: docker stack deploy -c master.stack.yml package-updater-master
|
||||||
#
|
#
|
||||||
# Before deploying:
|
# Before deploying:
|
||||||
@@ -6,19 +6,39 @@
|
|||||||
# 2. In Authentik: create an OAuth2/OpenID provider + application with redirect URI
|
# 2. In Authentik: create an OAuth2/OpenID provider + application with redirect URI
|
||||||
# https://updater.example.com/auth/callback, and a group "updater-admins".
|
# https://updater.example.com/auth/callback, and a group "updater-admins".
|
||||||
# 3. docker secret create pu_oidc_client_secret - ; docker secret create pu_database_url -
|
# 3. docker secret create pu_oidc_client_secret - ; docker secret create pu_database_url -
|
||||||
|
# 4. docker secret create pu_updater_api_key - (required by this example; it enables POST /update)
|
||||||
|
# To run without the webhook, remove the pu_updater_api_key lines (the UPDATER_API_KEY
|
||||||
|
# part of the entrypoint, the service secret and the secrets entry); the endpoint then answers 503.
|
||||||
|
# The reverse proxy must allow a read timeout longer than UPDATE_TIMEOUT (default 5m).
|
||||||
|
# 5. Optional, for registry polling of private images: put a config.json written by
|
||||||
|
# `docker login` (inline "auths" only; no credsStore) in /path/to/docker-config and
|
||||||
|
# uncomment DOCKER_CONFIG and the volumes entry below. The master runs as 65534:65534, so
|
||||||
|
# the directory and config.json must be readable by that user while staying private:
|
||||||
|
# `chown -R 65534:65534 /path/to/docker-config` (keep 0700/0600), or set `user:` below
|
||||||
|
# to the owner's uid:gid. Do not make config.json world-readable; it holds registry
|
||||||
|
# passwords. The directory must exist on every manager node (the master is placed on a
|
||||||
|
# manager), or Swarm rejects the task. Without it registries are accessed anonymously.
|
||||||
services:
|
services:
|
||||||
master:
|
master:
|
||||||
image: gitea.shcizo.se/shcizo/package-updater:latest
|
image: gitea.shcizo.se/shcizo/package-updater:latest
|
||||||
entrypoint: ["/bin/sh", "-c", "DATABASE_URL=$$(cat /run/secrets/pu_database_url) OIDC_CLIENT_SECRET=$$(cat /run/secrets/pu_oidc_client_secret) exec /usr/local/bin/updater-master"]
|
# Runs as nobody: the master needs no root (the agent does, for the Docker socket).
|
||||||
|
user: "65534:65534"
|
||||||
|
entrypoint: ["/bin/sh", "-c", "DATABASE_URL=$$(cat /run/secrets/pu_database_url) OIDC_CLIENT_SECRET=$$(cat /run/secrets/pu_oidc_client_secret) UPDATER_API_KEY=$$(cat /run/secrets/pu_updater_api_key) exec /usr/local/bin/updater-master"]
|
||||||
environment:
|
environment:
|
||||||
PUBLIC_URL: https://updater.example.com
|
PUBLIC_URL: https://updater.example.com
|
||||||
AGENT_URL: wss://10.0.0.5:8090/agent
|
AGENT_URL: wss://10.0.0.5:8090/agent
|
||||||
OIDC_ISSUER: https://auth.example.com/application/o/package-updater/
|
OIDC_ISSUER: https://auth.example.com/application/o/package-updater/
|
||||||
OIDC_CLIENT_ID: package-updater
|
OIDC_CLIENT_ID: package-updater
|
||||||
ALLOWED_GROUP: updater-admins
|
ALLOWED_GROUP: updater-admins
|
||||||
|
# UPDATE_TIMEOUT: 5m
|
||||||
|
# POLL_INTERVAL: 6h
|
||||||
|
# DOCKER_CONFIG: /docker-config # step 5
|
||||||
secrets:
|
secrets:
|
||||||
- pu_database_url
|
- pu_database_url
|
||||||
- pu_oidc_client_secret
|
- pu_oidc_client_secret
|
||||||
|
- pu_updater_api_key
|
||||||
|
# volumes: # step 5
|
||||||
|
# - /path/to/docker-config:/docker-config:ro
|
||||||
ports:
|
ports:
|
||||||
# Agents connect here directly over the internal network, not via the proxy.
|
# Agents connect here directly over the internal network, not via the proxy.
|
||||||
- target: 8090
|
- target: 8090
|
||||||
@@ -35,6 +55,8 @@ secrets:
|
|||||||
external: true
|
external: true
|
||||||
pu_oidc_client_secret:
|
pu_oidc_client_secret:
|
||||||
external: true
|
external: true
|
||||||
|
pu_updater_api_key:
|
||||||
|
external: true
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
proxy:
|
proxy:
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# Master/Agent Phase 3 — Webhook and Auto Mode Implementation Plan
|
||||||
|
|
||||||
|
> This plan documents what was built for issue #8 and where it deviates from the spec. Code in "Frozen contracts" is final; tasks give the contract and the test cases.
|
||||||
|
|
||||||
|
**Goal:** CI calls `POST /update` on the master after pushing an image. The master records the pushed version as *available* on every service that uses the image. Services in `auto` mode are updated at once and the request waits for the result, so CI sees a broken deploy. People switch a service between `manual` and `auto` in the UI.
|
||||||
|
|
||||||
|
**Architecture:** A new package `internal/master/webhook` is an `http.Handler` mounted on the master's mux outside the OIDC login; it does its own bearer-token check. It looks up services by image repo, writes `available_*` on each match, starts a job through `jobs.Service.Start` for the `auto` ones and waits for them. Waiting subscribes to the in-process broker before any job exists, reacts to `events.Jobs`, re-reads each pending job from Postgres on every signal and on a fallback ticker, and gives up at `UPDATE_TIMEOUT`. `FinishJob` clears `available_*` in the transaction that records a successful deploy.
|
||||||
|
|
||||||
|
**Tech Stack:** unchanged. No new dependencies.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-10-12-master-agent-design.md`, "Webhook" section. Phase 2 plan: `docs/superpowers/plans/2026-10-12-master-agent-phase-2-jobs.md` (its conventions still apply).
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- `go test ./...` passes with no Docker daemon and no Postgres. Postgres tests carry `//go:build integration` and need `DATABASE_URL`. Run `go test -race ./internal/agent/... ./internal/master/... ./internal/e2e/`.
|
||||||
|
- Interfaces are declared in the consuming package. Hand-written fakes, `stretchr/testify`, table-driven where there is more than one case.
|
||||||
|
- Every exported identifier gets a GoDoc comment. Conventional Commits; every commit message ends with ` (#8)`.
|
||||||
|
- The token (`UPDATER_API_KEY`) never appears in logs.
|
||||||
|
- Every state-changing UI request is a POST carrying the CSRF token (the mode toggle included).
|
||||||
|
- The HTTP server for the UI has no `WriteTimeout`, so a webhook request can wait up to `UPDATE_TIMEOUT`.
|
||||||
|
|
||||||
|
## Deliberate deviations from the spec in this phase
|
||||||
|
|
||||||
|
- **No `image_versions` table and no `available_digest` column.** Both belong to phase 4 (version history and the version picker). Phase 3 stores only `available_ref`, `available_source` (`webhook`) and `available_at` on `services`.
|
||||||
|
- **The old `207` is gone.** A request with both a working and a failed service answers `500` as a whole; there is no partial-success code. The Gitea action fails on any 4xx/5xx, so CI surfaces it.
|
||||||
|
- **`busy` and `refused` are not failures.** A service that already has an active job (`busy`, with that job's ID, not waited for) or may not be updated (`refused`, with the reason) does not make the request fail. Only `failed` and `timeout` do. A manual match is `marked`, also not a failure.
|
||||||
|
- **A webhook with a `tag` bypasses the "follows no tag" refusal.** The target is then explicit (`<service's image_repo>:<tag>`), so a `repo@digest` service can be moved to a tag. Removed, self, built and container refusals still apply. Without a `tag`, the target is the followed tag (bare image = `:latest`); a service that follows no tag is left alone (`available_*` untouched) and is reported `refused` ("no tag given and the service follows no tag"), manual or auto. Because of this, the UI allows `auto` for a service that follows no tag (`jobs.AutoAllowed`), unlike the Update button (`jobs.Updatable`).
|
||||||
|
- **The target ref is built from the service's own repo.** `imageref.Parse(req.Image).Repo` only selects services; it never reaches the job, so a request cannot point a service at another registry.
|
||||||
|
- **Token is optional on the master.** Unset `UPDATER_API_KEY` means `POST /update` answers `503` and startup logs `webhook_disabled`; the master still runs.
|
||||||
|
- **`tag` is validated** against Docker's tag grammar (`400 invalid tag`). The spec does not say; without it a tag such as `v1@sha256:x` or one with spaces would end up in the override file and fail on the agent.
|
||||||
|
- **An oversized body (over 64 KiB) answers `400 invalid JSON body`**, not `413`.
|
||||||
|
- **`UPDATE_TIMEOUT` exists on both sides** with the same name and parsing: on the agent it bounds one job, on the master it bounds one webhook request. The master never fails or cancels a job that outlives the request; the agent's own timeout does.
|
||||||
|
- **Marking and starting jobs runs on `context.WithoutCancel(r.Context())`.** A client that disconnects stops only the waiting; otherwise a queued job could be left that no agent knows about.
|
||||||
|
|
||||||
|
## Review Focus
|
||||||
|
|
||||||
|
| Failure case | Test that covers it |
|
||||||
|
|---|---|
|
||||||
|
| Wrong, missing or malformed token never reaches the lookup; unset key answers 503. | webhook `TestAuth` |
|
||||||
|
| An unknown or differently-cased image answers 404 and starts nothing. | webhook `TestNoMatch` |
|
||||||
|
| The image string is normalised (tag, digest, registry with port) before matching. | webhook `TestImageIsNormalisedBeforeMatching` |
|
||||||
|
| The auto job's target is built from the service's repo and the request tag, never the request's image string. | webhook `TestAutoJobTargetIsBuiltFromServiceRepo` |
|
||||||
|
| A job that fails at start (agent offline) is reported at once, not waited for. | webhook `TestJobFailedAtStartIsNotWaitedFor` |
|
||||||
|
| A dropped broker signal is recovered by the fallback poll. | webhook `TestDroppedSignalIsRecoveredByPolling` |
|
||||||
|
| A timed-out job is reported `timeout` and left untouched. | webhook `TestTimeout` |
|
||||||
|
| A client that disconnects does not abort marking or starting. | webhook `TestClientCancelStopsWaitingButNotStarting` |
|
||||||
|
| The webhook waits for the real agent's result and answers 200 / 500. | e2e `TestWebhookWaitsForAutoJob`, `TestWebhookFailedAutoJobIs500` |
|
||||||
|
| The available ref is cleared only by a successful job whose `to_ref` equals it and that was created at or after the suggestion; a different ref, a newer suggestion and a failed job keep it. | store `TestFinishJob_ClearsAvailableOnlyWhenThatVersionWasDeployed` |
|
||||||
|
| Re-reporting inventory never resets a service's update mode; new services are manual. | store `TestUpsertKeepsUpdateMode` |
|
||||||
|
| `auto` is refused for removed, self, built and container services; `manual` is always allowed. | jobs `TestAutoAllowed`; web `TestSetMode_AutoRefusedShows409`, `TestSetMode_ManualAlwaysAllowed` |
|
||||||
|
|
||||||
|
## Frozen contracts
|
||||||
|
|
||||||
|
### C1. Migration `internal/master/store/migrations/0003_webhook.sql` (complete)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE services ADD COLUMN available_ref text NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE services ADD COLUMN available_source text NOT NULL DEFAULT ''
|
||||||
|
CHECK (available_source IN ('', 'webhook'));
|
||||||
|
ALTER TABLE services ADD COLUMN available_at timestamptz;
|
||||||
|
|
||||||
|
CREATE INDEX services_by_image_repo ON services (image_repo) WHERE removed_at IS NULL;
|
||||||
|
```
|
||||||
|
|
||||||
|
### C2. `internal/master/model` additions
|
||||||
|
|
||||||
|
`Service` gains `AvailableRef string`, `AvailableSource string`, `AvailableAt *time.Time`. Constants `ModeManual`, `ModeAuto`, `SourceWebhook`. `type Available struct { Ref, Source string; At time.Time }`.
|
||||||
|
|
||||||
|
### C3. Store methods
|
||||||
|
|
||||||
|
```go
|
||||||
|
ServicesByRepo(ctx, repo string) ([]model.Service, error) // non-removed, same order as ListServices
|
||||||
|
SetAvailable(ctx, id int64, a model.Available) error
|
||||||
|
SetUpdateMode(ctx, id int64, mode string) error // model.ErrNotFound for unknown or removed
|
||||||
|
```
|
||||||
|
|
||||||
|
`FinishJob` additionally reads the job's `created_at` and, on success, clears `available_*` in the same transaction when the job's `to_ref` (as created, without `@digest`) equals `available_ref` and `available_at <= created_at`.
|
||||||
|
|
||||||
|
### C4. `internal/master/jobs`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type StartRequest struct {
|
||||||
|
Trigger model.JobTrigger
|
||||||
|
RequestedBy string
|
||||||
|
Ref string // empty: the service's followed tag; set: used as is
|
||||||
|
}
|
||||||
|
func (s *Service) Start(ctx, svc model.Service, req StartRequest) (model.Job, error)
|
||||||
|
func (s *Service) AutoAllowed(svc model.Service) error
|
||||||
|
func FollowedRef(actualRef string) string // "repo:tag", or "" when no tag is followed
|
||||||
|
```
|
||||||
|
|
||||||
|
`Start` has the return contract `Trigger` had: `*model.NotUpdatableError` with no job, `model.ErrJobActive` with the existing job, and an already-failed job for offline / too-old / start failures. `Trigger(ctx, id, by)` is `GetService` plus `Start` with `TriggerManual`.
|
||||||
|
|
||||||
|
### C5. `internal/master/webhook`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Config struct { Token string; Timeout time.Duration; PublicURL string }
|
||||||
|
func New(store Store, jobs Jobs, broker Broker, cfg Config, log *slog.Logger) *Handler
|
||||||
|
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request)
|
||||||
|
```
|
||||||
|
|
||||||
|
Request `{"image","tag"}`; response `{"request_id","image","tag","matched","results":[{"service_id","service","kind","mode","status","job_id","job_url","to_ref","error","duration_ms"}]}`. Statuses `succeeded | failed | timeout | marked | busy | refused`; HTTP `500` when any result is `failed` or `timeout`, else `200`. Other codes: `400`, `401`, `404`, `503`. See the README for the full table.
|
||||||
|
|
||||||
|
### C6. Master configuration
|
||||||
|
|
||||||
|
`UPDATER_API_KEY` (optional) and `UPDATE_TIMEOUT` (default `5m`, positive Go duration, same error text style as the agent's).
|
||||||
|
|
||||||
|
### C7. Web
|
||||||
|
|
||||||
|
`POST /services/{id}/mode` with `mode=manual|auto` and optional `return=overview`. `303` to the service page (or `/`), `400` for a bad mode, `404` for an unknown or removed service, `409` re-rendering the service page with "Cannot enable auto: <reason>." Publishes `events.Services`.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
### Task 1: Store and model
|
||||||
|
|
||||||
|
Migration C1, model C2, store C3. Integration tests (`//go:build integration`): `ServicesByRepo` excludes removed rows and other repos; `SetAvailable` round trip; `SetUpdateMode` including `ErrNotFound`; new service is manual and upsert/snapshot keep the mode; `FinishJob` clears the same ref, keeps a different ref, keeps newer, keeps on failure.
|
||||||
|
|
||||||
|
### Task 2: Jobs service
|
||||||
|
|
||||||
|
Extract `Start` from `Trigger`; `updatable(svc, explicitRef)`; `AutoAllowed`; `FollowedRef`. Tests: webhook trigger with explicit ref is recorded; explicit ref bypasses only the no-tag refusal; `AutoAllowed` table; existing `TestTrigger*` stay green.
|
||||||
|
|
||||||
|
### Task 3: Config, webhook, wiring
|
||||||
|
|
||||||
|
Config C6 with tests. Handler C5 with fakes and the real `events.Broker`. `cmd/master/main.go` mounts `POST /update` directly on the mux and logs `webhook_disabled` when the key is empty.
|
||||||
|
|
||||||
|
### Task 4: Web
|
||||||
|
|
||||||
|
Store gains `SetUpdateMode`, Jobs gains `AutoAllowed`. Overview: mode column with a toggle form (hidden `csrf_token`, `mode`, `return=overview`; disabled with the reason as visible text when auto is not allowed) and an "update available" badge. Service page: Available row, Mode row, toggle form, 409 error. The forms are inside the `services` template that the SSE fragment re-renders, so they survive a refresh.
|
||||||
|
|
||||||
|
### Task 5: End-to-end
|
||||||
|
|
||||||
|
`internal/e2e/webhook_test.go`: `TestWebhookWaitsForAutoJob` (real in-process master and agent, gate executor, the handler on an `httptest` server) and `TestWebhookFailedAutoJobIs500`. The in-memory `memStore` gains `ServicesByRepo`, `SetAvailable` and the same clearing rule as the SQL.
|
||||||
|
|
||||||
|
### Task 6: Documentation
|
||||||
|
|
||||||
|
README (webhook section, master variables, warning about `auto`), `gitea-action/README.md` (do not change `action.yml`), `deploy/master.stack.yml` (`UPDATER_API_KEY` as a Docker secret), `CLAUDE.md`, this plan.
|
||||||
|
|
||||||
|
## Final verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gofmt -l . ; go vet ./...
|
||||||
|
go build ./cmd/master ./cmd/agent && rm -f master agent
|
||||||
|
go test ./...
|
||||||
|
go test -race ./internal/agent/... ./internal/master/... ./internal/e2e/
|
||||||
|
DATABASE_URL=postgres://... go test -tags integration ./internal/master/store/ # when a Postgres is available
|
||||||
|
```
|
||||||
|
|
||||||
|
Manual smoke test: set `UPDATER_API_KEY`, make a throwaway Compose service `auto`, then `curl -X POST -H "Authorization: Bearer $KEY" -d '{"image":"<repo>","tag":"<pushed tag>"}' https://<master>/update`. Expect `200` with `succeeded` after the job finishes, the job visible in the UI with trigger `webhook`, and the "update available" badge gone.
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
# Master/Agent Phase 4 — Registry Polling and Version History Implementation Plan
|
||||||
|
|
||||||
|
> This plan documents what to build for issue #9 and where it deviates from the spec. Code in "Frozen contracts" is final; tasks give the contract and the test cases.
|
||||||
|
|
||||||
|
**Goal:** The master records every version it sees (webhook, registry polling, running inventory) in `image_versions`. Every `POLL_INTERVAL` (default 6h) it checks each distinct followed `repo:tag` against its registry with one HEAD request. A new digest becomes the service's available suggestion, and `auto` services get a job. The Service page lists the 10 most recent versions of the service's repo. Picking one starts a manual job to exactly that reference; picking an older one is a rollback, including to a digest-pinned or tagless reference.
|
||||||
|
|
||||||
|
**Architecture:**
|
||||||
|
- New package `internal/master/registry`: thin wrapper around `go-containerregistry`'s `remote.Head` with `authn.DefaultKeychain` (reads `DOCKER_CONFIG`/`config.json`).
|
||||||
|
- New package `internal/master/poller`: owns the cycle (list services, group by followed ref, run HEADs with bounded concurrency, then set available / start auto jobs / store poll error per service). `cmd/master` runs it as a background goroutine on the signal context.
|
||||||
|
- Store: `image_versions` written in `SetAvailable` (webhook and poll, same transaction) and in `upsertService` (running inventory, inside the existing snapshot/upsert transactions).
|
||||||
|
- Jobs gains `TriggerVersion`, which takes a version ID, never a ref from the form. `Hub.Route` gains a minimum protocol version so a pinned or tagless ref only goes to agents that understand it.
|
||||||
|
- Agent: resolver passes digest-pinned refs through unchanged. Protocol version goes to 3.
|
||||||
|
|
||||||
|
**Tech Stack:** adds `github.com/google/go-containerregistry` (`pkg/name`, `pkg/v1/remote`, `pkg/authn`; `pkg/registry` and `pkg/v1/random` in tests only).
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-10-12-master-agent-design.md`, sections "Versions: what the system has seen", "Data model", "Update", "Failure handling", "Registry polling", "UI". Phase 3 plan conventions still apply.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- `go test ./...` passes with no Docker, no Postgres and no network. Registry tests use `httptest` plus `ggcr/pkg/registry`. Postgres tests carry `//go:build integration`.
|
||||||
|
- Interfaces declared at the consumer (`poller.Store`, `poller.Jobs`, `poller.Registry`). Hand-written fakes and testify, table-driven where there is more than one case.
|
||||||
|
- GoDoc on every exported identifier. Conventional Commits, each ending with ` (#9)`.
|
||||||
|
- Registry credentials never appear in logs or in `last_poll_error`. Error text stored truncated to 500 bytes.
|
||||||
|
- Registry traffic is HEAD only: never `GET /v2/<repo>/manifests/...` (Docker Hub counts GETs as pulls). A test asserts this.
|
||||||
|
- A pick from the UI is a POST with the CSRF token and a version ID. The ref is built server-side from the `image_versions` row, and the row must belong to the service's `image_repo`.
|
||||||
|
- The poller never overlaps itself. Next cycle is scheduled after the previous one finishes.
|
||||||
|
- `go.mod` `go` directive must stay `1.26.3` after `go get`.
|
||||||
|
|
||||||
|
## Deliberate deviations from the spec in this phase
|
||||||
|
|
||||||
|
1. **Webhook records `(repo, tag, '')`, no digest.** The master does not resolve the digest at webhook time (no registry round-trip/credentials in the CI path). The digest arrives later from polling or running inventory. The picker hides a digest-less row when a row with the same `(repo, tag)` and a digest was first seen at or after it.
|
||||||
|
2. **Empty string instead of NULL** for missing tag or digest in `image_versions`, so `UNIQUE (repo, tag, digest)` works. A CHECK forbids both being empty.
|
||||||
|
3. **`first_seen_at` is never bumped.** Re-observation is `ON CONFLICT DO NOTHING`; `source` is the first source that saw it. "10 most recent" = "10 most recently first seen". A new digest of a tag is a new row at the top; old digest stays below (enables rollback of moving tags).
|
||||||
|
4. **Not polled:** services that follow no tag, `build:` services, services with empty `actual_digest`. UI shows the reason. Self and container services ARE polled (badge informative; jobs still refused by `jobs`).
|
||||||
|
5. **Poller does not re-suggest the same version.** If `available_ref == ref && available_digest == D`, the badge is not written again (no `available_at` bump). The auto job is decided separately: it starts only if the service is auto, `actual_digest != D`, `D != last_tag_digest` and `desired_ref != ref@D`, on a fresh read of the row. `last_tag_digest` is set when a job row was created (not on `ErrJobActive`/`NotUpdatableError`, which retry next cycle). A failed auto job is not retried every interval; a rollback of an auto service is not undone by polling until a new digest appears. The poller also sets `last_tag_digest` to D when `actual_digest == D` (seeding services that predate the column). `FinishJob` also sets `last_tag_digest` to the pinned digest after any successful job whose `to_ref` as created has no digest (manual, webhook or poll); pinned and tagless picks leave it, so a rollback holds whoever started the update.
|
||||||
|
6. **FinishJob clearing rule gets a digest condition** (C3). Rollback to an older digest of the same tag no longer clears the newer-version badge.
|
||||||
|
7. **Poll auto jobs use `repo:tag`, not `repo:tag@D`.** v2 agents keep working for poll and webhook jobs. Only UI picks of digest-pinned or tagless versions need a v3 agent.
|
||||||
|
8. **First cycle runs 1 minute after start**, then every `POLL_INTERVAL` measured from the end of the previous cycle.
|
||||||
|
9. **No `last_polled_at` column** (YAGNI).
|
||||||
|
10. **Overview gets two new badges: "follows no tag" and "poll failed"** (error as `title`). Service page gets a "Polling" row. "Drifted" is not in this phase.
|
||||||
|
11. **Credentials from `DOCKER_CONFIG`** (a directory), same name and meaning as on the agent. Only inline `auths` entries supported in the container.
|
||||||
|
|
||||||
|
## Implementation notes (differences found while building)
|
||||||
|
|
||||||
|
- The plan's test names `TestResolve` and `TestComposeWritesPinnedRef` became `TestResolver`
|
||||||
|
(extended), a subtest of `TestComposeExecutor` and `TestSwarmUsesPinnedRef`.
|
||||||
|
- Tasks 2 and 3 are one commit: changing `Hub.Route`'s signature breaks `jobs` and the e2e hub
|
||||||
|
wrapper until both are updated.
|
||||||
|
- `Poller.Cycle` runs the registry lookups concurrently and then records the results
|
||||||
|
sequentially (on `context.WithoutCancel`).
|
||||||
|
- `web.Config` gained `PollInterval` for the "every <interval>" text.
|
||||||
|
- `authn.DefaultKeychain` with a `credsStore` whose helper binary is missing makes the lookup fail
|
||||||
|
with `error getting credentials - err: exec: "docker-credential-...": executable file not found in $PATH`
|
||||||
|
(covered by `TestCredsStoreWithoutHelperFailsTheLookup`); it does not fall back to anonymous access.
|
||||||
|
|
||||||
|
## Review Focus
|
||||||
|
|
||||||
|
| Failure case | Test that covers it |
|
||||||
|
|---|---|
|
||||||
|
| Registry access is HEAD only; no manifest GET ever happens. | registry `TestDigestUsesHeadOnly` |
|
||||||
|
| Multi-arch: digest returned is the index digest (same as `RepoDigests` / Swarm spec). | registry `TestDigestOfIndexIsIndexDigest` |
|
||||||
|
| Credentials from `$DOCKER_CONFIG/config.json` are sent; anonymous works without the file. | registry `TestCredentialsFromDockerConfig`, `TestAnonymousWithoutConfig` |
|
||||||
|
| One HEAD per distinct followed ref (`repo:tag`, `repo:tag@d`, bare → `:latest`). | poller `TestOneHeadPerDistinctRef` |
|
||||||
|
| No tag / built / no digest are skipped, no HEAD. | poller `TestSkipped` |
|
||||||
|
| Error on one ref goes into its services' `last_poll_error`; others continue; later success clears it. | poller `TestErrorIsPerRef`, `TestErrorClearedOnSuccess` |
|
||||||
|
| Already-suggested digest neither re-suggested nor re-started. | poller `TestSameSuggestionIsIdempotent` |
|
||||||
|
| Auto gets exactly one `TriggerPoll` job to `repo:tag`; manual only gets marked. | poller `TestNewDigest` table |
|
||||||
|
| `ErrJobActive` / `NotUpdatableError` from `Start` do not abort the cycle. | poller `TestStartErrorsDoNotAbortCycle` |
|
||||||
|
| Concurrency within limit; cancellation stops the cycle; marking/starting on `WithoutCancel`. | poller `TestConcurrencyLimit`, `TestRunStopsOnCancel` |
|
||||||
|
| Form cannot deploy an arbitrary ref: unknown version ID → 404; version of another repo → 404, no job. | jobs `TestTriggerVersion_*`, web `TestUpdateService_Version*` |
|
||||||
|
| Pinned/tagless refs routed only to protocol ≥ 3 agents; old agents → failed "agent too old to deploy a pinned version (needs protocol 3)" job (`ErrAgentTooOldForPinned`, which also satisfies `errors.Is(err, ErrAgentTooOld)`). | jobs `TestTriggerVersion_RequiresPinnedProtocol`, hub `TestRoute` |
|
||||||
|
| Agent deploys a pinned ref verbatim, no registry resolve. | updater `TestResolve`, `TestComposeWritesPinnedRef`, `TestSwarmUsesPinnedRef` |
|
||||||
|
| Rollback to older digest of same tag keeps newer suggestion; pick of suggested digest clears it. | store `TestFinishJob_ClearsAvailableOnlyWhenThatVersionWasDeployed` (extended) |
|
||||||
|
| Running versions recorded only with a digest; re-snapshot does not duplicate or reorder. | store `TestSnapshotRecordsRunningVersions` |
|
||||||
|
| Digest-less webhook row hidden once the tag's digest is known; order, limit, repo filter. | store `TestRecentVersions` |
|
||||||
|
| `available_source = 'poll'` passes the CHECK. | store `TestSetAvailable_RecordsVersion` |
|
||||||
|
| E2E: pick of a pinned version reaches the agent as that exact ref. | e2e `TestPickPinnedVersion` |
|
||||||
|
|
||||||
|
## Frozen contracts
|
||||||
|
|
||||||
|
### C1. Migration `internal/master/store/migrations/0004_polling.sql`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE services DROP CONSTRAINT services_available_source_check;
|
||||||
|
ALTER TABLE services ADD CONSTRAINT services_available_source_check
|
||||||
|
CHECK (available_source IN ('', 'webhook', 'poll'));
|
||||||
|
ALTER TABLE services ADD COLUMN available_digest text NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE services ADD COLUMN last_poll_error text NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE services ADD COLUMN last_tag_digest text NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
-- Every version the master has seen. '' means "no tag" / "digest unknown";
|
||||||
|
-- never NULL, so the unique constraint deduplicates.
|
||||||
|
CREATE TABLE image_versions (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
repo text NOT NULL,
|
||||||
|
tag text NOT NULL DEFAULT '',
|
||||||
|
digest text NOT NULL DEFAULT '',
|
||||||
|
source text NOT NULL CHECK (source IN ('webhook', 'poll', 'running')),
|
||||||
|
first_seen_at timestamptz NOT NULL,
|
||||||
|
CONSTRAINT image_versions_tag_or_digest CHECK (tag <> '' OR digest <> ''),
|
||||||
|
CONSTRAINT image_versions_unique UNIQUE (repo, tag, digest)
|
||||||
|
);
|
||||||
|
CREATE INDEX image_versions_recent ON image_versions (repo, first_seen_at DESC, id DESC);
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirm the existing constraint name in `0003_webhook.sql` before relying on it.
|
||||||
|
|
||||||
|
### C2. `internal/master/model`
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Service gains:
|
||||||
|
AvailableDigest string // digest of AvailableRef when known (poll); "" from the webhook
|
||||||
|
LastPollError string // last registry error for this service's followed ref; "" when fine
|
||||||
|
|
||||||
|
const SourcePoll = "poll" // alongside SourceWebhook
|
||||||
|
const SourceRunning = "running" // image_versions only
|
||||||
|
|
||||||
|
type Available struct { Ref, Digest, Source string; At time.Time } // Digest added
|
||||||
|
|
||||||
|
// ImageVersion is one row of image_versions.
|
||||||
|
type ImageVersion struct {
|
||||||
|
ID int64
|
||||||
|
Repo, Tag, Digest, Source string
|
||||||
|
FirstSeenAt time.Time
|
||||||
|
}
|
||||||
|
// Ref returns "repo:tag@digest", "repo:tag" or "repo@digest".
|
||||||
|
func (v ImageVersion) Ref() string
|
||||||
|
```
|
||||||
|
|
||||||
|
### C3. Store
|
||||||
|
|
||||||
|
```go
|
||||||
|
// SetAvailable sets available_ref/_digest/_source/_at and, in the same
|
||||||
|
// transaction, inserts image_versions(repo, tag parsed from a.Ref, a.Digest,
|
||||||
|
// a.Source, a.At) ON CONFLICT DO NOTHING.
|
||||||
|
SetAvailable(ctx, id int64, a model.Available) error
|
||||||
|
|
||||||
|
// RecentVersions returns up to limit versions of repo, newest first_seen_at
|
||||||
|
// first (ties: id DESC). It leaves out a digest-less row when a row with the
|
||||||
|
// same repo and tag and a digest was first seen at or after it.
|
||||||
|
RecentVersions(ctx, repo string, limit int) ([]model.ImageVersion, error)
|
||||||
|
|
||||||
|
// GetImageVersion returns one version or model.ErrNotFound.
|
||||||
|
GetImageVersion(ctx, id int64) (model.ImageVersion, error)
|
||||||
|
|
||||||
|
// SetPollError sets last_poll_error on the given services ("" clears).
|
||||||
|
SetPollError(ctx, ids []int64, msg string) error
|
||||||
|
```
|
||||||
|
|
||||||
|
`RecentVersions` SQL:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT id, repo, tag, digest, source, first_seen_at FROM image_versions v
|
||||||
|
WHERE v.repo = $1
|
||||||
|
AND NOT (v.digest = '' AND EXISTS (
|
||||||
|
SELECT 1 FROM image_versions d
|
||||||
|
WHERE d.repo = v.repo AND d.tag = v.tag AND d.digest <> ''
|
||||||
|
AND d.first_seen_at >= v.first_seen_at))
|
||||||
|
ORDER BY v.first_seen_at DESC, v.id DESC
|
||||||
|
LIMIT $2
|
||||||
|
```
|
||||||
|
|
||||||
|
`upsertService` (running inventory): after the service upsert, on the same `execer`, only when `svc.Digest` matches `^sha256:[0-9a-f]{64}$`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO image_versions (repo, tag, digest, source, first_seen_at)
|
||||||
|
VALUES ($1, $2, $3, 'running', $4) ON CONFLICT DO NOTHING
|
||||||
|
```
|
||||||
|
|
||||||
|
`repo`/`tag` from `imageref.Parse(svc.Image)`, `digest` = `svc.Digest`. A bare image (no tag, no digest in the name) is recorded with `tag = 'latest'`, like `jobs.FollowedRef`, so it dedupes with the poll row; `repo@digest` is recorded with `tag = ''`. Covers `ApplySnapshot` and `UpsertService` with no hub change.
|
||||||
|
|
||||||
|
`serviceColumns` / `scanService` gain `s.available_digest, s.last_poll_error, s.last_tag_digest`. `SetLastTagDigest(ctx, id, digest)` sets the last column; inventory writes never touch it.
|
||||||
|
|
||||||
|
`FinishJob` clearing rule (replaces phase 3's). With `requestedRef, requestedDigest, _ := strings.Cut(to_ref_as_created, "@")`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
available_ref = $4 AND available_at <= $3
|
||||||
|
AND ($5 = '' OR available_digest = $5)
|
||||||
|
```
|
||||||
|
|
||||||
|
`$5` = `requestedDigest`. A digest-pinned job does NOT clear a digest-less (webhook) suggestion: a rollback to `main@old` must not hide that a newer `main` was announced. The badge then stays until a plain tag job succeeds; that is accepted. Clearing sets `available_ref=''`, `available_digest=''`, `available_source=''`, `available_at=NULL`.
|
||||||
|
|
||||||
|
### C4. `internal/master/registry`
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Client looks up tag digests with HEAD requests only.
|
||||||
|
type Client struct{ /* keychain authn.Keychain, userAgent string */ }
|
||||||
|
// New uses authn.DefaultKeychain ($DOCKER_CONFIG/config.json, else ~/.docker/config.json).
|
||||||
|
func New(userAgent string) *Client
|
||||||
|
// Digest returns the digest ref ("repo:tag", any digest suffix ignored) points
|
||||||
|
// at: the index digest for multi-arch images. HEAD /v2/<repo>/manifests/<tag> only.
|
||||||
|
func (c *Client) Digest(ctx context.Context, ref string) (string, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
Implementation: `name.ParseReference(repo+":"+tag)`; `remote.Head(r, remote.WithContext(ctx), remote.WithAuthFromKeychain(c.keychain), remote.WithUserAgent(c.userAgent))`; return `desc.Digest.String()`. Never fall back to GET if `Docker-Content-Digest` is missing.
|
||||||
|
|
||||||
|
### C5. `internal/master/poller`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Store interface {
|
||||||
|
ListServices(ctx context.Context) ([]model.Service, error)
|
||||||
|
GetService(ctx context.Context, id int64) (model.Service, error)
|
||||||
|
SetAvailable(ctx context.Context, id int64, a model.Available) error
|
||||||
|
SetLastTagDigest(ctx context.Context, id int64, digest string) error
|
||||||
|
SetPollError(ctx context.Context, ids []int64, msg string) error
|
||||||
|
}
|
||||||
|
type Jobs interface {
|
||||||
|
Start(ctx context.Context, svc model.Service, req jobs.StartRequest) (model.Job, error)
|
||||||
|
}
|
||||||
|
type Registry interface { Digest(ctx context.Context, ref string) (string, error) }
|
||||||
|
type Publisher interface { Publish(t events.Topic) }
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Interval time.Duration // POLL_INTERVAL
|
||||||
|
FirstDelay time.Duration // 1m in production; small in tests
|
||||||
|
Workers int // 4
|
||||||
|
}
|
||||||
|
func New(st Store, j Jobs, reg Registry, pub Publisher, cfg Config, log *slog.Logger) *Poller
|
||||||
|
// Run polls until ctx is done. Blocking.
|
||||||
|
func (p *Poller) Run(ctx context.Context)
|
||||||
|
// Cycle runs one poll over all services (exported for tests and e2e).
|
||||||
|
func (p *Poller) Cycle(ctx context.Context)
|
||||||
|
// SkipReason says why svc is not polled: "follows no tag", "built from
|
||||||
|
// source", "no registry digest", or "" when it is polled.
|
||||||
|
func SkipReason(svc model.Service) string
|
||||||
|
```
|
||||||
|
|
||||||
|
Cycle semantics, per distinct `ref = jobs.FollowedRef(svc.ActualRef)` over non-removed, non-skipped services:
|
||||||
|
1. `D, err := reg.Digest(ctx, ref)` with a 30s timeout per call.
|
||||||
|
2. On error: `SetPollError(ids whose LastPollError != msg, msg)`, log `poll_failed` (ref, error).
|
||||||
|
3. On success: `SetPollError(ids with LastPollError != "", "")`. Then per service:
|
||||||
|
- Suggestion: `svc.ActualDigest == D` → nothing; `svc.AvailableRef == ref && svc.AvailableDigest == D` → no write; otherwise `SetAvailable({ref, D, poll, now})`.
|
||||||
|
- Auto job, independent of the suggestion: if `UpdateMode == auto && ActualDigest != D && D != LastTagDigest && DesiredRef != ref+"@"+D`, re-read the service (`GetService`), re-check the same condition and removed/mode on the fresh row, then `Start(TriggerPoll, Ref: ref)`. If a job row was created, `SetLastTagDigest(D)`. `ErrJobActive` and `NotUpdatableError` are logged at debug and ignored, leaving `last_tag_digest` unset so the next cycle retries.
|
||||||
|
|
||||||
|
Steps 2 and 3 use `context.WithoutCancel(ctx)`. At the end, publish `events.Services` if anything was written; log `poll_cycle` with refs, ok, failed, suggested, jobs, skipped, duration_ms.
|
||||||
|
|
||||||
|
### C6. `internal/master/jobs` and hub
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Hub.Route gains the minimum protocol version for this job:
|
||||||
|
Route(svc model.Service, minProtocol int) (model.Route, error)
|
||||||
|
|
||||||
|
// Store gains:
|
||||||
|
GetImageVersion(ctx context.Context, id int64) (model.ImageVersion, error)
|
||||||
|
|
||||||
|
// TriggerVersion starts a manual job to exactly version versionID of the
|
||||||
|
// service's repo. model.ErrNotFound for an unknown service, an unknown
|
||||||
|
// version, or a version of another repo. Otherwise what Start returns.
|
||||||
|
func (s *Service) TriggerVersion(ctx context.Context, serviceID, versionID int64, requestedBy string) (model.Job, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
- Ref = `svc.ImageRepo` + `":"+tag` if tag set + `"@"+digest` if digest set.
|
||||||
|
- Calls `Start` with `TriggerManual` and explicit ref ("follows no tag" refusal does not apply; removed, self, built, container still do).
|
||||||
|
- `Start` passes `protocol.MinPinnedRefVersion` to `Route` when the ref has a digest or no tag, otherwise `protocol.MinJobVersion`.
|
||||||
|
|
||||||
|
### C7. Protocol and agent
|
||||||
|
|
||||||
|
```go
|
||||||
|
const Version = 3
|
||||||
|
const MinJobVersion = 2 // unchanged: repo:tag jobs
|
||||||
|
const MinPinnedRefVersion = 3 // jobs whose Ref has a digest or no tag
|
||||||
|
```
|
||||||
|
|
||||||
|
`JobStart` doc: "Ref is `repo:tag`, `repo:tag@digest` or `repo@digest`. For `repo:tag` the agent resolves the tag's current digest; a ref with a digest is deployed as is."
|
||||||
|
|
||||||
|
`Resolver.Resolve`: digest present → return ref unchanged, no `DistributionInspect`; tag present → resolve as today; neither → error "has no tag to resolve" (unchanged). Compose writes the ref verbatim into the override, Swarm sets it as the image. Executors print `pinned <ref>` instead of `resolved <ref>` when nothing was resolved.
|
||||||
|
|
||||||
|
### C8. Master configuration
|
||||||
|
|
||||||
|
- `POLL_INTERVAL`: default `6h`, positive Go duration, error `"POLL_INTERVAL must be a positive duration such as 6h, got %q"`.
|
||||||
|
- `DOCKER_CONFIG`: not parsed by `config`; `go-containerregistry` reads it. `main` logs `registry_credentials` with the path and whether `config.json` exists, never its contents.
|
||||||
|
|
||||||
|
### C9. Web
|
||||||
|
|
||||||
|
- `POST /services/{id}/update` gains optional form field `version` (an `image_versions.id`): empty → `Trigger` as today; non-numeric → `400 invalid version`; otherwise `TriggerVersion`. Results: `303` to the job (also on `ErrJobActive`), `404` on `ErrNotFound`, `409` re-render "Cannot update: <reason>."
|
||||||
|
- `web.Store` gains `RecentVersions`; `web.Jobs` gains `TriggerVersion`.
|
||||||
|
- Service page: "Versions" table, up to 10 rows: ref (shortRef, full ref as title), source, first seen, "running" marker when digest equals `ActualDigest`, a POST form with `csrf_token` and `version`. Forms shown only when `AutoBlocked == ""`. Rows with empty tag note "the service will follow no tag afterwards". When the service is `auto`, header notes auto mode will move it forward at the next new version. A "Polling" row shows `SkipReason`, or `last_poll_error`, or "every <interval>".
|
||||||
|
- Overview: badge "follows no tag" when `FollowedRef(ActualRef) == ""`; badge "poll failed" (title = error) when `LastPollError != ""`. Template func `followsNoTag`.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
### Task 1: Store and model (C1, C2, C3)
|
||||||
|
Migration, model fields, store methods, `upsertService` running versions, `FinishJob` rule. Integration tests: SetAvailable writes digest/source poll and records version, second call keeps first_seen_at; TestSnapshotRecordsRunningVersions (tag+digest, tagless digest, no digest skipped, re-snapshot idempotent); TestRecentVersions (order, limit 10, repo filter, digest-less row hidden only when a digest row for the same tag was first seen at or after it); GetImageVersion ErrNotFound; SetPollError sets/clears, upsert does not touch last_poll_error/available_*; FinishJob table extended (tag job clears poll suggestion; pick tag@old keeps suggestion tag@new; pick tag@new clears; webhook suggestion (digest '') cleared by matching tag; tagless pick keeps it). Update the e2e `memStore` in the same commit.
|
||||||
|
|
||||||
|
### Task 2: Protocol, agent and hub routing (C7, C6 Route)
|
||||||
|
Bump protocol, MinPinnedRefVersion, docs; Resolver pass-through; compose/swarm output line; `hub.Route(svc, minProtocol)`. Tests: TestResolve table (tag resolves; tag@d and @d pass through with zero inspect calls; bare errors); compose pinned ref verbatim in override; swarm spec image equals pinned ref; route rows v2 agent + min 3 → ErrAgentTooOld, v3 → ok, swarm fallback to v3 manager when reporting manager is v2.
|
||||||
|
|
||||||
|
### Task 3: Jobs (C6)
|
||||||
|
`Start` picks min version from the ref; `TriggerVersion`. Tests: version of other repo → ErrNotFound, no CreateJob; ref construction table; min version passed to fake hub (plain Trigger → 2, pinned → 3); explicit ref bypasses no-tag but not removed/self/built/container; existing tests green.
|
||||||
|
|
||||||
|
### Task 4: Registry client and poller (C4, C5)
|
||||||
|
Registry tests (local httptest + ggcr/pkg/registry, no network): random.Index digest equals index digest; recording handler shows only HEAD on /manifests/; basic-auth handler succeeds with temp DOCKER_CONFIG (t.Setenv) and fails without; 404 → error. Verify behaviour of DefaultKeychain when config.json has credsStore but no helper binary; document. Poller tests per Review Focus, plus Run test (first cycle after delay, no overlap, returns on cancel).
|
||||||
|
|
||||||
|
### Task 5: Config, wiring, web (C8, C9)
|
||||||
|
Config POLL_INTERVAL with tests; add to setAll list. `cmd/master/main.go`: registry.New, poller, `go p.Run(ctx)` with done channel, wait for it after HTTP shutdown and before st.Close; log registry_credentials. Web handlers/templates per C9 with tests (picker ≤10 rows with CSRF and version IDs; hidden for built/self/container/removed; POST with version → TriggerVersion, without → Trigger; 400/404/409; overview badges; Polling row).
|
||||||
|
|
||||||
|
### Task 6: End-to-end
|
||||||
|
`internal/e2e/versions_test.go`: TestPickPinnedVersion; TestPollStartsAutoJob (poller.Cycle with fake registry → one TriggerPoll job to repo:tag; second Cycle starts nothing). `currentHub.Route` new signature.
|
||||||
|
|
||||||
|
### Task 7: Documentation
|
||||||
|
README (POLL_INTERVAL, DOCKER_CONFIG, "Registry polling" section: polled/skipped, credentials only inline auths, Docker Hub note, version picker & rollback, auto caveat, agent protocol 3 for pinned/tagless picks). Spec: replace "Believed not to count against Docker Hub pull limits for HEAD requests — verify during implementation" with verified statement + source link (docs.docker.com/docker-hub/usage/pulls: "Using GET emulates a real pull and counts towards the limit. Using HEAD won't."; note the separate unquantified abuse rate limit). `deploy/master.stack.yml`: mount config dir read-only, set DOCKER_CONFIG. CLAUDE.md: architecture entries for registry and poller; gotchas (HEAD only; digest-less webhook row hidden once digest known; image_versions uses '' not NULL; picks by version ID and pinned/tagless need protocol 3; FinishJob rule includes digest (replace existing bullet); rollback to repo@digest makes the service follow no tag); phase 4 plan reference. Save this plan as `docs/superpowers/plans/2026-10-13-master-agent-phase-4-polling.md`.
|
||||||
|
|
||||||
|
## Final verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gofmt -l . ; go vet ./...
|
||||||
|
go build ./cmd/master ./cmd/agent && rm -f master agent
|
||||||
|
go test ./...
|
||||||
|
go test -race ./internal/agent/... ./internal/master/... ./internal/e2e/
|
||||||
|
DATABASE_URL=postgres://... go test -tags integration ./internal/master/store/
|
||||||
|
```
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# Master/Agent Phase 5 — Drift and Merge Implementation Plan
|
||||||
|
|
||||||
|
> This plan documents what was built for issue #10 (backend only) and where it deviates from the spec. Code in "Frozen contracts" is final; tasks give the contract and the test cases.
|
||||||
|
|
||||||
|
**Goal:** The master knows when a service runs something other than the version the last job deployed (drift), never corrects it by itself, and offers two ways out: restore the desired version or accept the running one. When a Compose service disappears from one agent and appears on another, the master suggests that it moved, and a merge moves the old row (history, mode, desired ref) to the new location. Merging can also be done by hand.
|
||||||
|
|
||||||
|
**Architecture:**
|
||||||
|
- Model: `Service.Drifted()` plus `RunsRef` and `RunningRef` decide drift and what accept writes. `Move` carries a suggestion.
|
||||||
|
- Store: migration 0005 adds `services.awaiting_inventory`; `FinishJob` sets it, every inventory upsert clears it. New `AcceptRunning`, `MoveCandidates`, `MergeSources`, `MergeService` (the only code that deletes a `services` row).
|
||||||
|
- Jobs: `Start` refuses poll and webhook jobs for a drifted service. `Restorable` and `RestoreDesired` deploy the recorded ref again as a manual job.
|
||||||
|
- Web: three POST routes (`/services/{id}/restore`, `/accept`, `/merge`) and new `pageData` fields. No template or CSS changes: issue #18 (the Ledger rewrite of the Services and Agents pages) is a separate UI change; the restore, accept and merge buttons arrive with the UI follow-up, issue #28.
|
||||||
|
|
||||||
|
**Tech Stack:** no new dependencies.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-10-12-master-agent-design.md`, sections "Drift" and "Service identity and moves". Phase 4 plan conventions still apply.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- `go test ./...` passes with no Docker and no Postgres. Postgres tests carry `//go:build integration`.
|
||||||
|
- No changes to `internal/master/web/templates/*`, `static/*`, `layout.html` or `groupServices`.
|
||||||
|
- Interfaces declared at the consumer (`jobs.Store`, `web.Store`, `web.Jobs`). Hand-written fakes and testify, table-driven where there is more than one case.
|
||||||
|
- GoDoc on every exported identifier. Conventional Commits, each ending with ` (#10)`.
|
||||||
|
- Every state-changing request is a POST with a CSRF token. Refusals render the service page with the reason (409), not a bare error.
|
||||||
|
- The invariants of earlier phases hold: one active job per service (`update_jobs_one_active`), the webhook and poller never create services, the updater's own services are never updated or merged.
|
||||||
|
|
||||||
|
## Deliberate deviations from the spec in this phase
|
||||||
|
|
||||||
|
1. **Drift compares what runs, not reference strings.** The spec says a service drifts when `actual_ref` differs from `desired_ref`. `desired_ref` is always `repo:tag@digest` while `actual_ref` is the raw image string, so a string comparison would flag every healthy service. `RunsRef` compares the repo exactly (no `docker.io` normalisation), then the digest when both sides know one, else the followed tag. The same digest under a different tag is therefore not drift.
|
||||||
|
2. **`awaiting_inventory`.** The agent's upsert arrives a debounce interval after `job.result`; until then `desired_ref` is newer than `actual_ref`. The flag, set by `FinishJob` and cleared by the next upsert of the row, keeps that window from counting as drift.
|
||||||
|
3. **Poll and webhook jobs are refused for a drifted service** (`ReasonDrifted`). Manual jobs are allowed, so a person can still update or roll back.
|
||||||
|
4. **Move suggestion is stricter than "exactly one appears".** Besides one removed and one live row on each side, the live row must be newer, have no desired ref and no jobs, and the removed row must have something worth moving (desired ref, auto mode or jobs). Otherwise every restarted stack would produce a suggestion.
|
||||||
|
5. **Manual merge is allowed for Compose and Swarm of the same kind**, names may differ, same agent allowed. Plain containers and the updater itself are refused.
|
||||||
|
6. **Accept does not clear `available_*`**, and the form's `actual` field (`Service.RunningRef()`) is only a guard; the value written comes from the row.
|
||||||
|
7. **The overview does not show moves or drift yet.** `MoveCandidates` is only read on the service page. `Service.Drifted()` is a model method, so the UI follow-up needs no further wiring.
|
||||||
|
8. **`CreateJob` maps the foreign key violation on `update_jobs_service_id_fkey` to `ErrNotFound`**, so a job for a service that a merge just deleted fails cleanly.
|
||||||
|
|
||||||
|
## Review Focus
|
||||||
|
|
||||||
|
| Failure case | Test that covers it |
|
||||||
|
|---|---|
|
||||||
|
| Drift by digest, not string: tag+digest vs tag and reported digest, bare image, tagless, port colon, other repo, case. | model `TestRunsRef`, `TestDrifted` |
|
||||||
|
| Accept writes `repo:tag@digest` from the running version. | model `TestRunningRef` |
|
||||||
|
| A fresh job result is not drift until the inventory arrives. | store `TestFinishJob_SetsAwaitingInventoryUntilTheNextUpsert`; e2e `TestNoDriftAfterASuccessfulJob` |
|
||||||
|
| Accept is guarded by the running version, refuses removed/never-updated, leaves `last_tag_digest`, `available_*`, mode alone. | store `TestAcceptRunning_*` |
|
||||||
|
| Poll and webhook start no job for a drifted service; manual does. | jobs `TestStart_RefusesPollAndWebhookWhileDrifted`; e2e `TestDriftedAutoServiceIsNotUpdatedByPollOrWebhook` |
|
||||||
|
| Restore deploys the recorded ref and remembers the drifted digest of the same tag. | jobs `TestRestoreDesired_*`; e2e `TestRestoreDeploysThePinnedRefOnTheAgent` |
|
||||||
|
| Move suggestion only for a unique, new, history-less target and a removed source with history. | store `TestMoveCandidates` |
|
||||||
|
| Merge keeps id, mode, desired ref, first seen; takes location and inventory; moves jobs; deletes the duplicate. | store `TestMergeService_MovesHistoryModeAndDesiredToTheNewLocation` |
|
||||||
|
| Merge refusals change nothing (active job, kinds, container, self, live old, removed new, revoked agent, unknown). | store `TestMergeService_Refusals` |
|
||||||
|
| Inventory after a merge updates the survivor; the old agent returning creates a new row. | store `TestMergeService_InventoryAfterTheMerge` |
|
||||||
|
| A job for a deleted service is `ErrNotFound`. | store `TestCreateJob_UnknownServiceIsNotFound` |
|
||||||
|
| Handlers: redirects, 400/404/409, signals published, survivor id in the redirect. | web `TestRestoreDesired`, `TestAcceptRunning_*`, `TestMergeService_*`, `TestServiceDetail_DriftAndMoveData` |
|
||||||
|
|
||||||
|
## Frozen contracts
|
||||||
|
|
||||||
|
### C1. Migration `0005_drift_merge.sql`
|
||||||
|
`services.awaiting_inventory boolean NOT NULL DEFAULT false`; index `services_by_compose_name` on `(compose_project, compose_service) WHERE kind = 'compose'`.
|
||||||
|
|
||||||
|
### C2. `internal/master/model`
|
||||||
|
`Service.AwaitingInventory`; `Service.Drifted()`; `RunsRef(desired, actualRef, actualDigest)`; `RunningRef(actualRef, actualDigest)`; `Move{FromID, ToID, FromAgent, ToAgent}` (FromID is the removed row). `ReasonDrifted`, `ReasonNotDrifted`; `MergeRefusedError{Reason}` with the `ReasonMerge*` constants (`ReasonSelf` is reused).
|
||||||
|
|
||||||
|
### C3. Store
|
||||||
|
- `upsertService` sets `awaiting_inventory = false`; `FinishJob` success sets it to true.
|
||||||
|
- `AcceptRunning(ctx, id, actualRef, actualDigest, desired)`: updates only a live, previously updated row that still reports that actual ref and digest. `ErrNotFound` for unknown or removed, `ErrConflict` otherwise.
|
||||||
|
- `MoveCandidates(ctx) ([]model.Move, error)`: Compose only, not self, other agent, unique on both sides before the extra filters (live row newer, no desired ref, no jobs; removed row has a desired ref, auto mode or jobs). Agent names by `LEFT JOIN`, so a revoked agent keeps its name.
|
||||||
|
- `MergeSources(ctx, toID)`: removed rows of the same kind (not container, not self), same name first, then `removed_at DESC`, at most 50.
|
||||||
|
- `MergeService(ctx, fromID, toID)`: one transaction. Lock order: the live row's agent (`FOR SHARE`, `ErrRevoked`), then both rows `ORDER BY id FOR UPDATE`. Validates (`MergeRefusedError`), refuses an active job on either row (`ErrJobActive`), moves the duplicate's jobs, reads its location and inventory, **deletes it**, then updates the old row (keeps id, kind, `first_seen_at`, `update_mode`, `desired_ref`; takes agent, location, inventory columns, available suggestion and poll error; `last_tag_digest` only if the old row has none; `removed_at = NULL`, `awaiting_inventory = false`). The delete comes first because `location_key` is unique and not deferrable.
|
||||||
|
- `CreateJob`: foreign key violation `update_jobs_service_id_fkey` becomes `ErrNotFound`.
|
||||||
|
|
||||||
|
### C4. Jobs
|
||||||
|
`Start` returns `NotUpdatableError{ReasonDrifted}` for a non-manual trigger on a drifted service. `Restorable(svc)`: drifted, then `updatable(svc, true)`, and the desired ref's repo is not the agent image repo. `RestoreDesired(ctx, id, by)`: `Start` with `Ref: svc.DesiredRef`, manual. After a created job, `SetLastTagDigest` to the drifted digest when the actual tag equals the desired tag, the digest is well formed, differs from the desired digest and from `last_tag_digest`, and `last_tag_digest` is empty or equals the desired digest (otherwise it holds back a newer digest and stays).
|
||||||
|
|
||||||
|
### C5. Web
|
||||||
|
Routes `POST /services/{id}/restore`, `/accept` (form `actual` required, the `Service.RunningRef()` the user saw, compared with the row's; optional `return=overview`), `/merge` (form `from` = removed row, `{id}` = live row; redirects to the surviving id). `pageData` gains `RestoreBlocked`, `MoveFrom`, `MoveTo`, `MergeSources`. Log events `drift_accepted` and `service_merged`.
|
||||||
|
|
||||||
|
### C6. Concurrency
|
||||||
|
An upsert for the live row's location waits on its lock, then updates the survivor. A snapshot after the merge lists the survivor's key, so it is not marked removed. A removal racing a merge can be lost; the next snapshot fixes it. `CreateJob` on the deleted row gets `ErrNotFound`; the poller and webhook re-read and log it.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
### Task 1: Store and model (C1, C2, C3)
|
||||||
|
Migration, `imageref.IsDigest` (replaces the local pattern in the store), model types and functions, store methods. Tests: model tables; integration tests listed in Review Focus.
|
||||||
|
|
||||||
|
### Task 2: Jobs (C4)
|
||||||
|
`Store.SetLastTagDigest` on the consumer interface, `Start` refusal, `Restorable`, `RestoreDesired`. Tests: refusal table, manual job to the desired ref with protocol 3, active job, old agent, last-tag-digest table.
|
||||||
|
|
||||||
|
### Task 3: Web (C5)
|
||||||
|
Handlers, interfaces, `pageData`. Tests with fakes.
|
||||||
|
|
||||||
|
### Task 4: End-to-end
|
||||||
|
`memStore.FinishJob` sets `AwaitingInventory`. `internal/e2e/drift_test.go`.
|
||||||
|
|
||||||
|
### Task 5: Documentation
|
||||||
|
README section "Drift and moved services", webhook note, CLAUDE.md design intent and gotchas, this plan.
|
||||||
|
|
||||||
|
## Final verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gofmt -l . ; go vet ./...
|
||||||
|
go build ./cmd/master ./cmd/agent && rm -f master agent
|
||||||
|
go test ./...
|
||||||
|
go test -race ./internal/agent/... ./internal/master/... ./internal/e2e/
|
||||||
|
DATABASE_URL=postgres://... go test -tags integration ./internal/master/store/
|
||||||
|
```
|
||||||
@@ -247,10 +247,19 @@ them. An agent recreating itself would kill the job it is running.
|
|||||||
- Mechanism: `HEAD /v2/<repo>/manifests/<tag>`; the returned digest is compared with
|
- Mechanism: `HEAD /v2/<repo>/manifests/<tag>`; the returned digest is compared with
|
||||||
`actual_digest`. Use `go-containerregistry` — it handles Docker Hub's token auth and
|
`actual_digest`. Use `go-containerregistry` — it handles Docker Hub's token auth and
|
||||||
multi-arch indexes.
|
multi-arch indexes.
|
||||||
- Interval `POLL_INTERVAL`, default 6h. Believed not to count against Docker Hub pull
|
- Interval `POLL_INTERVAL`, default 6h. Docker Hub documents that HEAD requests do not
|
||||||
limits for HEAD requests — verify during implementation.
|
count against the pull limit ("Using GET emulates a real pull and counts towards the
|
||||||
|
limit. Using HEAD won't."; verified against
|
||||||
|
https://docs.docker.com/docker-hub/usage/pulls/). The same page mentions a separate,
|
||||||
|
unquantified abuse rate limit for many pulls from one IP address, so the poller stays
|
||||||
|
HEAD-only and never downloads a manifest.
|
||||||
- Credentials: a Docker `config.json` (as written by `docker login`) mounted into the
|
- Credentials: a Docker `config.json` (as written by `docker login`) mounted into the
|
||||||
master. No credential UI, no encryption in Postgres.
|
master. No credential UI, no encryption in Postgres.
|
||||||
|
- Newer version tags (e.g. `gitea:1.27.3` → `1.27.4`): for a followed tag with at least
|
||||||
|
two numeric components the poller lists the repo's tags once per cycle
|
||||||
|
(`GET /v2/<repo>/tags/list`) and stores the highest tag with the same prefix, suffix,
|
||||||
|
component count and major number in `services.newer_ref`. A suggestion only, never
|
||||||
|
deployed automatically; deploying it is the existing version pick.
|
||||||
|
|
||||||
## Webhook
|
## Webhook
|
||||||
|
|
||||||
@@ -360,10 +369,6 @@ Each phase ends with something that works on its own.
|
|||||||
|
|
||||||
## Out of scope (later specs)
|
## Out of scope (later specs)
|
||||||
|
|
||||||
- Detecting newer version *tags* for images we do not build (e.g. `gitea:1.27.3` →
|
|
||||||
`1.27.4`, `postgres:17.10`, `loki:3.4.2`). Requires listing tags and ordering them;
|
|
||||||
major upgrades such as Postgres 17 → 18 must never be automatic. Revisit after
|
|
||||||
checking the other two servers' stack files.
|
|
||||||
- Notifications.
|
- Notifications.
|
||||||
- Export/import of a fleet configuration to restore a setup. The `services` table
|
- Export/import of a fleet configuration to restore a setup. The `services` table
|
||||||
already holds what an export needs.
|
already holds what an export needs.
|
||||||
|
|||||||
+15
-4
@@ -1,6 +1,6 @@
|
|||||||
# Deploy via package-updater (composite action)
|
# Deploy via package-updater (composite action)
|
||||||
|
|
||||||
Notifies `package-updater` to `docker compose pull` + `up -d` for the matching service(s) after a CI build.
|
Notifies the `package-updater` master after a CI build. The master records the pushed version on every matching service and, for services in auto mode, updates them and waits for the result.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ jobs:
|
|||||||
token: ${{ secrets.UPDATER_TOKEN }}
|
token: ${{ secrets.UPDATER_TOKEN }}
|
||||||
```
|
```
|
||||||
|
|
||||||
`UPDATER_TOKEN` should be set as an organisation-level secret in Gitea so all repos share it.
|
`endpoint` is the master's `/update`, and `UPDATER_TOKEN` is the master's `UPDATER_API_KEY`. It should be set as an organisation-level secret in Gitea so all repos share it.
|
||||||
|
|
||||||
## Inputs
|
## Inputs
|
||||||
|
|
||||||
@@ -28,9 +28,20 @@ jobs:
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `endpoint` | yes | — | Full URL to `/update` |
|
| `endpoint` | yes | — | Full URL to `/update` |
|
||||||
| `image` | yes | — | Image reference without tag |
|
| `image` | yes | — | Image reference without tag |
|
||||||
| `tag` | no | `""` | Tag that was just pushed (logged for audit) |
|
| `tag` | no | `""` | Tag that was just pushed. Auto services are updated to `<their image repo>:<tag>`, so it must exist in the registry. Omit it to re-pull the tag each service already follows. |
|
||||||
| `token` | yes | — | Bearer token configured in package-updater |
|
| `token` | yes | — | Bearer token configured in package-updater |
|
||||||
|
|
||||||
## Failure modes
|
## Failure modes
|
||||||
|
|
||||||
The step exits non-zero if `package-updater` returns HTTP 4xx or 5xx. This is intentional — the workflow surfaces the deploy failure to whoever pushed.
|
The step exits non-zero on any HTTP 4xx or 5xx. This is intentional: the workflow surfaces the deploy failure to whoever pushed.
|
||||||
|
|
||||||
|
| Code | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `200` | Every match was marked, updated, busy or refused. Manual services are only marked, which is not an error. A service that follows no tag is `refused` when the request has no `tag`. |
|
||||||
|
| `401` | Wrong or missing token. |
|
||||||
|
| `404` | No service uses the image. Check `image` for typos; matching is case-sensitive. |
|
||||||
|
| `500` | At least one auto job failed or timed out (`UPDATE_TIMEOUT` on the master). |
|
||||||
|
| `503` | The master has no `UPDATER_API_KEY`. |
|
||||||
|
|
||||||
|
A request that matches both a working and a failed service now fails as a whole (`500`); the old single-instance updater's `207` no longer exists.
|
||||||
|
The master waits for auto jobs, so the request can take minutes; raise any step timeout in your workflow accordingly.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ require (
|
|||||||
github.com/coreos/go-oidc/v3 v3.21.0
|
github.com/coreos/go-oidc/v3 v3.21.0
|
||||||
github.com/distribution/reference v0.6.0
|
github.com/distribution/reference v0.6.0
|
||||||
github.com/docker/docker v28.5.2+incompatible
|
github.com/docker/docker v28.5.2+incompatible
|
||||||
|
github.com/google/go-containerregistry v0.22.1
|
||||||
github.com/jackc/pgx/v5 v5.10.0
|
github.com/jackc/pgx/v5 v5.10.0
|
||||||
github.com/opencontainers/go-digest v1.0.0
|
github.com/opencontainers/go-digest v1.0.0
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
@@ -21,6 +22,8 @@ require (
|
|||||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||||
github.com/containerd/log v0.1.0 // indirect
|
github.com/containerd/log v0.1.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/docker/cli v29.7.2+incompatible // indirect
|
||||||
|
github.com/docker/docker-credential-helpers v0.9.3 // indirect
|
||||||
github.com/docker/go-connections v0.7.0 // indirect
|
github.com/docker/go-connections v0.7.0 // indirect
|
||||||
github.com/docker/go-units v0.5.0 // indirect
|
github.com/docker/go-units v0.5.0 // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
@@ -30,6 +33,7 @@ require (
|
|||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
github.com/klauspost/compress v1.19.2 // indirect
|
||||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||||
github.com/moby/sys/atomicwriter v0.1.0 // indirect
|
github.com/moby/sys/atomicwriter v0.1.0 // indirect
|
||||||
github.com/moby/term v0.5.2 // indirect
|
github.com/moby/term v0.5.2 // indirect
|
||||||
@@ -37,14 +41,15 @@ require (
|
|||||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
|
||||||
go.opentelemetry.io/otel v1.43.0 // indirect
|
go.opentelemetry.io/otel v1.43.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.22.0 // indirect
|
||||||
golang.org/x/sys v0.42.0 // indirect
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
golang.org/x/text v0.35.0 // indirect
|
golang.org/x/text v0.35.0 // indirect
|
||||||
golang.org/x/time v0.15.0 // indirect
|
golang.org/x/time v0.15.0 // indirect
|
||||||
gotest.tools/v3 v3.5.2 // indirect
|
gotest.tools/v3 v3.5.2 // indirect
|
||||||
|
|||||||
@@ -21,8 +21,12 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
|||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
|
github.com/docker/cli v29.7.2+incompatible h1:dlkwallR8XqfeVnA2ELEhdwvb4lsSwuB4IgsG8Q9cLY=
|
||||||
|
github.com/docker/cli v29.7.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||||
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
|
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
|
||||||
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||||
|
github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8=
|
||||||
|
github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo=
|
||||||
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
|
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
|
||||||
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
|
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
|
||||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||||
@@ -38,6 +42,8 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
|||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/go-containerregistry v0.22.1 h1:RZuuSYhTvlDvtsK+NkutoCZ//C0X2ebLK8X8l3ULs84=
|
||||||
|
github.com/google/go-containerregistry v0.22.1/go.mod h1:bJR35SK8XgisYmhg/FMQ/5RK0S/XrOAqLBV5/LR2XE0=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
|
||||||
@@ -50,6 +56,8 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
|||||||
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
|
||||||
|
github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
@@ -74,8 +82,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
|||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
@@ -101,18 +109,22 @@ go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09
|
|||||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||||
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
|
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
|
||||||
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
|
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
|
||||||
|
golang.org/x/mod v0.39.0 h1:UF5zwQdCRRUpHfyPwr7d4UrGiVeldIsogtzWVnczL74=
|
||||||
|
golang.org/x/mod v0.39.0/go.mod h1:bvIbwjQ0HUFFf5AKukeeYQG4ZBUG9yxQbR9aEweIwYY=
|
||||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||||
|
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
||||||
|
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
|
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
|
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
|
||||||
|
|||||||
@@ -91,10 +91,10 @@ func NewComposeExecutor(cli ComposeDocker, resolve *Resolver, selfID string, run
|
|||||||
return &ComposeExecutor{cli: cli, resolve: resolve, selfID: selfID, run: run, build: NewBuildChecker(cli, selfID, runOut)}
|
return &ComposeExecutor{cli: cli, resolve: resolve, selfID: selfID, run: run, build: NewBuildChecker(cli, selfID, runOut)}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute pins job.Ref to its current digest, writes it to the stack's
|
// Execute pins job.Ref to its current digest (a ref that has one is used as
|
||||||
// override file and recreates the service. Working directory and config
|
// is), writes it to the stack's override file and recreates the service.
|
||||||
// files come from the service's container labels on this machine, never from
|
// Working directory and config files come from the service's container
|
||||||
// the job. It returns the pinned ref.
|
// labels on this machine, never from the job. It returns the pinned ref.
|
||||||
func (e *ComposeExecutor) Execute(ctx context.Context, job protocol.JobStart, out io.Writer) (string, error) {
|
func (e *ComposeExecutor) Execute(ctx context.Context, job protocol.JobStart, out io.Writer) (string, error) {
|
||||||
project, service := job.ComposeProject, job.ComposeService
|
project, service := job.ComposeProject, job.ComposeService
|
||||||
cts, err := e.cli.ContainerList(ctx, container.ListOptions{
|
cts, err := e.cli.ContainerList(ctx, container.ListOptions{
|
||||||
@@ -150,11 +150,17 @@ func (e *ComposeExecutor) Execute(ctx context.Context, job protocol.JobStart, ou
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
fmt.Fprintf(out, "resolved %s\n", pinned)
|
fmt.Fprintln(out, resolveNote(job.Ref, pinned))
|
||||||
|
|
||||||
if _, err := WriteOverride(dir, service, pinned); err != nil {
|
// Giving the override the stack's owner is best effort: a failure is only
|
||||||
|
// a warning, the update itself is fine.
|
||||||
|
_, ownerErr, err := WriteOverride(dir, service, pinned, files[0])
|
||||||
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
if ownerErr != nil {
|
||||||
|
fmt.Fprintf(out, "warning: could not give the override the stack's owner: %v\n", ownerErr)
|
||||||
|
}
|
||||||
|
|
||||||
base := composeBase(project, workingDir, files, envs)
|
base := composeBase(project, workingDir, files, envs)
|
||||||
for _, step := range [][]string{{"pull", service}, {"up", "-d", service}} {
|
for _, step := range [][]string{{"pull", service}, {"up", "-d", service}} {
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ func TestComposeExecutor(t *testing.T) {
|
|||||||
root, _ := filepath.EvalSymlinks(t.TempDir())
|
root, _ := filepath.EvalSymlinks(t.TempDir())
|
||||||
stack := filepath.Join(root, "slusk")
|
stack := filepath.Join(root, "slusk")
|
||||||
require.NoError(t, os.MkdirAll(stack, 0o755))
|
require.NoError(t, os.MkdirAll(stack, 0o755))
|
||||||
|
composeFile := stackFile(t, stack)
|
||||||
job := protocol.JobStart{Kind: protocol.KindCompose, ComposeProject: "slusk", ComposeService: "web", Ref: "gitea.shcizo.se/shcizo/slusk:1.4"}
|
job := protocol.JobStart{Kind: protocol.KindCompose, ComposeProject: "slusk", ComposeService: "web", Ref: "gitea.shcizo.se/shcizo/slusk:1.4"}
|
||||||
pinned := job.Ref + "@" + d
|
pinned := job.Ref + "@" + d
|
||||||
|
|
||||||
@@ -107,6 +108,37 @@ func TestComposeExecutor(t *testing.T) {
|
|||||||
_, doc := readOverride(t, stack)
|
_, doc := readOverride(t, stack)
|
||||||
assert.Equal(t, pinned, serviceImage(doc, "web"))
|
assert.Equal(t, pinned, serviceImage(doc, "web"))
|
||||||
assert.Contains(t, out.String(), "resolved "+pinned)
|
assert.Contains(t, out.String(), "resolved "+pinned)
|
||||||
|
assert.NotContains(t, out.String(), "warning")
|
||||||
|
})
|
||||||
|
t.Run("an unreadable owner is a warning and the job still succeeds", func(t *testing.T) {
|
||||||
|
t.Cleanup(cleanOverride)
|
||||||
|
require.NoError(t, os.Rename(composeFile, composeFile+".bak"))
|
||||||
|
t.Cleanup(func() { os.Rename(composeFile+".bak", composeFile) })
|
||||||
|
var out strings.Builder
|
||||||
|
got, err := setup(fakeCompose{rw, []container.Summary{ct}}, &fakeDist{digest: d}, &recorder{}).Execute(context.Background(), job, &out)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, pinned, got)
|
||||||
|
assert.Contains(t, out.String(), "warning: could not give the override the stack's owner: read owner: ")
|
||||||
|
_, doc := readOverride(t, stack)
|
||||||
|
assert.Equal(t, pinned, serviceImage(doc, "web"))
|
||||||
|
})
|
||||||
|
t.Run("a pinned ref is written verbatim and not resolved", func(t *testing.T) {
|
||||||
|
t.Cleanup(cleanOverride)
|
||||||
|
for _, ref := range []string{"gitea.shcizo.se/shcizo/slusk:1.3@" + d, "gitea.shcizo.se/shcizo/slusk@" + d} {
|
||||||
|
rec := &recorder{}
|
||||||
|
dist := &fakeDist{digest: "sha256:other"}
|
||||||
|
var out strings.Builder
|
||||||
|
pinnedJob := job
|
||||||
|
pinnedJob.Ref = ref
|
||||||
|
got, err := setup(fakeCompose{rw, []container.Summary{ct}}, dist, rec).Execute(context.Background(), pinnedJob, &out)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, ref, got)
|
||||||
|
assert.Empty(t, dist.refs, "no registry lookup")
|
||||||
|
_, doc := readOverride(t, stack)
|
||||||
|
assert.Equal(t, ref, serviceImage(doc, "web"))
|
||||||
|
assert.Contains(t, out.String(), "pinned "+ref)
|
||||||
|
assert.NotContains(t, out.String(), "resolved")
|
||||||
|
}
|
||||||
})
|
})
|
||||||
t.Run("built from source is refused before anything is written", func(t *testing.T) {
|
t.Run("built from source is refused before anything is written", func(t *testing.T) {
|
||||||
t.Cleanup(cleanOverride)
|
t.Cleanup(cleanOverride)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/mount"
|
"github.com/docker/docker/api/types/mount"
|
||||||
@@ -34,15 +35,68 @@ var overrideNames = []string{
|
|||||||
// keeping every other entry in the file, and returns the file's path. It
|
// keeping every other entry in the file, and returns the file's path. It
|
||||||
// refuses when the file exists without the header, or when another
|
// refuses when the file exists without the header, or when another
|
||||||
// auto-loaded override file exists in dir. The write is atomic.
|
// auto-loaded override file exists in dir. The write is atomic.
|
||||||
func WriteOverride(dir, service, ref string) (string, error) {
|
//
|
||||||
|
// composeFile is the stack's first compose file. The new file gets the owner
|
||||||
|
// of the existing override, or of composeFile when there is none or the
|
||||||
|
// existing one is owned by root (see pickOwner). The agent runs as root and the
|
||||||
|
// rename creates a new file, so without this the stack's owner would lose
|
||||||
|
// control of the file. ownerErr reports that the owner could not be read or
|
||||||
|
// set; the override is still written. err means it was not written.
|
||||||
|
func WriteOverride(dir, service, ref, composeFile string) (path string, ownerErr error, err error) {
|
||||||
|
overridePath := filepath.Join(dir, OverrideName)
|
||||||
|
return writeOverride(dir, service, ref, func() (fileOwner, error) {
|
||||||
|
return resolveOwner(overridePath, composeFile)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileOwner is the uid and gid of a file.
|
||||||
|
type fileOwner struct{ uid, gid int }
|
||||||
|
|
||||||
|
// readOwner returns the owner of the file at path.
|
||||||
|
func readOwner(path string) (fileOwner, error) {
|
||||||
|
fi, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return fileOwner{}, fmt.Errorf("read owner: %w", err)
|
||||||
|
}
|
||||||
|
st, ok := fi.Sys().(*syscall.Stat_t)
|
||||||
|
if !ok {
|
||||||
|
return fileOwner{}, fmt.Errorf("read owner of %s: not available on this platform", path)
|
||||||
|
}
|
||||||
|
return fileOwner{int(st.Uid), int(st.Gid)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickOwner chooses the owner of a new override: the existing override's
|
||||||
|
// (nil when there is none), unless that is root, then the compose file's.
|
||||||
|
// Overrides written before the agent set owners are root-owned and would
|
||||||
|
// otherwise stay so. compose is only called when needed.
|
||||||
|
func pickOwner(override *fileOwner, compose func() (fileOwner, error)) (fileOwner, error) {
|
||||||
|
if override != nil && override.uid != 0 {
|
||||||
|
return *override, nil
|
||||||
|
}
|
||||||
|
return compose()
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveOwner applies pickOwner to the files on disk.
|
||||||
|
func resolveOwner(overridePath, composeFile string) (fileOwner, error) {
|
||||||
|
var existing *fileOwner
|
||||||
|
if o, err := readOwner(overridePath); err == nil {
|
||||||
|
existing = &o
|
||||||
|
} else if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return fileOwner{}, err
|
||||||
|
}
|
||||||
|
return pickOwner(existing, func() (fileOwner, error) { return readOwner(composeFile) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeOverride is WriteOverride with the owner lookup injected, for tests.
|
||||||
|
func writeOverride(dir, service, ref string, owner func() (fileOwner, error)) (string, error, error) {
|
||||||
for _, name := range overrideNames {
|
for _, name := range overrideNames {
|
||||||
if name == OverrideName {
|
if name == OverrideName {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, err := os.Lstat(filepath.Join(dir, name)); err == nil {
|
if _, err := os.Lstat(filepath.Join(dir, name)); err == nil {
|
||||||
return "", fmt.Errorf("refused: %s exists; the agent only manages %s and never edits operator files", name, OverrideName)
|
return "", nil, fmt.Errorf("refused: %s exists; the agent only manages %s and never edits operator files", name, OverrideName)
|
||||||
} else if !errors.Is(err, os.ErrNotExist) {
|
} else if !errors.Is(err, os.ErrNotExist) {
|
||||||
return "", fmt.Errorf("check %s: %w", name, err)
|
return "", nil, fmt.Errorf("check %s: %w", name, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
path := filepath.Join(dir, OverrideName)
|
path := filepath.Join(dir, OverrideName)
|
||||||
@@ -51,12 +105,12 @@ func WriteOverride(dir, service, ref string) (string, error) {
|
|||||||
switch {
|
switch {
|
||||||
case errors.Is(err, os.ErrNotExist):
|
case errors.Is(err, os.ErrNotExist):
|
||||||
case err != nil:
|
case err != nil:
|
||||||
return "", fmt.Errorf("read override: %w", err)
|
return "", nil, fmt.Errorf("read override: %w", err)
|
||||||
case !bytes.HasPrefix(raw, []byte(overrideHeader)):
|
case !bytes.HasPrefix(raw, []byte(overrideHeader)):
|
||||||
return "", fmt.Errorf("refused: %s exists and was not written by package-updater", path)
|
return "", nil, fmt.Errorf("refused: %s exists and was not written by package-updater", path)
|
||||||
default:
|
default:
|
||||||
if err := yaml.Unmarshal(raw, &doc); err != nil {
|
if err := yaml.Unmarshal(raw, &doc); err != nil {
|
||||||
return "", fmt.Errorf("parse override: %w", err)
|
return "", nil, fmt.Errorf("parse override: %w", err)
|
||||||
}
|
}
|
||||||
if doc == nil {
|
if doc == nil {
|
||||||
doc = map[string]any{}
|
doc = map[string]any{}
|
||||||
@@ -75,30 +129,38 @@ func WriteOverride(dir, service, ref string) (string, error) {
|
|||||||
doc["services"] = services
|
doc["services"] = services
|
||||||
body, err := yaml.Marshal(doc)
|
body, err := yaml.Marshal(doc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("encode override: %w", err)
|
return "", nil, fmt.Errorf("encode override: %w", err)
|
||||||
}
|
}
|
||||||
// Temp file in the same directory + rename: a crash never leaves a
|
// Temp file in the same directory + rename: a crash never leaves a
|
||||||
// half-written override that compose would then load.
|
// half-written override that compose would then load.
|
||||||
tmp, err := os.CreateTemp(dir, "."+OverrideName+".*")
|
tmp, err := os.CreateTemp(dir, "."+OverrideName+".*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("write override: %w", err)
|
return "", nil, fmt.Errorf("write override: %w", err)
|
||||||
}
|
}
|
||||||
defer os.Remove(tmp.Name()) // no-op after a successful rename
|
defer os.Remove(tmp.Name()) // no-op after a successful rename
|
||||||
if _, err := tmp.WriteString(overrideHeader + string(body)); err != nil {
|
if _, err := tmp.WriteString(overrideHeader + string(body)); err != nil {
|
||||||
tmp.Close()
|
tmp.Close()
|
||||||
return "", fmt.Errorf("write override: %w", err)
|
return "", nil, fmt.Errorf("write override: %w", err)
|
||||||
}
|
}
|
||||||
if err := tmp.Chmod(0o644); err != nil {
|
if err := tmp.Chmod(0o644); err != nil {
|
||||||
tmp.Close()
|
tmp.Close()
|
||||||
return "", fmt.Errorf("write override: %w", err)
|
return "", nil, fmt.Errorf("write override: %w", err)
|
||||||
|
}
|
||||||
|
// Chown the open file, not the path after the rename: a path could have
|
||||||
|
// been swapped for a symlink by someone with write access to the stack.
|
||||||
|
var ownerErr error
|
||||||
|
if o, err := owner(); err != nil {
|
||||||
|
ownerErr = err
|
||||||
|
} else if err := tmp.Chown(o.uid, o.gid); err != nil {
|
||||||
|
ownerErr = fmt.Errorf("set owner %d:%d: %w", o.uid, o.gid, err)
|
||||||
}
|
}
|
||||||
if err := tmp.Close(); err != nil {
|
if err := tmp.Close(); err != nil {
|
||||||
return "", fmt.Errorf("write override: %w", err)
|
return "", nil, fmt.Errorf("write override: %w", err)
|
||||||
}
|
}
|
||||||
if err := os.Rename(tmp.Name(), path); err != nil {
|
if err := os.Rename(tmp.Name(), path); err != nil {
|
||||||
return "", fmt.Errorf("write override: %w", err)
|
return "", nil, fmt.Errorf("write override: %w", err)
|
||||||
}
|
}
|
||||||
return path, nil
|
return path, ownerErr, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SelfInspector is the subset of the Docker SDK MountRoots needs.
|
// SelfInspector is the subset of the Docker SDK MountRoots needs.
|
||||||
|
|||||||
@@ -29,11 +29,96 @@ func serviceImage(doc map[string]any, name string) any {
|
|||||||
return entry["image"]
|
return entry["image"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// stackFile creates dir/compose.yaml and returns its path.
|
||||||
|
func stackFile(t *testing.T, dir string) string {
|
||||||
|
t.Helper()
|
||||||
|
p := filepath.Join(dir, "compose.yaml")
|
||||||
|
require.NoError(t, os.WriteFile(p, []byte("services: {}\n"), 0o644))
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPickOwner(t *testing.T) {
|
||||||
|
compose := fileOwner{1000, 1000}
|
||||||
|
fromCompose := func() (fileOwner, error) { return compose, nil }
|
||||||
|
failing := func() (fileOwner, error) { return fileOwner{}, errors.New("boom") }
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
override *fileOwner
|
||||||
|
compose func() (fileOwner, error)
|
||||||
|
want fileOwner
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"no override uses the compose file", nil, fromCompose, compose, false},
|
||||||
|
{"override owned by a user wins", &fileOwner{1001, 1002}, fromCompose, fileOwner{1001, 1002}, false},
|
||||||
|
{"root-owned override uses the compose file", &fileOwner{0, 0}, fromCompose, compose, false},
|
||||||
|
{"compose file is not read when the override decides", &fileOwner{1001, 1002}, failing, fileOwner{1001, 1002}, false},
|
||||||
|
{"unreadable compose file is an error", nil, failing, fileOwner{}, true},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := pickOwner(tt.override, tt.compose)
|
||||||
|
assert.Equal(t, tt.wantErr, err != nil)
|
||||||
|
assert.Equal(t, tt.want, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveOwner(t *testing.T) {
|
||||||
|
me := fileOwner{os.Getuid(), os.Getgid()}
|
||||||
|
t.Run("no override uses the compose file", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
got, err := resolveOwner(filepath.Join(dir, OverrideName), stackFile(t, dir))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, me, got)
|
||||||
|
})
|
||||||
|
t.Run("existing override is used without the compose file", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, OverrideName), nil, 0o644))
|
||||||
|
got, err := resolveOwner(filepath.Join(dir, OverrideName), filepath.Join(dir, "missing.yaml"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, me, got)
|
||||||
|
})
|
||||||
|
t.Run("neither file is an error", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
_, err := resolveOwner(filepath.Join(dir, OverrideName), filepath.Join(dir, "missing.yaml"))
|
||||||
|
assert.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteOverride_Owner(t *testing.T) {
|
||||||
|
t.Run("success sets no ownerErr", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
_, ownerErr, err := WriteOverride(dir, "web", "a:1@sha256:aa", stackFile(t, dir))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NoError(t, ownerErr)
|
||||||
|
})
|
||||||
|
t.Run("unreadable owner is reported and the file is still written", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
_, ownerErr, err := WriteOverride(dir, "web", "a:1@sha256:aa", filepath.Join(dir, "missing.yaml"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.ErrorContains(t, ownerErr, "read owner")
|
||||||
|
_, doc := readOverride(t, dir)
|
||||||
|
assert.Equal(t, "a:1@sha256:aa", serviceImage(doc, "web"))
|
||||||
|
})
|
||||||
|
t.Run("chown failure is reported and the file is still written", func(t *testing.T) {
|
||||||
|
if os.Getuid() == 0 {
|
||||||
|
t.Skip("root can chown to anyone")
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
_, ownerErr, err := writeOverride(dir, "web", "a:1@sha256:aa", func() (fileOwner, error) { return fileOwner{0, 0}, nil })
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.ErrorContains(t, ownerErr, "set owner 0:0")
|
||||||
|
_, doc := readOverride(t, dir)
|
||||||
|
assert.Equal(t, "a:1@sha256:aa", serviceImage(doc, "web"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestWriteOverride(t *testing.T) {
|
func TestWriteOverride(t *testing.T) {
|
||||||
t.Run("new file", func(t *testing.T) {
|
t.Run("new file", func(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path, err := WriteOverride(dir, "web", "a:1@sha256:aa")
|
path, ownerErr, err := WriteOverride(dir, "web", "a:1@sha256:aa", stackFile(t, dir))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
assert.NoError(t, ownerErr)
|
||||||
assert.Equal(t, filepath.Join(dir, OverrideName), path)
|
assert.Equal(t, filepath.Join(dir, OverrideName), path)
|
||||||
raw, doc := readOverride(t, dir)
|
raw, doc := readOverride(t, dir)
|
||||||
assert.True(t, len(raw) > len(overrideHeader) && raw[:len(overrideHeader)] == overrideHeader)
|
assert.True(t, len(raw) > len(overrideHeader) && raw[:len(overrideHeader)] == overrideHeader)
|
||||||
@@ -42,7 +127,7 @@ func TestWriteOverride(t *testing.T) {
|
|||||||
t.Run("keeps other services", func(t *testing.T) {
|
t.Run("keeps other services", func(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, OverrideName), []byte(overrideHeader+"services:\n db:\n image: db:1\n"), 0o644))
|
require.NoError(t, os.WriteFile(filepath.Join(dir, OverrideName), []byte(overrideHeader+"services:\n db:\n image: db:1\n"), 0o644))
|
||||||
_, err := WriteOverride(dir, "web", "a:1@sha256:aa")
|
_, _, err := WriteOverride(dir, "web", "a:1@sha256:aa", stackFile(t, dir))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
_, doc := readOverride(t, dir)
|
_, doc := readOverride(t, dir)
|
||||||
assert.Equal(t, "db:1", serviceImage(doc, "db"))
|
assert.Equal(t, "db:1", serviceImage(doc, "db"))
|
||||||
@@ -51,7 +136,7 @@ func TestWriteOverride(t *testing.T) {
|
|||||||
t.Run("replaces own entry", func(t *testing.T) {
|
t.Run("replaces own entry", func(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, OverrideName), []byte(overrideHeader+"services:\n web:\n image: old\n"), 0o644))
|
require.NoError(t, os.WriteFile(filepath.Join(dir, OverrideName), []byte(overrideHeader+"services:\n web:\n image: old\n"), 0o644))
|
||||||
_, err := WriteOverride(dir, "web", "a:2@sha256:bb")
|
_, _, err := WriteOverride(dir, "web", "a:2@sha256:bb", stackFile(t, dir))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
_, doc := readOverride(t, dir)
|
_, doc := readOverride(t, dir)
|
||||||
assert.Equal(t, "a:2@sha256:bb", serviceImage(doc, "web"))
|
assert.Equal(t, "a:2@sha256:bb", serviceImage(doc, "web"))
|
||||||
@@ -60,7 +145,7 @@ func TestWriteOverride(t *testing.T) {
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
const body = "services:\n web:\n environment: [A=1]\n"
|
const body = "services:\n web:\n environment: [A=1]\n"
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, OverrideName), []byte(body), 0o644))
|
require.NoError(t, os.WriteFile(filepath.Join(dir, OverrideName), []byte(body), 0o644))
|
||||||
_, err := WriteOverride(dir, "web", "a:1@sha256:aa")
|
_, _, err := WriteOverride(dir, "web", "a:1@sha256:aa", stackFile(t, dir))
|
||||||
assert.ErrorContains(t, err, "refused")
|
assert.ErrorContains(t, err, "refused")
|
||||||
got, _ := os.ReadFile(filepath.Join(dir, OverrideName))
|
got, _ := os.ReadFile(filepath.Join(dir, OverrideName))
|
||||||
assert.Equal(t, body, string(got))
|
assert.Equal(t, body, string(got))
|
||||||
@@ -69,7 +154,7 @@ func TestWriteOverride(t *testing.T) {
|
|||||||
t.Run("refuses next to "+other, func(t *testing.T) {
|
t.Run("refuses next to "+other, func(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, other), []byte("services: {}\n"), 0o644))
|
require.NoError(t, os.WriteFile(filepath.Join(dir, other), []byte("services: {}\n"), 0o644))
|
||||||
_, err := WriteOverride(dir, "web", "a:1@sha256:aa")
|
_, _, err := WriteOverride(dir, "web", "a:1@sha256:aa", stackFile(t, dir))
|
||||||
assert.ErrorContains(t, err, other)
|
assert.ErrorContains(t, err, other)
|
||||||
_, statErr := os.Stat(filepath.Join(dir, OverrideName))
|
_, statErr := os.Stat(filepath.Join(dir, OverrideName))
|
||||||
assert.True(t, errors.Is(statErr, os.ErrNotExist))
|
assert.True(t, errors.Is(statErr, os.ErrNotExist))
|
||||||
@@ -77,12 +162,13 @@ func TestWriteOverride(t *testing.T) {
|
|||||||
}
|
}
|
||||||
t.Run("leaves no temp files", func(t *testing.T) {
|
t.Run("leaves no temp files", func(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
_, err := WriteOverride(dir, "web", "a:1@sha256:aa")
|
_, _, err := WriteOverride(dir, "web", "a:1@sha256:aa", stackFile(t, dir))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
entries, err := os.ReadDir(dir)
|
entries, err := os.ReadDir(dir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, entries, 1)
|
require.Len(t, entries, 2)
|
||||||
assert.Equal(t, OverrideName, entries[0].Name())
|
assert.Equal(t, "compose.override.yaml", entries[0].Name())
|
||||||
|
assert.Equal(t, "compose.yaml", entries[1].Name())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ type DistributionClient interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Resolver turns "repo:tag" into "repo:tag@digest" by asking the local
|
// Resolver turns "repo:tag" into "repo:tag@digest" by asking the local
|
||||||
// daemon, which contacts the registry.
|
// daemon, which contacts the registry. A ref that already has a digest is
|
||||||
|
// passed through.
|
||||||
type Resolver struct {
|
type Resolver struct {
|
||||||
cli DistributionClient
|
cli DistributionClient
|
||||||
auth *RegistryAuth
|
auth *RegistryAuth
|
||||||
@@ -27,13 +28,15 @@ func NewResolver(cli DistributionClient, auth *RegistryAuth) *Resolver {
|
|||||||
return &Resolver{cli: cli, auth: auth}
|
return &Resolver{cli: cli, auth: auth}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve returns ref pinned to the digest its tag currently points at. It
|
// Resolve returns ref pinned to the digest its tag currently points at. A ref
|
||||||
// fails for a ref without a tag or with a digest already.
|
// that already has a digest ("repo:tag@digest" or "repo@digest") is returned
|
||||||
|
// unchanged without asking the daemon. It fails for a ref with neither tag nor
|
||||||
|
// digest.
|
||||||
func (r *Resolver) Resolve(ctx context.Context, ref string) (string, error) {
|
func (r *Resolver) Resolve(ctx context.Context, ref string) (string, error) {
|
||||||
p := imageref.Parse(ref)
|
p := imageref.Parse(ref)
|
||||||
switch {
|
switch {
|
||||||
case p.Digest != "":
|
case p.Digest != "":
|
||||||
return "", fmt.Errorf("image %q already has a digest", ref)
|
return ref, nil
|
||||||
case p.Tag == "":
|
case p.Tag == "":
|
||||||
return "", fmt.Errorf("image %q has no tag to resolve", ref)
|
return "", fmt.Errorf("image %q has no tag to resolve", ref)
|
||||||
}
|
}
|
||||||
@@ -46,3 +49,13 @@ func (r *Resolver) Resolve(ctx context.Context, ref string) (string, error) {
|
|||||||
}
|
}
|
||||||
return ref + "@" + info.Descriptor.Digest.String(), nil
|
return ref + "@" + info.Descriptor.Digest.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveNote is the job-output line that says which ref is deployed:
|
||||||
|
// "resolved" when pinned came from resolving a tag, "pinned" when ref already
|
||||||
|
// carried its digest.
|
||||||
|
func resolveNote(ref, pinned string) string {
|
||||||
|
if pinned == ref {
|
||||||
|
return "pinned " + pinned
|
||||||
|
}
|
||||||
|
return "resolved " + pinned
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,14 +21,21 @@ func TestResolver(t *testing.T) {
|
|||||||
assert.Equal(t, []string{"gitea.shcizo.se/shcizo/slusk:1.4"}, dist.refs)
|
assert.Equal(t, []string{"gitea.shcizo.se/shcizo/slusk:1.4"}, dist.refs)
|
||||||
assert.Equal(t, []string{"enc-gitea"}, dist.auths)
|
assert.Equal(t, []string{"enc-gitea"}, dist.auths)
|
||||||
})
|
})
|
||||||
t.Run("digest or missing tag is refused without asking the daemon", func(t *testing.T) {
|
t.Run("a ref with a digest passes through without asking the daemon", func(t *testing.T) {
|
||||||
for _, ref := range []string{"repo@sha256:x", "repo:1@sha256:x", "repo"} {
|
for _, ref := range []string{"repo@sha256:x", "repo:1@sha256:x", "localhost:5000/repo:1@sha256:x"} {
|
||||||
dist := &fakeDist{digest: d}
|
dist := &fakeDist{digest: d}
|
||||||
_, err := NewResolver(dist, nil).Resolve(context.Background(), ref)
|
got, err := NewResolver(dist, nil).Resolve(context.Background(), ref)
|
||||||
assert.Error(t, err, ref)
|
require.NoError(t, err, ref)
|
||||||
|
assert.Equal(t, ref, got)
|
||||||
assert.Empty(t, dist.refs)
|
assert.Empty(t, dist.refs)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
t.Run("a bare ref is refused without asking the daemon", func(t *testing.T) {
|
||||||
|
dist := &fakeDist{digest: d}
|
||||||
|
_, err := NewResolver(dist, nil).Resolve(context.Background(), "repo")
|
||||||
|
assert.ErrorContains(t, err, "has no tag to resolve")
|
||||||
|
assert.Empty(t, dist.refs)
|
||||||
|
})
|
||||||
t.Run("daemon error is wrapped", func(t *testing.T) {
|
t.Run("daemon error is wrapped", func(t *testing.T) {
|
||||||
cause := errors.New("unauthorized")
|
cause := errors.New("unauthorized")
|
||||||
_, err := NewResolver(&fakeDist{err: cause}, nil).Resolve(context.Background(), "repo:1")
|
_, err := NewResolver(&fakeDist{err: cause}, nil).Resolve(context.Background(), "repo:1")
|
||||||
|
|||||||
@@ -35,11 +35,11 @@ func NewSwarmExecutor(cli SwarmClient, resolve *Resolver, auth *RegistryAuth) *S
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Execute checks that the local daemon is a manager of the job's cluster,
|
// Execute checks that the local daemon is a manager of the job's cluster,
|
||||||
// pins job.Ref to its current digest and updates the service to it. The
|
// pins job.Ref to its current digest (a ref that has one is used as is) and
|
||||||
// update is addressed by the ID and version of the service that was
|
// updates the service to it. The update is addressed by the ID and version of
|
||||||
// inspected, so it changes exactly that object. Success means the daemon
|
// the service that was inspected, so it changes exactly that object. Success
|
||||||
// accepted the new spec, not that the tasks have converged. It returns the
|
// means the daemon accepted the new spec, not that the tasks have converged.
|
||||||
// pinned ref.
|
// It returns the pinned ref.
|
||||||
func (e *SwarmExecutor) Execute(ctx context.Context, job protocol.JobStart, out io.Writer) (string, error) {
|
func (e *SwarmExecutor) Execute(ctx context.Context, job protocol.JobStart, out io.Writer) (string, error) {
|
||||||
info, err := e.cli.Info(ctx)
|
info, err := e.cli.Info(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -71,7 +71,7 @@ func (e *SwarmExecutor) Execute(ctx context.Context, job protocol.JobStart, out
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
fmt.Fprintf(out, "resolved %s\n", pinned)
|
fmt.Fprintln(out, resolveNote(job.Ref, pinned))
|
||||||
|
|
||||||
spec := svc.Spec
|
spec := svc.Spec
|
||||||
spec.TaskTemplate.ContainerSpec.Image = pinned
|
spec.TaskTemplate.ContainerSpec.Image = pinned
|
||||||
|
|||||||
@@ -77,6 +77,22 @@ func swarmSvc(name string) swarm.Service {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSwarmUsesPinnedRef(t *testing.T) {
|
||||||
|
const ref = "gitea/gitea:1.26@sha256:abababababababababababababababababababababababababababababababab"
|
||||||
|
job := protocol.JobStart{Kind: protocol.KindSwarm, SwarmClusterID: "c1", SwarmService: "gitea_gitea", Ref: ref}
|
||||||
|
fake := &fakeSwarm{info: managerOf("c1"), svc: swarmSvc("gitea_gitea")}
|
||||||
|
dist := &fakeDist{digest: "sha256:other"}
|
||||||
|
var out bytes.Buffer
|
||||||
|
|
||||||
|
got, err := NewSwarmExecutor(fake, NewResolver(dist, nil), nil).Execute(context.Background(), job, &out)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, ref, got)
|
||||||
|
assert.Equal(t, ref, fake.updSpec.TaskTemplate.ContainerSpec.Image)
|
||||||
|
assert.Empty(t, dist.refs, "no registry lookup")
|
||||||
|
assert.Contains(t, out.String(), "pinned "+ref)
|
||||||
|
}
|
||||||
|
|
||||||
func TestSwarmExecutor(t *testing.T) {
|
func TestSwarmExecutor(t *testing.T) {
|
||||||
job := protocol.JobStart{Kind: protocol.KindSwarm, SwarmClusterID: "c1", SwarmService: "gitea_gitea", Ref: "gitea/gitea:1.27"}
|
job := protocol.JobStart{Kind: protocol.KindSwarm, SwarmClusterID: "c1", SwarmService: "gitea_gitea", Ref: "gitea/gitea:1.27"}
|
||||||
workerInfo := managerOf("c1")
|
workerInfo := managerOf("c1")
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -23,6 +24,7 @@ import (
|
|||||||
"github.com/shcizo/package-updater/internal/agent/conn"
|
"github.com/shcizo/package-updater/internal/agent/conn"
|
||||||
"github.com/shcizo/package-updater/internal/agent/updater"
|
"github.com/shcizo/package-updater/internal/agent/updater"
|
||||||
"github.com/shcizo/package-updater/internal/agenttoken"
|
"github.com/shcizo/package-updater/internal/agenttoken"
|
||||||
|
"github.com/shcizo/package-updater/internal/imageref"
|
||||||
"github.com/shcizo/package-updater/internal/master/events"
|
"github.com/shcizo/package-updater/internal/master/events"
|
||||||
"github.com/shcizo/package-updater/internal/master/hub"
|
"github.com/shcizo/package-updater/internal/master/hub"
|
||||||
"github.com/shcizo/package-updater/internal/master/model"
|
"github.com/shcizo/package-updater/internal/master/model"
|
||||||
@@ -43,6 +45,76 @@ type memStore struct {
|
|||||||
jobs map[int64]*model.Job
|
jobs map[int64]*model.Job
|
||||||
nextJob int64
|
nextJob int64
|
||||||
finishApplied int // FinishJob calls that returned true
|
finishApplied int // FinishJob calls that returned true
|
||||||
|
versions map[int64]model.ImageVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedVersion adds an image_versions row that GetImageVersion returns.
|
||||||
|
func (m *memStore) seedVersion(v model.ImageVersion) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if m.versions == nil {
|
||||||
|
m.versions = map[int64]model.ImageVersion{}
|
||||||
|
}
|
||||||
|
m.versions[v.ID] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memStore) GetImageVersion(_ context.Context, id int64) (model.ImageVersion, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
v, ok := m.versions[id]
|
||||||
|
if !ok {
|
||||||
|
return model.ImageVersion{}, model.ErrNotFound
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memStore) ListServices(_ context.Context) ([]model.Service, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
var out []model.Service
|
||||||
|
for _, svc := range m.svcRows {
|
||||||
|
if svc.RemovedAt == nil {
|
||||||
|
out = append(out, svc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memStore) SetPollError(_ context.Context, ids []int64, msg string) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
for _, id := range ids {
|
||||||
|
svc := m.svcRows[id]
|
||||||
|
svc.LastPollError = msg
|
||||||
|
m.svcRows[id] = svc
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memStore) SetLastTagDigest(_ context.Context, id int64, digest string) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
svc, ok := m.svcRows[id]
|
||||||
|
if !ok {
|
||||||
|
return model.ErrNotFound
|
||||||
|
}
|
||||||
|
svc.LastTagDigest = digest
|
||||||
|
m.svcRows[id] = svc
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memStore) SetNewer(_ context.Context, id int64, ref string, at time.Time) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
svc, ok := m.svcRows[id]
|
||||||
|
if !ok {
|
||||||
|
return model.ErrNotFound
|
||||||
|
}
|
||||||
|
svc.NewerRef = ref
|
||||||
|
svc.NewerAt = &at
|
||||||
|
m.svcRows[id] = svc
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedService adds a service row that jobs can be triggered for.
|
// seedService adds a service row that jobs can be triggered for.
|
||||||
@@ -65,6 +137,33 @@ func (m *memStore) GetService(_ context.Context, id int64) (model.Service, error
|
|||||||
return svc, nil
|
return svc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ServicesByRepo returns the seeded services whose image repo equals repo,
|
||||||
|
// ordered by ID.
|
||||||
|
func (m *memStore) ServicesByRepo(_ context.Context, repo string) ([]model.Service, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
var out []model.Service
|
||||||
|
for _, svc := range m.svcRows {
|
||||||
|
if svc.ImageRepo == repo && svc.RemovedAt == nil {
|
||||||
|
out = append(out, svc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memStore) SetAvailable(_ context.Context, id int64, a model.Available) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
svc, ok := m.svcRows[id]
|
||||||
|
if !ok {
|
||||||
|
return model.ErrNotFound
|
||||||
|
}
|
||||||
|
svc.AvailableRef, svc.AvailableDigest, svc.AvailableSource, svc.AvailableAt = a.Ref, a.Digest, a.Source, &a.At
|
||||||
|
m.svcRows[id] = svc
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *memStore) CreateJob(_ context.Context, nj model.NewJob) (model.Job, error) {
|
func (m *memStore) CreateJob(_ context.Context, nj model.NewJob) (model.Job, error) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
@@ -146,6 +245,7 @@ func (m *memStore) FinishJob(_ context.Context, agentID, jobID int64, f model.Jo
|
|||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
j.Status, j.Error, j.Output, j.OutputTruncated, j.FinishedAt = f.Status, f.Error, f.Output, f.OutputTruncated, &f.FinishedAt
|
j.Status, j.Error, j.Output, j.OutputTruncated, j.FinishedAt = f.Status, f.Error, f.Output, f.OutputTruncated, &f.FinishedAt
|
||||||
|
requestedRef := j.ToRef
|
||||||
if f.Ref != "" {
|
if f.Ref != "" {
|
||||||
j.ToRef = f.Ref
|
j.ToRef = f.Ref
|
||||||
}
|
}
|
||||||
@@ -155,6 +255,16 @@ func (m *memStore) FinishJob(_ context.Context, agentID, jobID int64, f model.Jo
|
|||||||
if f.Status == model.JobSucceeded {
|
if f.Status == model.JobSucceeded {
|
||||||
svc := m.svcRows[j.ServiceID]
|
svc := m.svcRows[j.ServiceID]
|
||||||
svc.DesiredRef = f.Ref
|
svc.DesiredRef = f.Ref
|
||||||
|
svc.AwaitingInventory = true
|
||||||
|
if _, d, _ := strings.Cut(f.Ref, "@"); !strings.Contains(requestedRef, "@") && imageref.IsDigest(d) {
|
||||||
|
svc.LastTagDigest = d
|
||||||
|
}
|
||||||
|
// Same rule as the SQL: the job was created for the suggested ref after it was reported.
|
||||||
|
requested, digest, _ := strings.Cut(requestedRef, "@")
|
||||||
|
if svc.AvailableAt != nil && svc.AvailableRef == requested && !svc.AvailableAt.After(j.CreatedAt) &&
|
||||||
|
(digest == "" || svc.AvailableDigest == digest) {
|
||||||
|
svc.AvailableRef, svc.AvailableDigest, svc.AvailableSource, svc.AvailableAt = "", "", "", nil
|
||||||
|
}
|
||||||
m.svcRows[j.ServiceID] = svc
|
m.svcRows[j.ServiceID] = svc
|
||||||
}
|
}
|
||||||
m.finishApplied++
|
m.finishApplied++
|
||||||
@@ -248,7 +358,9 @@ type currentHub struct{ p atomic.Pointer[hub.Hub] }
|
|||||||
|
|
||||||
func (c *currentHub) ServeHTTP(w http.ResponseWriter, r *http.Request) { c.p.Load().ServeHTTP(w, r) }
|
func (c *currentHub) ServeHTTP(w http.ResponseWriter, r *http.Request) { c.p.Load().ServeHTTP(w, r) }
|
||||||
func (c *currentHub) Disconnect(id int64) { c.p.Load().Disconnect(id) }
|
func (c *currentHub) Disconnect(id int64) { c.p.Load().Disconnect(id) }
|
||||||
func (c *currentHub) Route(svc model.Service) (model.Route, error) { return c.p.Load().Route(svc) }
|
func (c *currentHub) Route(svc model.Service, minProtocol int) (model.Route, error) {
|
||||||
|
return c.p.Load().Route(svc, minProtocol)
|
||||||
|
}
|
||||||
func (c *currentHub) StartJob(r model.Route, id int64, s protocol.JobStart) error {
|
func (c *currentHub) StartJob(r model.Route, id int64, s protocol.JobStart) error {
|
||||||
return c.p.Load().StartJob(r, id, s)
|
return c.p.Load().StartJob(r, id, s)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package e2e
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/shcizo/package-updater/internal/master/model"
|
||||||
|
"github.com/shcizo/package-updater/internal/master/poller"
|
||||||
|
"github.com/shcizo/package-updater/internal/protocol"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
driftDesired = "sha256:" + "d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1"
|
||||||
|
driftOther = "sha256:" + "0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seedDrifted replaces the environment's service with an auto service that was
|
||||||
|
// updated to slusk:1.4.2 at driftDesired but runs driftOther now.
|
||||||
|
func (e *jobEnv) seedDrifted() {
|
||||||
|
e.store.seedService(model.Service{ID: 1, Kind: protocol.KindCompose, ComposeProject: "slusk", ComposeService: "web",
|
||||||
|
AgentID: ptr(agentID), ImageRepo: slusk, UpdateMode: model.ModeAuto,
|
||||||
|
DesiredRef: slusk + ":1.4.2@" + driftDesired,
|
||||||
|
ActualRef: slusk + ":1.4.2", ActualDigest: driftOther})
|
||||||
|
}
|
||||||
|
|
||||||
|
// inventoryWrite is what the store does for an agent's upsert of the service.
|
||||||
|
func (e *jobEnv) inventoryWrite(id int64, actualRef, actualDigest string) {
|
||||||
|
e.store.mu.Lock()
|
||||||
|
defer e.store.mu.Unlock()
|
||||||
|
svc := e.store.svcRows[id]
|
||||||
|
svc.ActualRef, svc.ActualDigest, svc.AwaitingInventory = actualRef, actualDigest, false
|
||||||
|
e.store.svcRows[id] = svc
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDriftedAutoServiceIsNotUpdatedByPollOrWebhook(t *testing.T) {
|
||||||
|
e := newJobEnv(t)
|
||||||
|
e.seedDrifted()
|
||||||
|
reg := &fixedRegistry{digests: map[string]string{slusk + ":1.4.2": "sha256:new"}}
|
||||||
|
p := poller.New(e.store, e.jobs, reg, e.m.broker, poller.Config{Workers: 1}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
|
|
||||||
|
p.Cycle(context.Background())
|
||||||
|
|
||||||
|
assert.Equal(t, 1, reg.calls)
|
||||||
|
assert.Zero(t, e.store.jobCount(), "polling starts no job for a drifted service")
|
||||||
|
svc, err := e.store.GetService(context.Background(), 1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, slusk+":1.4.2", svc.AvailableRef, "the suggestion is still recorded")
|
||||||
|
|
||||||
|
code, body := callWebhook(t, e.webhookServer(t), `{"image":"`+slusk+`:ignored","tag":"2.0.0"}`)
|
||||||
|
|
||||||
|
assert.Equal(t, 200, code)
|
||||||
|
require.Len(t, body.Results, 1)
|
||||||
|
assert.Equal(t, "refused", body.Results[0].Status)
|
||||||
|
assert.Contains(t, body.Results[0].Error, "drifted")
|
||||||
|
assert.Zero(t, e.store.jobCount(), "the webhook starts no job either")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestoreDeploysThePinnedRefOnTheAgent(t *testing.T) {
|
||||||
|
e := newJobEnv(t)
|
||||||
|
e.seedDrifted()
|
||||||
|
close(e.exec.release)
|
||||||
|
|
||||||
|
job, err := e.jobs.RestoreDesired(context.Background(), 1, "alice")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, model.TriggerManual, job.Trigger)
|
||||||
|
select {
|
||||||
|
case ref := <-e.exec.started:
|
||||||
|
assert.Equal(t, slusk+":1.4.2@"+driftDesired, ref, "the agent receives exactly the desired ref")
|
||||||
|
case <-time.After(waitFor):
|
||||||
|
t.Fatal("the agent never received the job")
|
||||||
|
}
|
||||||
|
done := e.waitStatus(t, job.ID, model.JobSucceeded)
|
||||||
|
assert.Equal(t, slusk+":1.4.2@"+driftDesired, done.ToRef)
|
||||||
|
svc, err := e.store.GetService(context.Background(), 1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, driftOther, svc.LastTagDigest, "the drifted digest is remembered so polling does not redeploy it")
|
||||||
|
assert.False(t, svc.Drifted(), "the inventory has not caught up yet")
|
||||||
|
|
||||||
|
e.inventoryWrite(1, slusk+":1.4.2", driftDesired)
|
||||||
|
svc, err = e.store.GetService(context.Background(), 1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, svc.Drifted())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoDriftAfterASuccessfulJob(t *testing.T) {
|
||||||
|
e := newJobEnv(t)
|
||||||
|
close(e.exec.release)
|
||||||
|
|
||||||
|
job, err := e.jobs.Trigger(context.Background(), 1, "alice")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
e.waitStatus(t, job.ID, model.JobSucceeded)
|
||||||
|
svc, err := e.store.GetService(context.Background(), 1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, slusk+":1.4.2@"+e2eDigest, svc.DesiredRef)
|
||||||
|
assert.Equal(t, slusk+":1.4.2@sha256:old", svc.ActualRef, "the agent's inventory write has not arrived")
|
||||||
|
assert.False(t, svc.Drifted(), "between job.result and the inventory write the row is not drifted")
|
||||||
|
|
||||||
|
e.inventoryWrite(1, slusk+":1.4.2", e2eDigest)
|
||||||
|
svc, _ = e.store.GetService(context.Background(), 1)
|
||||||
|
assert.False(t, svc.Drifted())
|
||||||
|
|
||||||
|
e.inventoryWrite(1, slusk+":1.4.2", driftOther)
|
||||||
|
svc, _ = e.store.GetService(context.Background(), 1)
|
||||||
|
assert.True(t, svc.Drifted(), "a later inventory write of another digest is drift")
|
||||||
|
}
|
||||||
@@ -32,6 +32,9 @@ const (
|
|||||||
lostMessage = "lost: the agent did not know this job when it reconnected"
|
lostMessage = "lost: the agent did not know this job when it reconnected"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// e2eDigest is the digest the fake executor pins a tag to.
|
||||||
|
const e2eDigest = "sha256:" + "e2e0e2e0e2e0e2e0e2e0e2e0e2e0e2e0e2e0e2e0e2e0e2e0e2e0e2e0e2e0e2e0"
|
||||||
|
|
||||||
// gateExecutor writes a line of output, then blocks until release is closed
|
// gateExecutor writes a line of output, then blocks until release is closed
|
||||||
// or the job is cancelled.
|
// or the job is cancelled.
|
||||||
type gateExecutor struct {
|
type gateExecutor struct {
|
||||||
@@ -52,7 +55,10 @@ func (g *gateExecutor) Execute(ctx context.Context, job protocol.JobStart, out i
|
|||||||
return "", ctx.Err()
|
return "", ctx.Err()
|
||||||
}
|
}
|
||||||
_, _ = io.WriteString(out, "recreated\n")
|
_, _ = io.WriteString(out, "recreated\n")
|
||||||
return job.Ref + "@sha256:e2e", nil
|
if strings.Contains(job.Ref, "@") {
|
||||||
|
return job.Ref, nil
|
||||||
|
}
|
||||||
|
return job.Ref + "@" + e2eDigest, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// jobAgent is a connected agent with a real runner and its two lifetimes: the
|
// jobAgent is a connected agent with a real runner and its two lifetimes: the
|
||||||
@@ -102,7 +108,7 @@ func newJobEnv(t *testing.T) *jobEnv {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
store := &memStore{agents: map[string]model.Agent{agenttoken.Hash("pua_ok"): {ID: agentID, Name: "node-2"}}}
|
store := &memStore{agents: map[string]model.Agent{agenttoken.Hash("pua_ok"): {ID: agentID, Name: "node-2"}}}
|
||||||
store.seedService(model.Service{ID: 1, Kind: protocol.KindCompose, ComposeProject: "slusk", ComposeService: "web",
|
store.seedService(model.Service{ID: 1, Kind: protocol.KindCompose, ComposeProject: "slusk", ComposeService: "web",
|
||||||
AgentID: ptr(agentID), ActualRef: slusk + ":1.4.2@sha256:old"})
|
AgentID: ptr(agentID), ImageRepo: slusk, ActualRef: slusk + ":1.4.2@sha256:old"})
|
||||||
m := startMaster(t, store)
|
m := startMaster(t, store)
|
||||||
exec := newGateExecutor()
|
exec := newGateExecutor()
|
||||||
env := &jobEnv{store: store, m: m, exec: exec,
|
env := &jobEnv{store: store, m: m, exec: exec,
|
||||||
@@ -119,7 +125,7 @@ func (e *jobEnv) waitRoutable(t *testing.T) {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
svc, err := e.store.GetService(context.Background(), 1)
|
svc, err := e.store.GetService(context.Background(), 1)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Eventually(t, func() bool { _, err := e.m.hub.Route(svc); return err == nil }, waitFor, pollEvery)
|
require.Eventually(t, func() bool { _, err := e.m.hub.Route(svc, protocol.MinJobVersion); return err == nil }, waitFor, pollEvery)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *jobEnv) job(t *testing.T, id int64) model.Job {
|
func (e *jobEnv) job(t *testing.T, id int64) model.Job {
|
||||||
@@ -159,12 +165,12 @@ func TestManualJobFlowsToAgentAndBack(t *testing.T) {
|
|||||||
j := e.trigger(t)
|
j := e.trigger(t)
|
||||||
done := e.waitStatus(t, j.ID, model.JobSucceeded)
|
done := e.waitStatus(t, j.ID, model.JobSucceeded)
|
||||||
|
|
||||||
assert.Equal(t, slusk+":1.4.2@sha256:e2e", done.ToRef)
|
assert.Equal(t, slusk+":1.4.2@"+e2eDigest, done.ToRef)
|
||||||
assert.Contains(t, done.Output, "pulling "+slusk+":1.4.2")
|
assert.Contains(t, done.Output, "pulling "+slusk+":1.4.2")
|
||||||
assert.Contains(t, done.Output, "recreated")
|
assert.Contains(t, done.Output, "recreated")
|
||||||
svc, err := e.store.GetService(context.Background(), 1)
|
svc, err := e.store.GetService(context.Background(), 1)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, slusk+":1.4.2@sha256:e2e", svc.DesiredRef)
|
assert.Equal(t, slusk+":1.4.2@"+e2eDigest, svc.DesiredRef)
|
||||||
require.Eventually(t, func() bool { return len(e.agent.runner.Known()) == 0 }, waitFor, pollEvery, "the result must be acked")
|
require.Eventually(t, func() bool { return len(e.agent.runner.Known()) == 0 }, waitFor, pollEvery, "the result must be acked")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,7 +246,7 @@ func TestPhaseOneAgentGetsNoJobs(t *testing.T) {
|
|||||||
require.NoError(t, wsjson.Write(ctx, c, snap))
|
require.NoError(t, wsjson.Write(ctx, c, snap))
|
||||||
|
|
||||||
require.Eventually(t, func() bool {
|
require.Eventually(t, func() bool {
|
||||||
_, err := m.hub.Route(svcRow)
|
_, err := m.hub.Route(svcRow, protocol.MinJobVersion)
|
||||||
return errors.Is(err, model.ErrAgentTooOld)
|
return errors.Is(err, model.ErrAgentTooOld)
|
||||||
}, waitFor, pollEvery)
|
}, waitFor, pollEvery)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package e2e
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/shcizo/package-updater/internal/master/jobs"
|
||||||
|
"github.com/shcizo/package-updater/internal/master/model"
|
||||||
|
"github.com/shcizo/package-updater/internal/master/poller"
|
||||||
|
"github.com/shcizo/package-updater/internal/protocol"
|
||||||
|
)
|
||||||
|
|
||||||
|
// jobCount is the number of jobs created so far.
|
||||||
|
func (m *memStore) jobCount() int {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
return len(m.jobs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fixedRegistry answers every lookup from a fixed table.
|
||||||
|
type fixedRegistry struct {
|
||||||
|
digests map[string]string
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fixedRegistry) Digest(_ context.Context, ref string) (string, error) {
|
||||||
|
f.calls++
|
||||||
|
return f.digests[ref], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tags lists no tags, so no newer tag is ever suggested.
|
||||||
|
func (f *fixedRegistry) Tags(context.Context, string) ([]string, error) { return nil, nil }
|
||||||
|
|
||||||
|
func TestPickPinnedVersion(t *testing.T) {
|
||||||
|
env := newJobEnv(t)
|
||||||
|
env.store.seedVersion(model.ImageVersion{ID: 5, Repo: slusk, Tag: "1.3.0", Digest: "sha256:old130", Source: model.SourceRunning})
|
||||||
|
|
||||||
|
job, err := env.jobs.TriggerVersion(context.Background(), 1, 5, "alice")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, slusk+":1.3.0@sha256:old130", job.ToRef)
|
||||||
|
select {
|
||||||
|
case ref := <-env.exec.started:
|
||||||
|
assert.Equal(t, slusk+":1.3.0@sha256:old130", ref, "the agent receives exactly the picked version")
|
||||||
|
case <-time.After(waitFor):
|
||||||
|
t.Fatal("the agent never received the job")
|
||||||
|
}
|
||||||
|
close(env.exec.release)
|
||||||
|
done := env.waitStatus(t, job.ID, model.JobSucceeded)
|
||||||
|
assert.Equal(t, model.TriggerManual, done.Trigger)
|
||||||
|
assert.Equal(t, "alice", done.RequestedBy)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPickVersionOfAnotherRepoIsRefused(t *testing.T) {
|
||||||
|
env := newJobEnv(t)
|
||||||
|
env.store.seedVersion(model.ImageVersion{ID: 6, Repo: "evil/other", Tag: "1", Digest: "sha256:x", Source: model.SourcePoll})
|
||||||
|
|
||||||
|
_, err := env.jobs.TriggerVersion(context.Background(), 1, 6, "alice")
|
||||||
|
|
||||||
|
assert.ErrorIs(t, err, model.ErrNotFound)
|
||||||
|
assert.Zero(t, env.store.jobCount(), "no job was created")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPollStartsAutoJob(t *testing.T) {
|
||||||
|
env := newJobEnv(t)
|
||||||
|
env.store.seedService(model.Service{ID: 1, Kind: protocol.KindCompose, ComposeProject: "slusk", ComposeService: "web",
|
||||||
|
AgentID: ptr(agentID), ImageRepo: slusk, ActualRef: slusk + ":1.4.2@sha256:old", ActualDigest: "sha256:old",
|
||||||
|
UpdateMode: model.ModeAuto})
|
||||||
|
reg := &fixedRegistry{digests: map[string]string{slusk + ":1.4.2": "sha256:new"}}
|
||||||
|
p := poller.New(env.store, env.jobs, reg, env.m.broker, poller.Config{Workers: 1}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
|
|
||||||
|
p.Cycle(context.Background())
|
||||||
|
|
||||||
|
select {
|
||||||
|
case ref := <-env.exec.started:
|
||||||
|
assert.Equal(t, slusk+":1.4.2", ref, "the agent gets repo:tag and resolves the digest itself")
|
||||||
|
case <-time.After(waitFor):
|
||||||
|
t.Fatal("the agent never received the job")
|
||||||
|
}
|
||||||
|
require.Equal(t, 1, env.store.jobCount())
|
||||||
|
job := env.job(t, 1)
|
||||||
|
assert.Equal(t, model.TriggerPoll, job.Trigger)
|
||||||
|
assert.Equal(t, slusk+":1.4.2", job.ToRef)
|
||||||
|
svc, err := env.store.GetService(context.Background(), 1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, slusk+":1.4.2", svc.AvailableRef)
|
||||||
|
assert.Equal(t, "sha256:new", svc.AvailableDigest)
|
||||||
|
assert.Equal(t, model.SourcePoll, svc.AvailableSource)
|
||||||
|
assert.Equal(t, "sha256:new", svc.LastTagDigest)
|
||||||
|
|
||||||
|
p.Cycle(context.Background())
|
||||||
|
|
||||||
|
assert.Equal(t, 2, reg.calls, "the registry is asked again")
|
||||||
|
assert.Equal(t, 1, env.store.jobCount(), "the same digest does not start another job")
|
||||||
|
close(env.exec.release)
|
||||||
|
env.waitStatus(t, job.ID, model.JobSucceeded)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An update started by the webhook, then a rollback by hand: polling must not
|
||||||
|
// redo the update while the registry still shows the digest that was deployed.
|
||||||
|
func TestPollKeepsARollbackAfterAWebhookUpdate(t *testing.T) {
|
||||||
|
env := newJobEnv(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
close(env.exec.release)
|
||||||
|
env.store.seedService(model.Service{ID: 1, Kind: protocol.KindCompose, ComposeProject: "slusk", ComposeService: "web",
|
||||||
|
AgentID: ptr(agentID), ImageRepo: slusk, ActualRef: slusk + ":main@sha256:old", ActualDigest: "sha256:old",
|
||||||
|
UpdateMode: model.ModeAuto})
|
||||||
|
env.store.seedVersion(model.ImageVersion{ID: 5, Repo: slusk, Tag: "main", Digest: "sha256:old", Source: model.SourceRunning})
|
||||||
|
svc, err := env.store.GetService(ctx, 1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
hook, err := env.jobs.Start(ctx, svc, jobs.StartRequest{Trigger: model.TriggerWebhook, Ref: slusk + ":main"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
env.waitStatus(t, hook.ID, model.JobSucceeded)
|
||||||
|
svc, err = env.store.GetService(ctx, 1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, e2eDigest, svc.LastTagDigest, "the deployed digest of the tag is remembered")
|
||||||
|
|
||||||
|
pick, err := env.jobs.TriggerVersion(ctx, 1, 5, "alice")
|
||||||
|
require.NoError(t, err)
|
||||||
|
env.waitStatus(t, pick.ID, model.JobSucceeded)
|
||||||
|
jobsBefore := env.store.jobCount()
|
||||||
|
|
||||||
|
reg := &fixedRegistry{digests: map[string]string{slusk + ":main": e2eDigest}}
|
||||||
|
p := poller.New(env.store, env.jobs, reg, env.m.broker, poller.Config{Workers: 1}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
|
p.Cycle(ctx)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, reg.calls)
|
||||||
|
assert.Equal(t, jobsBefore, env.store.jobCount(), "the rollback stays")
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package e2e
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/shcizo/package-updater/internal/master/model"
|
||||||
|
"github.com/shcizo/package-updater/internal/master/webhook"
|
||||||
|
"github.com/shcizo/package-updater/internal/protocol"
|
||||||
|
)
|
||||||
|
|
||||||
|
// webhookServer mounts the webhook handler on a test server, wired to the
|
||||||
|
// job environment's store, jobs service and broker.
|
||||||
|
func (e *jobEnv) webhookServer(t *testing.T) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
h := webhook.New(e.store, e.jobs, e.m.broker, webhook.Config{
|
||||||
|
Token: "ci-key", Timeout: 5 * time.Second, PublicURL: "https://updater.example.com",
|
||||||
|
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
|
srv := httptest.NewServer(h)
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *jobEnv) setAuto(id int64) {
|
||||||
|
e.store.mu.Lock()
|
||||||
|
defer e.store.mu.Unlock()
|
||||||
|
svc := e.store.svcRows[id]
|
||||||
|
svc.UpdateMode = model.ModeAuto
|
||||||
|
e.store.svcRows[id] = svc
|
||||||
|
}
|
||||||
|
|
||||||
|
type webhookResponse struct {
|
||||||
|
Matched int `json:"matched"`
|
||||||
|
Results []struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
JobID int64 `json:"job_id"`
|
||||||
|
ToRef string `json:"to_ref"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
} `json:"results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func callWebhook(t *testing.T, srv *httptest.Server, body string) (int, webhookResponse) {
|
||||||
|
t.Helper()
|
||||||
|
req, err := http.NewRequest(http.MethodPost, srv.URL, strings.NewReader(body))
|
||||||
|
require.NoError(t, err)
|
||||||
|
req.Header.Set("Authorization", "Bearer ci-key")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var out webhookResponse
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&out))
|
||||||
|
return resp.StatusCode, out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebhookWaitsForAutoJob(t *testing.T) {
|
||||||
|
e := newJobEnv(t)
|
||||||
|
e.setAuto(1)
|
||||||
|
srv := e.webhookServer(t)
|
||||||
|
|
||||||
|
type reply struct {
|
||||||
|
code int
|
||||||
|
body webhookResponse
|
||||||
|
}
|
||||||
|
done := make(chan reply, 1)
|
||||||
|
go func() {
|
||||||
|
code, body := callWebhook(t, srv, `{"image":"`+slusk+`:ignored","tag":"2.0.0"}`)
|
||||||
|
done <- reply{code, body}
|
||||||
|
}()
|
||||||
|
|
||||||
|
e.waitStarted(t)
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
t.Fatal("the webhook answered before the job finished")
|
||||||
|
case <-time.After(100 * time.Millisecond):
|
||||||
|
}
|
||||||
|
close(e.exec.release)
|
||||||
|
|
||||||
|
var r reply
|
||||||
|
select {
|
||||||
|
case r = <-done:
|
||||||
|
case <-time.After(waitFor):
|
||||||
|
t.Fatal("the webhook never answered")
|
||||||
|
}
|
||||||
|
require.Equal(t, http.StatusOK, r.code)
|
||||||
|
require.Len(t, r.body.Results, 1)
|
||||||
|
assert.Equal(t, "succeeded", r.body.Results[0].Status)
|
||||||
|
assert.Equal(t, slusk+":2.0.0", r.body.Results[0].ToRef)
|
||||||
|
|
||||||
|
job := e.job(t, r.body.Results[0].JobID)
|
||||||
|
assert.Equal(t, model.TriggerWebhook, job.Trigger)
|
||||||
|
assert.Equal(t, model.JobSucceeded, job.Status)
|
||||||
|
assert.Equal(t, slusk+":2.0.0@"+e2eDigest, job.ToRef)
|
||||||
|
svc, err := e.store.GetService(context.Background(), 1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, svc.AvailableRef, "the job deployed the suggestion, so it is cleared")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebhookFailedAutoJobIs500(t *testing.T) {
|
||||||
|
e := newJobEnv(t)
|
||||||
|
e.setAuto(1)
|
||||||
|
srv := e.webhookServer(t)
|
||||||
|
e.agent.stopConn() // no agent connection: the job fails at once with "agent offline"
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
svc, _ := e.store.GetService(context.Background(), 1)
|
||||||
|
_, err := e.m.hub.Route(svc, protocol.MinJobVersion)
|
||||||
|
return err != nil
|
||||||
|
}, waitFor, pollEvery)
|
||||||
|
|
||||||
|
code, body := callWebhook(t, srv, `{"image":"`+slusk+`","tag":"2.0.0"}`)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusInternalServerError, code)
|
||||||
|
require.Len(t, body.Results, 1)
|
||||||
|
assert.Equal(t, "failed", body.Results[0].Status)
|
||||||
|
assert.Equal(t, "agent offline", body.Results[0].Error)
|
||||||
|
assert.Equal(t, model.JobFailed, e.job(t, body.Results[0].JobID).Status)
|
||||||
|
svc, err := e.store.GetService(context.Background(), 1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, slusk+":2.0.0", svc.AvailableRef, "a failed job keeps the suggestion")
|
||||||
|
}
|
||||||
@@ -2,7 +2,10 @@
|
|||||||
// and digest.
|
// and digest.
|
||||||
package imageref
|
package imageref
|
||||||
|
|
||||||
import "strings"
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
// Ref is an image reference split into its parts. Tag and Digest are empty
|
// Ref is an image reference split into its parts. Tag and Digest are empty
|
||||||
// when the reference has none.
|
// when the reference has none.
|
||||||
@@ -30,6 +33,12 @@ func Parse(ref string) Ref {
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsRegistryHost reports whether the first path segment of a repository names
|
||||||
|
// a registry: it contains a "." or ":" or is "localhost".
|
||||||
|
func IsRegistryHost(segment string) bool {
|
||||||
|
return strings.ContainsAny(segment, ".:") || segment == "localhost"
|
||||||
|
}
|
||||||
|
|
||||||
// String reassembles the reference as repo[:tag][@digest].
|
// String reassembles the reference as repo[:tag][@digest].
|
||||||
func (r Ref) String() string {
|
func (r Ref) String() string {
|
||||||
s := r.Repo
|
s := r.Repo
|
||||||
@@ -41,3 +50,8 @@ func (r Ref) String() string {
|
|||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var digestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
|
||||||
|
|
||||||
|
// IsDigest reports whether d is a well-formed sha256 digest.
|
||||||
|
func IsDigest(d string) bool { return digestPattern.MatchString(d) }
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package imageref
|
package imageref
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -27,3 +28,39 @@ func TestParse(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIsRegistryHost(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"lscr.io", true},
|
||||||
|
{"localhost:5000", true},
|
||||||
|
{"localhost", true},
|
||||||
|
{"registry:5000", true},
|
||||||
|
{"gitea", false},
|
||||||
|
{"library", false},
|
||||||
|
{"", false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.in, func(t *testing.T) {
|
||||||
|
assert.Equal(t, c.want, IsRegistryHost(c.in))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsDigest(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"sha256:" + strings.Repeat("a", 64), true},
|
||||||
|
{"sha256:" + strings.Repeat("a", 63), false},
|
||||||
|
{"sha256:" + strings.Repeat("A", 64), false},
|
||||||
|
{"sha512:" + strings.Repeat("a", 64), false},
|
||||||
|
{"", false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
assert.Equal(t, c.want, IsDigest(c.in), c.in)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config holds the master's runtime configuration.
|
// Config holds the master's runtime configuration.
|
||||||
@@ -20,8 +21,24 @@ type Config struct {
|
|||||||
Port string
|
Port string
|
||||||
AgentPort string
|
AgentPort string
|
||||||
LogLevel string
|
LogLevel string
|
||||||
|
|
||||||
|
// UpdaterAPIKey is the bearer token for POST /update (UPDATER_API_KEY).
|
||||||
|
// Empty disables the webhook.
|
||||||
|
UpdaterAPIKey string
|
||||||
|
// UpdateTimeout bounds how long one webhook request waits for its auto
|
||||||
|
// jobs (UPDATE_TIMEOUT, default 5m).
|
||||||
|
UpdateTimeout time.Duration
|
||||||
|
// PollInterval is the time between registry polling cycles
|
||||||
|
// (POLL_INTERVAL, default 6h).
|
||||||
|
PollInterval time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// defaultUpdateTimeout is used when UPDATE_TIMEOUT is unset.
|
||||||
|
const defaultUpdateTimeout = 5 * time.Minute
|
||||||
|
|
||||||
|
// defaultPollInterval is used when POLL_INTERVAL is unset.
|
||||||
|
const defaultPollInterval = 6 * time.Hour
|
||||||
|
|
||||||
// Load reads and validates the master configuration, failing fast on
|
// Load reads and validates the master configuration, failing fast on
|
||||||
// anything missing so a half-configured master never starts.
|
// anything missing so a half-configured master never starts.
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
@@ -37,6 +54,9 @@ func Load() (*Config, error) {
|
|||||||
Port: getenvDefault("PORT", "8080"),
|
Port: getenvDefault("PORT", "8080"),
|
||||||
AgentPort: getenvDefault("AGENT_PORT", "8090"),
|
AgentPort: getenvDefault("AGENT_PORT", "8090"),
|
||||||
LogLevel: getenvDefault("LOG_LEVEL", "info"),
|
LogLevel: getenvDefault("LOG_LEVEL", "info"),
|
||||||
|
UpdaterAPIKey: os.Getenv("UPDATER_API_KEY"),
|
||||||
|
UpdateTimeout: defaultUpdateTimeout,
|
||||||
|
PollInterval: defaultPollInterval,
|
||||||
}
|
}
|
||||||
for _, r := range []struct{ name, value string }{
|
for _, r := range []struct{ name, value string }{
|
||||||
{"DATABASE_URL", cfg.DatabaseURL},
|
{"DATABASE_URL", cfg.DatabaseURL},
|
||||||
@@ -57,6 +77,20 @@ func Load() (*Config, error) {
|
|||||||
if !strings.HasPrefix(cfg.AgentURL, "wss://") {
|
if !strings.HasPrefix(cfg.AgentURL, "wss://") {
|
||||||
return nil, fmt.Errorf("AGENT_URL must start with wss://")
|
return nil, fmt.Errorf("AGENT_URL must start with wss://")
|
||||||
}
|
}
|
||||||
|
if v := os.Getenv("UPDATE_TIMEOUT"); v != "" {
|
||||||
|
d, err := time.ParseDuration(v)
|
||||||
|
if err != nil || d <= 0 {
|
||||||
|
return nil, fmt.Errorf("UPDATE_TIMEOUT must be a positive duration such as 5m, got %q", v)
|
||||||
|
}
|
||||||
|
cfg.UpdateTimeout = d
|
||||||
|
}
|
||||||
|
if v := os.Getenv("POLL_INTERVAL"); v != "" {
|
||||||
|
d, err := time.ParseDuration(v)
|
||||||
|
if err != nil || d <= 0 {
|
||||||
|
return nil, fmt.Errorf("POLL_INTERVAL must be a positive duration such as 6h, got %q", v)
|
||||||
|
}
|
||||||
|
cfg.PollInterval = d
|
||||||
|
}
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -21,7 +22,7 @@ func setAll(t *testing.T) {
|
|||||||
for k, v := range required {
|
for k, v := range required {
|
||||||
t.Setenv(k, v)
|
t.Setenv(k, v)
|
||||||
}
|
}
|
||||||
for _, k := range []string{"PORT", "AGENT_PORT", "AGENT_IMAGE", "LOG_LEVEL"} {
|
for _, k := range []string{"PORT", "AGENT_PORT", "AGENT_IMAGE", "LOG_LEVEL", "UPDATER_API_KEY", "UPDATE_TIMEOUT", "POLL_INTERVAL"} {
|
||||||
t.Setenv(k, "")
|
t.Setenv(k, "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -35,6 +36,49 @@ func TestLoad_Defaults(t *testing.T) {
|
|||||||
assert.Equal(t, "gitea.shcizo.se/shcizo/package-updater:latest", cfg.AgentImage)
|
assert.Equal(t, "gitea.shcizo.se/shcizo/package-updater:latest", cfg.AgentImage)
|
||||||
assert.Equal(t, "info", cfg.LogLevel)
|
assert.Equal(t, "info", cfg.LogLevel)
|
||||||
assert.Equal(t, "updater-admins", cfg.AllowedGroup)
|
assert.Equal(t, "updater-admins", cfg.AllowedGroup)
|
||||||
|
assert.Empty(t, cfg.UpdaterAPIKey)
|
||||||
|
assert.Equal(t, 5*time.Minute, cfg.UpdateTimeout)
|
||||||
|
assert.Equal(t, 6*time.Hour, cfg.PollInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_PollInterval(t *testing.T) {
|
||||||
|
setAll(t)
|
||||||
|
t.Setenv("POLL_INTERVAL", "30m")
|
||||||
|
cfg, err := Load()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 30*time.Minute, cfg.PollInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_InvalidPollInterval(t *testing.T) {
|
||||||
|
for _, v := range []string{"abc", "0s", "-1h", "6"} {
|
||||||
|
t.Run(v, func(t *testing.T) {
|
||||||
|
setAll(t)
|
||||||
|
t.Setenv("POLL_INTERVAL", v)
|
||||||
|
_, err := Load()
|
||||||
|
assert.ErrorContains(t, err, "POLL_INTERVAL must be a positive duration such as 6h, got")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_WebhookSettings(t *testing.T) {
|
||||||
|
setAll(t)
|
||||||
|
t.Setenv("UPDATER_API_KEY", "s3cret")
|
||||||
|
t.Setenv("UPDATE_TIMEOUT", "90s")
|
||||||
|
cfg, err := Load()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "s3cret", cfg.UpdaterAPIKey)
|
||||||
|
assert.Equal(t, 90*time.Second, cfg.UpdateTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_InvalidUpdateTimeout(t *testing.T) {
|
||||||
|
for _, v := range []string{"abc", "0s", "-5m", "30"} {
|
||||||
|
t.Run(v, func(t *testing.T) {
|
||||||
|
setAll(t)
|
||||||
|
t.Setenv("UPDATE_TIMEOUT", v)
|
||||||
|
_, err := Load()
|
||||||
|
assert.ErrorContains(t, err, "UPDATE_TIMEOUT must be a positive duration")
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoad_EachRequiredVariable(t *testing.T) {
|
func TestLoad_EachRequiredVariable(t *testing.T) {
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ func waitRoute(t *testing.T, h *Hub) model.Route {
|
|||||||
var r model.Route
|
var r model.Route
|
||||||
require.Eventually(t, func() bool {
|
require.Eventually(t, func() bool {
|
||||||
var err error
|
var err error
|
||||||
r, err = h.Route(svc)
|
r, err = h.Route(svc, protocol.MinJobVersion)
|
||||||
return err == nil
|
return err == nil
|
||||||
}, 2*time.Second, 5*time.Millisecond)
|
}, 2*time.Second, 5*time.Millisecond)
|
||||||
return r
|
return r
|
||||||
@@ -61,7 +61,7 @@ func TestHub_ReconcilesBeforeRoutable(t *testing.T) {
|
|||||||
send(t, c, protocol.TypeHello, protocol.Hello{Hostname: "node-2", ProtocolVersion: 2, KnownJobs: []string{"7", "9", "x"}})
|
send(t, c, protocol.TypeHello, protocol.Hello{Hostname: "node-2", ProtocolVersion: 2, KnownJobs: []string{"7", "9", "x"}})
|
||||||
svc := model.Service{Kind: protocol.KindCompose, AgentID: ptr(node2.ID)}
|
svc := model.Service{Kind: protocol.KindCompose, AgentID: ptr(node2.ID)}
|
||||||
time.Sleep(50 * time.Millisecond) // hello is now blocked inside FailLostJobs
|
time.Sleep(50 * time.Millisecond) // hello is now blocked inside FailLostJobs
|
||||||
_, err = h.Route(svc)
|
_, err = h.Route(svc, protocol.MinJobVersion)
|
||||||
assert.ErrorIs(t, err, model.ErrAgentOffline)
|
assert.ErrorIs(t, err, model.ErrAgentOffline)
|
||||||
|
|
||||||
close(store.lostGate)
|
close(store.lostGate)
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ type candidate struct {
|
|||||||
hello protocol.Hello
|
hello protocol.Hello
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route picks the connection a job for svc goes to. It returns
|
// Route picks the connection a job for svc goes to, among agents that speak
|
||||||
// model.ErrAgentOffline or model.ErrAgentTooOld when there is none.
|
// at least protocol version minProtocol. It returns model.ErrAgentOffline or
|
||||||
func (h *Hub) Route(svc model.Service) (model.Route, error) {
|
// model.ErrAgentTooOld (model.ErrAgentTooOldForPinned when minProtocol asks
|
||||||
|
// for a pinned ref) when there is none.
|
||||||
|
func (h *Hub) Route(svc model.Service, minProtocol int) (model.Route, error) {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
conns := make([]candidate, 0, len(h.conns))
|
conns := make([]candidate, 0, len(h.conns))
|
||||||
for id, c := range h.conns {
|
for id, c := range h.conns {
|
||||||
@@ -25,7 +27,7 @@ func (h *Hub) Route(svc model.Service) (model.Route, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
return route(svc, conns)
|
return route(svc, conns, minProtocol)
|
||||||
}
|
}
|
||||||
|
|
||||||
// route picks the connection a job for svc goes to. Compose services and
|
// route picks the connection a job for svc goes to. Compose services and
|
||||||
@@ -33,7 +35,7 @@ func (h *Hub) Route(svc model.Service) (model.Route, error) {
|
|||||||
// run on the reporting agent if it qualifies, else on any other connected
|
// run on the reporting agent if it qualifies, else on any other connected
|
||||||
// manager of the same cluster (lowest agent ID first). Too-old wins over
|
// manager of the same cluster (lowest agent ID first). Too-old wins over
|
||||||
// offline only when a connection would otherwise have qualified.
|
// offline only when a connection would otherwise have qualified.
|
||||||
func route(svc model.Service, conns []candidate) (model.Route, error) {
|
func route(svc model.Service, conns []candidate, minProtocol int) (model.Route, error) {
|
||||||
sort.Slice(conns, func(a, b int) bool { return conns[a].agentID < conns[b].agentID })
|
sort.Slice(conns, func(a, b int) bool { return conns[a].agentID < conns[b].agentID })
|
||||||
tooOld := false
|
tooOld := false
|
||||||
try := func(c candidate) bool {
|
try := func(c candidate) bool {
|
||||||
@@ -42,7 +44,7 @@ func route(svc model.Service, conns []candidate) (model.Route, error) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if c.hello.ProtocolVersion < protocol.MinJobVersion {
|
if c.hello.ProtocolVersion < minProtocol {
|
||||||
tooOld = true
|
tooOld = true
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -63,6 +65,9 @@ func route(svc model.Service, conns []candidate) (model.Route, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if tooOld {
|
if tooOld {
|
||||||
|
if minProtocol >= protocol.MinPinnedRefVersion {
|
||||||
|
return model.Route{}, model.ErrAgentTooOldForPinned
|
||||||
|
}
|
||||||
return model.Route{}, model.ErrAgentTooOld
|
return model.Route{}, model.ErrAgentTooOld
|
||||||
}
|
}
|
||||||
return model.Route{}, model.ErrAgentOffline
|
return model.Route{}, model.ErrAgentOffline
|
||||||
|
|||||||
@@ -12,37 +12,46 @@ import (
|
|||||||
func TestRoute(t *testing.T) {
|
func TestRoute(t *testing.T) {
|
||||||
composeSvc := model.Service{Kind: protocol.KindCompose, AgentID: ptr(int64(2))}
|
composeSvc := model.Service{Kind: protocol.KindCompose, AgentID: ptr(int64(2))}
|
||||||
swarmSvc := model.Service{Kind: protocol.KindSwarm, AgentID: ptr(int64(1)), SwarmClusterID: "c1"}
|
swarmSvc := model.Service{Kind: protocol.KindSwarm, AgentID: ptr(int64(1)), SwarmClusterID: "c1"}
|
||||||
v2 := protocol.Hello{ProtocolVersion: protocol.Version}
|
v2 := protocol.Hello{ProtocolVersion: 2}
|
||||||
mgrV2 := protocol.Hello{ProtocolVersion: protocol.Version, SwarmManager: true, SwarmClusterID: "c1"}
|
v3 := protocol.Hello{ProtocolVersion: 3}
|
||||||
|
mgrV2 := protocol.Hello{ProtocolVersion: 2, SwarmManager: true, SwarmClusterID: "c1"}
|
||||||
|
mgrV3 := protocol.Hello{ProtocolVersion: 3, SwarmManager: true, SwarmClusterID: "c1"}
|
||||||
|
jobs, pinned := protocol.MinJobVersion, protocol.MinPinnedRefVersion
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
svc model.Service
|
svc model.Service
|
||||||
conns []candidate
|
conns []candidate
|
||||||
|
min int
|
||||||
want model.Route
|
want model.Route
|
||||||
err error
|
err error
|
||||||
}{
|
}{
|
||||||
{"compose on own agent", composeSvc, []candidate{{2, 20, v2}}, model.Route{AgentID: 2, ConnID: 20}, nil},
|
{"compose on own agent", composeSvc, []candidate{{2, 20, v2}}, jobs, model.Route{AgentID: 2, ConnID: 20}, nil},
|
||||||
{"compose, own agent too old", composeSvc, []candidate{{2, 20, protocol.Hello{}}}, model.Route{}, model.ErrAgentTooOld},
|
{"compose, own agent too old", composeSvc, []candidate{{2, 20, protocol.Hello{}}}, jobs, model.Route{}, model.ErrAgentTooOld},
|
||||||
{"compose, own agent missing", composeSvc, nil, model.Route{}, model.ErrAgentOffline},
|
{"compose, own agent missing", composeSvc, nil, jobs, model.Route{}, model.ErrAgentOffline},
|
||||||
{"compose, only another agent online", composeSvc, []candidate{{5, 50, v2}}, model.Route{}, model.ErrAgentOffline},
|
{"compose, only another agent online", composeSvc, []candidate{{5, 50, v2}}, jobs, model.Route{}, model.ErrAgentOffline},
|
||||||
{"swarm on reporting agent", swarmSvc, []candidate{{1, 10, mgrV2}, {3, 30, mgrV2}}, model.Route{AgentID: 1, ConnID: 10}, nil},
|
{"compose, v2 agent and a pinned ref", composeSvc, []candidate{{2, 20, v2}}, pinned, model.Route{}, model.ErrAgentTooOldForPinned},
|
||||||
{"swarm, reporting agent offline, other manager", swarmSvc, []candidate{{3, 30, mgrV2}}, model.Route{AgentID: 3, ConnID: 30}, nil},
|
{"compose, v3 agent and a pinned ref", composeSvc, []candidate{{2, 20, v3}}, pinned, model.Route{AgentID: 2, ConnID: 20}, nil},
|
||||||
{"swarm, lowest agent id first", swarmSvc, []candidate{{4, 40, mgrV2}, {3, 30, mgrV2}}, model.Route{AgentID: 3, ConnID: 30}, nil},
|
{"swarm on reporting agent", swarmSvc, []candidate{{1, 10, mgrV2}, {3, 30, mgrV2}}, jobs, model.Route{AgentID: 1, ConnID: 10}, nil},
|
||||||
|
{"swarm, reporting agent offline, other manager", swarmSvc, []candidate{{3, 30, mgrV2}}, jobs, model.Route{AgentID: 3, ConnID: 30}, nil},
|
||||||
|
{"swarm, lowest agent id first", swarmSvc, []candidate{{4, 40, mgrV2}, {3, 30, mgrV2}}, jobs, model.Route{AgentID: 3, ConnID: 30}, nil},
|
||||||
{"swarm, other manager in another cluster", swarmSvc,
|
{"swarm, other manager in another cluster", swarmSvc,
|
||||||
[]candidate{{3, 30, protocol.Hello{ProtocolVersion: 2, SwarmManager: true, SwarmClusterID: "c2"}}}, model.Route{}, model.ErrAgentOffline},
|
[]candidate{{3, 30, protocol.Hello{ProtocolVersion: 2, SwarmManager: true, SwarmClusterID: "c2"}}}, jobs, model.Route{}, model.ErrAgentOffline},
|
||||||
{"swarm, only a v0 manager of the cluster", swarmSvc,
|
{"swarm, only a v0 manager of the cluster", swarmSvc,
|
||||||
[]candidate{{1, 10, protocol.Hello{SwarmManager: true, SwarmClusterID: "c1"}}}, model.Route{}, model.ErrAgentTooOld},
|
[]candidate{{1, 10, protocol.Hello{SwarmManager: true, SwarmClusterID: "c1"}}}, jobs, model.Route{}, model.ErrAgentTooOld},
|
||||||
{"swarm, v0 manager but a v2 manager exists", swarmSvc,
|
{"swarm, v0 manager but a v2 manager exists", swarmSvc,
|
||||||
[]candidate{{1, 10, protocol.Hello{SwarmManager: true, SwarmClusterID: "c1"}}, {3, 30, mgrV2}}, model.Route{AgentID: 3, ConnID: 30}, nil},
|
[]candidate{{1, 10, protocol.Hello{SwarmManager: true, SwarmClusterID: "c1"}}, {3, 30, mgrV2}}, jobs, model.Route{AgentID: 3, ConnID: 30}, nil},
|
||||||
{"swarm, worker of the cluster", swarmSvc,
|
{"swarm, worker of the cluster", swarmSvc,
|
||||||
[]candidate{{1, 10, protocol.Hello{ProtocolVersion: 2, SwarmClusterID: "c1"}}}, model.Route{}, model.ErrAgentOffline},
|
[]candidate{{1, 10, protocol.Hello{ProtocolVersion: 2, SwarmClusterID: "c1"}}}, jobs, model.Route{}, model.ErrAgentOffline},
|
||||||
{"swarm, manager with empty cluster id", model.Service{Kind: protocol.KindSwarm},
|
{"swarm, manager with empty cluster id", model.Service{Kind: protocol.KindSwarm},
|
||||||
[]candidate{{1, 10, protocol.Hello{ProtocolVersion: 2, SwarmManager: true}}}, model.Route{}, model.ErrAgentOffline},
|
[]candidate{{1, 10, protocol.Hello{ProtocolVersion: 2, SwarmManager: true}}}, jobs, model.Route{}, model.ErrAgentOffline},
|
||||||
|
{"swarm, pinned ref falls back to a v3 manager when the reporting one is v2", swarmSvc,
|
||||||
|
[]candidate{{1, 10, mgrV2}, {3, 30, mgrV3}}, pinned, model.Route{AgentID: 3, ConnID: 30}, nil},
|
||||||
|
{"swarm, pinned ref and only v2 managers", swarmSvc, []candidate{{1, 10, mgrV2}}, pinned, model.Route{}, model.ErrAgentTooOldForPinned},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
got, err := route(tt.svc, tt.conns)
|
got, err := route(tt.svc, tt.conns, tt.min)
|
||||||
assert.ErrorIs(t, err, tt.err)
|
assert.ErrorIs(t, err, tt.err)
|
||||||
assert.Equal(t, tt.want, got)
|
assert.Equal(t, tt.want, got)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
package jobs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/shcizo/package-updater/internal/master/model"
|
||||||
|
"github.com/shcizo/package-updater/internal/protocol"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
driftRepo = "gitea.shcizo.se/shcizo/slusk"
|
||||||
|
driftD1 = "sha256:1111111111111111111111111111111111111111111111111111111111111111"
|
||||||
|
driftD2 = "sha256:2222222222222222222222222222222222222222222222222222222222222222"
|
||||||
|
driftD3 = "sha256:3333333333333333333333333333333333333333333333333333333333333333"
|
||||||
|
)
|
||||||
|
|
||||||
|
// driftedSvc was updated to 1.4.2 at D1 but now runs 1.4.2 at D2.
|
||||||
|
func driftedSvc() model.Service {
|
||||||
|
svc := composeSvc
|
||||||
|
svc.ImageRepo = driftRepo
|
||||||
|
svc.DesiredRef = driftRepo + ":1.4.2@" + driftD1
|
||||||
|
svc.ActualRef = driftRepo + ":1.4.2"
|
||||||
|
svc.ActualDigest = driftD2
|
||||||
|
return svc
|
||||||
|
}
|
||||||
|
|
||||||
|
func notUpdatableReason(t *testing.T, err error) string {
|
||||||
|
t.Helper()
|
||||||
|
var nu *model.NotUpdatableError
|
||||||
|
require.True(t, errors.As(err, &nu), "got %v", err)
|
||||||
|
return nu.Reason
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestorable(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
mutate func(*model.Service)
|
||||||
|
reason string // "" means restorable
|
||||||
|
}{
|
||||||
|
{"drifted", func(*model.Service) {}, ""},
|
||||||
|
{"drifted service that follows no tag", func(s *model.Service) {
|
||||||
|
s.ActualRef = driftRepo + "@" + driftD2
|
||||||
|
}, ""},
|
||||||
|
{"not drifted", func(s *model.Service) { s.ActualDigest = driftD1 }, model.ReasonNotDrifted},
|
||||||
|
{"never updated", func(s *model.Service) { s.DesiredRef = "" }, model.ReasonNotDrifted},
|
||||||
|
{"awaiting inventory", func(s *model.Service) { s.AwaitingInventory = true }, model.ReasonNotDrifted},
|
||||||
|
{"removed", func(s *model.Service) { s.RemovedAt = ptr(time.Now()) }, model.ReasonRemoved},
|
||||||
|
{"flagged self", func(s *model.Service) { s.Self = true }, model.ReasonSelf},
|
||||||
|
{"built", func(s *model.Service) { s.Built = true }, model.ReasonBuilt},
|
||||||
|
{"container", func(s *model.Service) { s.Kind = protocol.KindContainer }, model.ReasonContainer},
|
||||||
|
{"desired ref is the agent image", func(s *model.Service) {
|
||||||
|
s.DesiredRef = selfImage + "@" + driftD1
|
||||||
|
}, model.ReasonSelf},
|
||||||
|
}
|
||||||
|
s, _ := newService(&fakeStore{}, &fakeHub{})
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
svc := driftedSvc()
|
||||||
|
tt.mutate(&svc)
|
||||||
|
err := s.Restorable(svc)
|
||||||
|
if tt.reason == "" {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assert.Equal(t, tt.reason, notUpdatableReason(t, err))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestoreDesired_StartsAManualJobToTheDesiredRef(t *testing.T) {
|
||||||
|
svc := driftedSvc()
|
||||||
|
st := &fakeStore{services: map[int64]model.Service{1: svc}, jobs: map[int64]model.Job{}}
|
||||||
|
h := &fakeHub{route: model.Route{AgentID: 7, ConnID: 70}}
|
||||||
|
s, _ := newService(st, h)
|
||||||
|
|
||||||
|
job, err := s.RestoreDesired(context.Background(), 1, "sub-1")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, model.JobQueued, job.Status)
|
||||||
|
require.Len(t, st.created, 1)
|
||||||
|
assert.Equal(t, svc.DesiredRef, st.created[0].ToRef)
|
||||||
|
assert.Equal(t, svc.ActualRef, st.created[0].FromRef)
|
||||||
|
assert.Equal(t, "sub-1", st.created[0].RequestedBy)
|
||||||
|
assert.Equal(t, model.TriggerManual, st.created[0].Trigger)
|
||||||
|
assert.Equal(t, []int{protocol.MinPinnedRefVersion}, h.mins)
|
||||||
|
require.Len(t, h.starts, 1)
|
||||||
|
assert.Equal(t, svc.DesiredRef, h.starts[0].start.Ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestoreDesired_Refusals(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
mutate func(*model.Service)
|
||||||
|
reason string
|
||||||
|
}{
|
||||||
|
{"not drifted", func(s *model.Service) { s.ActualDigest = driftD1 }, model.ReasonNotDrifted},
|
||||||
|
{"self", func(s *model.Service) { s.Self = true }, model.ReasonSelf},
|
||||||
|
{"built", func(s *model.Service) { s.Built = true }, model.ReasonBuilt},
|
||||||
|
{"container", func(s *model.Service) { s.Kind = protocol.KindContainer }, model.ReasonContainer},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
svc := driftedSvc()
|
||||||
|
tt.mutate(&svc)
|
||||||
|
st := &fakeStore{services: map[int64]model.Service{1: svc}, jobs: map[int64]model.Job{}}
|
||||||
|
h := &fakeHub{route: model.Route{AgentID: 7, ConnID: 70}}
|
||||||
|
s, pub := newService(st, h)
|
||||||
|
|
||||||
|
_, err := s.RestoreDesired(context.Background(), 1, "sub-1")
|
||||||
|
|
||||||
|
assert.Equal(t, tt.reason, notUpdatableReason(t, err))
|
||||||
|
assert.Empty(t, st.created)
|
||||||
|
assert.Empty(t, h.starts)
|
||||||
|
assert.Empty(t, pub.topics)
|
||||||
|
assert.Empty(t, st.lastTag, "last_tag_digest untouched")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestoreDesired_UnknownService(t *testing.T) {
|
||||||
|
s, _ := newService(&fakeStore{services: map[int64]model.Service{}}, &fakeHub{})
|
||||||
|
|
||||||
|
_, err := s.RestoreDesired(context.Background(), 9, "sub-1")
|
||||||
|
|
||||||
|
assert.ErrorIs(t, err, model.ErrNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestoreDesired_ActiveJobReturnsItAndMarksNothing(t *testing.T) {
|
||||||
|
activeJob := model.Job{ID: 99, Status: model.JobRunning}
|
||||||
|
st := &fakeStore{services: map[int64]model.Service{1: driftedSvc()}, jobs: map[int64]model.Job{99: activeJob}, active: &activeJob}
|
||||||
|
s, _ := newService(st, &fakeHub{route: model.Route{AgentID: 7, ConnID: 70}})
|
||||||
|
|
||||||
|
job, err := s.RestoreDesired(context.Background(), 1, "sub-1")
|
||||||
|
|
||||||
|
assert.ErrorIs(t, err, model.ErrJobActive)
|
||||||
|
assert.Equal(t, int64(99), job.ID)
|
||||||
|
assert.Empty(t, st.lastTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestoreDesired_OldAgentGivesAFailedJob(t *testing.T) {
|
||||||
|
st := &fakeStore{services: map[int64]model.Service{1: driftedSvc()}, jobs: map[int64]model.Job{}}
|
||||||
|
h := &fakeHub{route: model.Route{AgentID: 7, ConnID: 70}, agentProtocol: 2}
|
||||||
|
s, _ := newService(st, h)
|
||||||
|
|
||||||
|
job, err := s.RestoreDesired(context.Background(), 1, "sub-1")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, model.JobFailed, job.Status)
|
||||||
|
assert.Equal(t, model.ErrAgentTooOldForPinned.Error(), job.Error)
|
||||||
|
assert.Empty(t, h.starts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestoreDesired_LastTagDigest(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
mutate func(*model.Service)
|
||||||
|
want string // "" means SetLastTagDigest is not called
|
||||||
|
}{
|
||||||
|
{"drifted to another digest of the same tag", func(*model.Service) {}, driftD2},
|
||||||
|
{"digest in the actual ref", func(s *model.Service) {
|
||||||
|
s.ActualRef, s.ActualDigest = driftRepo+":1.4.2@"+driftD3, ""
|
||||||
|
}, driftD3},
|
||||||
|
{"last tag digest holds back a newer one", func(s *model.Service) {
|
||||||
|
s.DesiredRef = driftRepo + ":1.4.2@" + driftD1
|
||||||
|
s.LastTagDigest = driftD3
|
||||||
|
}, ""},
|
||||||
|
{"last tag digest equals the desired digest", func(s *model.Service) { s.LastTagDigest = driftD1 }, driftD2},
|
||||||
|
{"already remembered", func(s *model.Service) { s.LastTagDigest = driftD2 }, ""},
|
||||||
|
{"different tag", func(s *model.Service) { s.ActualRef = driftRepo + ":1.5.0" }, ""},
|
||||||
|
{"actual digest malformed", func(s *model.Service) { s.ActualDigest = "sha256:abc" }, ""},
|
||||||
|
{"desired follows no tag", func(s *model.Service) { s.DesiredRef = driftRepo + "@" + driftD1 }, ""},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
svc := driftedSvc()
|
||||||
|
tt.mutate(&svc)
|
||||||
|
st := &fakeStore{services: map[int64]model.Service{1: svc}, jobs: map[int64]model.Job{}}
|
||||||
|
s, _ := newService(st, &fakeHub{route: model.Route{AgentID: 7, ConnID: 70}})
|
||||||
|
|
||||||
|
_, err := s.RestoreDesired(context.Background(), 1, "sub-1")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
if tt.want == "" {
|
||||||
|
assert.Empty(t, st.lastTag)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assert.Equal(t, map[int64]string{1: tt.want}, st.lastTag)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStart_RefusesPollAndWebhookWhileDrifted(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
trigger model.JobTrigger
|
||||||
|
refused bool
|
||||||
|
}{
|
||||||
|
{model.TriggerPoll, true},
|
||||||
|
{model.TriggerWebhook, true},
|
||||||
|
{model.TriggerManual, false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(string(tt.trigger), func(t *testing.T) {
|
||||||
|
svc := driftedSvc()
|
||||||
|
st := &fakeStore{services: map[int64]model.Service{1: svc}, jobs: map[int64]model.Job{}}
|
||||||
|
h := &fakeHub{route: model.Route{AgentID: 7, ConnID: 70}}
|
||||||
|
s, _ := newService(st, h)
|
||||||
|
|
||||||
|
_, err := s.Start(context.Background(), svc, StartRequest{Trigger: tt.trigger, Ref: driftRepo + ":2.0.0"})
|
||||||
|
|
||||||
|
if tt.refused {
|
||||||
|
assert.Equal(t, model.ReasonDrifted, notUpdatableReason(t, err))
|
||||||
|
assert.Empty(t, st.created)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, st.created, 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStart_PollAllowedWhenNotDrifted(t *testing.T) {
|
||||||
|
svc := driftedSvc()
|
||||||
|
svc.AwaitingInventory = true
|
||||||
|
st := &fakeStore{services: map[int64]model.Service{1: svc}, jobs: map[int64]model.Job{}}
|
||||||
|
s, _ := newService(st, &fakeHub{route: model.Route{AgentID: 7, ConnID: 70}})
|
||||||
|
|
||||||
|
_, err := s.Start(context.Background(), svc, StartRequest{Trigger: model.TriggerPoll})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, st.created, 1)
|
||||||
|
}
|
||||||
+165
-13
@@ -20,13 +20,16 @@ type Store interface {
|
|||||||
CreateJob(ctx context.Context, j model.NewJob) (model.Job, error)
|
CreateJob(ctx context.Context, j model.NewJob) (model.Job, error)
|
||||||
FailJob(ctx context.Context, id int64, msg string, at time.Time) (bool, error)
|
FailJob(ctx context.Context, id int64, msg string, at time.Time) (bool, error)
|
||||||
GetJob(ctx context.Context, id int64) (model.Job, error)
|
GetJob(ctx context.Context, id int64) (model.Job, error)
|
||||||
|
GetImageVersion(ctx context.Context, id int64) (model.ImageVersion, error)
|
||||||
|
SetLastTagDigest(ctx context.Context, id int64, digest string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hub routes jobs to agent connections. *hub.Hub satisfies it.
|
// Hub routes jobs to agent connections. *hub.Hub satisfies it.
|
||||||
type Hub interface {
|
type Hub interface {
|
||||||
// Route picks the connection for svc: model.ErrAgentOffline or
|
// Route picks the connection for svc among agents speaking at least
|
||||||
|
// protocol version minProtocol: model.ErrAgentOffline or
|
||||||
// model.ErrAgentTooOld when there is none.
|
// model.ErrAgentTooOld when there is none.
|
||||||
Route(svc model.Service) (model.Route, error)
|
Route(svc model.Service, minProtocol int) (model.Route, error)
|
||||||
// StartJob sends job.start over exactly the routed connection.
|
// StartJob sends job.start over exactly the routed connection.
|
||||||
// model.ErrAgentOffline if that connection is gone or was replaced.
|
// model.ErrAgentOffline if that connection is gone or was replaced.
|
||||||
StartJob(r model.Route, jobID int64, start protocol.JobStart) error
|
StartJob(r model.Route, jobID int64, start protocol.JobStart) error
|
||||||
@@ -68,9 +71,34 @@ func target(actualRef string) (repo, tag string, ok bool) {
|
|||||||
return ref.Repo, "", false
|
return ref.Repo, "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FollowedRef returns "repo:tag" for the tag a service with this actual ref
|
||||||
|
// follows (a bare image follows "latest"), or "" when it follows no tag.
|
||||||
|
func FollowedRef(actualRef string) string {
|
||||||
|
repo, tag, ok := target(actualRef)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return repo + ":" + tag
|
||||||
|
}
|
||||||
|
|
||||||
// Updatable returns nil or a *model.NotUpdatableError. It looks only at the
|
// Updatable returns nil or a *model.NotUpdatableError. It looks only at the
|
||||||
// service row, so the UI can call it for every page render.
|
// service row, so the UI can call it for every page render. It answers for an
|
||||||
|
// update to the service's own followed tag (the Update button).
|
||||||
func (s *Service) Updatable(svc model.Service) error {
|
func (s *Service) Updatable(svc model.Service) error {
|
||||||
|
return s.updatable(svc, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoAllowed returns nil or a *model.NotUpdatableError saying why the
|
||||||
|
// service can never be updated automatically. Unlike Updatable it does not
|
||||||
|
// refuse a service that follows no tag: a webhook that names a tag can still
|
||||||
|
// update it.
|
||||||
|
func (s *Service) AutoAllowed(svc model.Service) error {
|
||||||
|
return s.updatable(svc, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updatable checks the service row. explicitRef means the caller supplies the
|
||||||
|
// target ref, so a service that follows no tag is fine.
|
||||||
|
func (s *Service) updatable(svc model.Service, explicitRef bool) error {
|
||||||
switch {
|
switch {
|
||||||
case svc.RemovedAt != nil:
|
case svc.RemovedAt != nil:
|
||||||
return &model.NotUpdatableError{Reason: model.ReasonRemoved}
|
return &model.NotUpdatableError{Reason: model.ReasonRemoved}
|
||||||
@@ -81,32 +109,156 @@ func (s *Service) Updatable(svc model.Service) error {
|
|||||||
case svc.Kind == protocol.KindContainer:
|
case svc.Kind == protocol.KindContainer:
|
||||||
return &model.NotUpdatableError{Reason: model.ReasonContainer}
|
return &model.NotUpdatableError{Reason: model.ReasonContainer}
|
||||||
}
|
}
|
||||||
if _, _, ok := target(svc.ActualRef); !ok {
|
if _, _, ok := target(svc.ActualRef); !ok && !explicitRef {
|
||||||
return &model.NotUpdatableError{Reason: model.ReasonNoTag}
|
return &model.NotUpdatableError{Reason: model.ReasonNoTag}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restorable returns nil or a *model.NotUpdatableError saying why the service
|
||||||
|
// cannot be restored to its desired version: it must have drifted, and the
|
||||||
|
// desired version is deployed like an explicit ref, so a service that follows
|
||||||
|
// no tag is fine. The service page calls it for every render.
|
||||||
|
func (s *Service) Restorable(svc model.Service) error {
|
||||||
|
if svc.RemovedAt != nil {
|
||||||
|
return &model.NotUpdatableError{Reason: model.ReasonRemoved}
|
||||||
|
}
|
||||||
|
if !svc.Drifted() {
|
||||||
|
return &model.NotUpdatableError{Reason: model.ReasonNotDrifted}
|
||||||
|
}
|
||||||
|
if err := s.updatable(svc, true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if imageref.Parse(svc.DesiredRef).Repo == s.selfRepo {
|
||||||
|
return &model.NotUpdatableError{Reason: model.ReasonSelf}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestoreDesired starts a manual job that deploys the drifted service's
|
||||||
|
// desired ref again. The ref is read from the row, never from the caller. It
|
||||||
|
// returns model.ErrNotFound for an unknown service and otherwise what Start
|
||||||
|
// returns, with a *model.NotUpdatableError from Restorable if it cannot be
|
||||||
|
// restored.
|
||||||
|
//
|
||||||
|
// When the service had drifted to another digest of the same tag, that digest
|
||||||
|
// becomes last_tag_digest, so the poller does not deploy it again before the
|
||||||
|
// registry shows a newer one (the same rule as for any rollback). It is not
|
||||||
|
// written when last_tag_digest holds back a newer digest, that is when it is
|
||||||
|
// set and differs from the digest the desired ref names.
|
||||||
|
func (s *Service) RestoreDesired(ctx context.Context, serviceID int64, requestedBy string) (model.Job, error) {
|
||||||
|
svc, err := s.store.GetService(ctx, serviceID)
|
||||||
|
if err != nil {
|
||||||
|
return model.Job{}, err
|
||||||
|
}
|
||||||
|
if err := s.Restorable(svc); err != nil {
|
||||||
|
return model.Job{}, err
|
||||||
|
}
|
||||||
|
job, err := s.Start(ctx, svc, StartRequest{Trigger: model.TriggerManual, RequestedBy: requestedBy, Ref: svc.DesiredRef})
|
||||||
|
if err != nil {
|
||||||
|
return job, err
|
||||||
|
}
|
||||||
|
if d := driftedTagDigest(svc); d != "" && d != svc.LastTagDigest && holdsNothingNewer(svc) {
|
||||||
|
if err := s.store.SetLastTagDigest(ctx, svc.ID, d); err != nil {
|
||||||
|
s.log.Warn("last_tag_digest_not_set", "service_id", svc.ID, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return job, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// holdsNothingNewer reports whether last_tag_digest may be overwritten by a
|
||||||
|
// restore: it is empty or equals the digest the desired ref names. Anything
|
||||||
|
// else is a digest the poller must keep holding back.
|
||||||
|
func holdsNothingNewer(svc model.Service) bool {
|
||||||
|
return svc.LastTagDigest == "" || svc.LastTagDigest == imageref.Parse(svc.DesiredRef).Digest
|
||||||
|
}
|
||||||
|
|
||||||
|
// driftedTagDigest returns the digest a drifted service runs when it runs the
|
||||||
|
// same tag as desired at another digest, else "".
|
||||||
|
func driftedTagDigest(svc model.Service) string {
|
||||||
|
tag := FollowedRef(svc.DesiredRef)
|
||||||
|
if tag == "" || tag != FollowedRef(svc.ActualRef) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
d := imageref.Parse(svc.ActualRef).Digest
|
||||||
|
if d == "" {
|
||||||
|
d = svc.ActualDigest
|
||||||
|
}
|
||||||
|
if !imageref.IsDigest(d) || d == imageref.Parse(svc.DesiredRef).Digest {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartRequest says who asks for an update and what to deploy.
|
||||||
|
type StartRequest struct {
|
||||||
|
Trigger model.JobTrigger
|
||||||
|
RequestedBy string // OIDC subject for manual jobs; "" otherwise
|
||||||
|
// Ref is the ref to deploy ("repo:tag", "repo:tag@digest" or
|
||||||
|
// "repo@digest"). Empty means the service's followed tag, derived from
|
||||||
|
// its actual ref.
|
||||||
|
Ref string
|
||||||
|
}
|
||||||
|
|
||||||
// Trigger starts a manual update of the service to its current tag. It
|
// Trigger starts a manual update of the service to its current tag. It
|
||||||
// returns the job, which may already be failed ("agent offline", "agent too
|
// returns what Start returns, and model.ErrNotFound for an unknown service.
|
||||||
// old for updates"); a *model.NotUpdatableError when the service cannot be
|
|
||||||
// updated (no job created); the active job and model.ErrJobActive when one
|
|
||||||
// is queued or running; model.ErrNotFound for an unknown service.
|
|
||||||
func (s *Service) Trigger(ctx context.Context, serviceID int64, requestedBy string) (model.Job, error) {
|
func (s *Service) Trigger(ctx context.Context, serviceID int64, requestedBy string) (model.Job, error) {
|
||||||
svc, err := s.store.GetService(ctx, serviceID)
|
svc, err := s.store.GetService(ctx, serviceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return model.Job{}, err
|
return model.Job{}, err
|
||||||
}
|
}
|
||||||
if err := s.Updatable(svc); err != nil {
|
return s.Start(ctx, svc, StartRequest{Trigger: model.TriggerManual, RequestedBy: requestedBy})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TriggerVersion starts a manual update of the service to exactly the image
|
||||||
|
// version versionID, which must be a version of the service's own repo; the
|
||||||
|
// ref is built from the stored version, never taken from the caller. It
|
||||||
|
// returns model.ErrNotFound for an unknown service, an unknown version or a
|
||||||
|
// version of another repo, and otherwise what Start returns. A version
|
||||||
|
// without a tag leaves the service following no tag.
|
||||||
|
func (s *Service) TriggerVersion(ctx context.Context, serviceID, versionID int64, requestedBy string) (model.Job, error) {
|
||||||
|
svc, err := s.store.GetService(ctx, serviceID)
|
||||||
|
if err != nil {
|
||||||
return model.Job{}, err
|
return model.Job{}, err
|
||||||
}
|
}
|
||||||
repo, tag, _ := target(svc.ActualRef) // Updatable checked the tag
|
v, err := s.store.GetImageVersion(ctx, versionID)
|
||||||
ref := repo + ":" + tag
|
if err != nil {
|
||||||
|
return model.Job{}, err
|
||||||
|
}
|
||||||
|
if v.Repo != svc.ImageRepo {
|
||||||
|
return model.Job{}, model.ErrNotFound
|
||||||
|
}
|
||||||
|
return s.Start(ctx, svc, StartRequest{Trigger: model.TriggerManual, RequestedBy: requestedBy, Ref: v.Ref()})
|
||||||
|
}
|
||||||
|
|
||||||
r, routeErr := s.hub.Route(svc)
|
// Start starts an update of svc. It returns the job, which may already be
|
||||||
|
// failed ("agent offline", "agent too old for updates", or "agent too old to
|
||||||
|
// deploy a pinned version" when the ref has a digest or no tag and the agent
|
||||||
|
// speaks protocol version 2); a
|
||||||
|
// *model.NotUpdatableError when the service cannot be updated (no job
|
||||||
|
// created); the active job and model.ErrJobActive when one is queued or
|
||||||
|
// running. Poll and webhook jobs are refused for a drifted service; only a
|
||||||
|
// person starts one then.
|
||||||
|
func (s *Service) Start(ctx context.Context, svc model.Service, req StartRequest) (model.Job, error) {
|
||||||
|
if req.Trigger != model.TriggerManual && svc.Drifted() {
|
||||||
|
return model.Job{}, &model.NotUpdatableError{Reason: model.ReasonDrifted}
|
||||||
|
}
|
||||||
|
if err := s.updatable(svc, req.Ref != ""); err != nil {
|
||||||
|
return model.Job{}, err
|
||||||
|
}
|
||||||
|
ref := req.Ref
|
||||||
|
if ref == "" {
|
||||||
|
ref = FollowedRef(svc.ActualRef) // updatable checked the tag
|
||||||
|
}
|
||||||
|
|
||||||
|
minProtocol := protocol.MinJobVersion
|
||||||
|
if p := imageref.Parse(ref); p.Digest != "" || p.Tag == "" {
|
||||||
|
minProtocol = protocol.MinPinnedRefVersion
|
||||||
|
}
|
||||||
|
r, routeErr := s.hub.Route(svc, minProtocol)
|
||||||
nj := model.NewJob{
|
nj := model.NewJob{
|
||||||
ServiceID: svc.ID, FromRef: svc.ActualRef, ToRef: ref,
|
ServiceID: svc.ID, FromRef: svc.ActualRef, ToRef: ref,
|
||||||
Trigger: model.TriggerManual, RequestedBy: requestedBy, CreatedAt: s.now(),
|
Trigger: req.Trigger, RequestedBy: req.RequestedBy, CreatedAt: s.now(),
|
||||||
}
|
}
|
||||||
if routeErr == nil {
|
if routeErr == nil {
|
||||||
nj.AgentID = &r.AgentID
|
nj.AgentID = &r.AgentID
|
||||||
|
|||||||
@@ -21,8 +21,10 @@ type fakeStore struct {
|
|||||||
jobs map[int64]model.Job
|
jobs map[int64]model.Job
|
||||||
created []model.NewJob
|
created []model.NewJob
|
||||||
active *model.Job // returned with ErrJobActive when set
|
active *model.Job // returned with ErrJobActive when set
|
||||||
|
versions map[int64]model.ImageVersion
|
||||||
|
|
||||||
revokedAgents map[int64]bool // CreateJob returns ErrAgentOffline for these
|
revokedAgents map[int64]bool // CreateJob returns ErrAgentOffline for these
|
||||||
|
lastTag map[int64]string // SetLastTagDigest calls: service ID to digest
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeStore) GetService(_ context.Context, id int64) (model.Service, error) {
|
func (f *fakeStore) GetService(_ context.Context, id int64) (model.Service, error) {
|
||||||
@@ -54,6 +56,22 @@ func (f *fakeStore) FailJob(_ context.Context, id int64, msg string, at time.Tim
|
|||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) GetImageVersion(_ context.Context, id int64) (model.ImageVersion, error) {
|
||||||
|
v, ok := f.versions[id]
|
||||||
|
if !ok {
|
||||||
|
return model.ImageVersion{}, model.ErrNotFound
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) SetLastTagDigest(_ context.Context, id int64, digest string) error {
|
||||||
|
if f.lastTag == nil {
|
||||||
|
f.lastTag = map[int64]string{}
|
||||||
|
}
|
||||||
|
f.lastTag[id] = digest
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeStore) GetJob(_ context.Context, id int64) (model.Job, error) { return f.jobs[id], nil }
|
func (f *fakeStore) GetJob(_ context.Context, id int64) (model.Job, error) { return f.jobs[id], nil }
|
||||||
|
|
||||||
type startCall struct {
|
type startCall struct {
|
||||||
@@ -63,13 +81,24 @@ type startCall struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type fakeHub struct {
|
type fakeHub struct {
|
||||||
route model.Route
|
route model.Route
|
||||||
routeErr error
|
routeErr error
|
||||||
startErr error
|
startErr error
|
||||||
starts []startCall
|
starts []startCall
|
||||||
|
agentProtocol int // when set, Route fails with ErrAgentTooOld for a higher minProtocol
|
||||||
|
mins []int // minProtocol of every Route call
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *fakeHub) Route(model.Service) (model.Route, error) { return h.route, h.routeErr }
|
func (h *fakeHub) Route(_ model.Service, minProtocol int) (model.Route, error) {
|
||||||
|
h.mins = append(h.mins, minProtocol)
|
||||||
|
if h.agentProtocol != 0 && minProtocol > h.agentProtocol {
|
||||||
|
if minProtocol >= protocol.MinPinnedRefVersion {
|
||||||
|
return model.Route{}, model.ErrAgentTooOldForPinned
|
||||||
|
}
|
||||||
|
return model.Route{}, model.ErrAgentTooOld
|
||||||
|
}
|
||||||
|
return h.route, h.routeErr
|
||||||
|
}
|
||||||
|
|
||||||
func (h *fakeHub) StartJob(r model.Route, id int64, s protocol.JobStart) error {
|
func (h *fakeHub) StartJob(r model.Route, id int64, s protocol.JobStart) error {
|
||||||
h.starts = append(h.starts, startCall{r, id, s})
|
h.starts = append(h.starts, startCall{r, id, s})
|
||||||
@@ -242,3 +271,233 @@ func TestTrigger_NotUpdatableCreatesNoJob(t *testing.T) {
|
|||||||
assert.Empty(t, h.starts)
|
assert.Empty(t, h.starts)
|
||||||
assert.Empty(t, pub.topics)
|
assert.Empty(t, pub.topics)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStart_WebhookTriggerWithExplicitRef(t *testing.T) {
|
||||||
|
st := &fakeStore{services: map[int64]model.Service{1: composeSvc}, jobs: map[int64]model.Job{}}
|
||||||
|
h := &fakeHub{route: model.Route{AgentID: 7, ConnID: 70}}
|
||||||
|
s, _ := newService(st, h)
|
||||||
|
|
||||||
|
job, err := s.Start(context.Background(), composeSvc, StartRequest{
|
||||||
|
Trigger: model.TriggerWebhook, Ref: "gitea.shcizo.se/shcizo/slusk:2.0.0"})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, model.JobQueued, job.Status)
|
||||||
|
require.Len(t, st.created, 1)
|
||||||
|
assert.Equal(t, model.TriggerWebhook, st.created[0].Trigger)
|
||||||
|
assert.Empty(t, st.created[0].RequestedBy)
|
||||||
|
assert.Equal(t, "gitea.shcizo.se/shcizo/slusk:2.0.0", st.created[0].ToRef)
|
||||||
|
require.Len(t, h.starts, 1)
|
||||||
|
assert.Equal(t, "gitea.shcizo.se/shcizo/slusk:2.0.0", h.starts[0].start.Ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStart_ExplicitRefBypassesOnlyTheNoTagRefusal(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
svc model.Service
|
||||||
|
reason string // "" means the job starts
|
||||||
|
}{
|
||||||
|
{"digest only", model.Service{Kind: protocol.KindCompose, ActualRef: "redis@sha256:abc"}, ""},
|
||||||
|
{"removed", model.Service{Kind: protocol.KindCompose, ActualRef: "a:1", RemovedAt: ptr(time.Now())}, model.ReasonRemoved},
|
||||||
|
{"self", model.Service{Kind: protocol.KindCompose, ActualRef: "a:1", Self: true}, model.ReasonSelf},
|
||||||
|
{"built", model.Service{Kind: protocol.KindCompose, ActualRef: "a:1", Built: true}, model.ReasonBuilt},
|
||||||
|
{"container", model.Service{Kind: protocol.KindContainer, ActualRef: "redis:7"}, model.ReasonContainer},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
tt.svc.ID = 1
|
||||||
|
st := &fakeStore{services: map[int64]model.Service{1: tt.svc}, jobs: map[int64]model.Job{}}
|
||||||
|
h := &fakeHub{route: model.Route{AgentID: 7, ConnID: 70}}
|
||||||
|
s, _ := newService(st, h)
|
||||||
|
|
||||||
|
_, err := s.Start(context.Background(), tt.svc, StartRequest{Trigger: model.TriggerWebhook, Ref: "x/y:2"})
|
||||||
|
|
||||||
|
if tt.reason == "" {
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, st.created, 1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var nu *model.NotUpdatableError
|
||||||
|
require.True(t, errors.As(err, &nu))
|
||||||
|
assert.Equal(t, tt.reason, nu.Reason)
|
||||||
|
assert.Empty(t, st.created)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStart_WithoutRefStillRefusesNoTag(t *testing.T) {
|
||||||
|
svc := model.Service{ID: 1, Kind: protocol.KindCompose, ActualRef: "redis@sha256:abc"}
|
||||||
|
s, _ := newService(&fakeStore{jobs: map[int64]model.Job{}}, &fakeHub{})
|
||||||
|
|
||||||
|
_, err := s.Start(context.Background(), svc, StartRequest{Trigger: model.TriggerWebhook})
|
||||||
|
|
||||||
|
var nu *model.NotUpdatableError
|
||||||
|
require.True(t, errors.As(err, &nu))
|
||||||
|
assert.Equal(t, model.ReasonNoTag, nu.Reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutoAllowed(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
svc model.Service
|
||||||
|
reason string
|
||||||
|
}{
|
||||||
|
{"removed", model.Service{Kind: protocol.KindCompose, ActualRef: "a:1", RemovedAt: ptr(time.Now())}, model.ReasonRemoved},
|
||||||
|
{"self", model.Service{Kind: protocol.KindCompose, ActualRef: "a:1", Self: true}, model.ReasonSelf},
|
||||||
|
{"agent image repo", model.Service{Kind: protocol.KindCompose, ActualRef: selfImage}, model.ReasonSelf},
|
||||||
|
{"built", model.Service{Kind: protocol.KindCompose, ActualRef: "a:1", Built: true}, model.ReasonBuilt},
|
||||||
|
{"container", model.Service{Kind: protocol.KindContainer, ActualRef: "redis:7"}, model.ReasonContainer},
|
||||||
|
{"digest without tag is allowed", model.Service{Kind: protocol.KindCompose, ActualRef: "redis@sha256:abc"}, ""},
|
||||||
|
{"tag", model.Service{Kind: protocol.KindCompose, ActualRef: "postgres:16"}, ""},
|
||||||
|
}
|
||||||
|
s, _ := newService(&fakeStore{}, &fakeHub{})
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := s.AutoAllowed(tt.svc)
|
||||||
|
if tt.reason == "" {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var nu *model.NotUpdatableError
|
||||||
|
require.True(t, errors.As(err, &nu))
|
||||||
|
assert.Equal(t, tt.reason, nu.Reason)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFollowedRef(t *testing.T) {
|
||||||
|
assert.Equal(t, "postgres:16", FollowedRef("postgres:16@sha256:abc"))
|
||||||
|
assert.Equal(t, "postgres:latest", FollowedRef("postgres"))
|
||||||
|
assert.Equal(t, "localhost:5000/foo:latest", FollowedRef("localhost:5000/foo"))
|
||||||
|
assert.Empty(t, FollowedRef("redis@sha256:abc"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStart_RoutesWithTheMinimumProtocolOfTheRef(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
ref string // "" means the followed tag
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"followed tag", "", protocol.MinJobVersion},
|
||||||
|
{"repo:tag", "x/y:2", protocol.MinJobVersion},
|
||||||
|
{"repo:tag@digest", "x/y:2@sha256:a", protocol.MinPinnedRefVersion},
|
||||||
|
{"repo@digest", "x/y@sha256:a", protocol.MinPinnedRefVersion},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
st := &fakeStore{services: map[int64]model.Service{1: composeSvc}, jobs: map[int64]model.Job{}}
|
||||||
|
h := &fakeHub{route: model.Route{AgentID: 7, ConnID: 70}}
|
||||||
|
s, _ := newService(st, h)
|
||||||
|
|
||||||
|
_, err := s.Start(context.Background(), composeSvc, StartRequest{Trigger: model.TriggerManual, Ref: tt.ref})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []int{tt.want}, h.mins)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTriggerVersion_Ref(t *testing.T) {
|
||||||
|
const repo = "gitea.shcizo.se/shcizo/slusk"
|
||||||
|
noTag := model.Service{ID: 4, Kind: protocol.KindCompose, ComposeProject: "n", ComposeService: "n",
|
||||||
|
AgentID: ptr(int64(7)), ImageRepo: repo, ActualRef: repo + "@sha256:d1"}
|
||||||
|
svc := composeSvc
|
||||||
|
svc.ImageRepo = repo
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
svc model.Service
|
||||||
|
version model.ImageVersion
|
||||||
|
wantRef string
|
||||||
|
wantMin int
|
||||||
|
}{
|
||||||
|
{"tag and digest", svc, model.ImageVersion{ID: 10, Repo: repo, Tag: "1.3.0", Digest: "sha256:old"}, repo + ":1.3.0@sha256:old", protocol.MinPinnedRefVersion},
|
||||||
|
{"tag only", svc, model.ImageVersion{ID: 10, Repo: repo, Tag: "main"}, repo + ":main", protocol.MinJobVersion},
|
||||||
|
{"digest-less version from the tag list", svc, model.ImageVersion{ID: 10, Repo: repo, Tag: "1.28.0", Source: model.SourceTags}, repo + ":1.28.0", protocol.MinJobVersion},
|
||||||
|
{"digest only", svc, model.ImageVersion{ID: 10, Repo: repo, Digest: "sha256:old"}, repo + "@sha256:old", protocol.MinPinnedRefVersion},
|
||||||
|
{"service that follows no tag", noTag, model.ImageVersion{ID: 10, Repo: repo, Tag: "1.3.0", Digest: "sha256:old"}, repo + ":1.3.0@sha256:old", protocol.MinPinnedRefVersion},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
st := &fakeStore{
|
||||||
|
services: map[int64]model.Service{tt.svc.ID: tt.svc},
|
||||||
|
jobs: map[int64]model.Job{},
|
||||||
|
versions: map[int64]model.ImageVersion{10: tt.version},
|
||||||
|
}
|
||||||
|
h := &fakeHub{route: model.Route{AgentID: 7, ConnID: 70}}
|
||||||
|
s, _ := newService(st, h)
|
||||||
|
|
||||||
|
job, err := s.TriggerVersion(context.Background(), tt.svc.ID, 10, "sub-1")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, model.JobQueued, job.Status)
|
||||||
|
require.Len(t, st.created, 1)
|
||||||
|
assert.Equal(t, model.TriggerManual, st.created[0].Trigger)
|
||||||
|
assert.Equal(t, "sub-1", st.created[0].RequestedBy)
|
||||||
|
assert.Equal(t, tt.wantRef, st.created[0].ToRef)
|
||||||
|
require.Len(t, h.starts, 1)
|
||||||
|
assert.Equal(t, tt.wantRef, h.starts[0].start.Ref)
|
||||||
|
assert.Equal(t, []int{tt.wantMin}, h.mins)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTriggerVersion_NotFound(t *testing.T) {
|
||||||
|
const repo = "gitea.shcizo.se/shcizo/slusk"
|
||||||
|
svc := composeSvc
|
||||||
|
svc.ImageRepo = repo
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
serviceID int64
|
||||||
|
versionID int64
|
||||||
|
}{
|
||||||
|
{"unknown service", 9, 10},
|
||||||
|
{"unknown version", 1, 99},
|
||||||
|
{"version of another repo", 1, 11},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
st := &fakeStore{
|
||||||
|
services: map[int64]model.Service{1: svc},
|
||||||
|
jobs: map[int64]model.Job{},
|
||||||
|
versions: map[int64]model.ImageVersion{
|
||||||
|
10: {ID: 10, Repo: repo, Tag: "1"},
|
||||||
|
11: {ID: 11, Repo: "evil/other", Tag: "1"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := &fakeHub{route: model.Route{AgentID: 7, ConnID: 70}}
|
||||||
|
s, pub := newService(st, h)
|
||||||
|
|
||||||
|
_, err := s.TriggerVersion(context.Background(), tt.serviceID, tt.versionID, "sub-1")
|
||||||
|
|
||||||
|
assert.ErrorIs(t, err, model.ErrNotFound)
|
||||||
|
assert.Empty(t, st.created, "no job")
|
||||||
|
assert.Empty(t, h.starts)
|
||||||
|
assert.Empty(t, pub.topics)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTriggerVersion_RequiresPinnedProtocol(t *testing.T) {
|
||||||
|
const repo = "gitea.shcizo.se/shcizo/slusk"
|
||||||
|
svc := composeSvc
|
||||||
|
svc.ImageRepo = repo
|
||||||
|
st := &fakeStore{
|
||||||
|
services: map[int64]model.Service{1: svc},
|
||||||
|
jobs: map[int64]model.Job{},
|
||||||
|
versions: map[int64]model.ImageVersion{
|
||||||
|
10: {ID: 10, Repo: repo, Tag: "1.3.0", Digest: "sha256:old"},
|
||||||
|
11: {ID: 11, Repo: repo, Tag: "main"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := &fakeHub{route: model.Route{AgentID: 7, ConnID: 70}, agentProtocol: 2}
|
||||||
|
s, _ := newService(st, h)
|
||||||
|
|
||||||
|
pinned, err := s.TriggerVersion(context.Background(), 1, 10, "sub-1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, model.JobFailed, pinned.Status)
|
||||||
|
assert.Equal(t, "agent too old to deploy a pinned version (needs protocol 3)", pinned.Error)
|
||||||
|
assert.Empty(t, h.starts, "nothing sent to the old agent")
|
||||||
|
|
||||||
|
plain, err := s.TriggerVersion(context.Background(), 1, 11, "sub-1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, model.JobQueued, plain.Status)
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,8 +15,20 @@ var (
|
|||||||
// ErrAgentTooOld means the only agent that could run the job speaks a
|
// ErrAgentTooOld means the only agent that could run the job speaks a
|
||||||
// protocol version without jobs.
|
// protocol version without jobs.
|
||||||
ErrAgentTooOld = errors.New("agent too old for updates")
|
ErrAgentTooOld = errors.New("agent too old for updates")
|
||||||
|
// ErrAgentTooOldForPinned means the only agent that could run the job
|
||||||
|
// speaks protocol version 2, which cannot deploy a digest-pinned or
|
||||||
|
// tagless ref. errors.Is(err, ErrAgentTooOld) is true for it as well.
|
||||||
|
ErrAgentTooOldForPinned error = agentTooOldForPinned{}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type agentTooOldForPinned struct{}
|
||||||
|
|
||||||
|
func (agentTooOldForPinned) Error() string {
|
||||||
|
return "agent too old to deploy a pinned version (needs protocol 3)"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (agentTooOldForPinned) Is(target error) bool { return target == ErrAgentTooOld }
|
||||||
|
|
||||||
// NotUpdatableError says why a service cannot be updated. Reason is shown
|
// NotUpdatableError says why a service cannot be updated. Reason is shown
|
||||||
// in the UI as is.
|
// in the UI as is.
|
||||||
type NotUpdatableError struct {
|
type NotUpdatableError struct {
|
||||||
@@ -32,6 +44,27 @@ const (
|
|||||||
ReasonNoTag = "follows no tag"
|
ReasonNoTag = "follows no tag"
|
||||||
ReasonRemoved = "service no longer exists"
|
ReasonRemoved = "service no longer exists"
|
||||||
ReasonBuilt = "built from source (build:)"
|
ReasonBuilt = "built from source (build:)"
|
||||||
|
|
||||||
|
ReasonDrifted = "drifted: restore or accept the running version first"
|
||||||
|
ReasonNotDrifted = "runs its desired version"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MergeRefusedError says why two services cannot be merged. Reason is shown
|
||||||
|
// in the UI as is.
|
||||||
|
type MergeRefusedError struct {
|
||||||
|
Reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *MergeRefusedError) Error() string { return "cannot merge: " + e.Reason }
|
||||||
|
|
||||||
|
// Reasons two services cannot be merged. ReasonSelf is reused for the
|
||||||
|
// updater itself.
|
||||||
|
const (
|
||||||
|
ReasonMergeSame = "a service cannot be merged into itself"
|
||||||
|
ReasonMergeLiveOld = "the old service still exists"
|
||||||
|
ReasonMergeGoneNew = "the new service no longer exists"
|
||||||
|
ReasonMergeKind = "only services of the same kind can be merged"
|
||||||
|
ReasonMergeContainer = "plain containers cannot be merged"
|
||||||
)
|
)
|
||||||
|
|
||||||
// JobStatus is the state of an update job.
|
// JobStatus is the state of an update job.
|
||||||
@@ -46,7 +79,7 @@ const (
|
|||||||
JobFailed JobStatus = "failed"
|
JobFailed JobStatus = "failed"
|
||||||
)
|
)
|
||||||
|
|
||||||
// JobTrigger says what created a job. Only TriggerManual is used in phase 2.
|
// JobTrigger says what created a job. TriggerManual comes from the UI, TriggerWebhook from POST /update.
|
||||||
type JobTrigger string
|
type JobTrigger string
|
||||||
|
|
||||||
// Job triggers.
|
// Job triggers.
|
||||||
|
|||||||
@@ -8,12 +8,15 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/shcizo/package-updater/internal/imageref"
|
||||||
"github.com/shcizo/package-updater/internal/protocol"
|
"github.com/shcizo/package-updater/internal/protocol"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Sentinel errors returned by storage.
|
// Sentinel errors returned by storage.
|
||||||
var (
|
var (
|
||||||
ErrNotFound = errors.New("not found")
|
ErrNotFound = errors.New("not found")
|
||||||
|
// ErrConflict means the row already exists, or that it changed meanwhile
|
||||||
|
// (AcceptRunning, MergeService).
|
||||||
ErrConflict = errors.New("already exists")
|
ErrConflict = errors.New("already exists")
|
||||||
ErrRevoked = errors.New("agent revoked")
|
ErrRevoked = errors.New("agent revoked")
|
||||||
)
|
)
|
||||||
@@ -26,9 +29,12 @@ type Agent struct {
|
|||||||
Hostname string
|
Hostname string
|
||||||
AgentVersion string
|
AgentVersion string
|
||||||
SwarmManager bool
|
SwarmManager bool
|
||||||
LastSeenAt *time.Time
|
// SwarmClusterID is the Swarm cluster the agent's daemon was in when it last
|
||||||
RevokedAt *time.Time
|
// connected; "" outside a swarm.
|
||||||
CreatedAt time.Time
|
SwarmClusterID string
|
||||||
|
LastSeenAt *time.Time
|
||||||
|
RevokedAt *time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// Service is one service as last reported by an agent.
|
// Service is one service as last reported by an agent.
|
||||||
@@ -51,11 +57,139 @@ type Service struct {
|
|||||||
ActualDigest string
|
ActualDigest string
|
||||||
Status string
|
Status string
|
||||||
Self bool
|
Self bool
|
||||||
Built bool // Compose service with a build: section; never updated
|
Built bool // Compose service with a build: section; never updated
|
||||||
UpdateMode string
|
UpdateMode string // ModeManual or ModeAuto
|
||||||
FirstSeenAt time.Time
|
// AvailableRef is the newest ref a webhook or poll reported for this
|
||||||
UpdatedAt time.Time
|
// service; "" if none, or if a later job already deployed it.
|
||||||
RemovedAt *time.Time
|
AvailableRef string
|
||||||
|
AvailableDigest string // digest of AvailableRef when known (poll); "" from the webhook
|
||||||
|
AvailableSource string // SourceWebhook or SourcePoll; "" when AvailableRef is empty
|
||||||
|
AvailableAt *time.Time
|
||||||
|
LastPollError string // last registry error for the followed ref; "" when fine
|
||||||
|
// NewerRef is the newest repo:tag from the registry's tag list that
|
||||||
|
// tagver.Newer finds for the followed tag; "" if none. A suggestion only:
|
||||||
|
// it is never deployed automatically, not even for auto services.
|
||||||
|
NewerRef string
|
||||||
|
NewerAt *time.Time // when NewerRef was first suggested; nil when NewerRef is empty
|
||||||
|
// LastTagDigest is the newest digest of the followed tag that was deployed
|
||||||
|
// or started: set when the poller starts an auto job, when it sees the service
|
||||||
|
// already running that digest, and when any plain tag job succeeds (the
|
||||||
|
// digest the agent pinned); "" if never. The poller does
|
||||||
|
// not start another job for the same digest, so a rollback sticks until the
|
||||||
|
// registry shows a different one.
|
||||||
|
LastTagDigest string
|
||||||
|
// AwaitingInventory is true from a successful job until the next inventory
|
||||||
|
// write for the row. Until then DesiredRef is newer than ActualRef without
|
||||||
|
// the service having drifted.
|
||||||
|
AwaitingInventory bool
|
||||||
|
FirstSeenAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
RemovedAt *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update modes of a service.
|
||||||
|
const (
|
||||||
|
ModeManual = "manual"
|
||||||
|
ModeAuto = "auto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sources of an available ref and of an ImageVersion.
|
||||||
|
const (
|
||||||
|
SourceWebhook = "webhook"
|
||||||
|
SourcePoll = "poll"
|
||||||
|
// SourceRunning is used by image_versions only: an agent reported the
|
||||||
|
// version as running.
|
||||||
|
SourceRunning = "running"
|
||||||
|
// SourceTags is used by image_versions only: the tag appeared in the
|
||||||
|
// registry's tag list as a newer tag.
|
||||||
|
SourceTags = "tags"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Available is a newer ref reported for a service.
|
||||||
|
type Available struct {
|
||||||
|
Ref string
|
||||||
|
Digest string // "" when unknown (webhook)
|
||||||
|
Source string // SourceWebhook or SourcePoll
|
||||||
|
At time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImageVersion is one version of an image repo the master has seen. Tag and
|
||||||
|
// Digest are "" when the version has no tag or its digest is unknown; they
|
||||||
|
// are never both empty.
|
||||||
|
type ImageVersion struct {
|
||||||
|
ID int64
|
||||||
|
Repo string
|
||||||
|
Tag string
|
||||||
|
Digest string
|
||||||
|
Source string // SourceWebhook, SourcePoll, SourceRunning or SourceTags: the first source that saw it
|
||||||
|
FirstSeenAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ref returns "repo:tag@digest", "repo:tag" or "repo@digest".
|
||||||
|
func (v ImageVersion) Ref() string {
|
||||||
|
return imageref.Ref{Repo: v.Repo, Tag: v.Tag, Digest: v.Digest}.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drifted reports whether the service runs something other than the version
|
||||||
|
// the last job deployed. A service the updater never touched, a removed one
|
||||||
|
// and one whose inventory has not caught up with the last job do not drift.
|
||||||
|
func (s Service) Drifted() bool {
|
||||||
|
return s.DesiredRef != "" && s.RemovedAt == nil && !s.AwaitingInventory &&
|
||||||
|
!RunsRef(s.DesiredRef, s.ActualRef, s.ActualDigest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunsRef reports whether a service reporting actualRef and actualDigest runs
|
||||||
|
// the desired ref. The digest decides when both sides know one, so
|
||||||
|
// "repo:tag@D" is run by "repo:tag" reporting digest D, and by "repo@D".
|
||||||
|
// Otherwise the repo (compared exactly, without registry normalisation) and
|
||||||
|
// the followed tag must be equal.
|
||||||
|
func RunsRef(desired, actualRef, actualDigest string) bool {
|
||||||
|
d, a := imageref.Parse(desired), imageref.Parse(actualRef)
|
||||||
|
if d.Repo != a.Repo {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
ad := a.Digest
|
||||||
|
if ad == "" {
|
||||||
|
ad = actualDigest
|
||||||
|
}
|
||||||
|
if d.Digest != "" && ad != "" {
|
||||||
|
return d.Digest == ad
|
||||||
|
}
|
||||||
|
return followedTag(d) == followedTag(a)
|
||||||
|
}
|
||||||
|
|
||||||
|
// followedTag is the tag a ref follows: a bare image follows "latest" and a
|
||||||
|
// ref with a digest but no tag follows nothing.
|
||||||
|
func followedTag(r imageref.Ref) string {
|
||||||
|
if r.Tag == "" && r.Digest == "" {
|
||||||
|
return "latest"
|
||||||
|
}
|
||||||
|
return r.Tag
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunningRef is the ref to record as the desired one when the running version
|
||||||
|
// is accepted: actualRef itself if it carries a digest, "repo:tag@digest" if
|
||||||
|
// only actualDigest is known (a bare image is "latest"), else actualRef.
|
||||||
|
func RunningRef(actualRef, actualDigest string) string {
|
||||||
|
r := imageref.Parse(actualRef)
|
||||||
|
if r.Digest != "" || !imageref.IsDigest(actualDigest) {
|
||||||
|
return actualRef
|
||||||
|
}
|
||||||
|
r.Tag, r.Digest = followedTag(r), actualDigest
|
||||||
|
return r.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunningRef returns the ref accepting the running version records as the
|
||||||
|
// desired one; see the function RunningRef. Templates post it as the guard of
|
||||||
|
// the accept form.
|
||||||
|
func (s Service) RunningRef() string { return RunningRef(s.ActualRef, s.ActualDigest) }
|
||||||
|
|
||||||
|
// Move says that the removed service FromID has probably moved to the live
|
||||||
|
// service ToID on another agent. The names are the agents' own; a revoked
|
||||||
|
// agent keeps its name.
|
||||||
|
type Move struct {
|
||||||
|
FromID, ToID int64
|
||||||
|
FromAgent, ToAgent string
|
||||||
}
|
}
|
||||||
|
|
||||||
// DisplayName is the human-facing name of the service.
|
// DisplayName is the human-facing name of the service.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package model
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
@@ -40,6 +41,102 @@ func TestDisplayName(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestImageVersionRef(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
v ImageVersion
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"tag and digest", ImageVersion{Repo: "r/x", Tag: "1", Digest: "sha256:a"}, "r/x:1@sha256:a"},
|
||||||
|
{"tag only", ImageVersion{Repo: "r/x", Tag: "1"}, "r/x:1"},
|
||||||
|
{"digest only", ImageVersion{Repo: "r/x", Digest: "sha256:a"}, "r/x@sha256:a"},
|
||||||
|
{"registry port", ImageVersion{Repo: "localhost:5000/x", Tag: "1"}, "localhost:5000/x:1"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, c.want, c.v.Ref())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
dA = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||||
|
dB = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunsRef(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
desired, actualRef, actualDig string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"same string", "repo:v1@" + dA, "repo:v1@" + dA, "", true},
|
||||||
|
{"tag and digest, actual reports it separately", "repo:tag@" + dA, "repo:tag", dA, true},
|
||||||
|
{"bare image reports latest digest", "postgres:latest@" + dA, "postgres", dA, true},
|
||||||
|
{"tagless actual with the same digest", "repo:tag@" + dA, "repo@" + dA, "", true},
|
||||||
|
{"same digest under another tag", "repo:v1@" + dA, "repo:v2@" + dA, "", true},
|
||||||
|
{"same digest, actual tag differs, digest reported", "repo:v1@" + dA, "repo:v2", dA, true},
|
||||||
|
{"same tag, actual digest unknown", "repo:tag@" + dA, "repo:tag", "", true},
|
||||||
|
{"desired without digest, same tag", "repo:tag", "repo:tag@" + dA, "", true},
|
||||||
|
{"bare desired, latest actual", "repo", "repo:latest", "", true},
|
||||||
|
{"registry with port", "localhost:5000/foo:v1@" + dA, "localhost:5000/foo:v1", dA, true},
|
||||||
|
{"registry with port, other digest", "localhost:5000/foo:v1@" + dA, "localhost:5000/foo:v1", dB, false},
|
||||||
|
{"same tag, other digest", "repo:tag@" + dA, "repo:tag", dB, false},
|
||||||
|
{"other digest in the ref", "repo:tag@" + dA, "repo:tag@" + dB, "", false},
|
||||||
|
{"other tag, digest unknown", "repo:v1@" + dA, "repo:v2", "", false},
|
||||||
|
{"tagless desired, tagged actual, digest unknown", "repo@" + dA, "repo:v1", "", false},
|
||||||
|
{"other repo", "repo:tag@" + dA, "other:tag", dA, false},
|
||||||
|
{"no docker.io normalisation", "nginx:1@" + dA, "docker.io/library/nginx:1", dA, false},
|
||||||
|
{"case-sensitive repo", "Repo:1@" + dA, "repo:1", dA, false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, c.want, RunsRef(c.desired, c.actualRef, c.actualDig))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDrifted(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
drifted := Service{DesiredRef: "repo:tag@" + dA, ActualRef: "repo:tag", ActualDigest: dB}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
mutate func(*Service)
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"runs another digest", func(*Service) {}, true},
|
||||||
|
{"never updated", func(s *Service) { s.DesiredRef = "" }, false},
|
||||||
|
{"removed", func(s *Service) { s.RemovedAt = &now }, false},
|
||||||
|
{"awaiting inventory", func(s *Service) { s.AwaitingInventory = true }, false},
|
||||||
|
{"runs the desired digest", func(s *Service) { s.ActualDigest = dA }, false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
svc := drifted
|
||||||
|
c.mutate(&svc)
|
||||||
|
assert.Equal(t, c.want, svc.Drifted())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunningRef(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name, actualRef, actualDigest, want string
|
||||||
|
}{
|
||||||
|
{"ref with digest is kept", "repo:v1@" + dB, dA, "repo:v1@" + dB},
|
||||||
|
{"tag plus reported digest", "repo:v1", dA, "repo:v1@" + dA},
|
||||||
|
{"bare image becomes latest", "postgres", dA, "postgres:latest@" + dA},
|
||||||
|
{"registry with port", "localhost:5000/foo:v1", dA, "localhost:5000/foo:v1@" + dA},
|
||||||
|
{"no digest known", "repo:v1", "", "repo:v1"},
|
||||||
|
{"malformed digest", "repo:v1", "sha256:abc", "repo:v1"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, c.want, RunningRef(c.actualRef, c.actualDigest))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestJob_Active(t *testing.T) {
|
func TestJob_Active(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
status JobStatus
|
status JobStatus
|
||||||
|
|||||||
@@ -0,0 +1,465 @@
|
|||||||
|
// Package poller checks the registry for new digests of the tags the
|
||||||
|
// services follow. A new digest becomes the service's available version, and
|
||||||
|
// a service in auto mode gets an update job. It also lists the tags of the
|
||||||
|
// repos whose followed tag looks like a version and suggests a newer one;
|
||||||
|
// that suggestion never starts a job.
|
||||||
|
package poller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shcizo/package-updater/internal/imageref"
|
||||||
|
"github.com/shcizo/package-updater/internal/master/events"
|
||||||
|
"github.com/shcizo/package-updater/internal/master/jobs"
|
||||||
|
"github.com/shcizo/package-updater/internal/master/model"
|
||||||
|
"github.com/shcizo/package-updater/internal/master/tagver"
|
||||||
|
)
|
||||||
|
|
||||||
|
// lookupTimeout bounds one registry lookup.
|
||||||
|
const lookupTimeout = 30 * time.Second
|
||||||
|
|
||||||
|
// listTimeout bounds one tag listing, which may take several requests.
|
||||||
|
const listTimeout = 60 * time.Second
|
||||||
|
|
||||||
|
// maxErrorBytes caps the registry error stored in services.last_poll_error.
|
||||||
|
const maxErrorBytes = 500
|
||||||
|
|
||||||
|
// Store is the storage the poller needs. *store.Store satisfies it.
|
||||||
|
type Store interface {
|
||||||
|
ListServices(ctx context.Context) ([]model.Service, error)
|
||||||
|
GetService(ctx context.Context, id int64) (model.Service, error)
|
||||||
|
SetAvailable(ctx context.Context, id int64, a model.Available) error
|
||||||
|
SetLastTagDigest(ctx context.Context, id int64, digest string) error
|
||||||
|
SetNewer(ctx context.Context, id int64, ref string, at time.Time) error
|
||||||
|
SetPollError(ctx context.Context, ids []int64, msg string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jobs starts updates. *jobs.Service satisfies it.
|
||||||
|
type Jobs interface {
|
||||||
|
Start(ctx context.Context, svc model.Service, req jobs.StartRequest) (model.Job, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registry looks up the digest a "repo:tag" ref points at and lists the tags
|
||||||
|
// of a repo. *registry.Client satisfies it.
|
||||||
|
type Registry interface {
|
||||||
|
Digest(ctx context.Context, ref string) (string, error)
|
||||||
|
Tags(ctx context.Context, repo string) ([]string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publisher receives change signals. *events.Broker satisfies it.
|
||||||
|
type Publisher interface{ Publish(t events.Topic) }
|
||||||
|
|
||||||
|
// Config holds the poller settings.
|
||||||
|
type Config struct {
|
||||||
|
Interval time.Duration // time between the end of a cycle and the start of the next (POLL_INTERVAL)
|
||||||
|
FirstDelay time.Duration // wait before the first cycle; 1 minute in production
|
||||||
|
Workers int // registry requests in flight at once; 4 in production
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poller runs polling cycles.
|
||||||
|
type Poller struct {
|
||||||
|
store Store
|
||||||
|
jobs Jobs
|
||||||
|
reg Registry
|
||||||
|
pub Publisher
|
||||||
|
cfg Config
|
||||||
|
log *slog.Logger
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a Poller.
|
||||||
|
func New(st Store, j Jobs, reg Registry, pub Publisher, cfg Config, log *slog.Logger) *Poller {
|
||||||
|
if cfg.Workers < 1 {
|
||||||
|
cfg.Workers = 1
|
||||||
|
}
|
||||||
|
return &Poller{store: st, jobs: j, reg: reg, pub: pub, cfg: cfg, log: log, now: time.Now}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SkipReason says why svc is not polled: "follows no tag", "built from
|
||||||
|
// source", "no registry digest", or "" when it is polled.
|
||||||
|
func SkipReason(svc model.Service) string {
|
||||||
|
switch {
|
||||||
|
case jobs.FollowedRef(svc.ActualRef) == "":
|
||||||
|
return model.ReasonNoTag
|
||||||
|
case svc.Built:
|
||||||
|
return "built from source"
|
||||||
|
case svc.ActualDigest == "":
|
||||||
|
return "no registry digest"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run polls until ctx is done. The first cycle starts Config.FirstDelay after
|
||||||
|
// the call and each following one Config.Interval after the previous cycle
|
||||||
|
// ended, so cycles never overlap. It blocks.
|
||||||
|
func (p *Poller) Run(ctx context.Context) {
|
||||||
|
timer := time.NewTimer(p.cfg.FirstDelay)
|
||||||
|
defer timer.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-timer.C:
|
||||||
|
}
|
||||||
|
p.Cycle(ctx)
|
||||||
|
timer.Reset(p.cfg.Interval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookup is the registry's answer for one followed ref.
|
||||||
|
type lookup struct {
|
||||||
|
ref string
|
||||||
|
digest string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// cycleStats is what one cycle did, for the poll_cycle log line.
|
||||||
|
type cycleStats struct {
|
||||||
|
refs, ok, failed, suggested, jobs, skipped int
|
||||||
|
reposListed, tagsFailed, newer int
|
||||||
|
wrote bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cycle runs one poll over all services. It looks up every distinct followed
|
||||||
|
// ref once, with at most Config.Workers lookups at a time, then records the
|
||||||
|
// results. Then it lists the tags of each repo that has a service following a
|
||||||
|
// version-like tag, once per repo, and records newer tags. Writing results and
|
||||||
|
// starting jobs is not cut short by ctx ending during the lookups, so a job
|
||||||
|
// that was marked is always started.
|
||||||
|
func (p *Poller) Cycle(ctx context.Context) {
|
||||||
|
began := p.now()
|
||||||
|
svcs, err := p.store.ListServices(ctx)
|
||||||
|
if err != nil {
|
||||||
|
p.log.Error("poll_list_failed", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var stats cycleStats
|
||||||
|
byRef := map[string][]model.Service{}
|
||||||
|
var refs []string
|
||||||
|
for _, svc := range svcs {
|
||||||
|
if svc.RemovedAt != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if SkipReason(svc) != "" {
|
||||||
|
stats.skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ref := jobs.FollowedRef(svc.ActualRef)
|
||||||
|
if _, seen := byRef[ref]; !seen {
|
||||||
|
refs = append(refs, ref)
|
||||||
|
}
|
||||||
|
byRef[ref] = append(byRef[ref], svc)
|
||||||
|
}
|
||||||
|
stats.refs = len(refs)
|
||||||
|
|
||||||
|
write := context.WithoutCancel(ctx)
|
||||||
|
for _, l := range p.lookupAll(ctx, refs) {
|
||||||
|
if l.err != nil && ctx.Err() != nil {
|
||||||
|
continue // cancelled mid-lookup: not the registry's fault
|
||||||
|
}
|
||||||
|
p.record(write, l, byRef[l.ref], &stats)
|
||||||
|
}
|
||||||
|
// Publish after the digest phase too: listing tags can take a while and
|
||||||
|
// an "Update available" should not wait for it.
|
||||||
|
p.publishIfWrote(&stats)
|
||||||
|
p.syncNewer(ctx, write, svcs, &stats)
|
||||||
|
p.publishIfWrote(&stats)
|
||||||
|
p.log.Info("poll_cycle", "refs", stats.refs, "ok", stats.ok, "failed", stats.failed,
|
||||||
|
"suggested", stats.suggested, "jobs", stats.jobs, "skipped", stats.skipped,
|
||||||
|
"repos_listed", stats.reposListed, "tags_failed", stats.tagsFailed, "newer", stats.newer,
|
||||||
|
"duration_ms", p.now().Sub(began).Milliseconds())
|
||||||
|
}
|
||||||
|
|
||||||
|
// publishIfWrote tells the browsers to refresh if a phase of the cycle wrote
|
||||||
|
// anything, and starts the next phase with a clean slate.
|
||||||
|
func (p *Poller) publishIfWrote(stats *cycleStats) {
|
||||||
|
if stats.wrote {
|
||||||
|
p.pub.Publish(events.Services)
|
||||||
|
stats.wrote = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// forEach calls fn(i) for i in [0, n), Config.Workers at a time, and stops
|
||||||
|
// starting new calls once ctx is done. It returns when all started calls
|
||||||
|
// have returned.
|
||||||
|
func (p *Poller) forEach(ctx context.Context, n int, fn func(i int)) {
|
||||||
|
next := make(chan int)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for w := 0; w < p.cfg.Workers; w++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for i := range next {
|
||||||
|
fn(i)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
feed:
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
select {
|
||||||
|
case next <- i:
|
||||||
|
case <-ctx.Done():
|
||||||
|
break feed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
close(next)
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookupAll asks the registry for every ref, Config.Workers at a time. It
|
||||||
|
// returns the answers in ref order and stops starting new lookups once ctx
|
||||||
|
// is done; refs never looked up are missing from the result.
|
||||||
|
func (p *Poller) lookupAll(ctx context.Context, refs []string) []lookup {
|
||||||
|
results := make([]*lookup, len(refs))
|
||||||
|
p.forEach(ctx, len(refs), func(i int) {
|
||||||
|
lctx, cancel := context.WithTimeout(ctx, lookupTimeout)
|
||||||
|
d, err := p.reg.Digest(lctx, refs[i])
|
||||||
|
cancel()
|
||||||
|
results[i] = &lookup{ref: refs[i], digest: d, err: err}
|
||||||
|
})
|
||||||
|
var out []lookup
|
||||||
|
for _, r := range results {
|
||||||
|
if r != nil {
|
||||||
|
out = append(out, *r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// tagList is the registry's answer for the tags of one repo.
|
||||||
|
type tagList struct {
|
||||||
|
repo string
|
||||||
|
tags []string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// listAll lists the tags of every repo like lookupAll looks up refs.
|
||||||
|
func (p *Poller) listAll(ctx context.Context, repos []string) []tagList {
|
||||||
|
results := make([]*tagList, len(repos))
|
||||||
|
p.forEach(ctx, len(repos), func(i int) {
|
||||||
|
lctx, cancel := context.WithTimeout(ctx, listTimeout)
|
||||||
|
tags, err := p.reg.Tags(lctx, repos[i])
|
||||||
|
cancel()
|
||||||
|
results[i] = &tagList{repo: repos[i], tags: tags, err: err}
|
||||||
|
})
|
||||||
|
var out []tagList
|
||||||
|
for _, r := range results {
|
||||||
|
if r != nil {
|
||||||
|
out = append(out, *r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Followed returns the repo and tag svc follows: a bare image follows
|
||||||
|
// "latest", and a digest-only reference follows no tag.
|
||||||
|
func Followed(svc model.Service) (repo, tag string) {
|
||||||
|
ref := imageref.Parse(jobs.FollowedRef(svc.ActualRef))
|
||||||
|
return ref.Repo, ref.Tag
|
||||||
|
}
|
||||||
|
|
||||||
|
// followedComparable returns the repo and tag svc follows. ok is false unless
|
||||||
|
// svc is polled and the tag can have a newer tag.
|
||||||
|
func followedComparable(svc model.Service) (repo, tag string, ok bool) {
|
||||||
|
if SkipReason(svc) != "" {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
repo, tag = Followed(svc)
|
||||||
|
return repo, tag, tagver.Comparable(tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncNewer lists the tags of every repo that has a service following a
|
||||||
|
// version-like tag, once per repo, and stores the newer tag of each service.
|
||||||
|
// A service whose tag cannot have a newer tag any more gets its suggestion
|
||||||
|
// cleared; one whose repo could not be listed keeps it, unless it names
|
||||||
|
// another repo than the service follows now. Nothing is written
|
||||||
|
// when the value is unchanged. It never starts a job, not even for an auto
|
||||||
|
// service: a newer tag may be a major step the user has not chosen.
|
||||||
|
func (p *Poller) syncNewer(ctx, write context.Context, svcs []model.Service, stats *cycleStats) {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var repos []string
|
||||||
|
for _, svc := range svcs {
|
||||||
|
if repo, _, ok := followedComparable(svc); ok && svc.RemovedAt == nil && !seen[repo] {
|
||||||
|
seen[repo] = true
|
||||||
|
repos = append(repos, repo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lists := map[string][]string{}
|
||||||
|
for _, l := range p.listAll(ctx, repos) {
|
||||||
|
if l.err != nil {
|
||||||
|
p.log.Warn("poll_tags_failed", "repo", l.repo, "error", truncate(l.err.Error()))
|
||||||
|
if ctx.Err() == nil { // cancelled mid-listing: not the registry's fault
|
||||||
|
stats.tagsFailed++
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stats.reposListed++
|
||||||
|
lists[l.repo] = l.tags
|
||||||
|
}
|
||||||
|
|
||||||
|
type repoTag struct{ repo, tag string }
|
||||||
|
newest := map[repoTag]string{}
|
||||||
|
for _, svc := range svcs {
|
||||||
|
if svc.RemovedAt != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
want := ""
|
||||||
|
if repo, tag, ok := followedComparable(svc); ok {
|
||||||
|
tags, listed := lists[repo]
|
||||||
|
if !listed && imageref.Parse(svc.NewerRef).Repo == repo {
|
||||||
|
if svc.NewerRef != "" {
|
||||||
|
stats.newer++
|
||||||
|
}
|
||||||
|
continue // not listed this cycle: keep what is stored
|
||||||
|
}
|
||||||
|
if listed {
|
||||||
|
key := repoTag{repo, tag}
|
||||||
|
n, done := newest[key]
|
||||||
|
if !done {
|
||||||
|
n, _ = tagver.Newer(tag, tags)
|
||||||
|
newest[key] = n
|
||||||
|
}
|
||||||
|
if n != "" {
|
||||||
|
want = repo + ":" + n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if want != "" {
|
||||||
|
stats.newer++
|
||||||
|
}
|
||||||
|
if want == svc.NewerRef {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := p.store.SetNewer(write, svc.ID, want, p.now()); err != nil {
|
||||||
|
p.log.Error("poll_set_newer_failed", "service_id", svc.ID, "ref", want, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stats.wrote = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// record stores one lookup result for the services that follow l.ref.
|
||||||
|
func (p *Poller) record(ctx context.Context, l lookup, svcs []model.Service, stats *cycleStats) {
|
||||||
|
if l.err != nil {
|
||||||
|
stats.failed++
|
||||||
|
msg := truncate(l.err.Error())
|
||||||
|
p.log.Warn("poll_failed", "ref", l.ref, "error", msg)
|
||||||
|
p.setPollError(ctx, svcs, msg, stats, func(s model.Service) bool { return s.LastPollError != msg })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stats.ok++
|
||||||
|
p.setPollError(ctx, svcs, "", stats, func(s model.Service) bool { return s.LastPollError != "" })
|
||||||
|
for _, svc := range svcs {
|
||||||
|
if !p.suggest(ctx, svc, l, stats) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if autoWanted(svc, l.ref, l.digest) {
|
||||||
|
p.startAuto(ctx, svc.ID, l.ref, l.digest, stats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// suggest marks l.digest as the available version of svc unless the service
|
||||||
|
// already runs it or already has exactly that suggestion. A service that
|
||||||
|
// runs l.digest also gets it as last_tag_digest. It returns false only when
|
||||||
|
// writing the suggestion failed.
|
||||||
|
func (p *Poller) suggest(ctx context.Context, svc model.Service, l lookup, stats *cycleStats) bool {
|
||||||
|
if svc.ActualDigest == l.digest && svc.LastTagDigest != l.digest {
|
||||||
|
// Seed last_tag_digest for a service that was not updated by a tag job
|
||||||
|
// (it predates the column or was updated by hand), so that a later
|
||||||
|
// rollback is not undone.
|
||||||
|
if err := p.store.SetLastTagDigest(ctx, svc.ID, l.digest); err != nil {
|
||||||
|
p.log.Error("poll_set_last_tag_digest_failed", "service_id", svc.ID, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if svc.ActualDigest == l.digest || (svc.AvailableRef == l.ref && svc.AvailableDigest == l.digest) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
err := p.store.SetAvailable(ctx, svc.ID, model.Available{Ref: l.ref, Digest: l.digest, Source: model.SourcePoll, At: p.now()})
|
||||||
|
if err != nil {
|
||||||
|
p.log.Error("poll_set_available_failed", "service_id", svc.ID, "ref", l.ref, "error", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
stats.wrote = true
|
||||||
|
stats.suggested++
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoWanted reports whether an update of svc to ref at digest should be
|
||||||
|
// started automatically: the service is in auto mode, not removed, does not
|
||||||
|
// already run that digest, was not already updated automatically to it
|
||||||
|
// (otherwise a rollback to an older version would be undone at the next
|
||||||
|
// cycle), and its last job did not already deploy it (desired_ref holds the
|
||||||
|
// digest until the agent reports the new inventory).
|
||||||
|
func autoWanted(svc model.Service, ref, digest string) bool {
|
||||||
|
return svc.RemovedAt == nil &&
|
||||||
|
svc.UpdateMode == model.ModeAuto &&
|
||||||
|
svc.ActualDigest != digest &&
|
||||||
|
svc.LastTagDigest != digest &&
|
||||||
|
svc.DesiredRef != ref+"@"+digest
|
||||||
|
}
|
||||||
|
|
||||||
|
// startAuto starts the update job of an auto service to ref and remembers
|
||||||
|
// digest as services.last_tag_digest, so the same digest is not started
|
||||||
|
// again, for instance after the user rolled back. It re-reads the service
|
||||||
|
// first, because a cycle can run for a long time and the user may have
|
||||||
|
// switched the service to manual meanwhile. A service that already has a
|
||||||
|
// job or cannot be updated is not an error for the cycle, and last_tag_digest
|
||||||
|
// is left alone so the next cycle tries again.
|
||||||
|
func (p *Poller) startAuto(ctx context.Context, id int64, ref, digest string, stats *cycleStats) {
|
||||||
|
svc, err := p.store.GetService(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
p.log.Warn("poll_get_service_failed", "service_id", id, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !autoWanted(svc, ref, digest) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, err = p.jobs.Start(ctx, svc, jobs.StartRequest{Trigger: model.TriggerPoll, Ref: ref})
|
||||||
|
var notUpdatable *model.NotUpdatableError
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
stats.jobs++
|
||||||
|
if err := p.store.SetLastTagDigest(ctx, id, digest); err != nil {
|
||||||
|
p.log.Error("poll_set_last_tag_digest_failed", "service_id", id, "error", err)
|
||||||
|
}
|
||||||
|
case errors.Is(err, model.ErrJobActive), errors.As(err, ¬Updatable):
|
||||||
|
p.log.Debug("poll_job_not_started", "service_id", id, "ref", ref, "reason", err.Error())
|
||||||
|
default:
|
||||||
|
p.log.Warn("poll_job_failed", "service_id", id, "ref", ref, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setPollError sets msg as last_poll_error on the services for which
|
||||||
|
// changed returns true; with none, it writes nothing.
|
||||||
|
func (p *Poller) setPollError(ctx context.Context, svcs []model.Service, msg string, stats *cycleStats, changed func(model.Service) bool) {
|
||||||
|
var ids []int64
|
||||||
|
for _, svc := range svcs {
|
||||||
|
if changed(svc) {
|
||||||
|
ids = append(ids, svc.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := p.store.SetPollError(ctx, ids, msg); err != nil {
|
||||||
|
p.log.Error("poll_set_error_failed", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stats.wrote = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncate cuts msg to maxErrorBytes without leaving a broken UTF-8
|
||||||
|
// sequence, and drops NUL bytes, which Postgres text cannot hold.
|
||||||
|
func truncate(msg string) string {
|
||||||
|
msg = strings.ReplaceAll(msg, "\x00", "")
|
||||||
|
if len(msg) > maxErrorBytes {
|
||||||
|
msg = strings.ToValidUTF8(msg[:maxErrorBytes], "")
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
}
|
||||||
@@ -0,0 +1,922 @@
|
|||||||
|
package poller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/shcizo/package-updater/internal/master/events"
|
||||||
|
"github.com/shcizo/package-updater/internal/master/jobs"
|
||||||
|
"github.com/shcizo/package-updater/internal/master/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type availableCall struct {
|
||||||
|
id int64
|
||||||
|
a model.Available
|
||||||
|
}
|
||||||
|
|
||||||
|
type pollErrorCall struct {
|
||||||
|
ids []int64
|
||||||
|
msg string
|
||||||
|
}
|
||||||
|
|
||||||
|
type newerCall struct {
|
||||||
|
id int64
|
||||||
|
ref string
|
||||||
|
at time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type lastTagCall struct {
|
||||||
|
id int64
|
||||||
|
digest string
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
services []model.Service
|
||||||
|
fresh map[int64]model.Service // what GetService returns instead of the listed row
|
||||||
|
lastTag []lastTagCall
|
||||||
|
newer []newerCall
|
||||||
|
listErr error
|
||||||
|
available []availableCall
|
||||||
|
pollErrors []pollErrorCall
|
||||||
|
ctxErrs []error // ctx.Err() seen by every write
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) ListServices(context.Context) ([]model.Service, error) {
|
||||||
|
return f.services, f.listErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) GetService(_ context.Context, id int64) (model.Service, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if s, ok := f.fresh[id]; ok {
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
for _, s := range f.services {
|
||||||
|
if s.ID == id {
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return model.Service{}, model.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLastTagDigest records the call and, like the database, changes the
|
||||||
|
// row the next cycle lists.
|
||||||
|
func (f *fakeStore) SetLastTagDigest(_ context.Context, id int64, digest string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.lastTag = append(f.lastTag, lastTagCall{id, digest})
|
||||||
|
for i := range f.services {
|
||||||
|
if f.services[i].ID == id {
|
||||||
|
f.services[i].LastTagDigest = digest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) SetAvailable(ctx context.Context, id int64, a model.Available) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.ctxErrs = append(f.ctxErrs, ctx.Err())
|
||||||
|
f.available = append(f.available, availableCall{id, a})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetNewer records the call and, like the database, changes the row the
|
||||||
|
// next cycle lists.
|
||||||
|
func (f *fakeStore) SetNewer(ctx context.Context, id int64, ref string, at time.Time) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.ctxErrs = append(f.ctxErrs, ctx.Err())
|
||||||
|
f.newer = append(f.newer, newerCall{id, ref, at})
|
||||||
|
for i := range f.services {
|
||||||
|
if f.services[i].ID == id {
|
||||||
|
f.services[i].NewerRef = ref
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) SetPollError(ctx context.Context, ids []int64, msg string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.ctxErrs = append(f.ctxErrs, ctx.Err())
|
||||||
|
f.pollErrors = append(f.pollErrors, pollErrorCall{ids, msg})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type startCall struct {
|
||||||
|
svcID int64
|
||||||
|
req jobs.StartRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeJobs struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
starts []startCall
|
||||||
|
errFor map[int64]error // by service ID
|
||||||
|
ctxErrs []error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeJobs) Start(ctx context.Context, svc model.Service, req jobs.StartRequest) (model.Job, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.ctxErrs = append(f.ctxErrs, ctx.Err())
|
||||||
|
f.starts = append(f.starts, startCall{svc.ID, req})
|
||||||
|
return model.Job{ID: int64(len(f.starts))}, f.errFor[svc.ID]
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeRegistry struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
digests map[string]string // ref -> digest
|
||||||
|
errs map[string]error // ref -> error
|
||||||
|
calls []string
|
||||||
|
inFlight int
|
||||||
|
maxSeen int
|
||||||
|
delay time.Duration
|
||||||
|
started chan struct{} // receives a value whenever a lookup starts, if set
|
||||||
|
release chan struct{} // lookups wait for it to be closed, if set
|
||||||
|
onResult func() // called after a lookup computed its answer
|
||||||
|
|
||||||
|
tags map[string][]string // repo -> tags
|
||||||
|
tagErrs map[string]error // repo -> error
|
||||||
|
tagCalls []string
|
||||||
|
tagStarted chan struct{} // receives a value whenever a tag listing starts, if set
|
||||||
|
tagRelease chan struct{} // tag listings wait for it to be closed, if set
|
||||||
|
onTags func() // called after a tag listing computed its answer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRegistry) Tags(ctx context.Context, repo string) ([]string, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
f.tagCalls = append(f.tagCalls, repo)
|
||||||
|
f.mu.Unlock()
|
||||||
|
if f.tagStarted != nil {
|
||||||
|
f.tagStarted <- struct{}{}
|
||||||
|
}
|
||||||
|
if f.tagRelease != nil {
|
||||||
|
select {
|
||||||
|
case <-f.tagRelease:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if f.onTags != nil {
|
||||||
|
f.onTags()
|
||||||
|
}
|
||||||
|
if err := f.tagErrs[repo]; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return f.tags[repo], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRegistry) Digest(ctx context.Context, ref string) (string, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
f.calls = append(f.calls, ref)
|
||||||
|
f.inFlight++
|
||||||
|
if f.inFlight > f.maxSeen {
|
||||||
|
f.maxSeen = f.inFlight
|
||||||
|
}
|
||||||
|
f.mu.Unlock()
|
||||||
|
defer func() {
|
||||||
|
f.mu.Lock()
|
||||||
|
f.inFlight--
|
||||||
|
f.mu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
if f.started != nil {
|
||||||
|
f.started <- struct{}{}
|
||||||
|
}
|
||||||
|
if f.release != nil {
|
||||||
|
select {
|
||||||
|
case <-f.release:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return "", ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if f.delay > 0 {
|
||||||
|
time.Sleep(f.delay)
|
||||||
|
}
|
||||||
|
if f.onResult != nil {
|
||||||
|
f.onResult()
|
||||||
|
}
|
||||||
|
if err := f.errs[ref]; err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return f.digests[ref], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRegistry) sortedCalls() []string {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
out := append([]string(nil), f.calls...)
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakePub struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
topics []events.Topic
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *fakePub) Publish(t events.Topic) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
p.topics = append(p.topics, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
repoA = "gitea.shcizo.se/shcizo/slusk"
|
||||||
|
repoB = "gitea/gitea"
|
||||||
|
)
|
||||||
|
|
||||||
|
// svc builds a polled service whose running version has digest sha256:old.
|
||||||
|
func svc(id int64, actualRef string) model.Service {
|
||||||
|
return model.Service{ID: id, Kind: "compose", ActualRef: actualRef, ActualDigest: "sha256:old", UpdateMode: model.ModeManual}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPoller(st *fakeStore, j *fakeJobs, reg *fakeRegistry, cfg Config) (*Poller, *fakePub) {
|
||||||
|
pub := &fakePub{}
|
||||||
|
if cfg.Workers == 0 {
|
||||||
|
cfg.Workers = 4
|
||||||
|
}
|
||||||
|
p := New(st, j, reg, pub, cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
|
p.now = func() time.Time { return time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC) }
|
||||||
|
return p, pub
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkipReason(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
svc model.Service
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"tag and digest", model.Service{ActualRef: "r:1", ActualDigest: "sha256:a"}, ""},
|
||||||
|
{"bare image follows latest", model.Service{ActualRef: "r", ActualDigest: "sha256:a"}, ""},
|
||||||
|
{"digest only follows no tag", model.Service{ActualRef: "r@sha256:a", ActualDigest: "sha256:a"}, model.ReasonNoTag},
|
||||||
|
{"built", model.Service{ActualRef: "r:1", ActualDigest: "sha256:a", Built: true}, "built from source"},
|
||||||
|
{"no digest", model.Service{ActualRef: "r:1"}, "no registry digest"},
|
||||||
|
{"self and container are polled", model.Service{ActualRef: "r:1", ActualDigest: "sha256:a", Self: true, Kind: "container"}, ""},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.want, SkipReason(tt.svc))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOneHeadPerDistinctRef(t *testing.T) {
|
||||||
|
st := &fakeStore{services: []model.Service{
|
||||||
|
svc(1, repoA+":1.4"),
|
||||||
|
svc(2, repoA+":1.4@sha256:old"),
|
||||||
|
svc(3, repoB),
|
||||||
|
svc(4, repoB+":latest"),
|
||||||
|
svc(5, repoB+":1.27"),
|
||||||
|
}}
|
||||||
|
reg := &fakeRegistry{digests: map[string]string{}}
|
||||||
|
p, _ := newPoller(st, &fakeJobs{}, reg, Config{})
|
||||||
|
|
||||||
|
p.Cycle(context.Background())
|
||||||
|
|
||||||
|
assert.Equal(t, []string{repoA + ":1.4", repoB + ":1.27", repoB + ":latest"}, reg.sortedCalls())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkipped(t *testing.T) {
|
||||||
|
removed := svc(4, "r:1")
|
||||||
|
removed.RemovedAt = ptr(time.Now())
|
||||||
|
st := &fakeStore{services: []model.Service{
|
||||||
|
{ID: 1, ActualRef: "r@sha256:a", ActualDigest: "sha256:a"},
|
||||||
|
{ID: 2, ActualRef: "r:1", ActualDigest: "sha256:a", Built: true},
|
||||||
|
{ID: 3, ActualRef: "r:1"},
|
||||||
|
removed,
|
||||||
|
}}
|
||||||
|
reg := &fakeRegistry{}
|
||||||
|
j := &fakeJobs{}
|
||||||
|
p, pub := newPoller(st, j, reg, Config{})
|
||||||
|
|
||||||
|
p.Cycle(context.Background())
|
||||||
|
|
||||||
|
assert.Empty(t, reg.calls, "no lookups")
|
||||||
|
assert.Empty(t, st.available)
|
||||||
|
assert.Empty(t, st.pollErrors)
|
||||||
|
assert.Empty(t, j.starts)
|
||||||
|
assert.Empty(t, pub.topics)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewDigest(t *testing.T) {
|
||||||
|
ref := repoA + ":1.4"
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
mode string
|
||||||
|
wantStart bool
|
||||||
|
}{
|
||||||
|
{"auto gets one poll job to repo:tag", model.ModeAuto, true},
|
||||||
|
{"manual is only marked", model.ModeManual, false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
s := svc(1, ref+"@sha256:old")
|
||||||
|
s.UpdateMode = tt.mode
|
||||||
|
st := &fakeStore{services: []model.Service{s}}
|
||||||
|
reg := &fakeRegistry{digests: map[string]string{ref: "sha256:new"}}
|
||||||
|
j := &fakeJobs{}
|
||||||
|
p, pub := newPoller(st, j, reg, Config{})
|
||||||
|
|
||||||
|
p.Cycle(context.Background())
|
||||||
|
|
||||||
|
require.Len(t, st.available, 1)
|
||||||
|
assert.Equal(t, int64(1), st.available[0].id)
|
||||||
|
assert.Equal(t, model.Available{Ref: ref, Digest: "sha256:new", Source: model.SourcePoll,
|
||||||
|
At: time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)}, st.available[0].a)
|
||||||
|
if tt.wantStart {
|
||||||
|
assert.Equal(t, []startCall{{1, jobs.StartRequest{Trigger: model.TriggerPoll, Ref: ref}}}, j.starts)
|
||||||
|
assert.Equal(t, []lastTagCall{{1, "sha256:new"}}, st.lastTag)
|
||||||
|
} else {
|
||||||
|
assert.Empty(t, j.starts)
|
||||||
|
assert.Empty(t, st.lastTag)
|
||||||
|
}
|
||||||
|
assert.Equal(t, []events.Topic{events.Services}, pub.topics)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSameSuggestionIsIdempotent(t *testing.T) {
|
||||||
|
ref := repoA + ":1.4"
|
||||||
|
running := svc(1, ref+"@sha256:old")
|
||||||
|
running.UpdateMode = model.ModeAuto
|
||||||
|
running.ActualDigest = "sha256:new" // already on the registry's digest
|
||||||
|
suggested := svc(2, ref)
|
||||||
|
suggested.UpdateMode = model.ModeAuto
|
||||||
|
suggested.AvailableRef, suggested.AvailableDigest = ref, "sha256:new"
|
||||||
|
suggested.LastTagDigest = "sha256:new" // its job was started in an earlier cycle
|
||||||
|
staleSuggestion := svc(3, ref)
|
||||||
|
staleSuggestion.AvailableRef, staleSuggestion.AvailableDigest = ref, "sha256:older"
|
||||||
|
st := &fakeStore{services: []model.Service{running, suggested, staleSuggestion}}
|
||||||
|
reg := &fakeRegistry{digests: map[string]string{ref: "sha256:new"}}
|
||||||
|
j := &fakeJobs{}
|
||||||
|
p, _ := newPoller(st, j, reg, Config{})
|
||||||
|
|
||||||
|
p.Cycle(context.Background())
|
||||||
|
|
||||||
|
require.Len(t, st.available, 1, "only the service with an older suggestion is updated")
|
||||||
|
assert.Equal(t, int64(3), st.available[0].id)
|
||||||
|
assert.Empty(t, j.starts, "no new job for an already suggested digest")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartErrorsDoNotAbortCycle(t *testing.T) {
|
||||||
|
ref := repoA + ":1.4"
|
||||||
|
var svcs []model.Service
|
||||||
|
for id := int64(1); id <= 3; id++ {
|
||||||
|
s := svc(id, ref)
|
||||||
|
s.UpdateMode = model.ModeAuto
|
||||||
|
svcs = append(svcs, s)
|
||||||
|
}
|
||||||
|
st := &fakeStore{services: svcs}
|
||||||
|
reg := &fakeRegistry{digests: map[string]string{ref: "sha256:new"}}
|
||||||
|
j := &fakeJobs{errFor: map[int64]error{
|
||||||
|
1: model.ErrJobActive,
|
||||||
|
2: &model.NotUpdatableError{Reason: model.ReasonSelf},
|
||||||
|
3: errors.New("database down"),
|
||||||
|
}}
|
||||||
|
p, _ := newPoller(st, j, reg, Config{})
|
||||||
|
|
||||||
|
p.Cycle(context.Background())
|
||||||
|
|
||||||
|
assert.Len(t, st.available, 3)
|
||||||
|
assert.Len(t, j.starts, 3)
|
||||||
|
assert.Empty(t, st.lastTag, "a job that was not created is retried next cycle")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRollbackOfAutoServiceIsNotUndone(t *testing.T) {
|
||||||
|
ref := repoA + ":main"
|
||||||
|
// Updated to sha256:new automatically, then rolled back to sha256:old by hand.
|
||||||
|
s := svc(1, ref+"@sha256:old")
|
||||||
|
s.UpdateMode = model.ModeAuto
|
||||||
|
s.LastTagDigest = "sha256:new"
|
||||||
|
s.DesiredRef = ref + "@sha256:old"
|
||||||
|
st := &fakeStore{services: []model.Service{s}}
|
||||||
|
reg := &fakeRegistry{digests: map[string]string{ref: "sha256:new"}}
|
||||||
|
j := &fakeJobs{}
|
||||||
|
p, _ := newPoller(st, j, reg, Config{})
|
||||||
|
|
||||||
|
p.Cycle(context.Background())
|
||||||
|
p.Cycle(context.Background())
|
||||||
|
|
||||||
|
assert.Empty(t, j.starts, "same registry digest: the rollback stays")
|
||||||
|
assert.Equal(t, "sha256:new", st.available[0].a.Digest, "the newer version is still offered")
|
||||||
|
|
||||||
|
reg.digests[ref] = "sha256:newer"
|
||||||
|
p.Cycle(context.Background())
|
||||||
|
|
||||||
|
assert.Equal(t, []startCall{{1, jobs.StartRequest{Trigger: model.TriggerPoll, Ref: ref}}}, j.starts, "a new digest moves it again")
|
||||||
|
assert.Equal(t, []lastTagCall{{1, "sha256:newer"}}, st.lastTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActiveJobIsRetriedNextCycle(t *testing.T) {
|
||||||
|
ref := repoA + ":1.4"
|
||||||
|
s := svc(1, ref)
|
||||||
|
s.UpdateMode = model.ModeAuto
|
||||||
|
st := &fakeStore{services: []model.Service{s}}
|
||||||
|
reg := &fakeRegistry{digests: map[string]string{ref: "sha256:new"}}
|
||||||
|
j := &fakeJobs{errFor: map[int64]error{1: model.ErrJobActive}}
|
||||||
|
p, _ := newPoller(st, j, reg, Config{})
|
||||||
|
|
||||||
|
p.Cycle(context.Background())
|
||||||
|
require.Len(t, j.starts, 1)
|
||||||
|
assert.Empty(t, st.lastTag)
|
||||||
|
|
||||||
|
j.errFor = nil
|
||||||
|
p.Cycle(context.Background())
|
||||||
|
|
||||||
|
assert.Len(t, j.starts, 2)
|
||||||
|
assert.Equal(t, []lastTagCall{{1, "sha256:new"}}, st.lastTag)
|
||||||
|
|
||||||
|
p.Cycle(context.Background())
|
||||||
|
assert.Len(t, j.starts, 2, "started once the job exists")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJobThatAlreadyDeployedTheDigestIsNotStartedAgain(t *testing.T) {
|
||||||
|
ref := repoA + ":1.4"
|
||||||
|
// FinishJob set desired_ref; the agent has not reported the new digest yet.
|
||||||
|
s := svc(1, ref+"@sha256:old")
|
||||||
|
s.UpdateMode = model.ModeAuto
|
||||||
|
s.DesiredRef = ref + "@sha256:new"
|
||||||
|
st := &fakeStore{services: []model.Service{s}}
|
||||||
|
reg := &fakeRegistry{digests: map[string]string{ref: "sha256:new"}}
|
||||||
|
j := &fakeJobs{}
|
||||||
|
p, _ := newPoller(st, j, reg, Config{})
|
||||||
|
|
||||||
|
p.Cycle(context.Background())
|
||||||
|
|
||||||
|
assert.Empty(t, j.starts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceSwitchedToManualDuringTheCycleIsNotStarted(t *testing.T) {
|
||||||
|
ref := repoA + ":1.4"
|
||||||
|
s := svc(1, ref)
|
||||||
|
s.UpdateMode = model.ModeAuto
|
||||||
|
manual := s
|
||||||
|
manual.UpdateMode = model.ModeManual
|
||||||
|
st := &fakeStore{services: []model.Service{s}, fresh: map[int64]model.Service{1: manual}}
|
||||||
|
reg := &fakeRegistry{digests: map[string]string{ref: "sha256:new"}}
|
||||||
|
j := &fakeJobs{}
|
||||||
|
p, _ := newPoller(st, j, reg, Config{})
|
||||||
|
|
||||||
|
p.Cycle(context.Background())
|
||||||
|
|
||||||
|
assert.Len(t, st.available, 1, "still suggested")
|
||||||
|
assert.Empty(t, j.starts)
|
||||||
|
assert.Empty(t, st.lastTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorIsPerRef(t *testing.T) {
|
||||||
|
bad, good := repoA+":1.4", repoB+":1.27"
|
||||||
|
badOld := svc(1, bad)
|
||||||
|
badSame := svc(2, bad)
|
||||||
|
badSame.LastPollError = "head: 401 unauthorized"
|
||||||
|
goodSvc := svc(3, good)
|
||||||
|
st := &fakeStore{services: []model.Service{badOld, badSame, goodSvc}}
|
||||||
|
reg := &fakeRegistry{
|
||||||
|
errs: map[string]error{bad: errors.New("head: 401 unauthorized")},
|
||||||
|
digests: map[string]string{good: "sha256:new"},
|
||||||
|
}
|
||||||
|
p, pub := newPoller(st, &fakeJobs{}, reg, Config{})
|
||||||
|
|
||||||
|
p.Cycle(context.Background())
|
||||||
|
|
||||||
|
assert.Equal(t, []pollErrorCall{{[]int64{1}, "head: 401 unauthorized"}}, st.pollErrors,
|
||||||
|
"only the service whose stored error differs is written")
|
||||||
|
require.Len(t, st.available, 1, "the other ref is still processed")
|
||||||
|
assert.Equal(t, int64(3), st.available[0].id)
|
||||||
|
assert.Equal(t, []events.Topic{events.Services}, pub.topics)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorTextIsTruncated(t *testing.T) {
|
||||||
|
ref := repoA + ":1.4"
|
||||||
|
st := &fakeStore{services: []model.Service{svc(1, ref)}}
|
||||||
|
reg := &fakeRegistry{errs: map[string]error{ref: errors.New(strings.Repeat("é", 400) + "\x00")}}
|
||||||
|
p, _ := newPoller(st, &fakeJobs{}, reg, Config{})
|
||||||
|
|
||||||
|
p.Cycle(context.Background())
|
||||||
|
|
||||||
|
require.Len(t, st.pollErrors, 1)
|
||||||
|
msg := st.pollErrors[0].msg
|
||||||
|
assert.LessOrEqual(t, len(msg), maxErrorBytes)
|
||||||
|
assert.Equal(t, strings.Repeat("é", 250), msg, "cut on a character boundary, NUL removed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorClearedOnSuccess(t *testing.T) {
|
||||||
|
ref := repoA + ":1.4"
|
||||||
|
failing := svc(1, ref)
|
||||||
|
failing.LastPollError = "head: boom"
|
||||||
|
fine := svc(2, ref)
|
||||||
|
failing.ActualDigest, fine.ActualDigest = "sha256:new", "sha256:new"
|
||||||
|
st := &fakeStore{services: []model.Service{failing, fine}}
|
||||||
|
reg := &fakeRegistry{digests: map[string]string{ref: "sha256:new"}}
|
||||||
|
p, pub := newPoller(st, &fakeJobs{}, reg, Config{})
|
||||||
|
|
||||||
|
p.Cycle(context.Background())
|
||||||
|
|
||||||
|
assert.Equal(t, []pollErrorCall{{[]int64{1}, ""}}, st.pollErrors)
|
||||||
|
assert.Empty(t, st.available)
|
||||||
|
assert.Equal(t, []events.Topic{events.Services}, pub.topics)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNothingWrittenNothingPublished(t *testing.T) {
|
||||||
|
ref := repoA + ":1.4"
|
||||||
|
s := svc(1, ref)
|
||||||
|
s.ActualDigest = "sha256:new"
|
||||||
|
st := &fakeStore{services: []model.Service{s}}
|
||||||
|
reg := &fakeRegistry{digests: map[string]string{ref: "sha256:new"}}
|
||||||
|
p, pub := newPoller(st, &fakeJobs{}, reg, Config{})
|
||||||
|
|
||||||
|
p.Cycle(context.Background())
|
||||||
|
|
||||||
|
assert.Empty(t, st.pollErrors)
|
||||||
|
assert.Empty(t, st.available)
|
||||||
|
assert.Empty(t, pub.topics)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListErrorEndsTheCycle(t *testing.T) {
|
||||||
|
st := &fakeStore{listErr: errors.New("db down")}
|
||||||
|
reg := &fakeRegistry{}
|
||||||
|
p, _ := newPoller(st, &fakeJobs{}, reg, Config{})
|
||||||
|
|
||||||
|
p.Cycle(context.Background())
|
||||||
|
|
||||||
|
assert.Empty(t, reg.calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConcurrencyLimit(t *testing.T) {
|
||||||
|
const workers = 3
|
||||||
|
var svcs []model.Service
|
||||||
|
for i := 0; i < 8; i++ {
|
||||||
|
svcs = append(svcs, svc(int64(i+1), fmt.Sprintf("%s%d:1", repoA, i)))
|
||||||
|
}
|
||||||
|
st := &fakeStore{services: svcs}
|
||||||
|
reg := &fakeRegistry{release: make(chan struct{}), started: make(chan struct{}, 8)}
|
||||||
|
p, _ := newPoller(st, &fakeJobs{}, reg, Config{Workers: workers})
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() { p.Cycle(context.Background()); close(done) }()
|
||||||
|
|
||||||
|
for i := 0; i < workers; i++ {
|
||||||
|
select {
|
||||||
|
case <-reg.started:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatalf("only %d lookups started, want %d", i, workers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-reg.started:
|
||||||
|
t.Fatal("more lookups in flight than Workers")
|
||||||
|
case <-time.After(100 * time.Millisecond):
|
||||||
|
}
|
||||||
|
close(reg.release)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("Cycle did not finish")
|
||||||
|
}
|
||||||
|
assert.Len(t, reg.calls, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCycleStopsOnCancel(t *testing.T) {
|
||||||
|
var svcs []model.Service
|
||||||
|
for i := 0; i < 6; i++ {
|
||||||
|
svcs = append(svcs, svc(int64(i+1), fmt.Sprintf("%s%d:1", repoA, i)))
|
||||||
|
}
|
||||||
|
st := &fakeStore{services: svcs}
|
||||||
|
reg := &fakeRegistry{release: make(chan struct{}), started: make(chan struct{}, 6)}
|
||||||
|
p, pub := newPoller(st, &fakeJobs{}, reg, Config{Workers: 2})
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() { p.Cycle(ctx); close(done) }()
|
||||||
|
<-reg.started
|
||||||
|
<-reg.started
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("Cycle did not return after cancel")
|
||||||
|
}
|
||||||
|
assert.Len(t, reg.calls, 2, "no new lookups after cancel")
|
||||||
|
assert.Empty(t, st.pollErrors, "cancelled lookups are not recorded as registry errors")
|
||||||
|
assert.Empty(t, st.available)
|
||||||
|
assert.Empty(t, pub.topics)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarkedJobsAreStartedEvenIfCancelledDuringTheCycle(t *testing.T) {
|
||||||
|
ref := repoA + ":1.4"
|
||||||
|
s := svc(1, ref)
|
||||||
|
s.UpdateMode = model.ModeAuto
|
||||||
|
st := &fakeStore{services: []model.Service{s}}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
reg := &fakeRegistry{digests: map[string]string{ref: "sha256:new"}, onResult: cancel}
|
||||||
|
j := &fakeJobs{}
|
||||||
|
p, _ := newPoller(st, j, reg, Config{})
|
||||||
|
|
||||||
|
p.Cycle(ctx)
|
||||||
|
|
||||||
|
require.Len(t, st.available, 1)
|
||||||
|
require.Len(t, j.starts, 1)
|
||||||
|
assert.NoError(t, st.ctxErrs[0], "writes do not see the cancelled context")
|
||||||
|
assert.NoError(t, j.ctxErrs[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRun(t *testing.T) {
|
||||||
|
ref := repoA + ":1.4"
|
||||||
|
st := &fakeStore{services: []model.Service{svc(1, ref)}}
|
||||||
|
reg := &fakeRegistry{digests: map[string]string{ref: "sha256:old"}, delay: 30 * time.Millisecond}
|
||||||
|
p, _ := newPoller(st, &fakeJobs{}, reg, Config{Interval: 5 * time.Millisecond, FirstDelay: 60 * time.Millisecond, Workers: 1})
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
done := make(chan struct{})
|
||||||
|
began := time.Now()
|
||||||
|
go func() { p.Run(ctx); close(done) }()
|
||||||
|
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
reg.mu.Lock()
|
||||||
|
defer reg.mu.Unlock()
|
||||||
|
return len(reg.calls) >= 3
|
||||||
|
}, 5*time.Second, 5*time.Millisecond)
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("Run did not return after cancel")
|
||||||
|
}
|
||||||
|
assert.GreaterOrEqual(t, time.Since(began), 60*time.Millisecond+2*30*time.Millisecond, "first cycle waits FirstDelay")
|
||||||
|
assert.Equal(t, 1, reg.maxSeen, "cycles never overlap")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunReturnsDuringFirstDelay(t *testing.T) {
|
||||||
|
p, _ := newPoller(&fakeStore{}, &fakeJobs{}, &fakeRegistry{}, Config{Interval: time.Hour, FirstDelay: time.Hour})
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() { p.Run(ctx); close(done) }()
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("Run did not return after cancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptr[T any](v T) *T { return &v }
|
||||||
|
|
||||||
|
func TestSeedsTheTagDigestOfARunningService(t *testing.T) {
|
||||||
|
ref := repoA + ":main"
|
||||||
|
s := svc(1, ref+"@sha256:new")
|
||||||
|
s.UpdateMode = model.ModeAuto
|
||||||
|
s.ActualDigest = "sha256:new" // updated outside the updater: last_tag_digest is empty
|
||||||
|
st := &fakeStore{services: []model.Service{s}}
|
||||||
|
reg := &fakeRegistry{digests: map[string]string{ref: "sha256:new"}}
|
||||||
|
j := &fakeJobs{}
|
||||||
|
p, _ := newPoller(st, j, reg, Config{})
|
||||||
|
|
||||||
|
p.Cycle(context.Background())
|
||||||
|
|
||||||
|
assert.Equal(t, []lastTagCall{{1, "sha256:new"}}, st.lastTag)
|
||||||
|
assert.Empty(t, j.starts)
|
||||||
|
|
||||||
|
// Rolled back by hand to the older digest.
|
||||||
|
st.services[0].ActualRef, st.services[0].ActualDigest = ref+"@sha256:old", "sha256:old"
|
||||||
|
st.services[0].DesiredRef = ref + "@sha256:old"
|
||||||
|
p.Cycle(context.Background())
|
||||||
|
|
||||||
|
assert.Empty(t, j.starts, "the rollback stays")
|
||||||
|
assert.Len(t, st.lastTag, 1, "nothing more to seed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTagRegistry returns a fake registry that lists tags by repo.
|
||||||
|
func newTagRegistry(tags map[string][]string) *fakeRegistry {
|
||||||
|
return &fakeRegistry{digests: map[string]string{}, tags: tags}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newerSvc is a polled service running tag of repo that already runs the
|
||||||
|
// registry's digest, so the digest phase writes nothing.
|
||||||
|
func newerSvc(id int64, repo, tag string) model.Service {
|
||||||
|
s := svc(id, repo+":"+tag)
|
||||||
|
s.ActualDigest = "sha256:new"
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// newerRegistry is newTagRegistry that also answers sha256:new for refs.
|
||||||
|
func newerRegistry(tags map[string][]string, refs ...string) *fakeRegistry {
|
||||||
|
reg := newTagRegistry(tags)
|
||||||
|
for _, r := range refs {
|
||||||
|
reg.digests[r] = "sha256:new"
|
||||||
|
}
|
||||||
|
return reg
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewerTagSuggested(t *testing.T) {
|
||||||
|
ref := repoB + ":1.27.3"
|
||||||
|
st := &fakeStore{services: []model.Service{newerSvc(1, repoB, "1.27.3")}}
|
||||||
|
reg := newerRegistry(map[string][]string{repoB: {"1.27.4", "1.28.0", "2.0.0", "latest", "1.28.1-rootless"}}, ref)
|
||||||
|
p, pub := newPoller(st, &fakeJobs{}, reg, Config{})
|
||||||
|
|
||||||
|
p.Cycle(context.Background())
|
||||||
|
|
||||||
|
assert.Equal(t, []newerCall{{1, repoB + ":1.28.0", time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)}}, st.newer)
|
||||||
|
assert.Equal(t, []events.Topic{events.Services}, pub.topics)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOneTagListPerRepo(t *testing.T) {
|
||||||
|
st := &fakeStore{services: []model.Service{
|
||||||
|
newerSvc(1, repoB, "1.27.3"),
|
||||||
|
newerSvc(2, repoB, "2.1.0"),
|
||||||
|
newerSvc(3, repoB, "1.27.3"),
|
||||||
|
}}
|
||||||
|
reg := newerRegistry(map[string][]string{repoB: {"2.1.1", "1.27.4"}},
|
||||||
|
repoB+":1.27.3", repoB+":2.1.0")
|
||||||
|
p, _ := newPoller(st, &fakeJobs{}, reg, Config{})
|
||||||
|
|
||||||
|
p.Cycle(context.Background())
|
||||||
|
|
||||||
|
assert.Equal(t, []string{repoB}, reg.tagCalls)
|
||||||
|
got := map[int64]string{}
|
||||||
|
for _, c := range st.newer {
|
||||||
|
got[c.id] = c.ref
|
||||||
|
}
|
||||||
|
assert.Equal(t, map[int64]string{1: repoB + ":1.27.4", 2: repoB + ":2.1.1", 3: repoB + ":1.27.4"}, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoTagListWithoutComparableTag(t *testing.T) {
|
||||||
|
built := newerSvc(5, repoB, "1.2.3")
|
||||||
|
built.Built = true
|
||||||
|
noDigest := newerSvc(6, repoB, "1.2.3")
|
||||||
|
noDigest.ActualDigest = ""
|
||||||
|
st := &fakeStore{services: []model.Service{
|
||||||
|
newerSvc(1, repoB, "latest"),
|
||||||
|
newerSvc(2, repoB, "7"),
|
||||||
|
newerSvc(3, repoB, "pg16"),
|
||||||
|
newerSvc(4, repoB+"@sha256:abc", ""),
|
||||||
|
built,
|
||||||
|
noDigest,
|
||||||
|
}}
|
||||||
|
st.services[3].ActualRef = repoB + "@sha256:abc"
|
||||||
|
reg := newTagRegistry(map[string][]string{repoB: {"1.2.4"}})
|
||||||
|
p, _ := newPoller(st, &fakeJobs{}, reg, Config{})
|
||||||
|
|
||||||
|
p.Cycle(context.Background())
|
||||||
|
|
||||||
|
assert.Empty(t, reg.tagCalls)
|
||||||
|
assert.Empty(t, st.newer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewerClearedWhenNoCandidate(t *testing.T) {
|
||||||
|
s := newerSvc(1, repoB, "1.27.3")
|
||||||
|
s.NewerRef = repoB + ":1.27.4"
|
||||||
|
st := &fakeStore{services: []model.Service{s}}
|
||||||
|
reg := newerRegistry(map[string][]string{repoB: {"1.27.3", "2.0.0"}}, repoB+":1.27.3")
|
||||||
|
p, _ := newPoller(st, &fakeJobs{}, reg, Config{})
|
||||||
|
|
||||||
|
p.Cycle(context.Background())
|
||||||
|
|
||||||
|
assert.Equal(t, []newerCall{{1, "", time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)}}, st.newer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewerClearedWhenTagNoLongerComparable(t *testing.T) {
|
||||||
|
moved := newerSvc(1, repoB, "latest")
|
||||||
|
moved.NewerRef = repoB + ":1.27.4"
|
||||||
|
built := newerSvc(2, repoB, "1.27.3")
|
||||||
|
built.Built = true
|
||||||
|
built.NewerRef = repoB + ":1.27.4"
|
||||||
|
st := &fakeStore{services: []model.Service{moved, built}}
|
||||||
|
reg := newerRegistry(map[string][]string{repoB: {"1.27.4"}}, repoB+":latest")
|
||||||
|
p, pub := newPoller(st, &fakeJobs{}, reg, Config{})
|
||||||
|
|
||||||
|
p.Cycle(context.Background())
|
||||||
|
|
||||||
|
require.Len(t, st.newer, 2)
|
||||||
|
assert.Equal(t, "", st.newer[0].ref)
|
||||||
|
assert.Equal(t, "", st.newer[1].ref)
|
||||||
|
assert.Empty(t, reg.tagCalls)
|
||||||
|
assert.Equal(t, []events.Topic{events.Services}, pub.topics)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTagListErrorKeepsNewerAndPollError(t *testing.T) {
|
||||||
|
s := newerSvc(1, repoB, "1.27.3")
|
||||||
|
s.NewerRef = repoB + ":1.27.4"
|
||||||
|
st := &fakeStore{services: []model.Service{s}}
|
||||||
|
reg := newerRegistry(nil, repoB+":1.27.3")
|
||||||
|
reg.tagErrs = map[string]error{repoB: errors.New("list x: registry answered 500 Internal Server Error")}
|
||||||
|
p, pub := newPoller(st, &fakeJobs{}, reg, Config{})
|
||||||
|
|
||||||
|
p.Cycle(context.Background())
|
||||||
|
|
||||||
|
assert.Empty(t, st.newer, "the stored suggestion stays")
|
||||||
|
assert.Empty(t, st.pollErrors, "a failed tag list is not a poll error")
|
||||||
|
assert.Empty(t, pub.topics)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewerIdempotent(t *testing.T) {
|
||||||
|
s := newerSvc(1, repoB, "1.27.3")
|
||||||
|
s.NewerRef = repoB + ":1.28.0"
|
||||||
|
st := &fakeStore{services: []model.Service{s}}
|
||||||
|
reg := newerRegistry(map[string][]string{repoB: {"1.28.0"}}, repoB+":1.27.3")
|
||||||
|
p, pub := newPoller(st, &fakeJobs{}, reg, Config{})
|
||||||
|
|
||||||
|
p.Cycle(context.Background())
|
||||||
|
|
||||||
|
assert.Empty(t, st.newer)
|
||||||
|
assert.Empty(t, pub.topics)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewerNeverStartsJobs(t *testing.T) {
|
||||||
|
s := newerSvc(1, repoB, "1.27.3")
|
||||||
|
s.UpdateMode = model.ModeAuto
|
||||||
|
st := &fakeStore{services: []model.Service{s}}
|
||||||
|
reg := newerRegistry(map[string][]string{repoB: {"1.27.4"}}, repoB+":1.27.3")
|
||||||
|
j := &fakeJobs{}
|
||||||
|
p, _ := newPoller(st, j, reg, Config{})
|
||||||
|
|
||||||
|
p.Cycle(context.Background())
|
||||||
|
|
||||||
|
require.Len(t, st.newer, 1)
|
||||||
|
assert.Empty(t, j.starts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewerWritesSurviveCancel(t *testing.T) {
|
||||||
|
st := &fakeStore{services: []model.Service{newerSvc(1, repoB, "1.27.3")}}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
reg := newerRegistry(map[string][]string{repoB: {"1.27.4"}}, repoB+":1.27.3")
|
||||||
|
reg.onTags = cancel
|
||||||
|
p, _ := newPoller(st, &fakeJobs{}, reg, Config{})
|
||||||
|
|
||||||
|
p.Cycle(ctx)
|
||||||
|
|
||||||
|
require.Len(t, st.newer, 1)
|
||||||
|
assert.NoError(t, st.ctxErrs[0], "the write does not see the cancelled context")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCancelledListKeepsNewer(t *testing.T) {
|
||||||
|
s := newerSvc(1, repoB, "1.27.3")
|
||||||
|
s.NewerRef = repoB + ":1.27.4"
|
||||||
|
st := &fakeStore{services: []model.Service{s}}
|
||||||
|
reg := newerRegistry(map[string][]string{repoB: {"1.28.0"}}, repoB+":1.27.3")
|
||||||
|
reg.tagRelease = make(chan struct{})
|
||||||
|
reg.tagStarted = make(chan struct{}, 1)
|
||||||
|
p, pub := newPoller(st, &fakeJobs{}, reg, Config{})
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() { p.Cycle(ctx); close(done) }()
|
||||||
|
<-reg.tagStarted
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("Cycle did not return after cancel")
|
||||||
|
}
|
||||||
|
assert.Empty(t, st.newer)
|
||||||
|
assert.Empty(t, pub.topics)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFailedListClearsNewerFromAnotherRepo(t *testing.T) {
|
||||||
|
s := newerSvc(1, repoB, "1.27.3")
|
||||||
|
s.NewerRef = repoA + ":1.27.4"
|
||||||
|
st := &fakeStore{services: []model.Service{s}}
|
||||||
|
reg := newerRegistry(nil, repoB+":1.27.3")
|
||||||
|
reg.tagErrs = map[string]error{repoB: errors.New("list x: registry answered 500 Internal Server Error")}
|
||||||
|
p, _ := newPoller(st, &fakeJobs{}, reg, Config{})
|
||||||
|
|
||||||
|
p.Cycle(context.Background())
|
||||||
|
|
||||||
|
assert.Equal(t, []newerCall{{1, "", time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)}}, st.newer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDigestWritesArePublishedBeforeTagsAreListed(t *testing.T) {
|
||||||
|
st := &fakeStore{services: []model.Service{svc(1, repoB+":1.27.3")}}
|
||||||
|
reg := newerRegistry(map[string][]string{repoB: {"1.27.3"}}, repoB+":1.27.3")
|
||||||
|
reg.tagRelease = make(chan struct{})
|
||||||
|
reg.tagStarted = make(chan struct{}, 1)
|
||||||
|
p, pub := newPoller(st, &fakeJobs{}, reg, Config{})
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() { p.Cycle(context.Background()); close(done) }()
|
||||||
|
<-reg.tagStarted
|
||||||
|
|
||||||
|
pub.mu.Lock()
|
||||||
|
published := append([]events.Topic(nil), pub.topics...)
|
||||||
|
pub.mu.Unlock()
|
||||||
|
assert.Equal(t, []events.Topic{events.Services}, published, "published while the listing is still blocked")
|
||||||
|
|
||||||
|
close(reg.tagRelease)
|
||||||
|
<-done
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
// Package registry looks up the digest a tag points at in a container
|
||||||
|
// registry and lists the tags of a repository. Manifests are only ever
|
||||||
|
// requested with HEAD.
|
||||||
|
package registry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/go-containerregistry/pkg/authn"
|
||||||
|
"github.com/google/go-containerregistry/pkg/name"
|
||||||
|
"github.com/google/go-containerregistry/pkg/v1/remote"
|
||||||
|
"github.com/google/go-containerregistry/pkg/v1/remote/transport"
|
||||||
|
|
||||||
|
"github.com/shcizo/package-updater/internal/imageref"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client looks up tag digests and lists tags. Docker Hub counts a GET of a
|
||||||
|
// manifest as a pull against the rate limit and a HEAD as nothing, so Client
|
||||||
|
// never issues a manifest GET. The only GETs it sends are the /v2/ ping and
|
||||||
|
// tags/list, which Docker Hub does not count as a pull.
|
||||||
|
type Client struct {
|
||||||
|
keychain authn.Keychain
|
||||||
|
userAgent string
|
||||||
|
log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a Client that finds credentials the way the Docker CLI does:
|
||||||
|
// in $DOCKER_CONFIG/config.json, else ~/.docker/config.json. Registries
|
||||||
|
// without an entry are accessed anonymously. userAgent is added to the
|
||||||
|
// User-Agent header of every request. log receives the full error of a
|
||||||
|
// failed lookup, which Digest itself returns only in a sanitised form.
|
||||||
|
func New(userAgent string, log *slog.Logger) *Client {
|
||||||
|
return &Client{keychain: authn.DefaultKeychain, userAgent: userAgent, log: log}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Digest returns the digest that "repo:tag" points at, as the registry
|
||||||
|
// reports it: for a multi-arch image that is the digest of the index, the
|
||||||
|
// same one `docker inspect` shows in RepoDigests. A digest suffix in ref is
|
||||||
|
// ignored. It sends HEAD /v2/<repo>/manifests/<tag> and nothing else to the
|
||||||
|
// manifest endpoint; it does not fall back to GET when the registry omits
|
||||||
|
// the digest header.
|
||||||
|
func (c *Client) Digest(ctx context.Context, ref string) (string, error) {
|
||||||
|
p := imageref.Parse(ref)
|
||||||
|
if p.Tag == "" {
|
||||||
|
return "", fmt.Errorf("image %q has no tag", ref)
|
||||||
|
}
|
||||||
|
r, err := name.ParseReference(p.Repo + ":" + p.Tag)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("parse %s: %w", ref, err)
|
||||||
|
}
|
||||||
|
// Credentials are resolved here, not by ggcr, so that a broken credential
|
||||||
|
// helper keeps its own message: that text comes from the local config.
|
||||||
|
auth, err := c.keychain.Resolve(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("lookup %s: %w", r, err)
|
||||||
|
}
|
||||||
|
desc, err := remote.Head(r,
|
||||||
|
remote.WithContext(ctx),
|
||||||
|
remote.WithAuth(auth),
|
||||||
|
remote.WithUserAgent(c.userAgent))
|
||||||
|
if err != nil {
|
||||||
|
return "", c.lookupError("lookup", r.String(), ref, err)
|
||||||
|
}
|
||||||
|
return desc.Digest.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tags returns every tag of repo ("host/path", or a Docker Hub name), in the
|
||||||
|
// order the registry lists them. It sends GET /v2/<repo>/tags/list, following
|
||||||
|
// the registry's pagination links. Errors are sanitised like Digest's.
|
||||||
|
func (c *Client) Tags(ctx context.Context, repo string) ([]string, error) {
|
||||||
|
r, err := name.NewRepository(repo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse %s: %w", repo, err)
|
||||||
|
}
|
||||||
|
auth, err := c.keychain.Resolve(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list %s: %w", r, err)
|
||||||
|
}
|
||||||
|
tags, err := remote.List(r,
|
||||||
|
remote.WithContext(ctx),
|
||||||
|
remote.WithAuth(auth),
|
||||||
|
remote.WithUserAgent(c.userAgent))
|
||||||
|
if err != nil {
|
||||||
|
return nil, c.lookupError("list", r.String(), repo, err)
|
||||||
|
}
|
||||||
|
return tags, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookupError turns a failed registry request into an error that is safe to
|
||||||
|
// show: a registry answer is described by its status and codes, anything
|
||||||
|
// else by a fixed text. The full error goes to the log. verb and target
|
||||||
|
// start the message; ref is what the log line names.
|
||||||
|
func (c *Client) lookupError(verb, target, ref string, err error) error {
|
||||||
|
var terr *transport.Error
|
||||||
|
if errors.As(err, &terr) {
|
||||||
|
return fmt.Errorf("%s %s: %s", verb, target, describe(terr))
|
||||||
|
}
|
||||||
|
c.log.Warn("registry_request_failed", "ref", ref, "error", err)
|
||||||
|
return fmt.Errorf("%s %s: %s", verb, target, classify(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// classify returns a fixed text for an error that is not a registry answer.
|
||||||
|
// ggcr copies registry-controlled text (token responses, authentication
|
||||||
|
// challenges, redirect URLs, certificate names) into such errors, so none of
|
||||||
|
// it is passed on.
|
||||||
|
func classify(err error) string {
|
||||||
|
var netErr net.Error
|
||||||
|
var urlErr *url.Error
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, context.DeadlineExceeded), errors.Is(err, context.Canceled):
|
||||||
|
return "timeout"
|
||||||
|
case errors.As(err, &netErr), errors.As(err, &urlErr):
|
||||||
|
return "cannot reach registry"
|
||||||
|
}
|
||||||
|
return "lookup failed"
|
||||||
|
}
|
||||||
|
|
||||||
|
// errorCodePattern matches the registry error codes of the distribution
|
||||||
|
// spec ("MANIFEST_UNKNOWN"). Anything else is dropped from error text.
|
||||||
|
var errorCodePattern = regexp.MustCompile(`^[A-Z_]{1,40}$`)
|
||||||
|
|
||||||
|
// describe returns the HTTP status and the structured error codes of a
|
||||||
|
// registry error, without the messages, details or raw body that ggcr copies
|
||||||
|
// into its error text. The registry host comes from an image name, so
|
||||||
|
// whatever it answers must not end up in the UI.
|
||||||
|
func describe(e *transport.Error) string {
|
||||||
|
msg := fmt.Sprintf("registry answered %d %s", e.StatusCode, http.StatusText(e.StatusCode))
|
||||||
|
var codes []string
|
||||||
|
for _, d := range e.Errors {
|
||||||
|
if errorCodePattern.MatchString(string(d.Code)) {
|
||||||
|
codes = append(codes, string(d.Code))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(codes) > 0 {
|
||||||
|
msg += " (" + strings.Join(codes, ", ") + ")"
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
}
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
package registry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/go-containerregistry/pkg/authn"
|
||||||
|
"github.com/google/go-containerregistry/pkg/name"
|
||||||
|
ggcrregistry "github.com/google/go-containerregistry/pkg/registry"
|
||||||
|
"github.com/google/go-containerregistry/pkg/v1/random"
|
||||||
|
"github.com/google/go-containerregistry/pkg/v1/remote"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
var discardLog = slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
|
||||||
|
func newClient() *Client { return New("test", discardLog) }
|
||||||
|
|
||||||
|
// request is one request the test registry received.
|
||||||
|
type request struct{ method, path string }
|
||||||
|
|
||||||
|
// testRegistry is an in-memory registry that records every request.
|
||||||
|
type testRegistry struct {
|
||||||
|
srv *httptest.Server
|
||||||
|
host string // "127.0.0.1:port", the registry part of a ref
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
requests []request
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestRegistry starts a registry. When user is set every request needs
|
||||||
|
// those basic-auth credentials.
|
||||||
|
func newTestRegistry(t *testing.T, user, pass string) *testRegistry {
|
||||||
|
t.Helper()
|
||||||
|
backend := ggcrregistry.New(ggcrregistry.Logger(log.New(io.Discard, "", 0)))
|
||||||
|
tr := &testRegistry{}
|
||||||
|
tr.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tr.mu.Lock()
|
||||||
|
tr.requests = append(tr.requests, request{r.Method, r.URL.Path})
|
||||||
|
tr.mu.Unlock()
|
||||||
|
if user != "" {
|
||||||
|
if u, p, ok := r.BasicAuth(); !ok || u != user || p != pass {
|
||||||
|
w.Header().Set("WWW-Authenticate", `Basic realm="test"`)
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
backend.ServeHTTP(w, r)
|
||||||
|
}))
|
||||||
|
t.Cleanup(tr.srv.Close)
|
||||||
|
tr.host = strings.TrimPrefix(tr.srv.URL, "http://")
|
||||||
|
return tr
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushIndex stores a random multi-arch index as repo:tag and returns its
|
||||||
|
// digest. user and pass are used when the registry requires them.
|
||||||
|
func (tr *testRegistry) pushIndex(t *testing.T, repo, tag string, opts ...remote.Option) string {
|
||||||
|
t.Helper()
|
||||||
|
idx, err := random.Index(64, 1, 2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
ref, err := name.ParseReference(tr.host + "/" + repo + ":" + tag)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, remote.WriteIndex(ref, idx, opts...))
|
||||||
|
d, err := idx.Digest()
|
||||||
|
require.NoError(t, err)
|
||||||
|
return d.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// manifestRequests returns the requests that touched a manifest.
|
||||||
|
func (tr *testRegistry) manifestRequests() []request {
|
||||||
|
tr.mu.Lock()
|
||||||
|
defer tr.mu.Unlock()
|
||||||
|
var out []request
|
||||||
|
for _, r := range tr.requests {
|
||||||
|
if strings.Contains(r.path, "/manifests/") {
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// reset forgets the requests received so far.
|
||||||
|
func (tr *testRegistry) reset() {
|
||||||
|
tr.mu.Lock()
|
||||||
|
defer tr.mu.Unlock()
|
||||||
|
tr.requests = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dockerConfig points DOCKER_CONFIG at a fresh directory holding config.json
|
||||||
|
// with the given content; "" leaves the directory empty.
|
||||||
|
func dockerConfig(t *testing.T, configJSON string) {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
if configJSON != "" {
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "config.json"), []byte(configJSON), 0o600))
|
||||||
|
}
|
||||||
|
t.Setenv("DOCKER_CONFIG", dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDigestOfIndexIsIndexDigest(t *testing.T) {
|
||||||
|
dockerConfig(t, "")
|
||||||
|
reg := newTestRegistry(t, "", "")
|
||||||
|
want := reg.pushIndex(t, "team/app", "1.2")
|
||||||
|
|
||||||
|
got, err := newClient().Digest(context.Background(), reg.host+"/team/app:1.2")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, want, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDigestIgnoresDigestSuffix(t *testing.T) {
|
||||||
|
dockerConfig(t, "")
|
||||||
|
reg := newTestRegistry(t, "", "")
|
||||||
|
want := reg.pushIndex(t, "team/app", "1.2")
|
||||||
|
|
||||||
|
got, err := newClient().Digest(context.Background(), reg.host+"/team/app:1.2@sha256:"+strings.Repeat("0", 64))
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, want, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Digest asks the manifest endpoint with HEAD only; the one other request is
|
||||||
|
// the GET /v2/ ping that every client sends first.
|
||||||
|
func TestDigestUsesHeadOnly(t *testing.T) {
|
||||||
|
dockerConfig(t, "")
|
||||||
|
reg := newTestRegistry(t, "", "")
|
||||||
|
reg.pushIndex(t, "team/app", "1.2")
|
||||||
|
reg.reset()
|
||||||
|
|
||||||
|
_, err := newClient().Digest(context.Background(), reg.host+"/team/app:1.2")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []request{{http.MethodHead, "/v2/team/app/manifests/1.2"}}, reg.manifestRequests())
|
||||||
|
for _, r := range reg.requests {
|
||||||
|
if !strings.Contains(r.path, "/manifests/") {
|
||||||
|
assert.Equal(t, request{http.MethodGet, "/v2/"}, r, "the only other request is the API ping")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDigestSendsUserAgent(t *testing.T) {
|
||||||
|
dockerConfig(t, "")
|
||||||
|
var ua string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ua = r.Header.Get("User-Agent")
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
_, _ = New("package-updater-test/1", discardLog).Digest(context.Background(), strings.TrimPrefix(srv.URL, "http://")+"/a/b:1")
|
||||||
|
|
||||||
|
assert.Contains(t, ua, "package-updater-test/1")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCredentialsFromDockerConfig(t *testing.T) {
|
||||||
|
reg := newTestRegistry(t, "bot", "s3cret")
|
||||||
|
want := reg.pushIndex(t, "team/app", "1.2", remote.WithAuth(&authn.Basic{Username: "bot", Password: "s3cret"}))
|
||||||
|
auth := base64.StdEncoding.EncodeToString([]byte("bot:s3cret"))
|
||||||
|
dockerConfig(t, `{"auths":{"`+reg.host+`":{"auth":"`+auth+`"}}}`)
|
||||||
|
|
||||||
|
got, err := newClient().Digest(context.Background(), reg.host+"/team/app:1.2")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, want, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWrongCredentialsFail(t *testing.T) {
|
||||||
|
reg := newTestRegistry(t, "bot", "s3cret")
|
||||||
|
reg.pushIndex(t, "team/app", "1.2", remote.WithAuth(&authn.Basic{Username: "bot", Password: "s3cret"}))
|
||||||
|
auth := base64.StdEncoding.EncodeToString([]byte("bot:wrong"))
|
||||||
|
dockerConfig(t, `{"auths":{"`+reg.host+`":{"auth":"`+auth+`"}}}`)
|
||||||
|
|
||||||
|
_, err := newClient().Digest(context.Background(), reg.host+"/team/app:1.2")
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.NotContains(t, err.Error(), "wrong", "credentials do not leak into errors")
|
||||||
|
assert.NotContains(t, err.Error(), auth)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnonymousWithoutConfig(t *testing.T) {
|
||||||
|
dockerConfig(t, "")
|
||||||
|
open := newTestRegistry(t, "", "")
|
||||||
|
want := open.pushIndex(t, "team/app", "1.2")
|
||||||
|
protected := newTestRegistry(t, "bot", "s3cret")
|
||||||
|
|
||||||
|
got, err := newClient().Digest(context.Background(), open.host+"/team/app:1.2")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, want, got, "anonymous access works without a config file")
|
||||||
|
|
||||||
|
_, err = newClient().Digest(context.Background(), protected.host+"/team/app:1.2")
|
||||||
|
assert.Error(t, err, "a registry that needs credentials fails without them")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnknownTagIsAnError(t *testing.T) {
|
||||||
|
dockerConfig(t, "")
|
||||||
|
reg := newTestRegistry(t, "", "")
|
||||||
|
reg.pushIndex(t, "team/app", "1.2")
|
||||||
|
|
||||||
|
_, err := newClient().Digest(context.Background(), reg.host+"/team/app:nope")
|
||||||
|
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefWithoutTagIsAnError(t *testing.T) {
|
||||||
|
_, err := newClient().Digest(context.Background(), "team/app@sha256:"+strings.Repeat("a", 64))
|
||||||
|
|
||||||
|
assert.ErrorContains(t, err, "has no tag")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A config.json whose credsStore names a helper that is not installed makes
|
||||||
|
// the lookup fail for every registry, including ones that would work
|
||||||
|
// anonymously. The master container has no credential helpers, so only
|
||||||
|
// inline "auths" entries are supported there.
|
||||||
|
func TestCredsStoreWithoutHelperFailsTheLookup(t *testing.T) {
|
||||||
|
reg := newTestRegistry(t, "", "")
|
||||||
|
reg.pushIndex(t, "team/app", "1.2")
|
||||||
|
dockerConfig(t, `{"credsStore":"no-such-helper-for-package-updater-tests"}`)
|
||||||
|
|
||||||
|
_, err := newClient().Digest(context.Background(), reg.host+"/team/app:1.2")
|
||||||
|
|
||||||
|
assert.ErrorContains(t, err, "error getting credentials")
|
||||||
|
assert.ErrorContains(t, err, "docker-credential-no-such-helper-for-package-updater-tests")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A registry that leaves out Docker-Content-Digest must not make the client
|
||||||
|
// retry with a manifest GET, which Docker Hub counts as a pull.
|
||||||
|
func TestMissingDigestHeaderDoesNotFallBackToDownload(t *testing.T) {
|
||||||
|
dockerConfig(t, "")
|
||||||
|
var mu sync.Mutex
|
||||||
|
var methods []string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !strings.Contains(r.URL.Path, "/manifests/") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
methods = append(methods, r.Method)
|
||||||
|
mu.Unlock()
|
||||||
|
w.Header().Set("Content-Type", "application/vnd.oci.image.index.v1+json")
|
||||||
|
w.Header().Set("Content-Length", "10")
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
_, err := newClient().Digest(context.Background(), strings.TrimPrefix(srv.URL, "http://")+"/a/b:1")
|
||||||
|
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Equal(t, []string{http.MethodHead}, methods)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ggcr copies up to 64 KiB of the registry's response into its errors. The
|
||||||
|
// registry host comes from an image name, so that text must not reach the
|
||||||
|
// stored poll error.
|
||||||
|
func TestRegistryErrorBodyDoesNotLeak(t *testing.T) {
|
||||||
|
dockerConfig(t, "")
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"raw body", "internal-secret-body", "registry answered 400 Bad Request"},
|
||||||
|
{"structured errors", `{"errors":[{"code":"MANIFEST_UNKNOWN","message":"internal-secret-body","detail":"internal-secret-body"},{"code":"internal-secret-body"}]}`,
|
||||||
|
"registry answered 400 Bad Request (MANIFEST_UNKNOWN)"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// HEAD answers carry no body, so the leak is in the GET /v2/ ping.
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_, _ = w.Write([]byte(tt.body))
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
host := strings.TrimPrefix(srv.URL, "http://")
|
||||||
|
|
||||||
|
_, err := newClient().Digest(context.Background(), host+"/a/b:1")
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.NotContains(t, err.Error(), "internal-secret-body")
|
||||||
|
assert.Equal(t, "lookup "+host+"/a/b:1: "+tt.want, strings.Replace(err.Error(), "index.docker.io/", "", 1))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ggcr puts the token server's answer into its error when the answer has no
|
||||||
|
// token, and the token server is named by the registry's challenge.
|
||||||
|
func TestNonRegistryErrorsAreFixedText(t *testing.T) {
|
||||||
|
dockerConfig(t, "")
|
||||||
|
tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`{"detail":"internal-secret-body"}`))
|
||||||
|
}))
|
||||||
|
t.Cleanup(tokenSrv.Close)
|
||||||
|
reg := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("WWW-Authenticate", `Bearer realm="`+tokenSrv.URL+`/internal-secret-realm",service="internal-secret-service"`)
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
}))
|
||||||
|
t.Cleanup(reg.Close)
|
||||||
|
host := strings.TrimPrefix(reg.URL, "http://")
|
||||||
|
|
||||||
|
_, err := newClient().Digest(context.Background(), host+"/a/b:1")
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.NotContains(t, err.Error(), "internal-secret")
|
||||||
|
assert.Equal(t, "lookup "+host+"/a/b:1: lookup failed", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassify(t *testing.T) {
|
||||||
|
refused := &url.Error{Op: "Get", URL: "http://internal-secret-host/v2/", Err: errors.New("connection refused")}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"deadline", fmt.Errorf("x: %w", context.DeadlineExceeded), "timeout"},
|
||||||
|
{"canceled", context.Canceled, "timeout"},
|
||||||
|
{"url error", refused, "cannot reach registry"},
|
||||||
|
{"net error", &net.DNSError{Name: "internal-secret-host"}, "cannot reach registry"},
|
||||||
|
{"deadline inside url error", &url.Error{Op: "Get", URL: "http://x", Err: context.DeadlineExceeded}, "timeout"},
|
||||||
|
{"anything else", errors.New("internal-secret"), "lookup failed"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.want, classify(tt.err))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushTags stores a random index under each tag of repo.
|
||||||
|
func (tr *testRegistry) pushTags(t *testing.T, repo string, tags ...string) {
|
||||||
|
t.Helper()
|
||||||
|
for _, tag := range tags {
|
||||||
|
tr.pushIndex(t, repo, tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Digest and Tags together never GET a manifest: every manifest request is a
|
||||||
|
// HEAD, and the only other requests are the /v2/ ping and tags/list.
|
||||||
|
func TestNoManifestGet(t *testing.T) {
|
||||||
|
dockerConfig(t, "")
|
||||||
|
reg := newTestRegistry(t, "", "")
|
||||||
|
reg.pushTags(t, "team/app", "1.2", "1.3")
|
||||||
|
reg.reset()
|
||||||
|
|
||||||
|
_, err := newClient().Digest(context.Background(), reg.host+"/team/app:1.2")
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = newClient().Tags(context.Background(), reg.host+"/team/app")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
reg.mu.Lock()
|
||||||
|
defer reg.mu.Unlock()
|
||||||
|
for _, r := range reg.requests {
|
||||||
|
switch {
|
||||||
|
case strings.Contains(r.path, "/manifests/"):
|
||||||
|
assert.Equal(t, http.MethodHead, r.method, r.path)
|
||||||
|
case r.path == "/v2/", r.path == "/v2/team/app/tags/list":
|
||||||
|
assert.Equal(t, http.MethodGet, r.method, r.path)
|
||||||
|
default:
|
||||||
|
assert.Failf(t, "unexpected request", "%s %s", r.method, r.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTagsListsAllTags(t *testing.T) {
|
||||||
|
dockerConfig(t, "")
|
||||||
|
reg := newTestRegistry(t, "", "")
|
||||||
|
reg.pushTags(t, "team/app", "1.2", "1.3", "latest")
|
||||||
|
|
||||||
|
got, err := newClient().Tags(context.Background(), reg.host+"/team/app")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.ElementsMatch(t, []string{"1.2", "1.3", "latest"}, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ggcr's in-memory registry sends no Link header, so pagination is tested
|
||||||
|
// against a handler that does.
|
||||||
|
func TestTagsFollowsLinkPagination(t *testing.T) {
|
||||||
|
dockerConfig(t, "")
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/v2/":
|
||||||
|
case r.URL.Path == "/v2/a/b/tags/list" && r.URL.Query().Get("last") == "":
|
||||||
|
w.Header().Set("Link", `</v2/a/b/tags/list?last=1.3&n=2>; rel="next"`)
|
||||||
|
_, _ = w.Write([]byte(`{"name":"a/b","tags":["1.2","1.3"]}`))
|
||||||
|
case r.URL.Path == "/v2/a/b/tags/list":
|
||||||
|
_, _ = w.Write([]byte(`{"name":"a/b","tags":["1.4"]}`))
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
got, err := newClient().Tags(context.Background(), strings.TrimPrefix(srv.URL, "http://")+"/a/b")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"1.2", "1.3", "1.4"}, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTagsUsesDockerConfigCredentials(t *testing.T) {
|
||||||
|
reg := newTestRegistry(t, "bot", "s3cret")
|
||||||
|
reg.pushIndex(t, "team/app", "1.2", remote.WithAuth(&authn.Basic{Username: "bot", Password: "s3cret"}))
|
||||||
|
auth := base64.StdEncoding.EncodeToString([]byte("bot:s3cret"))
|
||||||
|
dockerConfig(t, `{"auths":{"`+reg.host+`":{"auth":"`+auth+`"}}}`)
|
||||||
|
|
||||||
|
got, err := newClient().Tags(context.Background(), reg.host+"/team/app")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"1.2"}, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTagsErrorBodyDoesNotLeak(t *testing.T) {
|
||||||
|
dockerConfig(t, "")
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_, _ = w.Write([]byte(`{"errors":[{"code":"NAME_UNKNOWN","message":"internal-secret-body"}]}`))
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
host := strings.TrimPrefix(srv.URL, "http://")
|
||||||
|
|
||||||
|
_, err := newClient().Tags(context.Background(), host+"/a/b")
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.NotContains(t, err.Error(), "internal-secret-body")
|
||||||
|
assert.ErrorContains(t, err, "list "+host+"/a/b: registry answered 400 Bad Request")
|
||||||
|
}
|
||||||
@@ -13,11 +13,11 @@ import (
|
|||||||
"github.com/shcizo/package-updater/internal/protocol"
|
"github.com/shcizo/package-updater/internal/protocol"
|
||||||
)
|
)
|
||||||
|
|
||||||
const agentColumns = `id, name, hostname, agent_version, swarm_manager, last_seen_at, revoked_at, created_at`
|
const agentColumns = `id, name, hostname, agent_version, swarm_manager, swarm_cluster_id, last_seen_at, revoked_at, created_at`
|
||||||
|
|
||||||
func scanAgent(row pgx.Row) (model.Agent, error) {
|
func scanAgent(row pgx.Row) (model.Agent, error) {
|
||||||
var a model.Agent
|
var a model.Agent
|
||||||
err := row.Scan(&a.ID, &a.Name, &a.Hostname, &a.AgentVersion, &a.SwarmManager, &a.LastSeenAt, &a.RevokedAt, &a.CreatedAt)
|
err := row.Scan(&a.ID, &a.Name, &a.Hostname, &a.AgentVersion, &a.SwarmManager, &a.SwarmClusterID, &a.LastSeenAt, &a.RevokedAt, &a.CreatedAt)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return model.Agent{}, model.ErrNotFound
|
return model.Agent{}, model.ErrNotFound
|
||||||
}
|
}
|
||||||
@@ -101,8 +101,8 @@ func (s *Store) RevokeAgent(ctx context.Context, id int64, at time.Time) ([]int6
|
|||||||
// RecordHello stores what the agent reported about itself on connect.
|
// RecordHello stores what the agent reported about itself on connect.
|
||||||
func (s *Store) RecordHello(ctx context.Context, id int64, h protocol.Hello, at time.Time) error {
|
func (s *Store) RecordHello(ctx context.Context, id int64, h protocol.Hello, at time.Time) error {
|
||||||
if _, err := s.pool.Exec(ctx,
|
if _, err := s.pool.Exec(ctx,
|
||||||
`UPDATE agents SET hostname = $2, agent_version = $3, swarm_manager = $4, last_seen_at = $5 WHERE id = $1`,
|
`UPDATE agents SET hostname = $2, agent_version = $3, swarm_manager = $4, swarm_cluster_id = $6, last_seen_at = $5 WHERE id = $1`,
|
||||||
id, h.Hostname, h.AgentVersion, h.SwarmManager, at); err != nil {
|
id, h.Hostname, h.AgentVersion, h.SwarmManager, at, h.SwarmClusterID); err != nil {
|
||||||
return fmt.Errorf("record hello: %w", err)
|
return fmt.Errorf("record hello: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ func TestAgents_RecordHelloAndMarkSeen(t *testing.T) {
|
|||||||
assert.Equal(t, "swarm-mgr", agents[0].Hostname)
|
assert.Equal(t, "swarm-mgr", agents[0].Hostname)
|
||||||
assert.Equal(t, "v1", agents[0].AgentVersion)
|
assert.Equal(t, "v1", agents[0].AgentVersion)
|
||||||
assert.True(t, agents[0].SwarmManager)
|
assert.True(t, agents[0].SwarmManager)
|
||||||
|
assert.Equal(t, "c1", agents[0].SwarmClusterID)
|
||||||
require.NotNil(t, agents[0].LastSeenAt)
|
require.NotNil(t, agents[0].LastSeenAt)
|
||||||
assert.True(t, agents[0].LastSeenAt.Equal(at.Add(time.Minute)))
|
assert.True(t, agents[0].LastSeenAt.Equal(at.Add(time.Minute)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,554 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/shcizo/package-updater/internal/master/model"
|
||||||
|
"github.com/shcizo/package-updater/internal/protocol"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
digNew = "sha256:" + strings.Repeat("e", 64)
|
||||||
|
digOld = "sha256:" + strings.Repeat("f", 64)
|
||||||
|
sluskDrift = "gitea.shcizo.se/shcizo/slusk"
|
||||||
|
)
|
||||||
|
|
||||||
|
// serviceID returns the ID of the service the agent reports under svc's key.
|
||||||
|
func serviceID(t *testing.T, s *Store, agentID int64, svc protocol.Service) int64 {
|
||||||
|
t.Helper()
|
||||||
|
var id int64
|
||||||
|
require.NoError(t, s.pool.QueryRow(context.Background(),
|
||||||
|
`SELECT id FROM services WHERE location_key = $1`, model.LocationKey(agentID, "", svc.Key())).Scan(&id))
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// finishJob runs a job for the service to success with the deployed ref.
|
||||||
|
func finishJob(t *testing.T, s *Store, agentID, svcID int64, toRef, deployed string) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
nj := newJob(svcID, agentID)
|
||||||
|
nj.ToRef = toRef
|
||||||
|
j, err := s.CreateJob(ctx, nj)
|
||||||
|
require.NoError(t, err)
|
||||||
|
ok, err := s.FinishJob(ctx, agentID, j.ID, model.JobFinish{Status: model.JobSucceeded, Ref: deployed, StartedAt: t0, FinishedAt: t0})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustExec(t *testing.T, s *Store, sql string, args ...any) {
|
||||||
|
t.Helper()
|
||||||
|
_, err := s.pool.Exec(context.Background(), sql, args...)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFinishJob_SetsAwaitingInventoryUntilTheNextUpsert(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
agent, svc := jobFixture(t, s, "a")
|
||||||
|
|
||||||
|
got, err := s.GetService(ctx, svc)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, got.AwaitingInventory)
|
||||||
|
|
||||||
|
finishJob(t, s, agent, svc, "r:1", "r:1@"+digNew)
|
||||||
|
got, err = s.GetService(ctx, svc)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, got.AwaitingInventory, "set by the successful job")
|
||||||
|
assert.False(t, got.Drifted(), "desired is ahead of actual only until the inventory arrives")
|
||||||
|
|
||||||
|
require.NoError(t, s.UpsertService(ctx, agent, "", slusk, t0.Add(time.Minute)))
|
||||||
|
got, err = s.GetService(ctx, svc)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, got.AwaitingInventory, "cleared by the agent's upsert")
|
||||||
|
assert.True(t, got.Drifted(), "the agent still reports the old version")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFinishJob_FailedJobDoesNotSetAwaitingInventory(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
agent, svc := jobFixture(t, s, "a")
|
||||||
|
j, err := s.CreateJob(ctx, newJob(svc, agent))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ok, err := s.FinishJob(ctx, agent, j.ID, model.JobFinish{Status: model.JobFailed, Error: "boom", StartedAt: t0, FinishedAt: t0})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, ok)
|
||||||
|
|
||||||
|
got, err := s.GetService(ctx, svc)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, got.AwaitingInventory)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateJob_UnknownServiceIsNotFound(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
agent := newAgent(t, s, "a")
|
||||||
|
|
||||||
|
_, err := s.CreateJob(context.Background(), newJob(9999, agent))
|
||||||
|
|
||||||
|
assert.ErrorIs(t, err, model.ErrNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// driftedFixture is a service updated to digNew whose agent now reports digD1.
|
||||||
|
func driftedFixture(t *testing.T, s *Store) (agent, svc int64) {
|
||||||
|
t.Helper()
|
||||||
|
agent, svc = jobFixture(t, s, "a")
|
||||||
|
finishJob(t, s, agent, svc, "r:1", sluskDrift+":1.4.2@"+digNew)
|
||||||
|
require.NoError(t, s.UpsertService(context.Background(), agent, "", slusk, t0.Add(time.Minute)))
|
||||||
|
return agent, svc
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAcceptRunning_WritesTheDesiredRef(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
_, svc := driftedFixture(t, s)
|
||||||
|
require.NoError(t, s.SetLastTagDigest(ctx, svc, "sha256:ltd"))
|
||||||
|
require.NoError(t, s.SetAvailable(ctx, svc, model.Available{Ref: sluskDrift + ":1.5.0", Source: model.SourcePoll, At: t0}))
|
||||||
|
require.NoError(t, s.SetUpdateMode(ctx, svc, model.ModeAuto))
|
||||||
|
before, err := s.GetService(ctx, svc)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, before.Drifted())
|
||||||
|
|
||||||
|
running := model.RunningRef(before.ActualRef, before.ActualDigest)
|
||||||
|
require.NoError(t, s.AcceptRunning(ctx, svc, before.ActualRef, before.ActualDigest, running))
|
||||||
|
|
||||||
|
got, err := s.GetService(ctx, svc)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, sluskDrift+":1.4.2@"+digD1, got.DesiredRef)
|
||||||
|
assert.False(t, got.Drifted())
|
||||||
|
assert.False(t, got.AwaitingInventory)
|
||||||
|
assert.Equal(t, "sha256:ltd", got.LastTagDigest, "last_tag_digest untouched")
|
||||||
|
assert.Equal(t, sluskDrift+":1.5.0", got.AvailableRef, "available suggestion untouched")
|
||||||
|
assert.Equal(t, model.ModeAuto, got.UpdateMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAcceptRunning_Refusals(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
prepare func(t *testing.T, s *Store, agent, svc int64) (id int64, actualRef, actualDigest string)
|
||||||
|
wantError error
|
||||||
|
}{
|
||||||
|
{"running version changed", func(t *testing.T, s *Store, agent, svc int64) (int64, string, string) {
|
||||||
|
return svc, sluskDrift + ":1.4.2", digOld
|
||||||
|
}, model.ErrConflict},
|
||||||
|
{"actual ref changed", func(t *testing.T, s *Store, agent, svc int64) (int64, string, string) {
|
||||||
|
return svc, sluskDrift + ":1.3.0", digD1
|
||||||
|
}, model.ErrConflict},
|
||||||
|
{"never updated", func(t *testing.T, s *Store, agent, svc int64) (int64, string, string) {
|
||||||
|
mustExec(t, s, `UPDATE services SET desired_ref = '' WHERE id = $1`, svc)
|
||||||
|
return svc, sluskDrift + ":1.4.2", digD1
|
||||||
|
}, model.ErrConflict},
|
||||||
|
{"removed", func(t *testing.T, s *Store, agent, svc int64) (int64, string, string) {
|
||||||
|
require.NoError(t, s.RemoveService(context.Background(), agent, "", slusk.Key(), t0.Add(time.Hour)))
|
||||||
|
return svc, sluskDrift + ":1.4.2", digD1
|
||||||
|
}, model.ErrNotFound},
|
||||||
|
{"unknown", func(t *testing.T, s *Store, agent, svc int64) (int64, string, string) {
|
||||||
|
return 9999, sluskDrift + ":1.4.2", digD1
|
||||||
|
}, model.ErrNotFound},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
agent, svc := driftedFixture(t, s)
|
||||||
|
id, ref, digest := tt.prepare(t, s, agent, svc)
|
||||||
|
|
||||||
|
err := s.AcceptRunning(context.Background(), id, ref, digest, "x:1@"+digOld)
|
||||||
|
|
||||||
|
assert.ErrorIs(t, err, tt.wantError)
|
||||||
|
got, gerr := s.GetService(context.Background(), svc)
|
||||||
|
require.NoError(t, gerr)
|
||||||
|
assert.NotEqual(t, "x:1@"+digOld, got.DesiredRef, "nothing written")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// moveFixture has agent "a" with slusk removed after it was updated (history,
|
||||||
|
// auto mode) and agent "b" reporting a new slusk row.
|
||||||
|
type moveFixture struct {
|
||||||
|
a, b int64
|
||||||
|
from, to int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMoveFixture(t *testing.T, s *Store) moveFixture {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
f := moveFixture{a: newAgent(t, s, "a"), b: newAgent(t, s, "b")}
|
||||||
|
require.NoError(t, s.ApplySnapshot(ctx, f.a, worker, []protocol.Service{slusk}, t0))
|
||||||
|
f.from = serviceID(t, s, f.a, slusk)
|
||||||
|
finishJob(t, s, f.a, f.from, "r:1", sluskDrift+":1.4.2@"+digNew)
|
||||||
|
require.NoError(t, s.SetUpdateMode(ctx, f.from, model.ModeAuto))
|
||||||
|
require.NoError(t, s.ApplySnapshot(ctx, f.a, worker, nil, t0.Add(time.Hour)))
|
||||||
|
require.NoError(t, s.ApplySnapshot(ctx, f.b, worker, []protocol.Service{slusk}, t0.Add(2*time.Hour)))
|
||||||
|
f.to = serviceID(t, s, f.b, slusk)
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMoveCandidates(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
mutate func(t *testing.T, s *Store, f moveFixture)
|
||||||
|
want bool
|
||||||
|
from string // agent name of the removed row
|
||||||
|
}{
|
||||||
|
{"one removed and one live", func(*testing.T, *Store, moveFixture) {}, true, "a"},
|
||||||
|
{"removed row's agent was revoked", func(t *testing.T, s *Store, f moveFixture) {
|
||||||
|
_, err := s.RevokeAgent(context.Background(), f.a, t0.Add(3*time.Hour))
|
||||||
|
require.NoError(t, err)
|
||||||
|
}, true, "a"},
|
||||||
|
{"two live targets", func(t *testing.T, s *Store, f moveFixture) {
|
||||||
|
c := newAgent(t, s, "c")
|
||||||
|
require.NoError(t, s.ApplySnapshot(context.Background(), c, worker, []protocol.Service{slusk}, t0.Add(3*time.Hour)))
|
||||||
|
}, false, ""},
|
||||||
|
{"two removed sources", func(t *testing.T, s *Store, f moveFixture) {
|
||||||
|
ctx := context.Background()
|
||||||
|
d := newAgent(t, s, "d")
|
||||||
|
require.NoError(t, s.ApplySnapshot(ctx, d, worker, []protocol.Service{slusk}, t0))
|
||||||
|
require.NoError(t, s.ApplySnapshot(ctx, d, worker, nil, t0.Add(time.Hour)))
|
||||||
|
}, false, ""},
|
||||||
|
{"target has history", func(t *testing.T, s *Store, f moveFixture) {
|
||||||
|
finishJob(t, s, f.b, f.to, "r:1", sluskDrift+":1.4.2@"+digNew)
|
||||||
|
}, false, ""},
|
||||||
|
{"target has a desired ref", func(t *testing.T, s *Store, f moveFixture) {
|
||||||
|
mustExec(t, s, `UPDATE services SET desired_ref = 'r:1@x' WHERE id = $1`, f.to)
|
||||||
|
}, false, ""},
|
||||||
|
{"source has no history", func(t *testing.T, s *Store, f moveFixture) {
|
||||||
|
mustExec(t, s, `DELETE FROM update_jobs WHERE service_id = $1`, f.from)
|
||||||
|
mustExec(t, s, `UPDATE services SET desired_ref = '', update_mode = 'manual' WHERE id = $1`, f.from)
|
||||||
|
}, false, ""},
|
||||||
|
{"source only has auto mode", func(t *testing.T, s *Store, f moveFixture) {
|
||||||
|
mustExec(t, s, `DELETE FROM update_jobs WHERE service_id = $1`, f.from)
|
||||||
|
mustExec(t, s, `UPDATE services SET desired_ref = '' WHERE id = $1`, f.from)
|
||||||
|
}, true, "a"},
|
||||||
|
{"source only has jobs", func(t *testing.T, s *Store, f moveFixture) {
|
||||||
|
mustExec(t, s, `UPDATE services SET desired_ref = '', update_mode = 'manual' WHERE id = $1`, f.from)
|
||||||
|
}, true, "a"},
|
||||||
|
{"target is older than the source", func(t *testing.T, s *Store, f moveFixture) {
|
||||||
|
mustExec(t, s, `UPDATE services SET first_seen_at = $2 WHERE id = $1`, f.to, t0.Add(-time.Hour))
|
||||||
|
}, false, ""},
|
||||||
|
{"plain containers", func(t *testing.T, s *Store, f moveFixture) {
|
||||||
|
mustExec(t, s, `UPDATE services SET kind = 'container' WHERE id IN ($1, $2)`, f.from, f.to)
|
||||||
|
}, false, ""},
|
||||||
|
{"swarm services", func(t *testing.T, s *Store, f moveFixture) {
|
||||||
|
mustExec(t, s, `UPDATE services SET kind = 'swarm' WHERE id IN ($1, $2)`, f.from, f.to)
|
||||||
|
}, false, ""},
|
||||||
|
{"the updater itself (target)", func(t *testing.T, s *Store, f moveFixture) {
|
||||||
|
mustExec(t, s, `UPDATE services SET self = true WHERE id = $1`, f.to)
|
||||||
|
}, false, ""},
|
||||||
|
{"the updater itself (source)", func(t *testing.T, s *Store, f moveFixture) {
|
||||||
|
mustExec(t, s, `UPDATE services SET self = true WHERE id = $1`, f.from)
|
||||||
|
}, false, ""},
|
||||||
|
{"live row also removed", func(t *testing.T, s *Store, f moveFixture) {
|
||||||
|
require.NoError(t, s.RemoveService(context.Background(), f.b, "", slusk.Key(), t0.Add(3*time.Hour)))
|
||||||
|
}, false, ""},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
f := newMoveFixture(t, s)
|
||||||
|
tt.mutate(t, s, f)
|
||||||
|
|
||||||
|
got, err := s.MoveCandidates(context.Background())
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
if !tt.want {
|
||||||
|
assert.Empty(t, got)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assert.Equal(t, []model.Move{{FromID: f.from, ToID: f.to, FromAgent: tt.from, ToAgent: "b"}}, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeService_MovesHistoryModeAndDesiredToTheNewLocation(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
f := newMoveFixture(t, s)
|
||||||
|
finishJob(t, s, f.b, f.to, "r:2", sluskDrift+":2.0.0@"+digOld)
|
||||||
|
require.NoError(t, s.SetLastTagDigest(ctx, f.to, "sha256:fromto"))
|
||||||
|
require.NoError(t, s.SetAvailable(ctx, f.to, model.Available{Ref: sluskDrift + ":3", Digest: digNew, Source: model.SourcePoll, At: t0}))
|
||||||
|
require.NoError(t, s.SetPollError(ctx, []int64{f.to}, "registry down"))
|
||||||
|
require.NoError(t, s.SetNewer(ctx, f.to, sluskDrift+":3.1", t0))
|
||||||
|
// A job on the old row is finished, and awaiting_inventory is set on both.
|
||||||
|
before, err := s.GetService(ctx, f.from)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.NoError(t, s.MergeService(ctx, f.from, f.to))
|
||||||
|
|
||||||
|
got, err := s.GetService(ctx, f.from)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, f.from, got.ID, "the old id survives")
|
||||||
|
assert.Nil(t, got.RemovedAt)
|
||||||
|
assert.False(t, got.AwaitingInventory)
|
||||||
|
assert.Equal(t, "b", got.AgentName)
|
||||||
|
assert.Equal(t, model.LocationKey(f.b, "", slusk.Key()), got.LocationKey)
|
||||||
|
assert.Equal(t, before.FirstSeenAt, got.FirstSeenAt, "first_seen_at kept")
|
||||||
|
assert.Equal(t, model.ModeAuto, got.UpdateMode, "mode kept")
|
||||||
|
assert.Equal(t, before.DesiredRef, got.DesiredRef, "desired ref kept")
|
||||||
|
assert.Equal(t, slusk.Image, got.ActualRef, "inventory from the duplicate")
|
||||||
|
assert.Equal(t, digD1, got.ActualDigest)
|
||||||
|
assert.Equal(t, "running", got.Status)
|
||||||
|
assert.Equal(t, sluskDrift+":3", got.AvailableRef)
|
||||||
|
assert.Equal(t, digNew, got.AvailableDigest)
|
||||||
|
assert.Equal(t, "registry down", got.LastPollError)
|
||||||
|
assert.Equal(t, sluskDrift+":3.1", got.NewerRef, "newer tag from the duplicate")
|
||||||
|
require.NotNil(t, got.NewerAt)
|
||||||
|
assert.True(t, t0.Equal(*got.NewerAt))
|
||||||
|
assert.Equal(t, before.LastTagDigest, got.LastTagDigest, "an existing last_tag_digest wins")
|
||||||
|
|
||||||
|
_, err = s.GetService(ctx, f.to)
|
||||||
|
assert.ErrorIs(t, err, model.ErrNotFound, "the duplicate is deleted")
|
||||||
|
jobs, err := s.ListJobs(ctx, model.JobFilter{ServiceID: f.from})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, jobs, 2, "the duplicate's job moved to the survivor")
|
||||||
|
live, err := s.ListServices(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"slusk/web"}, names(live))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeService_TakesLastTagDigestOfTheDuplicateWhenTheOldRowHasNone(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
f := newMoveFixture(t, s)
|
||||||
|
mustExec(t, s, `UPDATE services SET last_tag_digest = '' WHERE id = $1`, f.from)
|
||||||
|
require.NoError(t, s.SetLastTagDigest(ctx, f.to, "sha256:fromto"))
|
||||||
|
|
||||||
|
require.NoError(t, s.MergeService(ctx, f.from, f.to))
|
||||||
|
|
||||||
|
got, err := s.GetService(ctx, f.from)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "sha256:fromto", got.LastTagDigest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeService_ManualMergeOfAnyRemovedServiceOfTheSameKind(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
f := newMoveFixture(t, s)
|
||||||
|
other := protocol.Service{Kind: protocol.KindCompose, ComposeProject: "renamed", ComposeService: "app", Image: "x/y:1", Status: "running"}
|
||||||
|
require.NoError(t, s.UpsertService(ctx, f.b, "", other, t0.Add(3*time.Hour)))
|
||||||
|
target := serviceID(t, s, f.b, other)
|
||||||
|
|
||||||
|
require.NoError(t, s.MergeService(ctx, f.from, target))
|
||||||
|
|
||||||
|
got, err := s.GetService(ctx, f.from)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "renamed/app", got.DisplayName(), "names may differ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeService_SameAgentIsAllowed(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
f := newMoveFixture(t, s)
|
||||||
|
other := protocol.Service{Kind: protocol.KindCompose, ComposeProject: "renamed", ComposeService: "app", Image: "x/y:1", Status: "running"}
|
||||||
|
require.NoError(t, s.UpsertService(ctx, f.a, "", other, t0.Add(3*time.Hour)))
|
||||||
|
|
||||||
|
require.NoError(t, s.MergeService(ctx, f.from, serviceID(t, s, f.a, other)))
|
||||||
|
|
||||||
|
got, err := s.GetService(ctx, f.from)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "a", got.AgentName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeService_Swarm(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
m1 := newAgent(t, s, "m1")
|
||||||
|
require.NoError(t, s.ApplySnapshot(ctx, m1, manager, []protocol.Service{gitea}, t0))
|
||||||
|
oldID := serviceIDByName(t, s, "gitea_gitea")
|
||||||
|
require.NoError(t, s.ApplySnapshot(ctx, m1, manager, nil, t0.Add(time.Hour)))
|
||||||
|
renamed := gitea
|
||||||
|
renamed.SwarmService = "gitea2_gitea"
|
||||||
|
require.NoError(t, s.ApplySnapshot(ctx, m1, manager, []protocol.Service{renamed}, t0.Add(2*time.Hour)))
|
||||||
|
newID := serviceIDByName(t, s, "gitea2_gitea")
|
||||||
|
|
||||||
|
require.NoError(t, s.MergeService(ctx, oldID, newID))
|
||||||
|
|
||||||
|
got, err := s.GetService(ctx, oldID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "gitea2_gitea", got.SwarmService)
|
||||||
|
assert.Nil(t, got.RemovedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func serviceIDByName(t *testing.T, s *Store, swarmService string) int64 {
|
||||||
|
t.Helper()
|
||||||
|
var id int64
|
||||||
|
require.NoError(t, s.pool.QueryRow(context.Background(),
|
||||||
|
`SELECT id FROM services WHERE swarm_service = $1`, swarmService).Scan(&id))
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeService_Refusals(t *testing.T) {
|
||||||
|
refused := func(reason string) func(*testing.T, error) {
|
||||||
|
return func(t *testing.T, err error) {
|
||||||
|
var mr *model.MergeRefusedError
|
||||||
|
require.True(t, errors.As(err, &mr), "got %v", err)
|
||||||
|
assert.Equal(t, reason, mr.Reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is := func(want error) func(*testing.T, error) {
|
||||||
|
return func(t *testing.T, err error) { assert.ErrorIs(t, err, want) }
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
run func(t *testing.T, s *Store, f moveFixture) error
|
||||||
|
assert func(*testing.T, error)
|
||||||
|
}{
|
||||||
|
{"into itself", func(t *testing.T, s *Store, f moveFixture) error {
|
||||||
|
return s.MergeService(context.Background(), f.to, f.to)
|
||||||
|
}, refused(model.ReasonMergeSame)},
|
||||||
|
{"the old service still exists", func(t *testing.T, s *Store, f moveFixture) error {
|
||||||
|
mustExec(t, s, `UPDATE services SET removed_at = NULL WHERE id = $1`, f.from)
|
||||||
|
return s.MergeService(context.Background(), f.from, f.to)
|
||||||
|
}, refused(model.ReasonMergeLiveOld)},
|
||||||
|
{"the new service was removed", func(t *testing.T, s *Store, f moveFixture) error {
|
||||||
|
require.NoError(t, s.RemoveService(context.Background(), f.b, "", slusk.Key(), t0.Add(3*time.Hour)))
|
||||||
|
return s.MergeService(context.Background(), f.from, f.to)
|
||||||
|
}, refused(model.ReasonMergeGoneNew)},
|
||||||
|
{"different kinds", func(t *testing.T, s *Store, f moveFixture) error {
|
||||||
|
mustExec(t, s, `UPDATE services SET kind = 'swarm' WHERE id = $1`, f.to)
|
||||||
|
return s.MergeService(context.Background(), f.from, f.to)
|
||||||
|
}, refused(model.ReasonMergeKind)},
|
||||||
|
{"plain containers", func(t *testing.T, s *Store, f moveFixture) error {
|
||||||
|
mustExec(t, s, `UPDATE services SET kind = 'container' WHERE id IN ($1, $2)`, f.from, f.to)
|
||||||
|
return s.MergeService(context.Background(), f.from, f.to)
|
||||||
|
}, refused(model.ReasonMergeContainer)},
|
||||||
|
{"the updater itself (new)", func(t *testing.T, s *Store, f moveFixture) error {
|
||||||
|
mustExec(t, s, `UPDATE services SET self = true WHERE id = $1`, f.to)
|
||||||
|
return s.MergeService(context.Background(), f.from, f.to)
|
||||||
|
}, refused(model.ReasonSelf)},
|
||||||
|
{"the updater itself (old)", func(t *testing.T, s *Store, f moveFixture) error {
|
||||||
|
mustExec(t, s, `UPDATE services SET self = true WHERE id = $1`, f.from)
|
||||||
|
return s.MergeService(context.Background(), f.from, f.to)
|
||||||
|
}, refused(model.ReasonSelf)},
|
||||||
|
{"active job on the old service", func(t *testing.T, s *Store, f moveFixture) error {
|
||||||
|
_, err := s.CreateJob(context.Background(), newJob(f.from, f.a))
|
||||||
|
require.NoError(t, err)
|
||||||
|
return s.MergeService(context.Background(), f.from, f.to)
|
||||||
|
}, is(model.ErrJobActive)},
|
||||||
|
{"active job on the new service", func(t *testing.T, s *Store, f moveFixture) error {
|
||||||
|
_, err := s.CreateJob(context.Background(), newJob(f.to, f.b))
|
||||||
|
require.NoError(t, err)
|
||||||
|
return s.MergeService(context.Background(), f.from, f.to)
|
||||||
|
}, is(model.ErrJobActive)},
|
||||||
|
{"unknown old service", func(t *testing.T, s *Store, f moveFixture) error {
|
||||||
|
return s.MergeService(context.Background(), 9999, f.to)
|
||||||
|
}, is(model.ErrNotFound)},
|
||||||
|
{"unknown new service", func(t *testing.T, s *Store, f moveFixture) error {
|
||||||
|
return s.MergeService(context.Background(), f.from, 9999)
|
||||||
|
}, is(model.ErrNotFound)},
|
||||||
|
{"the new service's agent is revoked", func(t *testing.T, s *Store, f moveFixture) error {
|
||||||
|
_, err := s.RevokeAgent(context.Background(), f.b, t0.Add(3*time.Hour))
|
||||||
|
require.NoError(t, err)
|
||||||
|
return s.MergeService(context.Background(), f.from, f.to)
|
||||||
|
}, is(model.ErrRevoked)},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
f := newMoveFixture(t, s)
|
||||||
|
var rowsBefore int
|
||||||
|
require.NoError(t, s.pool.QueryRow(context.Background(), `SELECT count(*) FROM services`).Scan(&rowsBefore))
|
||||||
|
|
||||||
|
err := tt.run(t, s, f)
|
||||||
|
|
||||||
|
tt.assert(t, err)
|
||||||
|
var rowsAfter int
|
||||||
|
require.NoError(t, s.pool.QueryRow(context.Background(), `SELECT count(*) FROM services`).Scan(&rowsAfter))
|
||||||
|
assert.Equal(t, rowsBefore, rowsAfter, "nothing is deleted when the merge is refused")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeService_InventoryAfterTheMerge(t *testing.T) {
|
||||||
|
setup := func(t *testing.T) (*Store, moveFixture) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
f := newMoveFixture(t, s)
|
||||||
|
require.NoError(t, s.MergeService(context.Background(), f.from, f.to))
|
||||||
|
return s, f
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("an upsert for the new location updates the survivor", func(t *testing.T) {
|
||||||
|
s, f := setup(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
moved := slusk
|
||||||
|
moved.Digest = digOld
|
||||||
|
require.NoError(t, s.UpsertService(ctx, f.b, "", moved, t0.Add(4*time.Hour)))
|
||||||
|
|
||||||
|
live, err := s.ListServices(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, live, 1)
|
||||||
|
assert.Equal(t, f.from, live[0].ID)
|
||||||
|
assert.Equal(t, digOld, live[0].ActualDigest)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a snapshot keeps the survivor", func(t *testing.T) {
|
||||||
|
s, f := setup(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
require.NoError(t, s.ApplySnapshot(ctx, f.b, worker, []protocol.Service{slusk}, t0.Add(4*time.Hour)))
|
||||||
|
|
||||||
|
got, err := s.GetService(ctx, f.from)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, got.RemovedAt)
|
||||||
|
live, err := s.ListServices(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, live, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the old agent returning with the stack creates a new row", func(t *testing.T) {
|
||||||
|
s, f := setup(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
require.NoError(t, s.ApplySnapshot(ctx, f.a, worker, []protocol.Service{slusk}, t0.Add(4*time.Hour)))
|
||||||
|
|
||||||
|
returned := serviceID(t, s, f.a, slusk)
|
||||||
|
assert.NotEqual(t, f.from, returned)
|
||||||
|
got, err := s.GetService(ctx, returned)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, model.ModeManual, got.UpdateMode)
|
||||||
|
assert.Empty(t, got.DesiredRef)
|
||||||
|
live, err := s.ListServices(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, live, 2)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeSources(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
f := newMoveFixture(t, s)
|
||||||
|
// A removed service with another name, removed later than the matching one.
|
||||||
|
other := protocol.Service{Kind: protocol.KindCompose, ComposeProject: "renamed", ComposeService: "app", Image: "x/y:1", Status: "running"}
|
||||||
|
require.NoError(t, s.UpsertService(ctx, f.a, "", other, t0))
|
||||||
|
require.NoError(t, s.RemoveService(ctx, f.a, "", other.Key(), t0.Add(5*time.Hour)))
|
||||||
|
otherID := serviceID(t, s, f.a, other)
|
||||||
|
// Other kinds, the updater itself and a live service never show up.
|
||||||
|
require.NoError(t, s.ApplySnapshot(ctx, f.a, worker, []protocol.Service{adhoc}, t0.Add(6*time.Hour)))
|
||||||
|
require.NoError(t, s.ApplySnapshot(ctx, f.a, worker, nil, t0.Add(7*time.Hour)))
|
||||||
|
selfSvc := protocol.Service{Kind: protocol.KindCompose, ComposeProject: "pu", ComposeService: "agent", Image: "pu:1", Status: "running", Self: true}
|
||||||
|
require.NoError(t, s.UpsertService(ctx, f.a, "", selfSvc, t0))
|
||||||
|
require.NoError(t, s.RemoveService(ctx, f.a, "", selfSvc.Key(), t0.Add(8*time.Hour)))
|
||||||
|
|
||||||
|
got, err := s.MergeSources(ctx, f.to)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
var ids []int64
|
||||||
|
for _, m := range got {
|
||||||
|
ids = append(ids, m.ID)
|
||||||
|
}
|
||||||
|
assert.Equal(t, []int64{f.from, otherID}, ids, "same name first, then the most recently removed")
|
||||||
|
assert.Equal(t, "a", got[0].AgentName)
|
||||||
|
|
||||||
|
_, err = s.MergeSources(ctx, 9999)
|
||||||
|
assert.ErrorIs(t, err, model.ErrNotFound)
|
||||||
|
|
||||||
|
containerID := serviceID(t, s, f.a, adhoc)
|
||||||
|
got, err = s.MergeSources(ctx, containerID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, got, "plain containers cannot be merged")
|
||||||
|
}
|
||||||
@@ -4,11 +4,13 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgconn"
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
|
||||||
|
"github.com/shcizo/package-updater/internal/imageref"
|
||||||
"github.com/shcizo/package-updater/internal/master/model"
|
"github.com/shcizo/package-updater/internal/master/model"
|
||||||
"github.com/shcizo/package-updater/internal/protocol"
|
"github.com/shcizo/package-updater/internal/protocol"
|
||||||
)
|
)
|
||||||
@@ -89,6 +91,9 @@ func (s *Store) CreateJob(ctx context.Context, j model.NewJob) (model.Job, error
|
|||||||
}
|
}
|
||||||
return active, model.ErrJobActive
|
return active, model.ErrJobActive
|
||||||
}
|
}
|
||||||
|
if errors.As(err, &pgErr) && pgErr.Code == "23503" && pgErr.ConstraintName == "update_jobs_service_id_fkey" {
|
||||||
|
return model.Job{}, model.ErrNotFound
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return model.Job{}, fmt.Errorf("create job: %w", err)
|
return model.Job{}, fmt.Errorf("create job: %w", err)
|
||||||
}
|
}
|
||||||
@@ -187,7 +192,12 @@ func (s *Store) JobProgress(ctx context.Context, agentID, jobID int64, chunk str
|
|||||||
// agentID: status, error, output (replacing what progress assembled),
|
// agentID: status, error, output (replacing what progress assembled),
|
||||||
// started_at (the agent's, or the existing one if the agent sent none),
|
// started_at (the agent's, or the existing one if the agent sent none),
|
||||||
// finished_at; to_ref = f.Ref when set. On success it also sets the
|
// finished_at; to_ref = f.Ref when set. On success it also sets the
|
||||||
// service's desired_ref = f.Ref, in the same transaction. Returns false if no
|
// service's desired_ref = f.Ref and clears its available ref when that is
|
||||||
|
// the ref the job was created for and was reported no later than the job was
|
||||||
|
// created (that version is now running), in the same transaction. A job
|
||||||
|
// created for "repo:tag@digest" clears only a suggestion with that same
|
||||||
|
// digest, so rolling back to an older digest keeps a newer suggestion (and a
|
||||||
|
// suggestion without a digest, such as a webhook's). Returns false if no
|
||||||
// such active job belongs to agentID.
|
// such active job belongs to agentID.
|
||||||
func (s *Store) FinishJob(ctx context.Context, agentID, jobID int64, f model.JobFinish) (bool, error) {
|
func (s *Store) FinishJob(ctx context.Context, agentID, jobID int64, f model.JobFinish) (bool, error) {
|
||||||
tx, err := s.pool.Begin(ctx)
|
tx, err := s.pool.Begin(ctx)
|
||||||
@@ -203,14 +213,18 @@ func (s *Store) FinishJob(ctx context.Context, agentID, jobID int64, f model.Job
|
|||||||
startedAt = &f.StartedAt
|
startedAt = &f.StartedAt
|
||||||
}
|
}
|
||||||
var serviceID int64
|
var serviceID int64
|
||||||
|
var createdAt time.Time
|
||||||
|
var requestedRef string
|
||||||
|
// requested is the job's to_ref as it was created, before f.Ref overwrites it.
|
||||||
err = tx.QueryRow(ctx, `
|
err = tx.QueryRow(ctx, `
|
||||||
UPDATE update_jobs SET
|
UPDATE update_jobs j SET
|
||||||
status = $3, error = $4, output = $5, output_truncated = $6,
|
status = $3, error = $4, output = $5, output_truncated = $6,
|
||||||
to_ref = CASE WHEN $7::text <> '' THEN $7::text ELSE to_ref END,
|
to_ref = CASE WHEN $7::text <> '' THEN $7::text ELSE j.to_ref END,
|
||||||
started_at = COALESCE($8, started_at), finished_at = $9
|
started_at = COALESCE($8, j.started_at), finished_at = $9
|
||||||
WHERE id = $1 AND agent_id = $2 AND status IN ('queued', 'running')
|
FROM (SELECT id, to_ref FROM update_jobs WHERE id = $1) requested
|
||||||
RETURNING service_id`,
|
WHERE j.id = requested.id AND j.agent_id = $2 AND j.status IN ('queued', 'running')
|
||||||
jobID, agentID, string(f.Status), f.Error, f.Output, f.OutputTruncated, f.Ref, startedAt, f.FinishedAt).Scan(&serviceID)
|
RETURNING j.service_id, j.created_at, requested.to_ref`,
|
||||||
|
jobID, agentID, string(f.Status), f.Error, f.Output, f.OutputTruncated, f.Ref, startedAt, f.FinishedAt).Scan(&serviceID, &createdAt, &requestedRef)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
@@ -218,7 +232,21 @@ func (s *Store) FinishJob(ctx context.Context, agentID, jobID int64, f model.Job
|
|||||||
return false, fmt.Errorf("finish job: %w", err)
|
return false, fmt.Errorf("finish job: %w", err)
|
||||||
}
|
}
|
||||||
if f.Status == model.JobSucceeded {
|
if f.Status == model.JobSucceeded {
|
||||||
if _, err := tx.Exec(ctx, `UPDATE services SET desired_ref = $2 WHERE id = $1`, serviceID, f.Ref); err != nil {
|
requestedRef, requestedDigest, _ := strings.Cut(requestedRef, "@")
|
||||||
|
// A plain tag job, whoever started it, deployed the tag at the digest
|
||||||
|
// the agent pinned; remember it. Pinned and tagless picks do not.
|
||||||
|
tagDigest := ""
|
||||||
|
if _, d, _ := strings.Cut(f.Ref, "@"); requestedDigest == "" && imageref.IsDigest(d) {
|
||||||
|
tagDigest = d
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
UPDATE services SET desired_ref = $2, awaiting_inventory = true,
|
||||||
|
last_tag_digest = CASE WHEN $6 <> '' THEN $6 ELSE last_tag_digest END,
|
||||||
|
available_ref = CASE WHEN available_ref = $4 AND available_at <= $3 AND ($5 = '' OR available_digest = $5) THEN '' ELSE available_ref END,
|
||||||
|
available_digest = CASE WHEN available_ref = $4 AND available_at <= $3 AND ($5 = '' OR available_digest = $5) THEN '' ELSE available_digest END,
|
||||||
|
available_source = CASE WHEN available_ref = $4 AND available_at <= $3 AND ($5 = '' OR available_digest = $5) THEN '' ELSE available_source END,
|
||||||
|
available_at = CASE WHEN available_ref = $4 AND available_at <= $3 AND ($5 = '' OR available_digest = $5) THEN NULL ELSE available_at END
|
||||||
|
WHERE id = $1`, serviceID, f.Ref, createdAt, requestedRef, requestedDigest, tagDigest); err != nil {
|
||||||
return false, fmt.Errorf("finish job: desired ref: %w", err)
|
return false, fmt.Errorf("finish job: desired ref: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
||||||
|
"github.com/shcizo/package-updater/internal/master/model"
|
||||||
|
"github.com/shcizo/package-updater/internal/protocol"
|
||||||
|
)
|
||||||
|
|
||||||
|
// moveCandidatesSQL pairs a removed Compose service with a live one of the
|
||||||
|
// same project and service name on another agent. A pair counts only when it
|
||||||
|
// is the single one for both rows, and the live row is new (newer than the
|
||||||
|
// removed one, never updated, no jobs) while the removed one has something
|
||||||
|
// worth moving (a desired ref, auto mode or jobs).
|
||||||
|
const moveCandidatesSQL = `
|
||||||
|
WITH pairs AS (
|
||||||
|
SELECT f.id AS from_id, t.id AS to_id
|
||||||
|
FROM services f
|
||||||
|
JOIN services t ON t.kind = 'compose' AND t.removed_at IS NULL AND NOT t.self
|
||||||
|
AND t.compose_project = f.compose_project AND t.compose_service = f.compose_service
|
||||||
|
AND t.agent_id IS DISTINCT FROM f.agent_id
|
||||||
|
WHERE f.kind = 'compose' AND f.removed_at IS NOT NULL AND NOT f.self
|
||||||
|
), uniq AS (
|
||||||
|
SELECT from_id, to_id FROM pairs p
|
||||||
|
WHERE (SELECT count(*) FROM pairs q WHERE q.from_id = p.from_id) = 1
|
||||||
|
AND (SELECT count(*) FROM pairs q WHERE q.to_id = p.to_id) = 1
|
||||||
|
)
|
||||||
|
SELECT u.from_id, u.to_id, COALESCE(fa.name, ''), COALESCE(ta.name, '')
|
||||||
|
FROM uniq u
|
||||||
|
JOIN services f ON f.id = u.from_id
|
||||||
|
JOIN services t ON t.id = u.to_id
|
||||||
|
LEFT JOIN agents fa ON fa.id = f.agent_id
|
||||||
|
LEFT JOIN agents ta ON ta.id = t.agent_id
|
||||||
|
WHERE t.first_seen_at > f.first_seen_at
|
||||||
|
AND t.desired_ref = ''
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM update_jobs j WHERE j.service_id = t.id)
|
||||||
|
AND (f.desired_ref <> '' OR f.update_mode = 'auto'
|
||||||
|
OR EXISTS (SELECT 1 FROM update_jobs j WHERE j.service_id = f.id))
|
||||||
|
ORDER BY u.to_id`
|
||||||
|
|
||||||
|
// MoveCandidates returns the removed Compose services that look like they
|
||||||
|
// moved to another agent; see model.Move. It only suggests: nothing is merged.
|
||||||
|
func (s *Store) MoveCandidates(ctx context.Context) ([]model.Move, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, moveCandidatesSQL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("move candidates: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []model.Move
|
||||||
|
for rows.Next() {
|
||||||
|
var m model.Move
|
||||||
|
if err := rows.Scan(&m.FromID, &m.ToID, &m.FromAgent, &m.ToAgent); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan move: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeSources returns up to 50 removed services that could be merged into
|
||||||
|
// the service toID: the same kind (Compose or Swarm), not the updater itself,
|
||||||
|
// those with the same name first, then the most recently removed. Returns
|
||||||
|
// model.ErrNotFound if toID does not exist.
|
||||||
|
func (s *Store) MergeSources(ctx context.Context, toID int64) ([]model.Service, error) {
|
||||||
|
to, err := s.GetService(ctx, toID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if to.Kind == protocol.KindContainer {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT `+serviceColumns+`
|
||||||
|
FROM services s LEFT JOIN agents a ON a.id = s.agent_id
|
||||||
|
WHERE s.removed_at IS NOT NULL AND s.kind = $1 AND NOT s.self AND s.id <> $2
|
||||||
|
ORDER BY (s.compose_project, s.compose_service, s.swarm_service) = ($3, $4, $5) DESC,
|
||||||
|
s.removed_at DESC, s.id DESC
|
||||||
|
LIMIT 50`,
|
||||||
|
string(to.Kind), toID, to.ComposeProject, to.ComposeService, to.SwarmService)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("merge sources: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []model.Service
|
||||||
|
for rows.Next() {
|
||||||
|
m, err := scanService(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("scan service: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeRow is what MergeService needs to know about one of the two rows.
|
||||||
|
type mergeRow struct {
|
||||||
|
kind string
|
||||||
|
agentID *int64
|
||||||
|
removed bool
|
||||||
|
self bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeService moves the live service toID onto the removed service fromID:
|
||||||
|
// the old row keeps its id, first_seen_at, update mode and desired ref, takes
|
||||||
|
// the location and inventory of the live row (and its available and newer-tag
|
||||||
|
// suggestions), becomes live, and the live row is deleted. The jobs of the live
|
||||||
|
// row move to the old one. This is the only place a services row is deleted.
|
||||||
|
//
|
||||||
|
// It returns *model.MergeRefusedError when the pair cannot be merged,
|
||||||
|
// model.ErrJobActive when either row has a queued or running job,
|
||||||
|
// model.ErrRevoked when the live row's agent is revoked, model.ErrNotFound
|
||||||
|
// when a row does not exist, and model.ErrConflict when the live row moved to
|
||||||
|
// another agent while the merge started.
|
||||||
|
//
|
||||||
|
// Locks are taken in the order inventory writes use: the agent row first,
|
||||||
|
// then the service rows by id. The live row is deleted before the old one is
|
||||||
|
// updated because location_key is unique.
|
||||||
|
func (s *Store) MergeService(ctx context.Context, fromID, toID int64) error {
|
||||||
|
if fromID == toID {
|
||||||
|
return &model.MergeRefusedError{Reason: model.ReasonMergeSame}
|
||||||
|
}
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin merge: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback(ctx) }()
|
||||||
|
|
||||||
|
var toAgent *int64
|
||||||
|
err = tx.QueryRow(ctx, `SELECT agent_id FROM services WHERE id = $1`, toID).Scan(&toAgent)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return model.ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("merge: find new service: %w", err)
|
||||||
|
}
|
||||||
|
if toAgent != nil {
|
||||||
|
if err := lockActiveAgent(ctx, tx, *toAgent); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := tx.Query(ctx, `
|
||||||
|
SELECT id, kind, agent_id, removed_at IS NOT NULL, self FROM services
|
||||||
|
WHERE id IN ($1, $2) ORDER BY id FOR UPDATE`, fromID, toID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("merge: lock services: %w", err)
|
||||||
|
}
|
||||||
|
locked := map[int64]mergeRow{}
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
var r mergeRow
|
||||||
|
if err := rows.Scan(&id, &r.kind, &r.agentID, &r.removed, &r.self); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return fmt.Errorf("merge: scan service: %w", err)
|
||||||
|
}
|
||||||
|
locked[id] = r
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return fmt.Errorf("merge: lock services: %w", err)
|
||||||
|
}
|
||||||
|
from, okFrom := locked[fromID]
|
||||||
|
to, okTo := locked[toID]
|
||||||
|
if !okFrom || !okTo {
|
||||||
|
return model.ErrNotFound
|
||||||
|
}
|
||||||
|
if (to.agentID == nil) != (toAgent == nil) || (toAgent != nil && *to.agentID != *toAgent) {
|
||||||
|
return model.ErrConflict
|
||||||
|
}
|
||||||
|
if reason := mergeRefusal(from, to); reason != "" {
|
||||||
|
return &model.MergeRefusedError{Reason: reason}
|
||||||
|
}
|
||||||
|
|
||||||
|
var active bool
|
||||||
|
if err := tx.QueryRow(ctx, `
|
||||||
|
SELECT EXISTS (SELECT 1 FROM update_jobs
|
||||||
|
WHERE service_id IN ($1, $2) AND status IN ('queued', 'running'))`,
|
||||||
|
fromID, toID).Scan(&active); err != nil {
|
||||||
|
return fmt.Errorf("merge: check active jobs: %w", err)
|
||||||
|
}
|
||||||
|
if active {
|
||||||
|
return model.ErrJobActive
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, `UPDATE update_jobs SET service_id = $1 WHERE service_id = $2`, fromID, toID); err != nil {
|
||||||
|
return fmt.Errorf("merge: move jobs: %w", err)
|
||||||
|
}
|
||||||
|
var t model.Service
|
||||||
|
var lastTagDigest string
|
||||||
|
err = tx.QueryRow(ctx, `
|
||||||
|
SELECT agent_id, location_key, compose_project, compose_service, working_dir, config_files, built,
|
||||||
|
swarm_cluster_id, swarm_service, container_name, image_repo, actual_ref, actual_digest, status, self,
|
||||||
|
updated_at, available_ref, available_digest, available_source, available_at, last_poll_error,
|
||||||
|
last_tag_digest, newer_ref, newer_at
|
||||||
|
FROM services WHERE id = $1`, toID).Scan(
|
||||||
|
&t.AgentID, &t.LocationKey, &t.ComposeProject, &t.ComposeService, &t.WorkingDir, &t.ConfigFiles, &t.Built,
|
||||||
|
&t.SwarmClusterID, &t.SwarmService, &t.ContainerName, &t.ImageRepo, &t.ActualRef, &t.ActualDigest, &t.Status, &t.Self,
|
||||||
|
&t.UpdatedAt, &t.AvailableRef, &t.AvailableDigest, &t.AvailableSource, &t.AvailableAt, &t.LastPollError,
|
||||||
|
&lastTagDigest, &t.NewerRef, &t.NewerAt)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("merge: read new service: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `DELETE FROM services WHERE id = $1`, toID); err != nil {
|
||||||
|
return fmt.Errorf("merge: delete new service: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
UPDATE services SET
|
||||||
|
agent_id = $2, location_key = $3, compose_project = $4, compose_service = $5, working_dir = $6,
|
||||||
|
config_files = $7, built = $8, swarm_cluster_id = $9, swarm_service = $10, container_name = $11,
|
||||||
|
image_repo = $12, actual_ref = $13, actual_digest = $14, status = $15, self = $16, updated_at = $17,
|
||||||
|
available_ref = $18, available_digest = $19, available_source = $20, available_at = $21,
|
||||||
|
last_poll_error = $22,
|
||||||
|
last_tag_digest = CASE WHEN last_tag_digest <> '' THEN last_tag_digest ELSE $23 END,
|
||||||
|
newer_ref = $24, newer_at = $25,
|
||||||
|
removed_at = NULL, awaiting_inventory = false
|
||||||
|
WHERE id = $1`,
|
||||||
|
fromID, t.AgentID, t.LocationKey, t.ComposeProject, t.ComposeService, t.WorkingDir,
|
||||||
|
t.ConfigFiles, t.Built, t.SwarmClusterID, t.SwarmService, t.ContainerName,
|
||||||
|
t.ImageRepo, t.ActualRef, t.ActualDigest, t.Status, t.Self, t.UpdatedAt,
|
||||||
|
t.AvailableRef, t.AvailableDigest, t.AvailableSource, t.AvailableAt,
|
||||||
|
t.LastPollError, lastTagDigest, t.NewerRef, t.NewerAt); err != nil {
|
||||||
|
return fmt.Errorf("merge: update old service: %w", err)
|
||||||
|
}
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return fmt.Errorf("commit merge: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeRefusal returns why from (the old row) cannot be merged with to (the
|
||||||
|
// live one), or "".
|
||||||
|
func mergeRefusal(from, to mergeRow) string {
|
||||||
|
switch {
|
||||||
|
case !from.removed:
|
||||||
|
return model.ReasonMergeLiveOld
|
||||||
|
case to.removed:
|
||||||
|
return model.ReasonMergeGoneNew
|
||||||
|
case from.kind != to.kind:
|
||||||
|
return model.ReasonMergeKind
|
||||||
|
case from.kind == string(protocol.KindContainer):
|
||||||
|
return model.ReasonMergeContainer
|
||||||
|
case from.self || to.self:
|
||||||
|
return model.ReasonSelf
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
ALTER TABLE services ADD COLUMN available_ref text NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE services ADD COLUMN available_source text NOT NULL DEFAULT ''
|
||||||
|
CHECK (available_source IN ('', 'webhook'));
|
||||||
|
ALTER TABLE services ADD COLUMN available_at timestamptz;
|
||||||
|
|
||||||
|
CREATE INDEX services_by_image_repo ON services (image_repo) WHERE removed_at IS NULL;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
ALTER TABLE services DROP CONSTRAINT services_available_source_check;
|
||||||
|
ALTER TABLE services ADD CONSTRAINT services_available_source_check
|
||||||
|
CHECK (available_source IN ('', 'webhook', 'poll'));
|
||||||
|
ALTER TABLE services ADD COLUMN available_digest text NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE services ADD COLUMN last_poll_error text NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE services ADD COLUMN last_tag_digest text NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
-- Every version the master has seen. '' means "no tag" / "digest unknown";
|
||||||
|
-- never NULL, so the unique constraint deduplicates.
|
||||||
|
CREATE TABLE image_versions (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
repo text NOT NULL,
|
||||||
|
tag text NOT NULL DEFAULT '',
|
||||||
|
digest text NOT NULL DEFAULT '',
|
||||||
|
source text NOT NULL CHECK (source IN ('webhook', 'poll', 'running')),
|
||||||
|
first_seen_at timestamptz NOT NULL,
|
||||||
|
CONSTRAINT image_versions_tag_or_digest CHECK (tag <> '' OR digest <> ''),
|
||||||
|
CONSTRAINT image_versions_unique UNIQUE (repo, tag, digest)
|
||||||
|
);
|
||||||
|
CREATE INDEX image_versions_recent ON image_versions (repo, first_seen_at DESC, id DESC);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- True from a successful job until the next inventory write for the row. The
|
||||||
|
-- agent's upsert arrives a debounce interval after job.result, and until then
|
||||||
|
-- desired_ref is newer than actual_ref without the service having drifted.
|
||||||
|
ALTER TABLE services ADD COLUMN awaiting_inventory boolean NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- Move suggestions look up Compose rows by project and service across agents.
|
||||||
|
CREATE INDEX services_by_compose_name ON services (compose_project, compose_service) WHERE kind = 'compose';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE agents ADD COLUMN swarm_cluster_id text NOT NULL DEFAULT '';
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- Newer version tag of the followed tag, found in the registry's tag list.
|
||||||
|
-- A suggestion only: nothing deploys it automatically.
|
||||||
|
ALTER TABLE services ADD COLUMN newer_ref text NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE services ADD COLUMN newer_at timestamptz;
|
||||||
|
|
||||||
|
-- A suggested tag is recorded in image_versions with source 'tags'.
|
||||||
|
ALTER TABLE image_versions DROP CONSTRAINT image_versions_source_check;
|
||||||
|
ALTER TABLE image_versions ADD CONSTRAINT image_versions_source_check
|
||||||
|
CHECK (source IN ('webhook', 'poll', 'running', 'tags'));
|
||||||
@@ -54,9 +54,11 @@ ON CONFLICT (location_key) DO UPDATE SET
|
|||||||
status = EXCLUDED.status,
|
status = EXCLUDED.status,
|
||||||
self = EXCLUDED.self,
|
self = EXCLUDED.self,
|
||||||
updated_at = EXCLUDED.updated_at,
|
updated_at = EXCLUDED.updated_at,
|
||||||
removed_at = NULL`
|
removed_at = NULL,
|
||||||
|
awaiting_inventory = false`
|
||||||
|
|
||||||
func upsertService(ctx context.Context, db execer, agentID int64, clusterID string, svc protocol.Service, at time.Time) error {
|
func upsertService(ctx context.Context, db execer, agentID int64, clusterID string, svc protocol.Service, at time.Time) error {
|
||||||
|
image := imageref.Parse(svc.Image)
|
||||||
clusterCol := ""
|
clusterCol := ""
|
||||||
if svc.Kind == protocol.KindSwarm {
|
if svc.Kind == protocol.KindSwarm {
|
||||||
clusterCol = clusterID
|
clusterCol = clusterID
|
||||||
@@ -65,11 +67,34 @@ func upsertService(ctx context.Context, db execer, agentID int64, clusterID stri
|
|||||||
string(svc.Kind), model.LocationKey(agentID, clusterID, svc.Key()), agentID,
|
string(svc.Kind), model.LocationKey(agentID, clusterID, svc.Key()), agentID,
|
||||||
svc.ComposeProject, svc.ComposeService, svc.WorkingDir, svc.ConfigFiles, svc.Built,
|
svc.ComposeProject, svc.ComposeService, svc.WorkingDir, svc.ConfigFiles, svc.Built,
|
||||||
clusterCol, svc.SwarmService, svc.ContainerName,
|
clusterCol, svc.SwarmService, svc.ContainerName,
|
||||||
imageref.Parse(svc.Image).Repo, svc.Image, svc.Digest,
|
image.Repo, svc.Image, svc.Digest,
|
||||||
svc.Status, svc.Self, at)
|
svc.Status, svc.Self, at)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("upsert service %s: %w", svc.Key(), err)
|
return fmt.Errorf("upsert service %s: %w", svc.Key(), err)
|
||||||
}
|
}
|
||||||
|
if imageref.IsDigest(svc.Digest) {
|
||||||
|
// A bare image runs "latest", as jobs.FollowedRef sees it, so it
|
||||||
|
// shares its row with the polled one. repo@digest has no tag.
|
||||||
|
if image.Tag == "" && image.Digest == "" {
|
||||||
|
image.Tag = "latest"
|
||||||
|
}
|
||||||
|
if err := insertVersion(ctx, db, image.Repo, image.Tag, svc.Digest, model.SourceRunning, at); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// insertVersion records a version in image_versions unless the same
|
||||||
|
// (repo, tag, digest) is already there; the first sighting keeps its source
|
||||||
|
// and first_seen_at.
|
||||||
|
func insertVersion(ctx context.Context, db execer, repo, tag, digest, source string, at time.Time) error {
|
||||||
|
if _, err := db.Exec(ctx, `
|
||||||
|
INSERT INTO image_versions (repo, tag, digest, source, first_seen_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5) ON CONFLICT DO NOTHING`,
|
||||||
|
repo, tag, digest, source, at); err != nil {
|
||||||
|
return fmt.Errorf("record version %s: %w", imageref.Ref{Repo: repo, Tag: tag, Digest: digest}, err)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,7 +174,8 @@ func (s *Store) RemoveService(ctx context.Context, agentID int64, clusterID, age
|
|||||||
const serviceColumns = `s.id, s.kind, s.location_key, s.agent_id, COALESCE(a.name, ''),
|
const serviceColumns = `s.id, s.kind, s.location_key, s.agent_id, COALESCE(a.name, ''),
|
||||||
s.compose_project, s.compose_service, s.working_dir, s.config_files, s.built, s.swarm_cluster_id,
|
s.compose_project, s.compose_service, s.working_dir, s.config_files, s.built, s.swarm_cluster_id,
|
||||||
s.swarm_service, s.container_name, s.image_repo, s.desired_ref, s.actual_ref, s.actual_digest,
|
s.swarm_service, s.container_name, s.image_repo, s.desired_ref, s.actual_ref, s.actual_digest,
|
||||||
s.status, s.self, s.update_mode, s.first_seen_at, s.updated_at, s.removed_at`
|
s.status, s.self, s.update_mode, s.available_ref, s.available_digest, s.available_source, s.available_at,
|
||||||
|
s.last_poll_error, s.newer_ref, s.newer_at, s.last_tag_digest, s.awaiting_inventory, s.first_seen_at, s.updated_at, s.removed_at`
|
||||||
|
|
||||||
func scanService(row pgx.Row) (model.Service, error) {
|
func scanService(row pgx.Row) (model.Service, error) {
|
||||||
var m model.Service
|
var m model.Service
|
||||||
@@ -157,7 +183,8 @@ func scanService(row pgx.Row) (model.Service, error) {
|
|||||||
err := row.Scan(&m.ID, &kind, &m.LocationKey, &m.AgentID, &m.AgentName,
|
err := row.Scan(&m.ID, &kind, &m.LocationKey, &m.AgentID, &m.AgentName,
|
||||||
&m.ComposeProject, &m.ComposeService, &m.WorkingDir, &m.ConfigFiles, &m.Built, &m.SwarmClusterID,
|
&m.ComposeProject, &m.ComposeService, &m.WorkingDir, &m.ConfigFiles, &m.Built, &m.SwarmClusterID,
|
||||||
&m.SwarmService, &m.ContainerName, &m.ImageRepo, &m.DesiredRef, &m.ActualRef, &m.ActualDigest,
|
&m.SwarmService, &m.ContainerName, &m.ImageRepo, &m.DesiredRef, &m.ActualRef, &m.ActualDigest,
|
||||||
&m.Status, &m.Self, &m.UpdateMode, &m.FirstSeenAt, &m.UpdatedAt, &m.RemovedAt)
|
&m.Status, &m.Self, &m.UpdateMode, &m.AvailableRef, &m.AvailableDigest, &m.AvailableSource, &m.AvailableAt,
|
||||||
|
&m.LastPollError, &m.NewerRef, &m.NewerAt, &m.LastTagDigest, &m.AwaitingInventory, &m.FirstSeenAt, &m.UpdatedAt, &m.RemovedAt)
|
||||||
m.Kind = protocol.Kind(kind)
|
m.Kind = protocol.Kind(kind)
|
||||||
return m, err
|
return m, err
|
||||||
}
|
}
|
||||||
@@ -165,13 +192,23 @@ func scanService(row pgx.Row) (model.Service, error) {
|
|||||||
// ListServices returns all services not marked removed: machine-bound
|
// ListServices returns all services not marked removed: machine-bound
|
||||||
// services grouped by agent name first, Swarm services last.
|
// services grouped by agent name first, Swarm services last.
|
||||||
func (s *Store) ListServices(ctx context.Context) ([]model.Service, error) {
|
func (s *Store) ListServices(ctx context.Context) ([]model.Service, error) {
|
||||||
|
return s.queryServices(ctx, "list services", "", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServicesByRepo returns the services not marked removed whose image repo
|
||||||
|
// equals repo (case-sensitive), in the same order as ListServices.
|
||||||
|
func (s *Store) ServicesByRepo(ctx context.Context, repo string) ([]model.Service, error) {
|
||||||
|
return s.queryServices(ctx, "services by repo", "AND s.image_repo = $1", []any{repo})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) queryServices(ctx context.Context, what, extraWhere string, args []any) ([]model.Service, error) {
|
||||||
rows, err := s.pool.Query(ctx, `
|
rows, err := s.pool.Query(ctx, `
|
||||||
SELECT `+serviceColumns+`
|
SELECT `+serviceColumns+`
|
||||||
FROM services s LEFT JOIN agents a ON a.id = s.agent_id
|
FROM services s LEFT JOIN agents a ON a.id = s.agent_id
|
||||||
WHERE s.removed_at IS NULL
|
WHERE s.removed_at IS NULL `+extraWhere+`
|
||||||
ORDER BY s.kind = 'swarm', COALESCE(a.name, ''), s.location_key`)
|
ORDER BY s.kind = 'swarm', COALESCE(a.name, ''), s.location_key`, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list services: %w", err)
|
return nil, fmt.Errorf("%s: %w", what, err)
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var out []model.Service
|
var out []model.Service
|
||||||
@@ -185,6 +222,184 @@ func (s *Store) ListServices(ctx context.Context) ([]model.Service, error) {
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetAvailable records a newer ref for the service, replacing any earlier
|
||||||
|
// suggestion, and records the version in image_versions in the same
|
||||||
|
// transaction (the tag is parsed from a.Ref).
|
||||||
|
func (s *Store) SetAvailable(ctx context.Context, id int64, a model.Available) error {
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("set available: begin: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback(ctx) }()
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
UPDATE services SET available_ref = $2, available_digest = $3, available_source = $4, available_at = $5
|
||||||
|
WHERE id = $1`, id, a.Ref, a.Digest, a.Source, a.At); err != nil {
|
||||||
|
return fmt.Errorf("set available: %w", err)
|
||||||
|
}
|
||||||
|
ref := imageref.Parse(a.Ref)
|
||||||
|
if err := insertVersion(ctx, tx, ref.Repo, ref.Tag, a.Digest, a.Source, a.At); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return fmt.Errorf("set available: commit: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetNewer sets the service's newer-tag suggestion (model.Service.NewerRef);
|
||||||
|
// ref "" clears it. A non-empty ref is also recorded in image_versions with
|
||||||
|
// source "tags", in the same transaction, unless that (repo, tag) is already
|
||||||
|
// there. The digest is unknown, so the row has none.
|
||||||
|
func (s *Store) SetNewer(ctx context.Context, id int64, ref string, at time.Time) error {
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("set newer: begin: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback(ctx) }()
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
UPDATE services SET newer_ref = $2, newer_at = CASE WHEN $2 = '' THEN NULL ELSE $3::timestamptz END
|
||||||
|
WHERE id = $1`, id, ref, at); err != nil {
|
||||||
|
return fmt.Errorf("set newer: %w", err)
|
||||||
|
}
|
||||||
|
if ref != "" {
|
||||||
|
p := imageref.Parse(ref)
|
||||||
|
if err := insertVersion(ctx, tx, p.Repo, p.Tag, "", model.SourceTags, at); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return fmt.Errorf("set newer: commit: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPollError sets last_poll_error on the given services; msg "" clears it.
|
||||||
|
func (s *Store) SetPollError(ctx context.Context, ids []int64, msg string) error {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, err := s.pool.Exec(ctx,
|
||||||
|
`UPDATE services SET last_poll_error = $2 WHERE id = ANY($1)`, ids, msg); err != nil {
|
||||||
|
return fmt.Errorf("set poll error: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLastTagDigest sets last_tag_digest; see model.Service.LastTagDigest. The
|
||||||
|
// poller and jobs.RestoreDesired call it, and FinishJob sets the same column
|
||||||
|
// after a tag job. Inventory writes never change it.
|
||||||
|
func (s *Store) SetLastTagDigest(ctx context.Context, id int64, digest string) error {
|
||||||
|
if _, err := s.pool.Exec(ctx,
|
||||||
|
`UPDATE services SET last_tag_digest = $2 WHERE id = $1`, id, digest); err != nil {
|
||||||
|
return fmt.Errorf("set last tag digest: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecentVersions returns up to limit versions of repo, most recently first
|
||||||
|
// seen first. A version without a digest is left out when a version with the
|
||||||
|
// same tag and a digest was first seen at or after it.
|
||||||
|
func (s *Store) RecentVersions(ctx context.Context, repo string, limit int) ([]model.ImageVersion, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT id, repo, tag, digest, source, first_seen_at FROM image_versions v
|
||||||
|
WHERE v.repo = $1
|
||||||
|
AND NOT (v.digest = '' AND EXISTS (
|
||||||
|
SELECT 1 FROM image_versions d
|
||||||
|
WHERE d.repo = v.repo AND d.tag = v.tag AND d.digest <> ''
|
||||||
|
AND d.first_seen_at >= v.first_seen_at))
|
||||||
|
ORDER BY v.first_seen_at DESC, v.id DESC
|
||||||
|
LIMIT $2`, repo, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("recent versions: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []model.ImageVersion
|
||||||
|
for rows.Next() {
|
||||||
|
var v model.ImageVersion
|
||||||
|
if err := rows.Scan(&v.ID, &v.Repo, &v.Tag, &v.Digest, &v.Source, &v.FirstSeenAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan version: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetImageVersion returns one version. Returns model.ErrNotFound if the ID
|
||||||
|
// does not exist.
|
||||||
|
func (s *Store) GetImageVersion(ctx context.Context, id int64) (model.ImageVersion, error) {
|
||||||
|
var v model.ImageVersion
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT id, repo, tag, digest, source, first_seen_at FROM image_versions WHERE id = $1`, id,
|
||||||
|
).Scan(&v.ID, &v.Repo, &v.Tag, &v.Digest, &v.Source, &v.FirstSeenAt)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return model.ImageVersion{}, model.ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return model.ImageVersion{}, fmt.Errorf("get image version: %w", err)
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTagVersion returns the version row of repo and tag with an empty digest, which
|
||||||
|
// SetNewer and the webhook create. Returns model.ErrNotFound if there is none.
|
||||||
|
func (s *Store) GetTagVersion(ctx context.Context, repo, tag string) (model.ImageVersion, error) {
|
||||||
|
var v model.ImageVersion
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT id, repo, tag, digest, source, first_seen_at FROM image_versions
|
||||||
|
WHERE repo = $1 AND tag = $2 AND digest = ''`, repo, tag,
|
||||||
|
).Scan(&v.ID, &v.Repo, &v.Tag, &v.Digest, &v.Source, &v.FirstSeenAt)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return model.ImageVersion{}, model.ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return model.ImageVersion{}, fmt.Errorf("get tag version: %w", err)
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetUpdateMode sets the service's update mode (model.ModeManual or
|
||||||
|
// model.ModeAuto). Returns model.ErrNotFound if the service does not exist
|
||||||
|
// or is marked removed.
|
||||||
|
func (s *Store) SetUpdateMode(ctx context.Context, id int64, mode string) error {
|
||||||
|
tag, err := s.pool.Exec(ctx,
|
||||||
|
`UPDATE services SET update_mode = $2 WHERE id = $1 AND removed_at IS NULL`, id, mode)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("set update mode: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return model.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AcceptRunning makes the running version the desired one: desired_ref =
|
||||||
|
// desired, provided the service is live, has been updated before and still
|
||||||
|
// reports actualRef and actualDigest (what the caller saw when it decided).
|
||||||
|
// It starts no job and leaves last_tag_digest, the available suggestion and
|
||||||
|
// the update mode alone. Returns model.ErrNotFound for an unknown or removed
|
||||||
|
// service and model.ErrConflict when the service was never updated or the
|
||||||
|
// running version changed meanwhile.
|
||||||
|
func (s *Store) AcceptRunning(ctx context.Context, id int64, actualRef, actualDigest, desired string) error {
|
||||||
|
tag, err := s.pool.Exec(ctx, `
|
||||||
|
UPDATE services SET desired_ref = $4, awaiting_inventory = false
|
||||||
|
WHERE id = $1 AND removed_at IS NULL AND desired_ref <> ''
|
||||||
|
AND actual_ref = $2 AND actual_digest = $3`, id, actualRef, actualDigest, desired)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("accept running: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
svc, err := s.GetService(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if svc.RemovedAt != nil {
|
||||||
|
return model.ErrNotFound
|
||||||
|
}
|
||||||
|
return model.ErrConflict
|
||||||
|
}
|
||||||
|
|
||||||
// GetService returns one service, including a removed one (RemovedAt set).
|
// GetService returns one service, including a removed one (RemovedAt set).
|
||||||
// Returns model.ErrNotFound if the ID does not exist.
|
// Returns model.ErrNotFound if the ID does not exist.
|
||||||
func (s *Store) GetService(ctx context.Context, id int64) (model.Service, error) {
|
func (s *Store) GetService(ctx context.Context, id int64) (model.Service, error) {
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ import (
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
t0 = time.Date(2026, 10, 12, 12, 0, 0, 0, time.UTC)
|
t0 = time.Date(2026, 10, 12, 12, 0, 0, 0, time.UTC)
|
||||||
slusk = protocol.Service{Kind: protocol.KindCompose, ComposeProject: "slusk", ComposeService: "web", WorkingDir: "/srv/slusk", Image: "gitea.shcizo.se/shcizo/slusk:1.4.2", Digest: "sha256:d1", Status: "running"}
|
slusk = protocol.Service{Kind: protocol.KindCompose, ComposeProject: "slusk", ComposeService: "web", WorkingDir: "/srv/slusk", Image: "gitea.shcizo.se/shcizo/slusk:1.4.2", Digest: digD1, Status: "running"}
|
||||||
adhoc = protocol.Service{Kind: protocol.KindContainer, ContainerName: "adhoc", Image: "redis:alpine", Status: "stopped"}
|
adhoc = protocol.Service{Kind: protocol.KindContainer, ContainerName: "adhoc", Image: "redis:alpine", Status: "stopped"}
|
||||||
gitea = protocol.Service{Kind: protocol.KindSwarm, SwarmService: "gitea_gitea", Image: "gitea/gitea:1.27.3@sha256:87a6", Digest: "sha256:87a6", Status: "running"}
|
gitea = protocol.Service{Kind: protocol.KindSwarm, SwarmService: "gitea_gitea", Image: "gitea/gitea:1.27.3@sha256:87a6", Digest: digGitea, Status: "running"}
|
||||||
worker = protocol.Hello{Hostname: "node-2"}
|
worker = protocol.Hello{Hostname: "node-2"}
|
||||||
manager = protocol.Hello{Hostname: "swarm-mgr", SwarmManager: true, SwarmClusterID: "c1"}
|
manager = protocol.Hello{Hostname: "swarm-mgr", SwarmManager: true, SwarmClusterID: "c1"}
|
||||||
)
|
)
|
||||||
@@ -53,7 +53,7 @@ func TestApplySnapshot_InsertsAsManualWithAgentName(t *testing.T) {
|
|||||||
assert.Equal(t, "node-2", got[0].AgentName)
|
assert.Equal(t, "node-2", got[0].AgentName)
|
||||||
assert.Equal(t, "gitea.shcizo.se/shcizo/slusk", got[0].ImageRepo)
|
assert.Equal(t, "gitea.shcizo.se/shcizo/slusk", got[0].ImageRepo)
|
||||||
assert.Equal(t, "gitea.shcizo.se/shcizo/slusk:1.4.2", got[0].ActualRef)
|
assert.Equal(t, "gitea.shcizo.se/shcizo/slusk:1.4.2", got[0].ActualRef)
|
||||||
assert.Equal(t, "sha256:d1", got[0].ActualDigest)
|
assert.Equal(t, digD1, got[0].ActualDigest)
|
||||||
assert.Equal(t, "compose|"+itoa(id)+"|slusk|web", got[0].LocationKey)
|
assert.Equal(t, "compose|"+itoa(id)+"|slusk|web", got[0].LocationKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/shcizo/package-updater/internal/master/model"
|
||||||
|
"github.com/shcizo/package-updater/internal/protocol"
|
||||||
|
)
|
||||||
|
|
||||||
|
const sluskRepo = "gitea.shcizo.se/shcizo/slusk"
|
||||||
|
|
||||||
|
// Digests of the fixture services; only well-formed ones are recorded as versions.
|
||||||
|
var (
|
||||||
|
digD1 = "sha256:" + strings.Repeat("d", 63) + "1"
|
||||||
|
digGitea = "sha256:" + strings.Repeat("a", 63) + "1"
|
||||||
|
digPg = "sha256:" + strings.Repeat("b", 63) + "1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func versionsOf(t *testing.T, s *Store, repo string) []model.ImageVersion {
|
||||||
|
t.Helper()
|
||||||
|
got, err := s.RecentVersions(context.Background(), repo, 100)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return got
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetAvailable_RecordsVersion(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
_, svc := jobFixture(t, s, "a")
|
||||||
|
|
||||||
|
require.NoError(t, s.SetAvailable(ctx, svc, model.Available{Ref: sluskRepo + ":1.5.0", Digest: "sha256:n1", Source: model.SourcePoll, At: t0}))
|
||||||
|
|
||||||
|
got, err := s.GetService(ctx, svc)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, sluskRepo+":1.5.0", got.AvailableRef)
|
||||||
|
assert.Equal(t, "sha256:n1", got.AvailableDigest)
|
||||||
|
assert.Equal(t, model.SourcePoll, got.AvailableSource)
|
||||||
|
vs := versionsOf(t, s, sluskRepo)
|
||||||
|
var found model.ImageVersion
|
||||||
|
for _, v := range vs {
|
||||||
|
if v.Digest == "sha256:n1" {
|
||||||
|
found = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NotZero(t, found.ID)
|
||||||
|
assert.Equal(t, "1.5.0", found.Tag)
|
||||||
|
assert.Equal(t, model.SourcePoll, found.Source)
|
||||||
|
|
||||||
|
// Seeing the same version again keeps the first sighting.
|
||||||
|
require.NoError(t, s.SetAvailable(ctx, svc, model.Available{Ref: sluskRepo + ":1.5.0", Digest: "sha256:n1", Source: model.SourceWebhook, At: t0.Add(time.Hour)}))
|
||||||
|
again, err := s.GetImageVersion(ctx, found.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, model.SourcePoll, again.Source)
|
||||||
|
assert.True(t, t0.Equal(again.FirstSeenAt))
|
||||||
|
assert.Len(t, versionsOf(t, s, sluskRepo), len(vs))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSnapshotRecordsRunningVersions(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
id := newAgent(t, s, "node-2")
|
||||||
|
tagless := protocol.Service{Kind: protocol.KindSwarm, SwarmService: "g_g", Image: "gitea/gitea@" + digGitea, Digest: digGitea, Status: "running"}
|
||||||
|
bare := protocol.Service{Kind: protocol.KindContainer, ContainerName: "pg", Image: "postgres", Digest: digPg, Status: "running"}
|
||||||
|
nodigest := protocol.Service{Kind: protocol.KindContainer, ContainerName: "nd", Image: "nodigest:1", Status: "running"}
|
||||||
|
hello := protocol.Hello{Hostname: "node-2", SwarmManager: true, SwarmClusterID: "c1"}
|
||||||
|
svcs := []protocol.Service{slusk, tagless, bare, nodigest}
|
||||||
|
|
||||||
|
require.NoError(t, s.ApplySnapshot(ctx, id, hello, svcs, t0))
|
||||||
|
require.NoError(t, s.ApplySnapshot(ctx, id, hello, svcs, t0.Add(time.Hour)))
|
||||||
|
|
||||||
|
got := versionsOf(t, s, sluskRepo)
|
||||||
|
require.Len(t, got, 1)
|
||||||
|
assert.Equal(t, "1.4.2", got[0].Tag)
|
||||||
|
assert.Equal(t, digD1, got[0].Digest)
|
||||||
|
assert.Equal(t, model.SourceRunning, got[0].Source)
|
||||||
|
assert.True(t, t0.Equal(got[0].FirstSeenAt), "re-snapshot does not bump first_seen_at")
|
||||||
|
tl := versionsOf(t, s, "gitea/gitea")
|
||||||
|
require.Len(t, tl, 1)
|
||||||
|
assert.Equal(t, "", tl[0].Tag)
|
||||||
|
assert.Equal(t, digGitea, tl[0].Digest)
|
||||||
|
b := versionsOf(t, s, "postgres")
|
||||||
|
require.Len(t, b, 1)
|
||||||
|
assert.Equal(t, "latest", b[0].Tag, "a bare image runs latest, like the followed ref")
|
||||||
|
assert.Empty(t, versionsOf(t, s, "nodigest"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSnapshotRecordsOnlyWellFormedDigests(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
id := newAgent(t, s, "node-2")
|
||||||
|
bad := protocol.Service{Kind: protocol.KindContainer, ContainerName: "bad", Image: "bad:1", Digest: "sha256:short", Status: "running"}
|
||||||
|
upper := protocol.Service{Kind: protocol.KindContainer, ContainerName: "up", Image: "up:1", Digest: "sha256:" + strings.Repeat("A", 64), Status: "running"}
|
||||||
|
|
||||||
|
require.NoError(t, s.ApplySnapshot(context.Background(), id, protocol.Hello{Hostname: "node-2"}, []protocol.Service{bad, upper}, t0))
|
||||||
|
|
||||||
|
assert.Empty(t, versionsOf(t, s, "bad"))
|
||||||
|
assert.Empty(t, versionsOf(t, s, "up"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecentVersions(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
t.Run("order, limit and repo filter", func(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
_, svc := jobFixture(t, s, "a")
|
||||||
|
require.NoError(t, s.SetAvailable(ctx, svc, model.Available{Ref: "other/repo:x", Digest: "sha256:ox", Source: model.SourcePoll, At: t0}))
|
||||||
|
// 12 versions of the service's repo, one per minute.
|
||||||
|
for i := 0; i < 12; i++ {
|
||||||
|
tag := "t" + string(rune('a'+i))
|
||||||
|
require.NoError(t, s.SetAvailable(ctx, svc, model.Available{Ref: sluskRepo + ":" + tag, Digest: "sha256:" + tag, Source: model.SourcePoll, At: t0.Add(time.Duration(i+1) * time.Minute)}))
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.RecentVersions(ctx, sluskRepo, 10)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, got, 10)
|
||||||
|
assert.Equal(t, "tl", got[0].Tag, "newest first")
|
||||||
|
assert.Equal(t, "tc", got[9].Tag)
|
||||||
|
for _, v := range got {
|
||||||
|
assert.Equal(t, sluskRepo, v.Repo)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("digest-less row is hidden once its tag has a digest", func(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
_, svc := jobFixture(t, s, "a")
|
||||||
|
add := func(tag, digest string, at time.Time) {
|
||||||
|
require.NoError(t, s.SetAvailable(ctx, svc, model.Available{Ref: sluskRepo + ":" + tag, Digest: digest, Source: model.SourceWebhook, At: at}))
|
||||||
|
}
|
||||||
|
add("main", "", t0.Add(time.Minute))
|
||||||
|
add("solo", "", t0.Add(2*time.Minute))
|
||||||
|
require.Len(t, versionsOf(t, s, sluskRepo), 3, "running 1.4.2 plus two digest-less rows")
|
||||||
|
|
||||||
|
add("main", "sha256:m1", t0.Add(3*time.Minute))
|
||||||
|
|
||||||
|
var refs []string
|
||||||
|
for _, v := range versionsOf(t, s, sluskRepo) {
|
||||||
|
refs = append(refs, v.Ref())
|
||||||
|
}
|
||||||
|
assert.Equal(t, []string{sluskRepo + ":main@sha256:m1", sluskRepo + ":solo", sluskRepo + ":1.4.2@" + digD1}, refs)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("digest-less row newer than the digest row of its tag is shown", func(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
_, svc := jobFixture(t, s, "a")
|
||||||
|
add := func(tag, digest string, at time.Time) {
|
||||||
|
require.NoError(t, s.SetAvailable(ctx, svc, model.Available{Ref: sluskRepo + ":" + tag, Digest: digest, Source: model.SourceWebhook, At: at}))
|
||||||
|
}
|
||||||
|
add("main", "sha256:m1", t0.Add(time.Minute))
|
||||||
|
add("main", "", t0.Add(2*time.Minute))
|
||||||
|
|
||||||
|
var refs []string
|
||||||
|
for _, v := range versionsOf(t, s, sluskRepo) {
|
||||||
|
refs = append(refs, v.Ref())
|
||||||
|
}
|
||||||
|
assert.Equal(t, []string{sluskRepo + ":main", sluskRepo + ":main@sha256:m1", sluskRepo + ":1.4.2@" + digD1}, refs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetImageVersion_NotFound(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
|
||||||
|
_, err := s.GetImageVersion(context.Background(), 99999)
|
||||||
|
|
||||||
|
assert.ErrorIs(t, err, model.ErrNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetPollError(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
agent, svc := jobFixture(t, s, "a")
|
||||||
|
require.NoError(t, s.SetAvailable(ctx, svc, model.Available{Ref: sluskRepo + ":1.5.0", Digest: "sha256:n1", Source: model.SourcePoll, At: t0}))
|
||||||
|
|
||||||
|
require.NoError(t, s.SetPollError(ctx, []int64{svc}, "boom"))
|
||||||
|
require.NoError(t, s.SetPollError(ctx, nil, "ignored"))
|
||||||
|
got, err := s.GetService(ctx, svc)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "boom", got.LastPollError)
|
||||||
|
|
||||||
|
require.NoError(t, s.UpsertService(ctx, agent, "", slusk, t0.Add(time.Hour)))
|
||||||
|
got, err = s.GetService(ctx, svc)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "boom", got.LastPollError, "inventory writes do not touch the poll error")
|
||||||
|
assert.Equal(t, "sha256:n1", got.AvailableDigest, "nor the suggestion")
|
||||||
|
|
||||||
|
require.NoError(t, s.SetPollError(ctx, []int64{svc}, ""))
|
||||||
|
got, err = s.GetService(ctx, svc)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, got.LastPollError)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetLastTagDigest(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
agent, svc := jobFixture(t, s, "a")
|
||||||
|
got, err := s.GetService(ctx, svc)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, got.LastTagDigest)
|
||||||
|
|
||||||
|
require.NoError(t, s.SetLastTagDigest(ctx, svc, "sha256:n1"))
|
||||||
|
require.NoError(t, s.UpsertService(ctx, agent, "", slusk, t0.Add(time.Hour)))
|
||||||
|
|
||||||
|
got, err = s.GetService(ctx, svc)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "sha256:n1", got.LastTagDigest, "inventory writes do not touch it")
|
||||||
|
list, err := s.ListServices(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, list, 1)
|
||||||
|
assert.Equal(t, "sha256:n1", list[0].LastTagDigest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetNewer_SetsAndClears(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
_, svc := jobFixture(t, s, "a")
|
||||||
|
|
||||||
|
require.NoError(t, s.SetNewer(ctx, svc, sluskRepo+":1.5.0", t0))
|
||||||
|
|
||||||
|
got, err := s.GetService(ctx, svc)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, sluskRepo+":1.5.0", got.NewerRef)
|
||||||
|
require.NotNil(t, got.NewerAt)
|
||||||
|
assert.True(t, t0.Equal(*got.NewerAt))
|
||||||
|
v, err := s.GetTagVersion(ctx, sluskRepo, "1.5.0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, model.SourceTags, v.Source)
|
||||||
|
assert.Empty(t, v.Digest)
|
||||||
|
assert.True(t, t0.Equal(v.FirstSeenAt))
|
||||||
|
|
||||||
|
require.NoError(t, s.SetNewer(ctx, svc, "", t0.Add(time.Hour)))
|
||||||
|
|
||||||
|
got, err = s.GetService(ctx, svc)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, got.NewerRef)
|
||||||
|
assert.Nil(t, got.NewerAt)
|
||||||
|
_, err = s.GetTagVersion(ctx, sluskRepo, "1.5.0")
|
||||||
|
assert.NoError(t, err, "clearing the suggestion keeps the version")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetNewer_KeepsEarlierVersionSource(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
_, svc := jobFixture(t, s, "a")
|
||||||
|
require.NoError(t, s.SetAvailable(ctx, svc, model.Available{Ref: sluskRepo + ":1.5.0", Source: model.SourceWebhook, At: t0}))
|
||||||
|
|
||||||
|
require.NoError(t, s.SetNewer(ctx, svc, sluskRepo+":1.5.0", t0.Add(time.Hour)))
|
||||||
|
|
||||||
|
v, err := s.GetTagVersion(ctx, sluskRepo, "1.5.0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, model.SourceWebhook, v.Source)
|
||||||
|
assert.True(t, t0.Equal(v.FirstSeenAt))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecentVersions_TagsRowHiddenOnceDigestKnown(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
_, svc := jobFixture(t, s, "a")
|
||||||
|
require.NoError(t, s.SetNewer(ctx, svc, sluskRepo+":1.5.0", t0.Add(time.Minute)))
|
||||||
|
require.Len(t, versionsOf(t, s, sluskRepo), 2, "running 1.4.2 and the tags row")
|
||||||
|
|
||||||
|
require.NoError(t, s.SetAvailable(ctx, svc, model.Available{Ref: sluskRepo + ":1.5.0", Digest: "sha256:n1", Source: model.SourcePoll, At: t0.Add(2 * time.Minute)}))
|
||||||
|
|
||||||
|
var refs []string
|
||||||
|
for _, v := range versionsOf(t, s, sluskRepo) {
|
||||||
|
refs = append(refs, v.Ref())
|
||||||
|
}
|
||||||
|
assert.Equal(t, []string{sluskRepo + ":1.5.0@sha256:n1", sluskRepo + ":1.4.2@" + digD1}, refs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTagVersion(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
_, svc := jobFixture(t, s, "a")
|
||||||
|
require.NoError(t, s.SetNewer(ctx, svc, sluskRepo+":1.5.0", t0))
|
||||||
|
|
||||||
|
got, err := s.GetTagVersion(ctx, sluskRepo, "1.5.0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, sluskRepo+":1.5.0", got.Ref())
|
||||||
|
|
||||||
|
_, err = s.GetTagVersion(ctx, sluskRepo, "9.9.9")
|
||||||
|
assert.ErrorIs(t, err, model.ErrNotFound)
|
||||||
|
_, err = s.GetTagVersion(ctx, "other/repo", "1.5.0")
|
||||||
|
assert.ErrorIs(t, err, model.ErrNotFound)
|
||||||
|
_, err = s.GetTagVersion(ctx, sluskRepo, "1.4.2")
|
||||||
|
assert.ErrorIs(t, err, model.ErrNotFound, "a row with a digest is not a tag version")
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/shcizo/package-updater/internal/master/model"
|
||||||
|
"github.com/shcizo/package-updater/internal/protocol"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestServicesByRepo(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
id := newAgent(t, s, "node-2")
|
||||||
|
other := protocol.Service{Kind: protocol.KindCompose, ComposeProject: "o", ComposeService: "o", Image: "gitea.shcizo.se/shcizo/Slusk:1", Status: "running"}
|
||||||
|
gone := protocol.Service{Kind: protocol.KindCompose, ComposeProject: "g", ComposeService: "g", Image: "gitea.shcizo.se/shcizo/slusk:0.9", Status: "running"}
|
||||||
|
require.NoError(t, s.ApplySnapshot(ctx, id, worker, []protocol.Service{slusk, other, gone, adhoc}, t0))
|
||||||
|
require.NoError(t, s.RemoveService(ctx, id, "", gone.Key(), t0))
|
||||||
|
|
||||||
|
got, err := s.ServicesByRepo(ctx, "gitea.shcizo.se/shcizo/slusk")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"slusk/web"}, names(got), "removed rows and rows of other (case-sensitive) repos are excluded")
|
||||||
|
assert.Equal(t, "node-2", got[0].AgentName)
|
||||||
|
|
||||||
|
none, err := s.ServicesByRepo(ctx, "nope")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, none)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetAvailable_RoundTrip(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
_, svc := jobFixture(t, s, "a")
|
||||||
|
|
||||||
|
require.NoError(t, s.SetAvailable(ctx, svc, model.Available{Ref: "r:2", Source: model.SourceWebhook, At: t0}))
|
||||||
|
|
||||||
|
got, err := s.GetService(ctx, svc)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "r:2", got.AvailableRef)
|
||||||
|
assert.Equal(t, model.SourceWebhook, got.AvailableSource)
|
||||||
|
require.NotNil(t, got.AvailableAt)
|
||||||
|
assert.True(t, t0.Equal(*got.AvailableAt))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetUpdateMode(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
agent, svc := jobFixture(t, s, "a")
|
||||||
|
|
||||||
|
require.NoError(t, s.SetUpdateMode(ctx, svc, model.ModeAuto))
|
||||||
|
got, err := s.GetService(ctx, svc)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, model.ModeAuto, got.UpdateMode)
|
||||||
|
|
||||||
|
require.NoError(t, s.SetUpdateMode(ctx, svc, model.ModeManual))
|
||||||
|
got, _ = s.GetService(ctx, svc)
|
||||||
|
assert.Equal(t, model.ModeManual, got.UpdateMode)
|
||||||
|
|
||||||
|
assert.ErrorIs(t, s.SetUpdateMode(ctx, 99999, model.ModeAuto), model.ErrNotFound)
|
||||||
|
|
||||||
|
require.NoError(t, s.RemoveService(ctx, agent, "", slusk.Key(), t0))
|
||||||
|
assert.ErrorIs(t, s.SetUpdateMode(ctx, svc, model.ModeAuto), model.ErrNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpsertKeepsUpdateMode(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
agent, svc := jobFixture(t, s, "a")
|
||||||
|
got, err := s.GetService(ctx, svc)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, model.ModeManual, got.UpdateMode, "a new service starts manual")
|
||||||
|
|
||||||
|
require.NoError(t, s.SetUpdateMode(ctx, svc, model.ModeAuto))
|
||||||
|
changed := slusk
|
||||||
|
changed.Image = "gitea.shcizo.se/shcizo/slusk:1.5.0"
|
||||||
|
require.NoError(t, s.UpsertService(ctx, agent, "", changed, t0.Add(time.Minute)))
|
||||||
|
require.NoError(t, s.ApplySnapshot(ctx, agent, worker, []protocol.Service{changed}, t0.Add(2*time.Minute)))
|
||||||
|
|
||||||
|
got, err = s.GetService(ctx, svc)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, model.ModeAuto, got.UpdateMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFinishJob_ClearsAvailableOnlyWhenThatVersionWasDeployed(t *testing.T) {
|
||||||
|
succeed := model.JobFinish{Status: model.JobSucceeded, Ref: "r:2@sha256:d", StartedAt: t0, FinishedAt: t0}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
availableRef string
|
||||||
|
availableDigest string
|
||||||
|
availableAt time.Time // the job is created at t0
|
||||||
|
toRef string // the job's to_ref as created
|
||||||
|
finish model.JobFinish
|
||||||
|
wantCleared bool
|
||||||
|
}{
|
||||||
|
{"same ref, suggestion before the job", "r:2", "", t0.Add(-time.Minute), "r:2", succeed, true},
|
||||||
|
{"same ref, suggestion at the same instant", "r:2", "", t0, "r:2", succeed, true},
|
||||||
|
{"same ref, suggestion after the job was created", "r:2", "", t0.Add(time.Minute), "r:2", succeed, false},
|
||||||
|
{"different ref keeps the suggestion", "r:3", "", t0.Add(-time.Minute), "r:2", succeed, false},
|
||||||
|
{"failed job keeps the suggestion", "r:2", "", t0.Add(-time.Minute), "r:2",
|
||||||
|
model.JobFinish{Status: model.JobFailed, Error: "boom", StartedAt: t0, FinishedAt: t0}, false},
|
||||||
|
{"tag job clears a poll suggestion with a digest", "r:2", "sha256:new", t0.Add(-time.Minute), "r:2", succeed, true},
|
||||||
|
{"pick of the suggested digest clears it", "r:2", "sha256:new", t0.Add(-time.Minute), "r:2@sha256:new", succeed, true},
|
||||||
|
{"rollback to an older digest keeps the newer suggestion", "r:2", "sha256:new", t0.Add(-time.Minute), "r:2@sha256:old", succeed, false},
|
||||||
|
{"digest pick keeps a suggestion without digest", "r:2", "", t0.Add(-time.Minute), "r:2@sha256:old", succeed, false},
|
||||||
|
{"tagless pick keeps the suggestion", "r:2", "sha256:new", t0.Add(-time.Minute), "r@sha256:old", succeed, false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
agent, svc := jobFixture(t, s, "a")
|
||||||
|
require.NoError(t, s.SetAvailable(ctx, svc, model.Available{Ref: tt.availableRef, Digest: tt.availableDigest, Source: model.SourcePoll, At: tt.availableAt}))
|
||||||
|
nj := newJob(svc, agent)
|
||||||
|
nj.ToRef = tt.toRef
|
||||||
|
j, err := s.CreateJob(ctx, nj)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ok, err := s.FinishJob(ctx, agent, j.ID, tt.finish)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, ok)
|
||||||
|
|
||||||
|
got, err := s.GetService(ctx, svc)
|
||||||
|
require.NoError(t, err)
|
||||||
|
if tt.wantCleared {
|
||||||
|
assert.Empty(t, got.AvailableRef)
|
||||||
|
assert.Empty(t, got.AvailableDigest)
|
||||||
|
assert.Empty(t, got.AvailableSource)
|
||||||
|
assert.Nil(t, got.AvailableAt)
|
||||||
|
} else {
|
||||||
|
assert.Equal(t, tt.availableRef, got.AvailableRef)
|
||||||
|
assert.Equal(t, tt.availableDigest, got.AvailableDigest)
|
||||||
|
assert.Equal(t, model.SourcePoll, got.AvailableSource)
|
||||||
|
assert.NotNil(t, got.AvailableAt)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFinishJob_WithoutAvailableLeavesItEmpty(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
agent, svc := jobFixture(t, s, "a")
|
||||||
|
j, err := s.CreateJob(ctx, newJob(svc, agent))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ok, err := s.FinishJob(ctx, agent, j.ID, model.JobFinish{Status: model.JobSucceeded, Ref: "r:1@sha256:d", StartedAt: t0, FinishedAt: t0})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, ok)
|
||||||
|
|
||||||
|
got, err := s.GetService(ctx, svc)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, got.AvailableRef)
|
||||||
|
assert.Nil(t, got.AvailableAt)
|
||||||
|
assert.Equal(t, "r:1@sha256:d", got.DesiredRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFinishJob_RemembersTheDeployedDigestOfATagJob(t *testing.T) {
|
||||||
|
good := "sha256:" + strings.Repeat("a", 64)
|
||||||
|
old := "sha256:" + strings.Repeat("b", 64)
|
||||||
|
ok := func(ref string) model.JobFinish {
|
||||||
|
return model.JobFinish{Status: model.JobSucceeded, Ref: ref, StartedAt: t0, FinishedAt: t0}
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
trigger model.JobTrigger
|
||||||
|
toRef string
|
||||||
|
finish model.JobFinish
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"webhook tag job", model.TriggerWebhook, "r:2", ok("r:2@" + good), good},
|
||||||
|
{"manual tag job", model.TriggerManual, "r:2", ok("r:2@" + good), good},
|
||||||
|
{"poll tag job", model.TriggerPoll, "r:2", ok("r:2@" + good), good},
|
||||||
|
{"pinned pick leaves it", model.TriggerManual, "r:2@" + old, ok("r:2@" + old), "sha256:before"},
|
||||||
|
{"tagless pick leaves it", model.TriggerManual, "r@" + old, ok("r@" + old), "sha256:before"},
|
||||||
|
{"failed job leaves it", model.TriggerWebhook, "r:2",
|
||||||
|
model.JobFinish{Status: model.JobFailed, Error: "boom", Ref: "r:2@" + good, StartedAt: t0, FinishedAt: t0}, "sha256:before"},
|
||||||
|
{"malformed digest leaves it", model.TriggerWebhook, "r:2", ok("r:2@sha256:d"), "sha256:before"},
|
||||||
|
{"no pinned ref leaves it", model.TriggerWebhook, "r:2", ok(""), "sha256:before"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
agent, svc := jobFixture(t, s, "a")
|
||||||
|
require.NoError(t, s.SetLastTagDigest(ctx, svc, "sha256:before"))
|
||||||
|
nj := newJob(svc, agent)
|
||||||
|
nj.ToRef, nj.Trigger = tt.toRef, tt.trigger
|
||||||
|
j, err := s.CreateJob(ctx, nj)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
done, err := s.FinishJob(ctx, agent, j.ID, tt.finish)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, done)
|
||||||
|
|
||||||
|
got, err := s.GetService(ctx, svc)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.want, got.LastTagDigest)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// Package tagver decides which tag of a repository is a newer version of the
|
||||||
|
// tag a service follows, using only the shape of the tag names.
|
||||||
|
package tagver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tagPattern splits a tag into a prefix without digits, a dotted numeric
|
||||||
|
// part and whatever follows it. "v1.2.3-alpine" gives "v", "1.2.3", "-alpine".
|
||||||
|
var tagPattern = regexp.MustCompile(`^([^0-9]*)([0-9]+(?:\.[0-9]+)*)(.*)$`)
|
||||||
|
|
||||||
|
// cosignPattern matches the tags cosign pushes next to an image to store its
|
||||||
|
// signature, attestation or SBOM. They look like versions to a naive check.
|
||||||
|
// The pattern is taken from Diun (crazy-max/diun, MIT, pkg/registry/tags.go).
|
||||||
|
var cosignPattern = regexp.MustCompile(`^sha256-[0-9a-f]{64}(\.att|\.sbom|\.sig)?$`)
|
||||||
|
|
||||||
|
// parsed is a tag split into its parts.
|
||||||
|
type parsed struct {
|
||||||
|
prefix, suffix string
|
||||||
|
nums []int
|
||||||
|
}
|
||||||
|
|
||||||
|
// parse splits tag. It returns false for a cosign tag, a tag without digits
|
||||||
|
// and a numeric component that does not fit an int.
|
||||||
|
func parse(tag string) (parsed, bool) {
|
||||||
|
if cosignPattern.MatchString(tag) {
|
||||||
|
return parsed{}, false
|
||||||
|
}
|
||||||
|
m := tagPattern.FindStringSubmatch(tag)
|
||||||
|
if m == nil {
|
||||||
|
return parsed{}, false
|
||||||
|
}
|
||||||
|
var nums []int
|
||||||
|
for _, part := range strings.Split(m[2], ".") {
|
||||||
|
n, err := strconv.Atoi(part)
|
||||||
|
if err != nil {
|
||||||
|
return parsed{}, false
|
||||||
|
}
|
||||||
|
nums = append(nums, n)
|
||||||
|
}
|
||||||
|
return parsed{prefix: m[1], suffix: m[3], nums: nums}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comparable reports whether tag can ever get a newer tag: it splits into
|
||||||
|
// prefix, numeric part and suffix, has at least two numeric components, and
|
||||||
|
// is not a cosign artifact tag. A tag with one component ("7", "pg16",
|
||||||
|
// "8-bookworm") is a moving tag, not a version.
|
||||||
|
func Comparable(tag string) bool {
|
||||||
|
p, ok := parse(tag)
|
||||||
|
return ok && len(p.nums) >= 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// Newer returns the highest candidate that is newer than current, and false
|
||||||
|
// when there is none. A candidate qualifies when it has the same prefix and
|
||||||
|
// suffix and the same number of components as current, the same first
|
||||||
|
// component, and a greater numeric part. Of two candidates with the same
|
||||||
|
// numeric value the lexically smaller string wins, so the result does not
|
||||||
|
// depend on the order of candidates. A tag such as "name2-v1.2.3", where a
|
||||||
|
// digit comes before the version, is not comparable.
|
||||||
|
func Newer(current string, candidates []string) (string, bool) {
|
||||||
|
cur, ok := parse(current)
|
||||||
|
if !ok || len(cur.nums) < 2 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
best, found := "", false
|
||||||
|
var bestNums []int
|
||||||
|
for _, c := range candidates {
|
||||||
|
p, ok := parse(c)
|
||||||
|
if !ok || p.prefix != cur.prefix || p.suffix != cur.suffix ||
|
||||||
|
len(p.nums) != len(cur.nums) || p.nums[0] != cur.nums[0] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if compare(p.nums, cur.nums) <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
best, bestNums, found = c, p.nums, true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if cmp := compare(p.nums, bestNums); cmp > 0 || (cmp == 0 && c < best) {
|
||||||
|
best, bestNums = c, p.nums
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best, found
|
||||||
|
}
|
||||||
|
|
||||||
|
// compare compares two numeric parts of equal length.
|
||||||
|
func compare(a, b []int) int {
|
||||||
|
for i := range a {
|
||||||
|
switch {
|
||||||
|
case a[i] < b[i]:
|
||||||
|
return -1
|
||||||
|
case a[i] > b[i]:
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package tagver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestComparable(t *testing.T) {
|
||||||
|
sig := "sha256-" + strings.Repeat("a", 64) + ".sig"
|
||||||
|
tests := []struct {
|
||||||
|
tag string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"latest", false},
|
||||||
|
{"release", false},
|
||||||
|
{"stable", false},
|
||||||
|
{"edge", false},
|
||||||
|
{"alpine", false},
|
||||||
|
{"7", false},
|
||||||
|
{"7-alpine", false},
|
||||||
|
{"8-bookworm", false},
|
||||||
|
{"pg16", false},
|
||||||
|
{"14-vectorchord0.4.3-pgvectors0.2.0", false},
|
||||||
|
{"name2-v1.2.3", false},
|
||||||
|
{sig, false},
|
||||||
|
{"99999999999999999999.1", false},
|
||||||
|
{"1.2", true},
|
||||||
|
{"1.2.3", true},
|
||||||
|
{"v1.2.3", true},
|
||||||
|
{"11.0-latest", true},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.tag, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.want, Comparable(tt.tag))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewer(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
current string
|
||||||
|
candidates []string
|
||||||
|
want string
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"highest within major", "1.2.3", []string{"1.2.4", "1.3.0", "2.0.0", "1.2.4-rc1", "1.3", "v1.2.4"}, "1.3.0", true},
|
||||||
|
{"v prefix kept", "v1.2.3", []string{"1.2.4", "v1.2.4"}, "v1.2.4", true},
|
||||||
|
{"name prefix kept", "name-v1.2.3", []string{"v1.2.4", "name-v1.2.4"}, "name-v1.2.4", true},
|
||||||
|
{"suffix kept", "11.0-latest", []string{"11.1", "11.1-latest"}, "11.1-latest", true},
|
||||||
|
{"two components", "7.0", []string{"8.0", "7.0.1", "7.2", "7.1"}, "7.2", true},
|
||||||
|
{"equal is not newer", "1.2.3", []string{"1.2.3"}, "", false},
|
||||||
|
{"older is not newer", "1.2.3", []string{"1.2.2", "1.1.9"}, "", false},
|
||||||
|
{"numeric not lexical", "1.9.0", []string{"1.10.0"}, "1.10.0", true},
|
||||||
|
{"empty candidates", "1.2.3", nil, "", false},
|
||||||
|
{"moving tag", "latest", []string{"1.2.3"}, "", false},
|
||||||
|
{"single component", "7", []string{"8"}, "", false},
|
||||||
|
{"digit before version", "name2-v1.2.3", []string{"name2-v1.2.4"}, "", false},
|
||||||
|
{"cosign tag ignored", "1.2.3", []string{"sha256-" + strings.Repeat("0", 64) + ".sig"}, "", false},
|
||||||
|
{"equal value picks smaller string", "1.2.3", []string{"1.02.4", "1.2.4"}, "1.02.4", true},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, ok := Newer(tt.current, tt.candidates)
|
||||||
|
assert.Equal(t, tt.ok, ok)
|
||||||
|
assert.Equal(t, tt.want, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/shcizo/package-updater/internal/master/events"
|
||||||
|
"github.com/shcizo/package-updater/internal/master/model"
|
||||||
|
"github.com/shcizo/package-updater/internal/protocol"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
driftDesired = "sha256:1111111111111111111111111111111111111111111111111111111111111111"
|
||||||
|
driftActual = "sha256:2222222222222222222222222222222222222222222222222222222222222222"
|
||||||
|
)
|
||||||
|
|
||||||
|
type acceptCall struct {
|
||||||
|
id int64
|
||||||
|
actualRef, actualDigest, desiredRef string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) AcceptRunning(_ context.Context, id int64, actualRef, actualDigest, desired string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.acceptCalls = append(f.acceptCalls, acceptCall{id, actualRef, actualDigest, desired})
|
||||||
|
return f.acceptErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) MoveCandidates(context.Context) ([]model.Move, error) { return f.moves, nil }
|
||||||
|
|
||||||
|
func (f *fakeStore) MergeSources(context.Context, int64) ([]model.Service, error) {
|
||||||
|
return f.sources, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) MergeService(_ context.Context, from, to int64) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.merged = append(f.merged, [2]int64{from, to})
|
||||||
|
return f.mergeErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeJobs) Restorable(model.Service) error { return f.restorable }
|
||||||
|
|
||||||
|
func (f *fakeJobs) RestoreDesired(_ context.Context, serviceID int64, by string) (model.Job, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.requestedBy = by
|
||||||
|
f.restored = append(f.restored, serviceID)
|
||||||
|
return f.job, f.err
|
||||||
|
}
|
||||||
|
|
||||||
|
// driftService makes the first fixture service a drifted one: updated to
|
||||||
|
// 1.4.2 at driftDesired, now running 1.4.2 at driftActual.
|
||||||
|
func driftService(store *fakeStore) {
|
||||||
|
store.services[0].DesiredRef = "gitea.shcizo.se/shcizo/slusk:1.4.2@" + driftDesired
|
||||||
|
store.services[0].ActualDigest = driftActual
|
||||||
|
}
|
||||||
|
|
||||||
|
func postTo(t *testing.T, srv *httptest.Server, path string, form url.Values) (*http.Response, string) {
|
||||||
|
t.Helper()
|
||||||
|
form.Set("csrf_token", "csrf")
|
||||||
|
resp, err := noRedirectClient().PostForm(srv.URL+path, form)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return resp, string(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestoreDesired(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
job model.Job
|
||||||
|
err error
|
||||||
|
path string
|
||||||
|
wantCode int
|
||||||
|
wantLocation string
|
||||||
|
wantBody string
|
||||||
|
}{
|
||||||
|
{name: "started", job: model.Job{ID: 42}, path: "/services/1/restore",
|
||||||
|
wantCode: http.StatusSeeOther, wantLocation: "/jobs/42"},
|
||||||
|
{name: "already running", job: model.Job{ID: 41}, err: model.ErrJobActive, path: "/services/1/restore",
|
||||||
|
wantCode: http.StatusSeeOther, wantLocation: "/jobs/41"},
|
||||||
|
{name: "refused", err: &model.NotUpdatableError{Reason: model.ReasonNotDrifted}, path: "/services/1/restore",
|
||||||
|
wantCode: http.StatusConflict, wantBody: "Cannot restore: runs its desired version."},
|
||||||
|
{name: "unknown service", err: model.ErrNotFound, path: "/services/1/restore", wantCode: http.StatusNotFound},
|
||||||
|
{name: "invalid id", path: "/services/x/restore", wantCode: http.StatusBadRequest},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
srv, _, _, _, jobs := newTestEnv(t)
|
||||||
|
jobs.job, jobs.err = tt.job, tt.err
|
||||||
|
|
||||||
|
resp, body := postTo(t, srv, tt.path, url.Values{})
|
||||||
|
|
||||||
|
assert.Equal(t, tt.wantCode, resp.StatusCode)
|
||||||
|
assert.Equal(t, tt.wantLocation, resp.Header.Get("Location"))
|
||||||
|
assert.Contains(t, body, tt.wantBody)
|
||||||
|
if tt.wantCode != http.StatusBadRequest {
|
||||||
|
assert.Equal(t, "alice", jobs.triggeredBy())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAcceptRunning_WritesRunningRefAndPublishes(t *testing.T) {
|
||||||
|
srv, store, _, broker, _ := newTestEnv(t)
|
||||||
|
driftService(store)
|
||||||
|
ch, cancel := broker.Subscribe()
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
resp, _ := postTo(t, srv, "/services/1/accept", url.Values{"actual": {"gitea.shcizo.se/shcizo/slusk:1.4.2@" + driftActual}})
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusSeeOther, resp.StatusCode)
|
||||||
|
assert.Equal(t, "/services/1", resp.Header.Get("Location"))
|
||||||
|
assert.Equal(t, []acceptCall{{
|
||||||
|
id: 1, actualRef: "gitea.shcizo.se/shcizo/slusk:1.4.2", actualDigest: driftActual,
|
||||||
|
desiredRef: "gitea.shcizo.se/shcizo/slusk:1.4.2@" + driftActual,
|
||||||
|
}}, store.acceptCalls, "the value written comes from the row, not the form")
|
||||||
|
select {
|
||||||
|
case topic := <-ch:
|
||||||
|
assert.Equal(t, events.Services, topic)
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("no services signal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAcceptRunning_ReturnToOverview(t *testing.T) {
|
||||||
|
srv, store, _, _, _ := newTestEnv(t)
|
||||||
|
driftService(store)
|
||||||
|
|
||||||
|
resp, _ := postTo(t, srv, "/services/1/accept",
|
||||||
|
url.Values{"actual": {"gitea.shcizo.se/shcizo/slusk:1.4.2@" + driftActual}, "return": {"overview"}})
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusSeeOther, resp.StatusCode)
|
||||||
|
assert.Equal(t, "/", resp.Header.Get("Location"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAcceptRunning_Refusals(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
form url.Values
|
||||||
|
notDrifted bool
|
||||||
|
removed bool
|
||||||
|
acceptErr error
|
||||||
|
wantCode int
|
||||||
|
wantBody string
|
||||||
|
}{
|
||||||
|
{name: "missing actual", path: "/services/1/accept", form: url.Values{}, wantCode: http.StatusBadRequest},
|
||||||
|
{name: "stale actual", path: "/services/1/accept", form: url.Values{"actual": {"gitea.shcizo.se/shcizo/slusk:1.3.0"}},
|
||||||
|
wantCode: http.StatusConflict, wantBody: "The running version changed; check it again."},
|
||||||
|
{name: "same ref, other digest", path: "/services/1/accept",
|
||||||
|
form: url.Values{"actual": {"gitea.shcizo.se/shcizo/slusk:1.4.2@" + driftDesired}},
|
||||||
|
wantCode: http.StatusConflict, wantBody: "The running version changed; check it again."},
|
||||||
|
{name: "not drifted", path: "/services/1/accept", form: url.Values{"actual": {"gitea.shcizo.se/shcizo/slusk:1.4.2@" + driftActual}},
|
||||||
|
notDrifted: true, wantCode: http.StatusConflict, wantBody: "Cannot accept: the service runs its desired version."},
|
||||||
|
{name: "changed under our feet", path: "/services/1/accept", form: url.Values{"actual": {"gitea.shcizo.se/shcizo/slusk:1.4.2@" + driftActual}},
|
||||||
|
acceptErr: model.ErrConflict, wantCode: http.StatusConflict, wantBody: "The running version changed; check it again."},
|
||||||
|
{name: "unknown service", path: "/services/9/accept", form: url.Values{"actual": {"x"}}, wantCode: http.StatusNotFound},
|
||||||
|
{name: "removed", path: "/services/1/accept", form: url.Values{"actual": {"gitea.shcizo.se/shcizo/slusk:1.4.2@" + driftActual}},
|
||||||
|
removed: true, wantCode: http.StatusNotFound},
|
||||||
|
{name: "removed meanwhile", path: "/services/1/accept", form: url.Values{"actual": {"gitea.shcizo.se/shcizo/slusk:1.4.2@" + driftActual}},
|
||||||
|
acceptErr: model.ErrNotFound, wantCode: http.StatusNotFound},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
srv, store, _, _, _ := newTestEnv(t)
|
||||||
|
if !tt.notDrifted {
|
||||||
|
driftService(store)
|
||||||
|
}
|
||||||
|
if tt.removed {
|
||||||
|
removedAt := time.Now()
|
||||||
|
store.services[0].RemovedAt = &removedAt
|
||||||
|
}
|
||||||
|
store.acceptErr = tt.acceptErr
|
||||||
|
|
||||||
|
resp, body := postTo(t, srv, tt.path, tt.form)
|
||||||
|
|
||||||
|
assert.Equal(t, tt.wantCode, resp.StatusCode)
|
||||||
|
assert.Contains(t, body, tt.wantBody)
|
||||||
|
if tt.acceptErr == nil {
|
||||||
|
assert.Empty(t, store.acceptCalls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeService_RedirectsToTheSurvivorAndPublishes(t *testing.T) {
|
||||||
|
srv, store, _, broker, _ := newTestEnv(t)
|
||||||
|
ch, cancel := broker.Subscribe()
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
resp, _ := postTo(t, srv, "/services/1/merge", url.Values{"from": {"17"}})
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusSeeOther, resp.StatusCode)
|
||||||
|
assert.Equal(t, "/services/17", resp.Header.Get("Location"))
|
||||||
|
assert.Equal(t, [][2]int64{{17, 1}}, store.merged)
|
||||||
|
var got []events.Topic
|
||||||
|
for len(got) < 2 {
|
||||||
|
select {
|
||||||
|
case topic := <-ch:
|
||||||
|
got = append(got, topic)
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatalf("signals so far: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.ElementsMatch(t, []events.Topic{events.Services, events.Jobs}, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeService_Refusals(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
id, from string
|
||||||
|
err error
|
||||||
|
wantCode int
|
||||||
|
wantBody string
|
||||||
|
}{
|
||||||
|
{name: "invalid from", id: "1", from: "x", wantCode: http.StatusBadRequest},
|
||||||
|
{name: "empty from", id: "1", from: "", wantCode: http.StatusBadRequest},
|
||||||
|
{name: "refused", id: "1", from: "17", err: &model.MergeRefusedError{Reason: model.ReasonMergeLiveOld},
|
||||||
|
wantCode: http.StatusConflict, wantBody: "Cannot merge: the old service still exists."},
|
||||||
|
{name: "job active", id: "1", from: "17", err: model.ErrJobActive,
|
||||||
|
wantCode: http.StatusConflict, wantBody: "Cannot merge: an update is already in progress for this service."},
|
||||||
|
{name: "agent revoked", id: "1", from: "17", err: model.ErrRevoked, wantCode: http.StatusConflict, wantBody: "Cannot merge:"},
|
||||||
|
{name: "not found", id: "1", from: "17", err: model.ErrNotFound, wantCode: http.StatusNotFound},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
srv, store, _, _, _ := newTestEnv(t)
|
||||||
|
store.mergeErr = tt.err
|
||||||
|
|
||||||
|
resp, body := postTo(t, srv, "/services/"+tt.id+"/merge", url.Values{"from": {tt.from}})
|
||||||
|
|
||||||
|
assert.Equal(t, tt.wantCode, resp.StatusCode)
|
||||||
|
assert.Contains(t, body, tt.wantBody)
|
||||||
|
assert.Empty(t, resp.Header.Get("Location"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// serviceDetail's data is what the service template reads.
|
||||||
|
func TestServiceDetail_DriftAndMoveData(t *testing.T) {
|
||||||
|
removed := time.Now()
|
||||||
|
live := model.Service{ID: 1, Kind: protocol.KindCompose, ComposeProject: "slusk", ComposeService: "web", ActualRef: "slusk:1"}
|
||||||
|
gone := model.Service{ID: 2, Kind: protocol.KindCompose, ComposeProject: "slusk", ComposeService: "web", ActualRef: "slusk:1", RemovedAt: &removed}
|
||||||
|
swarm := model.Service{ID: 3, Kind: protocol.KindSwarm, SwarmService: "gitea", ActualRef: "gitea:1"}
|
||||||
|
container := model.Service{ID: 4, Kind: protocol.KindContainer, ContainerName: "adhoc", ActualRef: "redis:7"}
|
||||||
|
self := model.Service{ID: 5, Kind: protocol.KindCompose, ComposeProject: "pu", ComposeService: "agent", ActualRef: "pu:1", Self: true}
|
||||||
|
move := model.Move{FromID: 2, ToID: 1, FromAgent: "old", ToAgent: "new"}
|
||||||
|
sources := []model.Service{gone}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
id int64
|
||||||
|
restorable error
|
||||||
|
wantBlocked string
|
||||||
|
wantFrom *model.Move
|
||||||
|
wantTo *model.Move
|
||||||
|
wantSources []model.Service
|
||||||
|
}{
|
||||||
|
{name: "live row gets the suggestion and merge choices", id: 1, wantFrom: &move, wantSources: sources},
|
||||||
|
{name: "removed row gets where it went", id: 2, wantTo: &move},
|
||||||
|
{name: "swarm service can merge, no suggestion", id: 3, wantSources: sources},
|
||||||
|
{name: "container cannot merge", id: 4},
|
||||||
|
{name: "self cannot merge", id: 5},
|
||||||
|
{name: "restore refusal is passed on", id: 1, restorable: &model.NotUpdatableError{Reason: model.ReasonSelf},
|
||||||
|
wantBlocked: model.ReasonSelf, wantFrom: &move, wantSources: sources},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
store := &fakeStore{services: []model.Service{live, gone, swarm, container, self},
|
||||||
|
moves: []model.Move{move}, sources: sources}
|
||||||
|
jobs := &fakeJobs{restorable: tt.restorable}
|
||||||
|
s, err := New(store, &fakeHub{}, jobs, events.NewBroker(), Config{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/services/"+strconv.FormatInt(tt.id, 10), nil)
|
||||||
|
|
||||||
|
data, err := s.serviceDetail(req, tt.id)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.wantBlocked, data.RestoreBlocked)
|
||||||
|
assert.Equal(t, tt.wantFrom, data.MoveFrom)
|
||||||
|
assert.Equal(t, tt.wantTo, data.MoveTo)
|
||||||
|
assert.Equal(t, tt.wantSources, data.MergeSources)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# CC0 1.0 Universal
|
||||||
|
|
||||||
|
## Statement of Purpose
|
||||||
|
|
||||||
|
The laws of most jurisdictions throughout the world automatically confer exclusive Copyright and Related Rights (defined below) upon the creator and subsequent owner(s) (each and all, an “owner”) of an original work of authorship and/or a database (each, a “Work”).
|
||||||
|
|
||||||
|
Certain owners wish to permanently relinquish those rights to a Work for the purpose of contributing to a commons of creative, cultural and scientific works (“Commons”) that the public can reliably and without fear of later claims of infringement build upon, modify, incorporate in other works, reuse and redistribute as freely as possible in any form whatsoever and for any purposes, including without limitation commercial purposes. These owners may contribute to the Commons to promote the ideal of a free culture and the further production of creative, cultural and scientific works, or to gain reputation or greater distribution for their Work in part through the use and efforts of others.
|
||||||
|
|
||||||
|
For these and/or other purposes and motivations, and without any expectation of additional consideration or compensation, the person associating CC0 with a Work (the “Affirmer”), to the extent that he or she is an owner of Copyright and Related Rights in the Work, voluntarily elects to apply CC0 to the Work and publicly distribute the Work under its terms, with knowledge of his or her Copyright and Related Rights in the Work and the meaning and intended legal effect of CC0 on those rights.
|
||||||
|
|
||||||
|
1. Copyright and Related Rights. A Work made available under CC0 may be protected by copyright and related or neighboring rights (“Copyright and Related Rights”). Copyright and Related Rights include, but are not limited to, the following:
|
||||||
|
1. the right to reproduce, adapt, distribute, perform, display, communicate, and translate a Work;
|
||||||
|
2. moral rights retained by the original author(s) and/or performer(s);
|
||||||
|
3. publicity and privacy rights pertaining to a person’s image or likeness depicted in a Work;
|
||||||
|
4. rights protecting against unfair competition in regards to a Work, subject to the limitations in paragraph 4(i), below;
|
||||||
|
5. rights protecting the extraction, dissemination, use and reuse of data in a Work;
|
||||||
|
6. database rights (such as those arising under Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, and under any national implementation thereof, including any amended or successor version of such directive); and
|
||||||
|
7. other similar, equivalent or corresponding rights throughout the world based on applicable law or treaty, and any national implementations thereof.
|
||||||
|
|
||||||
|
2. Waiver. To the greatest extent permitted by, but not in contravention of, applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and unconditionally waives, abandons, and surrenders all of Affirmer’s Copyright and Related Rights and associated claims and causes of action, whether now known or unknown (including existing as well as future claims and causes of action), in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the “Waiver”). Affirmer makes the Waiver for the benefit of each member of the public at large and to the detriment of Affirmer’s heirs and successors, fully intending that such Waiver shall not be subject to revocation, rescission, cancellation, termination, or any other legal or equitable action to disrupt the quiet enjoyment of the Work by the public as contemplated by Affirmer’s express Statement of Purpose.
|
||||||
|
|
||||||
|
3. Public License Fallback. Should any part of the Waiver for any reason be judged legally invalid or ineffective under applicable law, then the Waiver shall be preserved to the maximum extent permitted taking into account Affirmer’s express Statement of Purpose. In addition, to the extent the Waiver is so judged Affirmer hereby grants to each affected person a royalty-free, non transferable, non sublicensable, non exclusive, irrevocable and unconditional license to exercise Affirmer’s Copyright and Related Rights in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the “License”). The License shall be deemed effective as of the date CC0 was applied by Affirmer to the Work. Should any part of the License for any reason be judged legally invalid or ineffective under applicable law, such partial invalidity or ineffectiveness shall not invalidate the remainder of the License, and in such case Affirmer hereby affirms that he or she will not (i) exercise any of his or her remaining Copyright and Related Rights in the Work or (ii) assert any associated claims and causes of action with respect to the Work, in either case contrary to Affirmer’s express Statement of Purpose.
|
||||||
|
|
||||||
|
4. Limitations and Disclaimers.
|
||||||
|
1. No trademark or patent rights held by Affirmer are waived, abandoned, surrendered, licensed or otherwise affected by this document.
|
||||||
|
2. Affirmer offers the Work as-is and makes no representations or warranties of any kind concerning the Work, express, implied, statutory or otherwise, including without limitation warranties of title, merchantability, fitness for a particular purpose, non infringement, or the absence of latent or other defects, accuracy, or the present or absence of errors, whether or not discoverable, all to the greatest extent permissible under applicable law.
|
||||||
|
3. Affirmer disclaims responsibility for clearing rights of other persons that may apply to the Work or any use thereof, including without limitation any person’s Copyright and Related Rights in the Work. Further, Affirmer disclaims responsibility for obtaining any necessary consents, permissions or other rights required for any use of the Work.
|
||||||
|
4. Affirmer understands and acknowledges that Creative Commons is not a party to this document and has no duty or obligation with respect to this CC0 or use of the Work.
|
||||||
|
|
||||||
|
For more information, please see <https://creativecommons.org/publicdomain/zero/1.0>.
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
// Command gen builds simple-icons.json.gz, the icon data the icons package
|
||||||
|
// embeds. It downloads the pinned simple-icons npm tarball, checks its
|
||||||
|
// sha256 and writes a gzipped JSON object {slug: svg path data}. The output
|
||||||
|
// is deterministic. It writes into the current directory; run it with
|
||||||
|
//
|
||||||
|
// go generate ./internal/master/web/icons
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
tarballURL = "https://registry.npmjs.org/simple-icons/-/simple-icons-16.35.0.tgz"
|
||||||
|
tarballSHA256 = "20ebead4f6bc93464812a5e260d19fc4d84772e5eeff69a6fbea263ca2441c5f"
|
||||||
|
)
|
||||||
|
|
||||||
|
var pathData = regexp.MustCompile(`<path d="([^"]+)"`)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := run(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "gen:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() error {
|
||||||
|
tgz, err := download(tarballURL)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if sum := sha256.Sum256(tgz); hex.EncodeToString(sum[:]) != tarballSHA256 {
|
||||||
|
return fmt.Errorf("sha256 mismatch for %s: got %x, want %s", tarballURL, sum, tarballSHA256)
|
||||||
|
}
|
||||||
|
icons, license, err := extract(tgz)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var js bytes.Buffer
|
||||||
|
enc := json.NewEncoder(&js)
|
||||||
|
enc.SetEscapeHTML(false)
|
||||||
|
if err := enc.Encode(icons); err != nil { // map keys are written sorted
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var gz bytes.Buffer
|
||||||
|
zw, err := gzip.NewWriterLevel(&gz, gzip.BestCompression)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := zw.Write(js.Bytes()); err != nil { // the header has no name and a zero mtime
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := zw.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile("simple-icons.json.gz", gz.Bytes(), 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile("LICENSE-simple-icons.md", license, 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("wrote %d icons\n", len(icons))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func download(url string) ([]byte, error) {
|
||||||
|
resp, err := http.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("GET %s: %s", url, resp.Status)
|
||||||
|
}
|
||||||
|
return io.ReadAll(resp.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// extract returns the path data of every package/icons/*.svg by slug, and
|
||||||
|
// package/LICENSE.md.
|
||||||
|
func extract(tgz []byte) (icons map[string]string, license []byte, err error) {
|
||||||
|
zr, err := gzip.NewReader(bytes.NewReader(tgz))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
tr := tar.NewReader(zr)
|
||||||
|
icons = map[string]string{}
|
||||||
|
for {
|
||||||
|
h, err := tr.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case h.Name == "package/LICENSE.md":
|
||||||
|
if license, err = io.ReadAll(tr); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
case path.Dir(h.Name) == "package/icons" && strings.HasSuffix(h.Name, ".svg"):
|
||||||
|
svg, err := io.ReadAll(tr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
m := pathData.FindAllSubmatch(svg, -1)
|
||||||
|
if len(m) != 1 {
|
||||||
|
return nil, nil, fmt.Errorf("%s: want exactly one <path>, found %d", h.Name, len(m))
|
||||||
|
}
|
||||||
|
icons[strings.TrimSuffix(path.Base(h.Name), ".svg")] = string(m[0][1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if license == nil || len(icons) == 0 {
|
||||||
|
return nil, nil, fmt.Errorf("tarball has no LICENSE.md or no icons")
|
||||||
|
}
|
||||||
|
return icons, license, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// Package icons maps container image references to brand icons from the
|
||||||
|
// Simple Icons set (CC0, see LICENSE-simple-icons.md). Only the master's UI
|
||||||
|
// imports it, so the agent binary does not carry the data.
|
||||||
|
package icons
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/shcizo/package-updater/internal/imageref"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:generate go run ./gen
|
||||||
|
|
||||||
|
//go:embed simple-icons.json.gz
|
||||||
|
var data []byte
|
||||||
|
|
||||||
|
var (
|
||||||
|
once sync.Once
|
||||||
|
paths map[string]string
|
||||||
|
)
|
||||||
|
|
||||||
|
// aliases maps a normalised image name to the Simple Icons slug that differs
|
||||||
|
// from it.
|
||||||
|
var aliases = map[string]string{
|
||||||
|
"postgres": "postgresql",
|
||||||
|
"goauthentik": "authentik",
|
||||||
|
"traefik": "traefikproxy",
|
||||||
|
"node": "nodedotjs",
|
||||||
|
"mongo": "mongodb",
|
||||||
|
}
|
||||||
|
|
||||||
|
// skipNamespaces are registry namespaces that name a publisher, not a product.
|
||||||
|
var skipNamespaces = map[string]bool{"library": true, "linuxserver": true, "bitnami": true, "hotio": true}
|
||||||
|
|
||||||
|
// suffixes are stripped from the last path segment to find the product name
|
||||||
|
// ("authentik-server" is authentik).
|
||||||
|
var suffixes = []string{"-server", "-web", "-api", "-worker", "-agent"}
|
||||||
|
|
||||||
|
func load() {
|
||||||
|
once.Do(func() {
|
||||||
|
paths = map[string]string{}
|
||||||
|
zr, err := gzip.NewReader(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(zr).Decode(&paths)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path returns the SVG path data (24x24 viewBox) of the icon with this Simple
|
||||||
|
// Icons slug.
|
||||||
|
func Path(slug string) (string, bool) {
|
||||||
|
load()
|
||||||
|
p, ok := paths[slug]
|
||||||
|
return p, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count returns how many icons are embedded.
|
||||||
|
func Count() int {
|
||||||
|
load()
|
||||||
|
return len(paths)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForImage returns the slug of the icon that best matches the image
|
||||||
|
// reference, or "" when there is none. It tries the last path segment, that
|
||||||
|
// segment without a role suffix, and the namespace, in that order.
|
||||||
|
func ForImage(ref string) string {
|
||||||
|
segs := strings.Split(imageref.Parse(ref).Repo, "/")
|
||||||
|
if len(segs) > 1 && imageref.IsRegistryHost(segs[0]) {
|
||||||
|
segs = segs[1:]
|
||||||
|
}
|
||||||
|
last := segs[len(segs)-1]
|
||||||
|
candidates := []string{last}
|
||||||
|
for _, s := range suffixes {
|
||||||
|
if base, ok := strings.CutSuffix(last, s); ok && base != "" {
|
||||||
|
candidates = append(candidates, base)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(segs) > 1 && !skipNamespaces[segs[len(segs)-2]] {
|
||||||
|
candidates = append(candidates, segs[len(segs)-2])
|
||||||
|
}
|
||||||
|
for _, c := range candidates {
|
||||||
|
slug := normalise(c)
|
||||||
|
if a, ok := aliases[slug]; ok {
|
||||||
|
slug = a
|
||||||
|
}
|
||||||
|
if _, ok := Path(slug); ok {
|
||||||
|
return slug
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalise lowercases s and keeps only a-z and 0-9, the form of Simple Icons slugs.
|
||||||
|
func normalise(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range strings.ToLower(s) {
|
||||||
|
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' {
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package icons
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestForImage(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
ref, want string
|
||||||
|
}{
|
||||||
|
{"gitea/gitea:1.27.3", "gitea"},
|
||||||
|
{"ghcr.io/goauthentik/server:2026.8.2", "authentik"},
|
||||||
|
{"postgres:17.10", "postgresql"},
|
||||||
|
{"grafana/loki:3.4.2", "grafana"},
|
||||||
|
{"docker.n8n.io/n8nio/n8n:1.112.4", "n8n"},
|
||||||
|
{"lscr.io/linuxserver/prowlarr:latest", ""},
|
||||||
|
{"localhost:5000/foo:v1", ""},
|
||||||
|
{"gitea.shcizo.se/shcizo/slusk:v0.8.1", ""},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.ref, func(t *testing.T) {
|
||||||
|
assert.Equal(t, c.want, ForImage(c.ref))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// safePath is a strict allowlist for what an embedded path may contain, since
|
||||||
|
// the paths are written into the page's HTML.
|
||||||
|
var safePath = regexp.MustCompile(`^[MmLlHhVvCcSsQqTtAaZz0-9.,\- e]+$`)
|
||||||
|
|
||||||
|
func TestEmbeddedSet(t *testing.T) {
|
||||||
|
assert.Greater(t, Count(), 3000)
|
||||||
|
for slug, p := range paths {
|
||||||
|
if !safePath.MatchString(p) {
|
||||||
|
t.Errorf("icon %q has unexpected characters in its path", slug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p, ok := Path("gitea")
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.NotEmpty(t, p)
|
||||||
|
_, ok = Path("no-such-icon")
|
||||||
|
assert.False(t, ok)
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -0,0 +1,127 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEvents_SendsPingWhenIdle(t *testing.T) {
|
||||||
|
old := pingInterval
|
||||||
|
pingInterval = 20 * time.Millisecond
|
||||||
|
t.Cleanup(func() { pingInterval = old })
|
||||||
|
srv, _, _, _ := newTestServer(t)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/events", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
reader := bufio.NewReader(resp.Body)
|
||||||
|
for {
|
||||||
|
line, err := reader.ReadString('\n')
|
||||||
|
require.NoError(t, err)
|
||||||
|
if strings.HasPrefix(line, "event:") {
|
||||||
|
assert.Equal(t, "event: ping\n", line, "the Live box counts a named event; a comment would not reach the browser's listeners")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateAgent_ValidationShowsInsideTheAddPanel(t *testing.T) {
|
||||||
|
srv, _, _, _ := newTestServer(t)
|
||||||
|
resp, err := http.PostForm(srv.URL+"/agents", url.Values{"name": {"node-2"}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
assert.Equal(t, http.StatusConflict, resp.StatusCode)
|
||||||
|
assert.Contains(t, string(body), `class="drawer"`)
|
||||||
|
assert.Contains(t, string(body), `value="node-2"`, "the typed name is kept")
|
||||||
|
assert.NotContains(t, string(body), "pua_", "no token is shown")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNavFragment(t *testing.T) {
|
||||||
|
srv, _, _, _ := newTestServer(t)
|
||||||
|
cases := []struct {
|
||||||
|
query, want, notWant string
|
||||||
|
}{
|
||||||
|
{"?section=agents", `<a href="/agents" class="cur" aria-current="page">Agents <span class="n">1/3</span>`, `<a href="/" class="cur"`},
|
||||||
|
{"?section=services", `<a href="/" class="cur" aria-current="page">Services <span class="n">4</span>`, `<a href="/agents" class="cur"`},
|
||||||
|
{"?section=bogus", `<a href="/jobs">Jobs</a>`, `class="cur"`},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.query, func(t *testing.T) {
|
||||||
|
code, body := get(t, srv, "/fragments/nav"+c.query)
|
||||||
|
assert.Equal(t, http.StatusOK, code)
|
||||||
|
assert.NotContains(t, body, "<html")
|
||||||
|
assert.Contains(t, body, c.want)
|
||||||
|
assert.NotContains(t, body, c.notWant)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_, body := get(t, srv, "/agents")
|
||||||
|
assert.Contains(t, body, `hx-get="/fragments/nav?section=agents"`, "every page refreshes the sidebar on services and agents events")
|
||||||
|
assert.Contains(t, body, `hx-trigger="sse:services, sse:agents"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentConnectFragment(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
online bool
|
||||||
|
wantCode int
|
||||||
|
want []string
|
||||||
|
notWant []string
|
||||||
|
}{
|
||||||
|
{"never connected waits", "/fragments/agents/5/connect", false, http.StatusOK, []string{"Waiting for fresh", `action="/agents/5/revoke"`}, []string{"Connected", "add-steps"}},
|
||||||
|
{"online is connected", "/fragments/agents/5/connect", true, http.StatusOK, []string{"Connected · ", `id="add-steps" hx-swap-oob="innerHTML"`}, []string{"Waiting", "revoke"}},
|
||||||
|
{"seen before is connected", "/fragments/agents/3/connect", false, http.StatusOK, []string{"Connected · node-3.lan · v1.3.2"}, []string{"Waiting"}},
|
||||||
|
{"unknown", "/fragments/agents/99/connect", false, http.StatusNotFound, nil, nil},
|
||||||
|
{"revoked", "/fragments/agents/4/connect", false, http.StatusNotFound, nil, nil},
|
||||||
|
{"bad id", "/fragments/agents/abc/connect", false, http.StatusBadRequest, nil, nil},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
srv, _, hub, _, _ := newTestEnv(t)
|
||||||
|
hub.mu.Lock()
|
||||||
|
hub.online[5] = c.online
|
||||||
|
hub.mu.Unlock()
|
||||||
|
code, body := get(t, srv, c.path)
|
||||||
|
assert.Equal(t, c.wantCode, code)
|
||||||
|
for _, w := range c.want {
|
||||||
|
assert.Contains(t, body, w)
|
||||||
|
}
|
||||||
|
for _, w := range c.notWant {
|
||||||
|
assert.NotContains(t, body, w)
|
||||||
|
}
|
||||||
|
assert.NotContains(t, body, "pua_", "the token is never sent again")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatic_FontsAndAppJS(t *testing.T) {
|
||||||
|
srv, _, _, _ := newTestServer(t)
|
||||||
|
for _, path := range []string{
|
||||||
|
"/static/app.js", "/static/app.css",
|
||||||
|
"/static/schibsted-grotesk-latin-wght-normal.woff2", "/static/jetbrains-mono-latin-wght-normal.woff2",
|
||||||
|
"/static/OFL-schibsted-grotesk.txt", "/static/OFL-jetbrains-mono.txt",
|
||||||
|
} {
|
||||||
|
code, _ := get(t, srv, path)
|
||||||
|
assert.Equal(t, http.StatusOK, code, path)
|
||||||
|
}
|
||||||
|
_, css := get(t, srv, "/static/app.css")
|
||||||
|
assert.NotContains(t, css, "fonts.googleapis.com", "fonts are served by the master")
|
||||||
|
assert.Contains(t, css, "format('woff2')")
|
||||||
|
_, page := get(t, srv, "/")
|
||||||
|
assert.NotContains(t, page, "fonts.googleapis.com")
|
||||||
|
assert.Contains(t, page, `src="/static/app.js"`)
|
||||||
|
}
|
||||||
+475
-43
@@ -10,6 +10,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -17,7 +18,9 @@ import (
|
|||||||
"github.com/shcizo/package-updater/internal/agenttoken"
|
"github.com/shcizo/package-updater/internal/agenttoken"
|
||||||
"github.com/shcizo/package-updater/internal/master/events"
|
"github.com/shcizo/package-updater/internal/master/events"
|
||||||
"github.com/shcizo/package-updater/internal/master/model"
|
"github.com/shcizo/package-updater/internal/master/model"
|
||||||
|
"github.com/shcizo/package-updater/internal/master/web/icons"
|
||||||
"github.com/shcizo/package-updater/internal/master/webauth"
|
"github.com/shcizo/package-updater/internal/master/webauth"
|
||||||
|
"github.com/shcizo/package-updater/internal/protocol"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed templates/*.html static/*
|
//go:embed templates/*.html static/*
|
||||||
@@ -26,12 +29,33 @@ var assets embed.FS
|
|||||||
// serviceJobsLimit is how many jobs the service page lists.
|
// serviceJobsLimit is how many jobs the service page lists.
|
||||||
const serviceJobsLimit = 20
|
const serviceJobsLimit = 20
|
||||||
|
|
||||||
|
// versionsLimit is how many image versions the service page lists.
|
||||||
|
const versionsLimit = 10
|
||||||
|
|
||||||
var agentNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`)
|
var agentNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`)
|
||||||
|
|
||||||
// Store is the storage the UI reads and writes. *store.Store satisfies it.
|
// Store is the storage the UI reads and writes. *store.Store satisfies it.
|
||||||
type Store interface {
|
type Store interface {
|
||||||
ListServices(ctx context.Context) ([]model.Service, error)
|
ListServices(ctx context.Context) ([]model.Service, error)
|
||||||
GetService(ctx context.Context, id int64) (model.Service, error)
|
GetService(ctx context.Context, id int64) (model.Service, error)
|
||||||
|
// GetTagVersion returns the version of repo with this tag and no digest;
|
||||||
|
// model.ErrNotFound if there is none.
|
||||||
|
GetTagVersion(ctx context.Context, repo, tag string) (model.ImageVersion, error)
|
||||||
|
// RecentVersions returns up to limit versions of repo the master has
|
||||||
|
// seen, most recently first seen first.
|
||||||
|
RecentVersions(ctx context.Context, repo string, limit int) ([]model.ImageVersion, error)
|
||||||
|
// SetUpdateMode returns model.ErrNotFound for an unknown or removed service.
|
||||||
|
SetUpdateMode(ctx context.Context, id int64, mode string) error
|
||||||
|
// AcceptRunning sets the desired ref of a drifted service; model.ErrNotFound
|
||||||
|
// for an unknown or removed service, model.ErrConflict when the running
|
||||||
|
// version is no longer actualRef@actualDigest.
|
||||||
|
AcceptRunning(ctx context.Context, id int64, actualRef, actualDigest, desired string) error
|
||||||
|
// MoveCandidates returns the suggested moves of removed services.
|
||||||
|
MoveCandidates(ctx context.Context) ([]model.Move, error)
|
||||||
|
// MergeSources returns the removed services that could be merged into toID.
|
||||||
|
MergeSources(ctx context.Context, toID int64) ([]model.Service, error)
|
||||||
|
// MergeService moves the live service toID onto the removed fromID.
|
||||||
|
MergeService(ctx context.Context, fromID, toID int64) error
|
||||||
GetJob(ctx context.Context, id int64) (model.Job, error)
|
GetJob(ctx context.Context, id int64) (model.Job, error)
|
||||||
ListJobs(ctx context.Context, f model.JobFilter) ([]model.Job, error)
|
ListJobs(ctx context.Context, f model.JobFilter) ([]model.Job, error)
|
||||||
ListAgents(ctx context.Context) ([]model.Agent, error)
|
ListAgents(ctx context.Context) ([]model.Agent, error)
|
||||||
@@ -46,11 +70,19 @@ type Hub interface {
|
|||||||
Disconnect(agentID int64)
|
Disconnect(agentID int64)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Jobs starts updates and explains why a service cannot be updated.
|
// Jobs starts updates and explains why a service cannot be updated or set
|
||||||
// *jobs.Service satisfies it.
|
// to auto mode. *jobs.Service satisfies it.
|
||||||
type Jobs interface {
|
type Jobs interface {
|
||||||
Updatable(svc model.Service) error
|
Updatable(svc model.Service) error
|
||||||
|
AutoAllowed(svc model.Service) error
|
||||||
Trigger(ctx context.Context, serviceID int64, requestedBy string) (model.Job, error)
|
Trigger(ctx context.Context, serviceID int64, requestedBy string) (model.Job, error)
|
||||||
|
// TriggerVersion updates to exactly the image version versionID of the
|
||||||
|
// service's repo; model.ErrNotFound for an unknown service or version.
|
||||||
|
TriggerVersion(ctx context.Context, serviceID, versionID int64, requestedBy string) (model.Job, error)
|
||||||
|
// Restorable explains why a drifted service cannot be restored.
|
||||||
|
Restorable(svc model.Service) error
|
||||||
|
// RestoreDesired deploys the service's desired ref again.
|
||||||
|
RestoreDesired(ctx context.Context, serviceID int64, requestedBy string) (model.Job, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Broker delivers change signals to open streams and accepts signals from
|
// Broker delivers change signals to open streams and accepts signals from
|
||||||
@@ -60,11 +92,14 @@ type Broker interface {
|
|||||||
Publish(t events.Topic)
|
Publish(t events.Topic)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config holds values shown in the "add agent" snippet.
|
// Config holds values shown in the "add agent" snippet, on the agents page and
|
||||||
|
// on the service page.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
AgentURL string // what agents dial, e.g. wss://10.0.0.5:8090/agent
|
AgentURL string // what agents dial, e.g. wss://10.0.0.5:8090/agent
|
||||||
MasterKey string // the master's fingerprint
|
MasterKey string // the master's fingerprint
|
||||||
AgentImage string
|
AgentImage string
|
||||||
|
PollInterval time.Duration // shown as "every <interval>" on the service page
|
||||||
|
Version string // the master's build version; agents on another version are marked old
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server renders the UI.
|
// Server renders the UI.
|
||||||
@@ -82,9 +117,9 @@ type Server struct {
|
|||||||
// New parses the templates. Each page is its own template set so every page
|
// New parses the templates. Each page is its own template set so every page
|
||||||
// can define its own "content" block.
|
// can define its own "content" block.
|
||||||
func New(store Store, hub Hub, jobs Jobs, broker Broker, cfg Config, log *slog.Logger) (*Server, error) {
|
func New(store Store, hub Hub, jobs Jobs, broker Broker, cfg Config, log *slog.Logger) (*Server, error) {
|
||||||
funcs := template.FuncMap{"shortRef": shortRef, "duration": jobDuration}
|
funcs := template.FuncMap{"shortRef": shortRef, "duration": jobDuration, "followsNoTag": followsNoTag, "middle": middle}
|
||||||
pages := map[string]*template.Template{}
|
pages := map[string]*template.Template{}
|
||||||
for _, page := range []string{"overview.html", "agents.html", "agent_created.html", "service.html", "jobs.html", "job.html"} {
|
for _, page := range []string{"overview.html", "agents.html", "service.html", "jobs.html", "job.html"} {
|
||||||
t, err := template.New(page).Funcs(funcs).ParseFS(assets, "templates/layout.html", "templates/"+page)
|
t, err := template.New(page).Funcs(funcs).ParseFS(assets, "templates/layout.html", "templates/"+page)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("parse %s: %w", page, err)
|
return nil, fmt.Errorf("parse %s: %w", page, err)
|
||||||
@@ -104,31 +139,83 @@ func (s *Server) Register(mux *http.ServeMux, protect func(http.Handler) http.Ha
|
|||||||
route("GET /services/{id}", s.servicePage)
|
route("GET /services/{id}", s.servicePage)
|
||||||
route("GET /fragments/services/{id}/jobs", s.serviceJobsFragment)
|
route("GET /fragments/services/{id}/jobs", s.serviceJobsFragment)
|
||||||
route("POST /services/{id}/update", s.updateService)
|
route("POST /services/{id}/update", s.updateService)
|
||||||
|
route("POST /services/{id}/mode", s.setMode)
|
||||||
|
route("POST /services/{id}/restore", s.restoreDesired)
|
||||||
|
route("POST /services/{id}/accept", s.acceptRunning)
|
||||||
|
route("POST /services/{id}/merge", s.mergeService)
|
||||||
route("GET /jobs", s.jobsPage)
|
route("GET /jobs", s.jobsPage)
|
||||||
route("GET /fragments/jobs", s.jobsFragment)
|
route("GET /fragments/jobs", s.jobsFragment)
|
||||||
route("GET /jobs/{id}", s.jobPage)
|
route("GET /jobs/{id}", s.jobPage)
|
||||||
route("GET /fragments/jobs/{id}", s.jobFragment)
|
route("GET /fragments/jobs/{id}", s.jobFragment)
|
||||||
route("GET /agents", s.agents)
|
route("GET /agents", s.agents)
|
||||||
route("GET /fragments/agents", s.agentsFragment)
|
route("GET /fragments/agents", s.agentsFragment)
|
||||||
|
route("GET /fragments/agents/{id}/connect", s.agentConnectFragment)
|
||||||
|
route("GET /fragments/nav", s.navFragment)
|
||||||
route("POST /agents", s.createAgent)
|
route("POST /agents", s.createAgent)
|
||||||
route("POST /agents/{id}/revoke", s.revokeAgent)
|
route("POST /agents/{id}/revoke", s.revokeAgent)
|
||||||
route("GET /events", s.events)
|
route("GET /events", s.events)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// navData is what the sidebar shows on every page.
|
||||||
|
type navData struct {
|
||||||
|
Section string // services, jobs or agents; "" for none
|
||||||
|
Services int
|
||||||
|
AgentsOnline int
|
||||||
|
AgentsTotal int // agents that are not revoked
|
||||||
|
}
|
||||||
|
|
||||||
|
// spriteIcon is one icon of the sprite the services fragment carries.
|
||||||
|
type spriteIcon struct {
|
||||||
|
Slug string
|
||||||
|
Path string
|
||||||
|
}
|
||||||
|
|
||||||
|
// sections maps a page to the sidebar entry that is current on it.
|
||||||
|
var sections = map[string]string{
|
||||||
|
"overview.html": "services", "service.html": "services",
|
||||||
|
"jobs.html": "jobs", "job.html": "jobs",
|
||||||
|
"agents.html": "agents",
|
||||||
|
}
|
||||||
|
|
||||||
type pageData struct {
|
type pageData struct {
|
||||||
Title string
|
Title string
|
||||||
CSRF string
|
CSRF string
|
||||||
Groups []serviceGroup
|
Nav navData
|
||||||
|
Groups []serviceGroup
|
||||||
|
// ServiceCount is how many services the groups hold.
|
||||||
|
ServiceCount int
|
||||||
|
// Sprite holds the icons the groups use, so the page carries only those.
|
||||||
|
Sprite []spriteIcon
|
||||||
Agents []agentRow
|
Agents []agentRow
|
||||||
Error string
|
Error string
|
||||||
Created model.Agent
|
Created model.Agent
|
||||||
Snippet string
|
Snippet string
|
||||||
|
|
||||||
|
// AddStep is the step of the "add agent" panel: 0 closed, 1 name,
|
||||||
|
// 2 deploy (Created, Token, MasterKey and Snippet are set).
|
||||||
|
AddStep int
|
||||||
|
FormName string // what was typed in step 1
|
||||||
|
Token string
|
||||||
|
MasterKey string
|
||||||
|
Connected bool // the agent Created has connected
|
||||||
|
MasterVersion string // the master's build version; "" if unknown
|
||||||
|
|
||||||
Service model.Service
|
Service model.Service
|
||||||
ConfigFiles []string
|
ConfigFiles []string
|
||||||
Reason string // why Service cannot be updated; "" when it can
|
Reason string // why Service cannot be updated; "" when it can
|
||||||
Jobs []model.Job
|
AutoBlocked string // why Service cannot be set to auto; "" when it can
|
||||||
Job model.Job
|
Versions []versionRow
|
||||||
|
NewerTag string // Service's newer tag while it still holds; "" otherwise
|
||||||
|
Polling string // what the service page says about registry polling
|
||||||
|
PollFailed bool // Polling is the last registry error
|
||||||
|
// RestoreBlocked is why a drifted Service cannot be restored; "" when it
|
||||||
|
// can. Only meaningful when Service.Drifted().
|
||||||
|
RestoreBlocked string
|
||||||
|
MoveFrom *model.Move // a live Service: the removed service it probably replaces
|
||||||
|
MoveTo *model.Move // a removed Service: the live service it probably moved to
|
||||||
|
MergeSources []model.Service // a live Compose or Swarm Service: removed ones to merge in
|
||||||
|
Jobs []model.Job
|
||||||
|
Job model.Job
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) base(r *http.Request, title string) pageData {
|
func (s *Server) base(r *http.Request, title string) pageData {
|
||||||
@@ -136,6 +223,58 @@ func (s *Server) base(r *http.Request, title string) pageData {
|
|||||||
return pageData{Title: title, CSRF: sess.CSRFToken}
|
return pageData{Title: title, CSRF: sess.CSRFToken}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// page renders a full page: the sidebar numbers are looked up and put in data.
|
||||||
|
func (s *Server) page(w http.ResponseWriter, r *http.Request, status int, page string, data pageData) {
|
||||||
|
nav, err := s.navData(r.Context(), sections[page])
|
||||||
|
if err != nil {
|
||||||
|
s.fail(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data.Nav = nav
|
||||||
|
s.render(w, status, page, "layout", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// navData counts what the sidebar shows next to its entries.
|
||||||
|
func (s *Server) navData(ctx context.Context, section string) (navData, error) {
|
||||||
|
nav := navData{Section: section}
|
||||||
|
services, err := s.store.ListServices(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nav, err
|
||||||
|
}
|
||||||
|
nav.Services = len(services)
|
||||||
|
agents, err := s.store.ListAgents(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nav, err
|
||||||
|
}
|
||||||
|
for _, a := range agents {
|
||||||
|
if a.RevokedAt != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nav.AgentsTotal++
|
||||||
|
if s.hub.Online(a.ID) {
|
||||||
|
nav.AgentsOnline++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nav, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// navFragment renders the sidebar entries. The page asks for it with its own
|
||||||
|
// section so the current entry stays marked.
|
||||||
|
func (s *Server) navFragment(w http.ResponseWriter, r *http.Request) {
|
||||||
|
section := r.URL.Query().Get("section")
|
||||||
|
if section != "services" && section != "jobs" && section != "agents" {
|
||||||
|
section = ""
|
||||||
|
}
|
||||||
|
nav, err := s.navData(r.Context(), section)
|
||||||
|
if err != nil {
|
||||||
|
s.fail(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := s.base(r, "")
|
||||||
|
data.Nav = nav
|
||||||
|
s.render(w, http.StatusOK, "overview.html", "nav", data)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) render(w http.ResponseWriter, status int, page, block string, data pageData) {
|
func (s *Server) render(w http.ResponseWriter, status int, page, block string, data pageData) {
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
@@ -150,17 +289,41 @@ func (s *Server) serviceData(r *http.Request) (pageData, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return data, err
|
return data, err
|
||||||
}
|
}
|
||||||
data.Groups = groupServices(services)
|
agents, err := s.store.ListAgents(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return data, err
|
||||||
|
}
|
||||||
|
data.Groups = groupServices(services, agents, s.hub.Online, s.autoBlocked)
|
||||||
|
data.ServiceCount = len(services)
|
||||||
|
data.Sprite = spriteFor(data.Groups)
|
||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// spriteFor collects the icons the groups' rows use, each once.
|
||||||
|
func spriteFor(groups []serviceGroup) []spriteIcon {
|
||||||
|
var sprite []spriteIcon
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, g := range groups {
|
||||||
|
for _, row := range g.Services {
|
||||||
|
if row.Icon == "" || seen[row.Icon] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[row.Icon] = true
|
||||||
|
if path, ok := icons.Path(row.Icon); ok {
|
||||||
|
sprite = append(sprite, spriteIcon{Slug: row.Icon, Path: path})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sprite
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) overview(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) overview(w http.ResponseWriter, r *http.Request) {
|
||||||
data, err := s.serviceData(r)
|
data, err := s.serviceData(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.fail(w, err)
|
s.fail(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.render(w, http.StatusOK, "overview.html", "layout", data)
|
s.page(w, r, http.StatusOK, "overview.html", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) servicesFragment(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) servicesFragment(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -169,7 +332,16 @@ func (s *Server) servicesFragment(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.fail(w, err)
|
s.fail(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.render(w, http.StatusOK, "overview.html", "services", data)
|
s.render(w, http.StatusOK, "overview.html", "services-fragment", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoBlocked returns the reason svc cannot be set to auto mode, or "".
|
||||||
|
func (s *Server) autoBlocked(svc model.Service) string {
|
||||||
|
var nu *model.NotUpdatableError
|
||||||
|
if err := s.jobs.AutoAllowed(svc); errors.As(err, &nu) {
|
||||||
|
return nu.Reason
|
||||||
|
}
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// pathID parses the {id} path value. On failure it has already answered 400.
|
// pathID parses the {id} path value. On failure it has already answered 400.
|
||||||
@@ -207,10 +379,57 @@ func (s *Server) serviceDetail(r *http.Request, id int64) (pageData, error) {
|
|||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return data, err
|
return data, err
|
||||||
}
|
}
|
||||||
|
data.AutoBlocked = s.autoBlocked(svc)
|
||||||
|
if err := s.jobs.Restorable(svc); errors.As(err, &nu) {
|
||||||
|
data.RestoreBlocked = nu.Reason
|
||||||
|
} else if err != nil {
|
||||||
|
return data, err
|
||||||
|
}
|
||||||
|
if err := s.moveData(r, &data); err != nil {
|
||||||
|
return data, err
|
||||||
|
}
|
||||||
|
versions, err := s.store.RecentVersions(r.Context(), svc.ImageRepo, versionsLimit)
|
||||||
|
if err != nil {
|
||||||
|
return data, err
|
||||||
|
}
|
||||||
|
data.NewerTag = newerTag(svc)
|
||||||
|
if data.NewerTag != "" && !slices.ContainsFunc(versions, func(v model.ImageVersion) bool { return v.Tag == data.NewerTag && v.Digest == "" }) {
|
||||||
|
// The suggested tag can be older than the listed versions; it must
|
||||||
|
// still be pickable.
|
||||||
|
v, err := s.store.GetTagVersion(r.Context(), svc.ImageRepo, data.NewerTag)
|
||||||
|
if err == nil {
|
||||||
|
versions = append(versions, v)
|
||||||
|
} else if !errors.Is(err, model.ErrNotFound) {
|
||||||
|
return data, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data.Versions = versionRows(versions, svc.ActualDigest)
|
||||||
|
data.Polling, data.PollFailed = pollingStatus(svc, s.cfg.PollInterval)
|
||||||
data.Jobs, err = s.store.ListJobs(r.Context(), model.JobFilter{ServiceID: id, Limit: serviceJobsLimit})
|
data.Jobs, err = s.store.ListJobs(r.Context(), model.JobFilter{ServiceID: id, Limit: serviceJobsLimit})
|
||||||
return data, err
|
return data, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// moveData fills the move suggestion and the merge choices of the service page.
|
||||||
|
func (s *Server) moveData(r *http.Request, data *pageData) error {
|
||||||
|
svc := data.Service
|
||||||
|
moves, err := s.store.MoveCandidates(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i, m := range moves {
|
||||||
|
switch svc.ID {
|
||||||
|
case m.ToID:
|
||||||
|
data.MoveFrom = &moves[i]
|
||||||
|
case m.FromID:
|
||||||
|
data.MoveTo = &moves[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if svc.RemovedAt == nil && !svc.Self && svc.Kind != protocol.KindContainer {
|
||||||
|
data.MergeSources, err = s.store.MergeSources(r.Context(), svc.ID)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) servicePage(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) servicePage(w http.ResponseWriter, r *http.Request) {
|
||||||
id, ok := pathID(w, r)
|
id, ok := pathID(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -221,7 +440,7 @@ func (s *Server) servicePage(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.failLookup(w, err)
|
s.failLookup(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.render(w, http.StatusOK, "service.html", "layout", data)
|
s.page(w, r, http.StatusOK, "service.html", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) serviceJobsFragment(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) serviceJobsFragment(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -245,24 +464,189 @@ func (s *Server) updateService(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
sess, _ := webauth.SessionFrom(r.Context())
|
sess, _ := webauth.SessionFrom(r.Context())
|
||||||
job, err := s.jobs.Trigger(r.Context(), id, sess.Subject)
|
var job model.Job
|
||||||
|
var err error
|
||||||
|
if v := r.PostFormValue("version"); v == "" {
|
||||||
|
job, err = s.jobs.Trigger(r.Context(), id, sess.Subject)
|
||||||
|
} else {
|
||||||
|
versionID, perr := strconv.ParseInt(v, 10, 64)
|
||||||
|
if perr != nil {
|
||||||
|
http.Error(w, "invalid version", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
job, err = s.jobs.TriggerVersion(r.Context(), id, versionID, sess.Subject)
|
||||||
|
}
|
||||||
var nu *model.NotUpdatableError
|
var nu *model.NotUpdatableError
|
||||||
switch {
|
switch {
|
||||||
case err == nil || errors.Is(err, model.ErrJobActive):
|
case err == nil || errors.Is(err, model.ErrJobActive):
|
||||||
http.Redirect(w, r, "/jobs/"+strconv.FormatInt(job.ID, 10), http.StatusSeeOther)
|
http.Redirect(w, r, "/jobs/"+strconv.FormatInt(job.ID, 10), http.StatusSeeOther)
|
||||||
case errors.As(err, &nu):
|
case errors.As(err, &nu):
|
||||||
data, derr := s.serviceDetail(r, id)
|
s.renderServiceError(w, r, id, http.StatusConflict, "Cannot update: "+nu.Reason+".")
|
||||||
if derr != nil {
|
|
||||||
s.failLookup(w, derr)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
data.Error = "Cannot update: " + nu.Reason + "."
|
|
||||||
s.render(w, http.StatusConflict, "service.html", "layout", data)
|
|
||||||
default:
|
default:
|
||||||
s.failLookup(w, err)
|
s.failLookup(w, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// restoreDesired deploys the desired ref of a drifted service again.
|
||||||
|
func (s *Server) restoreDesired(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := pathID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sess, _ := webauth.SessionFrom(r.Context())
|
||||||
|
job, err := s.jobs.RestoreDesired(r.Context(), id, sess.Subject)
|
||||||
|
var nu *model.NotUpdatableError
|
||||||
|
switch {
|
||||||
|
case err == nil || errors.Is(err, model.ErrJobActive):
|
||||||
|
http.Redirect(w, r, "/jobs/"+strconv.FormatInt(job.ID, 10), http.StatusSeeOther)
|
||||||
|
case errors.As(err, &nu):
|
||||||
|
s.renderServiceError(w, r, id, http.StatusConflict, "Cannot restore: "+nu.Reason+".")
|
||||||
|
default:
|
||||||
|
s.failLookup(w, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// acceptRunning makes the running version of a drifted service the desired
|
||||||
|
// one. The form's actual field is the running ref the user saw (Service.RunningRef);
|
||||||
|
// it only guards against the service having changed since, the value written
|
||||||
|
// comes from the row.
|
||||||
|
func (s *Server) acceptRunning(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := pathID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actual := r.PostFormValue("actual")
|
||||||
|
if actual == "" {
|
||||||
|
http.Error(w, "missing actual", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
svc, err := s.store.GetService(r.Context(), id)
|
||||||
|
if err == nil && svc.RemovedAt != nil {
|
||||||
|
err = model.ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
s.failLookup(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !svc.Drifted() {
|
||||||
|
s.renderServiceError(w, r, id, http.StatusConflict, "Cannot accept: the service runs its desired version.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if actual != svc.RunningRef() {
|
||||||
|
s.renderServiceError(w, r, id, http.StatusConflict, "The running version changed; check it again.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
desired := svc.RunningRef()
|
||||||
|
err = s.store.AcceptRunning(r.Context(), id, svc.ActualRef, svc.ActualDigest, desired)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, model.ErrConflict):
|
||||||
|
s.renderServiceError(w, r, id, http.StatusConflict, "The running version changed; check it again.")
|
||||||
|
return
|
||||||
|
case err != nil:
|
||||||
|
s.failLookup(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sess, _ := webauth.SessionFrom(r.Context())
|
||||||
|
s.log.Info("drift_accepted", "service_id", id, "subject", sess.Subject, "desired", desired)
|
||||||
|
s.broker.Publish(events.Services)
|
||||||
|
if r.PostFormValue("return") == "overview" {
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/services/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeService moves the live service {id} onto the removed service in the
|
||||||
|
// form field "from", which keeps its history, mode and desired ref.
|
||||||
|
func (s *Server) mergeService(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := pathID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
from, err := strconv.ParseInt(r.PostFormValue("from"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid service", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = s.store.MergeService(r.Context(), from, id)
|
||||||
|
var refused *model.MergeRefusedError
|
||||||
|
switch {
|
||||||
|
case errors.As(err, &refused):
|
||||||
|
s.renderServiceError(w, r, id, http.StatusConflict, "Cannot merge: "+refused.Reason+".")
|
||||||
|
return
|
||||||
|
case errors.Is(err, model.ErrJobActive):
|
||||||
|
s.renderServiceError(w, r, id, http.StatusConflict, "Cannot merge: "+model.ErrJobActive.Error()+".")
|
||||||
|
return
|
||||||
|
case errors.Is(err, model.ErrRevoked):
|
||||||
|
s.renderServiceError(w, r, id, http.StatusConflict, "Cannot merge: the agent is revoked.")
|
||||||
|
return
|
||||||
|
case errors.Is(err, model.ErrConflict):
|
||||||
|
s.renderServiceError(w, r, id, http.StatusConflict, "Cannot merge: the service changed; check it again.")
|
||||||
|
return
|
||||||
|
case err != nil:
|
||||||
|
s.failLookup(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sess, _ := webauth.SessionFrom(r.Context())
|
||||||
|
s.log.Info("service_merged", "from_id", from, "to_id", id, "subject", sess.Subject)
|
||||||
|
s.broker.Publish(events.Services)
|
||||||
|
s.broker.Publish(events.Jobs)
|
||||||
|
http.Redirect(w, r, "/services/"+strconv.FormatInt(from, 10), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderServiceError renders the service page of id with msg as its error.
|
||||||
|
func (s *Server) renderServiceError(w http.ResponseWriter, r *http.Request, id int64, status int, msg string) {
|
||||||
|
data, err := s.serviceDetail(r, id)
|
||||||
|
if err != nil {
|
||||||
|
s.failLookup(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data.Error = msg
|
||||||
|
s.page(w, r, status, "service.html", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setMode switches a service between manual and auto. Manual is always
|
||||||
|
// allowed; auto only when jobs.AutoAllowed says so.
|
||||||
|
func (s *Server) setMode(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := pathID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mode := r.PostFormValue("mode")
|
||||||
|
if mode != model.ModeManual && mode != model.ModeAuto {
|
||||||
|
http.Error(w, "invalid mode", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if mode == model.ModeAuto {
|
||||||
|
svc, err := s.store.GetService(r.Context(), id)
|
||||||
|
if err == nil && svc.RemovedAt != nil {
|
||||||
|
err = model.ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
s.failLookup(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var nu *model.NotUpdatableError
|
||||||
|
if err := s.jobs.AutoAllowed(svc); errors.As(err, &nu) {
|
||||||
|
s.renderServiceError(w, r, id, http.StatusConflict, "Cannot enable auto: "+nu.Reason+".")
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
s.fail(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.store.SetUpdateMode(r.Context(), id, mode); err != nil {
|
||||||
|
s.failLookup(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.broker.Publish(events.Services)
|
||||||
|
if r.PostFormValue("return") == "overview" {
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/services/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) jobsData(r *http.Request) (pageData, error) {
|
func (s *Server) jobsData(r *http.Request) (pageData, error) {
|
||||||
data := s.base(r, "Jobs")
|
data := s.base(r, "Jobs")
|
||||||
var err error
|
var err error
|
||||||
@@ -276,7 +660,7 @@ func (s *Server) jobsPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.fail(w, err)
|
s.fail(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.render(w, http.StatusOK, "jobs.html", "layout", data)
|
s.page(w, r, http.StatusOK, "jobs.html", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) jobsFragment(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) jobsFragment(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -307,7 +691,7 @@ func (s *Server) jobData(w http.ResponseWriter, r *http.Request) (pageData, bool
|
|||||||
|
|
||||||
func (s *Server) jobPage(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) jobPage(w http.ResponseWriter, r *http.Request) {
|
||||||
if data, ok := s.jobData(w, r); ok {
|
if data, ok := s.jobData(w, r); ok {
|
||||||
s.render(w, http.StatusOK, "job.html", "layout", data)
|
s.page(w, r, http.StatusOK, "job.html", data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,23 +703,28 @@ func (s *Server) jobFragment(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (s *Server) agentData(r *http.Request) (pageData, error) {
|
func (s *Server) agentData(r *http.Request) (pageData, error) {
|
||||||
data := s.base(r, "Agents")
|
data := s.base(r, "Agents")
|
||||||
|
data.MasterVersion = s.cfg.Version
|
||||||
agents, err := s.store.ListAgents(r.Context())
|
agents, err := s.store.ListAgents(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return data, err
|
return data, err
|
||||||
}
|
}
|
||||||
for _, a := range agents {
|
for _, a := range agents {
|
||||||
data.Agents = append(data.Agents, agentRow{Agent: a, Online: s.hub.Online(a.ID)})
|
data.Agents = append(data.Agents, newAgentRow(a, s.hub.Online(a.ID), s.cfg.Version))
|
||||||
}
|
}
|
||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// agents shows the agent list; with ?add=1 the "add agent" panel is open.
|
||||||
func (s *Server) agents(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) agents(w http.ResponseWriter, r *http.Request) {
|
||||||
data, err := s.agentData(r)
|
data, err := s.agentData(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.fail(w, err)
|
s.fail(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.render(w, http.StatusOK, "agents.html", "layout", data)
|
if r.URL.Query().Get("add") == "1" {
|
||||||
|
data.AddStep = 1
|
||||||
|
}
|
||||||
|
s.page(w, r, http.StatusOK, "agents.html", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) agentsFragment(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) agentsFragment(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -344,13 +733,41 @@ func (s *Server) agentsFragment(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.fail(w, err)
|
s.fail(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.render(w, http.StatusOK, "agents.html", "agents", data)
|
if data.Nav, err = s.navData(r.Context(), "agents"); err != nil {
|
||||||
|
s.fail(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.render(w, http.StatusOK, "agents.html", "agents-fragment", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// agentConnectFragment is the last step of the "add agent" panel: it says
|
||||||
|
// whether the agent that was just added has connected. It never repeats the
|
||||||
|
// token.
|
||||||
|
func (s *Server) agentConnectFragment(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := pathID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
agents, err := s.store.ListAgents(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
s.fail(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
i := slices.IndexFunc(agents, func(a model.Agent) bool { return a.ID == id && a.RevokedAt == nil })
|
||||||
|
if i < 0 {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := s.base(r, "Agents")
|
||||||
|
data.Created = agents[i]
|
||||||
|
data.Connected = s.hub.Online(id) || agents[i].LastSeenAt != nil
|
||||||
|
s.render(w, http.StatusOK, "agents.html", "connect", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) createAgent(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) createAgent(w http.ResponseWriter, r *http.Request) {
|
||||||
name := strings.TrimSpace(r.FormValue("name"))
|
name := strings.TrimSpace(r.FormValue("name"))
|
||||||
if !agentNamePattern.MatchString(name) {
|
if !agentNamePattern.MatchString(name) {
|
||||||
s.agentsWithError(w, r, http.StatusBadRequest, "Agent names use lowercase letters, digits and dashes.")
|
s.agentsWithError(w, r, http.StatusBadRequest, name, "Agent names use lowercase letters, digits and dashes.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
token, err := agenttoken.Generate()
|
token, err := agenttoken.Generate()
|
||||||
@@ -360,28 +777,39 @@ func (s *Server) createAgent(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
agent, err := s.store.CreateAgent(r.Context(), name, agenttoken.Hash(token))
|
agent, err := s.store.CreateAgent(r.Context(), name, agenttoken.Hash(token))
|
||||||
if errors.Is(err, model.ErrConflict) {
|
if errors.Is(err, model.ErrConflict) {
|
||||||
s.agentsWithError(w, r, http.StatusConflict, fmt.Sprintf("An agent named %q already exists.", name))
|
s.agentsWithError(w, r, http.StatusConflict, name, fmt.Sprintf("An agent named %q already exists.", name))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.fail(w, err)
|
s.fail(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data := s.base(r, "Agent created")
|
|
||||||
data.Created = agent
|
|
||||||
data.Snippet = agentSnippet(s.cfg, token)
|
|
||||||
w.Header().Set("Cache-Control", "no-store") // the page shows the one-time token
|
|
||||||
s.render(w, http.StatusOK, "agent_created.html", "layout", data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) agentsWithError(w http.ResponseWriter, r *http.Request, status int, msg string) {
|
|
||||||
data, err := s.agentData(r)
|
data, err := s.agentData(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.fail(w, err)
|
s.fail(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
data.AddStep = 2
|
||||||
|
data.Created = agent
|
||||||
|
data.Token = token
|
||||||
|
data.MasterKey = s.cfg.MasterKey
|
||||||
|
data.Snippet = agentSnippet(s.cfg, token)
|
||||||
|
w.Header().Set("Cache-Control", "no-store") // the page shows the one-time token
|
||||||
|
s.page(w, r, http.StatusOK, "agents.html", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// agentsWithError shows the agent list with the "add agent" panel open at
|
||||||
|
// its first step, name kept and msg under the field.
|
||||||
|
func (s *Server) agentsWithError(w http.ResponseWriter, r *http.Request, status int, name, msg string) {
|
||||||
|
data, err := s.agentData(r)
|
||||||
|
if err != nil {
|
||||||
|
s.fail(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data.AddStep = 1
|
||||||
|
data.FormName = name
|
||||||
data.Error = msg
|
data.Error = msg
|
||||||
s.render(w, status, "agents.html", "layout", data)
|
s.page(w, r, status, "agents.html", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) revokeAgent(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) revokeAgent(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -396,6 +824,7 @@ func (s *Server) revokeAgent(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.hub.Disconnect(id)
|
s.hub.Disconnect(id)
|
||||||
|
s.broker.Publish(events.Agents) // the agent list and the sidebar change
|
||||||
s.broker.Publish(events.Services) // the agent's services were marked removed
|
s.broker.Publish(events.Services) // the agent's services were marked removed
|
||||||
s.broker.Publish(events.Jobs) // the agent's active jobs were failed
|
s.broker.Publish(events.Jobs) // the agent's active jobs were failed
|
||||||
for _, jobID := range failedJobs {
|
for _, jobID := range failedJobs {
|
||||||
@@ -404,6 +833,9 @@ func (s *Server) revokeAgent(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, "/agents", http.StatusSeeOther)
|
http.Redirect(w, r, "/agents", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pingInterval is how often an idle event stream sends "event: ping".
|
||||||
|
var pingInterval = 25 * time.Second
|
||||||
|
|
||||||
// events streams change signals as Server-Sent Events. htmx's SSE extension
|
// events streams change signals as Server-Sent Events. htmx's SSE extension
|
||||||
// turns "event: services" into a refetch of the services fragment. The event
|
// turns "event: services" into a refetch of the services fragment. The event
|
||||||
// carries no state, so a dropped signal is recovered by the next one.
|
// carries no state, so a dropped signal is recovered by the next one.
|
||||||
@@ -423,7 +855,7 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.log.Error("sse_flush_unsupported", "error", err)
|
s.log.Error("sse_flush_unsupported", "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
keepalive := time.NewTicker(25 * time.Second)
|
keepalive := time.NewTicker(pingInterval)
|
||||||
defer keepalive.Stop()
|
defer keepalive.Stop()
|
||||||
for {
|
for {
|
||||||
var msg string
|
var msg string
|
||||||
@@ -433,7 +865,7 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
|
|||||||
case t := <-ch:
|
case t := <-ch:
|
||||||
msg = fmt.Sprintf("event: %s\ndata: changed\n\n", t)
|
msg = fmt.Sprintf("event: %s\ndata: changed\n\n", t)
|
||||||
case <-keepalive.C:
|
case <-keepalive.C:
|
||||||
msg = ": keepalive\n\n"
|
msg = "event: ping\ndata: ok\n\n" // keeps proxies open; the Live box in the sidebar counts it
|
||||||
}
|
}
|
||||||
if _, err := fmt.Fprint(w, msg); err != nil {
|
if _, err := fmt.Fprint(w, msg); err != nil {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -34,6 +34,24 @@ type fakeStore struct {
|
|||||||
created map[string]string // name → token hash
|
created map[string]string // name → token hash
|
||||||
revokedID int64
|
revokedID int64
|
||||||
revokeFailed []int64 // job IDs RevokeAgent reports as failed
|
revokeFailed []int64 // job IDs RevokeAgent reports as failed
|
||||||
|
versions []model.ImageVersion
|
||||||
|
|
||||||
|
acceptCalls []acceptCall
|
||||||
|
acceptErr error
|
||||||
|
moves []model.Move
|
||||||
|
sources []model.Service
|
||||||
|
merged [][2]int64 // from, to
|
||||||
|
mergeErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) RecentVersions(_ context.Context, repo string, limit int) ([]model.ImageVersion, error) {
|
||||||
|
var out []model.ImageVersion
|
||||||
|
for _, v := range f.versions {
|
||||||
|
if v.Repo == repo && len(out) < limit {
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeStore) ListServices(context.Context) ([]model.Service, error) { return f.services, nil }
|
func (f *fakeStore) ListServices(context.Context) ([]model.Service, error) { return f.services, nil }
|
||||||
@@ -46,6 +64,29 @@ func (f *fakeStore) GetService(_ context.Context, id int64) (model.Service, erro
|
|||||||
return model.Service{}, model.ErrNotFound
|
return model.Service{}, model.ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) SetUpdateMode(_ context.Context, id int64, mode string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
for i := range f.services {
|
||||||
|
if f.services[i].ID == id && f.services[i].RemovedAt == nil {
|
||||||
|
f.services[i].UpdateMode = mode
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return model.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) modeOf(id int64) string {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
for _, s := range f.services {
|
||||||
|
if s.ID == id {
|
||||||
|
return s.UpdateMode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeStore) GetJob(_ context.Context, id int64) (model.Job, error) {
|
func (f *fakeStore) GetJob(_ context.Context, id int64) (model.Job, error) {
|
||||||
for _, j := range f.jobs {
|
for _, j := range f.jobs {
|
||||||
if j.ID == id {
|
if j.ID == id {
|
||||||
@@ -99,19 +140,38 @@ func (f *fakeStore) revoked() int64 {
|
|||||||
// fakeJobs returns scripted results and records who asked for the update.
|
// fakeJobs returns scripted results and records who asked for the update.
|
||||||
type fakeJobs struct {
|
type fakeJobs struct {
|
||||||
updatable error
|
updatable error
|
||||||
|
autoErr error
|
||||||
job model.Job
|
job model.Job
|
||||||
err error
|
err error
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
requestedBy string
|
requestedBy string
|
||||||
|
triggered []int64 // version IDs passed to TriggerVersion
|
||||||
|
triggerSvc []int64 // service IDs passed to TriggerVersion
|
||||||
|
plainCalls int // Trigger calls
|
||||||
|
|
||||||
|
restorable error // returned by Restorable
|
||||||
|
restored []int64 // service IDs passed to RestoreDesired
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeJobs) Updatable(model.Service) error { return f.updatable }
|
func (f *fakeJobs) Updatable(model.Service) error { return f.updatable }
|
||||||
|
|
||||||
|
func (f *fakeJobs) AutoAllowed(model.Service) error { return f.autoErr }
|
||||||
|
|
||||||
func (f *fakeJobs) Trigger(_ context.Context, _ int64, by string) (model.Job, error) {
|
func (f *fakeJobs) Trigger(_ context.Context, _ int64, by string) (model.Job, error) {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
f.requestedBy = by
|
f.requestedBy = by
|
||||||
|
f.plainCalls++
|
||||||
|
return f.job, f.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeJobs) TriggerVersion(_ context.Context, serviceID, versionID int64, by string) (model.Job, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.requestedBy = by
|
||||||
|
f.triggerSvc = append(f.triggerSvc, serviceID)
|
||||||
|
f.triggered = append(f.triggered, versionID)
|
||||||
return f.job, f.err
|
return f.job, f.err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,31 +211,56 @@ func newTestServer(t *testing.T) (*httptest.Server, *fakeStore, *fakeHub, *event
|
|||||||
return srv, store, hub, broker
|
return srv, store, hub, broker
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newTestEnv starts the UI on fake storage, with polling off.
|
||||||
func newTestEnv(t *testing.T) (*httptest.Server, *fakeStore, *fakeHub, *events.Broker, *fakeJobs) {
|
func newTestEnv(t *testing.T) (*httptest.Server, *fakeStore, *fakeHub, *events.Broker, *fakeJobs) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
agentID := int64(2)
|
return newTestEnvPolling(t, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestEnvPolling is newTestEnv with pollInterval as the polling interval
|
||||||
|
// the service page shows.
|
||||||
|
func newTestEnvPolling(t *testing.T, pollInterval time.Duration) (*httptest.Server, *fakeStore, *fakeHub, *events.Broker, *fakeJobs) {
|
||||||
|
t.Helper()
|
||||||
|
return newTestEnvConfig(t, func(c *Config) { c.PollInterval = pollInterval })
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestEnvVersion is newTestEnv with version as the master's build version.
|
||||||
|
func newTestEnvVersion(t *testing.T, version string) (*httptest.Server, *fakeStore, *fakeHub, *events.Broker, *fakeJobs) {
|
||||||
|
t.Helper()
|
||||||
|
return newTestEnvConfig(t, func(c *Config) { c.Version = version })
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestEnvConfig is newTestEnv with edit applied to the UI's Config.
|
||||||
|
func newTestEnvConfig(t *testing.T, edit func(*Config)) (*httptest.Server, *fakeStore, *fakeHub, *events.Broker, *fakeJobs) {
|
||||||
|
t.Helper()
|
||||||
|
agentID, offlineAgentID := int64(2), int64(3)
|
||||||
store := &fakeStore{
|
store := &fakeStore{
|
||||||
created: map[string]string{"node-2": "x"},
|
created: map[string]string{"node-2": "x"},
|
||||||
services: []model.Service{
|
services: []model.Service{
|
||||||
{ID: 1, Kind: protocol.KindCompose, ComposeProject: "slusk", ComposeService: "web", AgentID: &agentID, AgentName: "node-2",
|
{ID: 1, Kind: protocol.KindCompose, ComposeProject: "slusk", ComposeService: "web", AgentID: &agentID, AgentName: "node-2",
|
||||||
ActualRef: "gitea.shcizo.se/shcizo/slusk:1.4.2", Status: "running", UpdateMode: "manual"},
|
ActualRef: "gitea.shcizo.se/shcizo/slusk:1.4.2", Status: "running", UpdateMode: "manual"},
|
||||||
{ID: 2, Kind: protocol.KindSwarm, SwarmService: "jellyfin_jellyfin",
|
{ID: 2, Kind: protocol.KindSwarm, SwarmService: "jellyfin_jellyfin", SwarmClusterID: "c1abcdefgh",
|
||||||
ActualRef: "lscr.io/linuxserver/jellyfin@sha256:0f42497a69fa0441bfd5f9d6bba8694f2a656ec984e571d0ac04c6dd91250039", Status: "degraded", UpdateMode: "manual"},
|
ActualRef: "lscr.io/linuxserver/jellyfin@sha256:0f42497a69fa0441bfd5f9d6bba8694f2a656ec984e571d0ac04c6dd91250039", Status: "degraded", UpdateMode: "manual"},
|
||||||
{ID: 3, Kind: protocol.KindContainer, ContainerName: "updater-agent", AgentID: &agentID, AgentName: "node-2",
|
{ID: 3, Kind: protocol.KindContainer, ContainerName: "updater-agent", AgentID: &agentID, AgentName: "node-2",
|
||||||
ActualRef: "pu:1", Status: "running", UpdateMode: "manual", Self: true},
|
ActualRef: "pu:1", Status: "running", UpdateMode: "manual", Self: true},
|
||||||
|
{ID: 4, Kind: protocol.KindCompose, ComposeProject: "media", ComposeService: "radarr", AgentID: &offlineAgentID, AgentName: "node-3",
|
||||||
|
ActualRef: "lscr.io/linuxserver/radarr:latest", Status: "running", UpdateMode: "manual"},
|
||||||
},
|
},
|
||||||
agents: []model.Agent{
|
agents: []model.Agent{
|
||||||
{ID: 2, Name: "node-2", Hostname: "node-2"},
|
{ID: 2, Name: "node-2", Hostname: "node-2.lan", AgentVersion: "v1.4.0", SwarmManager: true, SwarmClusterID: "c1abcdefgh", LastSeenAt: ptr(time.Now().UTC())},
|
||||||
{ID: 3, Name: "node-3"},
|
{ID: 3, Name: "node-3", Hostname: "node-3.lan", AgentVersion: "v1.3.2", LastSeenAt: ptr(time.Date(2026, 10, 12, 13, 51, 0, 0, time.UTC))},
|
||||||
{ID: 4, Name: "old", RevokedAt: ptr(time.Now())},
|
{ID: 4, Name: "old", RevokedAt: ptr(time.Now())},
|
||||||
|
{ID: 5, Name: "fresh"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
hub := &fakeHub{online: map[int64]bool{2: true}}
|
hub := &fakeHub{online: map[int64]bool{2: true}}
|
||||||
broker := events.NewBroker()
|
broker := events.NewBroker()
|
||||||
jobs := &fakeJobs{}
|
jobs := &fakeJobs{}
|
||||||
s, err := New(store, hub, jobs, broker, Config{
|
cfg := Config{
|
||||||
AgentURL: "wss://10.0.0.5:8090/agent", MasterKey: "sha256:abc", AgentImage: "gitea.shcizo.se/shcizo/package-updater:latest",
|
AgentURL: "wss://10.0.0.5:8090/agent", MasterKey: "sha256:abc", AgentImage: "gitea.shcizo.se/shcizo/package-updater:latest",
|
||||||
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
}
|
||||||
|
edit(&cfg)
|
||||||
|
s, err := New(store, hub, jobs, broker, cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
s.Register(mux, func(h http.Handler) http.Handler {
|
s.Register(mux, func(h http.Handler) http.Handler {
|
||||||
@@ -210,13 +295,44 @@ func TestOverview_GroupsServicesPerMachineAndSwarm(t *testing.T) {
|
|||||||
code, body := get(t, srv, "/")
|
code, body := get(t, srv, "/")
|
||||||
assert.Equal(t, http.StatusOK, code)
|
assert.Equal(t, http.StatusOK, code)
|
||||||
assert.Contains(t, body, "<html")
|
assert.Contains(t, body, "<html")
|
||||||
assert.Contains(t, body, "node-2")
|
assert.Contains(t, body, ">Swarm</h2>", "one cluster is just called Swarm")
|
||||||
assert.Contains(t, body, "Swarm")
|
assert.Contains(t, body, `title="c1abcdefgh"`, "the full cluster id is the hover text")
|
||||||
assert.Contains(t, body, "slusk/web")
|
assert.Contains(t, body, "managers node-2")
|
||||||
assert.Contains(t, body, "jellyfin_jellyfin")
|
assert.Contains(t, body, ">node-2</h2>")
|
||||||
|
assert.Contains(t, body, `<span class="nmt">web</span>`)
|
||||||
|
assert.Contains(t, body, `<span class="stk">slusk</span>`)
|
||||||
|
assert.Contains(t, body, "jellyfin_jellyfin", "Swarm service names are shown as they are")
|
||||||
assert.Contains(t, body, "sha256:0f42497a69fa…", "digests are shortened")
|
assert.Contains(t, body, "sha256:0f42497a69fa…", "digests are shortened")
|
||||||
assert.Contains(t, body, "managed outside")
|
assert.Contains(t, body, "Managed outside")
|
||||||
assert.Less(t, strings.Index(body, "node-2"), strings.Index(body, "Swarm"), "machines before Swarm")
|
assert.Less(t, strings.Index(body, ">Swarm</h2>"), strings.Index(body, ">node-2</h2>"), "a cluster is followed by its managers' machines")
|
||||||
|
assert.Contains(t, body, `class="sub" data-machine="agent-2"`, "the machine filter lists the manager under the cluster")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverview_OfflineGroupShowsSince(t *testing.T) {
|
||||||
|
srv, store, hub, _, _ := newTestEnv(t)
|
||||||
|
_, body := get(t, srv, "/")
|
||||||
|
assert.Regexp(t, `Agent offline since <time datetime="2026-10-12T13:51:00Z">13:51</time> · showing last known state`, body)
|
||||||
|
assert.NotContains(t, body, "No manager online", "the cluster has a connected manager")
|
||||||
|
|
||||||
|
hub.mu.Lock()
|
||||||
|
hub.online[2] = false
|
||||||
|
hub.mu.Unlock()
|
||||||
|
_, body = get(t, srv, "/")
|
||||||
|
since := store.agents[0].LastSeenAt.Format("2006-01-02T15:04:05Z07:00")
|
||||||
|
assert.Contains(t, body, `No manager online since <time datetime="`+since+`">`)
|
||||||
|
assert.Contains(t, body, ` · showing last known state`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverview_IconOrLetter(t *testing.T) {
|
||||||
|
srv, store, _, _, _ := newTestEnv(t)
|
||||||
|
store.services[0].ActualRef = "gitea/gitea:1.27.3"
|
||||||
|
for _, path := range []string{"/", "/fragments/services"} {
|
||||||
|
_, body := get(t, srv, path)
|
||||||
|
assert.Contains(t, body, `<symbol id="si-gitea"`, path)
|
||||||
|
assert.Equal(t, 1, strings.Count(body, `<symbol id="si-gitea"`), "the sprite holds each icon once: "+path)
|
||||||
|
assert.Contains(t, body, `href="#si-gitea"`, path)
|
||||||
|
assert.Contains(t, body, `class="ltr lc-`, "services without an icon get a letter: "+path)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServicesFragment_IsNotAFullPage(t *testing.T) {
|
func TestServicesFragment_IsNotAFullPage(t *testing.T) {
|
||||||
@@ -224,15 +340,64 @@ func TestServicesFragment_IsNotAFullPage(t *testing.T) {
|
|||||||
code, body := get(t, srv, "/fragments/services")
|
code, body := get(t, srv, "/fragments/services")
|
||||||
assert.Equal(t, http.StatusOK, code)
|
assert.Equal(t, http.StatusOK, code)
|
||||||
assert.NotContains(t, body, "<html")
|
assert.NotContains(t, body, "<html")
|
||||||
assert.Contains(t, body, "slusk/web")
|
assert.Contains(t, body, `title="slusk/web"`)
|
||||||
|
assert.Contains(t, body, `id="machines" hx-swap-oob`, "the machine filter in the sidebar is refreshed with the rows")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAgentsPage_ShowsConnectionState(t *testing.T) {
|
func TestAgentsPage_ShowsConnectionState(t *testing.T) {
|
||||||
srv, _, _, _ := newTestServer(t)
|
srv, _, _, _ := newTestServer(t)
|
||||||
_, body := get(t, srv, "/agents")
|
_, body := get(t, srv, "/agents")
|
||||||
assert.Regexp(t, regexp.MustCompile(`node-2[\s\S]*online`), body)
|
assert.Regexp(t, regexp.MustCompile(`node-2[\s\S]*Online`), body)
|
||||||
assert.Regexp(t, regexp.MustCompile(`node-3[\s\S]*offline`), body)
|
assert.Regexp(t, regexp.MustCompile(`node-3[\s\S]*Offline`), body)
|
||||||
assert.Regexp(t, regexp.MustCompile(`old[\s\S]*revoked`), body)
|
assert.Regexp(t, regexp.MustCompile(`old[\s\S]*Revoked`), body)
|
||||||
|
assert.Regexp(t, regexp.MustCompile(`fresh[\s\S]*Never connected`), body)
|
||||||
|
assert.Contains(t, body, "Manager")
|
||||||
|
assert.Contains(t, body, "3 machines", "revoked agents are not counted")
|
||||||
|
assert.NotContains(t, body, `class="drawer"`, "the add panel is closed by default")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentsFragment_RefreshesHeaderCount(t *testing.T) {
|
||||||
|
srv, _, _, _ := newTestServer(t)
|
||||||
|
code, body := get(t, srv, "/fragments/agents")
|
||||||
|
assert.Equal(t, http.StatusOK, code)
|
||||||
|
assert.Contains(t, body, `id="agents-sub" hx-swap-oob="true">3 machines`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentsPage_RevokeUsesPopover(t *testing.T) {
|
||||||
|
srv, _, _, _ := newTestServer(t)
|
||||||
|
_, body := get(t, srv, "/agents")
|
||||||
|
assert.Contains(t, body, `<details class="rv" data-id="2">`)
|
||||||
|
assert.Contains(t, body, `action="/agents/2/revoke"`)
|
||||||
|
assert.NotContains(t, body, "confirm(")
|
||||||
|
assert.NotContains(t, body, `action="/agents/4/revoke"`, "a revoked agent cannot be revoked again")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentsPage_MasterVersionAndOldAgent(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name, version string
|
||||||
|
wantOld bool
|
||||||
|
}{
|
||||||
|
{"other version is old", "v1.4.0", true},
|
||||||
|
{"dev master marks nothing", "dev", false},
|
||||||
|
{"unknown master version marks nothing", "", false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
srv, _, _, _, _ := newTestEnvVersion(t, c.version)
|
||||||
|
_, body := get(t, srv, "/agents")
|
||||||
|
assert.Equal(t, c.version != "", strings.Contains(body, "master "+c.version))
|
||||||
|
assert.Equal(t, c.wantOld, strings.Contains(body, `<div class="ver old">`+"<span class=\"k\">Agent</span>v1.3.2"), "node-3 runs v1.3.2")
|
||||||
|
assert.NotContains(t, body, `<div class="ver old"><span class="k">Agent</span>v1.4.0`, "node-2 is current")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentsPage_AddPanelOpensWithQuery(t *testing.T) {
|
||||||
|
srv, _, _, _ := newTestServer(t)
|
||||||
|
_, body := get(t, srv, "/agents?add=1")
|
||||||
|
assert.Contains(t, body, `class="drawer"`)
|
||||||
|
assert.Contains(t, body, `<form method="post" action="/agents"`)
|
||||||
|
assert.Contains(t, body, `name="csrf_token" value="csrf"`)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateAgent_ShowsSnippetOnceAndStoresHash(t *testing.T) {
|
func TestCreateAgent_ShowsSnippetOnceAndStoresHash(t *testing.T) {
|
||||||
@@ -250,6 +415,10 @@ func TestCreateAgent_ShowsSnippetOnceAndStoresHash(t *testing.T) {
|
|||||||
assert.Contains(t, string(body), "MASTER_KEY: sha256:abc")
|
assert.Contains(t, string(body), "MASTER_KEY: sha256:abc")
|
||||||
assert.Contains(t, string(body), "MASTER_URL: wss://10.0.0.5:8090/agent")
|
assert.Contains(t, string(body), "MASTER_URL: wss://10.0.0.5:8090/agent")
|
||||||
assert.Contains(t, string(body), "shown only once")
|
assert.Contains(t, string(body), "shown only once")
|
||||||
|
assert.Contains(t, string(body), `<pre id="snippet">`)
|
||||||
|
assert.Contains(t, string(body), `data-copy="`+token+`"`)
|
||||||
|
assert.Contains(t, string(body), `hx-get="/fragments/agents/9/connect"`, "step 3 waits for the agent")
|
||||||
|
assert.Contains(t, string(body), ">node-3</b>", "the agent list is still on the page")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateAgent_Validation(t *testing.T) {
|
func TestCreateAgent_Validation(t *testing.T) {
|
||||||
@@ -289,16 +458,16 @@ func TestRevokeAgent_RevokesAndDisconnects(t *testing.T) {
|
|||||||
assert.Equal(t, int64(2), store.revoked())
|
assert.Equal(t, int64(2), store.revoked())
|
||||||
assert.Equal(t, int64(2), hub.lastDisconnected(), "a revoked agent must be cut off immediately")
|
assert.Equal(t, int64(2), hub.lastDisconnected(), "a revoked agent must be cut off immediately")
|
||||||
var got []events.Topic
|
var got []events.Topic
|
||||||
for len(got) < 4 {
|
for len(got) < 5 {
|
||||||
select {
|
select {
|
||||||
case topic := <-ch:
|
case topic := <-ch:
|
||||||
got = append(got, topic)
|
got = append(got, topic)
|
||||||
default:
|
default:
|
||||||
t.Fatalf("revoke published %v, want services, jobs and one topic per failed job", got)
|
t.Fatalf("revoke published %v, want agents, services, jobs and one topic per failed job", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
assert.ElementsMatch(t, []events.Topic{events.Services, events.Jobs, events.JobTopic(5), events.JobTopic(6)}, got,
|
assert.ElementsMatch(t, []events.Topic{events.Agents, events.Services, events.Jobs, events.JobTopic(5), events.JobTopic(6)}, got,
|
||||||
"the overview drops the agent's services; job lists and open job pages show the failures")
|
"the agent list and sidebar change; the overview drops the agent's services; job lists and open job pages show the failures")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRevokeAgent_BadID(t *testing.T) {
|
func TestRevokeAgent_BadID(t *testing.T) {
|
||||||
@@ -504,3 +673,148 @@ func TestJob_NotFound(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusNotFound, code)
|
assert.Equal(t, http.StatusNotFound, code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func postMode(t *testing.T, srv *httptest.Server, id string, form url.Values) (*http.Response, string) {
|
||||||
|
t.Helper()
|
||||||
|
form.Set("csrf_token", "csrf")
|
||||||
|
resp, err := noRedirectClient().PostForm(srv.URL+"/services/"+id+"/mode", form)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return resp, string(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetMode_AutoRedirectsToServiceAndPublishes(t *testing.T) {
|
||||||
|
srv, store, _, broker, _ := newTestEnv(t)
|
||||||
|
sub, cancel := broker.Subscribe()
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
resp, _ := postMode(t, srv, "1", url.Values{"mode": {"auto"}})
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusSeeOther, resp.StatusCode)
|
||||||
|
assert.Equal(t, "/services/1", resp.Header.Get("Location"))
|
||||||
|
assert.Equal(t, "auto", store.modeOf(1))
|
||||||
|
assert.Equal(t, events.Services, <-sub)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetMode_ReturnToOverview(t *testing.T) {
|
||||||
|
srv, store, _, _, _ := newTestEnv(t)
|
||||||
|
resp, _ := postMode(t, srv, "1", url.Values{"mode": {"auto"}, "return": {"overview"}})
|
||||||
|
assert.Equal(t, http.StatusSeeOther, resp.StatusCode)
|
||||||
|
assert.Equal(t, "/", resp.Header.Get("Location"))
|
||||||
|
assert.Equal(t, "auto", store.modeOf(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetMode_AutoRefusedShows409(t *testing.T) {
|
||||||
|
srv, store, _, _, jobs := newTestEnv(t)
|
||||||
|
jobs.autoErr = &model.NotUpdatableError{Reason: model.ReasonBuilt}
|
||||||
|
|
||||||
|
resp, body := postMode(t, srv, "1", url.Values{"mode": {"auto"}})
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusConflict, resp.StatusCode)
|
||||||
|
assert.Contains(t, body, "Cannot enable auto: "+model.ReasonBuilt+".")
|
||||||
|
assert.Equal(t, "manual", store.modeOf(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetMode_ManualAlwaysAllowed(t *testing.T) {
|
||||||
|
srv, store, _, _, jobs := newTestEnv(t)
|
||||||
|
store.services[0].UpdateMode = "auto"
|
||||||
|
jobs.autoErr = &model.NotUpdatableError{Reason: model.ReasonBuilt}
|
||||||
|
|
||||||
|
resp, _ := postMode(t, srv, "1", url.Values{"mode": {"manual"}})
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusSeeOther, resp.StatusCode)
|
||||||
|
assert.Equal(t, "manual", store.modeOf(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetMode_InvalidModeAndUnknownService(t *testing.T) {
|
||||||
|
srv, store, _, _, _ := newTestEnv(t)
|
||||||
|
store.services = append(store.services, model.Service{ID: 5, Kind: protocol.KindCompose, UpdateMode: "manual", RemovedAt: ptr(time.Now())})
|
||||||
|
|
||||||
|
resp, _ := postMode(t, srv, "1", url.Values{"mode": {"yolo"}})
|
||||||
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
|
resp, _ = postMode(t, srv, "1", url.Values{})
|
||||||
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
|
resp, _ = postMode(t, srv, "99", url.Values{"mode": {"auto"}})
|
||||||
|
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||||
|
resp, _ = postMode(t, srv, "99", url.Values{"mode": {"manual"}})
|
||||||
|
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||||
|
resp, _ = postMode(t, srv, "5", url.Values{"mode": {"auto"}})
|
||||||
|
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||||
|
resp, _ = postMode(t, srv, "abc", url.Values{"mode": {"auto"}})
|
||||||
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
|
assert.Equal(t, "manual", store.modeOf(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverview_ShowsModeToggleAndAvailableBadge(t *testing.T) {
|
||||||
|
srv, store, _, _, jobs := newTestEnv(t)
|
||||||
|
store.services[0].AvailableRef = "gitea.shcizo.se/shcizo/slusk:1.5.0"
|
||||||
|
store.services[1].UpdateMode = "auto"
|
||||||
|
jobs.autoErr = &model.NotUpdatableError{Reason: model.ReasonBuilt}
|
||||||
|
|
||||||
|
for _, path := range []string{"/", "/fragments/services"} {
|
||||||
|
_, body := get(t, srv, path)
|
||||||
|
assert.Contains(t, body, `action="/services/1/mode"`, path)
|
||||||
|
assert.Contains(t, body, `name="csrf_token" value="csrf"`, path)
|
||||||
|
assert.Contains(t, body, `name="return" value="overview"`, path)
|
||||||
|
assert.Contains(t, body, `class="tag f-up" title="gitea.shcizo.se/shcizo/slusk:1.5.0">Update available<em>1.5.0</em>`, path)
|
||||||
|
assert.Contains(t, body, `<input type="hidden" name="mode" value="manual">`+"\n"+` <button type="submit" class="sw" role="switch" aria-checked="true">`, "auto services toggle to manual: "+path)
|
||||||
|
assert.Contains(t, body, `<input type="hidden" name="mode" value="auto">`, "manual services toggle to auto: "+path)
|
||||||
|
assert.Contains(t, body, `aria-checked="false" disabled title="`+model.ReasonBuilt+`">`, "blocked manual services get a disabled switch: "+path)
|
||||||
|
assert.Contains(t, body, `<span class="na">`+model.ReasonBuilt+`</span>`, "and say why: "+path)
|
||||||
|
assert.Contains(t, body, `<span class="na">n/a</span>`, "services managed outside have no switch: "+path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServicePage_ShowsAvailableAndModeForm(t *testing.T) {
|
||||||
|
srv, store, _, _, _ := newTestEnv(t)
|
||||||
|
at := time.Date(2026, 10, 12, 12, 30, 0, 0, time.UTC)
|
||||||
|
store.services[0].AvailableRef = "gitea.shcizo.se/shcizo/slusk:1.5.0"
|
||||||
|
store.services[0].AvailableSource = "webhook"
|
||||||
|
store.services[0].AvailableAt = &at
|
||||||
|
|
||||||
|
_, body := get(t, srv, "/services/1")
|
||||||
|
|
||||||
|
assert.Contains(t, body, "gitea.shcizo.se/shcizo/slusk:1.5.0")
|
||||||
|
assert.Contains(t, body, "via webhook, 2026-10-12 12:30")
|
||||||
|
assert.Contains(t, body, `action="/services/1/mode"`)
|
||||||
|
assert.Contains(t, body, `<input type="hidden" name="mode" value="auto">`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServicePage_AutoBlockedDisablesToggle(t *testing.T) {
|
||||||
|
srv, _, _, _, jobs := newTestEnv(t)
|
||||||
|
jobs.autoErr = &model.NotUpdatableError{Reason: model.ReasonContainer}
|
||||||
|
_, body := get(t, srv, "/services/1")
|
||||||
|
assert.Contains(t, body, "Auto unavailable: "+model.ReasonContainer+".")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTagVersion returns the digest-less version of repo with this tag.
|
||||||
|
func (f *fakeStore) GetTagVersion(_ context.Context, repo, tag string) (model.ImageVersion, error) {
|
||||||
|
for _, v := range f.versions {
|
||||||
|
if v.Repo == repo && v.Tag == tag && v.Digest == "" {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return model.ImageVersion{}, model.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverview_ShowsNewerTagChipBesideUpdateChip(t *testing.T) {
|
||||||
|
srv, store, _, _, _ := newTestEnv(t)
|
||||||
|
store.services[0].AvailableRef = "gitea.shcizo.se/shcizo/slusk:1.4.2"
|
||||||
|
store.services[0].NewerRef = "gitea.shcizo.se/shcizo/slusk:1.5.0"
|
||||||
|
|
||||||
|
for _, path := range []string{"/", "/fragments/services"} {
|
||||||
|
_, body := get(t, srv, path)
|
||||||
|
assert.Contains(t, body, `class="tag f-newer" title="gitea.shcizo.se/shcizo/slusk:1.5.0">Newer tag<em>1.5.0</em>`, path)
|
||||||
|
assert.Contains(t, body, `class="tag f-up"`, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverview_HidesStaleNewerTag(t *testing.T) {
|
||||||
|
srv, store, _, _, _ := newTestEnv(t)
|
||||||
|
store.services[0].NewerRef = "gitea.shcizo.se/shcizo/slusk:1.4.1" // not newer than 1.4.2
|
||||||
|
|
||||||
|
_, body := get(t, srv, "/")
|
||||||
|
|
||||||
|
assert.NotContains(t, body, "f-newer")
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) JetBrainsMono-Italic[wght].ttf: Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2023 The Schibsted-Grotesk Project Authors (https://github.com/schibsted/schibsted-grotesk) SchibstedGrotesk-Italic[wght].ttf: Copyright 2023 The Schibsted-Grotesk Project Authors (https://github.com/schibsted/schibsted-grotesk)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
@@ -1,30 +1,221 @@
|
|||||||
:root { --bg: #111418; --fg: #e6e8eb; --muted: #8a929c; --line: #262b31; --ok: #3fb950; --warn: #d29922; --bad: #f85149; --accent: #58a6ff; }
|
@font-face{font-family:'Schibsted Grotesk';font-style:normal;font-weight:400 900;font-display:swap;src:url(/static/schibsted-grotesk-latin-wght-normal.woff2) format('woff2')}
|
||||||
@media (prefers-color-scheme: light) {
|
@font-face{font-family:'JetBrains Mono';font-style:normal;font-weight:100 800;font-display:swap;src:url(/static/jetbrains-mono-latin-wght-normal.woff2) format('woff2')}
|
||||||
:root { --bg: #ffffff; --fg: #1f2328; --muted: #59636e; --line: #d1d9e0; --ok: #1a7f37; --warn: #9a6700; --bad: #cf222e; --accent: #0969da; }
|
|
||||||
|
/* Ledger. Tokens live on <html class="app">; a script in <head> adds t-light before first paint. */
|
||||||
|
.app{color-scheme:dark;--bg:#0f1218;--side:#0b0d12;--card:#151a23;--hover:#171c26;--line:#232a37;--line2:#323a4a;--fg:#e7eaf1;--mut:#a0a9bb;--dim:#828ba0;--blue:#74a8ff;--bluebg:rgba(116,168,255,.12);--orange:#ffa262;--orangebg:rgba(255,162,98,.12);--green:#62d4a0;--red:#ff7d88;--redbg:rgba(255,125,136,.10);--violet:#c5a9ff;--violetbg:rgba(197,169,255,.12);--l0:#74a8ff;--l1:#c5a9ff;--l2:#5fd0e0;--l3:#f29bd0;--onblue:#06142e;--term:#080a0e;--shadow:0 12px 32px rgba(0,0,0,.5);--sans:'Schibsted Grotesk',system-ui,-apple-system,sans-serif;--mono:'JetBrains Mono',ui-monospace,Menlo,monospace;min-height:100vh;background:var(--bg);color:var(--fg);font:14px/1.45 var(--sans)}
|
||||||
|
.app.t-light{color-scheme:light;--bg:#f7f8fa;--side:#eceff4;--card:#ffffff;--hover:#f0f2f6;--line:#dde1e8;--line2:#c6ccd7;--fg:#121620;--mut:#4b5468;--dim:#5c6579;--blue:#2156c8;--bluebg:rgba(33,86,200,.08);--orange:#a84b0b;--orangebg:rgba(200,96,20,.09);--green:#177649;--red:#b32a3b;--redbg:rgba(179,42,59,.07);--violet:#6943c1;--violetbg:rgba(105,67,193,.08);--l0:#2156c8;--l1:#6943c1;--l2:#0b7285;--l3:#a8306f;--onblue:#ffffff;--term:#121620;--shadow:0 12px 32px rgba(20,30,50,.16)}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
body{margin:0}
|
||||||
|
[hidden]{display:none !important}
|
||||||
|
a{color:var(--blue)}a:hover{color:var(--fg)}
|
||||||
|
button,input{font-family:inherit}
|
||||||
|
:focus-visible{outline:2px solid var(--blue);outline-offset:2px}
|
||||||
|
|
||||||
|
/* Shell */
|
||||||
|
.shell{display:flex;flex-wrap:wrap;min-height:100vh}
|
||||||
|
.side{flex:0 0 232px;background:var(--side);border-right:1px solid var(--line);display:flex;flex-direction:column;gap:28px;padding:20px 14px}
|
||||||
|
.brand{display:flex;align-items:center;gap:10px;font:700 15px var(--sans);letter-spacing:-.01em;padding:0 8px;color:var(--fg);text-decoration:none}
|
||||||
|
.brand svg{color:var(--blue)}
|
||||||
|
.snav{display:flex;flex-direction:column;gap:2px}
|
||||||
|
.snav a,.mlist a{display:flex;align-items:center;gap:10px;padding:7px 8px;border-radius:6px;color:var(--mut);text-decoration:none;font-weight:500}
|
||||||
|
.snav a:hover,.mlist a:hover{background:var(--hover);color:var(--fg)}
|
||||||
|
.snav a.cur,.mlist a.cur{background:var(--card);color:var(--fg);box-shadow:inset 0 0 0 1px var(--line)}
|
||||||
|
.snav .n,.mlist .n{margin-left:auto;font:12px var(--mono);color:var(--dim)}
|
||||||
|
.slabel{font:600 11px var(--sans);text-transform:uppercase;letter-spacing:.08em;color:var(--dim);padding:0 8px 6px}
|
||||||
|
.mlist{display:flex;flex-direction:column;gap:2px}
|
||||||
|
.mlist .sub{padding-left:22px;font-size:13px}
|
||||||
|
.mdot{width:7px;height:7px;border-radius:50%;background:var(--green);flex:none}
|
||||||
|
.mdot.off{background:transparent;box-shadow:inset 0 0 0 1.5px var(--red)}
|
||||||
|
.sw-icon{width:7px;height:7px;flex:none;border-radius:2px;box-shadow:inset 0 0 0 1.5px var(--blue)}
|
||||||
|
.livebox{margin-top:auto;padding:10px 8px;border-top:1px solid var(--line);font-size:12px;color:var(--mut);display:flex;flex-direction:column;gap:2px}
|
||||||
|
.livebox b{display:flex;align-items:center;gap:8px;color:var(--fg);font-weight:600}
|
||||||
|
.pulse{width:8px;height:8px;border-radius:50%;background:var(--green);animation:breathe 2.4s ease-in-out infinite}
|
||||||
|
.livebox.down .pulse{background:var(--red);animation:none}
|
||||||
|
@keyframes breathe{0%{box-shadow:0 0 0 0 rgba(98,212,160,.45)}50%{box-shadow:0 0 0 5px rgba(98,212,160,0)}}
|
||||||
|
.sfoot{display:flex;flex-wrap:wrap;align-items:center;gap:4px 8px}
|
||||||
|
.sfoot form{margin:0}
|
||||||
|
.work{flex:999 1 640px;min-width:0;display:flex;flex-wrap:wrap}
|
||||||
|
.main{flex:999 1 520px;min-width:0;padding:28px 36px 48px;container-type:inline-size}
|
||||||
|
.sprite{position:absolute;width:0;height:0;overflow:hidden}
|
||||||
|
|
||||||
|
/* Page header, buttons */
|
||||||
|
.hdr{display:flex;flex-wrap:wrap;align-items:flex-end;gap:12px 24px;margin-bottom:18px}
|
||||||
|
.hdr h1{margin:0;font:700 34px/1 var(--sans);letter-spacing:-.025em}
|
||||||
|
.hdr .sub{font-size:14px;color:var(--mut)}
|
||||||
|
.hdr .end{margin-left:auto}
|
||||||
|
.btn,button:not([class]){height:32px;padding:0 14px;border-radius:6px;border:1px solid var(--line2);background:var(--card);color:var(--fg);font:600 13px var(--sans);cursor:pointer;display:inline-flex;align-items:center;gap:8px;white-space:nowrap;text-decoration:none}
|
||||||
|
.btn:hover,button:not([class]):hover{border-color:var(--dim);color:var(--fg)}
|
||||||
|
button:not([class]):disabled{opacity:.5;cursor:not-allowed}
|
||||||
|
.btn.pri,.btn.pri:hover{background:var(--blue);border-color:var(--blue);color:var(--onblue)}
|
||||||
|
.btn.sm{height:28px;padding:0 10px;font-size:12.5px}
|
||||||
|
.btn.quiet{border-color:transparent;background:transparent;color:var(--mut)}
|
||||||
|
.btn.quiet:hover{color:var(--fg)}
|
||||||
|
.btn.red{color:var(--red);border-color:var(--line2);background:transparent}
|
||||||
|
.btn.redfill,.btn.redfill:hover{background:var(--red);border-color:var(--red);color:#1d0408}
|
||||||
|
summary.btn{list-style:none}
|
||||||
|
summary.btn::-webkit-details-marker{display:none}
|
||||||
|
|
||||||
|
/* Overview */
|
||||||
|
.tools{display:flex;flex-wrap:wrap;align-items:center;gap:10px 18px;padding:12px 0;border-top:1px solid var(--line);border-bottom:1px solid var(--line);margin-bottom:8px}
|
||||||
|
.search{flex:0 1 340px;min-width:220px;display:flex;align-items:center;gap:8px;height:34px;padding:0 12px;border-radius:6px;background:var(--card);border:1px solid var(--line2);color:var(--dim)}
|
||||||
|
.search input{flex:1;min-width:0;border:0;outline:0;background:transparent;color:var(--fg);font-size:14px}
|
||||||
|
.search input::placeholder{color:var(--dim)}
|
||||||
|
.tools .end{margin-left:auto;display:flex;align-items:center;gap:10px}
|
||||||
|
.tools .lbl{font-size:12.5px;color:var(--dim)}
|
||||||
|
.seg{display:inline-flex;padding:2px;border-radius:7px;background:var(--card);box-shadow:inset 0 0 0 1px var(--line2)}
|
||||||
|
.seg button{display:inline-flex;align-items:center;gap:6px;height:28px;padding:0 10px;border:0;border-radius:5px;background:transparent;color:var(--mut);font:500 12.5px var(--sans);cursor:pointer}
|
||||||
|
.seg button:hover{color:var(--fg)}
|
||||||
|
.seg button[aria-pressed=true]{background:var(--hover);color:var(--fg);box-shadow:inset 0 0 0 1px var(--line2)}
|
||||||
|
.grp{margin-top:28px}
|
||||||
|
.gh{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 12px;padding-bottom:8px;border-bottom:2px solid var(--fg)}
|
||||||
|
.gh h2{margin:0;font:700 20px var(--sans);letter-spacing:-.015em}
|
||||||
|
.gh .kind{font:600 11px var(--sans);text-transform:uppercase;letter-spacing:.08em;color:var(--mut)}
|
||||||
|
.gh .meta{font-size:13px;color:var(--dim)}
|
||||||
|
.gh .warn{font-size:13px;color:var(--red);font-weight:500}
|
||||||
|
.gh .count{margin-left:auto;font:12px var(--mono);color:var(--dim)}
|
||||||
|
.grp.offline .gh{border-bottom-color:var(--line2)}
|
||||||
|
.grp.offline .row{color:var(--dim)}
|
||||||
|
.row{position:relative;display:grid;grid-template-columns:minmax(200px,1.4fr) minmax(200px,2fr) 130px minmax(160px,1.5fr) minmax(104px,150px);align-items:center;column-gap:18px;min-height:42px;padding:0 6px;border-bottom:1px solid var(--line)}
|
||||||
|
.row:hover{background:var(--hover)}
|
||||||
|
.nm{display:flex;align-items:center;gap:8px;min-width:0}
|
||||||
|
.nm a{display:inline-flex;align-items:center;gap:10px;min-width:0;color:var(--fg);font-weight:600;text-decoration:none;white-space:nowrap}
|
||||||
|
.nm a:hover .nmt{text-decoration:underline;text-underline-offset:3px}
|
||||||
|
.nm .nmt{overflow:hidden;text-overflow:ellipsis}
|
||||||
|
.nm .stk{font-size:12.5px;color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
.ico{flex:none;display:grid;place-items:center;width:26px;height:26px;border-radius:7px;background:var(--card);box-shadow:inset 0 0 0 1px var(--line2);color:var(--fg)}
|
||||||
|
.ico svg{fill:currentColor}
|
||||||
|
.ltr{flex:none;display:grid;place-items:center;width:26px;height:26px;border-radius:50%;font:700 12.5px var(--sans)}
|
||||||
|
.lc-0{color:var(--l0);background:color-mix(in srgb,var(--l0) 18%,transparent)}
|
||||||
|
.lc-1{color:var(--l1);background:color-mix(in srgb,var(--l1) 18%,transparent)}
|
||||||
|
.lc-2{color:var(--l2);background:color-mix(in srgb,var(--l2) 18%,transparent)}
|
||||||
|
.lc-3{color:var(--l3);background:color-mix(in srgb,var(--l3) 18%,transparent)}
|
||||||
|
.grp.offline .ico,.grp.offline .ltr{opacity:.65}
|
||||||
|
.d-icon .nmt,.d-icon .stk{display:none}
|
||||||
|
.d-text .ico,.d-text .ltr{display:none}
|
||||||
|
.ver{display:flex;min-width:0;font:12.5px var(--mono);color:var(--dim);white-space:nowrap}
|
||||||
|
.ver .r{overflow:hidden;text-overflow:ellipsis;min-width:0}
|
||||||
|
.ver .t{flex:none}
|
||||||
|
.ver b{color:var(--fg);font-weight:500}
|
||||||
|
.st{display:inline-flex;align-items:center;gap:8px;font-size:13px;font-weight:500;white-space:nowrap}
|
||||||
|
.st svg{flex:none}
|
||||||
|
.st circle{fill:currentColor}
|
||||||
|
.s-running{color:var(--green)}.s-degraded{color:var(--orange)}.s-stopped{color:var(--dim)}.s-unknown{color:var(--dim)}
|
||||||
|
.s-stopped circle,.s-unknown circle{fill:none}
|
||||||
|
.flags{display:flex;flex-wrap:wrap;gap:6px}
|
||||||
|
.tag{font:500 12px/20px var(--sans);padding:0 8px;border-radius:999px;white-space:nowrap}
|
||||||
|
.tag em{font:500 11.5px var(--mono);font-style:normal;margin-left:6px}
|
||||||
|
.tag em:empty{display:none}
|
||||||
|
.f-up{color:var(--orange);background:var(--orangebg)}
|
||||||
|
.f-newer em{color:var(--orange)}
|
||||||
|
.f-err{color:var(--red);background:var(--redbg)}
|
||||||
|
.f-newer,.f-notag{color:var(--mut);box-shadow:inset 0 0 0 1px var(--line2)}
|
||||||
|
.f-out{color:var(--dim);box-shadow:inset 0 0 0 1px var(--line)}
|
||||||
|
.mode{display:flex;flex-direction:column;align-items:flex-start;gap:2px}
|
||||||
|
.mode form{margin:0;display:flex;flex-direction:column;align-items:flex-start;gap:2px}
|
||||||
|
.sw{display:inline-flex;align-items:center;gap:8px;padding:0;border:0;background:none;font:inherit;font-size:13px;color:var(--mut);cursor:pointer}
|
||||||
|
.sw i{flex:none;width:30px;height:18px;border-radius:999px;background:var(--line2);position:relative;transition:background .15s}
|
||||||
|
.sw i::after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:var(--fg);transition:transform .15s}
|
||||||
|
.sw[aria-checked=true] i{background:var(--blue)}
|
||||||
|
.sw[aria-checked=true] i::after{transform:translateX(12px);background:var(--onblue)}
|
||||||
|
.sw[aria-checked=true]{color:var(--blue);font-weight:600}
|
||||||
|
.sw:disabled{opacity:.55;cursor:not-allowed}
|
||||||
|
.na{font-size:12.5px;color:var(--dim)}
|
||||||
|
.nomatch{padding:24px 6px;color:var(--dim)}
|
||||||
|
|
||||||
|
/* Agents */
|
||||||
|
.hdr.ruled{padding-bottom:16px;border-bottom:2px solid var(--fg);margin-bottom:0}
|
||||||
|
.agent{position:relative;display:grid;grid-template-columns:minmax(150px,1fr) 130px 90px 110px 120px 84px;grid-template-areas:"id st ver mgr last act";align-items:center;column-gap:18px;padding:16px 6px;border-bottom:1px solid var(--line)}
|
||||||
|
.agent .id{grid-area:id}
|
||||||
|
.agent .id b{display:block;font:700 16px var(--sans)}
|
||||||
|
.agent .id span{font:12.5px var(--mono);color:var(--dim)}
|
||||||
|
.agent .stc{grid-area:st}.agent .ver{grid-area:ver;display:block;font:13px var(--mono);color:var(--mut)}.agent .mgr{grid-area:mgr;font-size:13px;color:var(--mut)}.agent .last{grid-area:last;font-size:13px;color:var(--mut)}.agent .act{grid-area:act;display:flex;justify-content:flex-end}
|
||||||
|
.agent .k{display:block;font:600 10.5px var(--sans);text-transform:uppercase;letter-spacing:.08em;color:var(--dim);margin-bottom:2px}
|
||||||
|
.agent .ver.old{color:var(--orange)}
|
||||||
|
.agent .st{font-size:13.5px;font-weight:600}
|
||||||
|
.agent .st i{width:8px;height:8px;border-radius:50%;background:currentColor}
|
||||||
|
.a-on{color:var(--green)}.a-off{color:var(--red)}.a-never,.a-revoked{color:var(--dim)}
|
||||||
|
.a-off i,.a-never i{background:transparent !important;box-shadow:inset 0 0 0 1.5px currentColor}
|
||||||
|
/* After the .agent rules: a container query adds no specificity, so it must come later to win. */
|
||||||
|
@container (max-width:760px){
|
||||||
|
.agent{grid-template-columns:1fr auto;grid-template-areas:"id st" "ver mgr" "last act";row-gap:10px}
|
||||||
|
}
|
||||||
|
.pop{position:absolute;right:6px;top:calc(100% - 8px);z-index:5;width:360px;padding:14px 16px;border-radius:8px;background:var(--card);border:1px solid var(--line2);box-shadow:var(--shadow);font-size:13px;color:var(--mut)}
|
||||||
|
.pop::before{content:"";position:absolute;top:-6px;right:30px;width:10px;height:10px;background:var(--card);border-left:1px solid var(--line2);border-top:1px solid var(--line2);transform:rotate(45deg)}
|
||||||
|
.pop h4{margin:0 0 4px;font:700 14px var(--sans);color:var(--fg)}
|
||||||
|
.pop p{margin:0 0 12px}
|
||||||
|
.pop .acts{display:flex;gap:8px;justify-content:flex-end;margin:0}
|
||||||
|
.drawer{flex:1 1 440px;min-width:0;max-width:520px;border-left:1px solid var(--line2);background:var(--card);padding:28px 28px 40px;display:flex;flex-direction:column;gap:18px}
|
||||||
|
.dh{display:flex;align-items:center;gap:12px}
|
||||||
|
.dh h2{margin:0;font:700 20px var(--sans);letter-spacing:-.015em}
|
||||||
|
.dh .x{margin-left:auto}
|
||||||
|
.steps{display:flex;gap:8px;font-size:12.5px;color:var(--dim)}
|
||||||
|
.steps span{display:flex;align-items:center;gap:6px}
|
||||||
|
.steps b{display:inline-grid;place-items:center;width:20px;height:20px;border-radius:50%;font:600 11px var(--mono);box-shadow:inset 0 0 0 1px var(--line2)}
|
||||||
|
.steps .done{color:var(--green)}.steps .done b{box-shadow:none;background:var(--green);color:var(--bg)}
|
||||||
|
.steps .now{color:var(--fg)}.steps .now b{box-shadow:none;background:var(--blue);color:var(--onblue)}
|
||||||
|
.steps .sep{width:16px;height:1px;background:var(--line2)}
|
||||||
|
.dform{display:flex;flex-direction:column;gap:18px;margin:0}
|
||||||
|
.field{display:flex;flex-direction:column;gap:6px;font-size:13.5px;color:var(--mut)}
|
||||||
|
.field input{height:36px;padding:0 12px;border-radius:6px;border:1px solid var(--line2);background:var(--bg);color:var(--fg);font:500 14px var(--mono)}
|
||||||
|
.field small{color:var(--dim);font-size:12.5px}
|
||||||
|
.namebox{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;border-radius:6px;background:var(--bg);font-size:13.5px}
|
||||||
|
.namebox code{font:500 14px var(--mono);color:var(--fg)}
|
||||||
|
.notice{display:flex;gap:10px;padding:12px 14px;border-radius:6px;background:var(--orangebg);color:var(--fg);font-size:13.5px}
|
||||||
|
.notice svg{flex:none;color:var(--orange);margin-top:1px}
|
||||||
|
.code{border-radius:6px;background:var(--term);overflow:hidden}
|
||||||
|
.code-h{display:flex;align-items:center;justify-content:space-between;padding:6px 8px 6px 12px;border-bottom:1px solid #1d2230;font:12px var(--mono);color:#8d96a8}
|
||||||
|
.code pre{margin:0;padding:12px;border:0;color:#cdd3df;font:12px/1.6 var(--mono);overflow-x:auto}
|
||||||
|
.secret{display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:8px 12px;font-size:13px;color:var(--mut)}
|
||||||
|
.secret code{font:12.5px var(--mono);color:var(--fg);overflow-wrap:anywhere}
|
||||||
|
.df{display:flex;gap:8px;justify-content:flex-end;align-items:center;margin-top:auto}
|
||||||
|
.df form{margin:0}
|
||||||
|
.df > span{margin-right:auto;font-size:13px;color:var(--mut);display:flex;align-items:center;gap:8px}
|
||||||
|
.df .ok{color:var(--green)}
|
||||||
|
.spin{width:12px;height:12px;border-radius:50%;box-shadow:inset 0 0 0 2px var(--line2);border-top:2px solid var(--blue);box-sizing:border-box;animation:spin 1s linear infinite}
|
||||||
|
@keyframes spin{to{transform:rotate(360deg)}}
|
||||||
|
|
||||||
|
/* Pages that keep the plain markup: service, jobs, job */
|
||||||
|
h1{font:700 34px/1.1 var(--sans);letter-spacing:-.025em;margin:0 0 18px}
|
||||||
|
h2{font:700 20px var(--sans);letter-spacing:-.015em;margin:28px 0 8px}
|
||||||
|
table{width:100%;border-collapse:collapse;margin-bottom:24px}
|
||||||
|
th,td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--line)}
|
||||||
|
th{color:var(--mut);font-weight:500}
|
||||||
|
code,pre{font:13px var(--mono)}
|
||||||
|
pre{padding:12px;border:1px solid var(--line);border-radius:6px;overflow-x:auto}
|
||||||
|
.badge{font-size:12px;color:var(--mut);border:1px solid var(--line);padding:0 6px;border-radius:8px}
|
||||||
|
.status-running{color:var(--green)}
|
||||||
|
.status-degraded,.status-unknown{color:var(--orange)}
|
||||||
|
.status-stopped{color:var(--red)}
|
||||||
|
.error{color:var(--red)}
|
||||||
|
.warning{color:var(--orange)}
|
||||||
|
.muted{color:var(--dim)}
|
||||||
|
.status-job-succeeded{color:var(--green)}
|
||||||
|
.status-job-queued,.status-job-running{color:var(--orange)}
|
||||||
|
.status-job-failed{color:var(--red)}
|
||||||
|
table.details{width:auto}
|
||||||
|
table.details th{padding-right:24px}
|
||||||
|
pre.log{white-space:pre-wrap}
|
||||||
|
|
||||||
|
@media (max-width:760px){
|
||||||
|
.side{flex:1 1 100%;flex-direction:row;flex-wrap:wrap;align-items:center;gap:8px 16px;padding:12px 16px;border-right:0;border-bottom:1px solid var(--line)}
|
||||||
|
#snav{order:3;width:100%}
|
||||||
|
.snav{flex-direction:row;overflow-x:auto}
|
||||||
|
.snav a{padding:10px 12px}
|
||||||
|
.snav .n{margin-left:4px}
|
||||||
|
.mblock{display:none}
|
||||||
|
.livebox{margin:0 0 0 auto;border:0;padding:0}
|
||||||
|
.livebox #live-age{display:none}
|
||||||
|
.main{padding:20px 16px 40px}
|
||||||
|
.hdr h1{font-size:28px}
|
||||||
|
.hdr .end,.tools .end{margin-left:0}
|
||||||
|
.search{flex:1 1 100%}
|
||||||
|
.btn,button:not([class]){height:44px}
|
||||||
|
.btn.sm{height:36px}
|
||||||
|
.row{grid-template-columns:1fr auto;grid-template-areas:"nm st" "ver ver" "fl fl" "mode mode";row-gap:8px;padding:12px 4px}
|
||||||
|
.nm{grid-area:nm}.stc{grid-area:st}.row .ver{grid-area:ver}.flags{grid-area:fl}.mode{grid-area:mode}
|
||||||
|
.flags:empty{display:none}
|
||||||
|
.agent{grid-template-columns:1fr auto;grid-template-areas:"id st" "ver mgr" "last act";row-gap:10px}
|
||||||
|
.drawer{order:-1;max-width:none;border-left:0;border-bottom:1px solid var(--line2);padding:20px 16px}
|
||||||
|
.pop{left:0;right:0;width:auto}
|
||||||
|
table{display:block;overflow-x:auto}
|
||||||
}
|
}
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body { margin: 0; background: var(--bg); color: var(--fg); font: 14px/1.5 system-ui, sans-serif; }
|
|
||||||
nav { display: flex; gap: 16px; align-items: center; padding: 12px 16px; border-bottom: 1px solid var(--line); }
|
|
||||||
nav form { margin-left: auto; }
|
|
||||||
main { padding: 16px; max-width: 1200px; margin: 0 auto; }
|
|
||||||
a { color: var(--accent); }
|
|
||||||
table { width: 100%; border-collapse: collapse; margin-bottom: 24px; }
|
|
||||||
th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--line); }
|
|
||||||
th { color: var(--muted); font-weight: 500; }
|
|
||||||
code, pre { font: 13px ui-monospace, monospace; }
|
|
||||||
pre { padding: 12px; border: 1px solid var(--line); overflow-x: auto; }
|
|
||||||
.badge { font-size: 12px; color: var(--muted); border: 1px solid var(--line); padding: 0 6px; border-radius: 8px; }
|
|
||||||
.status-running { color: var(--ok); }
|
|
||||||
.status-degraded, .status-unknown { color: var(--warn); }
|
|
||||||
.status-stopped { color: var(--bad); }
|
|
||||||
.error { color: var(--bad); }
|
|
||||||
.warning { color: var(--warn); }
|
|
||||||
form.inline { display: flex; gap: 8px; margin-bottom: 16px; }
|
|
||||||
@media (max-width: 600px) { table { display: block; overflow-x: auto; } }
|
|
||||||
.muted { color: var(--muted); }
|
|
||||||
.status-job-succeeded { color: var(--ok); }
|
|
||||||
.status-job-queued, .status-job-running { color: var(--warn); }
|
|
||||||
.status-job-failed { color: var(--bad); }
|
|
||||||
table.details { width: auto; }
|
|
||||||
table.details th { padding-right: 24px; }
|
|
||||||
pre.log { white-space: pre-wrap; }
|
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
// Behaviour the server-rendered pages need in the browser: theme, display
|
||||||
|
// mode, search and machine filter, the Live box, local times, copy buttons and
|
||||||
|
// the revoke popovers. The pages work without it; htmx and the SSE extension
|
||||||
|
// do the live updates. Everything stored in the browser is wrapped in
|
||||||
|
// try/catch because storage can be blocked.
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
var root = document.documentElement;
|
||||||
|
|
||||||
|
function load(area, key) {
|
||||||
|
try { return window[area].getItem(key); } catch (e) { return null; }
|
||||||
|
}
|
||||||
|
function save(area, key, value) {
|
||||||
|
try { window[area].setItem(key, value); } catch (e) { /* storage is blocked */ }
|
||||||
|
}
|
||||||
|
function each(selector, fn) {
|
||||||
|
Array.prototype.forEach.call(document.querySelectorAll(selector), fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Theme: the head script set the initial class; the toggle overrides it.
|
||||||
|
var colorScheme = window.matchMedia ? window.matchMedia('(prefers-color-scheme: light)') : null;
|
||||||
|
var themeButton = document.getElementById('theme-toggle');
|
||||||
|
if (themeButton) {
|
||||||
|
themeButton.addEventListener('click', function () {
|
||||||
|
var light = !root.classList.contains('t-light');
|
||||||
|
root.classList.toggle('t-light', light);
|
||||||
|
themeButton.setAttribute('aria-pressed', String(light));
|
||||||
|
save('localStorage', 'pu-theme', light ? 'light' : 'dark');
|
||||||
|
});
|
||||||
|
themeButton.setAttribute('aria-pressed', String(root.classList.contains('t-light')));
|
||||||
|
}
|
||||||
|
if (colorScheme && colorScheme.addEventListener) {
|
||||||
|
colorScheme.addEventListener('change', function (e) {
|
||||||
|
if (!load('localStorage', 'pu-theme')) root.classList.toggle('t-light', e.matches);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display mode: icon, icon + text, or text.
|
||||||
|
function setDisplay(mode) {
|
||||||
|
['icon', 'both', 'text'].forEach(function (m) { root.classList.toggle('d-' + m, m === mode); });
|
||||||
|
each('[data-display]', function (b) { b.setAttribute('aria-pressed', String(b.dataset.display === mode)); });
|
||||||
|
}
|
||||||
|
setDisplay(root.classList.contains('d-icon') ? 'icon' : root.classList.contains('d-text') ? 'text' : 'both');
|
||||||
|
|
||||||
|
// Search and machine filter. The rows are all in the page; this hides them.
|
||||||
|
var filter = { q: '', machine: '' };
|
||||||
|
try {
|
||||||
|
var saved = JSON.parse(load('sessionStorage', 'pu-filter') || '{}');
|
||||||
|
if (typeof saved.q === 'string') filter.q = saved.q;
|
||||||
|
if (typeof saved.machine === 'string') filter.machine = saved.machine;
|
||||||
|
} catch (e) { /* ignore a broken saved filter */ }
|
||||||
|
var search = document.getElementById('q');
|
||||||
|
if (search) search.value = filter.q;
|
||||||
|
|
||||||
|
// The machine list is hidden on narrow screens; a filter that cannot be
|
||||||
|
// seen or cleared there is ignored. Search still applies.
|
||||||
|
var narrow = window.matchMedia ? window.matchMedia('(max-width:760px)') : null;
|
||||||
|
|
||||||
|
function applyFilter() {
|
||||||
|
var box = document.getElementById('services');
|
||||||
|
if (!box) return;
|
||||||
|
var groups = box.querySelectorAll('.grp');
|
||||||
|
var known = Array.prototype.some.call(groups, function (g) { return g.dataset.key === filter.machine; });
|
||||||
|
if (filter.machine && !known) filter.machine = '';
|
||||||
|
var machine = narrow && narrow.matches ? '' : filter.machine;
|
||||||
|
var q = filter.q.trim().toLowerCase();
|
||||||
|
var any = false;
|
||||||
|
Array.prototype.forEach.call(groups, function (g) {
|
||||||
|
var inMachine = !machine || g.dataset.key === machine;
|
||||||
|
var shown = 0;
|
||||||
|
Array.prototype.forEach.call(g.querySelectorAll('.row'), function (row) {
|
||||||
|
var match = inMachine && (!q || (row.dataset.search || '').indexOf(q) !== -1);
|
||||||
|
row.hidden = !match;
|
||||||
|
if (match) shown++;
|
||||||
|
});
|
||||||
|
g.hidden = shown === 0;
|
||||||
|
if (shown > 0) any = true;
|
||||||
|
});
|
||||||
|
var none = document.getElementById('nomatch');
|
||||||
|
if (none) none.hidden = any || groups.length === 0;
|
||||||
|
each('#machines a', function (a) {
|
||||||
|
var cur = a.dataset.machine === filter.machine;
|
||||||
|
a.classList.toggle('cur', cur);
|
||||||
|
if (cur) a.setAttribute('aria-current', 'true'); else a.removeAttribute('aria-current');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (narrow && narrow.addEventListener) narrow.addEventListener('change', function () { applyFilter(); });
|
||||||
|
function saveFilter() { save('sessionStorage', 'pu-filter', JSON.stringify(filter)); }
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
search.addEventListener('input', function () { filter.q = search.value; saveFilter(); applyFilter(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live box: counts any event on the stream, including the server's ping.
|
||||||
|
var live = document.getElementById('live');
|
||||||
|
var liveAge = document.getElementById('live-age');
|
||||||
|
var lastEvent = null;
|
||||||
|
var down = false;
|
||||||
|
var wired = typeof WeakSet === 'function' ? new WeakSet() : null;
|
||||||
|
function touch() { lastEvent = Date.now(); }
|
||||||
|
document.addEventListener('htmx:sseOpen', function (e) {
|
||||||
|
var source = e.detail && e.detail.source;
|
||||||
|
touch();
|
||||||
|
down = false;
|
||||||
|
if (source && wired && !wired.has(source)) {
|
||||||
|
wired.add(source);
|
||||||
|
['ping', 'services', 'agents', 'jobs'].forEach(function (name) { source.addEventListener(name, touch); });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.addEventListener('htmx:sseError', function () { down = true; });
|
||||||
|
function renderLive() {
|
||||||
|
if (!live || !liveAge) return;
|
||||||
|
live.classList.toggle('down', down);
|
||||||
|
if (down) { liveAge.textContent = 'Reconnecting…'; return; }
|
||||||
|
if (lastEvent === null) { liveAge.textContent = 'connecting…'; return; }
|
||||||
|
var s = Math.max(0, Math.round((Date.now() - lastEvent) / 1000));
|
||||||
|
liveAge.textContent = 'last event ' + (s < 60 ? s + ' s ago' : Math.floor(s / 60) + ' min ago');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Times: the server writes <time datetime="..."> with its own clock as
|
||||||
|
// fallback text; show them in the browser's time zone.
|
||||||
|
var months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
function pad(n) { return (n < 10 ? '0' : '') + n; }
|
||||||
|
function localiseTimes() {
|
||||||
|
each('time[datetime]', function (t) {
|
||||||
|
var d = new Date(t.getAttribute('datetime'));
|
||||||
|
if (isNaN(d.getTime())) return;
|
||||||
|
var text;
|
||||||
|
if (t.hasAttribute('data-rel')) {
|
||||||
|
var min = Math.floor((Date.now() - d.getTime()) / 60000);
|
||||||
|
text = min < 1 ? 'just now' : min < 60 ? min + ' min ago' : min < 1440 ? Math.floor(min / 60) + ' h ago' : Math.floor(min / 1440) + ' d ago';
|
||||||
|
t.title = d.toLocaleString();
|
||||||
|
} else {
|
||||||
|
var clock = pad(d.getHours()) + ':' + pad(d.getMinutes());
|
||||||
|
text = d.toDateString() === new Date().toDateString() ? clock : months[d.getMonth()] + ' ' + d.getDate() + ' ' + clock;
|
||||||
|
}
|
||||||
|
if (t.textContent !== text) t.textContent = text;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy buttons: data-copy holds the text, data-copy-target the id of the element to copy.
|
||||||
|
function flash(button, text) {
|
||||||
|
if (!button.dataset.label) button.dataset.label = button.textContent;
|
||||||
|
button.textContent = text;
|
||||||
|
setTimeout(function () { button.textContent = button.dataset.label; }, 2000);
|
||||||
|
}
|
||||||
|
function copyFallback(button, text) {
|
||||||
|
var target = button.dataset.copyTarget ? document.getElementById(button.dataset.copyTarget) : null;
|
||||||
|
if (target) {
|
||||||
|
var range = document.createRange();
|
||||||
|
range.selectNodeContents(target);
|
||||||
|
var selection = window.getSelection();
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
flash(button, 'Press Ctrl+C');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var area = document.createElement('textarea');
|
||||||
|
area.value = text;
|
||||||
|
area.setAttribute('readonly', '');
|
||||||
|
area.style.position = 'fixed';
|
||||||
|
area.style.opacity = '0';
|
||||||
|
document.body.appendChild(area);
|
||||||
|
area.select();
|
||||||
|
var ok = false;
|
||||||
|
try { ok = document.execCommand('copy'); } catch (e) { /* not allowed */ }
|
||||||
|
document.body.removeChild(area);
|
||||||
|
flash(button, ok ? 'Copied' : 'Copy failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke popovers are <details>. A swap of the agent list would close them, so reopen those that were open.
|
||||||
|
var openRevokes = [];
|
||||||
|
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
var display = e.target.closest('[data-display]');
|
||||||
|
if (display) {
|
||||||
|
setDisplay(display.dataset.display);
|
||||||
|
save('localStorage', 'pu-display', display.dataset.display);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var machine = e.target.closest('#machines a');
|
||||||
|
if (machine) {
|
||||||
|
e.preventDefault();
|
||||||
|
filter.machine = filter.machine === machine.dataset.machine ? '' : machine.dataset.machine;
|
||||||
|
saveFilter();
|
||||||
|
applyFilter();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var close = e.target.closest('[data-close]');
|
||||||
|
if (close) {
|
||||||
|
var details = close.closest('details');
|
||||||
|
if (details) details.open = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var copy = e.target.closest('[data-copy], [data-copy-target]');
|
||||||
|
if (copy) {
|
||||||
|
var text = copy.dataset.copy;
|
||||||
|
if (text === undefined) {
|
||||||
|
var el = document.getElementById(copy.dataset.copyTarget);
|
||||||
|
text = el ? el.textContent : '';
|
||||||
|
}
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
navigator.clipboard.writeText(text).then(function () { flash(copy, 'Copied'); }, function () { copyFallback(copy, text); });
|
||||||
|
} else {
|
||||||
|
copyFallback(copy, text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape') each('details.rv[open]', function (d) { d.open = false; });
|
||||||
|
});
|
||||||
|
document.addEventListener('htmx:beforeSwap', function () {
|
||||||
|
openRevokes = [];
|
||||||
|
each('details.rv[open]', function (d) { openRevokes.push(d.dataset.id); });
|
||||||
|
});
|
||||||
|
document.addEventListener('htmx:afterSwap', function () {
|
||||||
|
each('details.rv', function (d) { if (openRevokes.indexOf(d.dataset.id) !== -1) d.open = true; });
|
||||||
|
});
|
||||||
|
document.addEventListener('htmx:afterSettle', function () { applyFilter(); localiseTimes(); });
|
||||||
|
|
||||||
|
applyFilter();
|
||||||
|
localiseTimes();
|
||||||
|
renderLive();
|
||||||
|
setInterval(renderLive, 1000);
|
||||||
|
setInterval(localiseTimes, 15000);
|
||||||
|
})();
|
||||||
Binary file not shown.
Binary file not shown.
@@ -1,6 +0,0 @@
|
|||||||
{{define "content"}}
|
|
||||||
<h1>Agent "{{.Created.Name}}" created</h1>
|
|
||||||
<p class="warning">The token is shown only once. Save this as <code>docker-compose.yml</code> on the machine and run <code>docker compose up -d</code>.</p>
|
|
||||||
<pre>{{.Snippet}}</pre>
|
|
||||||
<p><a href="/agents">Back to agents</a></p>
|
|
||||||
{{end}}
|
|
||||||
@@ -1,38 +1,108 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<h1>Agents</h1>
|
<div class="hdr ruled">
|
||||||
<form method="post" action="/agents" class="inline">
|
<h1>Agents</h1>
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRF}}">
|
<span class="sub" id="agents-sub">{{template "agents-sub" .}}</span>
|
||||||
<label>Name <input name="name" required placeholder="node-2"></label>
|
<div class="end"><a class="btn pri" href="/agents?add=1">Add agent</a></div>
|
||||||
<button type="submit">Add agent</button>
|
</div>
|
||||||
</form>
|
|
||||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
|
||||||
<div id="agents" hx-get="/fragments/agents" hx-trigger="sse:agents" hx-swap="innerHTML">
|
<div id="agents" hx-get="/fragments/agents" hx-trigger="sse:agents" hx-swap="innerHTML">
|
||||||
{{template "agents" .}}
|
{{template "agents" .}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "agents"}}
|
{{define "agents-sub"}}{{.Nav.AgentsTotal}} {{if eq .Nav.AgentsTotal 1}}machine{{else}}machines{{end}}{{if .MasterVersion}} · master {{.MasterVersion}}{{end}}{{end}}
|
||||||
<table>
|
|
||||||
<thead><tr><th>Name</th><th>Hostname</th><th>State</th><th>Version</th><th>Swarm manager</th><th>Last seen</th><th></th></tr></thead>
|
{{/* What /fragments/agents returns: the list, and the header count swapped in out of band. */}}
|
||||||
<tbody>
|
{{define "agents-fragment"}}
|
||||||
{{range .Agents}}
|
{{template "agents" .}}
|
||||||
<tr>
|
<span class="sub" id="agents-sub" hx-swap-oob="true">{{template "agents-sub" .}}</span>
|
||||||
<td>{{.Name}}</td>
|
|
||||||
<td>{{.Hostname}}</td>
|
|
||||||
<td>{{if .RevokedAt}}<span class="status status-stopped">revoked</span>{{else if .Online}}<span class="status status-running">online</span>{{else}}<span class="status status-degraded">offline</span>{{end}}</td>
|
|
||||||
<td>{{.AgentVersion}}</td>
|
|
||||||
<td>{{if .SwarmManager}}yes{{end}}</td>
|
|
||||||
<td>{{with .LastSeenAt}}{{.Format "2006-01-02 15:04"}}{{end}}</td>
|
|
||||||
<td>{{if not .RevokedAt}}
|
|
||||||
<form method="post" action="/agents/{{.ID}}/revoke" onsubmit="return confirm('Revoke {{.Name}}? It is disconnected immediately and must be added again to come back.')">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{$.CSRF}}">
|
|
||||||
<button type="submit">Revoke</button>
|
|
||||||
</form>
|
|
||||||
{{end}}</td>
|
|
||||||
</tr>
|
|
||||||
{{else}}
|
|
||||||
<tr><td colspan="7">No agents yet.</td></tr>
|
|
||||||
{{end}}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{define "agents"}}
|
||||||
|
{{range .Agents}}
|
||||||
|
<div class="agent">
|
||||||
|
<div class="id"><b>{{.Name}}</b><span>{{.Hostname}}</span></div>
|
||||||
|
<div class="stc"><span class="st {{.StatusClass}}"><i></i>{{.Status}}</span></div>
|
||||||
|
<div class="ver{{if .VersionOld}} old{{end}}"><span class="k">Agent</span>{{.AgentVersion}}</div>
|
||||||
|
<div class="mgr"><span class="k">Swarm</span>{{.Role}}</div>
|
||||||
|
<div class="last"><span class="k">Last seen</span>{{if .Online}}now{{else}}{{with .LastSeenAt}}<time datetime="{{.Format "2006-01-02T15:04:05Z07:00"}}" data-rel>{{.Format "2006-01-02 15:04"}}</time>{{else}}—{{end}}{{end}}</div>
|
||||||
|
<div class="act">{{if not .RevokedAt}}
|
||||||
|
<details class="rv" data-id="{{.ID}}">
|
||||||
|
<summary class="btn sm red">Revoke</summary>
|
||||||
|
<div class="pop" role="dialog" aria-label="Confirm revoke">
|
||||||
|
<h4>Revoke {{.Name}}?</h4>
|
||||||
|
<p>Its token stops working immediately. Services on {{.Name}} keep running, but drop out of package-updater.</p>
|
||||||
|
<form method="post" action="/agents/{{.ID}}/revoke" class="acts">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{$.CSRF}}">
|
||||||
|
<button type="button" class="btn sm quiet" data-close>Cancel</button>
|
||||||
|
<button type="submit" class="btn sm redfill">Revoke</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{{end}}</div>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<p class="nomatch">No agents yet.</p>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{/* The "add agent" panel beside the list. Step 1 asks for a name, step 2 shows the one-time token, step 3 waits for the agent to connect. */}}
|
||||||
|
{{define "drawer"}}
|
||||||
|
{{if .AddStep}}
|
||||||
|
<section class="drawer" aria-label="Add agent">
|
||||||
|
<div class="dh">
|
||||||
|
<h2>Add agent</h2>
|
||||||
|
<a class="btn sm quiet x" href="/agents" aria-label="Close">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="M3 3l8 8M11 3l-8 8"></path></svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{{if eq .AddStep 1}}
|
||||||
|
<div class="steps" id="add-steps">{{template "steps" 1}}</div>
|
||||||
|
<form method="post" action="/agents" class="dform">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{.CSRF}}">
|
||||||
|
<label class="field">Name
|
||||||
|
<input name="name" required autofocus autocomplete="off" placeholder="node-2" value="{{.FormName}}">
|
||||||
|
<small>Lowercase letters, digits and dashes. Use the machine's name.</small>
|
||||||
|
</label>
|
||||||
|
{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
|
||||||
|
<div class="df">
|
||||||
|
<a class="btn quiet" href="/agents">Cancel</a>
|
||||||
|
<button type="submit" class="btn pri">Create agent</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{{else}}
|
||||||
|
<div class="steps" id="add-steps">{{template "steps" 2}}</div>
|
||||||
|
<div class="namebox"><span>Name</span><code>{{.Created.Name}}</code></div>
|
||||||
|
<div class="notice" role="note">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="M8 1.8L15 14H1L8 1.8z"></path><path d="M8 6.5v3.5M8 12v.01"></path></svg>
|
||||||
|
<span><b>This token is shown only once.</b> Save the snippet as <code>docker-compose.yml</code> on the machine and run <code>docker compose up -d</code>.</span>
|
||||||
|
</div>
|
||||||
|
<div class="code">
|
||||||
|
<div class="code-h"><span>docker-compose.yml</span><button type="button" class="btn sm" data-copy-target="snippet">Copy</button></div>
|
||||||
|
<pre id="snippet">{{.Snippet}}</pre>
|
||||||
|
</div>
|
||||||
|
<div class="secret">
|
||||||
|
<span>Token</span><code>{{middle .Token}}</code><button type="button" class="btn sm" data-copy="{{.Token}}">Copy</button>
|
||||||
|
<span>Master key</span><code>{{middle .MasterKey}}</code><button type="button" class="btn sm" data-copy="{{.MasterKey}}">Copy</button>
|
||||||
|
</div>
|
||||||
|
<div class="df" id="connect" hx-get="/fragments/agents/{{.Created.ID}}/connect" hx-trigger="load, sse:agents" hx-swap="innerHTML">
|
||||||
|
{{template "connect" .}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{/* step is the one in progress (1 to 3); 4 marks all three as done. */}}
|
||||||
|
{{define "steps"}}<span class="{{if gt . 1}}done{{else}}now{{end}}"><b>1</b>Name</span><span class="sep"></span><span class="{{if gt . 2}}done{{else if eq . 2}}now{{end}}"><b>2</b>Deploy</span><span class="sep"></span><span class="{{if gt . 3}}done{{else if eq . 3}}now{{end}}"><b>3</b>Connect</span>{{end}}
|
||||||
|
|
||||||
|
{{/* Step 3. Until the agent has connected, Cancel revokes the token that was just created. */}}
|
||||||
|
{{define "connect"}}{{if .Connected}}<span class="ok">Connected · {{.Created.Hostname}} · {{.Created.AgentVersion}}</span>
|
||||||
|
<a class="btn pri" href="/agents">Done</a>
|
||||||
|
<div id="add-steps" hx-swap-oob="innerHTML">{{template "steps" 4}}</div>
|
||||||
|
{{else}}<span><span class="spin" aria-hidden="true"></span>Waiting for {{.Created.Name}}…</span>
|
||||||
|
<form method="post" action="/agents/{{.Created.ID}}/revoke">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{.CSRF}}">
|
||||||
|
<button type="submit" class="btn quiet">Cancel</button>
|
||||||
|
</form>
|
||||||
|
<a class="btn pri" href="/agents">Done</a>
|
||||||
|
{{end}}{{end}}
|
||||||
|
|||||||
@@ -1,23 +1,62 @@
|
|||||||
{{define "layout"}}<!doctype html>
|
{{define "layout"}}<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en" class="app">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="color-scheme" content="dark light">
|
||||||
<title>{{.Title}} · package-updater</title>
|
<title>{{.Title}} · package-updater</title>
|
||||||
|
<script>
|
||||||
|
// Sets the theme and the display mode before first paint. app.js changes them later.
|
||||||
|
(function () {
|
||||||
|
var root = document.documentElement, theme = null, display = null;
|
||||||
|
try { theme = localStorage.getItem('pu-theme'); display = localStorage.getItem('pu-display'); } catch (e) {}
|
||||||
|
if (theme !== 'light' && theme !== 'dark') {
|
||||||
|
theme = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
||||||
|
}
|
||||||
|
if (theme === 'light') root.classList.add('t-light');
|
||||||
|
root.classList.add(display === 'icon' || display === 'text' ? 'd-' + display : 'd-both');
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<link rel="preload" href="/static/schibsted-grotesk-latin-wght-normal.woff2" as="font" type="font/woff2" crossorigin>
|
||||||
<link rel="stylesheet" href="/static/app.css">
|
<link rel="stylesheet" href="/static/app.css">
|
||||||
<script src="/static/htmx.min.js"></script>
|
<script src="/static/htmx.min.js"></script>
|
||||||
<script src="/static/htmx-ext-sse.js"></script>
|
<script src="/static/htmx-ext-sse.js"></script>
|
||||||
|
<script src="/static/app.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body hx-headers='{"X-CSRF-Token": "{{.CSRF}}"}' hx-ext="sse" sse-connect="/events">
|
<body hx-headers='{"X-CSRF-Token": "{{.CSRF}}"}' hx-ext="sse" sse-connect="/events">
|
||||||
<nav>
|
<div class="shell">
|
||||||
<a href="/">Services</a>
|
<aside class="side">
|
||||||
<a href="/jobs">Jobs</a>
|
<div class="brand">
|
||||||
<a href="/agents">Agents</a>
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><path d="M3 6h14M3 10h9M3 14h6"></path><path d="M15 16V10m-2.5 2.5L15 10l2.5 2.5"></path></svg>
|
||||||
<form method="post" action="/auth/logout">
|
package-updater
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRF}}">
|
</div>
|
||||||
<button type="submit">Log out</button>
|
<div id="snav" hx-get="/fragments/nav?section={{.Nav.Section}}" hx-trigger="sse:services, sse:agents" hx-swap="innerHTML">{{template "nav" .}}</div>
|
||||||
</form>
|
{{block "machines" .}}{{end}}
|
||||||
</nav>
|
<div class="livebox" id="live">
|
||||||
<main>{{template "content" .}}</main>
|
<b><span class="pulse" aria-hidden="true"></span>Live</b>
|
||||||
|
<span id="live-age">connecting…</span>
|
||||||
|
</div>
|
||||||
|
<div class="sfoot">
|
||||||
|
<button type="button" class="btn sm quiet" id="theme-toggle">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><circle cx="7" cy="7" r="5"></circle><path d="M7 2v10" ></path></svg>
|
||||||
|
Theme
|
||||||
|
</button>
|
||||||
|
<form method="post" action="/auth/logout">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{.CSRF}}">
|
||||||
|
<button type="submit" class="btn sm quiet">Log out</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<div class="work">
|
||||||
|
<main class="main">{{template "content" .}}</main>
|
||||||
|
{{block "drawer" .}}{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>{{end}}
|
</html>{{end}}
|
||||||
|
|
||||||
|
{{define "nav"}}<nav class="snav" aria-label="Main">
|
||||||
|
<a href="/"{{if eq .Nav.Section "services"}} class="cur" aria-current="page"{{end}}>Services <span class="n">{{.Nav.Services}}</span></a>
|
||||||
|
<a href="/jobs"{{if eq .Nav.Section "jobs"}} class="cur" aria-current="page"{{end}}>Jobs</a>
|
||||||
|
<a href="/agents"{{if eq .Nav.Section "agents"}} class="cur" aria-current="page"{{end}}>Agents <span class="n">{{.Nav.AgentsOnline}}/{{.Nav.AgentsTotal}}</span></a>
|
||||||
|
</nav>{{end}}
|
||||||
|
|||||||
@@ -1,29 +1,104 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<h1>Services</h1>
|
<div class="hdr">
|
||||||
<div id="services" hx-get="/fragments/services" hx-trigger="sse:services" hx-swap="innerHTML">
|
<h1>Services</h1>
|
||||||
|
</div>
|
||||||
|
<div class="tools">
|
||||||
|
<label class="search">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><circle cx="6.5" cy="6.5" r="5"></circle><path d="M10.3 10.3L14 14"></path></svg>
|
||||||
|
<input type="search" id="q" placeholder="Search services" aria-label="Search services" autocomplete="off">
|
||||||
|
</label>
|
||||||
|
<div class="end">
|
||||||
|
<span class="lbl" id="showlbl">Show</span>
|
||||||
|
<div class="seg" role="group" aria-labelledby="showlbl">
|
||||||
|
<button type="button" data-display="icon" aria-pressed="false">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><rect x="2" y="2" width="10" height="10" rx="2.5"></rect></svg>
|
||||||
|
Icon
|
||||||
|
</button>
|
||||||
|
<button type="button" data-display="both" aria-pressed="true">
|
||||||
|
<svg width="18" height="14" viewBox="0 0 18 14" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><rect x="1" y="3" width="8" height="8" rx="2"></rect><path d="M12 7h5"></path></svg>
|
||||||
|
Icon + text
|
||||||
|
</button>
|
||||||
|
<button type="button" data-display="text" aria-pressed="false">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="M2 4.5h10M2 9.5h7"></path></svg>
|
||||||
|
Text
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="services" hx-get="/fragments/services" hx-trigger="sse:services, sse:agents" hx-swap="innerHTML">
|
||||||
{{template "services" .}}
|
{{template "services" .}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "services"}}
|
{{/* The machine filter in the sidebar. The page defines it; the services fragment replaces its links out of band. */}}
|
||||||
|
{{define "machines"}}
|
||||||
|
<div class="mblock">
|
||||||
|
<div class="slabel">Machines</div>
|
||||||
|
<nav class="mlist" id="machines" aria-label="Filter by machine">{{template "machine-links" .}}</nav>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "machine-links"}}
|
||||||
|
<a class="cur" href="/" aria-current="true" data-machine="">All <span class="n">{{.ServiceCount}}</span></a>
|
||||||
{{range .Groups}}
|
{{range .Groups}}
|
||||||
<section>
|
<a href="/"{{if .Sub}} class="sub"{{end}} data-machine="{{.Key}}">{{if .Cluster}}<span class="sw-icon" aria-hidden="true"></span>{{else}}<span class="mdot{{if .Offline}} off{{end}}" aria-hidden="true"></span>{{end}}{{.Title}} <span class="n">{{len .Services}}</span></a>
|
||||||
<h2>{{.Title}}</h2>
|
{{end}}
|
||||||
<table>
|
{{end}}
|
||||||
<thead><tr><th>Service</th><th>Image</th><th>Status</th><th>Mode</th></tr></thead>
|
|
||||||
<tbody>
|
{{/* What /fragments/services returns: the groups, and the machine filter swapped in out of band. */}}
|
||||||
{{range .Services}}
|
{{define "services-fragment"}}
|
||||||
<tr>
|
{{template "services" .}}
|
||||||
<td><a href="/services/{{.ID}}">{{.DisplayName}}</a>{{if .Self}} <span class="badge">managed outside</span>{{end}}</td>
|
<div id="machines" hx-swap-oob="innerHTML">{{template "machine-links" .}}</div>
|
||||||
<td><code>{{shortRef .ActualRef}}</code></td>
|
{{end}}
|
||||||
<td class="status status-{{.Status}}">{{.Status}}</td>
|
|
||||||
<td>{{.UpdateMode}}</td>
|
{{define "services"}}
|
||||||
</tr>
|
{{if .Sprite}}<svg class="sprite" aria-hidden="true">{{range .Sprite}}<symbol id="si-{{.Slug}}" viewBox="0 0 24 24"><path fill="currentColor" d="{{.Path}}"/></symbol>{{end}}</svg>{{end}}
|
||||||
{{end}}
|
{{range .Groups}}
|
||||||
</tbody>
|
<section class="grp{{if .Offline}} offline{{end}}" data-key="{{.Key}}" aria-label="{{.Title}}">
|
||||||
</table>
|
<div class="gh">
|
||||||
|
<h2{{if .TitleHint}} title="{{.TitleHint}}"{{end}}>{{.Title}}</h2>
|
||||||
|
<span class="kind">{{.Kind}}</span>
|
||||||
|
{{if .Meta}}<span class="meta">{{.Meta}}</span>{{end}}
|
||||||
|
{{if .Offline}}<span class="warn">{{.OfflineLabel}}{{with .OfflineSince}} since <time datetime="{{.Format "2006-01-02T15:04:05Z07:00"}}">{{.Format "15:04"}}</time>{{end}} · showing last known state</span>{{end}}
|
||||||
|
<span class="count">{{len .Services}}</span>
|
||||||
|
</div>
|
||||||
|
{{range .Services}}
|
||||||
|
<div class="row" data-search="{{.Search}}">
|
||||||
|
<div class="nm">
|
||||||
|
<a href="/services/{{.ID}}" title="{{.DisplayName}}" aria-label="{{.DisplayName}}">
|
||||||
|
{{if .Icon}}<span class="ico" aria-hidden="true"><svg width="15" height="15" viewBox="0 0 24 24"><use href="#si-{{.Icon}}"></use></svg></span>{{else}}<span class="ltr {{.LetterClass}}" aria-hidden="true">{{.Letter}}</span>{{end}}
|
||||||
|
<span class="nmt">{{.Name}}</span>
|
||||||
|
</a>
|
||||||
|
{{if .Stack}}<span class="stk">{{.Stack}}</span>{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="ver" title="{{.ActualRef}}"><span class="r">{{.Repo}}</span><span class="t"><b>{{.Tag}}</b>{{.Digest}}</span></div>
|
||||||
|
<div class="stc">
|
||||||
|
<span class="st {{.StatusClass}}"><svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><circle cx="5" cy="5" r="4" stroke="currentColor" stroke-width="1.5"></circle></svg>{{.StatusLabel}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flags">
|
||||||
|
{{if .AvailableRef}}<span class="tag f-up" title="{{.AvailableRef}}">Update available<em>{{.AvailableTag}}</em></span>{{end}}
|
||||||
|
{{if .NewerTag}}<span class="tag f-newer" title="{{.NewerRef}}">Newer tag<em>{{.NewerTag}}</em></span>{{end}}
|
||||||
|
{{if followsNoTag .Service}}<span class="tag f-notag">Follows no tag</span>{{end}}
|
||||||
|
{{if .LastPollError}}<span class="tag f-err" title="{{.LastPollError}}">Poll failed</span>{{end}}
|
||||||
|
{{if .Self}}<span class="tag f-out">Managed outside</span>{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="mode">
|
||||||
|
{{if .Self}}<span class="na">n/a</span>
|
||||||
|
{{else}}<form method="post" action="/services/{{.ID}}/mode">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{$.CSRF}}">
|
||||||
|
<input type="hidden" name="return" value="overview">
|
||||||
|
{{if eq .UpdateMode "auto"}}<input type="hidden" name="mode" value="manual">
|
||||||
|
<button type="submit" class="sw" role="switch" aria-checked="true"><i></i>Auto</button>
|
||||||
|
{{else}}<input type="hidden" name="mode" value="auto">
|
||||||
|
<button type="submit" class="sw" role="switch" aria-checked="false"{{if .AutoBlocked}} disabled title="{{.AutoBlocked}}"{{end}}><i></i>Manual</button>
|
||||||
|
{{if .AutoBlocked}}<span class="na">{{.AutoBlocked}}</span>{{end}}{{end}}
|
||||||
|
</form>{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
</section>
|
</section>
|
||||||
{{else}}
|
{{else}}
|
||||||
<p>No services reported yet. <a href="/agents">Add an agent</a> to get started.</p>
|
<p>No services reported yet. <a href="/agents?add=1">Add an agent</a> to get started.</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
<p class="nomatch" id="nomatch" hidden>No services match.</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
{{if .Service.ActualDigest}}<tr><th>Digest</th><td><code>{{.Service.ActualDigest}}</code></td></tr>{{end}}
|
{{if .Service.ActualDigest}}<tr><th>Digest</th><td><code>{{.Service.ActualDigest}}</code></td></tr>{{end}}
|
||||||
<tr><th>Status</th><td class="status status-{{.Service.Status}}">{{.Service.Status}}</td></tr>
|
<tr><th>Status</th><td class="status status-{{.Service.Status}}">{{.Service.Status}}</td></tr>
|
||||||
{{if .Service.DesiredRef}}<tr><th>Desired image</th><td><code>{{shortRef .Service.DesiredRef}}</code></td></tr>{{end}}
|
{{if .Service.DesiredRef}}<tr><th>Desired image</th><td><code>{{shortRef .Service.DesiredRef}}</code></td></tr>{{end}}
|
||||||
|
{{if .Service.AvailableRef}}<tr><th>Available</th><td><code>{{.Service.AvailableRef}}</code> <span class="muted">via {{.Service.AvailableSource}}{{with .Service.AvailableAt}}, {{.Format "2006-01-02 15:04"}}{{end}}</span></td></tr>{{end}}
|
||||||
|
{{if .NewerTag}}<tr><th>Newer tag</th><td><code>{{.Service.NewerRef}}</code> <span class="muted">from the registry's tag list{{with .Service.NewerAt}}, {{.Format "2006-01-02 15:04"}}{{end}}; deploy it from Versions</span></td></tr>{{end}}
|
||||||
|
<tr><th>Mode</th><td>{{.Service.UpdateMode}}</td></tr>
|
||||||
|
<tr><th>Polling</th><td{{if .PollFailed}} class="error"{{end}}>{{.Polling}}</td></tr>
|
||||||
{{if .ConfigFiles}}<tr><th>Config files</th><td>{{range .ConfigFiles}}<code>{{.}}</code><br>{{end}}</td></tr>{{end}}
|
{{if .ConfigFiles}}<tr><th>Config files</th><td>{{range .ConfigFiles}}<code>{{.}}</code><br>{{end}}</td></tr>{{end}}
|
||||||
</table>
|
</table>
|
||||||
{{if .Reason}}
|
{{if .Reason}}
|
||||||
@@ -19,6 +23,39 @@
|
|||||||
</form>
|
</form>
|
||||||
<p class="muted">Pulls the current tag again and redeploys the service.</p>
|
<p class="muted">Pulls the current tag again and redeploys the service.</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
<form method="post" action="/services/{{.Service.ID}}/mode">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{.CSRF}}">
|
||||||
|
{{if eq .Service.UpdateMode "auto"}}<input type="hidden" name="mode" value="manual">
|
||||||
|
<button type="submit">Make manual</button>
|
||||||
|
<p class="muted">The webhook will only mark new versions as available.</p>
|
||||||
|
{{else}}<input type="hidden" name="mode" value="auto">
|
||||||
|
<button type="submit"{{if .AutoBlocked}} disabled{{end}}>Make auto</button>
|
||||||
|
<p class="muted">{{if .AutoBlocked}}Auto unavailable: {{.AutoBlocked}}.{{else}}The webhook will update this service as soon as CI pushes a new image.{{end}}</p>
|
||||||
|
{{end}}
|
||||||
|
</form>
|
||||||
|
<h2>Versions</h2>
|
||||||
|
{{if eq .Service.UpdateMode "auto"}}<p class="warning">This service is in auto mode: a version picked here is replaced again when a newer version is announced or found by polling.</p>{{end}}
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Version</th><th>Seen via</th><th>First seen</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Versions}}
|
||||||
|
<tr>
|
||||||
|
<td><code title="{{.Ref}}">{{shortRef .Ref}}</code>{{if .Running}} <span class="badge">running</span>{{end}}{{if not .Tag}} <span class="muted">the service will follow no tag afterwards</span>{{end}}</td>
|
||||||
|
<td>{{.Source}}</td>
|
||||||
|
<td>{{.FirstSeenAt.Format "2006-01-02 15:04"}}</td>
|
||||||
|
{{/* AutoBlocked is the same check that decides whether the service can be updated with an explicit ref, which is what a pick is. */}}
|
||||||
|
<td>{{if not $.AutoBlocked}}
|
||||||
|
<form method="post" action="/services/{{$.Service.ID}}/update">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{$.CSRF}}">
|
||||||
|
<input type="hidden" name="version" value="{{.ID}}">
|
||||||
|
<button type="submit">Deploy</button>
|
||||||
|
</form>{{end}}</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr><td colspan="4">No versions seen yet.</td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
<h2>Update history</h2>
|
<h2>Update history</h2>
|
||||||
<div id="history" hx-get="/fragments/services/{{.Service.ID}}/jobs" hx-trigger="sse:jobs" hx-swap="innerHTML">
|
<div id="history" hx-get="/fragments/services/{{.Service.ID}}/jobs" hx-trigger="sse:jobs" hx-swap="innerHTML">
|
||||||
{{template "history" .}}
|
{{template "history" .}}
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/shcizo/package-updater/internal/master/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
const sluskRepo = "gitea.shcizo.se/shcizo/slusk"
|
||||||
|
|
||||||
|
func TestServicePage_VersionPicker(t *testing.T) {
|
||||||
|
srv, store, _, _, _ := newTestEnv(t)
|
||||||
|
store.services[0].ImageRepo = sluskRepo
|
||||||
|
store.services[0].ActualDigest = "sha256:cur"
|
||||||
|
first := time.Date(2026, 10, 12, 9, 5, 0, 0, time.UTC)
|
||||||
|
store.versions = []model.ImageVersion{
|
||||||
|
{ID: 31, Repo: sluskRepo, Tag: "1.5.0", Digest: "sha256:" + strings.Repeat("a", 40), Source: "poll", FirstSeenAt: first},
|
||||||
|
{ID: 32, Repo: sluskRepo, Tag: "1.4.2", Digest: "sha256:cur", Source: "running", FirstSeenAt: first},
|
||||||
|
{ID: 33, Repo: sluskRepo, Digest: "sha256:" + strings.Repeat("b", 40), Source: "running", FirstSeenAt: first},
|
||||||
|
{ID: 34, Repo: sluskRepo, Tag: "main", Source: "webhook", FirstSeenAt: first},
|
||||||
|
{ID: 40, Repo: "other/repo", Tag: "x", Source: "poll", FirstSeenAt: first},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, body := get(t, srv, "/services/1")
|
||||||
|
|
||||||
|
for _, id := range []string{"31", "32", "33", "34"} {
|
||||||
|
assert.Contains(t, body, `<input type="hidden" name="version" value="`+id+`">`, id)
|
||||||
|
}
|
||||||
|
assert.NotContains(t, body, `name="version" value="40"`, "versions of other repos are not listed")
|
||||||
|
assert.Equal(t, 4, strings.Count(body, `name="version"`))
|
||||||
|
assert.Contains(t, body, `name="csrf_token" value="csrf"`)
|
||||||
|
assert.Contains(t, body, `title="`+sluskRepo+`:1.5.0@sha256:`+strings.Repeat("a", 40)+`"`, "full ref as title")
|
||||||
|
assert.Contains(t, body, sluskRepo+":1.5.0@sha256:"+strings.Repeat("a", 12), "short ref as text")
|
||||||
|
assert.Equal(t, 1, strings.Count(body, `<span class="badge">running</span>`), "only the running digest is marked")
|
||||||
|
assert.Equal(t, 1, strings.Count(body, "the service will follow no tag afterwards"), "only the tagless row")
|
||||||
|
assert.Contains(t, body, "2026-10-12 09:05")
|
||||||
|
assert.NotContains(t, body, "auto mode", "no auto warning for a manual service")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServicePage_VersionPickerShowsAtMostTenRows(t *testing.T) {
|
||||||
|
srv, store, _, _, _ := newTestEnv(t)
|
||||||
|
store.services[0].ImageRepo = sluskRepo
|
||||||
|
for i := int64(1); i <= 12; i++ {
|
||||||
|
store.versions = append(store.versions, model.ImageVersion{ID: i, Repo: sluskRepo, Tag: "t", Digest: "sha256:d", Source: "poll"})
|
||||||
|
}
|
||||||
|
|
||||||
|
_, body := get(t, srv, "/services/1")
|
||||||
|
|
||||||
|
assert.Equal(t, 10, strings.Count(body, `name="version"`))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServicePage_VersionPickerWarnsForAutoServices(t *testing.T) {
|
||||||
|
srv, store, _, _, _ := newTestEnv(t)
|
||||||
|
store.services[0].UpdateMode = "auto"
|
||||||
|
|
||||||
|
_, body := get(t, srv, "/services/1")
|
||||||
|
|
||||||
|
assert.Contains(t, body, "This service is in auto mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServicePage_VersionPickerHiddenWhenServiceCannotBeUpdated(t *testing.T) {
|
||||||
|
for _, reason := range []string{model.ReasonSelf, model.ReasonContainer, model.ReasonRemoved, model.ReasonBuilt} {
|
||||||
|
t.Run(reason, func(t *testing.T) {
|
||||||
|
srv, store, _, _, jobs := newTestEnv(t)
|
||||||
|
store.services[0].ImageRepo = sluskRepo
|
||||||
|
store.versions = []model.ImageVersion{{ID: 31, Repo: sluskRepo, Tag: "1.5.0", Source: "poll"}}
|
||||||
|
jobs.autoErr = &model.NotUpdatableError{Reason: reason}
|
||||||
|
|
||||||
|
_, body := get(t, srv, "/services/1")
|
||||||
|
|
||||||
|
assert.Contains(t, body, "<h2>Versions</h2>")
|
||||||
|
assert.Contains(t, body, "1.5.0", "the history is still shown")
|
||||||
|
assert.NotContains(t, body, `name="version"`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServicePage_VersionPickerOffersVersionsToAServiceThatFollowsNoTag(t *testing.T) {
|
||||||
|
srv, store, _, _, jobs := newTestEnv(t)
|
||||||
|
store.services[0].ImageRepo = sluskRepo
|
||||||
|
store.versions = []model.ImageVersion{{ID: 31, Repo: sluskRepo, Tag: "1.5.0", Digest: "sha256:a", Source: "poll"}}
|
||||||
|
jobs.updatable = &model.NotUpdatableError{Reason: model.ReasonNoTag} // autoErr stays nil: an explicit ref is fine
|
||||||
|
|
||||||
|
_, body := get(t, srv, "/services/1")
|
||||||
|
|
||||||
|
assert.Contains(t, body, `name="version" value="31"`)
|
||||||
|
assert.NotContains(t, body, `<button type="submit">Update</button>`, "the plain Update button stays hidden")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServicePage_PollingRow(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
edit func(*model.Service)
|
||||||
|
want string
|
||||||
|
failed bool
|
||||||
|
}{
|
||||||
|
{"polled", func(s *model.Service) { s.ActualDigest = "sha256:cur" }, "every 6h", false},
|
||||||
|
{"poll error", func(s *model.Service) { s.ActualDigest = "sha256:cur"; s.LastPollError = "head x: 401 <b>" }, "last check failed: head x: 401 <b>", true},
|
||||||
|
{"built", func(s *model.Service) { s.ActualDigest = "sha256:cur"; s.Built = true }, "not polled: built from source", false},
|
||||||
|
{"no digest", func(s *model.Service) {}, "not polled: no registry digest", false},
|
||||||
|
{"no tag", func(s *model.Service) { s.ActualRef = "r@sha256:a"; s.ActualDigest = "sha256:a" }, "not polled: follows no tag", false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
srv, store, _, _, _ := newTestEnvPolling(t, 6*time.Hour)
|
||||||
|
tt.edit(&store.services[0])
|
||||||
|
|
||||||
|
_, body := get(t, srv, "/services/1")
|
||||||
|
|
||||||
|
assert.Contains(t, body, "<th>Polling</th><td")
|
||||||
|
assert.Contains(t, body, tt.want)
|
||||||
|
assert.Equal(t, tt.failed, strings.Contains(body, `<td class="error">`+tt.want))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShortDuration(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
d time.Duration
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{6 * time.Hour, "6h"},
|
||||||
|
{90 * time.Minute, "1h30m"},
|
||||||
|
{30 * time.Minute, "30m"},
|
||||||
|
{90 * time.Second, "1m30s"},
|
||||||
|
{45 * time.Second, "45s"},
|
||||||
|
{6*time.Hour + 5*time.Second, "6h0m5s"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.want, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.want, shortDuration(tt.d))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func postVersion(t *testing.T, srv *httptest.Server, version string) (*http.Response, string) {
|
||||||
|
t.Helper()
|
||||||
|
resp, err := noRedirectClient().PostForm(srv.URL+"/services/1/update", url.Values{"csrf_token": {"csrf"}, "version": {version}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return resp, string(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateService_VersionStartsThatVersion(t *testing.T) {
|
||||||
|
srv, _, _, _, jobs := newTestEnv(t)
|
||||||
|
jobs.job = model.Job{ID: 42}
|
||||||
|
|
||||||
|
resp, _ := postVersion(t, srv, "31")
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusSeeOther, resp.StatusCode)
|
||||||
|
assert.Equal(t, "/jobs/42", resp.Header.Get("Location"))
|
||||||
|
assert.Equal(t, []int64{31}, jobs.triggered)
|
||||||
|
assert.Equal(t, []int64{1}, jobs.triggerSvc)
|
||||||
|
assert.Equal(t, "alice", jobs.triggeredBy())
|
||||||
|
assert.Zero(t, jobs.plainCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateService_WithoutVersionTriggersTheFollowedTag(t *testing.T) {
|
||||||
|
srv, _, _, _, jobs := newTestEnv(t)
|
||||||
|
jobs.job = model.Job{ID: 42}
|
||||||
|
|
||||||
|
resp, _ := postVersion(t, srv, "")
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusSeeOther, resp.StatusCode)
|
||||||
|
assert.Equal(t, 1, jobs.plainCalls)
|
||||||
|
assert.Empty(t, jobs.triggered)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateService_VersionErrors(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
version string
|
||||||
|
err error
|
||||||
|
wantCode int
|
||||||
|
wantBody string
|
||||||
|
wantJob string // Location header
|
||||||
|
}{
|
||||||
|
{"not a number", "abc", nil, http.StatusBadRequest, "invalid version", ""},
|
||||||
|
{"a ref instead of an id", "repo:evil@sha256:x", nil, http.StatusBadRequest, "invalid version", ""},
|
||||||
|
{"unknown version or other repo", "77", model.ErrNotFound, http.StatusNotFound, "not found", ""},
|
||||||
|
{"not updatable", "31", &model.NotUpdatableError{Reason: model.ReasonBuilt}, http.StatusConflict, "Cannot update: " + model.ReasonBuilt + ".", ""},
|
||||||
|
{"job already active", "31", model.ErrJobActive, http.StatusSeeOther, "", "/jobs/41"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
srv, _, _, _, jobs := newTestEnv(t)
|
||||||
|
jobs.err, jobs.job = tt.err, model.Job{ID: 41}
|
||||||
|
|
||||||
|
resp, body := postVersion(t, srv, tt.version)
|
||||||
|
|
||||||
|
assert.Equal(t, tt.wantCode, resp.StatusCode)
|
||||||
|
assert.Contains(t, body, tt.wantBody)
|
||||||
|
assert.Equal(t, tt.wantJob, resp.Header.Get("Location"))
|
||||||
|
if tt.wantCode == http.StatusBadRequest {
|
||||||
|
assert.Empty(t, jobs.triggered, "no job for a malformed version")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverview_ShowsFollowsNoTagAndPollFailedBadges(t *testing.T) {
|
||||||
|
srv, store, _, _, _ := newTestEnv(t)
|
||||||
|
store.services[0].LastPollError = `head x: "boom" <script>`
|
||||||
|
|
||||||
|
for _, path := range []string{"/", "/fragments/services"} {
|
||||||
|
_, body := get(t, srv, path)
|
||||||
|
assert.Equal(t, 1, strings.Count(body, "Follows no tag"), "only the digest-only Swarm service: "+path)
|
||||||
|
assert.Contains(t, body, `class="tag f-err" title="head x: "boom" <script>">Poll failed</span>`, path)
|
||||||
|
assert.Equal(t, 1, strings.Count(body, "Poll failed"), path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServicePage_VersionPickerAppendsNewerTagBeyondLimit(t *testing.T) {
|
||||||
|
srv, store, _, _, _ := newTestEnv(t)
|
||||||
|
store.services[0].ImageRepo = sluskRepo
|
||||||
|
store.services[0].NewerRef = sluskRepo + ":1.5.0"
|
||||||
|
for i := int64(1); i <= 12; i++ {
|
||||||
|
store.versions = append(store.versions, model.ImageVersion{ID: i, Repo: sluskRepo, Tag: "t", Digest: "sha256:d", Source: "poll"})
|
||||||
|
}
|
||||||
|
store.versions = append(store.versions, model.ImageVersion{ID: 99, Repo: sluskRepo, Tag: "1.5.0", Source: "tags"})
|
||||||
|
|
||||||
|
_, body := get(t, srv, "/services/1")
|
||||||
|
|
||||||
|
assert.Equal(t, 11, strings.Count(body, `name="version"`), "the ten recent versions and the newer tag")
|
||||||
|
assert.Contains(t, body, `<input type="hidden" name="version" value="99">`)
|
||||||
|
assert.Contains(t, body, "<th>Newer tag</th>")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServicePage_NewerTagAlreadyListedIsNotAppendedTwice(t *testing.T) {
|
||||||
|
srv, store, _, _, _ := newTestEnv(t)
|
||||||
|
store.services[0].ImageRepo = sluskRepo
|
||||||
|
store.services[0].NewerRef = sluskRepo + ":1.5.0"
|
||||||
|
store.versions = []model.ImageVersion{{ID: 31, Repo: sluskRepo, Tag: "1.5.0", Source: "tags"}}
|
||||||
|
|
||||||
|
_, body := get(t, srv, "/services/1")
|
||||||
|
|
||||||
|
assert.Equal(t, 1, strings.Count(body, `name="version"`))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServicePage_PinnedRowOfTheNewerTagDoesNotHideThePlainRow(t *testing.T) {
|
||||||
|
srv, store, _, _, _ := newTestEnv(t)
|
||||||
|
store.services[0].ImageRepo = sluskRepo
|
||||||
|
store.services[0].NewerRef = sluskRepo + ":1.5.0"
|
||||||
|
store.versions = []model.ImageVersion{
|
||||||
|
{ID: 31, Repo: sluskRepo, Tag: "1.5.0", Digest: "sha256:d", Source: "poll"},
|
||||||
|
{ID: 32, Repo: sluskRepo, Tag: "1.5.0", Source: "tags"},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, body := get(t, srv, "/services/1")
|
||||||
|
|
||||||
|
assert.Equal(t, 2, strings.Count(body, `name="version"`))
|
||||||
|
assert.Contains(t, body, `<input type="hidden" name="version" value="32">`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServicePage_NoNewerTagRowWithoutSuggestion(t *testing.T) {
|
||||||
|
srv, _, _, _, _ := newTestEnv(t)
|
||||||
|
|
||||||
|
_, body := get(t, srv, "/services/1")
|
||||||
|
|
||||||
|
assert.NotContains(t, body, "Newer tag")
|
||||||
|
}
|
||||||
+320
-22
@@ -1,54 +1,352 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/shcizo/package-updater/internal/imageref"
|
"github.com/shcizo/package-updater/internal/imageref"
|
||||||
|
"github.com/shcizo/package-updater/internal/master/jobs"
|
||||||
"github.com/shcizo/package-updater/internal/master/model"
|
"github.com/shcizo/package-updater/internal/master/model"
|
||||||
|
"github.com/shcizo/package-updater/internal/master/poller"
|
||||||
|
"github.com/shcizo/package-updater/internal/master/tagver"
|
||||||
|
"github.com/shcizo/package-updater/internal/master/web/icons"
|
||||||
"github.com/shcizo/package-updater/internal/protocol"
|
"github.com/shcizo/package-updater/internal/protocol"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// serviceGroup is one block of the overview: a machine, a Swarm cluster or
|
||||||
|
// the services whose agent is unknown.
|
||||||
type serviceGroup struct {
|
type serviceGroup struct {
|
||||||
Title string
|
Key string // "agent-<id>", "swarm-<cluster id>" or "unknown"; the machine filter matches on it
|
||||||
Services []model.Service
|
Title string
|
||||||
|
TitleHint string // hover text of the title; the full cluster id
|
||||||
|
Kind string // "Machine" or "Swarm cluster"
|
||||||
|
Meta string // hostname, or the cluster's managers
|
||||||
|
Cluster bool
|
||||||
|
Sub bool // a machine that belongs to the cluster shown above it
|
||||||
|
// Offline says the agent (or, for a cluster, every manager) is not
|
||||||
|
// connected, so the rows show the last known state.
|
||||||
|
Offline bool
|
||||||
|
OfflineLabel string // what the banner says before "since"
|
||||||
|
OfflineSince *time.Time // when the agent was last seen; nil if unknown
|
||||||
|
Services []serviceRow
|
||||||
}
|
}
|
||||||
|
|
||||||
// groupServices groups machine-bound services per agent and Swarm services
|
// serviceRow is a service as shown in the overview.
|
||||||
// in one group, keeping the store's order (machines first, Swarm last).
|
type serviceRow struct {
|
||||||
func groupServices(services []model.Service) []serviceGroup {
|
model.Service
|
||||||
var groups []serviceGroup
|
AutoBlocked string // why the service cannot be set to auto; "" when it can
|
||||||
index := map[string]int{}
|
|
||||||
|
Name string // the service's own name, without its stack
|
||||||
|
Stack string // the Compose project; "" for Swarm and plain containers
|
||||||
|
Repo string // image repository without the registry host
|
||||||
|
Tag string // ":tag", or ""
|
||||||
|
Digest string // "@sha256:<12 hex>…" when the image has no tag, else ""
|
||||||
|
AvailableTag string // the tag of AvailableRef, or ""
|
||||||
|
NewerTag string // the tag of NewerRef while it is still newer than the followed tag, or ""
|
||||||
|
Icon string // Simple Icons slug; "" when there is no icon
|
||||||
|
Letter string // shown instead of an icon
|
||||||
|
LetterClass string // lc-0 .. lc-3
|
||||||
|
StatusLabel string
|
||||||
|
StatusClass string
|
||||||
|
Search string // what the search box matches against, lowercased
|
||||||
|
}
|
||||||
|
|
||||||
|
// newServiceRow fills the display fields of a row.
|
||||||
|
func newServiceRow(s model.Service, autoBlocked string) serviceRow {
|
||||||
|
r := serviceRow{Service: s, AutoBlocked: autoBlocked}
|
||||||
|
switch s.Kind {
|
||||||
|
case protocol.KindCompose:
|
||||||
|
r.Name, r.Stack = s.ComposeService, s.ComposeProject
|
||||||
|
case protocol.KindSwarm:
|
||||||
|
r.Name = s.SwarmService
|
||||||
|
default:
|
||||||
|
r.Name = s.ContainerName
|
||||||
|
}
|
||||||
|
r.Repo, r.Tag, r.Digest = imageParts(s.ActualRef)
|
||||||
|
r.AvailableTag = imageref.Parse(s.AvailableRef).Tag
|
||||||
|
r.NewerTag = newerTag(s)
|
||||||
|
r.Icon = icons.ForImage(s.ActualRef)
|
||||||
|
r.Letter = "?"
|
||||||
|
if first, _ := utf8.DecodeRuneInString(r.Name); first != utf8.RuneError {
|
||||||
|
r.Letter = strings.ToUpper(string(first))
|
||||||
|
}
|
||||||
|
r.LetterClass = letterClass(r.Name)
|
||||||
|
switch s.Status {
|
||||||
|
case "running":
|
||||||
|
r.StatusLabel, r.StatusClass = "Running", "s-running"
|
||||||
|
case "degraded":
|
||||||
|
r.StatusLabel, r.StatusClass = "Degraded", "s-degraded"
|
||||||
|
case "stopped":
|
||||||
|
r.StatusLabel, r.StatusClass = "Stopped", "s-stopped"
|
||||||
|
default:
|
||||||
|
r.StatusLabel, r.StatusClass = "Unknown", "s-unknown"
|
||||||
|
}
|
||||||
|
r.Search = strings.ToLower(strings.Join([]string{r.Name, r.Stack, r.Repo, strings.TrimPrefix(r.Tag, ":"), s.AgentName}, " "))
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// imageParts splits an image reference for display: the repository without
|
||||||
|
// its registry host, ":tag", and a shortened digest that is only given when
|
||||||
|
// there is no tag.
|
||||||
|
func imageParts(ref string) (repo, tag, digest string) {
|
||||||
|
r := imageref.Parse(ref)
|
||||||
|
repo = r.Repo
|
||||||
|
if host, rest, ok := strings.Cut(repo, "/"); ok && (imageref.IsRegistryHost(host)) {
|
||||||
|
repo = rest
|
||||||
|
}
|
||||||
|
if r.Tag != "" {
|
||||||
|
return repo, ":" + r.Tag, ""
|
||||||
|
}
|
||||||
|
if r.Digest != "" {
|
||||||
|
return repo, "", "@" + shortDigest(r.Digest)
|
||||||
|
}
|
||||||
|
return repo, "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// shortDigest shortens "sha256:<hex>" to 12 hex characters and an ellipsis.
|
||||||
|
func shortDigest(d string) string {
|
||||||
|
if hex, ok := strings.CutPrefix(d, "sha256:"); ok && len(hex) > 12 {
|
||||||
|
return "sha256:" + hex[:12] + "…"
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// letterClass picks one of four colours from the name, so a service keeps
|
||||||
|
// its colour.
|
||||||
|
func letterClass(name string) string {
|
||||||
|
sum := 0
|
||||||
|
for i := 0; i < len(name); i++ {
|
||||||
|
sum += int(name[i])
|
||||||
|
}
|
||||||
|
return "lc-" + strconv.Itoa(sum%4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// groupServices groups the services into one block per Swarm cluster and per
|
||||||
|
// machine. A cluster comes first, followed by the machines that belong to it;
|
||||||
|
// the remaining machines follow by name and the services whose agent is
|
||||||
|
// unknown come last. Agents without services get no block. online says
|
||||||
|
// whether an agent is connected; autoBlocked returns the reason a service
|
||||||
|
// cannot be set to auto, or "".
|
||||||
|
func groupServices(services []model.Service, agents []model.Agent, online func(int64) bool, autoBlocked func(model.Service) string) []serviceGroup {
|
||||||
|
agentByID := map[int64]model.Agent{}
|
||||||
|
for _, a := range agents {
|
||||||
|
agentByID[a.ID] = a
|
||||||
|
}
|
||||||
|
var clusterIDs []string
|
||||||
|
clusterRows := map[string][]serviceRow{}
|
||||||
|
clusterRowAgents := map[string]map[int64]bool{} // agents named by a cluster's rows
|
||||||
|
machineRows := map[int64][]serviceRow{}
|
||||||
|
var unknownRows []serviceRow
|
||||||
for _, s := range services {
|
for _, s := range services {
|
||||||
title := s.AgentName
|
row := newServiceRow(s, autoBlocked(s))
|
||||||
if s.Kind == protocol.KindSwarm {
|
_, known := agentByID[derefID(s.AgentID)]
|
||||||
title = "Swarm"
|
switch {
|
||||||
} else if title == "" {
|
case s.Kind == protocol.KindSwarm:
|
||||||
title = "Unknown agent"
|
if _, seen := clusterRows[s.SwarmClusterID]; !seen {
|
||||||
|
clusterIDs = append(clusterIDs, s.SwarmClusterID)
|
||||||
|
clusterRowAgents[s.SwarmClusterID] = map[int64]bool{}
|
||||||
|
}
|
||||||
|
clusterRows[s.SwarmClusterID] = append(clusterRows[s.SwarmClusterID], row)
|
||||||
|
if s.AgentID != nil {
|
||||||
|
clusterRowAgents[s.SwarmClusterID][*s.AgentID] = true
|
||||||
|
}
|
||||||
|
case s.AgentID != nil && known:
|
||||||
|
machineRows[*s.AgentID] = append(machineRows[*s.AgentID], row)
|
||||||
|
default:
|
||||||
|
unknownRows = append(unknownRows, row)
|
||||||
}
|
}
|
||||||
i, ok := index[title]
|
}
|
||||||
if !ok {
|
|
||||||
i = len(groups)
|
machineIDs := make([]int64, 0, len(machineRows))
|
||||||
index[title] = i
|
for id := range machineRows {
|
||||||
groups = append(groups, serviceGroup{Title: title})
|
machineIDs = append(machineIDs, id)
|
||||||
|
}
|
||||||
|
slices.SortFunc(machineIDs, func(a, b int64) int {
|
||||||
|
return cmp.Or(cmp.Compare(agentByID[a].Name, agentByID[b].Name), cmp.Compare(a, b))
|
||||||
|
})
|
||||||
|
machine := func(id int64, sub bool) serviceGroup {
|
||||||
|
a := agentByID[id]
|
||||||
|
return serviceGroup{
|
||||||
|
Key: "agent-" + strconv.FormatInt(id, 10), Title: a.Name, Kind: "Machine", Meta: a.Hostname, Sub: sub,
|
||||||
|
Offline: !online(id), OfflineLabel: "Agent offline", OfflineSince: a.LastSeenAt, Services: machineRows[id],
|
||||||
}
|
}
|
||||||
groups[i].Services = append(groups[i].Services, s)
|
}
|
||||||
|
|
||||||
|
var groups []serviceGroup
|
||||||
|
placed := map[int64]bool{}
|
||||||
|
for _, cid := range clusterIDs {
|
||||||
|
title := "Swarm"
|
||||||
|
if len(clusterIDs) > 1 {
|
||||||
|
title += " " + cid[:min(len(cid), 8)]
|
||||||
|
}
|
||||||
|
g := serviceGroup{
|
||||||
|
Key: "swarm-" + cid, Title: title, TitleHint: cid, Kind: "Swarm cluster", Cluster: true,
|
||||||
|
Offline: true, OfflineLabel: "No manager online", Services: clusterRows[cid],
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
for _, a := range agents {
|
||||||
|
isManager := a.SwarmManager && a.SwarmClusterID == cid
|
||||||
|
if a.RevokedAt != nil || !(isManager || clusterRowAgents[cid][a.ID]) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
names = append(names, a.Name)
|
||||||
|
if online(a.ID) {
|
||||||
|
g.Offline = false
|
||||||
|
}
|
||||||
|
if a.LastSeenAt != nil && (g.OfflineSince == nil || a.LastSeenAt.After(*g.OfflineSince)) {
|
||||||
|
g.OfflineSince = a.LastSeenAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.Sort(names)
|
||||||
|
if len(names) > 0 {
|
||||||
|
g.Meta = "managers " + strings.Join(names, ", ")
|
||||||
|
}
|
||||||
|
groups = append(groups, g)
|
||||||
|
for _, id := range machineIDs {
|
||||||
|
if cid != "" && agentByID[id].SwarmClusterID == cid && !placed[id] {
|
||||||
|
placed[id] = true
|
||||||
|
groups = append(groups, machine(id, true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, id := range machineIDs {
|
||||||
|
if !placed[id] {
|
||||||
|
groups = append(groups, machine(id, false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(unknownRows) > 0 {
|
||||||
|
groups = append(groups, serviceGroup{
|
||||||
|
Key: "unknown", Title: "Unknown agent", Kind: "Machine", Offline: true, OfflineLabel: "Agent offline", Services: unknownRows,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return groups
|
return groups
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func derefID(id *int64) int64 {
|
||||||
|
if id == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return *id
|
||||||
|
}
|
||||||
|
|
||||||
// shortRef shortens a digest in an image reference to 12 hex characters.
|
// shortRef shortens a digest in an image reference to 12 hex characters.
|
||||||
func shortRef(ref string) string {
|
func shortRef(ref string) string {
|
||||||
r := imageref.Parse(ref)
|
r := imageref.Parse(ref)
|
||||||
if hex, ok := strings.CutPrefix(r.Digest, "sha256:"); ok && len(hex) > 12 {
|
r.Digest = shortDigest(r.Digest)
|
||||||
r.Digest = "sha256:" + hex[:12] + "…"
|
|
||||||
}
|
|
||||||
return r.String()
|
return r.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// followsNoTag says the service's image reference has a digest but no tag,
|
||||||
|
// so there is no tag to poll or update.
|
||||||
|
func followsNoTag(svc model.Service) bool {
|
||||||
|
return jobs.FollowedRef(svc.ActualRef) == ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// newerTag returns the tag of the service's newer-tag suggestion, or "" when
|
||||||
|
// there is none or it no longer holds. The poller writes the suggestion once
|
||||||
|
// per cycle, so after the service moved to another tag the stored value can
|
||||||
|
// be stale until the next cycle; checking again here hides it meanwhile. A
|
||||||
|
// suggestion from another repo than the service follows now is hidden too.
|
||||||
|
func newerTag(s model.Service) string {
|
||||||
|
if s.RemovedAt != nil || s.NewerRef == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
newer := imageref.Parse(s.NewerRef)
|
||||||
|
repo, followed := poller.Followed(s)
|
||||||
|
if newer.Repo != repo {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if _, ok := tagver.Newer(followed, []string{newer.Tag}); !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return newer.Tag
|
||||||
|
}
|
||||||
|
|
||||||
|
// versionRow is an image version as shown on the service page.
|
||||||
|
type versionRow struct {
|
||||||
|
model.ImageVersion
|
||||||
|
Running bool // the service runs exactly this digest
|
||||||
|
}
|
||||||
|
|
||||||
|
// versionRows marks the version the service runs now, by digest.
|
||||||
|
func versionRows(versions []model.ImageVersion, actualDigest string) []versionRow {
|
||||||
|
rows := make([]versionRow, len(versions))
|
||||||
|
for i, v := range versions {
|
||||||
|
rows[i] = versionRow{ImageVersion: v, Running: v.Digest != "" && v.Digest == actualDigest}
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
// pollingStatus is the text of the "Polling" row on the service page and
|
||||||
|
// whether it is a registry error.
|
||||||
|
func pollingStatus(svc model.Service, interval time.Duration) (text string, failed bool) {
|
||||||
|
switch {
|
||||||
|
case poller.SkipReason(svc) != "":
|
||||||
|
return "not polled: " + poller.SkipReason(svc), false
|
||||||
|
case svc.LastPollError != "":
|
||||||
|
return "last check failed: " + svc.LastPollError, true
|
||||||
|
}
|
||||||
|
return "every " + shortDuration(interval), false
|
||||||
|
}
|
||||||
|
|
||||||
|
// shortDuration formats d without trailing zero units: "6h", "1h30m", "90s".
|
||||||
|
func shortDuration(d time.Duration) string {
|
||||||
|
s := d.String()
|
||||||
|
if strings.HasSuffix(s, "m0s") {
|
||||||
|
s = s[:len(s)-2]
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(s, "h0m") {
|
||||||
|
s = s[:len(s)-2]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// agentRow is an agent as shown on the agents page.
|
||||||
type agentRow struct {
|
type agentRow struct {
|
||||||
model.Agent
|
model.Agent
|
||||||
Online bool
|
Online bool
|
||||||
|
Status string // Online, Offline, Never connected or Revoked
|
||||||
|
StatusClass string
|
||||||
|
Role string // Manager, Worker or "—"
|
||||||
|
VersionOld bool // the agent runs another version than the master
|
||||||
|
}
|
||||||
|
|
||||||
|
// newAgentRow fills the display fields of a row. masterVersion is the
|
||||||
|
// master's build version; "dev" and "" never mark an agent as old.
|
||||||
|
func newAgentRow(a model.Agent, online bool, masterVersion string) agentRow {
|
||||||
|
r := agentRow{Agent: a, Online: online}
|
||||||
|
switch {
|
||||||
|
case a.RevokedAt != nil:
|
||||||
|
r.Status, r.StatusClass = "Revoked", "a-revoked"
|
||||||
|
case online:
|
||||||
|
r.Status, r.StatusClass = "Online", "a-on"
|
||||||
|
case a.LastSeenAt == nil:
|
||||||
|
r.Status, r.StatusClass = "Never connected", "a-never"
|
||||||
|
default:
|
||||||
|
r.Status, r.StatusClass = "Offline", "a-off"
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case a.SwarmManager:
|
||||||
|
r.Role = "Manager"
|
||||||
|
case a.SwarmClusterID != "":
|
||||||
|
r.Role = "Worker"
|
||||||
|
default:
|
||||||
|
r.Role = "—"
|
||||||
|
}
|
||||||
|
r.VersionOld = a.AgentVersion != "" && masterVersion != "" && masterVersion != "dev" && a.AgentVersion != masterVersion
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// middle shortens a long secret for display: its first 8 and last 4 characters.
|
||||||
|
func middle(s string) string {
|
||||||
|
if len(s) <= 14 {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:8] + "…" + s[len(s)-4:]
|
||||||
}
|
}
|
||||||
|
|
||||||
// jobDuration is how long a job ran, or has been running; "" before it starts.
|
// jobDuration is how long a job ran, or has been running; "" before it starts.
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/shcizo/package-updater/internal/master/model"
|
||||||
|
"github.com/shcizo/package-updater/internal/protocol"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestImageParts(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
ref, repo, tag, digest string
|
||||||
|
}{
|
||||||
|
{"postgres:17.10", "postgres", ":17.10", ""},
|
||||||
|
{"postgres", "postgres", "", ""},
|
||||||
|
{"gitea/gitea:1.27.3", "gitea/gitea", ":1.27.3", ""},
|
||||||
|
{"gitea.shcizo.se/shcizo/slusk:v0.8.1", "shcizo/slusk", ":v0.8.1", ""},
|
||||||
|
{"localhost:5000/foo:v1", "foo", ":v1", ""},
|
||||||
|
{"localhost/foo", "foo", "", ""},
|
||||||
|
{"lscr.io/linuxserver/jellyfin@sha256:0f42497a69fa0441bfd5f9d6bba8694f2a656ec984e571d0ac04c6dd91250039", "linuxserver/jellyfin", "", "@sha256:0f42497a69fa…"},
|
||||||
|
{"r/x:1.0@sha256:0f42497a69fa0441", "r/x", ":1.0", ""}, // the digest is hidden when there is a tag
|
||||||
|
{"r/x@sha256:abc", "r/x", "", "@sha256:abc"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.ref, func(t *testing.T) {
|
||||||
|
repo, tag, digest := imageParts(c.ref)
|
||||||
|
assert.Equal(t, c.repo, repo)
|
||||||
|
assert.Equal(t, c.tag, tag)
|
||||||
|
assert.Equal(t, c.digest, digest)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLetterClass(t *testing.T) {
|
||||||
|
assert.Equal(t, "lc-0", letterClass(""))
|
||||||
|
assert.Equal(t, "lc-1", letterClass("a"), "97 mod 4")
|
||||||
|
assert.Equal(t, letterClass("gitea"), letterClass("gitea"), "a name always gets the same colour")
|
||||||
|
for _, name := range []string{"gitea", "authentik-server", "ø", "n8n"} {
|
||||||
|
assert.Regexp(t, `^lc-[0-3]$`, letterClass(name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewAgentRow(t *testing.T) {
|
||||||
|
seen := ptr(time.Now())
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
agent model.Agent
|
||||||
|
online bool
|
||||||
|
masterVersion string
|
||||||
|
status, role string
|
||||||
|
old bool
|
||||||
|
}{
|
||||||
|
{"online manager", model.Agent{SwarmManager: true, AgentVersion: "v1"}, true, "v1", "Online", "Manager", false},
|
||||||
|
{"offline worker on another version", model.Agent{SwarmClusterID: "c", AgentVersion: "v0", LastSeenAt: seen}, false, "v1", "Offline", "Worker", true},
|
||||||
|
{"never connected", model.Agent{}, false, "v1", "Never connected", "—", false},
|
||||||
|
{"revoked wins over online", model.Agent{RevokedAt: seen, LastSeenAt: seen}, true, "v1", "Revoked", "—", false},
|
||||||
|
{"dev master marks nothing", model.Agent{AgentVersion: "v0"}, true, "dev", "Online", "—", false},
|
||||||
|
{"agent without version", model.Agent{}, true, "v1", "Online", "—", false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
row := newAgentRow(c.agent, c.online, c.masterVersion)
|
||||||
|
assert.Equal(t, c.status, row.Status)
|
||||||
|
assert.Equal(t, c.role, row.Role)
|
||||||
|
assert.Equal(t, c.old, row.VersionOld)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMiddle(t *testing.T) {
|
||||||
|
assert.Equal(t, "short", middle("short"))
|
||||||
|
assert.Equal(t, "pua_7Hq2…Ge5A", middle("pua_7Hq2mX9vKc4LwT8nRb3ZyF6dJp1sGe5A"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGroupServices(t *testing.T) {
|
||||||
|
at := func(h, m int) *time.Time { return ptr(time.Date(2026, 10, 12, h, m, 0, 0, time.UTC)) }
|
||||||
|
id := func(n int64) *int64 { return &n }
|
||||||
|
compose := func(sid int64, agent *int64, name string) model.Service {
|
||||||
|
return model.Service{ID: sid, Kind: protocol.KindCompose, AgentID: agent, ComposeProject: "p", ComposeService: name, ActualRef: "r/" + name + ":1"}
|
||||||
|
}
|
||||||
|
swarm := func(sid int64, cluster string, agent *int64) model.Service {
|
||||||
|
return model.Service{ID: sid, Kind: protocol.KindSwarm, SwarmClusterID: cluster, AgentID: agent, SwarmService: "s" + cluster}
|
||||||
|
}
|
||||||
|
noBlock := func(model.Service) string { return "" }
|
||||||
|
onlineSet := func(ids ...int64) func(int64) bool {
|
||||||
|
return func(a int64) bool {
|
||||||
|
for _, i := range ids {
|
||||||
|
if i == a {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
titles := func(groups []serviceGroup) []string {
|
||||||
|
var out []string
|
||||||
|
for _, g := range groups {
|
||||||
|
out = append(out, g.Title)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("empty", func(t *testing.T) {
|
||||||
|
assert.Empty(t, groupServices(nil, []model.Agent{{ID: 1, Name: "a"}}, onlineSet(), noBlock))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("agents without services get no group", func(t *testing.T) {
|
||||||
|
groups := groupServices([]model.Service{compose(1, id(1), "web")},
|
||||||
|
[]model.Agent{{ID: 1, Name: "a"}, {ID: 2, Name: "idle"}}, onlineSet(1, 2), noBlock)
|
||||||
|
assert.Equal(t, []string{"a"}, titles(groups))
|
||||||
|
assert.Equal(t, "agent-1", groups[0].Key)
|
||||||
|
assert.Equal(t, "Machine", groups[0].Kind)
|
||||||
|
assert.False(t, groups[0].Offline)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("services without a known agent are offline and last", func(t *testing.T) {
|
||||||
|
groups := groupServices(
|
||||||
|
[]model.Service{compose(1, nil, "orphan"), compose(2, id(7), "ghost"), compose(3, id(1), "web")},
|
||||||
|
[]model.Agent{{ID: 1, Name: "a"}}, onlineSet(1), noBlock)
|
||||||
|
assert.Equal(t, []string{"a", "Unknown agent"}, titles(groups))
|
||||||
|
unknown := groups[1]
|
||||||
|
assert.Equal(t, "unknown", unknown.Key)
|
||||||
|
assert.True(t, unknown.Offline)
|
||||||
|
assert.Nil(t, unknown.OfflineSince)
|
||||||
|
assert.Len(t, unknown.Services, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("an offline agent says since when", func(t *testing.T) {
|
||||||
|
groups := groupServices([]model.Service{compose(1, id(1), "web")},
|
||||||
|
[]model.Agent{{ID: 1, Name: "a", Hostname: "a.lan", LastSeenAt: at(13, 51)}}, onlineSet(), noBlock)
|
||||||
|
require.Len(t, groups, 1)
|
||||||
|
assert.True(t, groups[0].Offline)
|
||||||
|
assert.Equal(t, "Agent offline", groups[0].OfflineLabel)
|
||||||
|
assert.Equal(t, at(13, 51), groups[0].OfflineSince)
|
||||||
|
assert.Equal(t, "a.lan", groups[0].Meta)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a cluster is online when any manager is", func(t *testing.T) {
|
||||||
|
agents := []model.Agent{
|
||||||
|
{ID: 1, Name: "m1", SwarmManager: true, SwarmClusterID: "c1", LastSeenAt: at(10, 0)},
|
||||||
|
{ID: 2, Name: "m2", SwarmManager: true, SwarmClusterID: "c1", LastSeenAt: at(11, 0)},
|
||||||
|
}
|
||||||
|
groups := groupServices([]model.Service{swarm(1, "c1", nil)}, agents, onlineSet(2), noBlock)
|
||||||
|
require.Len(t, groups, 1)
|
||||||
|
assert.False(t, groups[0].Offline)
|
||||||
|
assert.Equal(t, "Swarm", groups[0].Title)
|
||||||
|
assert.Equal(t, "c1", groups[0].TitleHint)
|
||||||
|
assert.Equal(t, "Swarm cluster", groups[0].Kind)
|
||||||
|
assert.Equal(t, "managers m1, m2", groups[0].Meta)
|
||||||
|
assert.Equal(t, "swarm-c1", groups[0].Key)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a cluster with no manager online has been offline since the latest manager was seen", func(t *testing.T) {
|
||||||
|
agents := []model.Agent{
|
||||||
|
{ID: 1, Name: "m1", SwarmManager: true, SwarmClusterID: "c1", LastSeenAt: at(10, 0)},
|
||||||
|
{ID: 2, Name: "m2", SwarmManager: true, SwarmClusterID: "c1", LastSeenAt: at(11, 0)},
|
||||||
|
}
|
||||||
|
groups := groupServices([]model.Service{swarm(1, "c1", nil)}, agents, onlineSet(), noBlock)
|
||||||
|
assert.True(t, groups[0].Offline)
|
||||||
|
assert.Equal(t, "No manager online", groups[0].OfflineLabel)
|
||||||
|
assert.Equal(t, at(11, 0), groups[0].OfflineSince)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a revoked manager does not count", func(t *testing.T) {
|
||||||
|
agents := []model.Agent{
|
||||||
|
{ID: 1, Name: "gone", SwarmManager: true, SwarmClusterID: "c1", LastSeenAt: at(12, 0), RevokedAt: at(12, 5)},
|
||||||
|
{ID: 2, Name: "m2", SwarmManager: true, SwarmClusterID: "c1", LastSeenAt: at(9, 0)},
|
||||||
|
}
|
||||||
|
groups := groupServices([]model.Service{swarm(1, "c1", nil)}, agents, onlineSet(1), noBlock)
|
||||||
|
assert.True(t, groups[0].Offline, "the revoked manager is not connected for this purpose")
|
||||||
|
assert.Equal(t, "managers m2", groups[0].Meta)
|
||||||
|
assert.Equal(t, at(9, 0), groups[0].OfflineSince)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("managers are found through the rows when no agent reports the cluster", func(t *testing.T) {
|
||||||
|
agents := []model.Agent{{ID: 4, Name: "reporter", LastSeenAt: at(8, 0)}}
|
||||||
|
groups := groupServices([]model.Service{swarm(1, "c1", id(4))}, agents, onlineSet(4), noBlock)
|
||||||
|
assert.False(t, groups[0].Offline)
|
||||||
|
assert.Equal(t, "managers reporter", groups[0].Meta)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("order is cluster, its machines, other machines by name, unknown", func(t *testing.T) {
|
||||||
|
agents := []model.Agent{
|
||||||
|
{ID: 1, Name: "zeta"},
|
||||||
|
{ID: 2, Name: "mgr", SwarmManager: true, SwarmClusterID: "c1"},
|
||||||
|
{ID: 3, Name: "alpha"},
|
||||||
|
{ID: 4, Name: "mgr2", SwarmManager: true, SwarmClusterID: "c1"},
|
||||||
|
}
|
||||||
|
services := []model.Service{
|
||||||
|
compose(1, id(1), "a"), compose(2, id(2), "b"), compose(3, id(3), "c"),
|
||||||
|
compose(4, id(4), "d"), compose(5, nil, "e"), swarm(6, "c1", nil),
|
||||||
|
}
|
||||||
|
groups := groupServices(services, agents, onlineSet(1, 2, 3, 4), noBlock)
|
||||||
|
assert.Equal(t, []string{"Swarm", "mgr", "mgr2", "alpha", "zeta", "Unknown agent"}, titles(groups))
|
||||||
|
assert.Equal(t, []bool{false, true, true, false, false, false}, []bool{groups[0].Sub, groups[1].Sub, groups[2].Sub, groups[3].Sub, groups[4].Sub, groups[5].Sub})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("two clusters carry their ids in the titles", func(t *testing.T) {
|
||||||
|
groups := groupServices([]model.Service{swarm(1, "abcdefghijkl", nil), swarm(2, "xyz", nil)}, nil, onlineSet(), noBlock)
|
||||||
|
assert.Equal(t, []string{"Swarm abcdefgh", "Swarm xyz"}, titles(groups))
|
||||||
|
assert.Equal(t, "abcdefghijkl", groups[0].TitleHint)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the auto-blocked reason is kept on the row", func(t *testing.T) {
|
||||||
|
groups := groupServices([]model.Service{compose(1, id(1), "web")}, []model.Agent{{ID: 1, Name: "a"}}, onlineSet(1),
|
||||||
|
func(model.Service) string { return "because" })
|
||||||
|
assert.Equal(t, "because", groups[0].Services[0].AutoBlocked)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewerTag(t *testing.T) {
|
||||||
|
removed := time.Now()
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
svc model.Service
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"newer", model.Service{ActualRef: "gitea/gitea:1.27.3", NewerRef: "gitea/gitea:1.28.0"}, "1.28.0"},
|
||||||
|
{"bare image follows latest", model.Service{ActualRef: "gitea/gitea", NewerRef: "gitea/gitea:1.28.0"}, ""},
|
||||||
|
{"stale after the tag changed", model.Service{ActualRef: "gitea/gitea:1.28.0", NewerRef: "gitea/gitea:1.28.0"}, ""},
|
||||||
|
{"stale after the tag moved past it", model.Service{ActualRef: "gitea/gitea:1.29.0", NewerRef: "gitea/gitea:1.28.0"}, ""},
|
||||||
|
{"other repo", model.Service{ActualRef: "redis:7.2.4", NewerRef: "bitnami/redis:7.2.5"}, ""},
|
||||||
|
{"removed", model.Service{ActualRef: "gitea/gitea:1.27.3", NewerRef: "gitea/gitea:1.28.0", RemovedAt: &removed}, ""},
|
||||||
|
{"empty", model.Service{ActualRef: "gitea/gitea:1.27.3"}, ""},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.want, newerTag(tt.svc))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
// Package webhook implements POST /update, the endpoint CI calls after
|
||||||
|
// pushing an image. It records the pushed tag as "available" on every service
|
||||||
|
// that uses the image, starts an update for the services in auto mode and
|
||||||
|
// waits for those updates to finish.
|
||||||
|
package webhook
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shcizo/package-updater/internal/imageref"
|
||||||
|
"github.com/shcizo/package-updater/internal/logging"
|
||||||
|
"github.com/shcizo/package-updater/internal/master/events"
|
||||||
|
"github.com/shcizo/package-updater/internal/master/jobs"
|
||||||
|
"github.com/shcizo/package-updater/internal/master/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxBodyBytes caps the request body; a valid request is a few hundred bytes.
|
||||||
|
const maxBodyBytes = 64 << 10
|
||||||
|
|
||||||
|
// defaultPollEvery is how often pending jobs are re-read from the store when
|
||||||
|
// no broker signal arrives (signals to a slow subscriber can be dropped).
|
||||||
|
const defaultPollEvery = 5 * time.Second
|
||||||
|
|
||||||
|
// finalReadTimeout bounds the last read of pending jobs after the wait ends.
|
||||||
|
const finalReadTimeout = 2 * time.Second
|
||||||
|
|
||||||
|
// tagPattern is Docker's tag grammar. It keeps spaces, colons and "@" out of
|
||||||
|
// the ref the agent is asked to deploy.
|
||||||
|
var tagPattern = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$`)
|
||||||
|
|
||||||
|
// requestIDPattern limits a caller-supplied X-Request-ID to what is safe to
|
||||||
|
// echo in a header and write to the log.
|
||||||
|
var requestIDPattern = regexp.MustCompile(`^[A-Za-z0-9._-]{1,128}$`)
|
||||||
|
|
||||||
|
// Result statuses.
|
||||||
|
const (
|
||||||
|
statusSucceeded = "succeeded"
|
||||||
|
statusFailed = "failed"
|
||||||
|
statusTimeout = "timeout"
|
||||||
|
statusMarked = "marked"
|
||||||
|
statusBusy = "busy"
|
||||||
|
statusRefused = "refused"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store is the storage the webhook needs. *store.Store satisfies it.
|
||||||
|
type Store interface {
|
||||||
|
ServicesByRepo(ctx context.Context, repo string) ([]model.Service, error)
|
||||||
|
SetAvailable(ctx context.Context, id int64, a model.Available) error
|
||||||
|
GetJob(ctx context.Context, id int64) (model.Job, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jobs starts updates. *jobs.Service satisfies it.
|
||||||
|
type Jobs interface {
|
||||||
|
Start(ctx context.Context, svc model.Service, req jobs.StartRequest) (model.Job, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broker delivers change signals. *events.Broker satisfies it.
|
||||||
|
type Broker interface {
|
||||||
|
Subscribe() (<-chan events.Topic, func())
|
||||||
|
Publish(t events.Topic)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config holds the webhook settings.
|
||||||
|
type Config struct {
|
||||||
|
Token string // bearer token; empty disables the webhook
|
||||||
|
Timeout time.Duration // bounds the whole wait of one request
|
||||||
|
PublicURL string // base of job_url, without trailing slash
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler serves POST /update.
|
||||||
|
type Handler struct {
|
||||||
|
store Store
|
||||||
|
jobs Jobs
|
||||||
|
broker Broker
|
||||||
|
cfg Config
|
||||||
|
log *slog.Logger
|
||||||
|
|
||||||
|
now func() time.Time
|
||||||
|
pollEvery time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns the handler for POST /update. It does its own bearer-token
|
||||||
|
// check, so mount it outside the OIDC login.
|
||||||
|
func New(store Store, jobs Jobs, broker Broker, cfg Config, log *slog.Logger) *Handler {
|
||||||
|
return &Handler{store: store, jobs: jobs, broker: broker, cfg: cfg, log: log,
|
||||||
|
now: time.Now, pollEvery: defaultPollEvery}
|
||||||
|
}
|
||||||
|
|
||||||
|
type request struct {
|
||||||
|
Image string `json:"image"`
|
||||||
|
Tag string `json:"tag"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type result struct {
|
||||||
|
ServiceID int64 `json:"service_id"`
|
||||||
|
Service string `json:"service"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
JobID int64 `json:"job_id,omitempty"`
|
||||||
|
JobURL string `json:"job_url,omitempty"`
|
||||||
|
ToRef string `json:"to_ref,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
DurationMS int64 `json:"duration_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type response struct {
|
||||||
|
RequestID string `json:"request_id"`
|
||||||
|
Image string `json:"image"`
|
||||||
|
Tag string `json:"tag,omitempty"`
|
||||||
|
Matched int `json:"matched"`
|
||||||
|
Results []result `json:"results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeHTTP handles one webhook call. See the README for the contract.
|
||||||
|
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
started := h.now()
|
||||||
|
requestID := r.Header.Get("X-Request-ID")
|
||||||
|
if !requestIDPattern.MatchString(requestID) {
|
||||||
|
requestID = newRequestID()
|
||||||
|
}
|
||||||
|
w.Header().Set("X-Request-ID", requestID)
|
||||||
|
|
||||||
|
if h.cfg.Token == "" {
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "webhook disabled: UPDATER_API_KEY not set"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !h.authorized(r) {
|
||||||
|
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req request
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Image == "" {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "image is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Tag != "" && !tagPattern.MatchString(req.Tag) {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid tag"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log := logging.FromContext(logging.WithRequestID(r.Context(), requestID), h.log)
|
||||||
|
repo := imageref.Parse(req.Image).Repo
|
||||||
|
// A client that disconnects must not abort marking or starting: that
|
||||||
|
// could leave a queued job no agent knows about.
|
||||||
|
work := context.WithoutCancel(r.Context())
|
||||||
|
|
||||||
|
services, err := h.store.ServicesByRepo(work, repo)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("webhook_lookup_failed", "image", req.Image, "error", err)
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(services) == 0 {
|
||||||
|
writeJSON(w, http.StatusNotFound, map[string]string{
|
||||||
|
"error": "no service uses image " + repo, "request_id": requestID})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe before any job exists so no completion signal is missed.
|
||||||
|
sub, cancel := h.broker.Subscribe()
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
resp := response{RequestID: requestID, Image: req.Image, Tag: req.Tag, Matched: len(services)}
|
||||||
|
pending := map[int]int64{} // index into resp.Results -> job ID
|
||||||
|
for _, svc := range services {
|
||||||
|
res, job := h.handle(work, svc, req.Tag, started, log)
|
||||||
|
resp.Results = append(resp.Results, res)
|
||||||
|
if job != nil {
|
||||||
|
pending[len(resp.Results)-1] = job.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.broker.Publish(events.Services)
|
||||||
|
|
||||||
|
h.wait(r.Context(), sub, &resp, pending, started, log)
|
||||||
|
|
||||||
|
status := http.StatusOK
|
||||||
|
counts := map[string]int{}
|
||||||
|
for _, res := range resp.Results {
|
||||||
|
counts[res.Status]++
|
||||||
|
if res.Status == statusFailed || res.Status == statusTimeout {
|
||||||
|
status = http.StatusInternalServerError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Info("webhook_request", "image", req.Image, "tag", req.Tag, "matched", resp.Matched,
|
||||||
|
"succeeded", counts[statusSucceeded], "failed", counts[statusFailed], "timeout", counts[statusTimeout],
|
||||||
|
"marked", counts[statusMarked], "busy", counts[statusBusy], "refused", counts[statusRefused])
|
||||||
|
writeJSON(w, status, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle records the available ref on svc and, in auto mode, starts an update.
|
||||||
|
// It returns the result and, if the job is still active, that job.
|
||||||
|
func (h *Handler) handle(ctx context.Context, svc model.Service, tag string, started time.Time, log *slog.Logger) (result, *model.Job) {
|
||||||
|
res := result{ServiceID: svc.ID, Service: svc.DisplayName(), Kind: string(svc.Kind), Mode: svc.UpdateMode}
|
||||||
|
|
||||||
|
// The ref is built from the service's own repo, never from the request
|
||||||
|
// string, so a request cannot point a service at another registry.
|
||||||
|
var ref string
|
||||||
|
if tag != "" {
|
||||||
|
ref = svc.ImageRepo + ":" + tag
|
||||||
|
} else {
|
||||||
|
ref = jobs.FollowedRef(svc.ActualRef)
|
||||||
|
}
|
||||||
|
if ref == "" {
|
||||||
|
return h.finish(res, statusRefused, "no tag given and the service follows no tag", started), nil
|
||||||
|
}
|
||||||
|
res.ToRef = ref
|
||||||
|
err := h.store.SetAvailable(ctx, svc.ID, model.Available{Ref: ref, Source: model.SourceWebhook, At: h.now()})
|
||||||
|
if err != nil {
|
||||||
|
log.Error("webhook_set_available_failed", "service_id", svc.ID, "error", err)
|
||||||
|
return h.finish(res, statusFailed, "could not record available version", started), nil
|
||||||
|
}
|
||||||
|
if svc.UpdateMode != model.ModeAuto {
|
||||||
|
return h.finish(res, statusMarked, "", started), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
job, err := h.jobs.Start(ctx, svc, jobs.StartRequest{Trigger: model.TriggerWebhook, Ref: ref})
|
||||||
|
var notUpdatable *model.NotUpdatableError
|
||||||
|
switch {
|
||||||
|
case errors.As(err, ¬Updatable):
|
||||||
|
return h.finish(res, statusRefused, notUpdatable.Reason, started), nil
|
||||||
|
case errors.Is(err, model.ErrJobActive):
|
||||||
|
h.setJob(&res, job)
|
||||||
|
return h.finish(res, statusBusy, err.Error(), started), nil
|
||||||
|
case err != nil:
|
||||||
|
log.Error("webhook_start_failed", "service_id", svc.ID, "error", err)
|
||||||
|
return h.finish(res, statusFailed, "could not start job", started), nil
|
||||||
|
}
|
||||||
|
h.setJob(&res, job)
|
||||||
|
if job.Active() {
|
||||||
|
return res, &job
|
||||||
|
}
|
||||||
|
h.resolveJob(&res, job, started)
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) setJob(res *result, job model.Job) {
|
||||||
|
res.JobID = job.ID
|
||||||
|
res.JobURL = h.cfg.PublicURL + "/jobs/" + strconv.FormatInt(job.ID, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) finish(res result, status, msg string, started time.Time) result {
|
||||||
|
res.Status, res.Error = status, msg
|
||||||
|
res.DurationMS = h.now().Sub(started).Milliseconds()
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveJob sets the result from a job that is no longer active.
|
||||||
|
func (h *Handler) resolveJob(res *result, job model.Job, started time.Time) {
|
||||||
|
if job.Status == model.JobSucceeded {
|
||||||
|
*res = h.finish(*res, statusSucceeded, "", started)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
*res = h.finish(*res, statusFailed, job.Error, started)
|
||||||
|
}
|
||||||
|
|
||||||
|
// wait blocks until every pending job has finished, the timeout passes or
|
||||||
|
// the client goes away. Jobs still pending then get status "timeout"; the
|
||||||
|
// master never cancels or fails them.
|
||||||
|
func (h *Handler) wait(parent context.Context, sub <-chan events.Topic, resp *response,
|
||||||
|
pending map[int]int64, started time.Time, log *slog.Logger) {
|
||||||
|
if len(pending) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(parent, h.cfg.Timeout)
|
||||||
|
defer cancel()
|
||||||
|
ticker := time.NewTicker(h.pollEvery)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for len(pending) > 0 {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
// A job may have finished while the timer fired: look once more,
|
||||||
|
// on a context that outlives the request.
|
||||||
|
final, stop := context.WithTimeout(context.WithoutCancel(parent), finalReadTimeout)
|
||||||
|
h.readPending(final, resp, pending, started, log)
|
||||||
|
stop()
|
||||||
|
for i := range pending {
|
||||||
|
resp.Results[i] = h.finish(resp.Results[i], statusTimeout, "still running when the request timed out", started)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
case t := <-sub:
|
||||||
|
if t != events.Jobs {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
case <-ticker.C:
|
||||||
|
}
|
||||||
|
h.readPending(ctx, resp, pending, started, log)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readPending re-reads every pending job and resolves the ones that are done.
|
||||||
|
func (h *Handler) readPending(ctx context.Context, resp *response, pending map[int]int64,
|
||||||
|
started time.Time, log *slog.Logger) {
|
||||||
|
for i, id := range pending {
|
||||||
|
job, err := h.store.GetJob(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn("webhook_get_job_failed", "job_id", id, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !job.Active() {
|
||||||
|
h.resolveJob(&resp.Results[i], job, started)
|
||||||
|
delete(pending, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// authorized reports whether the request carries the configured bearer token.
|
||||||
|
// Both sides are hashed so the comparison takes the same time whatever the
|
||||||
|
// length of the supplied token.
|
||||||
|
func (h *Handler) authorized(r *http.Request) bool {
|
||||||
|
const prefix = "Bearer "
|
||||||
|
got, ok := strings.CutPrefix(r.Header.Get("Authorization"), prefix)
|
||||||
|
if !ok || got == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
a, b := sha256.Sum256([]byte(got)), sha256.Sum256([]byte(h.cfg.Token))
|
||||||
|
return subtle.ConstantTimeCompare(a[:], b[:]) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newRequestID returns a random 128-bit hex ID.
|
||||||
|
func newRequestID() string {
|
||||||
|
var b [16]byte
|
||||||
|
_, _ = rand.Read(b[:])
|
||||||
|
return hex.EncodeToString(b[:])
|
||||||
|
}
|
||||||
@@ -0,0 +1,517 @@
|
|||||||
|
package webhook
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/shcizo/package-updater/internal/master/events"
|
||||||
|
"github.com/shcizo/package-updater/internal/master/jobs"
|
||||||
|
"github.com/shcizo/package-updater/internal/master/model"
|
||||||
|
"github.com/shcizo/package-updater/internal/protocol"
|
||||||
|
)
|
||||||
|
|
||||||
|
const token = "s3cret"
|
||||||
|
|
||||||
|
type availCall struct {
|
||||||
|
id int64
|
||||||
|
a model.Available
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
services []model.Service // returned for any repo that equals their ImageRepo
|
||||||
|
jobs map[int64]model.Job
|
||||||
|
avail []availCall
|
||||||
|
repos []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) ServicesByRepo(_ context.Context, repo string) ([]model.Service, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.repos = append(f.repos, repo)
|
||||||
|
var out []model.Service
|
||||||
|
for _, s := range f.services {
|
||||||
|
if s.ImageRepo == repo {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) SetAvailable(_ context.Context, id int64, a model.Available) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.avail = append(f.avail, availCall{id, a})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) GetJob(_ context.Context, id int64) (model.Job, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return f.jobs[id], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) setJob(j model.Job) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.jobs[j.ID] = j
|
||||||
|
}
|
||||||
|
|
||||||
|
type startCall struct {
|
||||||
|
svc model.Service
|
||||||
|
req jobs.StartRequest
|
||||||
|
ctx context.Context
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeJobs hands out job IDs and applies per-service behaviour.
|
||||||
|
type fakeJobs struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
store *fakeStore
|
||||||
|
calls []startCall
|
||||||
|
errFor map[int64]error // returned together with errJob[id] if set
|
||||||
|
jobFor map[int64]model.Job // status of the job returned by Start
|
||||||
|
next int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeJobs) Start(ctx context.Context, svc model.Service, req jobs.StartRequest) (model.Job, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.calls = append(f.calls, startCall{svc, req, ctx})
|
||||||
|
if err, ok := f.errFor[svc.ID]; ok {
|
||||||
|
return f.jobFor[svc.ID], err
|
||||||
|
}
|
||||||
|
f.next++
|
||||||
|
j := model.Job{ID: f.next, ServiceID: svc.ID, Status: model.JobQueued, Trigger: req.Trigger, ToRef: req.Ref}
|
||||||
|
if o, ok := f.jobFor[svc.ID]; ok {
|
||||||
|
o.ID = j.ID
|
||||||
|
j = o
|
||||||
|
}
|
||||||
|
f.store.mu.Lock()
|
||||||
|
f.store.jobs[j.ID] = j
|
||||||
|
f.store.mu.Unlock()
|
||||||
|
return j, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeJobs) startCalls() []startCall {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return append([]startCall(nil), f.calls...)
|
||||||
|
}
|
||||||
|
|
||||||
|
type env struct {
|
||||||
|
store *fakeStore
|
||||||
|
jobs *fakeJobs
|
||||||
|
broker *events.Broker
|
||||||
|
h *Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
func newEnv(t *testing.T, services ...model.Service) *env {
|
||||||
|
t.Helper()
|
||||||
|
st := &fakeStore{services: services, jobs: map[int64]model.Job{}}
|
||||||
|
fj := &fakeJobs{store: st, errFor: map[int64]error{}, jobFor: map[int64]model.Job{}}
|
||||||
|
b := events.NewBroker()
|
||||||
|
h := New(st, fj, b, Config{Token: token, Timeout: 2 * time.Second, PublicURL: "https://updater.example.com"},
|
||||||
|
slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
|
h.now = func() time.Time { return time.Date(2026, 10, 12, 12, 0, 0, 0, time.UTC) }
|
||||||
|
return &env{store: st, jobs: fj, broker: b, h: h}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *env) post(body string, headers ...string) *httptest.ResponseRecorder {
|
||||||
|
return e.postCtx(context.Background(), body, headers...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *env) postCtx(ctx context.Context, body string, headers ...string) *httptest.ResponseRecorder {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/update", strings.NewReader(body)).WithContext(ctx)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
for i := 0; i+1 < len(headers); i += 2 {
|
||||||
|
req.Header.Set(headers[i], headers[i+1])
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
e.h.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func decode(t *testing.T, rec *httptest.ResponseRecorder) response {
|
||||||
|
t.Helper()
|
||||||
|
var r response
|
||||||
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &r), rec.Body.String())
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func compose(id int64, mode, actualRef string) model.Service {
|
||||||
|
return model.Service{ID: id, Kind: protocol.KindCompose, ComposeProject: "slusk", ComposeService: "web" + string(rune('a'+id)),
|
||||||
|
ImageRepo: "gitea.shcizo.se/shcizo/slusk", ActualRef: actualRef, UpdateMode: mode}
|
||||||
|
}
|
||||||
|
|
||||||
|
const slusk = "gitea.shcizo.se/shcizo/slusk"
|
||||||
|
|
||||||
|
func TestAuth(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
serverKey string
|
||||||
|
header string
|
||||||
|
wantStatus int
|
||||||
|
wantBody string
|
||||||
|
}{
|
||||||
|
{"missing header", token, "", 401, `{"error":"unauthorized"}`},
|
||||||
|
{"wrong scheme", token, "Basic " + token, 401, `{"error":"unauthorized"}`},
|
||||||
|
{"wrong token", token, "Bearer nope", 401, `{"error":"unauthorized"}`},
|
||||||
|
{"empty bearer", token, "Bearer ", 401, `{"error":"unauthorized"}`},
|
||||||
|
{"key unset", "", "Bearer " + token, 503, `{"error":"webhook disabled: UPDATER_API_KEY not set"}`},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
e := newEnv(t, compose(1, model.ModeManual, slusk+":1"))
|
||||||
|
e.h.cfg.Token = tt.serverKey
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/update", strings.NewReader(`{"image":"`+slusk+`"}`))
|
||||||
|
if tt.header != "" {
|
||||||
|
req.Header.Set("Authorization", tt.header)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
e.h.ServeHTTP(rec, req)
|
||||||
|
assert.Equal(t, tt.wantStatus, rec.Code)
|
||||||
|
assert.JSONEq(t, tt.wantBody, rec.Body.String())
|
||||||
|
assert.Empty(t, e.store.repos, "nothing may be looked up before auth")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBadRequest(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name, body, wantError string
|
||||||
|
}{
|
||||||
|
{"invalid JSON", `{`, "invalid JSON body"},
|
||||||
|
{"empty body", ``, "invalid JSON body"},
|
||||||
|
{"empty image", `{"image":""}`, "image is required"},
|
||||||
|
{"missing image", `{"tag":"v1"}`, "image is required"},
|
||||||
|
{"oversized", `{"image":"` + strings.Repeat("a", 70<<10) + `"}`, "invalid JSON body"},
|
||||||
|
{"tag with digest", `{"image":"` + slusk + `","tag":"v1@sha256:abc"}`, "invalid tag"},
|
||||||
|
{"tag with space", `{"image":"` + slusk + `","tag":"v 1"}`, "invalid tag"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
e := newEnv(t, compose(1, model.ModeAuto, slusk+":1"))
|
||||||
|
rec := e.post(tt.body)
|
||||||
|
assert.Equal(t, 400, rec.Code)
|
||||||
|
assert.JSONEq(t, `{"error":"`+tt.wantError+`"}`, rec.Body.String())
|
||||||
|
assert.Empty(t, e.jobs.startCalls())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoMatch(t *testing.T) {
|
||||||
|
e := newEnv(t, compose(1, model.ModeAuto, slusk+":1"))
|
||||||
|
for _, image := range []string{"gitea.shcizo.se/shcizo/other", "gitea.shcizo.se/shcizo/Slusk"} {
|
||||||
|
rec := e.post(`{"image":"` + image + `"}`)
|
||||||
|
assert.Equal(t, 404, rec.Code, image)
|
||||||
|
assert.Contains(t, rec.Body.String(), "no service uses image "+image)
|
||||||
|
}
|
||||||
|
assert.Empty(t, e.jobs.startCalls())
|
||||||
|
assert.Empty(t, e.store.avail)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageIsNormalisedBeforeMatching(t *testing.T) {
|
||||||
|
svc := compose(1, model.ModeManual, slusk+":1")
|
||||||
|
local := compose(2, model.ModeManual, "localhost:5000/foo:v1")
|
||||||
|
local.ImageRepo = "localhost:5000/foo"
|
||||||
|
for _, tt := range []struct{ image, wantRepo string }{
|
||||||
|
{slusk, slusk},
|
||||||
|
{slusk + ":2.0", slusk},
|
||||||
|
{slusk + "@sha256:abc", slusk},
|
||||||
|
{slusk + ":2.0@sha256:abc", slusk},
|
||||||
|
{"localhost:5000/foo:v1", "localhost:5000/foo"},
|
||||||
|
} {
|
||||||
|
e := newEnv(t, svc, local)
|
||||||
|
rec := e.post(`{"image":"` + tt.image + `"}`)
|
||||||
|
assert.Equal(t, 200, rec.Code, tt.image)
|
||||||
|
assert.Equal(t, []string{tt.wantRepo}, e.store.repos, tt.image)
|
||||||
|
assert.Equal(t, 1, decode(t, rec).Matched, tt.image)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManualServiceIsMarkedNotUpdated(t *testing.T) {
|
||||||
|
e := newEnv(t, compose(1, model.ModeManual, slusk+":1.4.2@sha256:old"))
|
||||||
|
sub, cancel := e.broker.Subscribe()
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
rec := e.post(`{"image":"` + slusk + `:ignored-in-ref","tag":"1.5.0"}`)
|
||||||
|
|
||||||
|
assert.Equal(t, 200, rec.Code)
|
||||||
|
assert.Empty(t, e.jobs.startCalls())
|
||||||
|
require.Len(t, e.store.avail, 1)
|
||||||
|
assert.Equal(t, int64(1), e.store.avail[0].id)
|
||||||
|
assert.Equal(t, model.Available{Ref: slusk + ":1.5.0", Source: model.SourceWebhook, At: e.h.now()}, e.store.avail[0].a)
|
||||||
|
resp := decode(t, rec)
|
||||||
|
assert.Equal(t, 1, resp.Matched)
|
||||||
|
require.Len(t, resp.Results, 1)
|
||||||
|
r := resp.Results[0]
|
||||||
|
assert.Equal(t, statusMarked, r.Status)
|
||||||
|
assert.Equal(t, "slusk/webb", r.Service)
|
||||||
|
assert.Equal(t, "compose", r.Kind)
|
||||||
|
assert.Equal(t, "manual", r.Mode)
|
||||||
|
assert.Equal(t, slusk+":1.5.0", r.ToRef)
|
||||||
|
assert.Zero(t, r.JobID)
|
||||||
|
assert.NotEmpty(t, resp.RequestID)
|
||||||
|
assert.Equal(t, resp.RequestID, rec.Header().Get("X-Request-ID"))
|
||||||
|
assert.Equal(t, events.Services, <-sub)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestIDIsEchoed(t *testing.T) {
|
||||||
|
e := newEnv(t, compose(1, model.ModeManual, slusk+":1"))
|
||||||
|
rec := e.post(`{"image":"`+slusk+`"}`, "X-Request-ID", "ci-123")
|
||||||
|
assert.Equal(t, "ci-123", decode(t, rec).RequestID)
|
||||||
|
assert.Equal(t, "ci-123", rec.Header().Get("X-Request-ID"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAvailableRefWithoutTag(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
actualRef string
|
||||||
|
wantRef string // "" means SetAvailable must not be called
|
||||||
|
}{
|
||||||
|
{"followed tag", slusk + ":1.4.2@sha256:old", slusk + ":1.4.2"},
|
||||||
|
{"bare image follows latest", slusk, slusk + ":latest"},
|
||||||
|
{"digest only", slusk + "@sha256:old", ""},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
e := newEnv(t, compose(1, model.ModeManual, tt.actualRef))
|
||||||
|
rec := e.post(`{"image":"` + slusk + `"}`)
|
||||||
|
assert.Equal(t, 200, rec.Code)
|
||||||
|
r := decode(t, rec).Results[0]
|
||||||
|
if tt.wantRef == "" {
|
||||||
|
assert.Empty(t, e.store.avail)
|
||||||
|
assert.Equal(t, statusRefused, r.Status)
|
||||||
|
assert.Equal(t, "no tag given and the service follows no tag", r.Error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assert.Equal(t, statusMarked, r.Status)
|
||||||
|
require.Len(t, e.store.avail, 1)
|
||||||
|
assert.Equal(t, tt.wantRef, e.store.avail[0].a.Ref)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutoDigestOnlyServiceWithoutTagIsRefused(t *testing.T) {
|
||||||
|
svc := compose(1, model.ModeAuto, slusk+"@sha256:old")
|
||||||
|
e := newEnv(t, svc)
|
||||||
|
|
||||||
|
rec := e.post(`{"image":"` + slusk + `"}`)
|
||||||
|
|
||||||
|
assert.Equal(t, 200, rec.Code)
|
||||||
|
assert.Empty(t, e.store.avail)
|
||||||
|
assert.Empty(t, e.jobs.startCalls(), "no target ref: nothing to start")
|
||||||
|
r := decode(t, rec).Results[0]
|
||||||
|
assert.Equal(t, statusRefused, r.Status)
|
||||||
|
assert.Equal(t, "no tag given and the service follows no tag", r.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidTags(t *testing.T) {
|
||||||
|
for _, tag := range []string{"latest", "1.2.3-rc.1", "v1_2", strings.Repeat("a", 128)} {
|
||||||
|
e := newEnv(t, compose(1, model.ModeManual, slusk+":1"))
|
||||||
|
rec := e.post(`{"image":"` + slusk + `","tag":"` + tag + `"}`)
|
||||||
|
assert.Equal(t, 200, rec.Code, tag)
|
||||||
|
require.Len(t, e.store.avail, 1, tag)
|
||||||
|
assert.Equal(t, slusk+":"+tag, e.store.avail[0].a.Ref)
|
||||||
|
}
|
||||||
|
e := newEnv(t, compose(1, model.ModeManual, slusk+":1"))
|
||||||
|
rec := e.post(`{"image":"` + slusk + `","tag":"` + strings.Repeat("a", 129) + `"}`)
|
||||||
|
assert.Equal(t, 400, rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnsafeRequestIDIsReplaced(t *testing.T) {
|
||||||
|
for _, id := range []string{"has space", "bad/slash", strings.Repeat("a", 129)} {
|
||||||
|
e := newEnv(t, compose(1, model.ModeManual, slusk+":1"))
|
||||||
|
rec := e.post(`{"image":"`+slusk+`"}`, "X-Request-ID", id)
|
||||||
|
got := rec.Header().Get("X-Request-ID")
|
||||||
|
assert.NotEqual(t, id, got)
|
||||||
|
assert.Regexp(t, `^[0-9a-f]{32}$`, got)
|
||||||
|
}
|
||||||
|
e := newEnv(t, compose(1, model.ModeManual, slusk+":1"))
|
||||||
|
rec := e.post(`{"image":"`+slusk+`"}`, "X-Request-ID", strings.Repeat("a", 128))
|
||||||
|
assert.Equal(t, strings.Repeat("a", 128), rec.Header().Get("X-Request-ID"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJobFinishedJustBeforeTimeoutIsNotReportedAsTimeout(t *testing.T) {
|
||||||
|
e := newEnv(t, compose(1, model.ModeAuto, slusk+":1.4.2"))
|
||||||
|
e.h.cfg.Timeout = 200 * time.Millisecond
|
||||||
|
e.h.pollEvery = time.Hour // only the final read after the deadline can see the result
|
||||||
|
go func() {
|
||||||
|
assert.Eventually(t, func() bool { return len(e.jobs.startCalls()) == 1 }, time.Second, time.Millisecond)
|
||||||
|
e.store.setJob(model.Job{ID: 1, Status: model.JobSucceeded}) // no Publish
|
||||||
|
}()
|
||||||
|
|
||||||
|
rec := e.post(`{"image":"` + slusk + `"}`)
|
||||||
|
|
||||||
|
assert.Equal(t, 200, rec.Code)
|
||||||
|
assert.Equal(t, statusSucceeded, decode(t, rec).Results[0].Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutoJobTargetIsBuiltFromServiceRepo(t *testing.T) {
|
||||||
|
e := newEnv(t, compose(1, model.ModeAuto, slusk+":1.4.2@sha256:old"))
|
||||||
|
e.jobs.jobFor[1] = model.Job{Status: model.JobSucceeded}
|
||||||
|
|
||||||
|
rec := e.post(`{"image":"` + slusk + `:other","tag":"2.0.0"}`)
|
||||||
|
|
||||||
|
assert.Equal(t, 200, rec.Code)
|
||||||
|
calls := e.jobs.startCalls()
|
||||||
|
require.Len(t, calls, 1)
|
||||||
|
assert.Equal(t, slusk+":2.0.0", calls[0].req.Ref)
|
||||||
|
assert.Equal(t, model.TriggerWebhook, calls[0].req.Trigger)
|
||||||
|
assert.Empty(t, calls[0].req.RequestedBy)
|
||||||
|
require.Len(t, e.store.avail, 1)
|
||||||
|
assert.Equal(t, slusk+":2.0.0", e.store.avail[0].a.Ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutoJobSucceeded(t *testing.T) {
|
||||||
|
e := newEnv(t, compose(1, model.ModeAuto, slusk+":1.4.2"))
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
assert.Eventually(t, func() bool { return len(e.jobs.startCalls()) == 1 }, time.Second, time.Millisecond)
|
||||||
|
e.store.setJob(model.Job{ID: 1, Status: model.JobSucceeded})
|
||||||
|
e.broker.Publish(events.JobTopic(1)) // progress topic is ignored
|
||||||
|
e.broker.Publish(events.Jobs)
|
||||||
|
}()
|
||||||
|
rec := e.post(`{"image":"` + slusk + `","tag":"1.5.0"}`)
|
||||||
|
|
||||||
|
assert.Equal(t, 200, rec.Code)
|
||||||
|
r := decode(t, rec).Results[0]
|
||||||
|
assert.Equal(t, statusSucceeded, r.Status)
|
||||||
|
assert.Equal(t, int64(1), r.JobID)
|
||||||
|
assert.Equal(t, "https://updater.example.com/jobs/1", r.JobURL)
|
||||||
|
assert.Equal(t, "auto", r.Mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutoJobFailedIs500(t *testing.T) {
|
||||||
|
e := newEnv(t, compose(1, model.ModeAuto, slusk+":1.4.2"))
|
||||||
|
go func() {
|
||||||
|
assert.Eventually(t, func() bool { return len(e.jobs.startCalls()) == 1 }, time.Second, time.Millisecond)
|
||||||
|
e.store.setJob(model.Job{ID: 1, Status: model.JobFailed, Error: "pull access denied"})
|
||||||
|
e.broker.Publish(events.Jobs)
|
||||||
|
}()
|
||||||
|
|
||||||
|
rec := e.post(`{"image":"` + slusk + `"}`)
|
||||||
|
|
||||||
|
assert.Equal(t, 500, rec.Code)
|
||||||
|
r := decode(t, rec).Results[0]
|
||||||
|
assert.Equal(t, statusFailed, r.Status)
|
||||||
|
assert.Equal(t, "pull access denied", r.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJobFailedAtStartIsNotWaitedFor(t *testing.T) {
|
||||||
|
e := newEnv(t, compose(1, model.ModeAuto, slusk+":1.4.2"))
|
||||||
|
e.jobs.jobFor[1] = model.Job{Status: model.JobFailed, Error: "agent offline"}
|
||||||
|
e.h.cfg.Timeout = time.Minute
|
||||||
|
|
||||||
|
begin := time.Now()
|
||||||
|
rec := e.post(`{"image":"` + slusk + `"}`)
|
||||||
|
|
||||||
|
assert.Less(t, time.Since(begin), 5*time.Second)
|
||||||
|
assert.Equal(t, 500, rec.Code)
|
||||||
|
r := decode(t, rec).Results[0]
|
||||||
|
assert.Equal(t, statusFailed, r.Status)
|
||||||
|
assert.Equal(t, "agent offline", r.Error)
|
||||||
|
assert.NotZero(t, r.JobID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTimeout(t *testing.T) {
|
||||||
|
e := newEnv(t, compose(1, model.ModeAuto, slusk+":1.4.2"))
|
||||||
|
e.h.cfg.Timeout = 50 * time.Millisecond
|
||||||
|
|
||||||
|
rec := e.post(`{"image":"` + slusk + `"}`)
|
||||||
|
|
||||||
|
assert.Equal(t, 500, rec.Code)
|
||||||
|
r := decode(t, rec).Results[0]
|
||||||
|
assert.Equal(t, statusTimeout, r.Status)
|
||||||
|
assert.Equal(t, int64(1), r.JobID)
|
||||||
|
assert.Equal(t, model.JobQueued, e.store.jobs[1].Status, "the master must not touch a timed-out job")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDroppedSignalIsRecoveredByPolling(t *testing.T) {
|
||||||
|
e := newEnv(t, compose(1, model.ModeAuto, slusk+":1.4.2"))
|
||||||
|
e.h.pollEvery = 5 * time.Millisecond
|
||||||
|
go func() {
|
||||||
|
assert.Eventually(t, func() bool { return len(e.jobs.startCalls()) == 1 }, time.Second, time.Millisecond)
|
||||||
|
e.store.setJob(model.Job{ID: 1, Status: model.JobSucceeded}) // no Publish
|
||||||
|
}()
|
||||||
|
|
||||||
|
rec := e.post(`{"image":"` + slusk + `"}`)
|
||||||
|
|
||||||
|
assert.Equal(t, 200, rec.Code)
|
||||||
|
assert.Equal(t, statusSucceeded, decode(t, rec).Results[0].Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBusyIsNotAFailure(t *testing.T) {
|
||||||
|
e := newEnv(t, compose(1, model.ModeAuto, slusk+":1.4.2"))
|
||||||
|
e.jobs.errFor[1] = model.ErrJobActive
|
||||||
|
e.jobs.jobFor[1] = model.Job{ID: 42, Status: model.JobRunning}
|
||||||
|
e.h.cfg.Timeout = time.Minute
|
||||||
|
|
||||||
|
rec := e.post(`{"image":"` + slusk + `"}`)
|
||||||
|
|
||||||
|
assert.Equal(t, 200, rec.Code)
|
||||||
|
r := decode(t, rec).Results[0]
|
||||||
|
assert.Equal(t, statusBusy, r.Status)
|
||||||
|
assert.Equal(t, int64(42), r.JobID)
|
||||||
|
assert.Equal(t, "https://updater.example.com/jobs/42", r.JobURL)
|
||||||
|
assert.NotEmpty(t, e.store.avail, "the available ref is still recorded")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefusedIsNotAFailure(t *testing.T) {
|
||||||
|
e := newEnv(t, compose(1, model.ModeAuto, slusk+":1.4.2"))
|
||||||
|
e.jobs.errFor[1] = &model.NotUpdatableError{Reason: model.ReasonSelf}
|
||||||
|
|
||||||
|
rec := e.post(`{"image":"` + slusk + `","tag":"2"}`)
|
||||||
|
|
||||||
|
assert.Equal(t, 200, rec.Code)
|
||||||
|
r := decode(t, rec).Results[0]
|
||||||
|
assert.Equal(t, statusRefused, r.Status)
|
||||||
|
assert.Equal(t, model.ReasonSelf, r.Error)
|
||||||
|
assert.Zero(t, r.JobID)
|
||||||
|
assert.NotEmpty(t, e.store.avail)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMixedAutoAndManual(t *testing.T) {
|
||||||
|
e := newEnv(t,
|
||||||
|
compose(1, model.ModeManual, slusk+":1"),
|
||||||
|
compose(2, model.ModeAuto, slusk+":1"),
|
||||||
|
compose(3, model.ModeAuto, slusk+":1"))
|
||||||
|
e.jobs.jobFor[2] = model.Job{Status: model.JobSucceeded}
|
||||||
|
e.jobs.jobFor[3] = model.Job{Status: model.JobFailed, Error: "boom"}
|
||||||
|
|
||||||
|
rec := e.post(`{"image":"` + slusk + `","tag":"2"}`)
|
||||||
|
|
||||||
|
assert.Equal(t, 500, rec.Code)
|
||||||
|
resp := decode(t, rec)
|
||||||
|
assert.Equal(t, 3, resp.Matched)
|
||||||
|
got := []string{resp.Results[0].Status, resp.Results[1].Status, resp.Results[2].Status}
|
||||||
|
assert.Equal(t, []string{statusMarked, statusSucceeded, statusFailed}, got)
|
||||||
|
assert.Len(t, e.store.avail, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientCancelStopsWaitingButNotStarting(t *testing.T) {
|
||||||
|
e := newEnv(t, compose(1, model.ModeAuto, slusk+":1.4.2"))
|
||||||
|
e.h.cfg.Timeout = time.Minute
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel() // the client is already gone when the handler runs
|
||||||
|
|
||||||
|
begin := time.Now()
|
||||||
|
rec := e.postCtx(ctx, `{"image":"`+slusk+`"}`)
|
||||||
|
|
||||||
|
assert.Less(t, time.Since(begin), 5*time.Second)
|
||||||
|
assert.Equal(t, statusTimeout, decode(t, rec).Results[0].Status)
|
||||||
|
calls := e.jobs.startCalls()
|
||||||
|
require.Len(t, calls, 1, "the job must still be started")
|
||||||
|
assert.NoError(t, calls[0].ctx.Err(), "Start must run on a context the client cannot cancel")
|
||||||
|
}
|
||||||
@@ -16,11 +16,16 @@ import (
|
|||||||
|
|
||||||
// Version is the protocol version this build speaks. Agents send it in
|
// Version is the protocol version this build speaks. Agents send it in
|
||||||
// Hello. Phase-1 agents do not send the field, which reads as 0.
|
// Hello. Phase-1 agents do not send the field, which reads as 0.
|
||||||
const Version = 2
|
const Version = 3
|
||||||
|
|
||||||
// MinJobVersion is the lowest agent protocol version the master sends jobs to.
|
// MinJobVersion is the lowest agent protocol version the master sends jobs to.
|
||||||
const MinJobVersion = 2
|
const MinJobVersion = 2
|
||||||
|
|
||||||
|
// MinPinnedRefVersion is the lowest agent protocol version the master sends
|
||||||
|
// jobs whose Ref has a digest or no tag to. Version-2 agents refuse a Ref
|
||||||
|
// with a digest.
|
||||||
|
const MinPinnedRefVersion = 3
|
||||||
|
|
||||||
// MaxJobOutput caps the job output an agent keeps and the master stores, in
|
// MaxJobOutput caps the job output an agent keeps and the master stores, in
|
||||||
// bytes. Only the tail is kept. With JSON escaping (worst case 6 bytes per
|
// bytes. Only the tail is kept. With JSON escaping (worst case 6 bytes per
|
||||||
// byte) a JobResult carrying this much output still fits the 1 MiB read limit.
|
// byte) a JobResult carrying this much output still fits the 1 MiB read limit.
|
||||||
@@ -123,8 +128,10 @@ type Remove struct {
|
|||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// JobStart asks the agent to deploy Ref to one service. Ref is "repo:tag";
|
// JobStart asks the agent to deploy Ref to one service. Ref is "repo:tag",
|
||||||
// the agent resolves the tag's current digest and deploys "repo:tag@digest".
|
// "repo:tag@digest" or "repo@digest". For "repo:tag" the agent resolves the
|
||||||
|
// tag's current digest and deploys "repo:tag@digest"; a Ref with a digest is
|
||||||
|
// deployed as is.
|
||||||
// The agent reads the Compose working directory and config files from its
|
// The agent reads the Compose working directory and config files from its
|
||||||
// own Docker daemon, not from this message.
|
// own Docker daemon, not from this message.
|
||||||
type JobStart struct {
|
type JobStart struct {
|
||||||
|
|||||||
Reference in New Issue
Block a user