Files
dsh-web/internal/api/snapshot_restore_test.go
T

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)
}
})
}
}