feat: add full snapshot downloads and safe manual restore
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
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"
|
||||
"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, "")
|
||||
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)
|
||||
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 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 := os.Symlink("/etc/passwd", filepath.Join(filepath.Dir(home), id, "unsafe")); 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user