fix: delete instance data and remove legacy shared workspace support
This commit is contained in:
@@ -27,11 +27,7 @@ func defaultAdmission(version, recipe, platform string) string {
|
||||
return "existing_only"
|
||||
}
|
||||
func supportedLayout(recipe string) bool {
|
||||
switch recipe {
|
||||
case "3", "4", "5", "6", "7":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
return recipe == imageRecipeRev
|
||||
}
|
||||
func positiveAdmission(state string) bool { return state == "recommended" || state == "compatible" }
|
||||
|
||||
|
||||
@@ -126,13 +126,47 @@ func TestAdmissionDowngradeWaitsForCreate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdmissionHistoricalCreateAndBlock(t *testing.T) {
|
||||
func TestAdmissionRejectsLegacyLayouts(t *testing.T) {
|
||||
for _, recipe := range []string{"", "1", "2", "3", "4", "5", "6", "8"} {
|
||||
t.Run(recipe, func(t *testing.T) {
|
||||
id := "sha256:" + strings.Repeat("a", 64)
|
||||
a, sid := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.Contains(r.URL.Path, "/images/") {
|
||||
t.Error("unexpected Docker operation", r.Method, r.URL.Path)
|
||||
}
|
||||
writeJSON(w, map[string]any{"Id": id, "Os": "linux", "Architecture": "amd64", "Config": map[string]any{"Labels": map[string]string{"dsh-mgr.role": "runtime", "dsh-mgr.version": "1.2.3", "dsh-mgr.recipe": recipe}}})
|
||||
})
|
||||
seedVerifiedImage(t, a, id, "1.2.3")
|
||||
for _, query := range []string{"UPDATE image_builds SET recipe_rev=?", "UPDATE image_admissions SET recipe_rev=?"} {
|
||||
if _, err := a.s.DB.Exec(query, recipe); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := a.s.DB.Exec("UPDATE users SET admin=1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
im, err := a.evaluateImage(context.Background(), id)
|
||||
if err != nil || im.Selectable || im.Admission != "existing_only" {
|
||||
t.Fatal(im, err)
|
||||
}
|
||||
for _, state := range []string{"compatible", "recommended"} {
|
||||
w := httptest.NewRecorder()
|
||||
a.imageAdmission(w, runtimeRequest("POST", "/api/images/admission", fmt.Sprintf(`{"image_id":%q,"state":%q,"evidence":"historical acceptance"}`, id, state), sid))
|
||||
if w.Code != 400 {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdmissionCurrentCreateAndBlock(t *testing.T) {
|
||||
id := "sha256:" + strings.Repeat("a", 64)
|
||||
var body map[string]any
|
||||
a, sid := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.Contains(r.URL.Path, "/images/"):
|
||||
writeJSON(w, map[string]any{"Id": id, "Os": "linux", "Architecture": "amd64", "Config": map[string]any{"Labels": map[string]string{"dsh-mgr.role": "runtime", "dsh-mgr.version": "1.2.3", "dsh-mgr.recipe": "4"}}})
|
||||
writeJSON(w, map[string]any{"Id": id, "Os": "linux", "Architecture": "amd64", "Config": map[string]any{"Labels": map[string]string{"dsh-mgr.role": "runtime", "dsh-mgr.version": "1.2.3", "dsh-mgr.recipe": imageRecipeRev}}})
|
||||
case strings.HasSuffix(r.URL.Path, "/containers/create"):
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Error(err)
|
||||
@@ -143,33 +177,33 @@ func TestAdmissionHistoricalCreateAndBlock(t *testing.T) {
|
||||
}
|
||||
})
|
||||
seedVerifiedImage(t, a, id, "1.2.3")
|
||||
if _, err := a.s.DB.Exec("UPDATE image_builds SET recipe_rev='4'; DELETE FROM image_admissions; UPDATE users SET admin=1"); err != nil {
|
||||
if _, err := a.s.DB.Exec("DELETE FROM image_admissions; UPDATE users SET admin=1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
im, err := a.evaluateImage(context.Background(), id)
|
||||
if err != nil || im.Status != "existing_only" || im.Selectable {
|
||||
t.Fatal(im, err)
|
||||
}
|
||||
records := []buildRecord{{ID: "unused", ImageID: id, Version: "1.2.3", RecipeRev: "4", Platform: imagePlatform, Status: "success"}}
|
||||
records := []buildRecord{{ID: "unused", ImageID: id, Version: "1.2.3", RecipeRev: imageRecipeRev, Platform: imagePlatform, Status: "success"}}
|
||||
if err := a.reconcileBuilds(context.Background(), records); err != nil || records[0].Status != "success" {
|
||||
t.Fatal(records, err)
|
||||
}
|
||||
admit := func(state string) *httptest.ResponseRecorder {
|
||||
w := httptest.NewRecorder()
|
||||
a.imageAdmission(w, runtimeRequest("POST", "/api/images/admission", fmt.Sprintf(`{"image_id":%q,"state":%q,"evidence":"legacy layout explicitly accepted"}`, id, state), sid))
|
||||
a.imageAdmission(w, runtimeRequest("POST", "/api/images/admission", fmt.Sprintf(`{"image_id":%q,"state":%q,"evidence":"current layout explicitly accepted"}`, id, state), sid))
|
||||
return w
|
||||
}
|
||||
if w := admit("compatible"); w.Code != 200 {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
a.instances(w, runtimeRequest("POST", "/api/instances", fmt.Sprintf(`{"name":"legacy","image_id":%q}`, id), sid))
|
||||
a.instances(w, runtimeRequest("POST", "/api/instances", fmt.Sprintf(`{"name":"current","image_id":%q}`, id), sid))
|
||||
if w.Code != 200 {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
host := body["HostConfig"].(map[string]any)
|
||||
mounts := host["Mounts"].([]any)
|
||||
if len(mounts) != 2 || mounts[0].(map[string]any)["Target"] != "/data/dsh-home" || mounts[1].(map[string]any)["Target"] != "/workspace" || !strings.Contains(fmt.Sprint(body["Env"]), "DSH_HOME=/data/dsh-home") {
|
||||
if len(mounts) != 1 || mounts[0].(map[string]any)["Target"] != "/workspace" || !strings.Contains(fmt.Sprint(body["Env"]), "DSH_HOME=/workspace") {
|
||||
t.Fatal(body)
|
||||
}
|
||||
var instance store.Instance
|
||||
|
||||
+14
-28
@@ -80,40 +80,21 @@ func safeRel(p string) (string, error) {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func workspacePath(uid string) string { return path.Join("workspaces", uid, "files") }
|
||||
|
||||
func (a *API) openWorkspace(u store.User, instanceIDs ...string) (*os.Root, error) {
|
||||
instanceID := ""
|
||||
if len(instanceIDs) > 0 {
|
||||
instanceID = instanceIDs[0]
|
||||
func (a *API) openWorkspace(u store.User, instanceID string) (*os.Root, error) {
|
||||
if instanceID == "" {
|
||||
return nil, errors.New("instance required")
|
||||
}
|
||||
base, err := os.OpenRoot(a.root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer base.Close()
|
||||
parts := []string{"workspaces", path.Join("workspaces", u.ID), workspacePath(u.ID)}
|
||||
if instanceID != "" {
|
||||
rel := path.Join("workspaces", u.ID, "instances", instanceID, "home")
|
||||
if err := rootSafe(base, rel, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return base.OpenRoot(rel)
|
||||
}
|
||||
for _, p := range parts {
|
||||
if err = rootSafe(base, p, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = base.Mkdir(p, 0750); err != nil && !errors.Is(err, os.ErrExist) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err = base.Chown(workspacePath(u.ID), 1000, 1000); err != nil {
|
||||
rel := path.Join("workspaces", u.ID, "instances", instanceID, "home")
|
||||
if err := rootSafe(base, rel, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return base.OpenRoot(workspacePath(u.ID))
|
||||
return base.OpenRoot(rel)
|
||||
}
|
||||
|
||||
func rootSafe(root *os.Root, rel string, finalMayMissing bool) error {
|
||||
if rel == "." {
|
||||
return nil
|
||||
@@ -184,6 +165,10 @@ func (a *API) workspace(w http.ResponseWriter, r *http.Request) {
|
||||
a.readersMu.Unlock()
|
||||
}()
|
||||
}
|
||||
if r.URL.Path == "/api/ws/usage" {
|
||||
a.wsUsage(w, r, u)
|
||||
return
|
||||
}
|
||||
user := a.userLocks.Acquire(u.ID)
|
||||
root, err := a.openWorkspace(u, instanceID)
|
||||
user.Release()
|
||||
@@ -193,8 +178,6 @@ func (a *API) workspace(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
defer root.Close()
|
||||
switch strings.TrimPrefix(r.URL.Path, "/api/ws/") {
|
||||
case "usage":
|
||||
a.wsUsage(w, r, root, u)
|
||||
case "tree":
|
||||
a.wsTree(w, r, root, u)
|
||||
case "mkdir":
|
||||
@@ -219,7 +202,7 @@ func entry(root *os.Root, rel string) (os.FileInfo, error) {
|
||||
return root.Lstat(rel)
|
||||
}
|
||||
|
||||
func (a *API) wsUsage(w http.ResponseWriter, r *http.Request, root *os.Root, u store.User) {
|
||||
func (a *API) wsUsage(w http.ResponseWriter, r *http.Request, u store.User) {
|
||||
if r.Method != "GET" {
|
||||
fail(w, 405, "method not allowed")
|
||||
return
|
||||
@@ -537,6 +520,9 @@ func (a *API) userUsage(u store.User) (int64, error) {
|
||||
}
|
||||
defer base.Close()
|
||||
root, e := base.OpenRoot(path.Join("workspaces", u.ID))
|
||||
if errors.Is(e, os.ErrNotExist) {
|
||||
return 0, nil
|
||||
}
|
||||
if e != nil {
|
||||
return 0, e
|
||||
}
|
||||
|
||||
@@ -449,7 +449,10 @@ func TestWorkspaceSafetyAndUpload(t *testing.T) {
|
||||
if err := s.CreateUser(u, "unused"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root, e := a.openWorkspace(u)
|
||||
if err := os.MkdirAll(filepath.Join(a.root, "workspaces/user/instances/one/home"), 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root, e := a.openWorkspace(u, "one")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
@@ -463,7 +466,7 @@ func TestWorkspaceSafetyAndUpload(t *testing.T) {
|
||||
}
|
||||
outside := t.TempDir()
|
||||
os.WriteFile(filepath.Join(outside, "secret"), []byte("secret"), 0600)
|
||||
if e = os.Symlink(outside, filepath.Join(a.root, "workspaces/user/files/link")); e != nil {
|
||||
if e = os.Symlink(outside, filepath.Join(a.root, "workspaces/user/instances/one/home/link")); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"git.misaka.ren/m1saka/dsh-web/internal/store"
|
||||
)
|
||||
|
||||
// Call only with the instance lock held, after Docker resources are removed.
|
||||
func (a *API) removeInstanceData(i store.Instance) error {
|
||||
for _, id := range []string{i.UID, i.ID} {
|
||||
if id == "" || id == "." || id == ".." || strings.ContainsAny(id, "/\\") {
|
||||
return errors.New("invalid instance storage path")
|
||||
}
|
||||
}
|
||||
for _, parts := range [][]string{{"workspaces", i.UID, "instances", i.ID}, {"backups", i.UID, i.ID}} {
|
||||
if err := a.removeDataDirectory(parts); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Walk parents without following symlinks; RemoveAll unlinks child symlinks.
|
||||
func (a *API) removeDataDirectory(parts []string) error {
|
||||
root, err := os.OpenRoot(a.root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { root.Close() }()
|
||||
for _, part := range parts[:len(parts)-1] {
|
||||
st, err := root.Lstat(part)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !st.IsDir() {
|
||||
return errors.New("unsafe storage path")
|
||||
}
|
||||
next, err := root.OpenRoot(part)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
actual, err := next.Stat(".")
|
||||
if err != nil || !os.SameFile(st, actual) {
|
||||
next.Close()
|
||||
return errors.New("storage path changed")
|
||||
}
|
||||
root.Close()
|
||||
root = next
|
||||
}
|
||||
return root.RemoveAll(parts[len(parts)-1])
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.misaka.ren/m1saka/dsh-web/internal/store"
|
||||
)
|
||||
|
||||
func TestInstanceDeleteDataAndRetry(t *testing.T) {
|
||||
for _, blocked := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "success", true: "retry"}[blocked], func(t *testing.T) {
|
||||
a, sid := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodDelete {
|
||||
w.WriteHeader(204)
|
||||
} else {
|
||||
w.WriteHeader(404)
|
||||
}
|
||||
})
|
||||
i := store.Instance{ID: "one", UID: "owner", ContainerID: "container"}
|
||||
if err := a.s.AddInstance(i); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
write := func(rel string) {
|
||||
p := filepath.Join(a.root, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte("keep"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write("workspaces/owner/instances/one/home/data")
|
||||
write("workspaces/owner/instances/one/pre-restore-test/data")
|
||||
write("workspaces/owner/instances/two/home/data")
|
||||
write("backups/owner/one/archive.dshbak")
|
||||
write("backups/owner/two/archive.dshbak")
|
||||
outside := t.TempDir()
|
||||
sentinel := filepath.Join(outside, "sentinel")
|
||||
if err := os.WriteFile(sentinel, []byte("safe"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join(a.root, "workspaces/owner/instances/one/home/link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backups := filepath.Join(a.root, "backups/owner")
|
||||
if blocked {
|
||||
if err := os.Rename(backups, backups+"-saved"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(backups+"-saved", backups); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
remove := func() *httptest.ResponseRecorder {
|
||||
w := httptest.NewRecorder()
|
||||
a.instance(w, runtimeRequest("DELETE", "/api/instances/one", "", sid))
|
||||
return w
|
||||
}
|
||||
w := remove()
|
||||
if blocked {
|
||||
if w.Code != 500 {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
current, err := a.s.GetInstance(i.ID)
|
||||
if err != nil || current.Status != "error" {
|
||||
t.Fatal(current, err)
|
||||
}
|
||||
if err := os.Remove(backups); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Rename(backups+"-saved", backups); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w = remove()
|
||||
}
|
||||
if w.Code != 200 {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
for _, rel := range []string{"workspaces/owner/instances/one", "backups/owner/one"} {
|
||||
if _, err := os.Lstat(filepath.Join(a.root, rel)); !os.IsNotExist(err) {
|
||||
t.Fatal("data retained", rel, err)
|
||||
}
|
||||
}
|
||||
for _, p := range []string{sentinel, filepath.Join(a.root, "workspaces/owner/instances/two/home/data"), filepath.Join(a.root, "backups/owner/two/archive.dshbak")} {
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
t.Fatal("unrelated data removed", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsageDoesNotCreateLegacyFiles(t *testing.T) {
|
||||
a, sid := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) { t.Fatal("unexpected Docker request") })
|
||||
w := httptest.NewRecorder()
|
||||
a.workspace(w, runtimeRequest("GET", "/api/ws/usage", "", sid))
|
||||
if w.Code != 200 {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(a.root, "workspaces/owner/files")); !os.IsNotExist(err) {
|
||||
t.Fatal("legacy directory created", err)
|
||||
}
|
||||
if _, err := a.openWorkspace(store.User{ID: "owner"}, ""); err == nil {
|
||||
t.Fatal("shared workspace accepted")
|
||||
}
|
||||
}
|
||||
@@ -62,17 +62,6 @@ func (a *API) checkOrphan(ctx context.Context, id string) error {
|
||||
return errors.New("host root unavailable")
|
||||
}
|
||||
expected := map[string]string{"/workspace": filepath.Join(host, "workspaces", owner, "instances", inst, "home")}
|
||||
// Accept complete historical layouts, never arbitrary bind sources.
|
||||
for _, m := range c.Mounts {
|
||||
if m.Destination == "/data/dsh-home" {
|
||||
expected = map[string]string{"/workspace": filepath.Join(host, "workspaces", owner, "files"), "/data/dsh-home": filepath.Join(host, "workspaces", owner, "instances", inst, "home")}
|
||||
}
|
||||
}
|
||||
for _, m := range c.Mounts {
|
||||
if m.Destination == "/shared" {
|
||||
expected["/shared"] = filepath.Join(host, "workspaces", owner, "files")
|
||||
}
|
||||
}
|
||||
for _, m := range c.Mounts {
|
||||
if m.Type == "tmpfs" && m.Destination == "/tmp" {
|
||||
continue
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
)
|
||||
|
||||
func TestOrphanReclaimGuards(t *testing.T) {
|
||||
for _, kind := range []string{"safe", "running", "foreign-mount", "reference", "upgrade", "confirmation", "nonadmin"} {
|
||||
for _, kind := range []string{"safe", "legacy", "shared", "running", "foreign-mount", "reference", "upgrade", "confirmation", "nonadmin"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
t.Setenv("HOST_DATA_ROOT", "/host/data")
|
||||
id, inst, owner := strings.Repeat("a", 64), strings.Repeat("b", 32), strings.Repeat("c", 32)
|
||||
@@ -28,7 +28,15 @@ func TestOrphanReclaimGuards(t *testing.T) {
|
||||
if kind == "foreign-mount" {
|
||||
source = "/unrelated"
|
||||
}
|
||||
writeJSON(w, map[string]any{"Id": id, "Name": "/dsh-inst-" + inst, "Config": map[string]any{"Labels": map[string]string{"dsh-mgr.role": "dsh", "dsh-mgr.instance": inst, "dsh-mgr.owner": owner}}, "State": map[string]any{"Status": "exited", "Running": kind == "running"}, "Mounts": []map[string]string{{"Type": "bind", "Source": source, "Destination": "/workspace"}}})
|
||||
mounts := []map[string]string{{"Type": "bind", "Source": source, "Destination": "/workspace"}}
|
||||
if kind == "legacy" {
|
||||
mounts[0]["Destination"] = "/data/dsh-home"
|
||||
mounts = append(mounts, map[string]string{"Type": "bind", "Source": "/host/data/workspaces/" + owner + "/files", "Destination": "/workspace"})
|
||||
}
|
||||
if kind == "shared" {
|
||||
mounts = append(mounts, map[string]string{"Type": "bind", "Source": "/host/data/workspaces/" + owner + "/files", "Destination": "/shared"})
|
||||
}
|
||||
writeJSON(w, map[string]any{"Id": id, "Name": "/dsh-inst-" + inst, "Config": map[string]any{"Labels": map[string]string{"dsh-mgr.role": "dsh", "dsh-mgr.instance": inst, "dsh-mgr.owner": owner}}, "State": map[string]any{"Status": "exited", "Running": kind == "running"}, "Mounts": mounts})
|
||||
})
|
||||
if err := a.s.CreateUser(store.User{ID: owner, Username: "orphan-owner"}, "unused"); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
+11
-10
@@ -167,6 +167,10 @@ func (a *API) createRuntime(ctx context.Context, u store.User, i store.Instance,
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := a.removeInstanceData(i); err != nil {
|
||||
a.s.SetInstance(i.ID, "error")
|
||||
return
|
||||
}
|
||||
a.s.DeleteInstance(i.ID)
|
||||
_, _ = a.s.DB.Exec("DELETE FROM runtime_networks WHERE instance_id=?", i.ID)
|
||||
_, _ = a.s.DB.Exec("DELETE FROM runtime_creates WHERE instance_id=?", i.ID)
|
||||
@@ -191,7 +195,7 @@ func (a *API) createRuntime(ctx context.Context, u store.User, i store.Instance,
|
||||
return i, errors.New("storage unavailable")
|
||||
}
|
||||
suffix := filepath.Join("workspaces", u.ID)
|
||||
for _, relative := range []string{filepath.Join(suffix, "files"), filepath.Join(suffix, "instances", i.ID, "home")} {
|
||||
for _, relative := range []string{filepath.Join(suffix, "instances", i.ID, "home")} {
|
||||
dir := filepath.Join(root, relative)
|
||||
if err := os.MkdirAll(dir, 0750); err != nil {
|
||||
return i, errors.New("storage unavailable")
|
||||
@@ -258,14 +262,6 @@ func (a *API) createRuntime(ctx context.Context, u store.User, i store.Instance,
|
||||
},
|
||||
},
|
||||
}
|
||||
if policy.RecipeRev != "7" {
|
||||
sharedSource, err := toHostPath(root, hostRoot, filepath.Join(root, suffix, "files"))
|
||||
if err != nil {
|
||||
return i, err
|
||||
}
|
||||
body["Env"] = []string{"DSH_HOME=/data/dsh-home", "HOME=/data/dsh-home", "XDG_DATA_HOME=/data/dsh-home/.local/share", "XDG_CONFIG_HOME=/data/dsh-home/.config"}
|
||||
body["HostConfig"].(map[string]any)["Mounts"] = []map[string]any{{"Type": "bind", "Source": homeSource, "Target": "/data/dsh-home"}, {"Type": "bind", "Source": sharedSource, "Target": "/workspace"}}
|
||||
}
|
||||
var created struct {
|
||||
ID string `json:"Id"`
|
||||
}
|
||||
@@ -415,12 +411,17 @@ func (a *API) instance(w http.ResponseWriter, r *http.Request) {
|
||||
fail(w, 502, "network deletion failed")
|
||||
return
|
||||
}
|
||||
if err := a.removeInstanceData(i); err != nil {
|
||||
a.s.SetInstance(i.ID, "error")
|
||||
fail(w, 500, "instance data deletion failed; retry deletion")
|
||||
return
|
||||
}
|
||||
if _, err := a.s.DB.ExecContext(ctx, "DELETE FROM instances WHERE id=?", i.ID); err != nil {
|
||||
fail(w, 500, "database error")
|
||||
return
|
||||
}
|
||||
_, _ = a.s.DB.Exec("DELETE FROM runtime_networks WHERE instance_id=?", i.ID)
|
||||
a.s.AddAudit(u.ID, "instance.delete", i.ID, "data retained")
|
||||
a.s.AddAudit(u.ID, "instance.delete", i.ID, "instance data deleted")
|
||||
// The instance row is gone and its resources were just cleaned, so
|
||||
// any pending flags cannot resolve later: drop the row unconditionally.
|
||||
_, _ = a.s.DB.Exec("DELETE FROM runtime_creates WHERE instance_id=?", i.ID)
|
||||
|
||||
@@ -278,8 +278,8 @@ func TestRuntimeLifecycleAndOwnership(t *testing.T) {
|
||||
if w.Code != 200 {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
t.Fatal("data removed", err)
|
||||
if _, err := os.Stat(dir); !os.IsNotExist(err) {
|
||||
t.Fatal("instance data retained", err)
|
||||
}
|
||||
if _, err := a.s.GetInstance(i.ID); err == nil {
|
||||
t.Fatal("row retained")
|
||||
|
||||
+6
-54
@@ -20,7 +20,7 @@ import (
|
||||
|
||||
const upgradeSnapshotLimit int64 = 1 << 30
|
||||
|
||||
func upgradeWorkspace(config, host map[string]json.RawMessage, recipe, home, shared string) error {
|
||||
func upgradeWorkspace(config, host map[string]json.RawMessage, recipe, home string) error {
|
||||
var mounts []map[string]json.RawMessage
|
||||
if err := json.Unmarshal(host["Mounts"], &mounts); err != nil {
|
||||
return errors.New("unsupported runtime mounts")
|
||||
@@ -30,53 +30,11 @@ func upgradeWorkspace(config, host map[string]json.RawMessage, recipe, home, sha
|
||||
_ = json.Unmarshal(m[key], &value)
|
||||
return value
|
||||
}
|
||||
homeIndex, sharedIndex := -1, -1
|
||||
for n, m := range mounts {
|
||||
target, source := get(m, "Target"), get(m, "Source")
|
||||
if target != "/workspace" && target != "/data/dsh-home" && target != "/shared" {
|
||||
continue
|
||||
}
|
||||
if get(m, "Type") != "bind" {
|
||||
return errors.New("unsupported runtime mount type")
|
||||
}
|
||||
switch {
|
||||
case source == home && (target == "/workspace" || target == "/data/dsh-home") && homeIndex == -1:
|
||||
homeIndex = n
|
||||
case source == shared && (target == "/workspace" || target == "/shared") && sharedIndex == -1:
|
||||
sharedIndex = n
|
||||
default:
|
||||
return errors.New("ambiguous runtime mounts")
|
||||
}
|
||||
}
|
||||
if homeIndex == -1 {
|
||||
return errors.New("instance home mount required")
|
||||
}
|
||||
if !supportedLayout(recipe) {
|
||||
return errors.New("unsupported template layout")
|
||||
return errors.New("unsupported template layout; template 7 required")
|
||||
}
|
||||
if recipe != "7" {
|
||||
if get(mounts[homeIndex], "Target") == "/workspace" {
|
||||
return errors.New("workspace home cannot be downgraded to legacy shared layout")
|
||||
}
|
||||
if sharedIndex == -1 || get(mounts[sharedIndex], "Target") != "/workspace" {
|
||||
return errors.New("legacy shared workspace mount required")
|
||||
}
|
||||
var env []string
|
||||
if raw := config["Env"]; len(raw) != 0 {
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
kept := []string{}
|
||||
for _, entry := range env {
|
||||
key, _, _ := strings.Cut(entry, "=")
|
||||
if key != "DSH_HOME" && key != "HOME" && key != "XDG_DATA_HOME" && key != "XDG_CONFIG_HOME" {
|
||||
kept = append(kept, entry)
|
||||
}
|
||||
}
|
||||
kept = append(kept, "DSH_HOME=/data/dsh-home", "HOME=/data/dsh-home", "XDG_DATA_HOME=/data/dsh-home/.local/share", "XDG_CONFIG_HOME=/data/dsh-home/.config")
|
||||
config["Env"], _ = json.Marshal(kept)
|
||||
return nil
|
||||
if len(mounts) != 1 || get(mounts[0], "Type") != "bind" || get(mounts[0], "Source") != home || get(mounts[0], "Target") != "/workspace" {
|
||||
return errors.New("independent instance home at /workspace required")
|
||||
}
|
||||
for _, key := range []string{"Binds", "VolumesFrom"} {
|
||||
var values []string
|
||||
@@ -102,11 +60,6 @@ func upgradeWorkspace(config, host map[string]json.RawMessage, recipe, home, sha
|
||||
}
|
||||
}
|
||||
kept = append(kept, "DSH_HOME=/workspace", "HOME=/workspace", "XDG_DATA_HOME=/workspace/.local/share", "XDG_CONFIG_HOME=/workspace/.config")
|
||||
mounts[homeIndex]["Target"], _ = json.Marshal("/workspace")
|
||||
if sharedIndex != -1 {
|
||||
mounts = append(mounts[:sharedIndex], mounts[sharedIndex+1:]...)
|
||||
}
|
||||
host["Mounts"], _ = json.Marshal(mounts)
|
||||
config["Env"], _ = json.Marshal(kept)
|
||||
return nil
|
||||
}
|
||||
@@ -373,12 +326,11 @@ func (a *API) upgrade(w http.ResponseWriter, r *http.Request, i store.Instance)
|
||||
hostRoot = a.root
|
||||
}
|
||||
homeSource, homeErr := toHostPath(a.root, hostRoot, filepath.Join(root.Name(), "home"))
|
||||
sharedSource, sharedErr := toHostPath(a.root, hostRoot, filepath.Join(a.root, "workspaces", i.UID, "files"))
|
||||
if homeErr != nil || sharedErr != nil {
|
||||
if homeErr != nil {
|
||||
fail(w, 500, "host storage unavailable")
|
||||
return
|
||||
}
|
||||
if err = upgradeWorkspace(old.Config, old.HostConfig, image.Config.Labels["dsh-mgr.recipe"], homeSource, sharedSource); err != nil {
|
||||
if err = upgradeWorkspace(old.Config, old.HostConfig, image.Config.Labels["dsh-mgr.recipe"], homeSource); err != nil {
|
||||
fail(w, 409, "incompatible runtime workspace layout")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -23,10 +23,10 @@ func TestUpgradeWorkspaceCompatibility(t *testing.T) {
|
||||
name, recipe, target string
|
||||
shared, reject bool
|
||||
}{
|
||||
{"legacy-to-seven", "7", "/data/dsh-home", true, false},
|
||||
{"legacy-to-seven", "7", "/data/dsh-home", true, true},
|
||||
{"single-to-seven", "7", "/workspace", false, false},
|
||||
{"migrated-to-seven", "7", "/workspace", true, false},
|
||||
{"legacy-to-six", "6", "/data/dsh-home", true, false},
|
||||
{"migrated-to-seven", "7", "/workspace", true, true},
|
||||
{"legacy-to-six", "6", "/data/dsh-home", true, true},
|
||||
{"legacy-to-unknown", "", "/data/dsh-home", true, true},
|
||||
{"single-to-six", "6", "/workspace", false, true},
|
||||
{"single-to-unknown", "", "/workspace", false, true},
|
||||
@@ -44,16 +44,10 @@ func TestUpgradeWorkspaceCompatibility(t *testing.T) {
|
||||
host := map[string]json.RawMessage{"Mounts": raw}
|
||||
config := map[string]json.RawMessage{"Env": json.RawMessage(`["CUSTOM=yes","DSH_HOME=/data/dsh-home","HOME=/data/dsh-home","XDG_DATA_HOME=/old/data","XDG_CONFIG_HOME=/old/config"]`)}
|
||||
before := string(config["Env"])
|
||||
err := upgradeWorkspace(config, host, tc.recipe, "/instance/home", "/user/files")
|
||||
err := upgradeWorkspace(config, host, tc.recipe, "/instance/home")
|
||||
if (err != nil) != tc.reject {
|
||||
t.Fatalf("error=%v", err)
|
||||
}
|
||||
if !tc.reject && tc.recipe != "7" {
|
||||
if !strings.Contains(string(config["Env"]), "XDG_DATA_HOME=/data/dsh-home/.local/share") || string(host["Mounts"]) != string(raw) {
|
||||
t.Fatal(config, host)
|
||||
}
|
||||
return
|
||||
}
|
||||
if tc.reject {
|
||||
if string(host["Mounts"]) != string(raw) || string(config["Env"]) != before {
|
||||
t.Fatal("legacy or rejected config changed")
|
||||
@@ -71,7 +65,7 @@ func TestUpgradeWorkspaceCompatibility(t *testing.T) {
|
||||
t.Fatal(got)
|
||||
}
|
||||
first, _ := json.Marshal([]any{config, host})
|
||||
if err := upgradeWorkspace(config, host, tc.recipe, "/instance/home", "/user/files"); err != nil {
|
||||
if err := upgradeWorkspace(config, host, tc.recipe, "/instance/home"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, _ := json.Marshal([]any{config, host})
|
||||
@@ -90,9 +84,9 @@ func TestUpgradeWorkspaceRejectsAmbiguousMounts(t *testing.T) {
|
||||
`{"Type":"bind","Source":"/instance/home","Target":"/workspace"}`,
|
||||
} {
|
||||
config := map[string]json.RawMessage{"Env": json.RawMessage(`["CUSTOM=yes"]`)}
|
||||
host := map[string]json.RawMessage{"Mounts": json.RawMessage(`[{"Type":"bind","Source":"/instance/home","Target":"/data/dsh-home"},` + extra + `]`)}
|
||||
host := map[string]json.RawMessage{"Mounts": json.RawMessage(`[{"Type":"bind","Source":"/instance/home","Target":"/workspace"},` + extra + `]`)}
|
||||
before, _ := json.Marshal([]any{config, host})
|
||||
if err := upgradeWorkspace(config, host, "7", "/instance/home", "/user/files"); err == nil {
|
||||
if err := upgradeWorkspace(config, host, "7", "/instance/home"); err == nil {
|
||||
t.Fatal("accepted ambiguous mounts", extra)
|
||||
}
|
||||
after, _ := json.Marshal([]any{config, host})
|
||||
@@ -106,7 +100,7 @@ func TestUpgradeLifecycle(t *testing.T) {
|
||||
if os.Geteuid() != 0 {
|
||||
t.Skip("requires chown")
|
||||
}
|
||||
for _, mode := range []string{"success", "start-failure", "readiness-failure", "cleanup-failure", "running", "running-failure", "restart-failure", "delete-failure", "invalid-image", "unverified-image", "failed-build", "version-mismatch", "image-id-mismatch", "db-failure"} {
|
||||
for _, mode := range []string{"success", "legacy-layout", "shared-layout", "start-failure", "readiness-failure", "cleanup-failure", "running", "running-failure", "restart-failure", "delete-failure", "invalid-image", "unverified-image", "failed-build", "version-mismatch", "image-id-mismatch", "db-failure"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
image := "sha256:" + strings.Repeat("b", 64)
|
||||
var a *API
|
||||
@@ -149,7 +143,15 @@ func TestUpgradeLifecycle(t *testing.T) {
|
||||
if running {
|
||||
state = "running"
|
||||
}
|
||||
writeJSON(w, map[string]any{"Config": map[string]any{"Hostname": "old", "Cmd": []string{"obsolete-flag"}, "Image": "old-image", "Env": []string{"CUSTOM=yes"}, "User": "1000:1000"}, "HostConfig": map[string]any{"NetworkMode": "dsh-inst-one", "Memory": 9007199254740993, "Mounts": []map[string]string{{"Type": "bind", "Source": home, "Target": "/data/dsh-home"}, {"Type": "bind", "Source": shared, "Target": "/workspace"}}}, "State": map[string]any{"Status": state, "Running": running && !stopped}, "NetworkSettings": map[string]any{"Networks": map[string]any{"dsh-inst-one": map[string]any{"IPAddress": "172.18.0.3", "Aliases": []string{"old"}}}}})
|
||||
mounts := []map[string]string{{"Type": "bind", "Source": home, "Target": "/workspace"}}
|
||||
if mode == "legacy-layout" {
|
||||
mounts[0]["Target"] = "/data/dsh-home"
|
||||
mounts = append(mounts, map[string]string{"Type": "bind", "Source": shared, "Target": "/workspace"})
|
||||
}
|
||||
if mode == "shared-layout" {
|
||||
mounts = append(mounts, map[string]string{"Type": "bind", "Source": shared, "Target": "/shared"})
|
||||
}
|
||||
writeJSON(w, map[string]any{"Config": map[string]any{"Hostname": "old", "Cmd": []string{"obsolete-flag"}, "Image": "old-image", "Env": []string{"CUSTOM=yes"}, "User": "1000:1000"}, "HostConfig": map[string]any{"NetworkMode": "dsh-inst-one", "Memory": 9007199254740993, "Mounts": mounts}, "State": map[string]any{"Status": state, "Running": running && !stopped}, "NetworkSettings": map[string]any{"Networks": map[string]any{"dsh-inst-one": map[string]any{"IPAddress": "172.18.0.3", "Aliases": []string{"old"}}}}})
|
||||
case strings.HasPrefix(p, "/images/"):
|
||||
role := "runtime"
|
||||
if mode == "invalid-image" {
|
||||
@@ -273,8 +275,11 @@ func TestUpgradeLifecycle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mode == "invalid-image" || mode == "unverified-image" || mode == "failed-build" || mode == "version-mismatch" || mode == "image-id-mismatch" {
|
||||
if mode == "legacy-layout" || mode == "shared-layout" || mode == "invalid-image" || mode == "unverified-image" || mode == "failed-build" || mode == "version-mismatch" || mode == "image-id-mismatch" {
|
||||
want := 400
|
||||
if mode == "legacy-layout" || mode == "shared-layout" {
|
||||
want = 409
|
||||
}
|
||||
if w.Code != want {
|
||||
t.Fatalf("%d %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user