Skip to content

Commit cada4d2

Browse files
authored
Merge branch 'redenfire:main' into main
2 parents 75117a3 + 1be91e1 commit cada4d2

10 files changed

Lines changed: 272 additions & 210 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ If you want stable/default deployment, keep using upstream image `ghcr.io/redenf
1515
docker run -d \
1616
--name image-watch \
1717
-v /var/run/docker.sock:/var/run/docker.sock \
18-
-v image-watch-data:/data \
1918
-p 8099:8080 \
2019
ghcr.io/redenfire/docker-image-watcher:latest
2120
```
@@ -35,27 +34,22 @@ services:
3534
- "8099:8080"
3635
volumes:
3736
- /var/run/docker.sock:/var/run/docker.sock
38-
- image-watch-data:/data
3937
environment:
4038
- PORT=8080
4139
- DOCKER_SOCK=/var/run/docker.sock
42-
- AUTO_FILE=/data/auto-update.json
4340
# - CHECK_INTERVAL=10m # default: 10m
4441
# - CHECK_CONCURRENCY=5 # default: 5
4542
# - AUTO_COOLDOWN=5m # default: 5m
4643
# - AUTH_USER=admin # uncomment to enable auth
4744
# - AUTH_PASS=changeme
48-
49-
volumes:
50-
image-watch-data:
5145
```
5246

5347
## Features
5448

5549
- Lists running containers grouped by image, with per-container status
5650
- Supports OCI-compatible registries including Docker Hub, GHCR, Quay, and self-hosted registries
5751
- One-click update per container or "Update all" per image group, with live progress bars
58-
- Per-container auto-update toggle persisted to disk
52+
- Per-container auto-update via Docker label `image-watch.auto-update`
5953
- Background check every 10 minutes with 5-minute auto-update cooldown
6054
- i18n with EN/IT language toggle
6155
- Optional auth via AUTH_USER/AUTH_PASS with login page and HMAC-signed session cookies
@@ -120,7 +114,6 @@ Full reference: [`docs/project/API.md`](docs/project/API.md)
120114
|---|---|---|
121115
| `PORT` | `8080` | HTTP listen port |
122116
| `DOCKER_SOCK` | `/var/run/docker.sock` | Docker Unix socket path |
123-
| `AUTO_FILE` | `/data/auto-update.json` | Persisted auto-update state file |
124117
| `AUTH_USER` | — | Enable HTTP auth (required together with AUTH_PASS) |
125118
| `AUTH_PASS` | — | Password for HTTP auth |
126119
| `CHECK_INTERVAL` | `10m` | Background check interval |

‎docker-compose.yml‎

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,12 @@ services:
77
- "8099:8080"
88
volumes:
99
- /var/run/docker.sock:/var/run/docker.sock
10-
- image-watch-data:/data
1110
environment:
1211
- PORT=8080
1312
- DOCKER_SOCK=/var/run/docker.sock
14-
- AUTO_FILE=/data/auto-update.json
1513
# - CHECK_INTERVAL=10m # default: 10m
1614
# - CHECK_CONCURRENCY=5 # default: 5
1715
# - AUTO_COOLDOWN=5m # default: 5m
1816
# - AUTH_USER=admin # uncomment to enable auth
1917
# - AUTH_PASS=changeme
20-
# - DOCKER_REGISTRY_AUTH=username:password # uncomment to avoid Docker Hub unauthenticated pull rate limits
21-
22-
volumes:
23-
image-watch-data:
18+
# - DOCKER_REGISTRY_AUTH=username:password # uncomment to avoid Docker Hub unauthenticated pull rate limits

‎docker.go‎

Lines changed: 69 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"encoding/json"
88
"fmt"
99
"io"
10+
"log"
1011
"net"
1112
"net/http"
1213
"net/url"
@@ -18,17 +19,19 @@ import (
1819
var dockerSocket = "/var/run/docker.sock"
1920

2021
type dockerContainer struct {
21-
ID string `json:"Id"`
22-
Names []string `json:"Names"`
23-
Image string `json:"Image"`
24-
ImageID string `json:"ImageID"`
22+
ID string `json:"Id"`
23+
Names []string `json:"Names"`
24+
Image string `json:"Image"`
25+
ImageID string `json:"ImageID"`
26+
Labels map[string]string `json:"Labels"`
2527
}
2628

2729
type dockerInspect struct {
28-
ID string `json:"Id"`
29-
Name string `json:"Name"`
30-
Config json.RawMessage `json:"Config"`
31-
HostConfig json.RawMessage `json:"HostConfig"`
30+
ID string `json:"Id"`
31+
Name string `json:"Name"`
32+
Config json.RawMessage `json:"Config"`
33+
HostConfig json.RawMessage `json:"HostConfig"`
34+
NetworkSettings json.RawMessage `json:"NetworkSettings"`
3235
}
3336

3437
func dockerClient() *http.Client {
@@ -293,6 +296,30 @@ func getImageDigest(imageID string) (string, error) {
293296
return "", fmt.Errorf("no digest found")
294297
}
295298

299+
func localDigestMatches(imageID, imageTag, remoteDigest string) bool {
300+
for _, ref := range []string{imageID, imageTag} {
301+
if ref == "" {
302+
continue
303+
}
304+
resp, err := dockerAPI("GET", "/images/"+ref+"/json", nil)
305+
if err != nil {
306+
continue
307+
}
308+
var data struct {
309+
RepoDigests []string `json:"RepoDigests"`
310+
}
311+
json.NewDecoder(resp.Body).Decode(&data)
312+
resp.Body.Close()
313+
for _, d := range data.RepoDigests {
314+
_, after, ok := strings.Cut(d, "@")
315+
if ok && after == remoteDigest {
316+
return true
317+
}
318+
}
319+
}
320+
return false
321+
}
322+
296323
func resolveImageName(imageID string) string {
297324
resp, err := dockerAPI("GET", "/images/"+imageID+"/json", nil)
298325
if err != nil {
@@ -326,40 +353,31 @@ func recreateContainer(id, image string) error {
326353
createBody["Image"] = image
327354
delete(createBody, "Hostname")
328355

329-
type hostCfg struct {
330-
Binds []string `json:"Binds"`
331-
PortBindings map[string]interface{} `json:"PortBindings"`
332-
RestartPolicy map[string]interface{} `json:"RestartPolicy"`
333-
NetworkMode string `json:"NetworkMode"`
334-
Privileged bool `json:"Privileged"`
335-
ExtraHosts []string `json:"ExtraHosts"`
336-
DNS []string `json:"Dns"`
337-
CapAdd []string `json:"CapAdd"`
338-
CapDrop []string `json:"CapDrop"`
339-
Devices []interface{} `json:"Devices"`
340-
ShmSize int64 `json:"ShmSize"`
341-
Sysctls map[string]string `json:"Sysctls"`
342-
Runtime string `json:"Runtime"`
343-
GroupAdd []string `json:"GroupAdd"`
344-
IpcMode string `json:"IpcMode"`
345-
PidMode string `json:"PidMode"`
346-
UsernsMode string `json:"UsernsMode"`
347-
UTSMode string `json:"UTSMode"`
348-
}
349-
350-
var hc hostCfg
351-
if err := json.Unmarshal(inspect.HostConfig, &hc); err != nil {
356+
var hostConfig map[string]interface{}
357+
if err := json.Unmarshal(inspect.HostConfig, &hostConfig); err != nil {
352358
return fmt.Errorf("unmarshal host config: %w", err)
353359
}
354-
createBody["HostConfig"] = hc
360+
createBody["HostConfig"] = hostConfig
355361

356-
if hc.NetworkMode != "" && hc.NetworkMode != "default" && hc.NetworkMode != "bridge" {
357-
netConfig := map[string]interface{}{
358-
"EndpointsConfig": map[string]interface{}{
359-
hc.NetworkMode: map[string]interface{}{},
360-
},
362+
if inspect.NetworkSettings != nil {
363+
var netSettings struct {
364+
Networks map[string]map[string]interface{} `json:"Networks"`
365+
}
366+
if err := json.Unmarshal(inspect.NetworkSettings, &netSettings); err == nil && len(netSettings.Networks) > 0 {
367+
endpoints := make(map[string]interface{}, len(netSettings.Networks))
368+
for netName, netData := range netSettings.Networks {
369+
ep := map[string]interface{}{}
370+
for _, k := range []string{"IPAMConfig", "Aliases", "Links", "DriverOpts"} {
371+
if v, ok := netData[k]; ok {
372+
ep[k] = v
373+
}
374+
}
375+
endpoints[netName] = ep
376+
}
377+
createBody["NetworkingConfig"] = map[string]interface{}{
378+
"EndpointsConfig": endpoints,
379+
}
361380
}
362-
createBody["NetworkingConfig"] = netConfig
363381
}
364382

365383
body, err := json.Marshal(createBody)
@@ -418,9 +436,22 @@ func recreateContainer(id, image string) error {
418436
if err != nil {
419437
return fmt.Errorf("start container %s: %w", created.ID, err)
420438
}
439+
if resp != nil && resp.StatusCode >= 400 {
440+
return fmt.Errorf("start container %s: daemon returned %s", created.ID, resp.Status)
441+
}
421442
return nil
422443
}
423444

445+
func pruneUnusedImages() {
446+
resp, err := dockerAPI("POST", "/images/prune?filters="+url.QueryEscape(`{"dangling":{"true":true}}`), nil)
447+
if resp != nil {
448+
resp.Body.Close()
449+
}
450+
if err != nil {
451+
log.Printf("prune images: %v", err)
452+
}
453+
}
454+
424455
func init() {
425456
if s := os.Getenv("DOCKER_SOCK"); s != "" {
426457
dockerSocket = s

‎docs/project/API.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ Fields:
4040
- `container_name`: Docker container name without leading `/`
4141
- `local_digest`: shortened local digest or `unknown`
4242
- `status`: `uptodate`, `outdated`, or `unknown`
43-
- `auto_update`: persisted toggle state for this container
43+
- `auto_update`: read from Docker label `image-watch.auto-update` on the container
4444

4545
### `PullProgress`
4646

@@ -109,7 +109,7 @@ curl -X POST http://localhost:8099/api/images/3d4c5b6a7f8e/update
109109

110110
### `POST /api/images/{id}/auto-update`
111111

112-
Toggle auto-update for given container.
112+
Returns the current auto-update state for the given container, read from the Docker label `image-watch.auto-update`. The endpoint is read-only — enable/disable by setting the label on the container definition.
113113

114114
Success response:
115115

@@ -218,7 +218,7 @@ Common errors emitted by handlers:
218218
| Status | Body | When |
219219
|---|---|---|
220220
| `400` | `bad path: /api/images/{id}/{action}\n` | Path after `/api/images/` does not contain exactly `{id}/{action}` |
221-
| `400` | `unknown action\n` | Action is not `update`, `auto-update`, or `progress` |
221+
| `400` | `unknown action\n` | Action is not `update`, `auto-update`, or `progress` (`auto-update` is now read-only) |
222222
| `404` | `container not found\n` | Container ID is not present in current `app.images` snapshot |
223223
| `404` | `no progress\n` | No active progress snapshot for given container |
224224
| `405` | `method not allowed\n` | Wrong HTTP method used for endpoint |

‎docs/project/ARCHITECTURE.md‎

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ Runtime dependencies:
7070
### `web/index.html`
7171

7272
- Polls `/api/images` every 10 seconds
73-
- Shows container table, digest comparison, status badges, progress UI, and auto-update toggles
73+
- Shows container table, digest comparison, status badges, progress UI, and auto-update indicators (read from Docker labels)
7474
- Calls API routes (images, groups, auth) directly from browser JavaScript
7575

7676
## Key Call Chains
@@ -104,14 +104,15 @@ main.go:updateContainer()
104104
-> main.go:checkAll()
105105
```
106106

