Files
dsh-web/internal/api/images_index_concurrency_test.go

138 lines
3.8 KiB
Go

package api
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
func TestImageIndexDoesNotUndoConcurrentDelete(t *testing.T) {
for _, initial := range []string{"available", "missing"} {
t.Run(initial, func(t *testing.T) {
id := "sha256:" + strings.Repeat("a", 64)
blocker := "sha256:" + strings.Repeat("b", 64)
reached, release := make(chan struct{}), make(chan struct{})
var once sync.Once
var reachedOnce sync.Once
var deleted atomic.Bool
unblock := func() { once.Do(func() { close(release) }) }
a, sid := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodDelete:
deleted.Store(true)
writeJSON(w, []any{})
case r.URL.Path == "/v1.47/images/"+id+"/json":
if deleted.Load() {
w.WriteHeader(http.StatusNotFound)
return
}
writeImageInspect(w, id, "1.2.3")
case r.URL.Path == "/v1.47/images/"+blocker+"/json":
reachedOnce.Do(func() { close(reached) })
select {
case <-release:
case <-r.Context().Done():
return
}
w.WriteHeader(http.StatusNotFound)
case r.URL.Path == "/v1.47/images/json", r.URL.Path == "/v1.47/containers/json":
writeJSON(w, []any{})
default:
t.Errorf("unexpected Docker request: %s %s", r.Method, r.URL)
w.WriteHeader(500)
}
})
defer unblock()
seedVerifiedImage(t, a, id, "1.2.3")
for _, q := range []struct {
sql string
args []any
}{
{"UPDATE users SET admin=1 WHERE id='u'", nil},
{"INSERT INTO images(image_id,version,status,built_at) VALUES(?,'1.2.3',?,'2'),(?,'2.0.0','missing','1')", []any{id, initial, blocker}},
} {
if _, err := a.s.DB.Exec(q.sql, q.args...); err != nil {
t.Fatal(err)
}
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
type outcome struct {
images []imageRecord
err error
}
done := make(chan outcome, 1)
go func() { images, err := a.imageIndex(ctx); done <- outcome{images, err} }()
select {
case <-reached:
case <-ctx.Done():
t.Fatal("index did not reach second image")
}
r := httptest.NewRequest(http.MethodDelete, "/api/images?image_id="+id, nil).WithContext(ctx)
r.AddCookie(&http.Cookie{Name: "dsh_session", Value: sid})
addTestAccess(r, sid)
w := httptest.NewRecorder()
a.images(w, r)
if w.Code != 200 {
t.Fatalf("delete: %d %s", w.Code, w.Body.String())
}
unblock()
select {
case out := <-done:
if out.err != nil {
t.Fatal(out.err)
}
matches := 0
for _, im := range out.images {
if im.ImageID == id {
matches++
}
}
if matches != 0 {
t.Errorf("returned %d records for deleted image, want 0", matches)
}
case <-ctx.Done():
t.Fatal("index did not finish")
}
var count int
if err := a.s.DB.QueryRow("SELECT count(*) FROM images WHERE image_id=?", id).Scan(&count); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Errorf("persisted %d rows for deleted image", count)
}
})
}
}
func TestImageIndexBoundsInvalidatedSnapshots(t *testing.T) {
var a *API
var calls atomic.Int32
a, _ = imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1.47/images/json" {
t.Errorf("unexpected request: %s", r.URL)
w.WriteHeader(500)
return
}
// Model a completed deletion during every unlocked observation phase.
buildQueue.Lock()
a.imageDeleteGeneration++
buildQueue.Unlock()
calls.Add(1)
writeJSON(w, []any{})
})
images, err := a.imageIndex(context.Background())
if !errors.Is(err, errImageIndexChanged) || images != nil {
t.Fatalf("images=%v err=%v; want no inventory and invalidation error", images, err)
}
if calls.Load() != 3 {
t.Fatalf("attempts=%d, want 3", calls.Load())
}
}