389 lines
14 KiB
Go
389 lines
14 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
)
|
|
|
|
func TestImageLegacyMigrationNeverCertifies(t *testing.T) {
|
|
a, sid := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/v1.47/images/json" {
|
|
io.WriteString(w, "[]")
|
|
return
|
|
}
|
|
writeImageInspect(w, strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/v1.47/images/"), "/json"), "0.2.0-rc.1")
|
|
})
|
|
_, err := a.s.DB.Exec(`CREATE TABLE image_builds(id TEXT PRIMARY KEY,version TEXT NOT NULL,status TEXT NOT NULL,logs TEXT NOT NULL DEFAULT '',error TEXT NOT NULL DEFAULT '',image_id TEXT NOT NULL DEFAULT '',created_at TEXT NOT NULL);
|
|
CREATE UNIQUE INDEX image_builds_version ON image_builds(version) WHERE status IN ('queued','running','validating','success');
|
|
INSERT INTO image_builds(id,version,status,image_id,created_at) VALUES('legacy','0.2.0-rc.1','success','legacy-image','now'),('queued','0.2.0-rc.2','queued','','now');
|
|
CREATE TABLE images(image_id TEXT PRIMARY KEY,version TEXT NOT NULL,tag TEXT NOT NULL DEFAULT '',status TEXT NOT NULL,built_at TEXT NOT NULL);
|
|
INSERT INTO images VALUES('legacy-image','0.2.0-rc.1','','available','now');`)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
verified, err := a.verifiedImages(context.Background())
|
|
if err != nil || len(verified) != 0 {
|
|
t.Fatalf("%v %v", verified, err)
|
|
}
|
|
var recipe, platform sql.NullString
|
|
if err = a.s.DB.QueryRow("SELECT recipe_rev,platform FROM image_builds WHERE id='legacy'").Scan(&recipe, &platform); err != nil || recipe.Valid || platform.Valid {
|
|
t.Fatalf("legacy metadata inferred: %v %v %v", recipe, platform, err)
|
|
}
|
|
w := imageRequest(a.images, sid, "GET", "")
|
|
var images []imageRecord
|
|
if err = json.Unmarshal(w.Body.Bytes(), &images); err != nil || len(images) != 1 || images[0].Status != "unverified" || images[0].RecipeRev != imageRecipeRev || images[0].Platform != imagePlatform {
|
|
t.Fatal(w.Body.String(), err)
|
|
}
|
|
if err = a.s.DB.QueryRow("SELECT recipe_rev,platform FROM image_builds WHERE id='legacy'").Scan(&recipe, &platform); err != nil || recipe.Valid || platform.Valid {
|
|
t.Fatalf("GET certified legacy metadata: %v %v %v", recipe, platform, err)
|
|
}
|
|
var status string
|
|
if err = a.s.DB.QueryRow("SELECT status FROM image_builds WHERE id='queued'").Scan(&status); err != nil || status != "failed" {
|
|
t.Fatal(status, err)
|
|
}
|
|
seedVerifiedImage(t, a, "new-image", "0.2.0-rc.1")
|
|
verified, err = a.verifiedImages(context.Background())
|
|
if err != nil || len(verified) != 1 || verified["new-image"] != "0.2.0-rc.1" {
|
|
t.Fatal(verified, err)
|
|
}
|
|
}
|
|
|
|
func TestImageInspectIdentity(t *testing.T) {
|
|
for _, mode := range []string{"valid", "recipe", "wrong-key", "platform", "variant", "unknown-platform", "role", "version", "id", "missing", "unavailable"} {
|
|
t.Run(mode, func(t *testing.T) {
|
|
a, _ := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "GET" {
|
|
t.Fatal("unexpected mutation")
|
|
}
|
|
if mode == "missing" {
|
|
w.WriteHeader(404)
|
|
return
|
|
}
|
|
if mode == "unavailable" {
|
|
w.WriteHeader(500)
|
|
return
|
|
}
|
|
parts := strings.Split(imagePlatform, "/")
|
|
labels := map[string]string{"dsh-mgr.role": "runtime", "dsh-mgr.version": "0.2.0-rc.1", "dsh-mgr.recipe": imageRecipeRev}
|
|
im := map[string]any{"Id": "image", "Os": parts[0], "Architecture": parts[1], "Config": map[string]any{"Labels": labels}}
|
|
switch mode {
|
|
case "recipe":
|
|
labels["dsh-mgr.recipe"] = "old"
|
|
case "wrong-key":
|
|
delete(labels, "dsh-mgr.recipe")
|
|
labels["dsh-mgr.recipe_rev"] = imageRecipeRev
|
|
case "platform":
|
|
im["Architecture"] = "other"
|
|
case "variant":
|
|
im["Variant"] = "v7"
|
|
case "unknown-platform":
|
|
delete(im, "Architecture")
|
|
case "role":
|
|
labels["dsh-mgr.role"] = "other"
|
|
case "version":
|
|
labels["dsh-mgr.version"] = "2.0.0"
|
|
case "id":
|
|
im["Id"] = "other"
|
|
}
|
|
writeJSON(w, im)
|
|
})
|
|
ok, err := a.imageMatches(context.Background(), "image", "0.2.0-rc.1")
|
|
if ok != (mode == "valid") || (err != nil) != (mode == "unavailable") {
|
|
t.Fatal(ok, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestImageIndexLocalInventory(t *testing.T) {
|
|
for _, mode := range []string{"external", "old-recipe", "other-platform", "inspect-failure"} {
|
|
t.Run(mode, func(t *testing.T) {
|
|
var deleted atomic.Bool
|
|
a, sid := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
t.Errorf("unexpected Docker mutation: %s", r.Method)
|
|
}
|
|
if r.URL.Path == "/v1.47/images/json" {
|
|
if deleted.Load() {
|
|
io.WriteString(w, "[]")
|
|
} else {
|
|
io.WriteString(w, `[{"Id":"image","Labels":{"dsh-mgr.role":"runtime","dsh-mgr.recipe":"2","dsh-mgr.version":"9.9.9"}}]`)
|
|
}
|
|
return
|
|
}
|
|
if mode == "inspect-failure" {
|
|
w.WriteHeader(500)
|
|
return
|
|
}
|
|
if deleted.Load() {
|
|
w.WriteHeader(404)
|
|
return
|
|
}
|
|
parts := strings.Split(imagePlatform, "/")
|
|
im := inspectedImage{ID: "image", Os: parts[0], Architecture: parts[1], Created: "2026-01-01T00:00:00Z", RepoTags: []string{"dsh-web:0.2.0-rc.1"}}
|
|
im.Config.Labels = map[string]string{"dsh-mgr.role": "runtime", "dsh-mgr.version": "0.2.0-rc.1", "dsh-mgr.recipe": imageRecipeRev}
|
|
if mode == "old-recipe" {
|
|
im.Config.Labels["dsh-mgr.recipe"] = "1"
|
|
}
|
|
if mode == "other-platform" {
|
|
im.Architecture, im.Variant = "arm", "v7"
|
|
}
|
|
writeJSON(w, im)
|
|
})
|
|
if mode != "external" {
|
|
seedVerifiedImage(t, a, "image", "0.2.0-rc.1")
|
|
}
|
|
if mode == "old-recipe" {
|
|
if _, err := a.s.DB.Exec("UPDATE image_builds SET recipe_rev='1'"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
w := imageRequest(a.images, sid, "GET", "")
|
|
if mode == "inspect-failure" {
|
|
var status string
|
|
if err := a.s.DB.QueryRow("SELECT status FROM image_builds WHERE image_id='image'").Scan(&status); err != nil || status != "success" || w.Code != 502 {
|
|
t.Fatal(status, w.Code, err)
|
|
}
|
|
return
|
|
}
|
|
wantStatus := "unverified"
|
|
if mode == "old-recipe" {
|
|
wantStatus = "existing_only"
|
|
}
|
|
var images []imageRecord
|
|
if err := json.Unmarshal(w.Body.Bytes(), &images); err != nil || w.Code != 200 || len(images) != 1 || images[0].Status != wantStatus || images[0].Version != "0.2.0-rc.1" || images[0].Created != "2026-01-01T00:00:00Z" || images[0].Tag != "dsh-web:0.2.0-rc.1" {
|
|
t.Fatal(w.Body.String(), err)
|
|
}
|
|
if mode == "other-platform" && images[0].Platform != strings.Split(imagePlatform, "/")[0]+"/arm/v7" {
|
|
t.Fatal(images[0].Platform)
|
|
}
|
|
if mode == "old-recipe" && images[0].RecipeRev != "1" {
|
|
t.Fatal(images[0].RecipeRev)
|
|
}
|
|
if w := imageRequest(a.instances, sid, "POST", `{"name":"rejected","image_id":"image"}`); w.Code != 400 {
|
|
t.Fatal(w.Code, w.Body.String())
|
|
}
|
|
deleted.Store(true)
|
|
w = imageRequest(a.images, sid, "GET", "")
|
|
if err := json.Unmarshal(w.Body.Bytes(), &images); err != nil || w.Code != 200 || len(images) != 0 {
|
|
t.Fatal(w.Body.String(), err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestImageIndexUsageMissingAndDockerFailure(t *testing.T) {
|
|
var mode atomic.Int32
|
|
a, sid := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "GET" {
|
|
t.Error("unexpected mutation")
|
|
}
|
|
if r.URL.Path == "/v1.47/images/json" && mode.Load() != 1 {
|
|
io.WriteString(w, "[]")
|
|
return
|
|
}
|
|
switch mode.Load() {
|
|
case 1:
|
|
w.WriteHeader(500)
|
|
case 2:
|
|
w.WriteHeader(404)
|
|
default:
|
|
writeImageInspect(w, "image", "0.2.0-rc.1")
|
|
}
|
|
})
|
|
seedVerifiedImage(t, a, "image", "0.2.0-rc.1")
|
|
if _, err := a.s.DB.Exec(`INSERT INTO instances(id,uid,name,image_id,status,created) VALUES('one','u','one','image','running',0),('two','other-user','two','image','stopped',0),('three','u','three','other','running',0)`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
check := func(status string, count int) {
|
|
t.Helper()
|
|
w := imageRequest(a.images, sid, "GET", "")
|
|
var images []imageRecord
|
|
if err := json.Unmarshal(w.Body.Bytes(), &images); err != nil || w.Code != 200 || len(images) != 1 || images[0].Status != status || images[0].UsageCount != count {
|
|
t.Fatal(w.Body.String(), err)
|
|
}
|
|
var storedStatus string
|
|
var storedCount int
|
|
if err := a.s.DB.QueryRow("SELECT status,usage_count FROM images WHERE image_id='image'").Scan(&storedStatus, &storedCount); err != nil || storedStatus != status || storedCount != count {
|
|
t.Fatal(storedStatus, storedCount, err)
|
|
}
|
|
}
|
|
check("available", 2)
|
|
mode.Store(1)
|
|
if w := imageRequest(a.images, sid, "GET", ""); w.Code != 502 {
|
|
t.Fatal(w.Code, w.Body.String())
|
|
}
|
|
var status string
|
|
if err := a.s.DB.QueryRow("SELECT status FROM image_builds WHERE image_id='image'").Scan(&status); err != nil || status != "success" {
|
|
t.Fatal(status, err)
|
|
}
|
|
mode.Store(2)
|
|
for refresh := 0; refresh < 3; refresh++ {
|
|
w := imageRequest(a.images, sid, "GET", "")
|
|
if w.Code != 200 || strings.TrimSpace(w.Body.String()) != "[]" {
|
|
t.Fatal(w.Code, w.Body.String())
|
|
}
|
|
var count int
|
|
if err := a.s.DB.QueryRow("SELECT count(*) FROM images").Scan(&count); err != nil || count != 0 {
|
|
t.Fatal(count, err)
|
|
}
|
|
}
|
|
var count int
|
|
if err := a.s.DB.QueryRow("SELECT count(*) FROM instances WHERE image_id='image'").Scan(&count); err != nil || count != 2 {
|
|
t.Fatal(count, err)
|
|
}
|
|
if err := a.s.DB.QueryRow("SELECT status FROM image_builds WHERE image_id='image'").Scan(&status); err != nil || status != "missing" {
|
|
t.Fatal(status, err)
|
|
}
|
|
verified, err := a.verifiedImages(context.Background())
|
|
if err != nil || len(verified) != 0 {
|
|
t.Fatal(verified, err)
|
|
}
|
|
}
|
|
|
|
func TestImageIndexFailurePreservesRows(t *testing.T) {
|
|
for _, mode := range []string{"list", "inspect", "rollback"} {
|
|
t.Run(mode, func(t *testing.T) {
|
|
a, _ := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/v1.47/images/json" {
|
|
if mode == "list" {
|
|
w.WriteHeader(500)
|
|
} else {
|
|
writeJSON(w, []any{})
|
|
}
|
|
return
|
|
}
|
|
if mode == "inspect" && strings.Contains(r.URL.Path, "second") {
|
|
w.WriteHeader(500)
|
|
return
|
|
}
|
|
w.WriteHeader(404)
|
|
})
|
|
seedVerifiedImage(t, a, "first", "1.0.0")
|
|
seedVerifiedImage(t, a, "second", "2.0.0")
|
|
if _, err := a.s.DB.Exec("INSERT INTO images(image_id,version,status,built_at) VALUES('first','1.0.0','available','2'),('second','2.0.0','available','1')"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if mode == "rollback" {
|
|
if _, err := a.s.DB.Exec("CREATE TRIGGER deny_second_delete BEFORE DELETE ON images WHEN OLD.image_id='second' BEGIN SELECT RAISE(FAIL,'unavailable'); END"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if images, err := a.imageIndex(context.Background()); err == nil || images != nil {
|
|
t.Fatalf("images=%v err=%v", images, err)
|
|
}
|
|
for _, query := range []string{"SELECT count(*) FROM images WHERE status='available'", "SELECT count(*) FROM image_builds WHERE status='success'"} {
|
|
var count int
|
|
if err := a.s.DB.QueryRow(query).Scan(&count); err != nil || count != 2 {
|
|
t.Fatal(query, count, err)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestImageIndexMissingHistoryAndBlockedReimport(t *testing.T) {
|
|
var present atomic.Bool
|
|
var inspections atomic.Int32
|
|
a, _ := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/v1.47/images/json" {
|
|
if present.Load() {
|
|
writeJSON(w, []any{map[string]any{"Id": "image", "Labels": map[string]string{"dsh-mgr.role": "runtime"}}})
|
|
} else {
|
|
writeJSON(w, []any{})
|
|
}
|
|
return
|
|
}
|
|
inspections.Add(1)
|
|
if !present.Load() {
|
|
w.WriteHeader(404)
|
|
return
|
|
}
|
|
writeImageInspect(w, "image", "0.2.0-rc.1")
|
|
})
|
|
seedVerifiedImage(t, a, "image", "0.2.0-rc.1")
|
|
if _, err := a.s.DB.Exec("UPDATE image_admissions SET state='blocked',evidence='unsafe'"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for refresh := 0; refresh < 3; refresh++ {
|
|
if images, err := a.imageIndex(context.Background()); err != nil || len(images) != 0 {
|
|
t.Fatal(images, err)
|
|
}
|
|
}
|
|
if inspections.Load() != 1 {
|
|
t.Fatalf("missing history caused %d inspections", inspections.Load())
|
|
}
|
|
present.Store(true)
|
|
images, err := a.imageIndex(context.Background())
|
|
if err != nil || len(images) != 1 || images[0].Status != "blocked" || images[0].Selectable || images[0].AdmissionReason != "unsafe" {
|
|
t.Fatal(images, err)
|
|
}
|
|
var status string
|
|
if err := a.s.DB.QueryRow("SELECT status FROM image_builds WHERE image_id='image'").Scan(&status); err != nil || status != "missing" {
|
|
t.Fatal(status, err)
|
|
}
|
|
}
|
|
|
|
func TestBuildDiskPolicy(t *testing.T) {
|
|
a, _ := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) { t.Error("unexpected build") })
|
|
if err := a.checkBuildDisk(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
probeErr := errors.New("capacity probe failed")
|
|
for _, tc := range []struct {
|
|
name string
|
|
available uint64
|
|
err error
|
|
want string
|
|
}{
|
|
{"insufficient", minimumBuildDiskBytes - 1, nil, "insufficient"},
|
|
{"exact", minimumBuildDiskBytes, nil, ""},
|
|
{"probe-error", 0, probeErr, "unavailable"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
buildDiskAvailable = func(path string) (uint64, error) {
|
|
if path != os.Getenv("DSH_DOCKER_ROOT_VISIBLE") {
|
|
t.Fatalf("unexpected probe path %q", path)
|
|
}
|
|
return tc.available, tc.err
|
|
}
|
|
err := a.checkBuildDisk(context.Background())
|
|
if tc.want == "" && err != nil || tc.want != "" && (err == nil || !strings.Contains(err.Error(), tc.want)) {
|
|
t.Fatal(err)
|
|
}
|
|
if tc.err != nil && !errors.Is(err, tc.err) {
|
|
t.Fatalf("probe error not preserved: %v", err)
|
|
}
|
|
})
|
|
}
|
|
buildDiskAvailable = func(string) (uint64, error) {
|
|
t.Fatal("unconfirmed Docker root must not be probed")
|
|
return 0, nil
|
|
}
|
|
t.Setenv("DSH_DOCKER_ROOT_VISIBLE", "")
|
|
if err := a.checkBuildDisk(context.Background()); err == nil || !strings.Contains(err.Error(), "unknown") {
|
|
t.Fatal(err)
|
|
}
|
|
if requireBuildDisk(minimumBuildDiskBytes-1) == nil || requireBuildDisk(minimumBuildDiskBytes) != nil {
|
|
t.Fatal("disk threshold")
|
|
}
|
|
}
|
|
|
|
func TestBuildListReconcilesMissingAndLatestTimestamp(t *testing.T) {
|
|
a, sid := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(404) })
|
|
seedVerifiedImage(t, a, "deleted", "0.2.0-rc.1")
|
|
w := imageRequest(a.builds, sid, "GET", "")
|
|
var records []buildRecord
|
|
if err := json.Unmarshal(w.Body.Bytes(), &records); err != nil || len(records) != 1 || records[0].Status != "missing" {
|
|
t.Fatal(w.Body.String())
|
|
}
|
|
}
|