Skip to content

Commit 0d0ab12

Browse files
authored
Merge branch 'main' into hypeship/template-as-state
2 parents 294c9ff + 6ebfa03 commit 0d0ab12

8 files changed

Lines changed: 349 additions & 4 deletions

lib/forkvm/copy.go

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,37 @@ import (
77
"os"
88
"path/filepath"
99
"strings"
10+
"sync/atomic"
1011
)
1112

12-
var ErrSparseCopyUnsupported = errors.New("sparse copy unsupported")
13+
var (
14+
ErrSparseCopyUnsupported = errors.New("sparse copy unsupported")
15+
ErrReflinkUnsupported = errors.New("reflink unsupported")
16+
)
17+
18+
// reflinkDisabled, when nonzero, forces CopyGuestDirectory to skip the FICLONE
19+
// fast path entirely. Tests set this; production code leaves it untouched.
20+
var reflinkDisabled atomic.Bool
21+
22+
// SetReflinkDisabled toggles the FICLONE fast path. Intended for tests that
23+
// need to exercise the sparse-copy fallback explicitly.
24+
func SetReflinkDisabled(disabled bool) {
25+
reflinkDisabled.Store(disabled)
26+
}
27+
28+
// reflinkUnsupportedSticky tracks whether reflink has already been observed to
29+
// fail with an "unsupported" signal for this destination filesystem. Once set,
30+
// we skip subsequent FICLONE attempts within the same CopyGuestDirectory call
31+
// to avoid re-paying the rejection on every file.
32+
type copyState struct {
33+
reflinkDead bool
34+
}
1335

