docker: an update clears "update available" without a page reload
The registry's answer was cached and served as-is, so the stack view and the machine-wide list kept calling an image behind after an update had pulled it. The cache now keeps the digest the registry named, and an image marked behind is re-checked locally on every read: once this machine holds that digest it is current, at no registry cost. The page re-reads the machine-wide answer after a stack task, a queued update, or a schedule run. Also: stack keys can no longer collide with the machine key, and the dead "/"+stack forget in the schedule runner is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d70ddb6d67
commit
ff1373e838
@@ -340,7 +340,6 @@ func (s *Server) dsRun(w http.ResponseWriter, r *http.Request) {
|
|||||||
rec.Error = err.Error()
|
rec.Error = err.Error()
|
||||||
}
|
}
|
||||||
_ = storage.WriteRun(s.cfg.DataDir(), "docker-"+id, rec)
|
_ = storage.WriteRun(s.cfg.DataDir(), "docker-"+id, rec)
|
||||||
stackUpdatesForget("/" + job.Stack)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonErr(w, 500, err.Error()+"\n"+tail(out, 2000))
|
jsonErr(w, 500, err.Error()+"\n"+tail(out, 2000))
|
||||||
return
|
return
|
||||||
|
|||||||
+43
-12
@@ -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()
|
_ = 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))
|
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))
|
jsonOK(w, 202, t.Snapshot(false))
|
||||||
}
|
}
|
||||||
@@ -755,6 +754,7 @@ type imageCheck struct {
|
|||||||
Image string `json:"image"`
|
Image string `json:"image"`
|
||||||
State string `json:"state"` // current | update | unknown
|
State string `json:"state"` // current | update | unknown
|
||||||
Detail string `json:"detail,omitempty"`
|
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 {
|
type updAnswer struct {
|
||||||
@@ -767,7 +767,41 @@ var (
|
|||||||
updCache = map[string]updAnswer{}
|
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
|
// 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.
|
// 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{}
|
res := []imageCheck{}
|
||||||
for _, img := range images {
|
for _, img := range images {
|
||||||
ic := imageCheck{Image: img, State: "unknown"}
|
ic := imageCheck{Image: img, State: "unknown"}
|
||||||
out, err := dockerRun(ctx, "image", "inspect", "-f", "{{json .RepoDigests}}", img)
|
have := localDigests(ctx, img)
|
||||||
var have []string
|
if len(have) == 0 {
|
||||||
if err != nil || json.Unmarshal(bytes.TrimSpace(out), &have) != nil || len(have) == 0 {
|
|
||||||
ic.Detail = "built or loaded here, not pulled from a registry"
|
ic.Detail = "built or loaded here, not pulled from a registry"
|
||||||
res = append(res, ic)
|
res = append(res, ic)
|
||||||
continue
|
continue
|
||||||
@@ -795,12 +828,10 @@ func checkImages(ctx context.Context, images []string) []imageCheck {
|
|||||||
res = append(res, ic)
|
res = append(res, ic)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
ic.State = "update"
|
ic.State, ic.remote = "update", digest
|
||||||
for _, h := range have {
|
if holdsDigest(have, digest) {
|
||||||
if strings.HasSuffix(h, "@"+digest) {
|
|
||||||
ic.State = "current"
|
ic.State = "current"
|
||||||
}
|
}
|
||||||
}
|
|
||||||
res = append(res, ic)
|
res = append(res, ic)
|
||||||
}
|
}
|
||||||
return res
|
return res
|
||||||
@@ -833,7 +864,7 @@ func (s *Server) machineUpdates(w http.ResponseWriter, r *http.Request) {
|
|||||||
c, ok := updCache[key]
|
c, ok := updCache[key]
|
||||||
updMu.Unlock()
|
updMu.Unlock()
|
||||||
if ok && time.Since(c.at) < 6*time.Hour && !refresh {
|
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
|
return
|
||||||
}
|
}
|
||||||
if !refresh {
|
if !refresh {
|
||||||
@@ -863,13 +894,13 @@ func (s *Server) stackUpdates(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonErr(w, 404, err.Error())
|
jsonErr(w, 404, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
key := st.Name
|
key := "stack:" + st.Name
|
||||||
updMu.Lock()
|
updMu.Lock()
|
||||||
c, ok := updCache[key]
|
c, ok := updCache[key]
|
||||||
updMu.Unlock()
|
updMu.Unlock()
|
||||||
refresh := r.URL.Query().Get("refresh") == "1"
|
refresh := r.URL.Query().Get("refresh") == "1"
|
||||||
if ok && time.Since(c.at) < 6*time.Hour && !refresh {
|
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
|
return
|
||||||
}
|
}
|
||||||
if !refresh {
|
if !refresh {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-8
@@ -414,8 +414,11 @@ export default function Docker() {
|
|||||||
const taken = useMemo(() => { const ports: Record<string, string> = {}; 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])
|
const taken = useMemo(() => { const ports: Record<string, string> = {}; 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
|
// 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.
|
// 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<MachineUpdates | null>(null)
|
const [upd, setUpd] = useState<MachineUpdates | null>(null)
|
||||||
|
const readUpd = useCallback(() => { api.machineUpdates().then(setUpd).catch(() => {}) }, [])
|
||||||
useEffect(() => { api.machineUpdates().then(setUpd).catch(() => setUpd(null)) }, [])
|
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 imgState = useMemo(() => Object.fromEntries((upd?.images ?? []).map((i) => [i.image, i.state])), [upd])
|
||||||
const behind = (upd?.images ?? []).filter((i) => i.state === 'update').length
|
const behind = (upd?.images ?? []).filter((i) => i.state === 'update').length
|
||||||
if (!d) return <div className="dash">{err ? <div className="notice">{err === 'not found' ? "This machine's Ham Lab is older than the Docker tab. Update it from Machines." : err}</div> : <Loading label="Asking Docker…" />}</div>
|
if (!d) return <div className="dash">{err ? <div className="notice">{err === 'not found' ? "This machine's Ham Lab is older than the Docker tab. Update it from Machines." : err}</div> : <Loading label="Asking Docker…" />}</div>
|
||||||
@@ -492,7 +495,7 @@ export default function Docker() {
|
|||||||
{shownTab === 'containers' && (cur ? <Detail c={cur} live={sensors} onBack={() => setSel('')} onChanged={load} /> : (sel && !selStack ? null : <div className="svc-empty muted">Select a container.</div>))}
|
{shownTab === 'containers' && (cur ? <Detail c={cur} live={sensors} onBack={() => setSel('')} onChanged={load} /> : (sel && !selStack ? null : <div className="svc-empty muted">Select a container.</div>))}
|
||||||
{making && <StackNew key={'new:' + (dup?.name ?? making)} mode={making} from={dup ?? undefined} networks={nets} taken={taken} onClose={() => { 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) }} />}
|
{making && <StackNew key={'new:' + (dup?.name ?? making)} mode={making} from={dup ?? undefined} networks={nets} taken={taken} onClose={() => { 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' && <Updates upd={upd} onChecked={setUpd} />}
|
{shownTab === 'updates' && <Updates upd={upd} onChecked={setUpd} />}
|
||||||
{shownTab === 'stacks' && (selStack ? <StackDetail key={selStack + (startTask?.name === selStack ? startTask.id : '')} name={selStack} startTask={startTask?.name === selStack ? startTask.id : undefined} onBack={() => setSel('')} onChanged={load} onDuplicate={(f) => { setDup(f); setMaking('stack') }} /> : <div className="svc-empty muted">Select a stack to edit its compose file.</div>)}
|
{shownTab === 'stacks' && (selStack ? <StackDetail key={selStack + (startTask?.name === selStack ? startTask.id : '')} name={selStack} startTask={startTask?.name === selStack ? startTask.id : undefined} onBack={() => setSel('')} onChanged={stackChanged} onDuplicate={(f) => { setDup(f); setMaking('stack') }} /> : <div className="svc-empty muted">Select a stack to edit its compose file.</div>)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -522,7 +525,7 @@ function Updates({ upd, onChecked }: { upd: MachineUpdates | null; onChecked: (u
|
|||||||
}
|
}
|
||||||
const run = async (j: UpdateJob) => {
|
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
|
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) => {
|
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
|
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.
|
// machine at once.
|
||||||
const [queue, setQueue] = useState<string[]>([])
|
const [queue, setQueue] = useState<string[]>([])
|
||||||
const [running, setRunning] = useState<{ stack: string; id: string } | null>(null)
|
const [running, setRunning] = useState<{ stack: string; id: string } | null>(null)
|
||||||
const [done, setDone] = useState<string[]>([])
|
|
||||||
const stacksFor = useCallback((img: string) => upd?.stacks?.[img] ?? [], [upd])
|
const stacksFor = useCallback((img: string) => upd?.stacks?.[img] ?? [], [upd])
|
||||||
const startNext = useCallback(async (rest: string[]) => {
|
const startNext = useCallback(async (rest: string[]) => {
|
||||||
const next = rest[0]
|
const next = rest[0]
|
||||||
@@ -548,7 +550,7 @@ function Updates({ upd, onChecked }: { upd: MachineUpdates | null; onChecked: (u
|
|||||||
const uniq = [...new Set(names)]
|
const uniq = [...new Set(names)]
|
||||||
if (uniq.length === 0) { toast('nothing here names a stack this console can deploy', 'info'); return }
|
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
|
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)))]
|
const behindStacks = [...new Set(behind.flatMap((i) => stacksFor(i.image)))]
|
||||||
return (
|
return (
|
||||||
@@ -567,9 +569,9 @@ function Updates({ upd, onChecked }: { upd: MachineUpdates | null; onChecked: (u
|
|||||||
{imgs.filter((i) => i.state !== 'current').map((i) => (
|
{imgs.filter((i) => i.state !== 'current').map((i) => (
|
||||||
<div className="row" key={i.image}>
|
<div className="row" key={i.image}>
|
||||||
<span className="k mono small">{i.image}</span>
|
<span className="k mono small">{i.image}</span>
|
||||||
<span className={i.state === 'update' ? 'warn-text' : 'muted small'}>{done.some((d) => stacksFor(i.image).includes(d)) ? <span className="good">updated just now</span> : i.state === 'update' ? 'a newer image is published' : i.detail || 'unknown'}</span>
|
<span className={i.state === 'update' ? 'warn-text' : 'muted small'}>{i.state === 'update' ? 'a newer image is published' : i.detail || 'unknown'}</span>
|
||||||
<span className="muted small">{stacksFor(i.image).join(' ')}</span>
|
<span className="muted small">{stacksFor(i.image).join(' ')}</span>
|
||||||
{i.state === 'update' && !done.some((d) => stacksFor(i.image).includes(d)) && (
|
{i.state === 'update' && (
|
||||||
<Btn className="btn" disabled={!!running || stacksFor(i.image).length === 0} title={stacksFor(i.image).length ? `pull it and recreate ${stacksFor(i.image).join(', ')}` : 'no stack here uses this image'}
|
<Btn className="btn" disabled={!!running || stacksFor(i.image).length === 0} title={stacksFor(i.image).length ? `pull it and recreate ${stacksFor(i.image).join(', ')}` : 'no stack here uses this image'}
|
||||||
onClick={() => updateStacks(stacksFor(i.image), `${i.image}`)}>Update</Btn>
|
onClick={() => updateStacks(stacksFor(i.image), `${i.image}`)}>Update</Btn>
|
||||||
)}
|
)}
|
||||||
@@ -581,8 +583,8 @@ function Updates({ upd, onChecked }: { upd: MachineUpdates | null; onChecked: (u
|
|||||||
<div style={{ marginTop: 8 }}>
|
<div style={{ marginTop: 8 }}>
|
||||||
<div className="sub-head"><span className="dname">Updating {running.stack}</span><span className="spacer" /><span className="muted small">{queue.length > 0 ? `${queue.length} to follow` : 'last one'}</span></div>
|
<div className="sub-head"><span className="dname">Updating {running.stack}</span><span className="spacer" /><span className="muted small">{queue.length > 0 ? `${queue.length} to follow` : 'last one'}</span></div>
|
||||||
<TaskOutput id={running.id} onDone={(t) => {
|
<TaskOutput id={running.id} onDone={(t) => {
|
||||||
if (t.exit_code === 0 && !t.error) setDone((d) => [...d, running.stack])
|
if (t.exit_code !== 0 || t.error) toast(`${running.stack}: update failed`, 'err')
|
||||||
else toast(`${running.stack}: update failed`, 'err')
|
api.machineUpdates().then(onChecked).catch(() => {}) // an image now held drops off the list
|
||||||
startNext(queue)
|
startNext(queue)
|
||||||
}} />
|
}} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user