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()
|
||||
}
|
||||
_ = storage.WriteRun(s.cfg.DataDir(), "docker-"+id, rec)
|
||||
stackUpdatesForget("/" + job.Stack)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error()+"\n"+tail(out, 2000))
|
||||
return
|
||||
|
||||
+44
-13
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user