1436
// CopyGuestDirectory recursively copies a guest directory to a new destination.
15-
// Regular files are copied using sparse extent copy only (SEEK_DATA/SEEK_HOLE).
16-
// Runtime sockets and logs are skipped because they are host-runtime artifacts.
37+
// Regular files are cloned via reflink (FICLONE) when the underlying filesystem
38+
// supports it; otherwise we fall back to a sparse extent copy
39+
// (SEEK_DATA/SEEK_HOLE). Runtime sockets and logs are skipped because they are
40+
// host-runtime artifacts.
1741
func CopyGuestDirectory(srcDir, dstDir string) error {
1842
srcInfo, err := os.Stat(srcDir)
1943
if err != nil {
@@ -27,6 +51,11 @@ func CopyGuestDirectory(srcDir, dstDir string) error {
2751
return fmt.Errorf("create destination directory: %w", err)
2852
}
2953

54+
state := &copyState{}
55+
if reflinkDisabled.Load() {
56+
state.reflinkDead = true
57+
}
58+
3059
return filepath.WalkDir(srcDir, func(path string, d fs.DirEntry, walkErr error) error {
3160
if walkErr != nil {
3261
return walkErr
@@ -61,7 +90,7 @@ func CopyGuestDirectory(srcDir, dstDir string) error {
6190
return nil
6291

6392
case mode.IsRegular():
64-
if err := copyRegularFileSparse(path, dstPath, mode.Perm()); err != nil {
93+
if err := copyRegularFile(state, path, dstPath, mode.Perm()); err != nil {
6594
return fmt.Errorf("copy file %s: %w", path, err)
6695
}
6796
return nil
@@ -86,6 +115,26 @@ func CopyGuestDirectory(srcDir, dstDir string) error {
86115
})
87116
}
88117

118+
// copyRegularFile clones path to dstPath, preferring FICLONE reflink and
119+
// falling back to sparse extent copy. The state object lets us short-circuit
120+
// future reflink attempts once we observe an "unsupported" signal from the
121+
// destination filesystem in the current copy.
122+
func copyRegularFile(state *copyState, srcPath, dstPath string, perms fs.FileMode) error {
123+
if state == nil || !state.reflinkDead {
124+
err := copyRegularFileReflink(srcPath, dstPath, perms)
125+
if err == nil {
126+
return nil
127+
}
128+
if !errors.Is(err, ErrReflinkUnsupported) {
129+
return err
130+
}
131+
if state != nil {
132+
state.reflinkDead = true
133+
}
134+
}
135+
return copyRegularFileSparse(srcPath, dstPath, perms)
136+
}
137+
89138
func shouldSkipDirectory(relPath string) bool {
90139
return relPath == "logs"
91140
}

lib/forkvm/copy_reflink_linux.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
//go:build linux
2+
3+
package forkvm
4+
5+
import (
6+
"errors"
7+
"fmt"
8+
"io/fs"
9+
"os"
10+
11+
"golang.org/x/sys/unix"
12+
)
13+
14+
// copyRegularFileReflink attempts to clone srcPath to dstPath via FICLONE
15+
// (reflink). On filesystems that support copy-on-write at the block layer
16+
// (btrfs, xfs with reflink=1, zfs, bcachefs), this is effectively
17+
// instantaneous and consumes no additional space until pages diverge.
18+
//
19+
// Returns ErrReflinkUnsupported when the filesystem or kernel rejects the
20+
// operation; callers should fall back to a full-copy path.
21+
func copyRegularFileReflink(srcPath, dstPath string, perms fs.FileMode) (retErr error) {
22+
src, err := os.Open(srcPath)
23+
if err != nil {
24+
return err
25+
}
26+
defer src.Close()
27+
28+
dst, err := os.OpenFile(dstPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, perms)
29+
if err != nil {
30+
return err
31+
}
32+
defer func() {
33+
if cerr := dst.Close(); retErr == nil && cerr != nil {
34+
retErr = cerr
35+
}
36+
if retErr != nil {
37+
_ = os.Remove(dstPath)
38+
}
39+
}()
40+
41+
if err := unix.IoctlFileClone(int(dst.Fd()), int(src.Fd())); err != nil {
42+
if isReflinkUnsupportedError(err) {
43+
return fmt.Errorf("%w: FICLONE rejected for %s: %v", ErrReflinkUnsupported, srcPath, err)
44+
}
45+
return fmt.Errorf("FICLONE %s -> %s: %w", srcPath, dstPath, err)
46+
}
47+
return nil
48+
}
49+
50+
// isReflinkUnsupportedError returns true when an FICLONE failure indicates the
51+
// operation cannot be served by the filesystem and the caller should fall
52+
// back. Real errors (EIO, ENOSPC) propagate as-is.
53+
func isReflinkUnsupportedError(err error) bool {
54+
switch {
55+
case errors.Is(err, unix.EINVAL),
56+
errors.Is(err, unix.ENOTSUP),
57+
errors.Is(err, unix.EOPNOTSUPP),
58+
errors.Is(err, unix.EXDEV),
59+
errors.Is(err, unix.ETXTBSY),
60+
errors.Is(err, unix.EISDIR),
61+
errors.Is(err, unix.ENOTTY):
62+
return true
63+
}
64+
return false
65+
}

lib/forkvm/copy_reflink_other.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
//go:build !linux
2+
3+
package forkvm
4+
5+
import (
6+
"fmt"
7+
"io/fs"
8+
)
9+
10+
// copyRegularFileReflink is unavailable on non-Linux platforms. On macOS APFS
11+
// supports clonefile(2) and could be wired up here, but we currently only
12+
// rely on the sparse-copy fallback off-Linux.
13+
func copyRegularFileReflink(srcPath, dstPath string, perms fs.FileMode) error {
14+
_ = dstPath
15+
_ = perms
16+
return fmt.Errorf("%w: reflink unsupported on this platform: %s", ErrReflinkUnsupported, srcPath)
17+
}

lib/forkvm/copy_reflink_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package forkvm
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
// TestCopyGuestDirectory_ReflinkFallback exercises the sparse-copy fallback
13+
// path. The reflink fast path is fs-dependent and not portable across CI
14+
// runners; this test forces it off and verifies copy correctness.
15+
func TestCopyGuestDirectory_ReflinkFallback(t *testing.T) {
16+
SetReflinkDisabled(true)
17+
t.Cleanup(func() { SetReflinkDisabled(false) })
18+
19+
src := filepath.Join(t.TempDir(), "src")
20+
dst := filepath.Join(t.TempDir(), "dst")
21+
22+
require.NoError(t, os.MkdirAll(src, 0755))
23+
require.NoError(t, os.WriteFile(filepath.Join(src, "rootfs.ext4"), []byte("rootfs-bytes"), 0644))
24+
require.NoError(t, os.WriteFile(filepath.Join(src, "config.json"), []byte(`{"x":1}`), 0644))
25+
26+
require.NoError(t, CopyGuestDirectory(src, dst))
27+
28+
got, err := os.ReadFile(filepath.Join(dst, "rootfs.ext4"))
29+
require.NoError(t, err)
30+
assert.Equal(t, "rootfs-bytes", string(got))
31+
32+
got, err = os.ReadFile(filepath.Join(dst, "config.json"))
33+
require.NoError(t, err)
34+
assert.Equal(t, `{"x":1}`, string(got))
35+
}
36+
37+
// TestCopyGuestDirectory_ReflinkAttempted verifies that with reflink enabled
38+
// (the default), the copy still produces a correct destination on filesystems
39+
// where FICLONE either succeeds or falls back transparently. This is the
40+
// happy-path smoke test for the new fast path; on filesystems that don't
41+
// support FICLONE the fallback handles correctness.
42+
func TestCopyGuestDirectory_ReflinkAttempted(t *testing.T) {
43+
SetReflinkDisabled(false)
44+
45+
src := filepath.Join(t.TempDir(), "src")
46+
dst := filepath.Join(t.TempDir(), "dst")
47+
48+
require.NoError(t, os.MkdirAll(src, 0755))
49+
require.NoError(t, os.WriteFile(filepath.Join(src, "rootfs.ext4"), []byte("rootfs-bytes"), 0644))
50+
51+
require.NoError(t, CopyGuestDirectory(src, dst))
52+
53+
got, err := os.ReadFile(filepath.Join(dst, "rootfs.ext4"))
54+
require.NoError(t, err)
55+
assert.Equal(t, "rootfs-bytes", string(got))
56+
}

lib/forkvm/copy_sparse_unix_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ func TestCopyGuestDirectory_PreservesSparseFiles(t *testing.T) {
4646
}
4747

4848
func TestCopyGuestDirectory_FailsWhenSparseSeekingUnsupported(t *testing.T) {
49+
SetReflinkDisabled(true)
50+
t.Cleanup(func() { SetReflinkDisabled(false) })
51+
4952
src := filepath.Join(t.TempDir(), "src")
5053
dst := filepath.Join(t.TempDir(), "dst")
5154
require.NoError(t, os.MkdirAll(src, 0755))
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
//go:build linux
2+
3+
package instances
4+
5+
import (
6+
"io/fs"
7+
"os"
8+
"path/filepath"
9+
"testing"
10+
"unsafe"
11+
12+
"github.com/stretchr/testify/require"
13+
"golang.org/x/sys/unix"
14+
)
15+
16+
const (
17+
fsIOCFiemap = 0xC020660B
18+
fiemapFlagSync = 0x1
19+
fiemapMaxExtents = 64
20+
)
21+
22+
type fiemapHeader struct {
23+
Start uint64
24+
Length uint64
25+
Flags uint32
26+
MappedExtents uint32
27+
ExtentCount uint32
28+
Reserved uint32
29+
}
30+
31+
type fiemapExtent struct {
32+
Logical uint64
33+
Physical uint64
34+
Length uint64
35+
Reserved64 [2]uint64
36+
Flags uint32
37+
Reserved [3]uint32
38+
}
39+
40+
type fiemapRequest struct {
41+
Header fiemapHeader
42+
Extents [fiemapMaxExtents]fiemapExtent
43+
}
44+
45+
func fileExtents(path string) ([]fiemapExtent, error) {
46+
f, err := os.Open(path)
47+
if err != nil {
48+
return nil, err
49+
}
50+
defer f.Close()
51+
52+
var req fiemapRequest
53+
req.Header.Length = ^uint64(0)
54+
req.Header.Flags = fiemapFlagSync
55+
req.Header.ExtentCount = fiemapMaxExtents
56+
57+
if _, _, errno := unix.Syscall(unix.SYS_IOCTL, f.Fd(), uintptr(fsIOCFiemap), uintptr(unsafe.Pointer(&req))); errno != 0 {
58+
return nil, errno
59+
}
60+
return req.Extents[:req.Header.MappedExtents], nil
61+
}
62+
63+
// assertCopyReflinked walks srcDir and verifies that at least one regular
64+
// file shares a physical extent with its counterpart under dstDir. A
65+
// successful FICLONE leaves the destination pointing at the source's
66+
// extents, so FIEMAP will report identical fe_physical offsets. If every
67+
// inspected pair has disjoint extents, the FICLONE fast path silently
68+
// degraded to a full byte copy and we want to fail loudly. Requires a
69+
// reflink-capable filesystem under the test's scratch directory (XFS with
70+
// reflink=1 in CI).
71+
func assertCopyReflinked(t *testing.T, srcDir, dstDir string) {
72+
t.Helper()
73+
74+
type candidate struct {
75+
rel string
76+
size int64
77+
}
78+
var candidates []candidate
79+
require.NoError(t, filepath.WalkDir(srcDir, func(path string, d fs.DirEntry, err error) error {
80+
if err != nil {
81+
return err
82+
}
83+
if !d.Type().IsRegular() {
84+
return nil
85+
}
86+
info, err := d.Info()
87+
if err != nil {
88+
return err
89+
}
90+
if info.Size() == 0 {
91+
return nil
92+
}
93+
rel, err := filepath.Rel(srcDir, path)
94+
if err != nil {
95+
return err
96+
}
97+
candidates = append(candidates, candidate{rel: rel, size: info.Size()})
98+
return nil
99+
}))
100+
require.NotEmpty(t, candidates, "no non-empty regular files under %s", srcDir)
101+
102+
var inspected, shared int
103+
for _, c := range candidates {
104+
dstPath := filepath.Join(dstDir, c.rel)
105+
if _, err := os.Stat(dstPath); err != nil {
106+
continue
107+
}
108+
srcExtents, err := fileExtents(filepath.Join(srcDir, c.rel))
109+
if err != nil {
110+
t.Logf("FIEMAP %s: %v", c.rel, err)
111+
continue
112+
}
113+
dstExtents, err := fileExtents(dstPath)
114+
if err != nil {
115+
t.Logf("FIEMAP %s: %v", dstPath, err)
116+
continue
117+
}
118+
inspected++
119+
if extentsShareAny(srcExtents, dstExtents) {
120+
shared++
121+
}
122+
}
123+
require.NotZero(t, inspected, "no files inspected for reflink sharing")
124+
require.NotZero(t, shared,
125+
"no files shared physical extents between %s and %s; FICLONE fast path produced full byte copies",
126+
srcDir, dstDir)
127+
}
128+
129+
func extentsShareAny(a, b []fiemapExtent) bool {
130+
if len(a) == 0 || len(b) == 0 {
131+
return false
132+
}
133+
seen := make(map[uint64]struct{}, len(a))
134+
for _, e := range a {
135+
seen[e.Physical] = struct{}{}
136+
}
137+
for _, e := range b {
138+
if _, ok := seen[e.Physical]; ok {
139+
return true
140+
}
141+
}
142+
return false
143+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
//go:build !linux
2+
3+
package instances
4+
5+
import "testing"
6+
7+
func assertCopyReflinked(t *testing.T, srcDir, dstDir string) {
8+
t.Helper()
9+
t.Logf("reflink assertion skipped on non-Linux (src=%s dst=%s)", srcDir, dstDir)
10+
}

0 commit comments

Comments
 (0)