107-
### Auto-update persistence
107+
### Auto-update detection
108108

109109
```text
110-
main.go:toggleAuto()
111-
-> update app.images
112-
-> update app.autoSaved
113-
-> saveAuto()
114-
-> write AUTO_FILE JSON map
110+
main.go:checkAll()
111+
-> docker.go:listContainers()
112+
(includes Labels in dockerContainer struct)
113+
-> for each container:
114+
-> read label "image-watch.auto-update"
115+
-> set item.AutoUpdate = true if label equals "true"
115116
```
116117

117118
## Route-to-Handler Mapping
@@ -123,7 +124,7 @@ main.go:toggleAuto()
123124
| `/api/auth/status` | `GET` | inline handler | Returns JSON `{"enabled": true/false}` |
124125
| `/api/images` | `GET` | `app.handleImages` | Returns JSON array of `ImageGroup` |
125126
| `/api/images/{id}/update` | `POST` | `app.handleImageAction` | Starts async update goroutine |
126-
| `/api/images/{id}/auto-update` | `POST` | `app.handleImageAction` | Toggles persisted auto-update flag |
127+
| `/api/images/{id}/auto-update` | `POST` | `app.handleImageAction` | Returns current auto-update state from Docker label (read-only) |
127128
| `/api/images/{id}/progress` | `GET` | `app.handleImageAction` | Returns `PullProgress` for in-flight update |
128129
| `/api/groups/{image}/update` | `POST` | `app.handleGroupAction` | Starts async update for all containers of an image |
129130
| `/api/login` | `POST` | `handleLogin` | Authenticates user, returns session cookie |
@@ -132,7 +133,7 @@ main.go:toggleAuto()
132133

