From 3d94d47d80eeee19f7801e10edffbf29c1223416 Mon Sep 17 00:00:00 2001 From: Chenyi Wang Date: Sat, 5 Sep 2026 02:59:13 -0700 Subject: [PATCH] Add a split durable-dir capture that hardlinks file contents Sealing a durable-dir volume as one tar reads and rewrites every byte the actor holds, on the paused critical path, so suspend latency scales with the actor's data rather than with what it changed. Add a second arrangement, selected by ATEOM_DURABLE_BACKEND=files: a metadata-only index tar plus one blob per non-empty regular file, hardlinked out of the directory instead of copied. Sealing costs one link per file, and restore adopts the blobs the same way, so neither direction spends a full copy of the tree. Both ends are guarded: CreateSplit falls back to copying on EXDEV/EMLINK/EPERM, and ExtractSplit copies any blob whose link count shows a second owner still holds it, which keeps the "this activation owns the blobs" contract enforced by the code rather than by convention. The blob name travels in a PAX record per entry, so a reader tells the two arrangements apart entry by entry and ExtractSplit reads a plain archive too. A restore dispatches on what the snapshot contains rather than on the variable, so a node configured either way can read back an actor captured the other way. atelet has to know the new names as well: carving durable data out of a FULL micro-VM capture for a DATA upload now selects every durable-dir snapshot file, not just the tar, and fails the upload when none are present instead of silently uploading an empty set. Also split the micro-VM cold-boot restore timing into its durable and boot halves, which is what shows the durable side going to nothing. --- cmd/atelet/main.go | 17 +- cmd/atelet/main_test.go | 49 +++ cmd/ateom-microvm/checkpoint.go | 16 +- cmd/ateom-microvm/durable.go | 93 ++++- cmd/ateom-microvm/durable_test.go | 126 ++++++- cmd/ateom-microvm/restore.go | 12 +- internal/ateompath/ateompath.go | 32 +- internal/ateompath/ateompath_test.go | 24 ++ internal/tarutil/split.go | 219 ++++++++++++ internal/tarutil/split_test.go | 503 +++++++++++++++++++++++++++ internal/tarutil/tarutil.go | 62 +++- 11 files changed, 1101 insertions(+), 52 deletions(-) create mode 100644 internal/tarutil/split.go create mode 100644 internal/tarutil/split_test.go diff --git a/cmd/atelet/main.go b/cmd/atelet/main.go index 433e71b2a6..fdbf85b8a4 100644 --- a/cmd/atelet/main.go +++ b/cmd/atelet/main.go @@ -921,18 +921,25 @@ func (s *AteomHerder) uploadLocalCheckpointDir(ctx context.Context, req *ateletp } // narrowFullCaptureToData rewrites rec so a FULL capture uploads as a DATA -// snapshot. Each sandbox class owns one branch: micro-VM durable data is a -// self-contained tar that can be carved out of the full file set; gVisor's +// snapshot. Each sandbox class owns one branch: micro-VM durable data is +// self-contained and can be carved out of the full file set by name; gVisor's // full checkpoint is monolithic until split checkpoints land. func narrowFullCaptureToData(rec *sandboxAssetsRecord) error { switch atev1alpha1.SandboxClass(rec.SandboxClass) { case atev1alpha1.SandboxClassMicroVM, atev1alpha1.SandboxClassGvisor: - if !slices.Contains(rec.SnapshotFiles, ateompath.DurableDirTarFile) { + // Selected by predicate rather than by name: a micro-VM checkpoint may + // have written the durable dir as an index plus blobs instead of one + // tar. A gVisor checkpoint only ever writes the tar, which the + // predicate also matches. + durable := slices.DeleteFunc(slices.Clone(rec.SnapshotFiles), func(f string) bool { + return !ateompath.DurableDirSnapshotFile(f) + }) + if len(durable) == 0 { // No durable-dir volumes were attached at pause: this snapshot // holds no data, and never will — not retryable. - return status.Errorf(codes.FailedPrecondition, "full %s capture has no %s; the actor has no durable data to upload as %s", rec.SandboxClass, ateompath.DurableDirTarFile, ateattr.SnapshotScopeData) + return status.Errorf(codes.FailedPrecondition, "full %s capture has no durable-dir files; the actor has no durable data to upload as %s", rec.SandboxClass, ateattr.SnapshotScopeData) } - rec.SnapshotFiles = []string{ateompath.DurableDirTarFile} + rec.SnapshotFiles = durable rec.Scope = ateattr.SnapshotScopeData return nil diff --git a/cmd/atelet/main_test.go b/cmd/atelet/main_test.go index 0e4ebb3cbf..21398098ed 100644 --- a/cmd/atelet/main_test.go +++ b/cmd/atelet/main_test.go @@ -1727,6 +1727,55 @@ func TestUploadLocalCheckpointDir(t *testing.T) { } }) + // The split arrangement spreads durable data over an index and one blob per + // file, so the carve has to select a set rather than a single name — and + // still leave the guest state behind. + t.Run("microvm full capture uploads the durable index and blobs as data", func(t *testing.T) { + store := &recordingObjectStorage{} + s := &AteomHerder{gcsClient: store} + dir := filepath.Join(t.TempDir(), "pause-snap-1") + rec := fullRec("microvm") + rec.SnapshotFiles = []string{ + "config.json", "memory-ranges", + ateompath.DurableDirIndexFile, + ateompath.DurableDirBlobPrefix + "0000", + ateompath.DurableDirBlobPrefix + "0001", + } + writeLocalSnapshot(t, dir, rec, map[string]string{ + "config.json": "cfg", "memory-ranges": "mem", + ateompath.DurableDirIndexFile: "index", + ateompath.DurableDirBlobPrefix + "0000": "one", + ateompath.DurableDirBlobPrefix + "0001": "two", + }) + + req := validUploadPausedCheckpointRequest() + req.DesiredScope = ateletpb.SnapshotScope_SNAPSHOT_SCOPE_DATA + if _, err := s.uploadLocalCheckpointDir(ctx, req, dir, uri); err != nil { + t.Fatalf("uploadLocalCheckpointDir: %v", err) + } + want := []string{ + pausedSnapshotPath + "/durable-dir.blob-0000.zstd", + pausedSnapshotPath + "/durable-dir.blob-0001.zstd", + pausedSnapshotPath + "/durable-dir.index.tar.zstd", + pausedSnapshotPath + "/manifest.json", + } + if got := store.keys(); !slices.Equal(got, want) { + t.Errorf("uploaded objects = %v, want %v", got, want) + } + uploaded := remoteManifest(t, store) + if uploaded.Scope != ateattr.SnapshotScopeData { + t.Errorf("uploaded manifest scope = %q, want %q", uploaded.Scope, ateattr.SnapshotScopeData) + } + want = []string{ + ateompath.DurableDirIndexFile, + ateompath.DurableDirBlobPrefix + "0000", + ateompath.DurableDirBlobPrefix + "0001", + } + if !slices.Equal(uploaded.SnapshotFiles, want) { + t.Errorf("uploaded manifest files = %v, want %v", uploaded.SnapshotFiles, want) + } + }) + t.Run("gvisor full capture without durable tar has no data", func(t *testing.T) { s := &AteomHerder{gcsClient: &recordingObjectStorage{}} dir := filepath.Join(t.TempDir(), "pause-snap-1") diff --git a/cmd/ateom-microvm/checkpoint.go b/cmd/ateom-microvm/checkpoint.go index 8a4c8c75a7..9763b6c9d5 100644 --- a/cmd/ateom-microvm/checkpoint.go +++ b/cmd/ateom-microvm/checkpoint.go @@ -130,7 +130,7 @@ func (s *AteomService) CheckpointWorkload(ctx context.Context, req *ateompb.Chec } // Capture the snapshot's pieces CONCURRENTLY: the CH snapshot, the - // durable-dir tar, and the rootfs upper tar read independent data from a + // durable-dir capture, and the rootfs upper tar read independent data from a // quiesced guest and write distinct files into checkpointDir, so the paused // window costs the slowest of them rather than their sum (the tars scale // with the actor's data; suspend latency is the metric that matters). @@ -140,8 +140,8 @@ func (s *AteomService) CheckpointWorkload(ctx context.Context, req *ateompb.Chec // since nothing will reattach to the frozen virtio-fs lower: at restore // the actor cold-boots from the OCI image (or, under an OnGolden data // resume policy, is combined with the golden snapshot's guest state). - // - Durable-dir tar (any scope, when declared): host-backed, so pausing - // the write-through share makes the tar coherent. + // - Durable-dir capture (any scope, when declared): host-backed, so + // pausing the write-through share makes it coherent. // - Rootfs upper tar (Full only): host-backed like the durable volumes — // the memory snapshot does not carry rootfs writes. Under Data the // workload cold-starts on restore, discarding rootfs state. @@ -157,7 +157,7 @@ func (s *AteomService) CheckpointWorkload(ctx context.Context, req *ateompb.Chec if durable { g.Go(func() error { t := time.Now() - if err := tarDurableVolumes(gctx, ateompath.DurableDirVolumeMountsDir(actorUID), checkpointDir); err != nil { + if err := captureDurableVolumes(gctx, ateompath.DurableDirVolumeMountsDir(actorUID), checkpointDir); err != nil { return err } dDurable = time.Since(t) @@ -180,7 +180,7 @@ func (s *AteomService) CheckpointWorkload(ctx context.Context, req *ateompb.Chec // Report exactly the files we wrote so atelet ships precisely this snapshot: for // Full, the CH snapshot (config.json + state.json + memory-ranges + base-id) plus - // any durable-dir tar; for Data, that tar alone. + // any durable-dir files; for Data, those alone. snapshotFiles, err := listFiles(checkpointDir) if err != nil { return nil, fmt.Errorf("while listing snapshot files: %w", err) @@ -201,9 +201,11 @@ func (s *AteomService) CheckpointWorkload(ctx context.Context, req *ateompb.Chec slog.InfoContext(ctx, "Actor checkpointed", slog.String("id", actorUID), slog.Any("snapshot_files", snapshotFiles), slog.String("scope", scope.String()), slog.Duration("pause", dPause), slog.Duration("snapshot", dSnapshot), - // The tars run while the guest is paused, CONCURRENTLY with the CH + // The captures run while the guest is paused, CONCURRENTLY with the CH // snapshot: the paused window costs max(snapshot, durable_dir, - // rootfs_upper), and the tar durations scale with the actor's data. + // rootfs_upper). rootfs_upper scales with the actor's data, and so does + // durable_dir unless the split arrangement is on, where it scales with + // the file count instead. slog.Duration("durable_dir", dDurable), slog.Duration("rootfs_upper", dUpper), slog.Duration("teardown", dTeardown)) return &ateompb.CheckpointWorkloadResponse{SnapshotFiles: snapshotFiles}, nil diff --git a/cmd/ateom-microvm/durable.go b/cmd/ateom-microvm/durable.go index 6287dec92c..00f607bd68 100644 --- a/cmd/ateom-microvm/durable.go +++ b/cmd/ateom-microvm/durable.go @@ -29,14 +29,29 @@ package main // virtio-fs share at SharedDir(actorUID)/durable, where each container's bind // is attached. // -// Snapshots carry the contents as a tar of the whole per-actor directory, so -// every volume rides along and the layout is reproduced verbatim on restore. +// Snapshots carry the contents of the whole per-actor directory, so every +// volume rides along and the layout is reproduced verbatim on restore. // virtiofsd serves the share write-through (no --writeback), so once the guest // is paused every completed guest write is already visible on the host and the -// tar is complete. +// capture is complete. +// +// There are two arrangements for that capture, chosen by ATEOM_DURABLE_BACKEND: +// +// - tar (the default): one archive of the whole directory. Sealing it reads +// and rewrites every byte the actor holds, on the paused critical path. +// - files: a metadata-only index tar plus one blob per non-empty regular +// file, hard-linked out of the directory rather than copied. Sealing costs +// one link per file instead of a copy of the tree, so the paused window +// stops scaling with the actor's data. The price is a snapshot of many +// objects rather than one, which a directory of many small files pays for +// on upload. +// +// A restore dispatches on what the snapshot actually contains, not on the +// variable, so either arrangement reads back under either setting. import ( "context" + "errors" "fmt" "os" "path/filepath" @@ -48,12 +63,30 @@ import ( "github.com/agent-substrate/substrate/internal/tarutil" ) -// durableTarFile is the snapshot file holding the tar of the actor's durable-dir -// volumes. Its entries are /... relative to -// ateompath.DurableDirVolumeMountsDir, so extraction restores the same layout. -// The name is shared with atelet, which uses it to carve durable data out of a -// FULL snapshot's file set when uploading a paused checkpoint as DATA. -const durableTarFile = ateompath.DurableDirTarFile +// The snapshot files holding the actor's durable-dir volumes, in whichever +// arrangement wrote them. Entries are /... relative to +// ateompath.DurableDirVolumeMountsDir, so a restore reproduces the same layout. +// The names are shared with atelet, which uses them to carve durable data out +// of a FULL snapshot's file set when uploading a paused checkpoint as DATA. +const ( + durableTarFile = ateompath.DurableDirTarFile + durableIndexFile = ateompath.DurableDirIndexFile + durableBlobPrefix = ateompath.DurableDirBlobPrefix +) + +// durableBackendEnvVar selects the arrangement a checkpoint writes. Only +// durableBackendFiles is recognized; anything else, including unset, keeps the +// tar. +const ( + durableBackendEnvVar = "ATEOM_DURABLE_BACKEND" + durableBackendFiles = "files" +) + +// splitDurableCapture reports whether checkpoints should write the split +// arrangement. +func splitDurableCapture() bool { + return os.Getenv(durableBackendEnvVar) == durableBackendFiles +} // hasDurableVolumes reports whether any container mounts a durable-dir volume. func hasDurableVolumes(containers []*ateompb.Container) bool { @@ -78,28 +111,60 @@ func (s *AteomService) stageDurableVolumes(ctx context.Context, actorUID string) return nil } -// tarDurableVolumes archives the actor's durable-dir volumes (dir) into the +// captureDurableVolumes writes the actor's durable-dir volumes (dir) into the // checkpoint directory. The caller must have paused the guest first: virtiofsd is // write-through, so a completed guest write is on the host by then, but a // running guest could still add more after the walk. // // Sockets the workload left behind are skipped rather than archived (tarutil // logs them); they hold no data and the workload recreates them on start. -func tarDurableVolumes(ctx context.Context, dir, checkpointDir string) error { - if err := tarutil.Create(ctx, filepath.Join(checkpointDir, durableTarFile), dir); err != nil { +// +// The split arrangement hardlinks file contents into the checkpoint directory, +// which means dir must not be written again afterwards. CheckpointWorkload +// guarantees that: the guest stays paused until terminateWorkload tears the +// sandbox down, and atelet resets the actor's directories after the RPC +// returns. +func captureDurableVolumes(ctx context.Context, dir, checkpointDir string) error { + var err error + if splitDurableCapture() { + _, err = tarutil.CreateSplit(ctx, filepath.Join(checkpointDir, durableIndexFile), dir, checkpointDir, durableBlobPrefix) + } else { + err = tarutil.Create(ctx, filepath.Join(checkpointDir, durableTarFile), dir) + } + if err != nil { return fmt.Errorf("while archiving durable-dir volumes from %q: %w", dir, err) } return nil } -// untarDurableVolumes restores the durable-dir volumes from a snapshot into the +// restoreDurableVolumes restores the durable-dir volumes from a snapshot into the // actor's host directory (dir, which atelet has already created, empty). It must // run before the durable share's virtiofsd starts, so the guest never observes // the directory mid-restore. -func untarDurableVolumes(dir, snapshotDir string) error { +// +// Which arrangement the snapshot is in is read off the snapshot itself: a node +// configured for one has to be able to restore an actor captured under the +// other, and the snapshot outlives whatever ateom wrote it. +// +// The split arrangement hands the guest the staged blobs themselves, hard-linked +// rather than copied, so the guest's writes land on snapshotDir's inodes. That +// is sound because snapshotDir belongs to this activation alone: atelet stages +// it by copying out of the retained checkpoint — so a write can never reach the +// checkpoint every later restore reads — nothing reads it once this call +// returns, and resetActorDirs deletes it together with dir. +func restoreDurableVolumes(dir, snapshotDir string) error { if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("while creating durable-dir volumes dir %q: %w", dir, err) } + index := filepath.Join(snapshotDir, durableIndexFile) + if _, err := os.Stat(index); err == nil { + if err := tarutil.ExtractSplit(index, snapshotDir, dir); err != nil { + return fmt.Errorf("while restoring durable-dir volumes into %q: %w", dir, err) + } + return nil + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("while checking for durable-dir index %q: %w", index, err) + } if err := tarutil.Extract(filepath.Join(snapshotDir, durableTarFile), dir); err != nil { return fmt.Errorf("while restoring durable-dir volumes into %q: %w", dir, err) } diff --git a/cmd/ateom-microvm/durable_test.go b/cmd/ateom-microvm/durable_test.go index cf2060bd64..949c599484 100644 --- a/cmd/ateom-microvm/durable_test.go +++ b/cmd/ateom-microvm/durable_test.go @@ -73,30 +73,32 @@ func durableDirWith(t *testing.T, volumes []string, strayFile bool) string { return dir } -func TestDurableVolumesRoundTrip(t *testing.T) { - // Checkpoint: every volume the actor has, archived while the guest is paused. +// volumeContents is the two-volume tree the round-trip tests capture. +var volumeContents = map[string]string{"data": "42", "cache": "7"} + +// captureTwoVolumes builds that tree and checkpoints it, returning the +// checkpoint directory. +func captureTwoVolumes(t *testing.T) string { + t.Helper() src := durableDirWith(t, []string{"data", "cache"}, false) - for vol, content := range map[string]string{"data": "42", "cache": "7"} { + for vol, content := range volumeContents { if err := os.WriteFile(filepath.Join(src, vol, "a.txt"), []byte(content), 0o644); err != nil { t.Fatalf("writing %q content: %v", vol, err) } } checkpointDir := t.TempDir() - if err := tarDurableVolumes(t.Context(), src, checkpointDir); err != nil { - t.Fatalf("tarDurableVolumes: %v", err) - } - if _, err := os.Stat(filepath.Join(checkpointDir, durableTarFile)); err != nil { - t.Fatalf("checkpoint is missing %s: %v", durableTarFile, err) + if err := captureDurableVolumes(t.Context(), src, checkpointDir); err != nil { + t.Fatalf("captureDurableVolumes: %v", err) } + return checkpointDir +} - // Restore: onto the empty directory atelet re-creates for the actor. - dst := t.TempDir() - if err := untarDurableVolumes(dst, checkpointDir); err != nil { - t.Fatalf("untarDurableVolumes: %v", err) - } - // Both volumes come back, each under its own name: the names are what the - // guest mount paths are built from after a restore onto another node. - for vol, want := range map[string]string{"data": "42", "cache": "7"} { +// assertVolumesRestored checks the tree came back under the same volume names, +// which are what the guest mount paths are built from after a restore onto +// another node. +func assertVolumesRestored(t *testing.T, dst string) { + t.Helper() + for vol, want := range volumeContents { got, err := os.ReadFile(filepath.Join(dst, vol, "a.txt")) if err != nil { t.Errorf("reading restored %q content: %v", vol, err) @@ -107,3 +109,95 @@ func TestDurableVolumesRoundTrip(t *testing.T) { } } } + +func TestDurableVolumesRoundTrip(t *testing.T) { + for _, tc := range []struct { + name string + backend string + // The file whose presence identifies the arrangement. + marker string + }{ + {name: "tar", backend: "", marker: durableTarFile}, + {name: "split", backend: durableBackendFiles, marker: durableIndexFile}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv(durableBackendEnvVar, tc.backend) + + checkpointDir := captureTwoVolumes(t) + if _, err := os.Stat(filepath.Join(checkpointDir, tc.marker)); err != nil { + t.Fatalf("checkpoint is missing %s: %v", tc.marker, err) + } + + // Restore onto the empty directory atelet re-creates for the actor. + dst := t.TempDir() + if err := restoreDurableVolumes(dst, checkpointDir); err != nil { + t.Fatalf("restoreDurableVolumes: %v", err) + } + assertVolumesRestored(t, dst) + }) + } +} + +// A snapshot outlives the ateom that wrote it and can be restored on a node +// configured the other way, so the arrangement has to be read off the snapshot +// rather than off the environment. +func TestDurableVolumesRestoreIgnoresTheBackendSetting(t *testing.T) { + for _, tc := range []struct{ capture, restore string }{ + {capture: "", restore: durableBackendFiles}, + {capture: durableBackendFiles, restore: ""}, + } { + t.Run(tc.capture+"-then-"+tc.restore, func(t *testing.T) { + t.Setenv(durableBackendEnvVar, tc.capture) + checkpointDir := captureTwoVolumes(t) + + t.Setenv(durableBackendEnvVar, tc.restore) + dst := t.TempDir() + if err := restoreDurableVolumes(dst, checkpointDir); err != nil { + t.Fatalf("restoreDurableVolumes: %v", err) + } + assertVolumesRestored(t, dst) + }) + } +} + +// The split arrangement earns its keep by not copying the actor's bytes on the +// paused path: every blob must share its inode with the file it came from, and +// the index must stay small however large the tree is. +func TestSplitDurableCaptureLinksRatherThanCopies(t *testing.T) { + t.Setenv(durableBackendEnvVar, durableBackendFiles) + + src := durableDirWith(t, []string{"data"}, false) + big := filepath.Join(src, "data", "big.bin") + if err := os.WriteFile(big, make([]byte, 1<<20), 0o644); err != nil { + t.Fatalf("writing large file: %v", err) + } + checkpointDir := t.TempDir() + if err := captureDurableVolumes(t.Context(), src, checkpointDir); err != nil { + t.Fatalf("captureDurableVolumes: %v", err) + } + + blob := filepath.Join(checkpointDir, durableBlobPrefix+"0000") + if !sameInode(t, big, blob) { + t.Errorf("%s is a copy of %s, not a link to it", blob, big) + } + index, err := os.Stat(filepath.Join(checkpointDir, durableIndexFile)) + if err != nil { + t.Fatalf("stat index: %v", err) + } + if index.Size() >= 1<<20 { + t.Errorf("index is %d bytes; it carries the file contents rather than just the metadata", index.Size()) + } +} + +func sameInode(t *testing.T, a, b string) bool { + t.Helper() + fa, err := os.Stat(a) + if err != nil { + t.Fatalf("stat %q: %v", a, err) + } + fb, err := os.Stat(b) + if err != nil { + t.Fatalf("stat %q: %v", b, err) + } + return os.SameFile(fa, fb) +} diff --git a/cmd/ateom-microvm/restore.go b/cmd/ateom-microvm/restore.go index 66c12f2665..9632d819d5 100644 --- a/cmd/ateom-microvm/restore.go +++ b/cmd/ateom-microvm/restore.go @@ -130,10 +130,11 @@ func (s *AteomService) RestoreWorkload(ctx context.Context, req *ateompb.Restore // cold-starts. The snapshot must carry them — the actor declares the volume, and // every scope captures it. if hasDurableVolumes(p.containers) { - if err := untarDurableVolumes(durableDir, restoreDir); err != nil { + if err := restoreDurableVolumes(durableDir, restoreDir); err != nil { return nil, err } } + tDurable := time.Now() switch scope := req.GetScope(); scope { case ateompb.SnapshotScope_SNAPSHOT_SCOPE_FULL, @@ -152,8 +153,15 @@ func (s *AteomService) RestoreWorkload(ctx context.Context, req *ateompb.Restore if err := s.coldBootActorRetrying(ctx, p); err != nil { return nil, err } + // Split, because the two halves answer different questions and the + // arrangement the snapshot is in moves cost between them: durable is the + // host materializing the volumes, boot is the guest starting on top of + // them — including its first reads of what was just written. slog.InfoContext(ctx, "Actor restored (durable-dir volumes, cold boot)", - slog.String("id", p.actorUID), slog.Duration("total", time.Since(tStart))) + slog.String("id", p.actorUID), + slog.Duration("durable", tDurable.Sub(tStart)), + slog.Duration("boot", time.Since(tDurable)), + slog.Duration("total", time.Since(tStart))) default: return nil, status.Errorf(codes.InvalidArgument, "unsupported snapshot scope: %v", scope) } diff --git a/internal/ateompath/ateompath.go b/internal/ateompath/ateompath.go index 401dd313b6..e7daa4caca 100644 --- a/internal/ateompath/ateompath.go +++ b/internal/ateompath/ateompath.go @@ -17,6 +17,7 @@ package ateompath import ( "path/filepath" + "strings" ) const ( @@ -193,11 +194,36 @@ func LocalSnapshotDir(actorUID, snapshotName string) string { // DurableDirTarFile is the snapshot file holding the tar of an // actor's durable-dir volumes (entries are /... relative to -// DurableDirVolumeMountsDir). Written by ateom-microvm at checkpoint; a DATA -// snapshot consists of this file alone, so atelet uses the name to carve the -// durable data out of a FULL snapshot's file set. +// DurableDirVolumeMountsDir). Written by ateom-microvm at checkpoint. const DurableDirTarFile = "durable-dir.tar" +// DurableDirIndexFile is the snapshot file holding the same tree in the split +// arrangement (ATEOM_DURABLE_BACKEND=files): a tar of the tree's metadata +// alone, whose regular-file entries carry no payload and instead name their +// contents in a sibling blob file. Its presence is what tells a restore which +// of the two arrangements the snapshot was written in. +const DurableDirIndexFile = "durable-dir.index.tar" + +// DurableDirBlobPrefix begins the filename of each blob in that arrangement, +// which continues with a four-digit sequence number. Unlike the tar, durable +// data here is MANY files — one per non-empty regular file — so the carve +// below is by predicate rather than by a single name. +const DurableDirBlobPrefix = "durable-dir.blob-" + +// DurableDirSnapshotFile reports whether a snapshot file holds micro-VM +// durable-dir data, in either arrangement. +// +// atelet uses it to carve the durable data out of a FULL snapshot's file set +// when uploading a paused checkpoint as DATA: what is left after the carve is +// exactly what a DATA restore needs and nothing else. Both writers keep to +// this naming for that reason — a durable file the predicate misses is data +// silently dropped from a DATA snapshot. +func DurableDirSnapshotFile(name string) bool { + return name == DurableDirTarFile || + name == DurableDirIndexFile || + strings.HasPrefix(name, DurableDirBlobPrefix) +} + // DurableDirVolumeMountsDir is the directory where individual durable-dir // volumes are mounted. func DurableDirVolumeMountsDir(actorUID string) string { diff --git a/internal/ateompath/ateompath_test.go b/internal/ateompath/ateompath_test.go index d4b372ba01..ce96bab514 100644 --- a/internal/ateompath/ateompath_test.go +++ b/internal/ateompath/ateompath_test.go @@ -94,3 +94,27 @@ func TestActorPathUsesUID(t *testing.T) { t.Errorf("ActorPath(%q) = %q, want suffix %q", uid1, path1, want) } } + +// The predicate is what atelet carves durable data out of a FULL snapshot's +// file set with, so a durable file it misses is data dropped from a DATA +// snapshot and a guest file it claims is guest state smuggled into one. +func TestDurableDirSnapshotFile(t *testing.T) { + for name, want := range map[string]bool{ + DurableDirTarFile: true, + DurableDirIndexFile: true, + DurableDirBlobPrefix + "0000": true, + DurableDirBlobPrefix + "9999": true, + "config.json": false, + "state.json": false, + "memory-ranges": false, + "base-id": false, + "rootfs-upper.tar": false, + "manifest.json": false, + "durable-dir": false, + "not-durable-dir.blob-0000": false, + } { + if got := DurableDirSnapshotFile(name); got != want { + t.Errorf("DurableDirSnapshotFile(%q) = %v, want %v", name, got, want) + } + } +} diff --git a/internal/tarutil/split.go b/internal/tarutil/split.go new file mode 100644 index 0000000000..d9a572623f --- /dev/null +++ b/internal/tarutil/split.go @@ -0,0 +1,219 @@ +//go:build linux + +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package tarutil + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "syscall" + + "golang.org/x/sys/unix" +) + +// blobRecord is the PAX key naming the sibling file that holds a regular-file +// entry's contents. Only entries written by CreateSplit carry it, so a reader +// tells the two arrangements apart per entry rather than per archive. +const blobRecord = "ate.blob" + +// CreateSplit archives srcDir like Create, except that each non-empty regular +// file's contents go to their own blob file in blobDir instead of into the +// archive. tarPath is left holding the tree's metadata alone — modes, +// ownership, times, symlinks, hardlinks, FIFOs, devices, and xattrs, exactly +// as Create records them — which makes it small no matter how large the tree +// is. Blobs are named blobPrefix followed by a four-digit sequence number in +// walk order; the returned slice lists them. +// +// The blobs are HARDLINKS to the source files, not copies. That is the whole +// point: the split exists to take the file contents off a checkpoint's paused +// critical path, and copying them would put the bytes straight back. The +// caller must therefore treat srcDir as frozen from the moment CreateSplit +// returns — a later write through an original path is a write to the blob. +// ateom relies on that: it captures a paused guest whose sandbox is torn down, +// and whose directories are reset, before anything can run again. A blobDir on +// a different filesystem than srcDir falls back to copying. +// +// ExtractSplit puts the two halves back together. +func CreateSplit(ctx context.Context, tarPath, srcDir, blobDir, blobPrefix string) ([]string, error) { + sink := &blobSink{dir: blobDir, prefix: blobPrefix} + if err := createTree(ctx, tarPath, srcDir, nil, sink); err != nil { + return nil, err + } + return sink.names, nil +} + +// ExtractSplit unpacks a CreateSplit pair into dstDir, which must already +// exist: tarPath supplies the tree and its metadata, blobDir the file +// contents. It accepts an archive Create wrote too, so a caller that dispatches +// on the archive's name does not also have to know how each entry was stored. +// +// Extraction ADOPTS each blob: the extracted file is a hardlink to it, not a +// copy, so blobDir is consumed rather than merely read and a later write to an +// extracted file is a write to the blob. This is the mirror of CreateSplit's +// hardlink and exists for the same reason — a copy here would spend a second +// full write of the tree on the restore path, which is what the split +// arrangement is trying to avoid. +// +// The caller must therefore own blobDir outright. A blob that is already linked +// from elsewhere is copied rather than adopted, so a caller that assembled the +// directory by linking gets correct contents instead of a shared inode; so does +// a blobDir on a different filesystem than dstDir. +func ExtractSplit(tarPath, blobDir, dstDir string) error { + blobs, err := os.OpenRoot(blobDir) + if err != nil { + return fmt.Errorf("opening blob directory %q: %w", blobDir, err) + } + defer blobs.Close() + return extractTree(tarPath, dstDir, blobs) +} + +// blobSink hands out sequential blob names and links each regular file to one. +type blobSink struct { + dir string + prefix string + names []string +} + +// put stores path's contents as the next blob and returns its name. +func (s *blobSink) put(path string) (string, error) { + name := fmt.Sprintf("%s%04d", s.prefix, len(s.names)) + dst := filepath.Join(s.dir, name) + + err := os.Link(path, dst) + // EXDEV: a blob directory on another filesystem. EMLINK: the inode is + // already at the filesystem's link ceiling. EPERM: some filesystems refuse + // hardlinks outright. None of them are worth failing a checkpoint over + // when copying the bytes still produces a correct blob. + if errors.Is(err, syscall.EXDEV) || errors.Is(err, syscall.EMLINK) || errors.Is(err, syscall.EPERM) { + err = copyBlob(path, dst) + } + if err != nil { + return "", fmt.Errorf("storing contents of %q as %q: %w", path, name, err) + } + + s.names = append(s.names, name) + return name, nil +} + +// copyBlob writes path's contents to dst, flushing them before returning: the +// blob is handed to atelet for upload as soon as the checkpoint completes. +func copyBlob(path, dst string) error { + in, err := os.Open(path) + if err != nil { + return err + } + defer in.Close() + out, err := os.OpenFile(dst, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + return err + } + defer out.Close() + if _, err := io.Copy(out, in); err != nil { + return err + } + if err := out.Sync(); err != nil { + return err + } + return out.Close() +} + +// checkBlobName rejects a blob name that is not a single path component: it is +// read back from an archive that traveled through object storage, so it is not +// trusted to stay inside the blob directory on its own. +func checkBlobName(name, entry string) error { + if name == "" || name == "." || name == ".." || strings.ContainsRune(name, filepath.Separator) { + return fmt.Errorf("entry %q names an invalid blob %q", entry, name) + } + return nil +} + +// openBlob returns a reader over the blob an entry's PAX record names. +func openBlob(blobs *os.Root, name, entry string) (io.ReadCloser, error) { + if err := checkBlobName(name, entry); err != nil { + return nil, err + } + f, err := blobs.Open(name) + if err != nil { + return nil, fmt.Errorf("opening blob %q for entry %q: %w", name, entry, err) + } + return f, nil +} + +// linkat is a seam for exercising the copy fallback, which otherwise needs two +// filesystems to reach. +var linkat = unix.Linkat + +// linkBlob hardlinks the blob named blobName to entry name under root, +// reporting whether it succeeded. A filesystem that cannot take the link is +// not a failure — the caller copies instead — so only a genuine error is +// returned. +// +// A blob that is already linked from somewhere else is copied instead. Adopting +// it would hand the extracted tree an inode a second owner still holds, and +// whatever that owner is keeping it for would then take the writes the tree +// receives. Refusing enforces the contract ExtractSplit states, rather than +// leaving it to whoever assembles the blob directory next. +// +// Both ends are addressed through their directory's file descriptor rather +// than by joining host paths, the same containment the rest of this package +// uses: a symlinked intermediate component in a crafted archive must not +// redirect the new link outside the extraction dir. +func linkBlob(blobs *os.Root, blobName string, root *os.Root, name string) (bool, error) { + if err := checkBlobName(blobName, name); err != nil { + return false, err + } + info, err := blobs.Stat(blobName) + if err != nil { + return false, fmt.Errorf("stating blob %q for entry %q: %w", blobName, name, err) + } + if st, ok := info.Sys().(*syscall.Stat_t); !ok || st.Nlink != 1 { + return false, nil + } + src, err := blobs.Open(".") + if err != nil { + return false, fmt.Errorf("opening blob directory to link blob %q: %w", blobName, err) + } + defer src.Close() + + dir, base := filepath.Split(name) + if dir == "" { + dir = "." + } + parent, err := root.Open(filepath.Clean(dir)) + if err != nil { + return false, fmt.Errorf("opening parent directory of %q to link blob %q: %w", name, blobName, err) + } + defer parent.Close() + + err = linkat(int(src.Fd()), blobName, int(parent.Fd()), base, 0) + switch { + case err == nil: + return true, nil + // EXDEV: the blob directory is on another filesystem. EMLINK: the inode is + // already at the filesystem's link ceiling. EPERM/EOPNOTSUPP: some + // filesystems refuse hardlinks outright. + case errors.Is(err, unix.EXDEV), errors.Is(err, unix.EMLINK), + errors.Is(err, unix.EPERM), errors.Is(err, unix.EOPNOTSUPP): + return false, nil + default: + return false, fmt.Errorf("linking blob %q to entry %q: %w", blobName, name, err) + } +} diff --git a/internal/tarutil/split_test.go b/internal/tarutil/split_test.go new file mode 100644 index 0000000000..f6ea886783 --- /dev/null +++ b/internal/tarutil/split_test.go @@ -0,0 +1,503 @@ +//go:build linux + +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package tarutil + +import ( + "archive/tar" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "golang.org/x/sys/unix" +) + +const testBlobPrefix = "blob-" + +// splitTree builds the tree the round trip is checked against and returns it. +func splitTree(t *testing.T) string { + t.Helper() + src := t.TempDir() + write := func(rel, content string, mode os.FileMode) { + t.Helper() + p := filepath.Join(src, rel) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatalf("mkdir for %q: %v", rel, err) + } + if err := os.WriteFile(p, []byte(content), mode); err != nil { + t.Fatalf("writing %q: %v", rel, err) + } + if err := os.Chmod(p, mode); err != nil { + t.Fatalf("chmod %q: %v", rel, err) + } + } + write("a.txt", "hello", 0o644) + write("sub/b.txt", "nested", 0o600) + write("empty.txt", "", 0o644) + if err := os.Symlink("a.txt", filepath.Join(src, "link")); err != nil { + t.Fatalf("symlink: %v", err) + } + if err := os.Link(filepath.Join(src, "a.txt"), filepath.Join(src, "hard.txt")); err != nil { + t.Fatalf("hardlink: %v", err) + } + return src +} + +func TestSplitRoundTrip(t *testing.T) { + src := splitTree(t) + mtime := time.Unix(1600000000, 0) + if err := os.Chtimes(filepath.Join(src, "a.txt"), mtime, mtime); err != nil { + t.Fatalf("chtimes: %v", err) + } + + blobDir := t.TempDir() + tarPath := filepath.Join(blobDir, "index.tar") + if _, err := CreateSplit(t.Context(), tarPath, src, blobDir, testBlobPrefix); err != nil { + t.Fatalf("CreateSplit: %v", err) + } + dst := t.TempDir() + if err := ExtractSplit(tarPath, blobDir, dst); err != nil { + t.Fatalf("ExtractSplit: %v", err) + } + + t.Run("contents", func(t *testing.T) { + for rel, want := range map[string]string{ + "a.txt": "hello", + "sub/b.txt": "nested", + "empty.txt": "", + "hard.txt": "hello", + } { + got, err := os.ReadFile(filepath.Join(dst, rel)) + if err != nil { + t.Errorf("reading %q: %v", rel, err) + continue + } + if string(got) != want { + t.Errorf("%q = %q, want %q", rel, got, want) + } + } + }) + + t.Run("modes", func(t *testing.T) { + for rel, want := range map[string]os.FileMode{ + "a.txt": 0o644, + "sub/b.txt": 0o600, + "empty.txt": 0o644, + } { + st, err := os.Lstat(filepath.Join(dst, rel)) + if err != nil { + t.Errorf("lstat %q: %v", rel, err) + continue + } + if got := st.Mode().Perm(); got != want { + t.Errorf("%q mode = %v, want %v", rel, got, want) + } + } + }) + + t.Run("mtime", func(t *testing.T) { + st, err := os.Stat(filepath.Join(dst, "a.txt")) + if err != nil { + t.Fatalf("stat: %v", err) + } + if !st.ModTime().Equal(mtime) { + t.Errorf("a.txt mtime = %v, want %v", st.ModTime(), mtime) + } + }) + + t.Run("symlink", func(t *testing.T) { + got, err := os.Readlink(filepath.Join(dst, "link")) + if err != nil { + t.Fatalf("readlink: %v", err) + } + if got != "a.txt" { + t.Errorf("link target = %q, want %q", got, "a.txt") + } + }) + + // Diverting contents must not turn the archive's second link into a second + // copy: the split records the link exactly as Create does, so only the + // first of the pair ever gets a blob. + t.Run("hardlink shares inode", func(t *testing.T) { + a, err := os.Stat(filepath.Join(dst, "a.txt")) + if err != nil { + t.Fatalf("stat a.txt: %v", err) + } + h, err := os.Stat(filepath.Join(dst, "hard.txt")) + if err != nil { + t.Fatalf("stat hard.txt: %v", err) + } + if !os.SameFile(a, h) { + t.Error("hard.txt is a copy, want the same inode as a.txt") + } + }) +} + +// The blobs must be links, not copies, and the archive must be left holding +// metadata alone. Together those are the whole reason the split exists: a +// checkpoint of a large tree costs one link per file instead of a rewrite. +func TestCreateSplitLinksContentsAndLeavesTheArchiveSmall(t *testing.T) { + src := t.TempDir() + big := filepath.Join(src, "big.bin") + if err := os.WriteFile(big, make([]byte, 1<<20), 0o644); err != nil { + t.Fatalf("writing big file: %v", err) + } + + blobDir := t.TempDir() + tarPath := filepath.Join(t.TempDir(), "index.tar") + blobs, err := CreateSplit(t.Context(), tarPath, src, blobDir, testBlobPrefix) + if err != nil { + t.Fatalf("CreateSplit: %v", err) + } + if len(blobs) != 1 { + t.Fatalf("blobs = %v, want exactly one", blobs) + } + + srcInfo, err := os.Stat(big) + if err != nil { + t.Fatalf("stat source: %v", err) + } + blobInfo, err := os.Stat(filepath.Join(blobDir, blobs[0])) + if err != nil { + t.Fatalf("stat blob: %v", err) + } + if !os.SameFile(srcInfo, blobInfo) { + t.Error("blob is a copy, want the same inode as the source file") + } + + idx, err := os.Stat(tarPath) + if err != nil { + t.Fatalf("stat archive: %v", err) + } + if idx.Size() >= 1<<20 { + t.Errorf("archive is %d bytes; it still carries the file contents", idx.Size()) + } +} + +// An empty file is fully described by its header, so giving it a blob would be +// one more object to ship for no bytes. +func TestCreateSplitSkipsEmptyFiles(t *testing.T) { + src := t.TempDir() + if err := os.WriteFile(filepath.Join(src, "empty.txt"), nil, 0o644); err != nil { + t.Fatalf("writing empty file: %v", err) + } + if err := os.WriteFile(filepath.Join(src, "full.txt"), []byte("x"), 0o644); err != nil { + t.Fatalf("writing non-empty file: %v", err) + } + + blobDir := t.TempDir() + blobs, err := CreateSplit(t.Context(), filepath.Join(t.TempDir(), "index.tar"), src, blobDir, testBlobPrefix) + if err != nil { + t.Fatalf("CreateSplit: %v", err) + } + if len(blobs) != 1 { + t.Errorf("blobs = %v, want one (the non-empty file alone)", blobs) + } + entries, err := os.ReadDir(blobDir) + if err != nil { + t.Fatalf("reading blob dir: %v", err) + } + if len(entries) != 1 { + t.Errorf("blob dir holds %d files, want 1", len(entries)) + } +} + +// A restore dispatches on the snapshot's file names, so ExtractSplit is +// reached with archives Create wrote too. +func TestExtractSplitReadsAPlainArchive(t *testing.T) { + src := splitTree(t) + tarPath := filepath.Join(t.TempDir(), "plain.tar") + if err := Create(t.Context(), tarPath, src); err != nil { + t.Fatalf("Create: %v", err) + } + dst := t.TempDir() + if err := ExtractSplit(tarPath, t.TempDir(), dst); err != nil { + t.Fatalf("ExtractSplit: %v", err) + } + got, err := os.ReadFile(filepath.Join(dst, "a.txt")) + if err != nil { + t.Fatalf("reading a.txt: %v", err) + } + if string(got) != "hello" { + t.Errorf("a.txt = %q, want %q", got, "hello") + } +} + +// The blob name travels through object storage inside the archive, so it is +// not trusted to name something inside the blob directory. +func TestExtractSplitRejectsBlobEscapes(t *testing.T) { + for _, blob := range []string{"../secret", "sub/blob-0000", "..", ""} { + t.Run(blob, func(t *testing.T) { + tarPath := filepath.Join(t.TempDir(), "crafted.tar") + writeTar(t, tarPath, tar.Header{ + Name: "a.txt", + Typeflag: tar.TypeReg, + Mode: 0o644, + Format: tar.FormatPAX, + PAXRecords: map[string]string{blobRecord: blob}, + }) + blobDir := t.TempDir() + if err := os.WriteFile(filepath.Join(filepath.Dir(blobDir), "secret"), []byte("nope"), 0o600); err != nil { + t.Fatalf("writing bait: %v", err) + } + err := ExtractSplit(tarPath, blobDir, t.TempDir()) + if blob == "" { + // An absent record is not an escape: the entry simply has no + // diverted contents, exactly like an empty file. + if err != nil { + t.Errorf("ExtractSplit with no blob record = %v, want nil", err) + } + return + } + if err == nil { + t.Errorf("ExtractSplit accepted blob name %q", blob) + } + }) + } +} + +// Extracting a split archive without its blobs must fail rather than quietly +// produce a tree of empty files, which would read as a successful restore that +// lost every byte the actor had. +func TestExtractRefusesASplitArchiveWithoutBlobs(t *testing.T) { + src := t.TempDir() + if err := os.WriteFile(filepath.Join(src, "a.txt"), []byte("hello"), 0o644); err != nil { + t.Fatalf("writing file: %v", err) + } + blobDir := t.TempDir() + tarPath := filepath.Join(t.TempDir(), "index.tar") + if _, err := CreateSplit(t.Context(), tarPath, src, blobDir, testBlobPrefix); err != nil { + t.Fatalf("CreateSplit: %v", err) + } + err := Extract(tarPath, t.TempDir()) + if err == nil { + t.Fatal("Extract accepted a split archive with no blob directory") + } + if !strings.Contains(err.Error(), "blob") { + t.Errorf("error = %v, want it to name the missing blobs", err) + } +} + +// Extraction adopts the blobs rather than copying them, which is what keeps a +// restore from writing the whole tree a second time. The test asserts the +// consequence as well as the inode, because the consequence is what callers +// have to hold up: a write to the extracted file is a write to the blob. +func TestExtractSplitAdoptsBlobs(t *testing.T) { + src := t.TempDir() + if err := os.WriteFile(filepath.Join(src, "a.txt"), []byte("hello"), 0o644); err != nil { + t.Fatalf("writing source: %v", err) + } + blobDir := t.TempDir() + tarPath := filepath.Join(t.TempDir(), "index.tar") + blobs, err := CreateSplit(t.Context(), tarPath, src, blobDir, testBlobPrefix) + if err != nil { + t.Fatalf("CreateSplit: %v", err) + } + + // atelet stages a restore by copying out of the retained checkpoint, and + // resetActorDirs has already removed the tree the blob was linked from, so + // the extraction becomes the blob's only other owner. Reproduce that here. + if err := os.RemoveAll(src); err != nil { + t.Fatalf("dropping the source link: %v", err) + } + + dst := t.TempDir() + if err := ExtractSplit(tarPath, blobDir, dst); err != nil { + t.Fatalf("ExtractSplit: %v", err) + } + + extracted := filepath.Join(dst, "a.txt") + blob := filepath.Join(blobDir, blobs[0]) + extractedInfo, err := os.Stat(extracted) + if err != nil { + t.Fatalf("stat extracted file: %v", err) + } + blobInfo, err := os.Stat(blob) + if err != nil { + t.Fatalf("stat blob: %v", err) + } + if !os.SameFile(extractedInfo, blobInfo) { + t.Error("extracted file is a copy, want the blob's own inode") + } + + if err := os.WriteFile(extracted, []byte("world"), 0o644); err != nil { + t.Fatalf("writing through the extracted file: %v", err) + } + got, err := os.ReadFile(blob) + if err != nil { + t.Fatalf("reading blob: %v", err) + } + if string(got) != "world" { + t.Errorf("blob = %q, want the write through the extracted file to reach it", got) + } +} + +// A filesystem that refuses the link must still produce the right tree, since +// a blob directory on a different filesystem than the destination is a node's +// layout rather than a fault. +func TestExtractSplitCopiesWhenTheLinkIsRefused(t *testing.T) { + src := t.TempDir() + if err := os.WriteFile(filepath.Join(src, "a.txt"), []byte("hello"), 0o644); err != nil { + t.Fatalf("writing source: %v", err) + } + blobDir := t.TempDir() + tarPath := filepath.Join(t.TempDir(), "index.tar") + blobs, err := CreateSplit(t.Context(), tarPath, src, blobDir, testBlobPrefix) + if err != nil { + t.Fatalf("CreateSplit: %v", err) + } + + // The nlink guard would otherwise short-circuit the link this test is about. + if err := os.RemoveAll(src); err != nil { + t.Fatalf("dropping the source link: %v", err) + } + + restore := linkat + t.Cleanup(func() { linkat = restore }) + linkat = func(int, string, int, string, int) error { return unix.EXDEV } + + dst := t.TempDir() + if err := ExtractSplit(tarPath, blobDir, dst); err != nil { + t.Fatalf("ExtractSplit: %v", err) + } + + extracted := filepath.Join(dst, "a.txt") + got, err := os.ReadFile(extracted) + if err != nil { + t.Fatalf("reading extracted file: %v", err) + } + if string(got) != "hello" { + t.Errorf("a.txt = %q, want %q", got, "hello") + } + extractedInfo, err := os.Stat(extracted) + if err != nil { + t.Fatalf("stat extracted file: %v", err) + } + blobInfo, err := os.Stat(filepath.Join(blobDir, blobs[0])) + if err != nil { + t.Fatalf("stat blob: %v", err) + } + if os.SameFile(extractedInfo, blobInfo) { + t.Error("extracted file shares the blob's inode, want a copy") + } +} + +// A blob someone else still holds a link to must be copied. Adopting it would +// carry the extracted tree's writes into whatever that other owner is keeping +// it for -- for ateom, the checkpoint every later restore reads. +func TestExtractSplitCopiesABlobItDoesNotOwn(t *testing.T) { + src := t.TempDir() + if err := os.WriteFile(filepath.Join(src, "a.txt"), []byte("hello"), 0o644); err != nil { + t.Fatalf("writing source: %v", err) + } + blobDir := t.TempDir() + tarPath := filepath.Join(t.TempDir(), "index.tar") + blobs, err := CreateSplit(t.Context(), tarPath, src, blobDir, testBlobPrefix) + if err != nil { + t.Fatalf("CreateSplit: %v", err) + } + // CreateSplit links out of src, so the blob already has a second owner. + blob := filepath.Join(blobDir, blobs[0]) + + dst := t.TempDir() + if err := ExtractSplit(tarPath, blobDir, dst); err != nil { + t.Fatalf("ExtractSplit: %v", err) + } + + extracted := filepath.Join(dst, "a.txt") + got, err := os.ReadFile(extracted) + if err != nil { + t.Fatalf("reading extracted file: %v", err) + } + if string(got) != "hello" { + t.Errorf("a.txt = %q, want %q", got, "hello") + } + extractedInfo, err := os.Stat(extracted) + if err != nil { + t.Fatalf("stat extracted file: %v", err) + } + blobInfo, err := os.Stat(blob) + if err != nil { + t.Fatalf("stat blob: %v", err) + } + if os.SameFile(extractedInfo, blobInfo) { + t.Error("extracted file adopted a blob a second owner still holds") + } +} + +// An error the fallback does not cover has to reach the caller: a restore that +// quietly produced a tree of empty files would read as a success that lost +// every byte the actor had. +func TestExtractSplitFailsOnAnUnexpectedLinkError(t *testing.T) { + src := t.TempDir() + if err := os.WriteFile(filepath.Join(src, "a.txt"), []byte("hello"), 0o644); err != nil { + t.Fatalf("writing source: %v", err) + } + blobDir := t.TempDir() + tarPath := filepath.Join(t.TempDir(), "index.tar") + if _, err := CreateSplit(t.Context(), tarPath, src, blobDir, testBlobPrefix); err != nil { + t.Fatalf("CreateSplit: %v", err) + } + + // The nlink guard would otherwise short-circuit the link this test is about. + if err := os.RemoveAll(src); err != nil { + t.Fatalf("dropping the source link: %v", err) + } + + restore := linkat + t.Cleanup(func() { linkat = restore }) + linkat = func(int, string, int, string, int) error { return unix.EIO } + + err := ExtractSplit(tarPath, blobDir, t.TempDir()) + if err == nil { + t.Fatal("ExtractSplit accepted a failed link") + } + if !strings.Contains(err.Error(), "blob") { + t.Errorf("error = %v, want it to name the blob", err) + } +} + +// The fallback for a blob directory the source cannot be linked into. +func TestCopyBlob(t *testing.T) { + src := filepath.Join(t.TempDir(), "a.txt") + if err := os.WriteFile(src, []byte("hello"), 0o644); err != nil { + t.Fatalf("writing source: %v", err) + } + dst := filepath.Join(t.TempDir(), "blob-0000") + if err := copyBlob(src, dst); err != nil { + t.Fatalf("copyBlob: %v", err) + } + got, err := os.ReadFile(dst) + if err != nil { + t.Fatalf("reading blob: %v", err) + } + if string(got) != "hello" { + t.Errorf("blob = %q, want %q", got, "hello") + } + srcInfo, err := os.Stat(src) + if err != nil { + t.Fatalf("stat source: %v", err) + } + blobInfo, err := os.Stat(dst) + if err != nil { + t.Fatalf("stat blob: %v", err) + } + if os.SameFile(srcInfo, blobInfo) { + t.Error("copyBlob linked rather than copied") + } +} diff --git a/internal/tarutil/tarutil.go b/internal/tarutil/tarutil.go index 0a89ad746b..5fe6eee095 100644 --- a/internal/tarutil/tarutil.go +++ b/internal/tarutil/tarutil.go @@ -103,6 +103,12 @@ type SkipFunc func(rel string) bool // CreateFiltered is Create with entries omitted where skip returns true. A nil // skip archives everything. func CreateFiltered(ctx context.Context, tarPath, srcDir string, skip SkipFunc) error { + return createTree(ctx, tarPath, srcDir, skip, nil) +} + +// createTree writes the archive. A non-nil sink diverts regular-file contents +// out of it (see CreateSplit). +func createTree(ctx context.Context, tarPath, srcDir string, skip SkipFunc, sink *blobSink) error { f, err := os.Create(tarPath) if err != nil { return fmt.Errorf("creating tar %q: %w", tarPath, err) @@ -116,7 +122,7 @@ func CreateFiltered(ctx context.Context, tarPath, srcDir string, skip SkipFunc) tarWriterPool.Put(bw) }() tw := tar.NewWriter(bw) - if err := writeTree(ctx, tw, srcDir, skip); err != nil { + if err := writeTree(ctx, tw, srcDir, skip, sink); err != nil { return err } if err := tw.Close(); err != nil { @@ -139,7 +145,7 @@ func CreateFiltered(ctx context.Context, tarPath, srcDir string, skip SkipFunc) // entry per path, omitting entries (and, for directories, subtrees) that skip // selects. The deterministic order keeps archives of identical trees // byte-comparable, which makes snapshot diffs meaningful. -func writeTree(ctx context.Context, tw *tar.Writer, srcDir string, skip SkipFunc) error { +func writeTree(ctx context.Context, tw *tar.Writer, srcDir string, skip SkipFunc, sink *blobSink) error { // Maps an already-archived multi-link inode to the name it was archived // under, so later links become tar hardlink entries instead of copies. linked := map[inodeKey]string{} @@ -230,6 +236,21 @@ func writeTree(ctx context.Context, tw *tar.Writer, srcDir string, skip SkipFunc } linked[key] = hdr.Name } + // An empty file needs no blob: the header alone reproduces it, and + // a blob per empty file would be one more object to ship for no + // bytes. + if sink != nil && hdr.Size > 0 { + blob, err := sink.put(path) + if err != nil { + return err + } + if hdr.PAXRecords == nil { + hdr.PAXRecords = map[string]string{} + } + hdr.PAXRecords[blobRecord] = blob + hdr.Size = 0 + return tw.WriteHeader(hdr) + } if err := tw.WriteHeader(hdr); err != nil { return fmt.Errorf("writing tar header for %q: %w", path, err) } @@ -271,6 +292,12 @@ func copyFileInto(tw *tar.Writer, path string) error { // an existing path replaces it ("later entry wins", standard tar semantics), // except that an existing directory is kept when the entry is also a directory. func Extract(tarPath, dstDir string) error { + return extractTree(tarPath, dstDir, nil) +} + +// extractTree unpacks the archive. blobs, when non-nil, is the directory +// holding the contents CreateSplit diverted out of it. +func extractTree(tarPath, dstDir string, blobs *os.Root) error { f, err := os.Open(tarPath) if err != nil { return fmt.Errorf("opening tar %q: %w", tarPath, err) @@ -312,7 +339,7 @@ func Extract(tarPath, dstDir string) error { if skip { continue } - if err := extractEntry(root, tr, hdr, name, dirs); err != nil { + if err := extractEntry(root, tr, hdr, name, dirs, blobs); err != nil { return err } } @@ -320,7 +347,7 @@ func Extract(tarPath, dstDir string) error { } // extractEntry materializes one archive entry under root. -func extractEntry(root *os.Root, tr *tar.Reader, hdr *tar.Header, name string, dirs map[string]*tar.Header) error { +func extractEntry(root *os.Root, tr *tar.Reader, hdr *tar.Header, name string, dirs map[string]*tar.Header, blobs *os.Root) error { mode := hdr.FileInfo().Mode().Perm() switch hdr.Typeflag { @@ -335,11 +362,36 @@ func extractEntry(root *os.Root, tr *tar.Reader, hdr *tar.Header, name string, d if err := replaceExisting(root, name); err != nil { return err } + // Contents come from the entry's blob when CreateSplit diverted them + // there, and from the archive stream otherwise. An archive can hold + // both kinds: empty files never get a blob. + contents := io.Reader(tr) + if blob := hdr.PAXRecords[blobRecord]; blob != "" { + if blobs == nil { + return fmt.Errorf("entry %q names blob %q but no blob directory was given", name, blob) + } + // Adopt the blob's inode rather than copying it; see ExtractSplit + // for what that costs the caller. Only a filesystem that refuses + // the link falls through to the copy below. + linked, err := linkBlob(blobs, blob, root, name) + if err != nil { + return err + } + if linked { + return restoreMeta(root, name, hdr) + } + rc, err := openBlob(blobs, blob, name) + if err != nil { + return err + } + defer rc.Close() + contents = rc + } out, err := root.OpenFile(name, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) if err != nil { return fmt.Errorf("creating file %q: %w", name, err) } - _, copyErr := copyPooled(out, tr) + _, copyErr := copyPooled(out, contents) closeErr := out.Close() if copyErr != nil { return fmt.Errorf("writing contents of %q: %w", name, copyErr)