Skip to content

Commit cbdc75e

Browse files
Merge: Diun Web Updater (#1)
Diun Web Updater — mobile web UI for one-click container updates
2 parents 795931c + b13314f commit cbdc75e

49 files changed

Lines changed: 13278 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.dockerignore

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
**/node_modules
2+
**/dist
3+
**/dev-dist
4+
.git
5+
.gitignore
6+
.mcp.json
7+
*.log
8+
.env
9+
.env.local
10+
data/
11+
*.db
12+
*.db-wal
13+
*.db-shm
14+
*.sqlite
15+
*.sqlite3
16+
playwright-report/
17+
test-results/
18+
README.md
19+
API_CONTRACT.md

.env.example

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Copy this file to .env and fill in real values. Never commit your real .env.
2+
3+
# Port the server listens on inside the container.
4+
PORT=5000
5+
6+
# Directory containing your docker-compose stacks. MUST be mounted at this
7+
# exact same path on the host and inside the diun-updater container (see the
8+
# comment in docker-compose.yml for why).
9+
STACKS_DIR=/opt/stacks
10+
11+
# Path to the host Docker socket, bind-mounted into the container.
12+
DOCKER_SOCKET=/var/run/docker.sock
13+
14+
# Directory where the SQLite database is stored (should be a persistent
15+
# volume).
16+
DATA_DIR=/data
17+
18+
# Password for the single admin login. Choose something strong.
19+
ADMIN_PASSWORD=change-me
20+
21+
# Secret used to sign the session cookie.
22+
# Generate one with: openssl rand -hex 32
23+
SESSION_SECRET=
24+
25+
# Bearer token Diun must send in the Authorization header when posting
26+
# webhook events to /api/diun/webhook.
27+
# Generate one with: openssl rand -hex 32
28+
DIUN_WEBHOOK_TOKEN=
29+
30+
# Session cookie lifetime, in seconds. Default is 7 days.
31+
SESSION_TTL=604800
32+
33+
# Public base URL of this app (used in logs / any absolute links).
34+
BASE_URL=http://localhost:5000

.github/workflows/ci.yml

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main, "claude/**"]
6+
pull_request:
7+
8+
jobs:
9+
server:
10+
name: Server tests
11+
runs-on: ubuntu-latest
12+
defaults:
13+
run:
14+
working-directory: server
15+
steps:
16+
- uses: actions/checkout@v4
17+
- uses: actions/setup-node@v4
18+
with:
19+
node-version: "22"
20+
cache: npm
21+
cache-dependency-path: server/package-lock.json
22+
- run: npm ci
23+
- run: npm test
24+
25+
client:
26+
name: Client build
27+
runs-on: ubuntu-latest
28+
defaults:
29+
run:
30+
working-directory: client
31+
steps:
32+
- uses: actions/checkout@v4
33+
- uses: actions/setup-node@v4
34+
with:
35+
node-version: "22"
36+
cache: npm
37+
cache-dependency-path: client/package-lock.json
38+
- run: npm ci
39+
- run: npm run build

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ data/
77
*.sqlite
88
*.sqlite3
99
*.db
10+
*.db-wal
11+
*.db-shm
1012
.DS_Store
1113
client/dev-dist/
1214
playwright-report/

