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)