133134
## Concurrency Model
134135

135-
- `sync.RWMutex` protects `images`, `autoSaved`, and `cooldowns`
136+
- `sync.RWMutex` protects `images` and `cooldowns`
136137
- `sync.Map` stores per-container progress snapshots
137138
- Manual update runs in goroutine from route handler
138139
- Periodic checks run in background ticker goroutine

‎docs/project/AUTO-UPDATE.md‎

Lines changed: 18 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,36 +2,31 @@
22

33
## How It Is Enabled
44

5-
Auto-update can be toggled:
5+
Auto-update is controlled via the Docker label `image-watch.auto-update` on the container.
66

7-
- from web UI toggle in `web/index.html`
8-
- via `POST /api/images/{id}/auto-update`
7+
To enable, add the label to your container or service:
98

10-
Toggle state is returned as:
11-
12-
```json
13-
{"auto_update":true}
9+
```yaml
10+
services:
11+
my-service:
12+
labels:
13+
- "image-watch.auto-update=true"
1414
```
1515
16-
## Persistence Model
16+
The label is **declarative**: it survives every recreate (manual or automatic) because `recreateContainer()` copies existing labels from the old container to the new one during `inspect -> stop -> remove -> create -> start`.
1717

18-
State is stored as JSON map in `AUTO_FILE`.
18+
The web UI displays the current auto-update state (read from the label) but does not allow toggling — the label must be set in the container definition (e.g., `docker-compose.yml`).
1919

20-
Example:
20+
The `POST /api/images/{id}/auto-update` endpoint still returns the current state, but does not modify it.
2121

22-
```json
23-
{
24-
"3d4c5b6a7f8e": true,
25-
"9f8e7d6c5b4a": false
26-
}
27-
```
22+
## Why Labels Instead of a File
2823

29-
Implementation points:
24+
| Approach | Survives recreate? | Declarative? |
25+
|---|---|---|
26+
| `auto-update.json` (previous) | No — new container ID = lost state | No |
27+
| Docker label `image-watch.auto-update` (current) | Yes — labels are copied on recreate | Yes |
3028

31-
- `App.autoSaved` keeps in-memory map
32-
- `loadAuto()` reads file on startup
33-
- `saveAuto()` writes file after each toggle
34-
- keys use short 12-character container IDs
29+
This makes auto-update a true lifecycle feature rather than a one-shot toggle.
3530

3631
## Check Interval
3732

@@ -57,13 +52,13 @@ This avoids repeated rapid recreate cycles when a container stays reported as ou
5752
Auto-update only triggers when all conditions are true:
5853

5954
- container appears in current running container list
60-
- auto-update is enabled for that container
55+
- container has label `image-watch.auto-update=true`
6156
- computed status is `outdated`
6257
- no active cooldown (default 5 min, configurable via `AUTO_COOLDOWN`) blocks that container
6358

6459
## Concurrency and State Safety
6560

66-
- `sync.RWMutex` protects `images`, `autoSaved`, and `cooldowns`
61+
- `sync.RWMutex` protects `images` and `cooldowns`
6762
- `sync.Map` stores pull progress snapshots
6863
- route-triggered manual updates run in goroutines
6964
- cooldown tracking reduces risk of concurrent repeated auto-updates for same container

0 commit comments

Comments
 (0)