diff --git a/internal/api/dockersched.go b/internal/api/dockersched.go index 48db5cd..8fc33c1 100644 --- a/internal/api/dockersched.go +++ b/internal/api/dockersched.go @@ -340,7 +340,6 @@ func (s *Server) dsRun(w http.ResponseWriter, r *http.Request) { rec.Error = err.Error() } _ = storage.WriteRun(s.cfg.DataDir(), "docker-"+id, rec) - stackUpdatesForget("/" + job.Stack) if err != nil { jsonErr(w, 500, err.Error()+"\n"+tail(out, 2000)) return diff --git a/internal/api/stacks.go b/internal/api/stacks.go index 061ad5c..3e856e1 100644 --- a/internal/api/stacks.go +++ b/internal/api/stacks.go @@ -744,7 +744,6 @@ func (s *Server) stackAction(w http.ResponseWriter, r *http.Request) { _ = exec.CommandContext(ctx, dockerTool(), "exec", via, "pkill", "-TERM", "-f", pattern).Run() }) } - stackUpdatesForget(st.Name) log.Printf("stack %s: %s started as task %s (from %s)", st.Name, r.PathValue("action"), t.ID, clientIP(r)) jsonOK(w, 202, t.Snapshot(false)) } @@ -755,6 +754,7 @@ type imageCheck struct { Image string `json:"image"` State string `json:"state"` // current | update | unknown Detail string `json:"detail,omitempty"` + remote string // the digest the registry named, so a later pull can be recognised without asking again } type updAnswer struct { @@ -767,7 +767,41 @@ var ( updCache = map[string]updAnswer{} ) -func stackUpdatesForget(key string) { updMu.Lock(); delete(updCache, key); updMu.Unlock() } +func stackUpdatesForget(name string) { updMu.Lock(); delete(updCache, "stack:"+name); updMu.Unlock() } + +// localDigests is what this machine holds for an image: the registry digests it was pulled as. +func localDigests(ctx context.Context, img string) []string { + out, err := dockerRun(ctx, "image", "inspect", "-f", "{{json .RepoDigests}}", img) + var have []string + if err != nil || json.Unmarshal(bytes.TrimSpace(out), &have) != nil { + return nil + } + return have +} + +func holdsDigest(have []string, digest string) bool { + for _, h := range have { + if strings.HasSuffix(h, "@"+digest) { + return true + } + } + return false +} + +// settled is a cached answer brought up to date with what is here now. The registry's side +// is kept for six hours, but this machine's side changes the moment an update pulls the +// newer image — so an image marked behind is looked at again locally, which costs no +// registry request, and is current once it holds the digest the registry named. +func settled(ctx context.Context, res []imageCheck) []imageCheck { + out := make([]imageCheck, len(res)) + copy(out, res) + for i, ic := range out { + if ic.State == "update" && ic.remote != "" && holdsDigest(localDigests(ctx, ic.Image), ic.remote) { + out[i].State = "current" + } + } + return out +} // checkImages asks the registry whether each image is still the one we hold. Each image is // one registry request, which Docker Hub counts — so this is only ever called when asked for. @@ -775,9 +809,8 @@ func checkImages(ctx context.Context, images []string) []imageCheck { res := []imageCheck{} for _, img := range images { ic := imageCheck{Image: img, State: "unknown"} - out, err := dockerRun(ctx, "image", "inspect", "-f", "{{json .RepoDigests}}", img) - var have []string - if err != nil || json.Unmarshal(bytes.TrimSpace(out), &have) != nil || len(have) == 0 { + have := localDigests(ctx, img) + if len(have) == 0 { ic.Detail = "built or loaded here, not pulled from a registry" res = append(res, ic) continue @@ -795,11 +828,9 @@ func checkImages(ctx context.Context, images []string) []imageCheck { res = append(res, ic) continue } - ic.State = "update" - for _, h := range have { - if strings.HasSuffix(h, "@"+digest) { - ic.State = "current" - } + ic.State, ic.remote = "update", digest + if holdsDigest(have, digest) { + ic.State = "current" } res = append(res, ic) } @@ -833,7 +864,7 @@ func (s *Server) machineUpdates(w http.ResponseWriter, r *http.Request) { c, ok := updCache[key] updMu.Unlock() if ok && time.Since(c.at) < 6*time.Hour && !refresh { - jsonOK(w, 200, map[string]any{"images": c.res, "checked": c.at.UnixMilli(), "stacks": byImage}) + jsonOK(w, 200, map[string]any{"images": settled(ctx, c.res), "checked": c.at.UnixMilli(), "stacks": byImage}) return } if !refresh { @@ -863,13 +894,13 @@ func (s *Server) stackUpdates(w http.ResponseWriter, r *http.Request) { jsonErr(w, 404, err.Error()) return } - key := st.Name + key := "stack:" + st.Name updMu.Lock() c, ok := updCache[key] updMu.Unlock() refresh := r.URL.Query().Get("refresh") == "1" if ok && time.Since(c.at) < 6*time.Hour && !refresh { - jsonOK(w, 200, map[string]any{"images": c.res, "checked": c.at.UnixMilli()}) + jsonOK(w, 200, map[string]any{"images": settled(ctx, c.res), "checked": c.at.UnixMilli()}) return } if !refresh { diff --git a/internal/api/updates_test.go b/internal/api/updates_test.go new file mode 100644 index 0000000..9a1b3ad --- /dev/null +++ b/internal/api/updates_test.go @@ -0,0 +1,47 @@ +package api + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +// fakeDocker puts a `docker` on PATH whose `image inspect` answers with the given RepoDigests. +func fakeDocker(t *testing.T, digests string) { + t.Helper() + dir := t.TempDir() + script := "#!/bin/sh\necho '" + digests + "'\n" + if err := os.WriteFile(filepath.Join(dir, "docker"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir) +} + +// An update pulls the newer image: the cached registry answer must not keep calling it behind. +func TestSettledSeesAPulledImage(t *testing.T) { + cached := []imageCheck{ + {Image: "nginx:alpine", State: "update", remote: "sha256:new"}, + {Image: "redis:7", State: "current", remote: "sha256:r"}, + {Image: "local/thing", State: "unknown"}, + } + fakeDocker(t, `["nginx@sha256:new"]`) + got := settled(context.Background(), cached) + if got[0].State != "current" { + t.Fatalf("pulled image still marked %q", got[0].State) + } + if got[1].State != "current" || got[2].State != "unknown" { + t.Fatalf("others changed: %+v", got) + } + if cached[0].State != "update" { + t.Fatal("settled wrote through to the cache") + } +} + +func TestSettledKeepsAnImageStillBehind(t *testing.T) { + fakeDocker(t, `["nginx@sha256:old"]`) + got := settled(context.Background(), []imageCheck{{Image: "nginx:alpine", State: "update", remote: "sha256:new"}}) + if got[0].State != "update" { + t.Fatalf("image not yet pulled marked %q", got[0].State) + } +} diff --git a/web/src/Docker.tsx b/web/src/Docker.tsx index 4113caf..b12ae64 100644 --- a/web/src/Docker.tsx +++ b/web/src/Docker.tsx @@ -414,8 +414,11 @@ export default function Docker() { const taken = useMemo(() => { const ports: Record = {}; for (const c of d?.containers ?? []) for (const m of (c.ports ?? '').matchAll(/:(\d+)->/g)) ports[m[1]] = c.name; return { names: (d?.containers ?? []).map((c) => c.name), ports } }, [d]) // What the registry last said about each image, so a container can be marked without // asking again. Only ever read from the cache here — checking is a deliberate act. + // Re-read after a stack task too: an update changes this machine's side of the answer. const [upd, setUpd] = useState(null) + const readUpd = useCallback(() => { api.machineUpdates().then(setUpd).catch(() => {}) }, []) useEffect(() => { api.machineUpdates().then(setUpd).catch(() => setUpd(null)) }, []) + const stackChanged = useCallback(() => { load(); readUpd() }, [load, readUpd]) const imgState = useMemo(() => Object.fromEntries((upd?.images ?? []).map((i) => [i.image, i.state])), [upd]) const behind = (upd?.images ?? []).filter((i) => i.state === 'update').length if (!d) return
{err ?
{err === 'not found' ? "This machine's Ham Lab is older than the Docker tab. Update it from Machines." : err}
: }
@@ -492,7 +495,7 @@ export default function Docker() { {shownTab === 'containers' && (cur ? setSel('')} onChanged={load} /> : (sel && !selStack ? null :
Select a container.
))} {making && { setMaking(''); setDup(null) }} onCreated={async (name, deploy) => { setMaking(''); setDup(null); setTab('stacks'); if (deploy) { try { setStartTask({ name, id: (await api.stackAction(name, 'deploy')).id }) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } else setStartTask(null); await load(); setSel('stack:' + name) }} />} {shownTab === 'updates' && } - {shownTab === 'stacks' && (selStack ? setSel('')} onChanged={load} onDuplicate={(f) => { setDup(f); setMaking('stack') }} /> :
Select a stack to edit its compose file.
)} + {shownTab === 'stacks' && (selStack ? setSel('')} onChanged={stackChanged} onDuplicate={(f) => { setDup(f); setMaking('stack') }} /> :
Select a stack to edit its compose file.
)} ) } @@ -522,7 +525,7 @@ function Updates({ upd, onChecked }: { upd: MachineUpdates | null; onChecked: (u } const run = async (j: UpdateJob) => { if (!await askYes(`Run “${j.name}” now? It pulls ${j.stack}'s images${j.apply ? ' and deploys them, which restarts its containers' : ', without deploying them'}.`, 'Run now')) return - try { await api.runUpdateJob(j.id); toast(`${j.name}: done`); loadJobs() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } + try { await api.runUpdateJob(j.id); toast(`${j.name}: done`); loadJobs(); api.machineUpdates().then(onChecked).catch(() => {}) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } const remove = async (j: UpdateJob) => { if (!await askYes(`Remove the schedule “${j.name}”? Nothing is updated or rolled back — the schedule simply stops.`, 'Remove')) return @@ -535,7 +538,6 @@ function Updates({ upd, onChecked }: { upd: MachineUpdates | null; onChecked: (u // machine at once. const [queue, setQueue] = useState([]) const [running, setRunning] = useState<{ stack: string; id: string } | null>(null) - const [done, setDone] = useState([]) const stacksFor = useCallback((img: string) => upd?.stacks?.[img] ?? [], [upd]) const startNext = useCallback(async (rest: string[]) => { const next = rest[0] @@ -548,7 +550,7 @@ function Updates({ upd, onChecked }: { upd: MachineUpdates | null; onChecked: (u const uniq = [...new Set(names)] if (uniq.length === 0) { toast('nothing here names a stack this console can deploy', 'info'); return } if (!await askYes(`Update ${what}?\n\nThis pulls the newer image${uniq.length > 1 ? 's' : ''} and recreates ${uniq.length === 1 ? `the ${uniq[0]} stack` : `these stacks, one at a time: ${uniq.join(', ')}`}. Their containers restart.`, 'Update')) return - setDone([]); startNext(uniq) + startNext(uniq) } const behindStacks = [...new Set(behind.flatMap((i) => stacksFor(i.image)))] return ( @@ -567,9 +569,9 @@ function Updates({ upd, onChecked }: { upd: MachineUpdates | null; onChecked: (u {imgs.filter((i) => i.state !== 'current').map((i) => (
{i.image} - {done.some((d) => stacksFor(i.image).includes(d)) ? updated just now : i.state === 'update' ? 'a newer image is published' : i.detail || 'unknown'} + {i.state === 'update' ? 'a newer image is published' : i.detail || 'unknown'} {stacksFor(i.image).join(' ')} - {i.state === 'update' && !done.some((d) => stacksFor(i.image).includes(d)) && ( + {i.state === 'update' && ( updateStacks(stacksFor(i.image), `${i.image}`)}>Update )} @@ -581,8 +583,8 @@ function Updates({ upd, onChecked }: { upd: MachineUpdates | null; onChecked: (u
Updating {running.stack}{queue.length > 0 ? `${queue.length} to follow` : 'last one'}
{ - if (t.exit_code === 0 && !t.error) setDone((d) => [...d, running.stack]) - else toast(`${running.stack}: update failed`, 'err') + if (t.exit_code !== 0 || t.error) toast(`${running.stack}: update failed`, 'err') + api.machineUpdates().then(onChecked).catch(() => {}) // an image now held drops off the list startNext(queue) }} />