API_CONTRACT.md

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
# API Contract
2+
3+
This document is the shared contract between the server and the client (and
4+
between work packages). Any change here should be coordinated across both.
5+
6+
All request/response bodies are JSON unless noted otherwise.
7+
8+
## Auth model
9+
10+
- Auth is a single shared password (`ADMIN_PASSWORD`), compared in constant
11+
time, no user accounts/database.
12+
- On successful login, the server sets a signed, httpOnly cookie named
13+
`diun_session` (`SameSite=Lax`, `Secure` when served over HTTPS,
14+
`Max-Age` = `SESSION_TTL` seconds).
15+
- Protected routes (everything except `/api/auth/login`, `/api/health`, and
16+
`/api/diun/webhook`) require a valid `diun_session` cookie. If it is
17+
missing, invalid, or expired, the server responds `401 Unauthorized` with
18+
`{ "error": "unauthorized" }`.
19+
- The Diun webhook route uses a separate auth mechanism: a static bearer
20+
token (`DIUN_WEBHOOK_TOKEN`) in the `Authorization` header. It does not
21+
use the session cookie.
22+
23+
## Endpoints
24+
25+
### `POST /api/auth/login`
26+
27+
- Auth: none.
28+
- Body: `{ "password": "string" }`
29+
- Response:
30+
- `200 { "ok": true }` + `Set-Cookie: diun_session=...` on success.
31+
- `401 { "error": "invalid_password" }` on bad password.
32+
33+
### `POST /api/auth/logout`
34+
35+
- Auth: cookie.
36+
- Body: none.
37+
- Response: `200 { "ok": true }`, clears the `diun_session` cookie.
38+
39+
### `GET /api/auth/me`
40+
41+
- Auth: cookie (optional — never errors, reports status).
42+
- Response: `200 { "authenticated": boolean }`
43+
44+
### `POST /api/diun/webhook`
45+
46+
- Auth: token — header `Authorization: Bearer <DIUN_WEBHOOK_TOKEN>`. `401`
47+
if missing/invalid.
48+
- Body: Diun webhook payload (see below).
49+
- Response: `204 No Content` on successful ingest. `400` if the payload is
50+
malformed.
51+
52+
### `GET /api/containers`
53+
54+
- Auth: cookie.
55+
- Response: `200` — array of container items (shape below).
56+
57+
### `POST /api/update/:name`
58+
59+
- Auth: cookie.
60+
- Path param: `name` — container name.
61+
- Body: none.
62+
- Response: `200 { "streamId": "string" }` — starts a pull + recreate
63+
operation for that container; use the returned `streamId` to subscribe to
64+
progress via the SSE endpoint below.
65+
- Errors: `404` if no such container; `409` if an update is already in
66+
progress for that container.
67+
68+
### `GET /api/update/:name/stream`
69+
70+
- Auth: cookie.
71+
- Path param: `name` — container name (same as used to start the update).
72+
- Response: `text/event-stream` (SSE). Events:
73+
- `data: {"type":"log","line":"..."}` — zero or more, streamed as the
74+
update runs (`docker compose pull` / `up -d` output).
75+
- `data: {"type":"result","success":boolean,"message":"..."}` — exactly
76+
one, final event; the stream closes after this.
77+
78+
### `GET /api/history`
79+
80+
- Auth: cookie.
81+
- Query params: `container` (optional, filter by container name), `limit`
82+
(default `50`), `offset` (default `0`).
83+
- Response: `200` — array of update history rows, newest first:
84+
```json
85+
[
86+
{
87+
"id": 1,
88+
"container_name": "nginx",
89+
"image": "nginx:latest",
90+
"old_digest": "sha256:...",
91+
"new_digest": "sha256:...",
92+
"status": "success",
93+
"message": "Updated successfully",
94+
"created_at": "2026-06-22 12:00:00"
95+
}
96+
]
97+
```
98+
99+
### `GET /api/history/:name`
100+
101+
- Auth: cookie.
102+
- Path param: `name` — container name.
103+
- Query params: `limit` (default `50`), `offset` (default `0`).
104+
- Response: same shape as `GET /api/history`, filtered to that container.
105+
106+
### `GET /api/pinned`
107+
108+
- Auth: cookie.
109+
- Response: `200` — array of pinned refs, e.g. `["nginx:latest", "redis:7"]`.
110+
111+
### `POST /api/pin`
112+
113+
- Auth: cookie.
114+
- Body: `{ "ref": "string" }`
115+
- Response: `200 { "ok": true }`. Idempotent.
116+
117+
### `DELETE /api/pin/:ref`
118+
119+
- Auth: cookie.
120+
- Path param: `ref` — the image ref to unpin (URL-encoded).
121+
- Response: `200 { "ok": true }`. Idempotent.
122+
123+
Note: refs passed to `POST /api/pin` and `DELETE /api/pin/:ref` are
124+
normalized server-side (via the same `normalizeRef` used for Diun events)
125+
before being stored/looked up, so e.g. raw `nginx` and
126+
`docker.io/library/nginx:latest` are equivalent and `GET /api/pinned`
127+
always returns normalized refs.
128+
129+
### `GET /api/health`
130+
131+
- Auth: none.
132+
- Response: `200 { "ok": true }`.
133+
134+
## `/api/containers` item shape
135+
136+
```json
137+
{
138+
"name": "nginx",
139+
"project": "web",
140+
"service": "nginx",
141+
"image": "nginx:latest",
142+
"currentDigest": "sha256:...",
143+
"updateAvailable": true,
144+
"availableDigest": "sha256:...",
145+
"pinned": false,
146+
"state": "running",
147+
"composeFile": "/stacks/web/docker-compose.yml",
148+
"workingDir": "/stacks/web"
149+
}
150+
```
151+
152+
Field notes:
153+
154+
- `name` — Docker container name.
155+
- `project` / `service` — derived from the `com.docker.compose.project` /
156+
`com.docker.compose.service` labels.
157+
- `image` — image ref as configured (tag, not digest).
158+
- `currentDigest` — digest of the image the running container was created
159+
from.
160+
- `updateAvailable``true` if the most recent unresolved Diun event for
161+
this image's normalized ref reports a digest different from
162+
`currentDigest`.
163+
- `availableDigest` — the digest from that unresolved event, if any (else
164+
`null`).
165+
- `pinned``true` if the image ref is in the `pinned` table (update
166+
indicator is suppressed, but manual update is still allowed).
167+
- `state` — Docker container state (`running`, `exited`, etc.).
168+
- `composeFile` / `workingDir` — derived from
169+
`com.docker.compose.project.config_files` /
170+
`com.docker.compose.project.working_dir` labels; used to run `docker
171+
compose` commands for that container.
172+
173+
## Diun webhook payload
174+
175+
Diun's webhook notifier posts a JSON body shaped roughly like:
176+
177+
```json
178+
{
179+
"status": "update",
180+
"image": "nginx:latest",
181+
"digest": "sha256:abc123...",
182+
"provider": "docker",
183+
"hub_link": "https://hub.docker.com/_/nginx",
184+
"platform": "linux/amd64",
185+
"metadata": {
186+
"hostname": "docker-host-1",
187+
"container": "nginx",
188+
"...": "additional Diun metadata fields"
189+
}
190+
}
191+
```
192+
193+
Fields we read:
194+
195+
- `status``"new"` (first time Diun sees this image) or `"update"` (a
196+
newer digest was found). Both are recorded; only `"update"` events are
197+
meaningful for the update indicator.
198+
- `image` — the image ref Diun checked, used to derive `normalized_ref`
199+
(registry/repo without tag-specific noise, used to key
200+
`update_events.normalized_ref`).
201+
- `digest` — the new digest Diun observed.
202+
- `provider` — Diun provider (`docker`, `swarm`, etc.) — stored for
203+
reference.
204+
- `hub_link` — informational link, stored for reference.
205+
- `platform` — image platform string, stored for reference.
206+
- `metadata` — passthrough object with additional Diun-provided context;
207+
stored as part of `raw_json`, not parsed individually.
208+
209+
The full raw payload is stored as `raw_json` in `update_events` for
210+
debugging/audit, regardless of which fields are explicitly parsed.

0 commit comments

Comments
 (0)