268 lines
9.3 KiB
Go
268 lines
9.3 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"git.misaka.ren/m1saka/dsh-web/internal/docker"
|
|
"git.misaka.ren/m1saka/dsh-web/internal/store"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"syscall"
|
|
"testing"
|
|
)
|
|
|
|
func snapshotRestoreFixture(t *testing.T, running bool, failure string) (*API, store.Instance, string, string) {
|
|
t.Helper()
|
|
a, i, home := configTestAPI(t, running, failure)
|
|
i.ImageID = "sha256:" + strings.Repeat("a", 64)
|
|
i.Version = "1.0"
|
|
if _, err := a.s.DB.Exec("UPDATE instances SET image_id=?,version=? WHERE id=?", i.ImageID, i.Version, i.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
original := a.docker
|
|
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if strings.Contains(r.URL.Path, "/images/") {
|
|
writeJSON(w, map[string]any{"Id": i.ImageID, "Config": map[string]any{"Labels": map[string]string{"dsh-mgr.recipe": "7", "dsh-mgr.version": i.Version}}})
|
|
return
|
|
}
|
|
if strings.HasSuffix(r.URL.Path, "/json") {
|
|
var data map[string]any
|
|
if err := original.Do(r.Context(), "GET", r.URL.RequestURI(), nil, &data); err != nil {
|
|
t.Error(err)
|
|
w.WriteHeader(500)
|
|
return
|
|
}
|
|
data["Image"] = i.ImageID
|
|
data["Config"] = map[string]any{"Env": []string{"DSH_HOME=/workspace", "HOME=/workspace", "XDG_DATA_HOME=/workspace/.local/share", "XDG_CONFIG_HOME=/workspace/.config"}}
|
|
data["Mounts"] = []map[string]string{{"Type": "bind", "Source": home, "Destination": "/workspace"}}
|
|
data["HostConfig"] = map[string]any{"Mounts": []map[string]string{{"Type": "bind", "Source": home, "Target": "/workspace"}}}
|
|
data["HostConfig"].(map[string]any)["Tmpfs"] = map[string]string{"/tmp": "rw,noexec,nosuid,size=64m"}
|
|
writeJSON(w, data)
|
|
return
|
|
}
|
|
resp, err := original.Stream(r.Context(), r.Method, r.URL.RequestURI(), nil, "")
|
|
if err != nil {
|
|
t.Error(err)
|
|
w.WriteHeader(500)
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
w.WriteHeader(resp.StatusCode)
|
|
io.Copy(w, resp.Body)
|
|
}))
|
|
t.Cleanup(proxy.Close)
|
|
a.docker = docker.New(proxy.URL)
|
|
id := "upgrade-" + strings.Repeat("b", 32) + "-snapshot"
|
|
if err := os.Mkdir(filepath.Join(filepath.Dir(home), id), 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
configWrite(t, filepath.Join(filepath.Dir(home), id, "data"), "snapshot")
|
|
configWrite(t, filepath.Join(home, "data"), "current")
|
|
return a, i, home, id
|
|
}
|
|
|
|
func snapshotRestoreBody(id string, unknown bool) string {
|
|
return fmt.Sprintf(`{"id":%q,"password":"original-password","confirm":true,"allow_unknown_version":%t}`, id, unknown)
|
|
}
|
|
|
|
func TestSnapshotRuntimeLayout(t *testing.T) {
|
|
for _, test := range []string{"valid", "env", "tmpfs", "volume", "effective"} {
|
|
t.Run(test, func(t *testing.T) {
|
|
env := []string{"DSH_HOME=/workspace", "HOME=/workspace", "XDG_DATA_HOME=/workspace/.local/share", "XDG_CONFIG_HOME=/workspace/.config"}
|
|
if test == "env" {
|
|
env = append(env, "DSH_HOME=/old-shared")
|
|
}
|
|
raw, _ := json.Marshal(env)
|
|
config := map[string]json.RawMessage{"Env": raw}
|
|
host := map[string]json.RawMessage{"Tmpfs": json.RawMessage(`{"/tmp":"rw"}`)}
|
|
mounts := []struct{ Type, Source, Destination string }{{"bind", "/home", "/workspace"}}
|
|
mounts = append(mounts, struct{ Type, Source, Destination string }{"tmpfs", "", "/tmp"})
|
|
if test == "tmpfs" {
|
|
host["Tmpfs"] = json.RawMessage(`{"/workspace/data":"rw"}`)
|
|
}
|
|
if test == "volume" {
|
|
config["Volumes"] = json.RawMessage(`{"/workspace/data":{}}`)
|
|
}
|
|
if test == "effective" {
|
|
mounts = append(mounts, struct{ Type, Source, Destination string }{"volume", "/other", "/workspace/data"})
|
|
}
|
|
if snapshotRuntimeLayout(config, host, mounts, "/home") != (test == "valid") {
|
|
t.Fatal(test)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSnapshotListProvenance(t *testing.T) {
|
|
a, i, _, id := snapshotRestoreFixture(t, false, "")
|
|
for _, known := range []bool{false, true} {
|
|
if known {
|
|
if err := a.recordSnapshot(i, id, "old-version", i.ImageID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
w := configCall(a, i, "GET", "snapshots", "")
|
|
var list []snapshotInfo
|
|
if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &list) != nil || len(list) != 1 {
|
|
t.Fatal(w.Code, w.Body.String())
|
|
}
|
|
if list[0].Restorable == known || list[0].RestoreReason == "" {
|
|
t.Fatal(list)
|
|
}
|
|
if !known && (list[0].SourceVersion != "" || list[0].SourceImage != "") {
|
|
t.Fatal("inferred provenance", list)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSnapshotFullRestore(t *testing.T) {
|
|
for _, running := range []bool{false, true} {
|
|
t.Run(fmt.Sprint(running), func(t *testing.T) {
|
|
a, i, home, id := snapshotRestoreFixture(t, running, "")
|
|
links := map[string]string{"absolute": "/workspace/example", "dangling": "missing", "loop": "loop", "relative": "data"}
|
|
for name, target := range links {
|
|
if err := os.Symlink(target, filepath.Join(filepath.Dir(home), id, name)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := a.recordSnapshot(i, id, i.Version, i.ImageID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
w := configCall(a, i, "POST", "snapshots", snapshotRestoreBody(id, false))
|
|
if w.Code != 200 {
|
|
t.Fatal(w.Code, w.Body.String())
|
|
}
|
|
var result struct {
|
|
SnapshotID string `json:"snapshot_id"`
|
|
}
|
|
json.Unmarshal(w.Body.Bytes(), &result)
|
|
for path, want := range map[string]string{filepath.Join(home, "data"): "snapshot", filepath.Join(filepath.Dir(home), id, "data"): "snapshot", filepath.Join(filepath.Dir(home), result.SnapshotID, "data"): "current"} {
|
|
got, err := os.ReadFile(path)
|
|
if err != nil || string(got) != want {
|
|
t.Fatal(path, string(got), err)
|
|
}
|
|
}
|
|
current, _ := a.s.GetInstance(i.ID)
|
|
for name, target := range links {
|
|
got, err := os.Readlink(filepath.Join(home, name))
|
|
if err != nil || got != target {
|
|
t.Fatalf("restored link %s: %q %v", name, got, err)
|
|
}
|
|
}
|
|
want := "exited"
|
|
if running {
|
|
want = "running"
|
|
}
|
|
if current.Status != want {
|
|
t.Fatal(current.Status)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSnapshotRestoreRollback(t *testing.T) {
|
|
a, i, home, id := snapshotRestoreFixture(t, false, "reconcile")
|
|
w := configCall(a, i, "POST", "snapshots", snapshotRestoreBody(id, true))
|
|
if w.Code != 400 || !strings.Contains(w.Body.String(), "previous home restored") {
|
|
t.Fatal(w.Code, w.Body.String())
|
|
}
|
|
for path, want := range map[string]string{filepath.Join(home, "data"): "current", filepath.Join(filepath.Dir(home), id, "data"): "snapshot"} {
|
|
got, err := os.ReadFile(path)
|
|
if err != nil || string(got) != want {
|
|
t.Fatal(path, string(got), err)
|
|
}
|
|
}
|
|
matches, _ := filepath.Glob(filepath.Join(filepath.Dir(home), "failed-restore-*", "data"))
|
|
if len(matches) != 1 {
|
|
t.Fatal(matches)
|
|
}
|
|
}
|
|
|
|
func TestIncompleteSnapshotNotRestorable(t *testing.T) {
|
|
a, i, _, id := snapshotRestoreFixture(t, false, "")
|
|
if _, err := a.s.DB.Exec("CREATE TABLE snapshot_copy_state (instance_id TEXT, id TEXT, ready INTEGER)"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := a.s.DB.Exec("INSERT INTO snapshot_copy_state VALUES(?,?,0)", i.ID, id); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
w := configCall(a, i, "GET", "snapshots", "")
|
|
var list []snapshotInfo
|
|
if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &list) != nil || len(list) != 1 || list[0].Restorable || list[0].RestoreReason != "snapshot copy incomplete" {
|
|
t.Fatal(w.Code, w.Body.String())
|
|
}
|
|
w = configCall(a, i, "POST", "snapshots", snapshotRestoreBody(id, true))
|
|
if w.Code != 409 || !strings.Contains(w.Body.String(), "snapshot copy incomplete") {
|
|
t.Fatal(w.Code, w.Body.String())
|
|
}
|
|
if err := a.recordSnapshot(i, id, i.Version, i.ImageID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := a.s.DB.Exec("UPDATE snapshot_copy_state SET ready=1"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, _, reason := a.snapshotCompatibility(i, id); reason != "" {
|
|
t.Fatal(reason)
|
|
}
|
|
if _, err := a.s.DB.Exec("ALTER TABLE snapshot_copy_state RENAME COLUMN ready TO unavailable"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, _, reason := a.snapshotCompatibility(i, id); reason != "snapshot completion metadata unavailable" {
|
|
t.Fatal(reason)
|
|
}
|
|
}
|
|
|
|
func TestSnapshotRestoreGuards(t *testing.T) {
|
|
for _, test := range []string{"unknown", "mismatch", "password", "confirm", "reader", "quota", "error", "unsafe", "foreign", "oversized"} {
|
|
t.Run(test, func(t *testing.T) {
|
|
a, i, home, id := snapshotRestoreFixture(t, false, "")
|
|
body := snapshotRestoreBody(id, true)
|
|
want := 409
|
|
switch test {
|
|
case "unknown":
|
|
body = snapshotRestoreBody(id, false)
|
|
case "mismatch":
|
|
if err := a.recordSnapshot(i, id, "different", i.ImageID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
case "password":
|
|
body = strings.ReplaceAll(body, "original-password", "wrong")
|
|
want = 403
|
|
case "confirm":
|
|
body = strings.ReplaceAll(body, `"confirm":true`, `"confirm":false`)
|
|
want = 400
|
|
case "reader":
|
|
a.workspaceReaders = map[string]int{i.ID: 1}
|
|
case "quota":
|
|
a.s.DB.Exec("UPDATE users SET quota=1 WHERE id=?", i.UID)
|
|
want = 413
|
|
case "error":
|
|
a.s.SetInstance(i.ID, "error")
|
|
case "unsafe":
|
|
if err := syscall.Mkfifo(filepath.Join(filepath.Dir(home), id, "unsafe"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want = 400
|
|
case "foreign":
|
|
i.UID = "someone-else"
|
|
want = 404
|
|
case "oversized":
|
|
body = strings.Repeat("x", 4097)
|
|
want = 413
|
|
}
|
|
w := configCall(a, i, "POST", "snapshots", body)
|
|
if w.Code != want {
|
|
t.Fatal(w.Code, w.Body.String())
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(home, "data"))
|
|
if err != nil || string(got) != "current" {
|
|
t.Fatal(string(got), err)
|
|
}
|
|
})
|
|
}
|
|
}
|