refactor!: separate runtime releases from panel source

This commit is contained in:
2026-10-14 15:05:10 +08:00
parent 20635ffaec
commit a2dd6ee27d
51 changed files with 221 additions and 2017 deletions
-19
View File
@@ -14,7 +14,6 @@ import (
// Admission writes serialize with new runtime operations, including their Docker side effects.
var admissionMu sync.RWMutex
var errBuildUnsupported = errors.New("version is not supported for normal builds; administrator experimental build required")
func defaultAdmission(version, recipe, platform string) string {
if recipe == "7" && platform == "linux/amd64" {
@@ -34,25 +33,7 @@ func supportedLayout(recipe string) bool {
}
return false
}
func normalBuildAllowed(version string) bool {
return positiveAdmission(defaultAdmission(version, imageRecipeRev, imagePlatform))
}
func positiveAdmission(state string) bool { return state == "recommended" || state == "compatible" }
func (a *API) compatibility(w http.ResponseWriter, r *http.Request) {
if _, _, err := a.user(r); err != nil {
fail(w, 401, "authentication required")
return
}
if r.Method != http.MethodGet {
fail(w, 405, "method not allowed")
return
}
entries := []map[string]any{}
for _, v := range []string{"0.2.0-rc.1", "0.2.0-rc.2"} {
entries = append(entries, map[string]any{"version": v, "recipe_rev": imageRecipeRev, "platform": imagePlatform, "state": defaultAdmission(v, imageRecipeRev, imagePlatform), "build_allowed": normalBuildAllowed(v), "reason": "only linux/amd64 recipe 7 is built-in; successful exact image verification still required"})
}
writeJSON(w, map[string]any{"recipe_rev": imageRecipeRev, "platform": imagePlatform, "entries": entries, "unknown": map[string]any{"state": "existing_only", "build_allowed": false, "reason": "not accepted; administrator experimental build and explicit admission required"}, "experimental_admin_only": true})
}
func (a *API) evaluateImage(ctx context.Context, id string) (imageRecord, error) {
im := imageRecord{ID: id, ImageID: id, Status: "missing", Admission: "existing_only", AdmissionReason: "local image missing"}
+5 -6
View File
@@ -77,16 +77,15 @@ func TestAdmissionRoutesEnforceAuthorization(t *testing.T) {
a.Handler().ServeHTTP(w, r)
return w
}
if w := request("GET", "/api/compatibility", "", false, false); w.Code != 401 {
t.Fatal(w.Code, w.Body.String())
}
if w := request("GET", "/api/compatibility", "", true, false); w.Code != 200 {
t.Fatal(w.Code, w.Body.String())
for _, path := range []string{"/api/latest", "/api/compatibility", "/api/intents", "/api/intents/old"} {
if w := request("GET", path, "", true, false); w.Code != 404 {
t.Fatal(path, w.Code, w.Body.String())
}
}
if w := request("POST", "/api/images/admission", body, true, true); w.Code != 403 {
t.Fatal("nonadmin admission", w.Code, w.Body.String())
}
if w := request("POST", "/api/builds", `{"version":"9.8.7","experimental":true}`, true, true); w.Code != 403 {
if w := request("POST", "/api/builds", `{"version":"9.8.7","experimental":true}`, true, true); w.Code != 405 {
t.Fatal("nonadmin experiment", w.Code, w.Body.String())
}
if _, err = a.s.DB.Exec("UPDATE users SET admin=1 WHERE id='owner'"); err != nil {
+8 -29
View File
@@ -12,9 +12,9 @@ import (
"time"
)
func TestExperimentalBuildSuccessRequiresAcceptance(t *testing.T) {
func TestHistoricalExperimentalBuildRequiresAcceptance(t *testing.T) {
id := "sha256:" + strings.Repeat("d", 64)
a, sid := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
a, _ := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1.47/images/json":
writeJSON(w, []any{map[string]any{"Id": id, "Labels": map[string]string{"dsh-mgr.role": "runtime", "dsh-mgr.version": "9.8.7", "dsh-mgr.recipe": imageRecipeRev}}})
@@ -35,27 +35,9 @@ func TestExperimentalBuildSuccessRequiresAcceptance(t *testing.T) {
if _, err := a.s.DB.Exec("UPDATE users SET admin=1 WHERE id='u'"); err != nil {
t.Fatal(err)
}
w := imageRequest(a.builds, sid, "POST", `{"version":"9.8.7","experimental":true}`)
if w.Code != 202 {
t.Fatal(w.Code, w.Body.String())
}
var b buildRecord
if err := json.Unmarshal(w.Body.Bytes(), &b); err != nil || !b.Experimental {
t.Fatal(b, err)
}
deadline := time.Now().Add(5 * time.Second)
for {
var state string
if err := a.s.DB.QueryRow("SELECT status FROM image_builds WHERE id=?", b.ID).Scan(&state); err != nil {
t.Fatal(err)
}
if state == "success" {
break
}
if state == "failed" || time.Now().After(deadline) {
t.Fatal(state)
}
time.Sleep(10 * time.Millisecond)
seedVerifiedImage(t, a, id, "9.8.7")
if _, err := a.s.DB.Exec("DELETE FROM image_admissions; UPDATE image_builds SET experimental=1"); err != nil {
t.Fatal(err)
}
im, err := a.evaluateImage(context.Background(), id)
if err != nil || im.Selectable || im.Status != "experimental" {
@@ -63,7 +45,7 @@ func TestExperimentalBuildSuccessRequiresAcceptance(t *testing.T) {
}
}
func TestQueuedUnsupportedPlatformFailsBeforeDocker(t *testing.T) {
func TestLegacyQueuedBuildFailsBeforeDocker(t *testing.T) {
a, _ := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) { t.Error("unsupported build contacted Docker") })
buildQueue.Lock()
err := a.ensureBuilds()
@@ -80,7 +62,7 @@ func TestQueuedUnsupportedPlatformFailsBeforeDocker(t *testing.T) {
}
a.runImageBuild(b)
var state, detail string
if err := a.s.DB.QueryRow("SELECT status,error FROM image_builds WHERE id=?", b.ID).Scan(&state, &detail); err != nil || state != "failed" || !strings.Contains(detail, "experimental") {
if err := a.s.DB.QueryRow("SELECT status,error FROM image_builds WHERE id=?", b.ID).Scan(&state, &detail); err != nil || state != "failed" || !strings.Contains(detail, "Git release identity required") {
t.Fatal(state, detail, err)
}
}
@@ -238,7 +220,7 @@ func TestAdmissionExperimentalAndPreflight(t *testing.T) {
handler http.HandlerFunc
body string
want int
}{{a.builds, `{"version":"9.8.7"}`, 400}, {a.builds, `{"version":"9.8.7","experimental":true}`, 403}, {a.intents, `{"name":"unknown","version":"9.8.7"}`, 400}, {a.instances, fmt.Sprintf(`{"name":"unknown","image_id":%q}`, id), 400}} {
}{{a.builds, `{"version":"9.8.7"}`, 405}, {a.builds, `{"version":"9.8.7","experimental":true}`, 405}, {a.instances, fmt.Sprintf(`{"name":"unknown","image_id":%q}`, id), 400}} {
w := httptest.NewRecorder()
tc.handler(w, runtimeRequest("POST", "/api/test", tc.body, sid))
if w.Code != tc.want {
@@ -249,9 +231,6 @@ func TestAdmissionExperimentalAndPreflight(t *testing.T) {
if err := a.s.DB.QueryRow("SELECT count(*) FROM image_build_requests").Scan(&n); err != nil || n != 0 {
t.Fatal(n, err)
}
if err := a.s.DB.QueryRow("SELECT count(*) FROM creation_intents").Scan(&n); err != nil || n != 0 {
t.Fatal(n, err)
}
if _, err := a.s.DB.Exec("UPDATE users SET admin=1"); err != nil {
t.Fatal(err)
}
-11
View File
@@ -34,13 +34,6 @@ type API struct {
// Protected by buildQueue; invalidates image observations across deletions.
imageDeleteGeneration uint64
// Per-image admission evaluations cached for the intent poller; guarded
// by evalCacheMu and invalidated by imageDeleteGeneration (buildQueue)
// plus a short TTL so Docker observations stay fresh.
evalCacheMu sync.Mutex
evalCache map[string]evalCacheEntry
evalCacheGen uint64
}
func New(s *store.Store, root string) *API {
@@ -70,14 +63,10 @@ func (a *API) Handler() http.Handler {
m.HandleFunc("/api/instances/", a.instance)
m.HandleFunc("/api/images", a.images)
m.HandleFunc("/api/images/admission", a.imageAdmission)
m.HandleFunc("/api/compatibility", a.compatibility)
m.HandleFunc("/api/latest", a.latest)
m.HandleFunc("/api/releases/latest", a.latestRelease)
m.HandleFunc("/api/releases/build", a.releaseBuild)
m.HandleFunc("/api/builds", a.builds)
m.HandleFunc("/api/builds/", a.buildDetail)
m.HandleFunc("/api/intents", a.intents)
m.HandleFunc("/api/intents/", a.intent)
m.HandleFunc("/api/ws/", a.workspace)
m.HandleFunc("/api/users", a.users)
m.HandleFunc("/api/users/reset-password", a.resetPassword)
-97
View File
@@ -1,97 +0,0 @@
package api
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
func TestRuntimePrestart(t *testing.T) {
for _, status := range []string{"0", "23", "124", "137", "empty"} {
t.Run(status, func(t *testing.T) {
dir := t.TempDir()
if status != "empty" {
if err := os.MkdirAll(filepath.Join(dir, "profiles/web"), 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "profiles/web/package.json"), []byte("{}"), 0600); err != nil {
t.Fatal(err)
}
}
scripts := map[string]string{
"node": "#!/bin/bash\nexit 0\n",
"timeout": `#!/bin/bash
printf "timeout %s\n" "$*" >> "$TRACE"
test "$1 $2 $3" = "--signal=TERM --kill-after=5s 120s" || exit 90
shift 3
exec "$@"
`,
"dsh": `#!/bin/bash
printf "dsh %s\n" "$*" >> "$TRACE"
test "$npm_config_ignore_scripts:$npm_config_ignore_pnpmfile" = true:true || exit 91
if [ "$1" = plugin ]; then
test "$*" = "plugin --profile web install --ignore-scripts" || exit 92
exit "$PLUGIN_STATUS"
fi
exit 0
`,
"caddy": "#!/bin/bash\necho caddy >> \"$TRACE\"\nsleep 0.1\n",
}
for name, script := range scripts {
if err := os.WriteFile(filepath.Join(dir, name), []byte(script), 0700); err != nil {
t.Fatal(err)
}
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "bash", "../../image/entrypoint.sh", "web", "--no-open", "--port", "3080")
cmd.Env = append(os.Environ(), "PATH="+dir+":"+os.Getenv("PATH"), "DSH_HOME="+dir, "TRACE="+filepath.Join(dir, "trace"), "PLUGIN_STATUS="+status)
output, err := cmd.CombinedOutput()
data, readErr := os.ReadFile(filepath.Join(dir, "trace"))
if readErr != nil {
t.Fatal(readErr)
}
trace := string(data)
if status == "empty" {
if err != nil || strings.Contains(trace, "plugin") || !strings.Contains(trace, "dsh web") {
t.Fatalf("%v: %s: %s", err, trace, output)
}
return
}
if !strings.Contains(trace, "dsh plugin --profile web install --ignore-scripts") {
t.Fatal(trace)
}
if status == "0" {
if err != nil || !strings.Contains(string(output), "reconciliation complete") {
t.Fatalf("%v: %s", err, output)
}
if !strings.Contains(trace, "dsh web") {
t.Fatal(trace)
}
} else {
if err == nil || !strings.Contains(string(output), "failed (exit "+status+")") {
t.Fatalf("%v: %s", err, output)
}
if strings.Contains(trace, "caddy") || strings.Contains(trace, "dsh web") {
t.Fatalf("application started after failure: %s", trace)
}
}
})
}
}
func TestRuntimePNPMPinned(t *testing.T) {
data, err := os.ReadFile("../../image/Dockerfile")
if err != nil {
t.Fatal(err)
}
for _, required := range []string{"[email protected]", `test "$(pnpm --version)" = "10.11.0"`, "USER node", `"/usr/bin/tini", "-g"`} {
if !strings.Contains(string(data), required) {
t.Errorf("missing %s", required)
}
}
}
+10 -338
View File
@@ -1,14 +1,11 @@
package api
import (
"archive/tar"
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
imageassets "git.misaka.ren/m1saka/dsh-web/image"
"git.misaka.ren/m1saka/dsh-web/internal/store"
"io"
"net/http"
@@ -51,9 +48,6 @@ func (a *API) ensureBuilds() error {
if err != nil {
return err
}
if _, err = a.s.DB.Exec("UPDATE image_builds SET status='failed',error='version requires administrator experimental build' WHERE status='queued' AND release_id='' AND experimental=0 AND version NOT IN ('0.2.0-rc.1','0.2.0-rc.2')"); err != nil {
return err
}
if !buildQueue.initialized[a.s.DB] {
if _, err = a.s.DB.Exec("UPDATE image_builds SET status='failed',error='image template identity is unknown' WHERE status='queued' AND (recipe_rev IS NULL OR recipe_rev<>? OR platform IS NULL OR platform<>?)", imageRecipeRev, imagePlatform); err != nil {
return err
@@ -66,179 +60,20 @@ func (a *API) ensureBuilds() error {
return nil
}
func (a *API) builds(w http.ResponseWriter, r *http.Request) {
u, _, authErr := a.user(r)
_, _, authErr := a.user(r)
if authErr != nil {
fail(w, 401, "authentication required")
return
}
if r.Method != http.MethodGet && r.Method != http.MethodPost {
if r.Method != http.MethodGet {
fail(w, 405, "method not allowed")
return
}
var in struct {
Version string `json:"version"`
Experimental bool `json:"experimental"`
}
if r.Method == http.MethodPost {
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096))
dec.DisallowUnknownFields()
if dec.Decode(&in) != nil || dec.Decode(new(any)) != io.EOF || !validBuildVersion(in.Version) {
fail(w, 400, "a fixed version is required")
return
}
}
if r.Method == http.MethodGet {
a.listBuilds(w, r)
return
}
if in.Experimental && !u.Admin {
fail(w, 403, "administrator required")
return
}
b, err := a.enqueueBuild(r.Context(), u.ID, in.Version, in.Experimental)
if err != nil {
status := 502
if errors.Is(err, errBuildUnsupported) {
status = 400
}
if errors.Is(err, errBuildRateLimited) {
status = 429
w.Header().Set("Retry-After", "3600")
}
fail(w, status, sanitizeBuildLog(err.Error()))
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
writeJSON(w, b)
a.listBuilds(w, r)
}
var errBuildRateLimited = errors.New("build limit: three new tasks per user per hour")
// lookupActiveBuild returns the active or successful record for a version; it
// performs no I/O and must be called with the build queue held.
func (a *API) lookupActiveBuild(version string) (buildRecord, bool, error) {
var b buildRecord
var finished, recipe, platform sql.NullString
err := a.s.DB.QueryRow("SELECT id,version,status,logs,error,image_id,created_at,requested_by,recipe_rev,platform,finished_at,experimental FROM image_builds WHERE release_id='' AND version=? AND recipe_rev=? AND platform=? AND status IN ('queued','running','validating','success')", version, imageRecipeRev, imagePlatform).Scan(&b.ID, &b.Version, &b.Status, &b.Logs, &b.Error, &b.ImageID, &b.Created, &b.RequestedBy, &recipe, &platform, &finished, &b.Experimental)
b.RecipeRev, b.Platform, b.Finished = recipe.String, platform.String, finished.String
if errors.Is(err, sql.ErrNoRows) {
return b, false, nil
}
if err != nil {
return b, false, err
}
return b, true, nil
}
func (a *API) enqueueBuild(ctx context.Context, uid, version string, experiment ...bool) (buildRecord, error) {
experimental := len(experiment) > 0 && experiment[0]
if experimental {
u, err := a.s.UserByID(uid)
if err != nil || !u.Admin {
return buildRecord{}, errors.New("administrator required")
}
}
if !experimental && !normalBuildAllowed(version) {
return buildRecord{}, errBuildUnsupported
}
if !validBuildVersion(version) {
return buildRecord{}, errors.New("a fixed version is required")
}
buildQueue.Lock()
err := a.ensureBuilds()
var b buildRecord
var found bool
if err == nil {
b, found, err = a.lookupActiveBuild(version)
}
buildQueue.Unlock()
if err != nil {
return b, err
}
if found && !experimental && b.Experimental && b.Status != "success" {
return b, errBuildUnsupported
}
if found && b.Status != "success" {
a.startBuildWorker()
return b, nil
}
if found {
// Verifying a previously built image talks to Docker; keep that I/O
// out of the queue lock shared with instance creation and upgrades.
verify, cancel := context.WithTimeout(ctx, 15*time.Second)
id, lookupErr := a.localBuildImage(verify, b.Version, b.ImageID)
cancel()
if lookupErr != nil {
return b, lookupErr
}
if id != "" && id == b.ImageID {
if !experimental {
im, e := a.evaluateImage(ctx, id)
if e != nil {
return b, e
}
if !im.Selectable {
return b, errBuildUnsupported
}
}
a.startBuildWorker()
return b, nil
}
buildQueue.Lock()
if _, err = a.s.DB.Exec("UPDATE image_builds SET status='missing',error='image no longer available' WHERE id=? AND status='success'", b.ID); err == nil {
// Another request may have queued a replacement while the image
// was being verified; honor it instead of inserting a duplicate.
b, found, err = a.lookupActiveBuild(version)
}
if err == nil && !found {
b, err = a.insertBuild(uid, version, experimental)
}
buildQueue.Unlock()
if err != nil {
return b, err
}
if !experimental && found && b.Experimental {
return b, errBuildUnsupported
}
a.startBuildWorker()
return b, nil
}
buildQueue.Lock()
defer buildQueue.Unlock()
// Re-check after the unlocked phases: another request may have inserted.
b, found, err = a.lookupActiveBuild(version)
if err != nil {
return b, err
}
if found {
if !experimental && b.Experimental {
return b, errBuildUnsupported
}
return b, nil
}
b, err = a.insertBuild(uid, version, experimental)
if err != nil {
return b, err
}
a.startBuildWorkerLocked()
return b, nil
}
// insertBuild applies the per-user rate limit and inserts a queued record;
// call with the build queue held.
func (a *API) insertBuild(uid, version string, experiment ...bool) (buildRecord, error) {
var count int
if err := a.s.DB.QueryRow("SELECT count(*) FROM image_build_requests WHERE user_id=? AND created_at>?", uid, time.Now().Add(-time.Hour).UTC().Format(time.RFC3339Nano)).Scan(&count); err != nil {
return buildRecord{}, err
}
if count >= 3 {
return buildRecord{}, errBuildRateLimited
}
b := buildRecord{ID: store.ID(), Version: version, Status: "queued", Created: time.Now().UTC().Format(time.RFC3339Nano), RequestedBy: uid, RecipeRev: imageRecipeRev, Platform: imagePlatform}
b.Experimental = len(experiment) > 0 && experiment[0]
return b, a.insertBuildRequest(b, uid)
}
func (a *API) startBuildWorker() {
buildQueue.Lock()
defer buildQueue.Unlock()
@@ -283,7 +118,7 @@ func (a *API) startBuildWorkerLocked() {
}
buildQueue.Lock()
var b buildRecord
row := a.s.DB.QueryRow("SELECT " + buildSummaryColumns + " FROM image_builds WHERE status='queued' ORDER BY created_at,id LIMIT 1")
row := a.s.DB.QueryRow("SELECT " + buildSummaryColumns + " FROM image_builds WHERE status='queued' AND release_id<>'' ORDER BY created_at,id LIMIT 1")
err := scanBuildSummary(row, &b)
if err == nil {
_, err = a.s.DB.Exec("UPDATE image_builds SET status='running' WHERE id=?", b.ID)
@@ -307,27 +142,6 @@ func (a *API) startBuildWorkerLocked() {
}
}()
}
func imageBuildContext() (io.Reader, error) {
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
for _, name := range []string{"Dockerfile", "Caddyfile", "entrypoint.sh", "install-mobile.mjs", "install-free-search.mjs", "install-workspace.mjs", "init-profile.mjs"} {
dataBytes, err := imageassets.Files.ReadFile(name)
if err != nil {
return nil, err
}
data := string(dataBytes)
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0755, Size: int64(len(data))}); err != nil {
return nil, err
}
if _, err := tw.Write([]byte(data)); err != nil {
return nil, err
}
}
if err := tw.Close(); err != nil {
return nil, err
}
return &buf, nil
}
func (a *API) runImageBuild(b buildRecord) {
buildWorker.Lock()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
@@ -348,33 +162,17 @@ func (a *API) runImageBuild(b buildRecord) {
_, logErr = a.s.DB.Exec("UPDATE image_builds SET logs=? WHERE id=?", logs, b.ID)
}
}
tarball, err := imageBuildContext()
if b.ReleaseID == "" && !b.Experimental && !normalBuildAllowed(b.Version) {
err = errBuildUnsupported
}
if b.Experimental {
u, e := a.s.UserByID(b.RequestedBy)
if e != nil || !u.Admin {
err = errors.New("experimental build requester is not an administrator")
}
}
var id string
if b.ReleaseID != "" {
var err error
if b.ReleaseID == "" {
err = errors.New("Git release identity required")
} else {
var tarball io.Reader
tarball, err = a.releaseBuildContext(ctx, b)
}
if err == nil && b.ReleaseID == "" {
id, err = a.localBuildImage(ctx, b.Version, "")
}
if err == nil && id == "" {
_, err = fetchNPMVersion(ctx, npmBuildClient, b.Version)
if err == nil {
err = a.checkBuildDisk(ctx)
}
}
if err == nil && id == "" {
if b.ReleaseID == "" {
id, err = a.docker.Build(ctx, tarball, b.Version, "dsh-web:"+b.Version+"-"+imageRecipeRev, appendLog)
} else {
if err == nil {
id, err = a.docker.BuildRelease(ctx, tarball, b.Version, "dsh-web:release-"+b.SourceCommit, b.ReleaseID, b.SourceRepository, b.SourceCommit, appendLog)
}
}
@@ -568,38 +366,6 @@ func (a *API) images(w http.ResponseWriter, r *http.Request) {
writeJSON(w, result)
}
// verifiedImagesTTL bounds how long a cached admission evaluation is reused
// by the one-second intent poller.
var verifiedImagesTTL = 5 * time.Second
type evalCacheEntry struct {
im imageRecord
at time.Time
}
// evaluateImageCached reuses recent per-image evaluations for the intent
// poller so repeated ticks do not hammer Docker. Deletions and admission
// writes bump imageDeleteGeneration and invalidate every entry.
func (a *API) evaluateImageCached(ctx context.Context, gen uint64, id string) (imageRecord, error) {
a.evalCacheMu.Lock()
if entry, ok := a.evalCache[id]; ok && a.evalCacheGen == gen && time.Since(entry.at) < verifiedImagesTTL {
a.evalCacheMu.Unlock()
return entry.im, nil
}
a.evalCacheMu.Unlock()
im, err := a.evaluateImage(ctx, id)
if err != nil {
return im, err
}
a.evalCacheMu.Lock()
if a.evalCache == nil || a.evalCacheGen != gen {
a.evalCache, a.evalCacheGen = make(map[string]evalCacheEntry), gen
}
a.evalCache[id] = evalCacheEntry{im: im, at: time.Now()}
a.evalCacheMu.Unlock()
return im, nil
}
func (a *API) successfulImageIDs(ctx context.Context) ([]string, error) {
rows, err := a.s.DB.QueryContext(ctx, "SELECT DISTINCT image_id FROM image_builds WHERE status='success' AND image_id<>''")
if err != nil {
@@ -646,74 +412,6 @@ func (a *API) verifiedImages(ctx context.Context) (map[string]string, error) {
return result, nil
}
// verifiedImagesPoll is the intent poller's view: build rows are reread every
// tick, but per-image Docker evaluation is cached briefly so a stuck waiting
// intent cannot turn the one-second loop into Docker hammering.
func (a *API) verifiedImagesPoll(ctx context.Context) (map[string]string, error) {
buildQueue.Lock()
err := a.ensureBuilds()
gen := a.imageDeleteGeneration
buildQueue.Unlock()
if err != nil {
return nil, err
}
ids, err := a.successfulImageIDs(ctx)
if err != nil {
return nil, err
}
result := map[string]string{}
for _, id := range ids {
im, err := a.evaluateImageCached(ctx, gen, id)
if err != nil {
return nil, err
}
if im.Selectable {
result[id] = im.Version
}
}
return result, nil
}
func (a *API) latest(w http.ResponseWriter, r *http.Request) {
if _, _, err := a.user(r); err != nil {
fail(w, 401, "authentication required")
return
}
if r.Method != http.MethodGet {
fail(w, 405, "method not allowed")
return
}
v, err := fetchLatest(r.Context(), npmBuildClient)
if err != nil {
fail(w, 502, "npm version lookup failed")
return
}
writeJSON(w, map[string]any{"version": v, "checked_at": time.Now().UTC().Format(time.RFC3339), "supported": normalBuildAllowed(v), "build_allowed": normalBuildAllowed(v), "admission": defaultAdmission(v, imageRecipeRev, imagePlatform), "admission_reason": "normal builds require a built-in tested tuple; unknown versions require administrator experimental validation and admission"})
}
func fetchLatest(ctx context.Context, client *http.Client) (string, error) {
return fetchNPMVersion(ctx, client, "latest")
}
func fetchNPMVersion(ctx context.Context, client *http.Client, version string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://registry.npmjs.org/@deepseek-ai%2Fdsh/"+url.PathEscape(version), nil)
if err != nil {
return "", err
}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("npm status %d", resp.StatusCode)
}
var v struct{ Version string }
if err = json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&v); err != nil {
return "", err
}
if !validBuildVersion(v.Version) || (version != "latest" && v.Version != version) {
return "", errors.New("invalid npm version")
}
return v.Version, nil
}
func validBuildVersion(v string) bool { return len(v) <= 100 && fixedVersion.MatchString(v) }
func sanitizeBuildLog(s string) string {
s = buildURLCredentials.ReplaceAllString(s, "$1[REDACTED]@")
@@ -724,29 +422,3 @@ func sanitizeBuildLog(s string) string {
}
return s
}
func (a *API) localBuildImage(ctx context.Context, version, preferred string) (string, error) {
filters, _ := json.Marshal(map[string][]string{"label": {"dsh-mgr.role=runtime", "dsh-mgr.version=" + version}})
var images []struct {
ID string
Labels map[string]string
}
if err := a.docker.Do(ctx, http.MethodGet, "/images/json?filters="+url.QueryEscape(string(filters)), nil, &images); err != nil {
return "", err
}
for _, im := range images {
if preferred == "" && im.Labels["dsh-mgr.release"] != "" {
continue
}
if im.ID != "" && im.Labels["dsh-mgr.role"] == "runtime" && im.Labels["dsh-mgr.version"] == version && im.Labels["dsh-mgr.recipe"] == imageRecipeRev && (preferred == "" || im.ID == preferred) {
ok, err := a.imageMatches(ctx, im.ID, version)
if err != nil {
return "", err
}
if ok {
return im.ID, nil
}
}
}
return "", nil
}
-2
View File
@@ -47,7 +47,6 @@ func (a *API) deleteImage(w http.ResponseWriter, r *http.Request) {
return
}
defer tx.Rollback()
// Take SQLite's writer lock before checking intents: reservation does not use the build queue.
if _, err = tx.ExecContext(ctx, "UPDATE images SET status=status WHERE image_id=?", id); err != nil {
fail(w, 500, "image index unavailable")
return
@@ -64,7 +63,6 @@ func (a *API) deleteImage(w http.ResponseWriter, r *http.Request) {
}
for _, check := range []struct{ table, query string }{
{"image_builds", "SELECT count(*) FROM image_builds WHERE status IN ('queued','running','validating')"},
{"creation_intents", "SELECT count(*) FROM creation_intents WHERE status IN ('waiting','creating')"},
{"upgrade_journal", "SELECT count(*) FROM upgrade_journal WHERE phase NOT IN ('complete','rolled_back')"},
} {
var exists, count int
+1 -6
View File
@@ -10,7 +10,7 @@ import (
func TestImageDelete(t *testing.T) {
id := "sha256:" + strings.Repeat("a", 64)
for _, mode := range []string{"success", "anonymous", "non-admin", "invalid", "unmanaged", "role", "instance", "container-running", "container-exited", "containers-failure", "containers-null", "containers-unknown", "inspect-failure", "delete-failure", "delete-conflict", "queued", "running", "validating", "worker", "waiting", "creating", "journal", "complete", "rolled_back", "db-failure", "task-query-failure", "audit-failure"} {
for _, mode := range []string{"success", "anonymous", "non-admin", "invalid", "unmanaged", "role", "instance", "container-running", "container-exited", "containers-failure", "containers-null", "containers-unknown", "inspect-failure", "delete-failure", "delete-conflict", "queued", "running", "validating", "worker", "journal", "complete", "rolled_back", "db-failure", "task-query-failure", "audit-failure"} {
t.Run(mode, func(t *testing.T) {
var deletes atomic.Int32
a, sid := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
@@ -101,11 +101,6 @@ func TestImageDelete(t *testing.T) {
buildQueue.active[a.s.DB] = true
buildQueue.Unlock()
defer func() { buildQueue.Lock(); delete(buildQueue.active, a.s.DB); buildQueue.Unlock() }()
case "waiting", "creating":
if err := a.s.EnsureIntents(); err != nil {
t.Fatal(err)
}
exec("INSERT INTO creation_intents(id,uid,name,version,status,host,created) VALUES('intent','other','i','2.0.0',?,'host',0)", mode)
case "db-failure":
exec("ALTER TABLE instances RENAME COLUMN image_id TO broken")
want = 500
@@ -1,14 +1,13 @@
package api
import (
"context"
"net/http"
"sync"
"testing"
"time"
)
func TestEnqueueBuildConcurrentMissingImageReturnsReplacement(t *testing.T) {
func TestEnqueueReleaseConcurrentMissingImageReturnsReplacement(t *testing.T) {
inspections := make(chan chan struct{}, 2)
stop := make(chan struct{})
a, _ := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
@@ -41,6 +40,10 @@ func TestEnqueueBuildConcurrentMissingImageReturnsReplacement(t *testing.T) {
}
})
seedVerifiedImage(t, a, "old-image", "0.2.0-rc.1")
info := releaseFixture()
if _, err := a.s.DB.Exec("UPDATE image_builds SET release_id=?,source_repository=?,source_commit=?,version=? WHERE image_id='old-image'", info.ReleaseID, info.Repository, info.Commit, info.Version); err != nil {
t.Fatal(err)
}
var oldID string
if err := a.s.DB.QueryRow("SELECT id FROM image_builds WHERE image_id='old-image'").Scan(&oldID); err != nil {
t.Fatal(err)
@@ -49,11 +52,9 @@ func TestEnqueueBuildConcurrentMissingImageReturnsReplacement(t *testing.T) {
buildQueue.Lock()
buildQueue.active[a.s.DB] = true
buildQueue.Unlock()
ctx, cancel := context.WithCancel(context.Background())
var submissions sync.WaitGroup
t.Cleanup(func() {
close(stop)
cancel()
submissions.Wait()
buildQueue.Lock()
delete(buildQueue.active, a.s.DB)
@@ -69,7 +70,7 @@ func TestEnqueueBuildConcurrentMissingImageReturnsReplacement(t *testing.T) {
submissions.Add(1)
go func() {
defer submissions.Done()
b, err := a.enqueueBuild(ctx, "u", "0.2.0-rc.1")
b, err := a.enqueueRelease("u", info)
done <- result{b, err}
}()
return done
-2
View File
@@ -16,8 +16,6 @@ import (
"time"
)
var npmBuildClient = &http.Client{Timeout: 15 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
const imageRecipeRev = "7"
var imagePlatform = runtime.GOOS + "/" + runtime.GOARCH
-129
View File
@@ -11,7 +11,6 @@ import (
"strings"
"sync/atomic"
"testing"
"time"
)
func TestImageLegacyMigrationNeverCertifies(t *testing.T) {
@@ -238,33 +237,6 @@ func TestImageIndexUsageMissingAndDockerFailure(t *testing.T) {
}
}
func TestBuildRateLimitDoesNotPenalizeSharedTask(t *testing.T) {
a, sid := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1.47/images/verified/json" {
writeImageInspect(w, "verified", "0.2.0-rc.1")
return
}
io.WriteString(w, `[{"Id":"verified","Labels":{"dsh-mgr.role":"runtime","dsh-mgr.version":"0.2.0-rc.1","dsh-mgr.recipe":"`+imageRecipeRev+`"}}]`)
})
seedVerifiedImage(t, a, "verified", "0.2.0-rc.1")
for _, id := range []string{"a", "b", "c"} {
if _, err := a.s.DB.Exec("INSERT INTO image_build_requests VALUES('u',?,?)", id, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
t.Fatal(err)
}
}
if w := imageRequest(a.builds, sid, "POST", `{"version":"0.2.0-rc.1"}`); w.Code != 202 {
t.Fatal(w.Code, w.Body.String())
}
if w := imageRequest(a.builds, sid, "POST", `{"version":"0.2.0-rc.2"}`); w.Code != 429 || w.Header().Get("Retry-After") == "" {
t.Fatal(w.Code, w.Body.String())
}
var n int
a.s.DB.QueryRow("SELECT count(*) FROM image_build_requests").Scan(&n)
if n != 3 {
t.Fatal(n)
}
}
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 {
@@ -310,62 +282,6 @@ func TestBuildDiskPolicy(t *testing.T) {
}
}
func TestUnknownDockerDiskBlocksBuild(t *testing.T) {
a, sid := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1.47/images/json" {
t.Errorf("unexpected Docker operation %s", r.URL.Path)
}
io.WriteString(w, "[]")
})
t.Setenv("DSH_DOCKER_ROOT_VISIBLE", "")
w := imageRequest(a.builds, sid, "POST", `{"version":"0.2.0-rc.1"}`)
if w.Code != 202 {
t.Fatal(w.Body.String())
}
deadline := time.Now().Add(3 * time.Second)
for {
var status, detail string
if err := a.s.DB.QueryRow("SELECT status,error FROM image_builds").Scan(&status, &detail); err != nil {
t.Fatal(err)
}
if status == "failed" {
if !strings.Contains(detail, "capacity unknown") {
t.Fatal(detail)
}
break
}
if time.Now().After(deadline) {
t.Fatal(status, detail)
}
time.Sleep(time.Millisecond)
}
}
func TestExactNPMMetadata(t *testing.T) {
for _, test := range []struct {
body string
status int
ok bool
}{
{`{"version":"0.2.0-rc.1"}`, 200, true},
{`{"version":"0.2.0-rc.2"}`, 200, false},
{`{"version":"latest"}`, 200, false},
{`{}`, 404, false},
{`invalid`, 200, false},
} {
client := &http.Client{Transport: imageRoundTrip(func(r *http.Request) (*http.Response, error) {
if r.URL.Host != "registry.npmjs.org" || !strings.HasSuffix(r.URL.Path, "/0.2.0-rc.1") {
t.Error(r.URL)
}
return &http.Response{StatusCode: test.status, Body: io.NopCloser(strings.NewReader(test.body)), Header: make(http.Header)}, nil
})}
_, err := fetchNPMVersion(context.Background(), client, "0.2.0-rc.1")
if (err == nil) != test.ok {
t.Fatalf("%s: %v", test.body, err)
}
}
}
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")
@@ -374,49 +290,4 @@ func TestBuildListReconcilesMissingAndLatestTimestamp(t *testing.T) {
if err := json.Unmarshal(w.Body.Bytes(), &records); err != nil || len(records) != 1 || records[0].Status != "missing" {
t.Fatal(w.Body.String())
}
w = imageRequest(a.latest, sid, "GET", "")
var latest struct {
Version string `json:"version"`
CheckedAt string `json:"checked_at"`
}
if err := json.Unmarshal(w.Body.Bytes(), &latest); err != nil {
t.Fatal(err)
}
if _, err := time.Parse(time.RFC3339, latest.CheckedAt); err != nil || latest.Version != "0.2.0-rc.1" {
t.Fatal(latest, err)
}
}
// The one-second intent poller must reuse per-image evaluations instead of
// re-inspecting Docker every tick, and a generation bump (deletion or
// admission change) must invalidate the cache.
func TestIntentPollerCachesImageEvaluations(t *testing.T) {
inspects := 0
a, _ := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1.47/images/image/json" {
inspects++
writeImageInspect(w, "image", "0.2.0-rc.1")
return
}
w.WriteHeader(404)
})
seedVerifiedImage(t, a, "image", "0.2.0-rc.1")
for n := 0; n < 3; n++ {
verified, err := a.verifiedImagesPoll(context.Background())
if err != nil || len(verified) != 1 || verified["image"] != "0.2.0-rc.1" {
t.Fatal(verified, err)
}
}
if inspects != 1 {
t.Fatalf("poller re-inspected Docker %d times for the same image", inspects)
}
buildQueue.Lock()
a.imageDeleteGeneration++
buildQueue.Unlock()
if _, err := a.verifiedImagesPoll(context.Background()); err != nil {
t.Fatal(err)
}
if inspects != 2 {
t.Fatalf("generation bump did not invalidate the cache: %d inspects", inspects)
}
}
+24 -272
View File
@@ -1,7 +1,6 @@
package api
import (
"archive/tar"
"context"
"encoding/json"
"fmt"
@@ -10,10 +9,8 @@ import (
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
@@ -24,11 +21,6 @@ func imageTestAPI(t *testing.T, h http.HandlerFunc) (*API, string) {
oldDiskAvailable := buildDiskAvailable
buildDiskAvailable = func(string) (uint64, error) { return minimumBuildDiskBytes, nil }
t.Cleanup(func() { buildDiskAvailable = oldDiskAvailable })
oldClient := npmBuildClient
npmBuildClient = &http.Client{Transport: imageRoundTrip(func(r *http.Request) (*http.Response, error) {
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"version":"0.2.0-rc.1"}`))}, nil
})}
t.Cleanup(func() { npmBuildClient = oldClient })
root := t.TempDir()
t.Setenv("DSH_DOCKER_ROOT_VISIBLE", root)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -129,34 +121,7 @@ func seedVerifiedImage(t *testing.T, a *API, id, version string) {
t.Fatal(err)
}
}
func TestImageContextAndSanitizing(t *testing.T) {
r, err := imageBuildContext()
if err != nil {
t.Fatal(err)
}
tr := tar.NewReader(r)
n := 0
for {
h, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatal(err)
}
data, _ := io.ReadAll(tr)
original, err := os.ReadFile(filepath.Join("..", "..", "image", h.Name))
if err != nil || string(data) != string(original) {
t.Fatalf("recipe drift %s: %v", h.Name, err)
}
if h.Name == "Dockerfile" && (!strings.Contains(string(data), "ARG RECIPE_REV="+imageRecipeRev+"\n") || !strings.Contains(string(data), "dsh-mgr.recipe=$RECIPE_REV")) {
t.Fatal("recipe revision or label differs from image policy")
}
n++
}
if n != 7 {
t.Fatal(n)
}
func TestImageSanitizing(t *testing.T) {
s := sanitizeBuildLog("token=topsecret Authorization: Bearer hidden password=private https://user:[email protected]/")
for _, secret := range []string{"topsecret", "hidden", "private", "user:pass"} {
if strings.Contains(s, secret) {
@@ -176,129 +141,6 @@ func TestImageContextAndSanitizing(t *testing.T) {
}
}
type imageRoundTrip func(*http.Request) (*http.Response, error)
func (f imageRoundTrip) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func TestLatestOfficialRegistry(t *testing.T) {
client := &http.Client{Transport: imageRoundTrip(func(r *http.Request) (*http.Response, error) {
if r.URL.Host != "registry.npmjs.org" || r.URL.Scheme != "https" {
t.Fatal(r.URL)
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"version":"0.2.0-rc.1"}`)), Header: make(http.Header)}, nil
})}
v, err := fetchLatest(context.Background(), client)
if err != nil || v != "0.2.0-rc.1" {
t.Fatalf("%s %v", v, err)
}
}
func TestBuildDedupAndVerification(t *testing.T) {
var calls, deleted atomic.Int32
a, sid := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1.47/images/sha256:abc/json", "/v1.47/images/sha256:reuse/json":
writeImageInspect(w, strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/v1.47/images/"), "/json"), "0.2.0-rc.1")
case "/v1.47/images/json":
if calls.Load() == 0 {
io.WriteString(w, "[]")
} else {
io.WriteString(w, `[{"Id":"sha256:abc","Labels":{"dsh-mgr.role":"runtime","dsh-mgr.version":"0.2.0-rc.1","dsh-mgr.recipe":"`+imageRecipeRev+`"}}]`)
}
case "/v1.47/build":
calls.Add(1)
if r.URL.Query().Get("t") != "dsh-web:0.2.0-rc.1-"+imageRecipeRev {
t.Error("unexpected tag", r.URL.Query())
}
var args map[string]string
json.Unmarshal([]byte(r.URL.Query().Get("buildargs")), &args)
if args["DSH_VERSION"] != "0.2.0-rc.1" {
t.Error(args)
}
io.Copy(io.Discard, r.Body)
io.WriteString(w, `{"stream":"token=secretvalue"}`+string(rune(10))+`{"aux":{"ID":"sha256:abc"}}`)
case "/v1.47/containers/create":
var cfg struct {
Image string
HostConfig struct {
NetworkMode string
ReadonlyRootfs bool
Tmpfs map[string]string
}
}
json.NewDecoder(r.Body).Decode(&cfg)
if cfg.Image != "sha256:abc" || cfg.HostConfig.NetworkMode != "none" || !cfg.HostConfig.ReadonlyRootfs || !strings.Contains(cfg.HostConfig.Tmpfs["/tmp"], "exec") {
t.Errorf("unsafe config %+v", cfg)
}
io.WriteString(w, `{"Id":"check"}`)
case "/v1.47/containers/check/start", "/v1.47/exec/probe/start":
w.WriteHeader(204)
case "/v1.47/containers/check/exec":
var cfg struct{ Cmd []string }
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil || len(cfg.Cmd) != 3 || !strings.Contains(cfg.Cmd[2], "execFileSync('dsh',['--version']") || !strings.Contains(cfg.Cmd[2], `actual!=="0.2.0-rc.1"`) {
t.Errorf("missing version verification: %+v %v", cfg, err)
}
io.WriteString(w, `{"Id":"probe"}`)
case "/v1.47/exec/probe/json":
io.WriteString(w, `{"Running":false,"ExitCode":0}`)
case "/v1.47/containers/check/json":
io.WriteString(w, `{"State":{"Running":true}}`)
case "/v1.47/containers/check":
deleted.Add(1)
w.WriteHeader(204)
default:
t.Errorf("unexpected %s", r.URL)
w.WriteHeader(500)
}
})
responses := make(chan *httptest.ResponseRecorder, 8)
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
responses <- imageRequest(a.builds, sid, "POST", `{"version":"0.2.0-rc.1"}`)
}()
}
wg.Wait()
close(responses)
first := ""
for w := range responses {
if w.Code != 202 {
t.Fatal(w.Body.String())
}
var b buildRecord
json.Unmarshal(w.Body.Bytes(), &b)
if first == "" {
first = b.ID
}
if first != b.ID {
t.Fatal("duplicate job")
}
}
deadline := time.Now().Add(5 * time.Second)
for {
w := imageRequest(a.builds, sid, "GET", "")
var jobs []buildRecord
json.Unmarshal(w.Body.Bytes(), &jobs)
if len(jobs) == 1 && jobs[0].Status == "success" {
if strings.Contains(jobs[0].Logs, "secretvalue") {
t.Fatal(jobs)
}
break
}
if time.Now().After(deadline) {
t.Fatal(w.Body.String())
}
time.Sleep(time.Millisecond)
}
if calls.Load() != 1 || deleted.Load() != 1 {
t.Fatalf("builds %d cleanup %d", calls.Load(), deleted.Load())
}
for _, body := range []string{`{"version":"latest"}`, `{"version":"0.2.0-rc.1","url":"bad"}`, `{"version":"0.2.0-rc.1"} {}`} {
if w := imageRequest(a.builds, sid, "POST", body); w.Code != 400 {
t.Fatal(w.Code)
}
}
}
func TestVerificationFailureCleansUp(t *testing.T) {
var deleted atomic.Bool
a, _ := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
@@ -321,86 +163,6 @@ func TestVerificationFailureCleansUp(t *testing.T) {
}
}
func TestBuildReuseAndMissingImage(t *testing.T) {
var exists atomic.Bool
exists.Store(true)
var builds, checks atomic.Int32
a, sid := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1.47/images/sha256:abc/json", "/v1.47/images/sha256:reuse/json":
writeImageInspect(w, strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/v1.47/images/"), "/json"), "0.2.0-rc.1")
case "/v1.47/images/json":
if exists.Load() {
io.WriteString(w, `[{"Id":"sha256:reuse","Labels":{"dsh-mgr.role":"runtime","dsh-mgr.version":"0.2.0-rc.1","dsh-mgr.recipe":"`+imageRecipeRev+`"}}]`)
} else {
io.WriteString(w, "[]")
}
case "/v1.47/build":
builds.Add(1)
exists.Store(true)
io.Copy(io.Discard, r.Body)
io.WriteString(w, `{"aux":{"ID":"sha256:reuse"}}`)
case "/v1.47/containers/create":
checks.Add(1)
io.WriteString(w, `{"Id":"c"}`)
case "/v1.47/containers/c/start", "/v1.47/exec/e/start", "/v1.47/containers/c":
w.WriteHeader(204)
case "/v1.47/containers/c/exec":
io.WriteString(w, `{"Id":"e"}`)
case "/v1.47/exec/e/json":
io.WriteString(w, `{"Running":false,"ExitCode":0}`)
case "/v1.47/containers/c/json":
io.WriteString(w, `{"State":{"Running":true}}`)
default:
t.Errorf("unexpected %s", r.URL)
w.WriteHeader(500)
}
})
wait := func(id string) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for {
var status, image string
err := a.s.DB.QueryRow("SELECT status,image_id FROM image_builds WHERE id=?", id).Scan(&status, &image)
if err == nil && status == "success" {
if image != "sha256:reuse" {
t.Fatal(image)
}
return
}
if time.Now().After(deadline) {
t.Fatalf("status %s: %v", status, err)
}
time.Sleep(time.Millisecond)
}
}
post := func() buildRecord {
t.Helper()
w := imageRequest(a.builds, sid, "POST", `{"version":"0.2.0-rc.1"}`)
if w.Code != 202 {
t.Fatal(w.Body.String())
}
var b buildRecord
if err := json.Unmarshal(w.Body.Bytes(), &b); err != nil {
t.Fatal(err)
}
return b
}
first := post()
wait(first.ID)
if builds.Load() != 0 || checks.Load() != 1 {
t.Fatal("local image not reused and verified")
}
exists.Store(false)
second := post()
if second.ID == first.ID {
t.Fatal("missing image deduplicated")
}
wait(second.ID)
if builds.Load() != 1 || checks.Load() != 2 {
t.Fatal("missing image not rebuilt")
}
}
func TestBuildRestartRecovery(t *testing.T) {
a, _ := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(500) })
buildQueue.Lock()
@@ -427,6 +189,7 @@ func TestBuildRestartRecovery(t *testing.T) {
func TestFailedVerificationNeverAvailable(t *testing.T) {
var deleted atomic.Bool
imageID := "sha256:" + strings.Repeat("c", 64)
info := releaseFixture()
a, sid := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1.47/images/sha256:abc/json", "/v1.47/images/sha256:reuse/json":
@@ -444,33 +207,36 @@ func TestFailedVerificationNeverAvailable(t *testing.T) {
case "/v1.47/containers/check":
deleted.Store(true)
w.WriteHeader(204)
case "/v1.47/build":
writeJSON(w, map[string]any{"aux": map[string]string{"ID": imageID}})
case "/v1.47/images/" + imageID + "/json":
writeImageInspect(w, imageID, "0.2.0-rc.1")
parts := strings.Split(imagePlatform, "/")
writeJSON(w, map[string]any{"Id": imageID, "Os": parts[0], "Architecture": parts[1], "Config": map[string]any{"Labels": map[string]string{"dsh-mgr.role": "runtime", "dsh-mgr.version": info.Version, "dsh-mgr.recipe": imageRecipeRev, "dsh-mgr.release": info.ReleaseID, "dsh-mgr.source": info.Repository, "dsh-mgr.commit": info.Commit}}})
default:
t.Errorf("unexpected Docker call %s", r.URL)
w.WriteHeader(500)
}
})
w := imageRequest(a.builds, sid, "POST", `{"version":"0.2.0-rc.1"}`)
if w.Code != 202 {
t.Fatal(w.Body.String())
if _, err := a.s.DB.Exec("UPDATE users SET admin=1 WHERE id='u'"); err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(5 * time.Second)
for {
var status, id, detail string
err := a.s.DB.QueryRow("SELECT status,image_id,error FROM image_builds").Scan(&status, &id, &detail)
if err == nil && status == "failed" {
if id != imageID || !strings.Contains(detail, "verification failed") || !deleted.Load() {
t.Fatalf("%s %s cleanup=%v", id, detail, deleted.Load())
}
break
}
if time.Now().After(deadline) {
t.Fatalf("status=%s error=%v", status, err)
}
time.Sleep(time.Millisecond)
old := fetchRelease
t.Cleanup(func() { fetchRelease = old })
fetchRelease = func(context.Context, string, string) (releaseInfo, io.Reader, error) {
return info, strings.NewReader("context"), nil
}
w = imageRequest(a.images, sid, "GET", "")
b, err := a.enqueueRelease("u", info)
if err != nil {
t.Fatal(err)
}
a.runImageBuild(b)
if err := scanBuildSummary(a.s.DB.QueryRow("SELECT "+buildSummaryColumns+" FROM image_builds WHERE id=?", b.ID), &b); err != nil {
t.Fatal(err)
}
if b.Status != "failed" || b.ImageID != imageID || !strings.Contains(b.Error, "verification failed") || !deleted.Load() {
t.Fatalf("%+v cleanup=%v", b, deleted.Load())
}
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 != "unverified" {
t.Fatal("failed image advertised", w.Body.String())
@@ -481,20 +247,6 @@ func TestFailedVerificationNeverAvailable(t *testing.T) {
}
}
func TestLocalBuildImagePrefersPersistedID(t *testing.T) {
a, _ := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1.47/images/verified/json" {
writeImageInspect(w, "verified", "0.2.0-rc.1")
return
}
io.WriteString(w, `[{"Id":"newer","Labels":{"dsh-mgr.role":"runtime","dsh-mgr.version":"0.2.0-rc.1","dsh-mgr.recipe":"`+imageRecipeRev+`"}},{"Id":"verified","Labels":{"dsh-mgr.role":"runtime","dsh-mgr.version":"0.2.0-rc.1","dsh-mgr.recipe":"`+imageRecipeRev+`"}}]`)
})
id, err := a.localBuildImage(context.Background(), "0.2.0-rc.1", "verified")
if err != nil || id != "verified" {
t.Fatalf("id=%s err=%v", id, err)
}
}
func writeImageInspect(w http.ResponseWriter, id, version string) {
parts := strings.Split(imagePlatform, "/")
writeJSON(w, map[string]any{"Id": id, "Os": parts[0], "Architecture": parts[1], "Config": map[string]any{"Labels": map[string]string{"dsh-mgr.role": "runtime", "dsh-mgr.version": version, "dsh-mgr.recipe": imageRecipeRev}}})
-233
View File
@@ -1,233 +0,0 @@
package api
import (
"context"
"database/sql"
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"time"
"git.misaka.ren/m1saka/dsh-web/internal/store"
)
func (a *API) intents(w http.ResponseWriter, r *http.Request) {
u, _, err := a.user(r)
if err != nil {
fail(w, 401, "authentication required")
return
}
if err = a.s.EnsureIntents(); err != nil {
fail(w, 500, "database error")
return
}
switch r.Method {
case http.MethodGet:
list, err := a.s.Intents(u.ID)
if err != nil {
fail(w, 500, "database error")
return
}
writeJSON(w, list)
case http.MethodPost:
var in struct {
Name string `json:"name"`
Version string `json:"version"`
}
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096))
dec.DisallowUnknownFields()
if dec.Decode(&in) != nil || dec.Decode(new(any)) != io.EOF || strings.TrimSpace(in.Name) == "" || len(in.Name) > 200 || !validBuildVersion(in.Version) {
fail(w, 400, "name and fixed version required")
return
}
if !normalBuildAllowed(in.Version) {
fail(w, 400, errBuildUnsupported.Error())
return
}
i := store.Intent{ID: store.ID(), UID: u.ID, Name: strings.TrimSpace(in.Name), Version: in.Version, Host: a.runtimeHost(r), Status: "waiting"}
if err = a.s.ReserveIntent(i); err != nil {
if errors.Is(err, store.ErrInstanceLimit) {
fail(w, 409, err.Error())
} else {
fail(w, 500, "database error")
}
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
writeJSON(w, i)
default:
fail(w, 405, "method not allowed")
}
}
func (a *API) intent(w http.ResponseWriter, r *http.Request) {
u, _, err := a.user(r)
if err != nil {
fail(w, 401, "authentication required")
return
}
if r.Method != http.MethodDelete {
fail(w, 405, "method not allowed")
return
}
if err = a.s.EnsureIntents(); err != nil {
fail(w, 500, "database error")
return
}
id := strings.TrimPrefix(r.URL.Path, "/api/intents/")
if err = a.s.CancelIntent(id, u.ID); err != nil {
if errors.Is(err, store.ErrIntentState) {
fail(w, 409, "intent not found or no longer waiting")
} else {
fail(w, 500, "database error")
}
return
}
writeJSON(w, map[string]string{"status": "cancelled"})
}
// Call once at startup, after the build queue has recovered interrupted jobs.
func (a *API) StartIntents(ctx context.Context) error {
if err := a.s.EnsureIntents(); err != nil {
return err
}
if err := a.recoverIntents(ctx); err != nil {
return err
}
go func() {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
default:
}
a.processIntents(ctx)
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}()
return nil
}
func (a *API) finishIntent(id, status, detail string) {
_, _ = a.s.DB.Exec(`UPDATE creation_intents SET status=?,error=? WHERE id=? AND status IN ('waiting','creating')`, status, detail, id)
}
func (a *API) recoverIntents(ctx context.Context) error {
list, err := a.s.Intents("")
if err != nil {
return err
}
for _, in := range list {
if in.Status != "creating" {
continue
}
i, err := a.s.GetInstance(in.InstanceID)
if errors.Is(err, sql.ErrNoRows) {
a.finishIntent(in.ID, "failed", "instance reservation missing after process restart")
continue
}
if err != nil {
return err
}
if err == nil && i.Status == "running" {
a.finishIntent(in.ID, "success", "")
continue
}
cleanup, cancel := context.WithTimeout(ctx, 30*time.Second)
if err == nil && i.ContainerID == "" {
err = a.recoverRuntimeContainer(cleanup, &i)
}
if err == nil && i.ContainerID != "" {
err = a.removeRuntimeContainer(cleanup, i.ContainerID)
}
if err == nil {
err = a.removeRuntimeNetwork(cleanup, in.InstanceID)
}
cancel()
if err != nil {
a.s.SetInstance(in.InstanceID, "error")
a.finishIntent(in.ID, "failed", "instance creation requires recovery: "+err.Error())
a.s.AddAudit(in.UID, "instance.recovery_error", in.InstanceID, err.Error())
continue
}
a.s.DeleteInstance(in.InstanceID)
_, _ = a.s.DB.Exec("DELETE FROM runtime_creates WHERE instance_id=?", in.InstanceID)
_, _ = a.s.DB.Exec("DELETE FROM runtime_networks WHERE instance_id=?", in.InstanceID)
a.finishIntent(in.ID, "failed", "instance creation interrupted by process restart")
}
return nil
}
func (a *API) processIntents(ctx context.Context) {
list, err := a.s.Intents("")
if err != nil {
return
}
for _, in := range list {
if ctx.Err() != nil {
return
}
if in.Status != "waiting" {
continue
}
if in.BuildID == "" {
b, err := a.enqueueBuild(ctx, in.UID, in.Version)
if err != nil {
a.finishIntent(in.ID, "failed", "build submission failed")
continue
}
if _, err = a.s.DB.Exec(`UPDATE creation_intents SET build_id=? WHERE id=? AND status='waiting'`, b.ID, in.ID); err != nil {
continue
}
in.BuildID = b.ID
}
var status, imageID, version string
err := a.s.DB.QueryRow(`SELECT status,image_id,version FROM image_builds WHERE id=?`, in.BuildID).Scan(&status, &imageID, &version)
if err != nil {
continue
}
if version != in.Version || status == "failed" || status == "cancelled" || status == "missing" {
a.finishIntent(in.ID, "failed", "build failed; requested version was not created")
continue
}
if status != "success" {
continue
}
work, cancel := context.WithTimeout(ctx, 90*time.Second)
verified, err := a.verifiedImagesPoll(work)
if err != nil {
cancel()
continue
}
if !runtimeImageID.MatchString(imageID) || verified[imageID] != in.Version {
cancel()
a.finishIntent(in.ID, "failed", "verified image for requested version unavailable")
continue
}
u, err := a.s.UserByID(in.UID)
if err != nil {
cancel()
a.finishIntent(in.ID, "failed", "owner unavailable")
continue
}
i := store.Instance{ID: store.ID(), UID: in.UID, Name: in.Name, Version: in.Version, ImageID: imageID, Status: "creating", Created: time.Now()}
if err = a.s.ClaimIntent(in.ID, i); err != nil {
cancel()
continue
}
_, err = a.createRuntime(work, u, i, in.Host)
cancel()
if err != nil {
a.finishIntent(in.ID, "failed", err.Error())
} else {
a.finishIntent(in.ID, "success", "")
}
}
}
-166
View File
@@ -1,166 +0,0 @@
package api
import (
"context"
"git.misaka.ren/m1saka/dsh-web/internal/store"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
)
func TestIntentsReserveCancel(t *testing.T) {
a, sid := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) { t.Error("reservation contacted Docker") })
if err := a.initAuth(); err != nil {
t.Fatal(err)
}
for n := 0; n < 3; n++ {
w := httptest.NewRecorder()
a.intents(w, runtimeRequest("POST", "/api/intents", `{"name":"one","version":"0.2.0-rc.1"}`, sid))
want := 202
if n == 2 {
want = 409
}
if w.Code != want {
t.Fatalf("%d: %d %s", n, w.Code, w.Body.String())
}
}
list, err := a.s.Intents("owner")
if err != nil || len(list) != 2 {
t.Fatalf("%v %v", list, err)
}
w := httptest.NewRecorder()
a.intent(w, runtimeRequest("DELETE", "/api/intents/"+list[0].ID, "", sid))
if w.Code != 200 {
t.Fatal(w.Code)
}
if err := a.s.ReserveIntent(store.Intent{ID: store.ID(), UID: "owner"}); err != nil {
t.Fatal(err)
}
}
func TestIntentBuildSuccessUsesRealRuntime(t *testing.T) {
if os.Geteuid() != 0 {
t.Skip("runtime filesystem ownership requires root")
}
image := "sha256:" + strings.Repeat("a", 64)
creates := 0
a, _ := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.Contains(r.URL.Path, "/images/"):
writeJSON(w, map[string]any{"Id": image, "Os": "linux", "Architecture": "amd64", "Config": map[string]any{"Labels": map[string]string{"dsh-mgr.role": "runtime", "dsh-mgr.version": "0.2.0-rc.1", "dsh-mgr.recipe": imageRecipeRev}}})
case strings.HasSuffix(r.URL.Path, "/containers/create"):
creates++
writeJSON(w, map[string]string{"Id": "runtime-created"})
default:
w.WriteHeader(204)
}
})
seedVerifiedImage(t, a, image, "0.2.0-rc.1")
if err := a.s.EnsureIntents(); err != nil {
t.Fatal(err)
}
var build string
if err := a.s.DB.QueryRow("SELECT id FROM image_builds WHERE version='0.2.0-rc.1'").Scan(&build); err != nil {
t.Fatal(err)
}
in := store.Intent{ID: store.ID(), UID: "owner", Name: "one", Version: "0.2.0-rc.1", Host: "panel.test"}
if err := a.s.ReserveIntent(in); err != nil {
t.Fatal(err)
}
if _, err := a.s.DB.Exec("UPDATE creation_intents SET build_id=? WHERE id=?", build, in.ID); err != nil {
t.Fatal(err)
}
a.processIntents(context.Background())
a.processIntents(context.Background())
list, err := a.s.Intents("owner")
if err != nil || len(list) != 1 || list[0].Status != "success" || creates != 1 {
t.Fatalf("%+v err=%v creates=%d", list, err, creates)
}
instances, err := a.s.Instances("owner")
if err != nil || len(instances) != 1 || instances[0].ImageID != image || instances[0].Version != "0.2.0-rc.1" {
t.Fatalf("%+v %v", instances, err)
}
}
func TestIntentFailedBuildReleasesAndCancelPreservesBuild(t *testing.T) {
a, _ := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) { t.Error("failed intent contacted Docker") })
seedVerifiedImage(t, a, "sha256:"+strings.Repeat("b", 64), "0.2.0-rc.1")
if err := a.s.EnsureIntents(); err != nil {
t.Fatal(err)
}
var build string
a.s.DB.QueryRow("SELECT id FROM image_builds LIMIT 1").Scan(&build)
for _, id := range []string{"cancel", "fail"} {
if err := a.s.ReserveIntent(store.Intent{ID: id, UID: "owner", Version: "0.2.0-rc.1"}); err != nil {
t.Fatal(err)
}
a.s.DB.Exec("UPDATE creation_intents SET build_id=? WHERE id=?", build, id)
}
if err := a.s.CancelIntent("cancel", "owner"); err != nil {
t.Fatal(err)
}
var status string
a.s.DB.QueryRow("SELECT status FROM image_builds WHERE id=?", build).Scan(&status)
if status != "success" {
t.Fatal(status)
}
a.s.DB.Exec("UPDATE image_builds SET status='failed' WHERE id=?", build)
a.processIntents(context.Background())
for n := 0; n < 2; n++ {
if err := a.s.ReserveIntent(store.Intent{ID: store.ID(), UID: "owner"}); err != nil {
t.Fatal(err)
}
}
}
func TestIntentRestartCleansInterruptedCreationAndRetainsWaiting(t *testing.T) {
deletes := 0
a, _ := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && r.URL.Path == "/v1.47/networks/dsh-inst-reserved" {
w.Write([]byte(`{"Id":"network","Name":"dsh-inst-reserved","Driver":"bridge","Labels":{"dsh-mgr.instance":"reserved"}}`))
return
}
if r.Method == "GET" && r.URL.Path == "/v1.47/containers/dsh-inst-reserved/json" {
w.Write([]byte(`{"Id":"recovered","Name":"/dsh-inst-reserved","Config":{"Labels":{"dsh-mgr.instance":"reserved","dsh-mgr.owner":"owner","dsh-mgr.role":"dsh"}}}`))
return
}
if r.Method == "DELETE" {
deletes++
}
w.WriteHeader(204)
})
if err := a.s.EnsureIntents(); err != nil {
t.Fatal(err)
}
for _, id := range []string{"waiting", "creating"} {
if err := a.s.ReserveIntent(store.Intent{ID: id, UID: "owner", Version: "0.2.0-rc.1"}); err != nil {
t.Fatal(err)
}
}
if err := a.s.ClaimIntent("creating", store.Instance{ID: "reserved", UID: "owner", Version: "0.2.0-rc.1", Status: "creating"}); err != nil {
t.Fatal(err)
}
if err := a.recoverIntents(context.Background()); err != nil {
t.Fatal(err)
}
if deletes != 2 {
t.Fatalf("cleanup calls=%d", deletes)
}
list, err := a.s.Intents("owner")
if err != nil {
t.Fatal(err)
}
for _, in := range list {
if in.ID == "waiting" && in.Status != "waiting" {
t.Fatal(in)
}
if in.ID == "creating" && in.Status != "failed" {
t.Fatal(in)
}
}
if err := a.s.ReserveIntent(store.Intent{ID: store.ID(), UID: "owner"}); err != nil {
t.Fatal(err)
}
}
+70 -3
View File
@@ -182,10 +182,26 @@ func TestPinnedReleaseWorkerAdmissionAndBlock(t *testing.T) {
parts := strings.Split(imagePlatform, "/")
writeJSON(w, map[string]any{"Id": "sha256:release", "Os": parts[0], "Architecture": parts[1], "Config": map[string]any{"Labels": labels}})
case "/v1.47/containers/create":
var cfg struct {
Image, User string
HostConfig struct {
NetworkMode string
ReadonlyRootfs bool
Tmpfs map[string]string
}
}
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil || cfg.Image != "sha256:release" || cfg.User != "1000:1000" || cfg.HostConfig.NetworkMode != "none" || !cfg.HostConfig.ReadonlyRootfs || !strings.Contains(cfg.HostConfig.Tmpfs["/tmp"], "exec") {
t.Errorf("unsafe verification config %+v err=%v", cfg, err)
}
io.WriteString(w, `{"Id":"check"}`)
case "/v1.47/containers/check/start", "/v1.47/exec/probe/start", "/v1.47/containers/check":
w.WriteHeader(204)
case "/v1.47/containers/check/exec":
var cfg struct{ Cmd []string }
expected, _ := json.Marshal(info.Version)
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil || len(cfg.Cmd) != 3 || !strings.Contains(cfg.Cmd[2], "execFileSync('dsh',['--version']") || !strings.Contains(cfg.Cmd[2], "actual!=="+string(expected)) {
t.Errorf("missing exact version check %+v err=%v", cfg, err)
}
io.WriteString(w, `{"Id":"probe"}`)
case "/v1.47/exec/probe/json":
io.WriteString(w, `{"Running":false,"ExitCode":0}`)
@@ -199,9 +215,7 @@ func TestPinnedReleaseWorkerAdmissionAndBlock(t *testing.T) {
if _, err := a.s.DB.Exec("UPDATE users SET admin=1 WHERE id='u'"); err != nil {
t.Fatal(err)
}
npmBuildClient = &http.Client{Transport: imageRoundTrip(func(r *http.Request) (*http.Response, error) {
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"version":"0.2.0-rc.3"}`)), Header: make(http.Header)}, nil
})}
old := fetchRelease
t.Cleanup(func() { fetchRelease = old })
fetchRelease = func(ctx context.Context, repository, ref string) (releaseInfo, io.Reader, error) {
@@ -233,3 +247,56 @@ func TestPinnedReleaseWorkerAdmissionAndBlock(t *testing.T) {
t.Fatalf("%+v %v", im, err)
}
}
func TestReleaseRateLimitDedup(t *testing.T) {
a, _ := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) { t.Error("queued release contacted Docker") })
info := releaseFixture()
first, err := a.enqueueRelease("u", info)
if err != nil {
t.Fatal(err)
}
for _, commit := range []string{strings.Repeat("b", 40), strings.Repeat("c", 40)} {
next := info
next.Commit = commit
if _, err := a.enqueueRelease("u", next); err != nil {
t.Fatal(err)
}
}
again, err := a.enqueueRelease("u", info)
if err != nil || again.ID != first.ID {
t.Fatal(again, err)
}
info.Commit = strings.Repeat("d", 40)
if _, err := a.enqueueRelease("u", info); err != errBuildRateLimited {
t.Fatal(err)
}
var count int
if err := a.s.DB.QueryRow("SELECT count(*) FROM image_build_requests").Scan(&count); err != nil || count != 3 {
t.Fatal(count, err)
}
}
func TestReleaseUnknownDiskBlocksBuild(t *testing.T) {
a, _ := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) { t.Errorf("unexpected Docker request %s", r.URL) })
t.Setenv("DSH_DOCKER_ROOT_VISIBLE", "")
if _, err := a.s.DB.Exec("UPDATE users SET admin=1 WHERE id='u'"); err != nil {
t.Fatal(err)
}
info := releaseFixture()
old := fetchRelease
t.Cleanup(func() { fetchRelease = old })
fetchRelease = func(context.Context, string, string) (releaseInfo, io.Reader, error) {
return info, strings.NewReader("context"), nil
}
b, err := a.enqueueRelease("u", info)
if err != nil {
t.Fatal(err)
}
a.runImageBuild(b)
if err := scanBuildSummary(a.s.DB.QueryRow("SELECT "+buildSummaryColumns+" FROM image_builds WHERE id=?", b.ID), &b); err != nil {
t.Fatal(err)
}
if b.Status != "failed" || !strings.Contains(b.Error, "capacity unknown") {
t.Fatalf("%+v", b)
}
}
+1 -64
View File
@@ -84,43 +84,6 @@ func TestRuntimeNetworkDeletionLostResponseAndReplacement(t *testing.T) {
})
}
}
func TestStartIntentsUncertainCreationKeepsRecoveryAccessible(t *testing.T) {
a, _ := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1.47/images/image/json" {
writeImageInspect(w, "image", "1.2.3")
return
}
if r.Method != "GET" {
t.Error("unexpected destructive call")
}
w.WriteHeader(404)
})
if err := a.s.EnsureIntents(); err != nil {
t.Fatal(err)
}
if err := a.s.ReserveIntent(store.Intent{ID: "intent", UID: "owner"}); err != nil {
t.Fatal(err)
}
if err := a.s.ClaimIntent("intent", store.Instance{ID: "pending", UID: "owner", Status: "creating"}); err != nil {
t.Fatal(err)
}
if err := a.setRuntimeCreatePending("pending", "container_pending", true); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := a.StartIntents(ctx); err != nil {
t.Fatalf("uncertain creation blocked startup: %v", err)
}
i, err := a.s.GetInstance("pending")
if err != nil || i.Status != "error" {
t.Fatalf("recovery reservation missing %+v %v", i, err)
}
list, err := a.s.Intents("owner")
if err != nil || len(list) != 1 || list[0].Status != "failed" {
t.Fatalf("intent not terminated %+v %v", list, err)
}
}
func TestNetworkCreateLostResponse(t *testing.T) {
for _, mode := range []string{"present", "late", "foreign"} {
@@ -258,7 +221,7 @@ func TestBuildTerminalFailureDoesNotBlockNextTask(t *testing.T) {
t.Fatal(err)
}
for _, id := range []string{"a", "b"} {
if _, err = a.s.DB.Exec("INSERT INTO image_builds(id,version,status,created_at) VALUES(?,'1.2.3','queued',?)", id, id); err != nil {
if _, err = a.s.DB.Exec("INSERT INTO image_builds(id,version,status,created_at,release_id) VALUES(?,'1.2.3','queued',?,'release')", id, id); err != nil {
t.Fatal(err)
}
}
@@ -535,32 +498,6 @@ func TestBuildResultTransactionAndRetry(t *testing.T) {
}
}
func TestIntentRecoveryMissingReservationDoesNotTouchDocker(t *testing.T) {
a, _ := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1.47/images/image/json" {
writeImageInspect(w, "image", "1.2.3")
return
}
t.Error("missing reservation must not authorize Docker cleanup")
})
if err := a.s.EnsureIntents(); err != nil {
t.Fatal(err)
}
if err := a.s.ReserveIntent(store.Intent{ID: "missing", UID: "owner"}); err != nil {
t.Fatal(err)
}
if _, err := a.s.DB.Exec("UPDATE creation_intents SET status='creating',instance_id='gone' WHERE id='missing'"); err != nil {
t.Fatal(err)
}
if err := a.recoverIntents(context.Background()); err != nil {
t.Fatal(err)
}
list, err := a.s.Intents("owner")
if err != nil || len(list) != 1 || list[0].Status != "failed" {
t.Fatalf("intent not terminated: %+v %v", list, err)
}
}
func TestBuildTerminalRetryStopsWhenDatabaseCloses(t *testing.T) {
a, _ := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) { t.Error("unexpected Docker call") })
a.s.Close()
-4
View File
@@ -110,10 +110,6 @@ func (a *API) instances(w http.ResponseWriter, r *http.Request) {
return
}
i := store.Instance{ID: store.ID(), UID: u.ID, Name: strings.TrimSpace(in.Name), ImageID: image.ID, Version: image.Config.Labels["dsh-mgr.version"], Status: "creating", Created: time.Now()}
if err := a.s.EnsureIntents(); err != nil {
fail(w, 500, "database error")
return
}
if err := a.s.ReserveInstance(i); err != nil {
if errors.Is(err, store.ErrInstanceLimit) {
fail(w, 409, err.Error())