feat: add full snapshot downloads and safe manual restore
This commit is contained in:
@@ -33,6 +33,10 @@ type snapshotInfo struct {
|
||||
Protected bool `json:"protected"`
|
||||
ProtectionReason string `json:"protection_reason"`
|
||||
SizeError string `json:"size_error,omitempty"`
|
||||
Restorable bool `json:"restorable"`
|
||||
RestoreReason string `json:"restore_reason"`
|
||||
SourceVersion string `json:"source_version,omitempty"`
|
||||
SourceImage string `json:"source_image,omitempty"`
|
||||
}
|
||||
|
||||
func managementRoot(base string, parts ...string) (*os.Root, error) {
|
||||
@@ -190,10 +194,18 @@ func (a *API) backupManagement(w http.ResponseWriter, r *http.Request, i store.I
|
||||
return
|
||||
}
|
||||
s := snapshotInfo{ID: entry.Name(), CreatedAt: st.ModTime(), Protected: reason != "", ProtectionReason: reason}
|
||||
s.SourceVersion, s.SourceImage, s.RestoreReason = a.snapshotCompatibility(i, s.ID)
|
||||
s.Restorable = s.RestoreReason == "" || s.RestoreReason == snapshotUnknownReason
|
||||
if reason != "" {
|
||||
s.Restorable = false
|
||||
s.RestoreReason = reason
|
||||
}
|
||||
s.Size, err = snapshotBytes(r.Context(), root, entry.Name())
|
||||
if err != nil {
|
||||
s.Size = -1
|
||||
s.SizeError = "snapshot size unavailable: " + err.Error()
|
||||
s.Restorable = false
|
||||
s.RestoreReason = "snapshot size unavailable"
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
|
||||
+17
-1
@@ -23,6 +23,14 @@ var backupIDPattern = regexp.MustCompile("^[a-f0-9]{32}$")
|
||||
var configBodySlots = make(chan struct{}, 4)
|
||||
|
||||
func (a *API) config(w http.ResponseWriter, r *http.Request, i store.Instance, action string) {
|
||||
if action == "snapshots" && r.Method == http.MethodGet && r.URL.Query().Get("download") != "" {
|
||||
a.snapshotDownload(w, r, i)
|
||||
return
|
||||
}
|
||||
if action == "snapshots" && r.Method == http.MethodPost {
|
||||
a.snapshotRestore(w, r, i)
|
||||
return
|
||||
}
|
||||
if action == "snapshots" || (action == "backups" && r.Method == http.MethodDelete) {
|
||||
a.backupManagement(w, r, i, action)
|
||||
return
|
||||
@@ -472,6 +480,15 @@ func (a *API) config(w http.ResponseWriter, r *http.Request, i store.Instance, a
|
||||
return
|
||||
}
|
||||
snapshot := "pre-restore-" + store.ID()
|
||||
failed := "failed-restore-" + store.ID()
|
||||
if e = a.recordSnapshot(i, snapshot, i.Version, i.ImageID); e != nil {
|
||||
fail(w, 500, "snapshot metadata unavailable")
|
||||
return
|
||||
}
|
||||
if e = a.recordSnapshot(i, failed, i.Version, i.ImageID); e != nil {
|
||||
fail(w, 500, "snapshot metadata unavailable")
|
||||
return
|
||||
}
|
||||
if _, e = a.quotaSnapshot(i.UID, parent, restoredBytes); e != nil {
|
||||
fail(w, 413, "quota exceeded or snapshot size unavailable")
|
||||
return
|
||||
@@ -540,7 +557,6 @@ func (a *API) config(w http.ResponseWriter, r *http.Request, i store.Instance, a
|
||||
fail(w, 500, "restore rollback requires runtime stop")
|
||||
return
|
||||
}
|
||||
failed := "failed-restore-" + store.ID()
|
||||
if parent.Rename("home", failed) != nil || parent.Rename(snapshot, "home") != nil || upgradeSync(parent) != nil {
|
||||
a.s.SetInstance(i.ID, "error")
|
||||
fail(w, 500, "restore rollback requires administrator recovery")
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.misaka.ren/m1saka/dsh-web/internal/store"
|
||||
)
|
||||
|
||||
var errSnapshotDownloadLimit = errors.New("snapshot archive exceeds size, entry or depth limit")
|
||||
var errUnsafeSnapshotArchive = errors.New("unsafe snapshot link or unsupported file type")
|
||||
|
||||
type snapshotArchiveWriter struct {
|
||||
ctx context.Context
|
||||
w io.Writer
|
||||
remaining int64
|
||||
}
|
||||
|
||||
func (w *snapshotArchiveWriter) Write(p []byte) (int, error) {
|
||||
if err := w.ctx.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if int64(len(p)) > w.remaining {
|
||||
return 0, errSnapshotDownloadLimit
|
||||
}
|
||||
n, err := w.w.Write(p)
|
||||
w.remaining -= int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func snapshotArchive(ctx context.Context, root *os.Root, out io.Writer, limit int64) error {
|
||||
// Bound headers, padding and gzip expansion independently of file content.
|
||||
gz := gzip.NewWriter(&snapshotArchiveWriter{ctx, out, limit + (256 << 20)})
|
||||
tw := tar.NewWriter(&snapshotArchiveWriter{ctx, gz, limit + (128 << 20)})
|
||||
remaining := limit
|
||||
entries := 100000
|
||||
var walk func(*os.Root, string, string, int) error
|
||||
walk = func(parent *os.Root, name, rel string, depth int) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
entries--
|
||||
if entries < 0 || depth > 128 {
|
||||
return errSnapshotDownloadLimit
|
||||
}
|
||||
st, err := parent.Lstat(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
link := ""
|
||||
if st.Mode()&os.ModeSymlink != 0 {
|
||||
link, err = parent.Readlink(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if filepath.IsAbs(link) || !filepath.IsLocal(filepath.Join(filepath.Dir(rel), link)) {
|
||||
return errUnsafeSnapshotArchive
|
||||
}
|
||||
// Root resolution also catches escapes through chains of relative links.
|
||||
if _, err = root.Stat(rel); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return errUnsafeSnapshotArchive
|
||||
}
|
||||
} else if !st.IsDir() && !st.Mode().IsRegular() {
|
||||
return errUnsafeSnapshotArchive
|
||||
}
|
||||
if st.Mode().IsRegular() && st.Size() > remaining {
|
||||
return errSnapshotDownloadLimit
|
||||
}
|
||||
if st.Mode().IsRegular() {
|
||||
remaining -= st.Size()
|
||||
}
|
||||
h, err := tar.FileInfoHeader(st, link)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h.Name = filepath.ToSlash(rel)
|
||||
if st.IsDir() {
|
||||
h.Name += "/"
|
||||
}
|
||||
if err = tw.WriteHeader(h); err != nil {
|
||||
return err
|
||||
}
|
||||
if st.Mode()&os.ModeSymlink != 0 {
|
||||
return nil
|
||||
}
|
||||
if st.IsDir() {
|
||||
child, err := parent.OpenRoot(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer child.Close()
|
||||
actual, err := child.Stat(".")
|
||||
if err != nil || !os.SameFile(st, actual) {
|
||||
return errors.New("snapshot directory changed")
|
||||
}
|
||||
f, err := child.Open(".")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
for {
|
||||
children, err := f.ReadDir(128)
|
||||
if err != nil && err != io.EOF {
|
||||
return err
|
||||
}
|
||||
for _, entry := range children {
|
||||
if err := walk(child, entry.Name(), filepath.Join(rel, entry.Name()), depth+1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err == io.EOF {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
f, err := parent.OpenFile(name, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
actual, err := f.Stat()
|
||||
if err != nil || !actual.Mode().IsRegular() || !os.SameFile(st, actual) || actual.Size() != st.Size() {
|
||||
return errors.New("snapshot file changed")
|
||||
}
|
||||
if _, err = io.CopyN(tw, f, st.Size()); err != nil {
|
||||
return err
|
||||
}
|
||||
after, err := f.Stat()
|
||||
if err != nil || after.Size() != st.Size() || !after.ModTime().Equal(st.ModTime()) {
|
||||
return errors.New("snapshot file changed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := walk(root, ".", ".", 0); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return gz.Close()
|
||||
}
|
||||
|
||||
func (a *API) snapshotDownload(w http.ResponseWriter, r *http.Request, i store.Instance) {
|
||||
if r.Method != http.MethodGet {
|
||||
fail(w, 405, "method not allowed")
|
||||
return
|
||||
}
|
||||
ids := r.URL.Query()["download"]
|
||||
if len(ids) != 1 || !snapshotIDPattern.MatchString(ids[0]) {
|
||||
fail(w, 400, "invalid snapshot ID")
|
||||
return
|
||||
}
|
||||
id := ids[0]
|
||||
select {
|
||||
case zipSlots <- struct{}{}:
|
||||
defer func() { <-zipSlots }()
|
||||
default:
|
||||
fail(w, 429, "too many archive downloads")
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), zipTimeout)
|
||||
defer cancel()
|
||||
archive, err := os.CreateTemp("", "dsh-snapshot-*.tar.gz")
|
||||
if err != nil {
|
||||
fail(w, 500, "temporary archive unavailable")
|
||||
return
|
||||
}
|
||||
defer os.Remove(archive.Name())
|
||||
defer archive.Close()
|
||||
code := 500
|
||||
err = func() error {
|
||||
lock := a.instanceLocks.Acquire(i.ID)
|
||||
defer lock.Release()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
current, err := a.s.GetInstance(i.ID)
|
||||
if err != nil || current.UID != i.UID {
|
||||
code = 404
|
||||
return errors.New("not found")
|
||||
}
|
||||
root, err := managementRoot(a.root, "workspaces", current.UID, "instances", current.ID, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
code = 404
|
||||
}
|
||||
if errors.Is(err, errUnsafeManagementPath) {
|
||||
code = 400
|
||||
}
|
||||
return errors.New("snapshot unavailable or unsafe path")
|
||||
}
|
||||
defer root.Close()
|
||||
return snapshotArchive(ctx, root, archive, upgradeSnapshotLimit)
|
||||
}()
|
||||
if err != nil {
|
||||
if errors.Is(err, errUnsafeSnapshotArchive) {
|
||||
code = 400
|
||||
}
|
||||
if errors.Is(err, errSnapshotDownloadLimit) {
|
||||
code = 413
|
||||
}
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
code = 408
|
||||
}
|
||||
fail(w, code, err.Error())
|
||||
return
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
fail(w, 408, "snapshot download timed out or canceled")
|
||||
return
|
||||
}
|
||||
if _, err = archive.Seek(0, io.SeekStart); err != nil {
|
||||
fail(w, 500, "archive unavailable")
|
||||
return
|
||||
}
|
||||
controller := http.NewResponseController(w)
|
||||
if err = controller.SetWriteDeadline(time.Now().Add(zipTimeout)); err != nil && !errors.Is(err, http.ErrNotSupported) {
|
||||
fail(w, 500, "download unavailable")
|
||||
return
|
||||
}
|
||||
defer controller.SetWriteDeadline(time.Time{})
|
||||
a.s.AddAudit(i.UID, "snapshot.download", i.ID, id)
|
||||
w.Header().Set("Content-Type", "application/gzip")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": id + ".tar.gz"}))
|
||||
http.ServeContent(w, r, id+".tar.gz", time.Time{}, archive)
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type snapshotTransferWriter struct {
|
||||
*httptest.ResponseRecorder
|
||||
check func()
|
||||
}
|
||||
|
||||
func (w *snapshotTransferWriter) WriteHeader(code int) {
|
||||
w.check()
|
||||
w.ResponseRecorder.WriteHeader(code)
|
||||
}
|
||||
|
||||
func TestSnapshotDownloadTransferUnlockedAndOversize(t *testing.T) {
|
||||
a, i, home := configTestAPI(t, false)
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("TMPDIR", tmp)
|
||||
id := "failed-home-" + strings.Repeat("c", 32)
|
||||
dir := filepath.Join(filepath.Dir(home), id)
|
||||
if err := os.Mkdir(dir, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
configWrite(t, filepath.Join(dir, "data"), "saved")
|
||||
checked := false
|
||||
w := &snapshotTransferWriter{httptest.NewRecorder(), func() {
|
||||
checked = true
|
||||
if a.instanceLocks.held() {
|
||||
t.Fatal("instance locked during transfer")
|
||||
}
|
||||
files, err := os.ReadDir(tmp)
|
||||
if err != nil || len(files) != 1 {
|
||||
t.Fatal(files, err)
|
||||
}
|
||||
st, err := files[0].Info()
|
||||
if err != nil || st.Mode().Perm() != 0600 {
|
||||
t.Fatal(st, err)
|
||||
}
|
||||
if err := os.RemoveAll(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}}
|
||||
a.snapshotDownload(w, httptest.NewRequest(http.MethodGet, "/?download="+id, nil), i)
|
||||
if !checked || w.Code != 200 {
|
||||
t.Fatal(checked, w.Code)
|
||||
}
|
||||
gz, err := gzip.NewReader(bytes.NewReader(w.Body.Bytes()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer gz.Close()
|
||||
if _, err := io.Copy(io.Discard, gz); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(dir, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, err := os.Create(filepath.Join(dir, "huge"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = f.Truncate(upgradeSnapshotLimit + 1)
|
||||
f.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
a.snapshotDownload(rec, httptest.NewRequest("GET", "/?download="+id, nil), i)
|
||||
if rec.Code != 413 || !strings.Contains(rec.Body.String(), "limit") {
|
||||
t.Fatal(rec.Code, rec.Body.String())
|
||||
}
|
||||
files, err := os.ReadDir(tmp)
|
||||
if err != nil || len(files) != 0 {
|
||||
t.Fatal(files, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotDownloadContentsAndCleanup(t *testing.T) {
|
||||
a, i, home := configTestAPI(t, true)
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("TMPDIR", tmp)
|
||||
id := "pre-restore-" + strings.Repeat("a", 32)
|
||||
dir := filepath.Join(filepath.Dir(home), id)
|
||||
if err := os.MkdirAll(filepath.Join(dir, "node_modules/.pnpm/pkg"), 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
configWrite(t, filepath.Join(dir, "node_modules/.pnpm/pkg/bin"), "#!/bin/sh")
|
||||
if err := os.Chmod(filepath.Join(dir, "node_modules/.pnpm/pkg/bin"), 0751); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(".pnpm/pkg", filepath.Join(dir, "node_modules/pkg")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
configWrite(t, filepath.Join(dir, ".credentials.yaml"), "secret")
|
||||
if _, err := a.s.DB.Exec("UPDATE instances SET status='error' WHERE id=?", i.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
a.config(w, httptest.NewRequest("GET", "/?download="+id, nil), i, "snapshots")
|
||||
if w.Code != 200 {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
_, params, err := mime.ParseMediaType(w.Header().Get("Content-Disposition"))
|
||||
if err != nil || params["filename"] != id+".tar.gz" {
|
||||
t.Fatal(params, err)
|
||||
}
|
||||
gz, err := gzip.NewReader(bytes.NewReader(w.Body.Bytes()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer gz.Close()
|
||||
tr := tar.NewReader(gz)
|
||||
seen := map[string]bool{}
|
||||
for {
|
||||
h, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seen[h.Name] = true
|
||||
b, err := io.ReadAll(tr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
switch h.Name {
|
||||
case "node_modules/.pnpm/pkg/bin":
|
||||
if string(b) != "#!/bin/sh" || h.Mode != 0751 {
|
||||
t.Fatal(h, string(b))
|
||||
}
|
||||
case "node_modules/pkg":
|
||||
if h.Typeflag != tar.TypeSymlink || h.Linkname != ".pnpm/pkg" {
|
||||
t.Fatal(h)
|
||||
}
|
||||
case ".credentials.yaml":
|
||||
if string(b) != "secret" {
|
||||
t.Fatal(string(b))
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(seen) != 7 || !seen["node_modules/pkg"] || seen["node_modules/pkg/bin"] {
|
||||
t.Fatal(seen)
|
||||
}
|
||||
files, err := os.ReadDir(tmp)
|
||||
if err != nil || len(files) != 0 {
|
||||
t.Fatal(files, err)
|
||||
}
|
||||
r := httptest.NewRequest("GET", "/?download="+id, nil)
|
||||
r.Header.Set("Range", "bytes=0-9")
|
||||
w = httptest.NewRecorder()
|
||||
a.snapshotDownload(w, r, i)
|
||||
if w.Code != 206 || w.Body.Len() != 10 {
|
||||
t.Fatal(w.Code, w.Body.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotDownloadRejectsUnsafeAndForeign(t *testing.T) {
|
||||
a, i, home := configTestAPI(t, false)
|
||||
id := "pre-restore-" + strings.Repeat("b", 32)
|
||||
dir := filepath.Join(filepath.Dir(home), id)
|
||||
if err := os.Mkdir(dir, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, query := range []string{"", "../home", "/home", id + "/../home", "pre-restore-ABC"} {
|
||||
w := httptest.NewRecorder()
|
||||
a.snapshotDownload(w, httptest.NewRequest("GET", "/?download="+url.QueryEscape(query), nil), i)
|
||||
if w.Code != 400 {
|
||||
t.Fatal(query, w.Code)
|
||||
}
|
||||
}
|
||||
foreign := i
|
||||
foreign.UID = "someone-else"
|
||||
w := httptest.NewRecorder()
|
||||
a.snapshotDownload(w, httptest.NewRequest("GET", "/?download="+id, nil), foreign)
|
||||
if w.Code != 404 {
|
||||
t.Fatal(w.Code)
|
||||
}
|
||||
for _, target := range []string{"../home", "/etc/passwd", "../../elsewhere"} {
|
||||
if err := os.Symlink(target, filepath.Join(dir, "link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w = httptest.NewRecorder()
|
||||
a.snapshotDownload(w, httptest.NewRequest("GET", "/?download="+id, nil), i)
|
||||
if w.Code != 400 || w.Header().Get("Content-Disposition") != "" {
|
||||
t.Fatal(target, w.Code)
|
||||
}
|
||||
if err := os.Remove(filepath.Join(dir, "link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := syscall.Mkfifo(filepath.Join(dir, "fifo"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w = httptest.NewRecorder()
|
||||
a.snapshotDownload(w, httptest.NewRequest("GET", "/?download="+id, nil), i)
|
||||
if w.Code != 400 {
|
||||
t.Fatal(w.Code)
|
||||
}
|
||||
if err := os.Remove(filepath.Join(dir, "fifo")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Remove(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(home, dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w = httptest.NewRecorder()
|
||||
a.snapshotDownload(w, httptest.NewRequest("GET", "/?download="+id, nil), i)
|
||||
if w.Code != 400 {
|
||||
t.Fatal(w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotArchiveLimitsAndCancellation(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configWrite(t, filepath.Join(dir, "file"), strings.Repeat("x", 2048))
|
||||
root, err := os.OpenRoot(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer root.Close()
|
||||
if err := snapshotArchive(context.Background(), root, io.Discard, 1024); !errors.Is(err, errSnapshotDownloadLimit) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := snapshotArchive(context.Background(), root, io.Discard, 2048); err != nil {
|
||||
t.Fatal("exact content limit should allow archive overhead", err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if err := snapshotArchive(ctx, root, io.Discard, upgradeSnapshotLimit); !errors.Is(err, context.Canceled) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a, i, _ := configTestAPI(t, false)
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("TMPDIR", tmp)
|
||||
r := httptest.NewRequest("GET", "/?download=pre-restore-"+strings.Repeat("a", 32), nil).WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
a.snapshotDownload(w, r, i)
|
||||
if w.Code != 408 {
|
||||
t.Fatal(w.Code)
|
||||
}
|
||||
files, err := os.ReadDir(tmp)
|
||||
if err != nil || len(files) != 0 {
|
||||
t.Fatal(files, err)
|
||||
}
|
||||
if len(zipSlots) != 0 || a.instanceLocks.held() {
|
||||
t.Fatal("resource leak")
|
||||
}
|
||||
t.Setenv("TMPDIR", filepath.Join(tmp, "missing"))
|
||||
w = httptest.NewRecorder()
|
||||
a.snapshotDownload(w, httptest.NewRequest("GET", r.URL.String(), nil), i)
|
||||
if w.Code != 500 {
|
||||
t.Fatal(w.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"git.misaka.ren/m1saka/dsh-web/internal/store"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const snapshotUnknownReason = "snapshot source image/version unknown; explicit acknowledgement required"
|
||||
|
||||
func snapshotRuntimeLayout(config, host map[string]json.RawMessage, mounts []struct{ Type, Source, Destination string }, home string) bool {
|
||||
workspace := 0
|
||||
for _, mount := range mounts {
|
||||
if mount.Type == "tmpfs" && mount.Destination == "/tmp" {
|
||||
continue
|
||||
}
|
||||
if mount.Type != "bind" || mount.Source != home || mount.Destination != "/workspace" {
|
||||
return false
|
||||
}
|
||||
workspace++
|
||||
}
|
||||
if workspace != 1 {
|
||||
return false
|
||||
}
|
||||
var env []string
|
||||
if json.Unmarshal(config["Env"], &env) != nil {
|
||||
return false
|
||||
}
|
||||
values := map[string]string{}
|
||||
for _, entry := range env {
|
||||
key, value, _ := strings.Cut(entry, "=")
|
||||
values[key] = value
|
||||
}
|
||||
for key, want := range map[string]string{"DSH_HOME": "/workspace", "HOME": "/workspace", "XDG_DATA_HOME": "/workspace/.local/share", "XDG_CONFIG_HOME": "/workspace/.config"} {
|
||||
if values[key] != want {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for index, raw := range []json.RawMessage{host["Tmpfs"], config["Volumes"]} {
|
||||
if len(raw) == 0 {
|
||||
continue
|
||||
}
|
||||
var mounts map[string]json.RawMessage
|
||||
if json.Unmarshal(raw, &mounts) != nil {
|
||||
return false
|
||||
}
|
||||
if index == 0 {
|
||||
delete(mounts, "/tmp")
|
||||
}
|
||||
if len(mounts) != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *API) recordSnapshot(i store.Instance, id, version, image string) error {
|
||||
_, err := a.s.DB.Exec("CREATE TABLE IF NOT EXISTS snapshot_metadata (instance_id TEXT NOT NULL, id TEXT NOT NULL, source_version TEXT NOT NULL, source_image TEXT NOT NULL, PRIMARY KEY(instance_id,id))")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = a.s.DB.Exec("INSERT INTO snapshot_metadata(instance_id,id,source_version,source_image) VALUES(?,?,?,?)", i.ID, id, version, image)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *API) snapshotCompatibility(i store.Instance, id string) (version, image, reason string) {
|
||||
var exists int
|
||||
if err := a.s.DB.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='snapshot_metadata'").Scan(&exists); err != nil {
|
||||
return "", "", "snapshot metadata unavailable"
|
||||
}
|
||||
if exists != 0 {
|
||||
err := a.s.DB.QueryRow("SELECT source_version,source_image FROM snapshot_metadata WHERE instance_id=? AND id=?", i.ID, id).Scan(&version, &image)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return "", "", "snapshot metadata unavailable"
|
||||
}
|
||||
}
|
||||
if (version != "" && version != i.Version) || (image != "" && image != i.ImageID) {
|
||||
return version, image, "snapshot source image/version is incompatible"
|
||||
}
|
||||
if version == "" || image == "" {
|
||||
return version, image, snapshotUnknownReason
|
||||
}
|
||||
return version, image, ""
|
||||
}
|
||||
|
||||
func (a *API) snapshotRestore(w http.ResponseWriter, r *http.Request, i store.Instance) {
|
||||
controller := http.NewResponseController(w)
|
||||
w, flush := deferJSONResponse(w)
|
||||
defer flush()
|
||||
select {
|
||||
case configBodySlots <- struct{}{}:
|
||||
defer func() { r.Body = http.NoBody; <-configBodySlots }()
|
||||
default:
|
||||
fail(w, 503, "configuration uploads busy; retry later")
|
||||
return
|
||||
}
|
||||
if !configReadBody(w, r, controller, 4096) {
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
ID string
|
||||
Password string
|
||||
Confirm bool
|
||||
AllowUnknown bool `json:"allow_unknown_version"`
|
||||
}
|
||||
dec := json.NewDecoder(r.Body)
|
||||
if dec.Decode(&in) != nil || dec.Decode(new(any)) != io.EOF || !snapshotIDPattern.MatchString(in.ID) || !in.Confirm {
|
||||
fail(w, 400, "valid snapshot ID and explicit confirmation required")
|
||||
return
|
||||
}
|
||||
lock := a.instanceLocks.Acquire(i.ID)
|
||||
defer lock.Release()
|
||||
a.readersMu.Lock()
|
||||
readers := a.workspaceReaders[i.ID]
|
||||
a.readersMu.Unlock()
|
||||
if readers > 0 {
|
||||
fail(w, 409, "instance files busy; retry later")
|
||||
return
|
||||
}
|
||||
user := a.userLocks.Acquire(i.UID)
|
||||
defer user.Release()
|
||||
current, err := a.s.GetInstance(i.ID)
|
||||
if err != nil || current.UID != i.UID {
|
||||
fail(w, 404, "not found")
|
||||
return
|
||||
}
|
||||
i = current
|
||||
if !a.reauthenticate(w, i.UID, in.Password) {
|
||||
return
|
||||
}
|
||||
if reason := a.backupProtection(i); reason != "" {
|
||||
fail(w, 409, reason)
|
||||
return
|
||||
}
|
||||
admissionMu.RLock()
|
||||
defer admissionMu.RUnlock()
|
||||
buildQueue.Lock()
|
||||
err = a.ensureBuilds()
|
||||
buildQueue.Unlock()
|
||||
if err != nil {
|
||||
fail(w, 500, "database error")
|
||||
return
|
||||
}
|
||||
var blocked int
|
||||
if err = a.s.DB.QueryRowContext(r.Context(), "SELECT count(*) FROM image_admissions WHERE image_id=? AND state='blocked'", i.ImageID).Scan(&blocked); err != nil {
|
||||
fail(w, 500, "database error")
|
||||
return
|
||||
}
|
||||
if blocked > 0 {
|
||||
fail(w, 409, "image is blocked; restore denied")
|
||||
return
|
||||
}
|
||||
version, image, reason := a.snapshotCompatibility(i, in.ID)
|
||||
if reason != "" && !(reason == snapshotUnknownReason && in.AllowUnknown) {
|
||||
fail(w, 409, reason)
|
||||
return
|
||||
}
|
||||
root, err := managementRoot(a.root, "workspaces", i.UID, "instances", i.ID)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
fail(w, 404, "home or snapshot not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
managementPathError(w, err)
|
||||
return
|
||||
}
|
||||
defer root.Close()
|
||||
for _, name := range []string{"home", in.ID} {
|
||||
st, e := root.Lstat(name)
|
||||
if errors.Is(e, os.ErrNotExist) {
|
||||
fail(w, 404, "home or snapshot not found")
|
||||
return
|
||||
}
|
||||
if e != nil {
|
||||
managementPathError(w, e)
|
||||
return
|
||||
}
|
||||
if !st.IsDir() {
|
||||
fail(w, 400, "unsafe home or snapshot")
|
||||
return
|
||||
}
|
||||
}
|
||||
f, err := root.Open(".")
|
||||
if err != nil {
|
||||
fail(w, 500, "snapshot storage unavailable")
|
||||
return
|
||||
}
|
||||
entries, err := f.ReadDir(-1)
|
||||
f.Close()
|
||||
count := 0
|
||||
for _, entry := range entries {
|
||||
if strings.HasPrefix(entry.Name(), "pre-restore-") || strings.HasPrefix(entry.Name(), "failed-restore-") {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
fail(w, 500, "snapshot listing failed")
|
||||
return
|
||||
}
|
||||
if count >= 3 {
|
||||
fail(w, 409, "restore snapshot limit reached; cleanup required")
|
||||
return
|
||||
}
|
||||
size, err := quotaTreeBytes(root, in.ID)
|
||||
var pathError *os.PathError
|
||||
if errors.As(err, &pathError) {
|
||||
managementPathError(w, err)
|
||||
return
|
||||
}
|
||||
if err != nil || a.quotaGrowth(i.UID, size) != nil {
|
||||
fail(w, 413, "quota exceeded or snapshot size unavailable")
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
|
||||
defer cancel()
|
||||
var container struct {
|
||||
Image string
|
||||
Config, HostConfig map[string]json.RawMessage
|
||||
Mounts []struct{ Type, Source, Destination string }
|
||||
State struct{ Running, Paused, Restarting bool }
|
||||
}
|
||||
err = a.docker.Do(ctx, "GET", "/containers/"+url.PathEscape(i.ContainerID)+"/json", nil, &container)
|
||||
if err != nil {
|
||||
fail(w, 502, "container inspection failed")
|
||||
return
|
||||
}
|
||||
if container.State.Paused || container.State.Restarting {
|
||||
fail(w, 409, "unsupported container state")
|
||||
return
|
||||
}
|
||||
var runtimeImage struct {
|
||||
ID string `json:"Id"`
|
||||
Config struct{ Labels map[string]string }
|
||||
}
|
||||
hostRoot := os.Getenv("HOST_DATA_ROOT")
|
||||
if hostRoot == "" {
|
||||
hostRoot = a.root
|
||||
}
|
||||
homeSource, homeErr := toHostPath(a.root, hostRoot, filepath.Join(root.Name(), "home"))
|
||||
if homeErr != nil || !snapshotRuntimeLayout(container.Config, container.HostConfig, container.Mounts, homeSource) {
|
||||
fail(w, 409, "incompatible effective runtime workspace layout")
|
||||
return
|
||||
}
|
||||
if i.ImageID == "" || container.Image != i.ImageID || container.Config == nil || container.HostConfig == nil || homeErr != nil || a.docker.Do(ctx, "GET", "/images/"+url.PathEscape(i.ImageID)+"/json", nil, &runtimeImage) != nil || runtimeImage.ID != i.ImageID || runtimeImage.Config.Labels["dsh-mgr.version"] != i.Version || upgradeWorkspace(container.Config, container.HostConfig, runtimeImage.Config.Labels["dsh-mgr.recipe"], homeSource) != nil {
|
||||
fail(w, 409, "incompatible runtime image or workspace layout")
|
||||
return
|
||||
}
|
||||
wasRunning := container.State.Running
|
||||
op := store.ID()
|
||||
staging := ".snapshot-restore-" + op
|
||||
previous := "pre-restore-" + op
|
||||
failed := "failed-restore-" + op
|
||||
if err = upgradeCopy(ctx, root, in.ID, staging, size); err != nil {
|
||||
if root.RemoveAll(staging) != nil {
|
||||
a.s.SetInstance(i.ID, "error")
|
||||
fail(w, 500, "staging cleanup requires administrator recovery")
|
||||
return
|
||||
}
|
||||
if errors.As(err, &pathError) {
|
||||
managementPathError(w, err)
|
||||
return
|
||||
}
|
||||
fail(w, 400, "unsafe or oversized snapshot")
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if root.RemoveAll(staging) != nil {
|
||||
a.s.SetInstance(i.ID, "error")
|
||||
}
|
||||
}()
|
||||
if err = a.recordSnapshot(i, previous, i.Version, i.ImageID); err != nil {
|
||||
fail(w, 500, "snapshot metadata unavailable")
|
||||
return
|
||||
}
|
||||
if err = a.recordSnapshot(i, failed, version, image); err != nil {
|
||||
fail(w, 500, "snapshot metadata unavailable")
|
||||
return
|
||||
}
|
||||
if _, err = a.s.DB.Exec("UPDATE instances SET status='error' WHERE id=?", i.ID); err != nil {
|
||||
fail(w, 500, "cannot record restore state")
|
||||
return
|
||||
}
|
||||
if _, err = a.s.DB.Exec("INSERT INTO audit(uid,action,target,detail,at) VALUES(?,?,?,?,?)", i.UID, "snapshot.restore.started", i.ID, in.ID+" "+previous+" "+failed, time.Now().Unix()); err != nil {
|
||||
fail(w, 500, "restore audit unavailable; recovery required")
|
||||
return
|
||||
}
|
||||
if err = a.configStop(ctx, i.ContainerID); err != nil {
|
||||
fail(w, 502, "runtime stop failed; recovery required")
|
||||
return
|
||||
}
|
||||
moved, installed := false, false
|
||||
var owner store.User
|
||||
owner.ID = i.UID
|
||||
err = a.s.DB.QueryRow("SELECT quota FROM users WHERE id=?", i.UID).Scan(&owner.Quota)
|
||||
if err == nil {
|
||||
var used int64
|
||||
used, err = a.userUsage(owner)
|
||||
if err == nil && used > owner.Quota {
|
||||
err = errors.New("quota exceeded after runtime stop")
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
err = root.Rename("home", previous)
|
||||
}
|
||||
if err == nil {
|
||||
moved = true
|
||||
err = upgradeSync(root)
|
||||
}
|
||||
if err == nil {
|
||||
err = root.Rename(staging, "home")
|
||||
installed = err == nil
|
||||
}
|
||||
if err == nil {
|
||||
err = upgradeSync(root)
|
||||
}
|
||||
startReady := func(c context.Context) error {
|
||||
runtimeAuthCache.Delete(runtimeAuthKey{a, i.ID})
|
||||
if e := a.docker.Do(c, "POST", "/containers/"+url.PathEscape(i.ContainerID)+"/start", nil, nil); e != nil {
|
||||
return e
|
||||
}
|
||||
return a.upgradeReady(c, i.ContainerID, i.ID, a.runtimeHost(r))
|
||||
}
|
||||
if err == nil {
|
||||
err = startReady(ctx)
|
||||
}
|
||||
if err == nil && !wasRunning {
|
||||
err = a.configStop(ctx, i.ContainerID)
|
||||
}
|
||||
rejected := err != nil
|
||||
if rejected {
|
||||
rollback, done := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer done()
|
||||
if a.configStop(rollback, i.ContainerID) != nil {
|
||||
fail(w, 500, "restore rollback requires runtime stop; recovery required")
|
||||
return
|
||||
}
|
||||
if installed {
|
||||
if root.Rename("home", failed) != nil || upgradeSync(root) != nil {
|
||||
fail(w, 500, "restore rollback requires administrator recovery")
|
||||
return
|
||||
}
|
||||
}
|
||||
if moved {
|
||||
if root.Rename(previous, "home") != nil || upgradeSync(root) != nil {
|
||||
fail(w, 500, "restore rollback requires administrator recovery")
|
||||
return
|
||||
}
|
||||
}
|
||||
if wasRunning {
|
||||
if startReady(rollback) != nil {
|
||||
fail(w, 500, "restore rollback readiness failed; recovery required")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
status := "exited"
|
||||
if wasRunning {
|
||||
status = "running"
|
||||
}
|
||||
if _, err = a.s.DB.Exec("UPDATE instances SET status=? WHERE id=?", status, i.ID); err != nil {
|
||||
fail(w, 500, "restore state requires administrator recovery")
|
||||
return
|
||||
}
|
||||
runtimeAuthCache.Delete(runtimeAuthKey{a, i.ID})
|
||||
if rejected {
|
||||
a.s.AddAudit(i.UID, "snapshot.restore.rolled_back", i.ID, in.ID)
|
||||
fail(w, 400, "restore rejected; previous home restored")
|
||||
return
|
||||
}
|
||||
a.s.AddAudit(i.UID, "snapshot.restore.completed", i.ID, in.ID)
|
||||
writeJSON(w, map[string]any{"ok": true, "snapshot_id": previous})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -340,6 +340,10 @@ func (a *API) upgrade(w http.ResponseWriter, r *http.Request, i store.Instance)
|
||||
}
|
||||
op := store.ID()
|
||||
snapshot, failedHome := "upgrade-"+op+"-snapshot", "failed-home-"+op
|
||||
if a.recordSnapshot(i, snapshot, i.Version, i.ImageID) != nil || a.recordSnapshot(i, failedHome, image.Config.Labels["dsh-mgr.version"], image.ID) != nil {
|
||||
fail(w, 500, "snapshot metadata unavailable")
|
||||
return
|
||||
}
|
||||
newName := runtimeNetwork(i.ID) + "-upgrade-" + op
|
||||
_, err = a.s.DB.Exec(`CREATE TABLE IF NOT EXISTS upgrade_journal (id TEXT PRIMARY KEY, instance_id TEXT NOT NULL, old_container TEXT NOT NULL, new_container TEXT NOT NULL DEFAULT '', new_name TEXT NOT NULL, image_id TEXT NOT NULL, snapshot TEXT NOT NULL, failed_home TEXT NOT NULL, phase TEXT NOT NULL)`)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user