Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion cmd/template-builder/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,8 @@ func runBuild(ctx context.Context, cfg buildConfig) error {
emitUser("system", "Saving template")
snapPath := filepath.Join(snapDir, "vmstate.snap")
memPath := filepath.Join(snapDir, "mem.snap")
if err := vm.CreateSnapshot(socketPath, snapPath, memPath, snapDir); err != nil {
// Flatten: per-sandbox restores skip apply_delta. Safe here — base isn't shared yet.
if err := vm.CreateSnapshot(socketPath, snapPath, memPath, snapDir, vm.SnapshotFlatten); err != nil {
return fmt.Errorf("snapshot: %w", err)
}
emitInternal("system", "snapshot captured")
Expand Down
3 changes: 3 additions & 0 deletions internal/vm/fc/models/snapshot_create_params.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 19 additions & 1 deletion internal/vm/firecracker.go
Original file line number Diff line number Diff line change
Expand Up @@ -231,10 +231,27 @@ func StartInstance(socketPath string) error {
// Snapshot operations
// ---------------------------------------------------------------------------

// SnapshotMode controls per-disk flatten behavior at snapshot creation.
type SnapshotMode int

const (
// SnapshotNormal: leave overlay deltas as-is. Sandboxes restored from
// this snapshot replay the delta into a per-VM overlay on create.
SnapshotNormal SnapshotMode = iota
// SnapshotFlatten: bake each overlay's dirty blocks into base.ext4 and
// zero the side-car bitmap. Sandboxes restored from this snapshot skip
// apply_delta. Only safe when the base isn't shared with other live VMs.
SnapshotFlatten
)

// CreateSnapshot pauses the VM and creates a full snapshot. Non-empty
// blockDeltaDir tells the forked engine to also emit <drive_id>.delta files
// containing dirty blocks — required to create sandboxes from this template.
func CreateSnapshot(socketPath, snapshotPath, memPath, blockDeltaDir string) error {
// mode=SnapshotFlatten bakes those deltas into base.ext4 (see SnapshotMode).
func CreateSnapshot(socketPath, snapshotPath, memPath, blockDeltaDir string, mode SnapshotMode) error {
if mode == SnapshotFlatten && blockDeltaDir == "" {
return fmt.Errorf("SnapshotFlatten requires non-empty blockDeltaDir")
}
fc := newFCClient(socketPath)
ctx := context.Background()

Expand All @@ -254,6 +271,7 @@ func CreateSnapshot(socketPath, snapshotPath, memPath, blockDeltaDir string) err
MemFilePath: &memPath,
SnapshotType: models.SnapshotCreateParamsSnapshotTypeFull,
BlockDeltaDir: blockDeltaDir,
Flatten: mode == SnapshotFlatten,
},
}); err != nil {
return fmt.Errorf("create snapshot: %w", err)
Expand Down
114 changes: 114 additions & 0 deletions internal/vm/firecracker_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package vm

import (
"encoding/json"
"io"
"net"
"net/http"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)

// TestCreateSnapshot_FlattenFieldInJSONBody asserts that the Go-side enum
// (SnapshotNormal/SnapshotFlatten) maps to the correct `flatten` field in
// the JSON body sent to firecracker. Guards against a future go-swagger
// model regen accidentally dropping the json:"flatten,omitempty" tag.
func TestCreateSnapshot_FlattenFieldInJSONBody(t *testing.T) {
cases := []struct {
name string
mode SnapshotMode
wantFlatten bool
}{
{"normal_mode_omits_flatten_or_sends_false", SnapshotNormal, false},
{"flatten_mode_sends_true", SnapshotFlatten, true},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
socketPath := filepath.Join(t.TempDir(), "fc.sock")

var (
bodyMu sync.Mutex
capturedBody []byte
)
ln, err := net.Listen("unix", socketPath)
if err != nil {
t.Fatalf("listen unix: %v", err)
}
defer ln.Close()

srv := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPatch && r.URL.Path == "/vm":
w.WriteHeader(http.StatusNoContent)
case r.Method == http.MethodPut && r.URL.Path == "/snapshot/create":
b, _ := io.ReadAll(r.Body)
bodyMu.Lock()
capturedBody = b
bodyMu.Unlock()
w.WriteHeader(http.StatusNoContent)
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}),
}
go srv.Serve(ln)
defer srv.Close()
waitForUnixSocket(t, socketPath)

if err := CreateSnapshot(socketPath, "/tmp/snap", "/tmp/mem", "/tmp/delta", tc.mode); err != nil {
t.Fatalf("CreateSnapshot: %v", err)
}

bodyMu.Lock()
body := capturedBody
bodyMu.Unlock()
if body == nil {
t.Fatal("snapshot/create handler never invoked")
}

var decoded struct {
Flatten bool `json:"flatten"`
}
if err := json.Unmarshal(body, &decoded); err != nil {
t.Fatalf("unmarshal body: %v (body=%s)", err, string(body))
}
if decoded.Flatten != tc.wantFlatten {
t.Errorf("flatten=%v, want %v (body=%s)", decoded.Flatten, tc.wantFlatten, string(body))
}
})
}
}

// Client-side guard: SnapshotFlatten with empty blockDeltaDir is rejected
// before any RPC.
func TestCreateSnapshot_FlattenRequiresBlockDeltaDir(t *testing.T) {
err := CreateSnapshot("/dev/null/unused", "/tmp/snap", "/tmp/mem", "", SnapshotFlatten)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "blockDeltaDir") {
t.Errorf("unexpected error: %v", err)
}
}

// waitForUnixSocket blocks until the listener at socketPath accepts a connection
// or the deadline elapses. Avoids the race where CreateSnapshot dials before
// http.Server.Serve has installed its handler in the accept loop.
func waitForUnixSocket(t *testing.T, socketPath string) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if conn, err := net.Dial("unix", socketPath); err == nil {
_ = conn.Close()
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("server at %s never became ready", socketPath)
}
4 changes: 2 additions & 2 deletions internal/vm/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -539,7 +539,7 @@ func (m *Manager) PauseVM(ctx context.Context, vmID, snapshotDir string) (snapsh
memPath = filepath.Join(snapshotDir, "mem.snap")

log.Info().Str("snapshot_path", snapshotPath).Msg("pausing VM — creating snapshot")
if err := CreateSnapshot(inst.SocketPath, snapshotPath, memPath, ""); err != nil {
if err := CreateSnapshot(inst.SocketPath, snapshotPath, memPath, "", SnapshotNormal); err != nil {
return "", "", m.handleVMError(vmID, fmt.Errorf("create snapshot: %w", err))
}

Expand Down Expand Up @@ -673,7 +673,7 @@ func (m *Manager) CreateVMSnapshot(ctx context.Context, vmID, snapshotDir string
snapshotPath = filepath.Join(snapshotDir, "vmstate.snap")
memPath = filepath.Join(snapshotDir, "mem.snap")

if err := CreateSnapshot(inst.SocketPath, snapshotPath, memPath, ""); err != nil {
if err := CreateSnapshot(inst.SocketPath, snapshotPath, memPath, "", SnapshotNormal); err != nil {
return "", "", fmt.Errorf("create snapshot: %w", err)
}

Expand Down
Loading