refactor!: separate runtime releases from panel source
This commit is contained in:
@@ -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"}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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}}})
|
||||
|
||||
@@ -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", "")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user