Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
70 commits
Select commit Hold shift + click to select a range
cb5b02e
Improve Firecracker fork concurrency
sjmiller609 Jun 1, 2026
f30247d
Gate concurrent fork prepare by capability
sjmiller609 Jun 2, 2026
77b176f
Extract snapshot alias lock helpers
sjmiller609 Jun 2, 2026
00af3c1
Make concurrent fork test platform independent
sjmiller609 Jun 2, 2026
69935a3
Extract Firecracker fork snapshot prep
sjmiller609 Jun 2, 2026
a5e94f0
Serialize snapshot memory prepare
sjmiller609 Jun 2, 2026
2719f06
Narrow snapshot alias read lock
sjmiller609 Jun 2, 2026
bf447bf
Preserve retained snapshot alias fallback
sjmiller609 Jun 2, 2026
af2b7bf
Cap concurrent Firecracker restores
sjmiller609 Jun 2, 2026
3c848c8
Retry transient TAP bridge attach errors
sjmiller609 Jun 2, 2026
8ecc27c
Serialize linux test packages
sjmiller609 Jun 2, 2026
ee1dfc4
Keep TAP up after bridge attach retry
sjmiller609 Jun 2, 2026
52fa3cd
Recreate TAP after transient attach failure
sjmiller609 Jun 2, 2026
2ad31d8
Attach TAP before bringing it up
sjmiller609 Jun 2, 2026
7964e88
Keep TAP success path unchanged
sjmiller609 Jun 2, 2026
9bab77f
Log gateway diagnostics on network test failures
sjmiller609 Jun 2, 2026
1f3ce72
Allow guest TCP to host gateway
sjmiller609 Jun 2, 2026
321b3ae
Allow forwarded guest gateway traffic
sjmiller609 Jun 2, 2026
494e5a4
Log host network rules on gateway probe failures
sjmiller609 Jun 2, 2026
7de7a6e
Add non-linux network diagnostic stub
sjmiller609 Jun 2, 2026
1c23044
Bind gateway TCP listeners to wildcard address
sjmiller609 Jun 2, 2026
f7b8132
Run Firecracker network lifecycle test serially
sjmiller609 Jun 2, 2026
4e3cf86
Allow host gateway responses to guest subnets
sjmiller609 Jun 2, 2026
6875f1e
Avoid duplicate TAP bridge attach after transient error
sjmiller609 Jun 3, 2026
0c410e6
Allow gateway replies without bridge output match
sjmiller609 Jun 3, 2026
c445979
Remove temporary gateway diagnostics
sjmiller609 Jun 3, 2026
eb15d03
Serialize TAP bridge attach retries
sjmiller609 Jun 3, 2026
3472ec3
Serialize linux package tests
sjmiller609 Jun 3, 2026
f07be38
Allow guest TCP to host gateway
sjmiller609 Jun 3, 2026
db57094
Allow gateway traffic through bridge filter
sjmiller609 Jun 3, 2026
8cffd57
Bind gateway listeners on wildcard address
sjmiller609 Jun 3, 2026
0448476
Add gateway failure diagnostics
sjmiller609 Jun 3, 2026
04c6cdc
Add non-linux gateway diagnostic stub
sjmiller609 Jun 3, 2026
cf6da60
Bind gateway listeners to bridge address
sjmiller609 Jun 3, 2026
323f6be
Serialize fork metadata admission
sjmiller609 Jun 3, 2026
0524be5
Disable TAP offloads for fork networking
sjmiller609 Jun 3, 2026
fc932f2
Serialize linux integration tests
sjmiller609 Jun 3, 2026
7a47e57
Allow gateway replies without output interface match
sjmiller609 Jun 3, 2026
f41683e
Allow gateway requests without input interface match
sjmiller609 Jun 3, 2026
0e3c541
Log gateway diagnostics on CI failure
sjmiller609 Jun 3, 2026
501db2c
Bind gateway listeners on wildcard address
sjmiller609 Jun 3, 2026
1c303a1
Log gateway offload diagnostics
sjmiller609 Jun 3, 2026
caade7c
Disable bridge and TAP offloads with ethtool
sjmiller609 Jun 3, 2026
5ed632d
Disable TAP offloads after bridge attach
sjmiller609 Jun 3, 2026
caf395d
Reapply TAP settings after VM startup
sjmiller609 Jun 3, 2026
eca7e26
Disable TAP generic checksum offload explicitly
sjmiller609 Jun 3, 2026
0a3d731
Add default class for async bridge shaping
sjmiller609 Jun 3, 2026
f2def6c
Create TAP devices with vnet headers
sjmiller609 Jun 3, 2026
6ce0ab0
Bind gateway listeners to bridge addresses
sjmiller609 Jun 3, 2026
bf07be9
Avoid duplicate TAP attach after transient errors
sjmiller609 Jun 3, 2026
fb24611
Disable guest tx checksum offload
sjmiller609 Jun 3, 2026
b257f62
Disable named guest checksum offload
sjmiller609 Jun 3, 2026
a68e47d
Disable host network checksum offload
sjmiller609 Jun 3, 2026
0054fcb
Reapply TAP checksum settings after VMM start
sjmiller609 Jun 3, 2026
c48dc63
Serialize linux test parallelism
sjmiller609 Jun 3, 2026
11d7543
Revert "Keep TAP up after bridge attach retry"
sjmiller609 Jun 3, 2026
6e0a9e4
Drop speculative TAP retry workaround
sjmiller609 Jun 3, 2026
4b64aa0
Restore default linux test parallelism
sjmiller609 Jun 3, 2026
c774bda
Share Firecracker restore capacity across managers
sjmiller609 Jun 3, 2026
f44e839
Allow guest traffic to host gateway
sjmiller609 Jun 3, 2026
c803b6d
Relax host gateway firewall matches
sjmiller609 Jun 3, 2026
cfd7a42
Allow bridged gateway forward traffic
sjmiller609 Jun 3, 2026
af89e1f
Limit linux test package concurrency
sjmiller609 Jun 3, 2026
dd91991
Drop speculative gateway firewall rules
sjmiller609 Jun 3, 2026
3bc1e4d
Restore linux test package parallelism
sjmiller609 Jun 3, 2026
fbf936d
Avoid source lock for no-alias fork restores
sjmiller609 Jun 3, 2026
c48afe5
Fix concurrency fork test restore signature
sjmiller609 Jun 4, 2026
f2b6a8e
Narrow UFFD version guard for Firecracker restore edits
sjmiller609 Jun 4, 2026
32b62db
Address fork concurrency review feedback
sjmiller609 Jun 4, 2026
bb2602e
Harden aliased fork restore source state
sjmiller609 Jun 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions cmd/api/api/instances.go
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,11 @@ func (s *ApiService) RestoreInstance(ctx context.Context, request oapi.RestoreIn
Code: "invalid_state",
Message: err.Error(),
}, nil
case errors.Is(err, instances.ErrInsufficientResources):
return oapi.RestoreInstance409JSONResponse{
Code: "insufficient_resources",
Message: err.Error(),
}, nil
default:
log.ErrorContext(ctx, "failed to restore instance", "error", err)
return oapi.RestoreInstance500JSONResponse{
Expand Down Expand Up @@ -683,6 +688,11 @@ func (s *ApiService) ForkInstance(ctx context.Context, request oapi.ForkInstance
Code: "name_conflict",
Message: err.Error(),
}, nil
case errors.Is(err, instances.ErrInsufficientResources):
return oapi.ForkInstance409JSONResponse{
Code: "insufficient_resources",
Message: err.Error(),
}, nil
case errors.Is(err, instances.ErrNotSupported):
return oapi.ForkInstance501JSONResponse{
Code: "not_supported",
Expand Down
37 changes: 37 additions & 0 deletions cmd/api/api/instances_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1261,6 +1261,43 @@ func TestForkInstance_InvalidRequest(t *testing.T) {
assert.Equal(t, "invalid_request", badReq.Code)
}

func TestForkInstance_InsufficientResources(t *testing.T) {
t.Parallel()
svc := newTestService(t)

source := instances.Instance{
StoredMetadata: instances.StoredMetadata{
Id: "src-instance",
Name: "src-instance",
Image: "docker.io/library/alpine:latest",
CreatedAt: time.Now(),
HypervisorType: hypervisor.TypeFirecracker,
},
State: instances.StateStandby,
}

mockMgr := &captureForkManager{
Manager: svc.InstanceManager,
err: fmt.Errorf("apply fork target state: %w: insufficient network bandwidth", instances.ErrInsufficientResources),
}
svc.InstanceManager = mockMgr

resp, err := svc.ForkInstance(
mw.WithResolvedInstance(ctx(), source.Id, source),
oapi.ForkInstanceRequestObject{
Id: source.Id,
Body: &oapi.ForkInstanceRequest{
Name: "forked-instance",
},
},
)
require.NoError(t, err)

conflict, ok := resp.(oapi.ForkInstance409JSONResponse)
require.True(t, ok, "expected 409 response")
assert.Equal(t, "insufficient_resources", conflict.Code)
}

func TestStandbyInstance_InvalidRequest(t *testing.T) {
t.Parallel()
svc := newTestService(t)
Expand Down
2 changes: 2 additions & 0 deletions cmd/api/api/snapshots.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,8 @@ func (s *ApiService) ForkSnapshot(ctx context.Context, request oapi.ForkSnapshot
return oapi.ForkSnapshot400JSONResponse{Code: "invalid_request", Message: err.Error()}, nil
case errors.Is(err, instances.ErrInvalidState), errors.Is(err, instances.ErrAlreadyExists), errors.Is(err, network.ErrNameExists):
return oapi.ForkSnapshot409JSONResponse{Code: "conflict", Message: err.Error()}, nil
case errors.Is(err, instances.ErrInsufficientResources):
return oapi.ForkSnapshot409JSONResponse{Code: "insufficient_resources", Message: err.Error()}, nil
case errors.Is(err, instances.ErrNotSupported):
return oapi.ForkSnapshot501JSONResponse{Code: "not_supported", Message: err.Error()}, nil
default:
Expand Down
5 changes: 5 additions & 0 deletions cmd/api/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ type HypervisorConfig struct {
FirecrackerBinaryPath string `koanf:"firecracker_binary_path"`
FirecrackerSnapshotMemoryBackend string `koanf:"firecracker_snapshot_memory_backend"`
FirecrackerUFFDCacheMaxBytes string `koanf:"firecracker_uffd_cache_max_bytes"`
FirecrackerMaxConcurrentRestores int `koanf:"firecracker_max_concurrent_restores"`
Memory HypervisorMemoryConfig `koanf:"memory"`
}

Expand Down Expand Up @@ -411,6 +412,7 @@ func defaultConfig() *Config {
FirecrackerBinaryPath: "",
FirecrackerSnapshotMemoryBackend: "file",
FirecrackerUFFDCacheMaxBytes: "4294967296",
FirecrackerMaxConcurrentRestores: 32,
Memory: HypervisorMemoryConfig{
Enabled: false,
KernelPageInitMode: "hardened",
Expand Down Expand Up @@ -573,6 +575,9 @@ func (c *Config) Validate() error {
if c.Build.Timeout <= 0 {
return fmt.Errorf("build.timeout must be positive, got %d", c.Build.Timeout)
}
if c.Hypervisor.FirecrackerMaxConcurrentRestores < 0 {
return fmt.Errorf("hypervisor.firecracker_max_concurrent_restores must be >= 0, got %d", c.Hypervisor.FirecrackerMaxConcurrentRestores)
}
if c.Instances.LifecycleEventBufferSize <= 0 {
return fmt.Errorf("instances.lifecycle_event_buffer_size must be positive, got %d", c.Instances.LifecycleEventBufferSize)
}
Expand Down
11 changes: 11 additions & 0 deletions cmd/api/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ func TestDefaultConfigIncludesMetricsSettings(t *testing.T) {
if cfg.Hypervisor.FirecrackerUFFDCacheMaxBytes != "4294967296" {
t.Fatalf("expected default firecracker uffd cache size to be 4294967296, got %q", cfg.Hypervisor.FirecrackerUFFDCacheMaxBytes)
}
if cfg.Hypervisor.FirecrackerMaxConcurrentRestores != 32 {
t.Fatalf("expected default firecracker max concurrent restores to be 32, got %d", cfg.Hypervisor.FirecrackerMaxConcurrentRestores)
}
}

func TestValidateFirecrackerSnapshotMemoryBackend(t *testing.T) {
Expand All @@ -86,6 +89,14 @@ func TestValidateFirecrackerSnapshotMemoryBackend(t *testing.T) {
}
}

func TestValidateFirecrackerMaxConcurrentRestores(t *testing.T) {
cfg := defaultConfig()
cfg.Hypervisor.FirecrackerMaxConcurrentRestores = -1
if err := cfg.Validate(); err == nil {
t.Fatalf("expected invalid firecracker max concurrent restores validation error")
}
}

func TestLoadEnvOverridesMetricsAndOtelInterval(t *testing.T) {
t.Setenv("METRICS__LISTEN_ADDRESS", "0.0.0.0")
t.Setenv("METRICS__PORT", "9999")
Expand Down
2 changes: 2 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ data_dir: /var/lib/hypeman
# default: cloud-hypervisor
# # Optional: use a custom Firecracker binary path instead of the embedded one.
# # firecracker_binary_path: /usr/local/bin/firecracker
# # Caps concurrent Firecracker snapshot restore/resume work to avoid host overload.
# # firecracker_max_concurrent_restores: 32
Comment thread
sjmiller609 marked this conversation as resolved.
# memory:
# enabled: false
# # performance: init_on_alloc=0 init_on_free=0 (better density)
Expand Down
19 changes: 10 additions & 9 deletions lib/hypervisor/firecracker/firecracker.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,16 @@ func (f *Firecracker) Capabilities() hypervisor.Capabilities {

func capabilities() hypervisor.Capabilities {
return hypervisor.Capabilities{
SupportsSnapshot: true,
SupportsHotplugMemory: false,
SupportsBalloonControl: true,
SupportsPause: true,
SupportsVsock: true,
SupportsGPUPassthrough: false,
SupportsDiskIOLimit: true,
SupportsGracefulVMMShutdown: false,
SupportsSnapshotBaseReuse: true,
SupportsSnapshot: true,
SupportsHotplugMemory: false,
SupportsBalloonControl: true,
SupportsPause: true,
SupportsVsock: true,
SupportsGPUPassthrough: false,
SupportsDiskIOLimit: true,
SupportsGracefulVMMShutdown: false,
SupportsSnapshotBaseReuse: true,
SupportsConcurrentForkPrepare: true,
}
}

Expand Down
30 changes: 7 additions & 23 deletions lib/hypervisor/firecracker/fork.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ package firecracker

import (
"context"
"fmt"
"os"
"path/filepath"

"github.com/kernel/hypeman/lib/hypervisor"
Expand Down Expand Up @@ -33,6 +31,7 @@ func (s *Starter) PrepareFork(ctx context.Context, req hypervisor.ForkPrepareReq
}

changed := false
result := hypervisor.ForkPrepareResult{}
if req.Network != nil && req.Network.TAPDevice != "" {
if len(meta.NetworkOverrides) == 0 {
meta.NetworkOverrides = []networkOverride{{
Expand All @@ -50,27 +49,12 @@ func (s *Starter) PrepareFork(ctx context.Context, req hypervisor.ForkPrepareReq
}
}
if req.SourceDataDir != "" && req.TargetDataDir != "" && req.SourceDataDir != req.TargetDataDir {
if meta.RetainSnapshotSourceDataDirAlias && meta.SnapshotSourceDataDir != "" {
// Keep the upstream source path for snapshot-derived forks. The retained
// Firecracker base can still reference that path after later diff snapshots.
} else {
retainAlias := false
if _, err := os.Stat(req.SourceDataDir); err != nil {
if os.IsNotExist(err) {
retainAlias = true
} else {
return hypervisor.ForkPrepareResult{}, fmt.Errorf("stat snapshot source data dir %q: %w", req.SourceDataDir, err)
}
}
if meta.SnapshotSourceDataDir != req.SourceDataDir {
meta.SnapshotSourceDataDir = req.SourceDataDir
changed = true
}
if meta.RetainSnapshotSourceDataDirAlias != retainAlias {
meta.RetainSnapshotSourceDataDirAlias = retainAlias
changed = true
}
updated, err := prepareSnapshotDataDirForFork(req.SnapshotConfigPath, meta, req.SourceDataDir, req.TargetDataDir)
if err != nil {
return hypervisor.ForkPrepareResult{}, err
}
changed = changed || updated
result.RequiresSnapshotSourceAlias = needsSnapshotSourceDirAlias(meta, req.TargetDataDir)
}

if changed {
Expand All @@ -79,5 +63,5 @@ func (s *Starter) PrepareFork(ctx context.Context, req hypervisor.ForkPrepareReq
}
}

return hypervisor.ForkPrepareResult{}, nil
return result, nil
}
101 changes: 101 additions & 0 deletions lib/hypervisor/firecracker/fork_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package firecracker

import (
"context"
"encoding/binary"
"os"
"path/filepath"
"testing"
Expand Down Expand Up @@ -65,6 +66,74 @@ func TestPrepareFork_DoesNotRetainExistingSourceAlias(t *testing.T) {
assert.False(t, meta.RetainSnapshotSourceDataDirAlias)
}

func TestPrepareFork_RewritesSnapshotStatePathsWithoutAlias(t *testing.T) {
starter := NewStarter()
tmp := t.TempDir()
sourceDir := filepath.Join(tmp, "source-12345678901234567890")
targetDir := filepath.Join(tmp, "target-12345678901234567890")
snapshotDir := filepath.Join(targetDir, "snapshots", "snapshot-latest")
require.NoError(t, os.MkdirAll(sourceDir, 0755))
require.NoError(t, os.MkdirAll(snapshotDir, 0755))
require.NoError(t, saveRestoreMetadata(targetDir, []networkInterface{{IfaceID: "eth0", HostDevName: "tap-old"}}))
writeSnapshotStateForTest(t, snapshotStatePath(snapshotDir), []byte("drive="+filepath.Join(sourceDir, "overlay.raw")+" vsock="+filepath.Join(sourceDir, "vsock.sock")))

result, err := starter.PrepareFork(context.Background(), hypervisor.ForkPrepareRequest{
SnapshotConfigPath: filepath.Join(snapshotDir, "config.json"),
SourceDataDir: sourceDir,
TargetDataDir: targetDir,
Network: &hypervisor.ForkNetworkConfig{
TAPDevice: "tap-new",
},
})
require.NoError(t, err)
assert.False(t, result.RequiresSnapshotSourceAlias)

meta, err := loadRestoreMetadata(targetDir)
require.NoError(t, err)
require.Len(t, meta.NetworkOverrides, 1)
assert.Equal(t, "tap-new", meta.NetworkOverrides[0].HostDevName)
assert.Empty(t, meta.SnapshotSourceDataDir)
assert.False(t, meta.RetainSnapshotSourceDataDirAlias)

data, err := os.ReadFile(snapshotStatePath(snapshotDir))
require.NoError(t, err)
assert.Equal(t, uint64(0), firecrackerSnapshotCRC64(data))
assert.NotContains(t, string(data), sourceDir)
assert.Contains(t, string(data), filepath.Join(targetDir, "overlay.raw"))
assert.Contains(t, string(data), filepath.Join(targetDir, "vsock.sock"))
}

func TestPrepareFork_FallsBackToAliasWhenSnapshotStatePathLengthDiffers(t *testing.T) {
starter := NewStarter()
tmp := t.TempDir()
sourceDir := filepath.Join(tmp, "source-short")
targetDir := filepath.Join(tmp, "target-much-longer")
snapshotDir := filepath.Join(targetDir, "snapshots", "snapshot-latest")
require.NoError(t, os.MkdirAll(sourceDir, 0755))
require.NoError(t, os.MkdirAll(snapshotDir, 0755))
require.NoError(t, saveRestoreMetadata(targetDir, nil))
writeSnapshotStateForTest(t, snapshotStatePath(snapshotDir), []byte("drive="+filepath.Join(sourceDir, "overlay.raw")))

result, err := starter.PrepareFork(context.Background(), hypervisor.ForkPrepareRequest{
SnapshotConfigPath: filepath.Join(snapshotDir, "config.json"),
SourceDataDir: sourceDir,
TargetDataDir: targetDir,
})
require.NoError(t, err)
assert.True(t, result.RequiresSnapshotSourceAlias)

meta, err := loadRestoreMetadata(targetDir)
require.NoError(t, err)
assert.Equal(t, sourceDir, meta.SnapshotSourceDataDir)
assert.False(t, meta.RetainSnapshotSourceDataDirAlias)

data, err := os.ReadFile(snapshotStatePath(snapshotDir))
require.NoError(t, err)
assert.Equal(t, uint64(0), firecrackerSnapshotCRC64(data))
assert.Contains(t, string(data), sourceDir)
assert.NotContains(t, string(data), targetDir)
}

func TestPrepareFork_ReturnsSourceStatErrors(t *testing.T) {
starter := NewStarter()
tmp := t.TempDir()
Expand All @@ -81,6 +150,30 @@ func TestPrepareFork_ReturnsSourceStatErrors(t *testing.T) {
assert.Contains(t, err.Error(), "stat snapshot source data dir")
}

func TestPrepareFork_RetainedAliasSkipsSourceStat(t *testing.T) {
starter := NewStarter()
tmp := t.TempDir()
upstreamDir := filepath.Join(tmp, "upstream")
targetDir := filepath.Join(tmp, "target")
require.NoError(t, os.MkdirAll(targetDir, 0755))
require.NoError(t, saveRestoreMetadataState(targetDir, &restoreMetadata{
SnapshotSourceDataDir: upstreamDir,
RetainSnapshotSourceDataDirAlias: true,
}))

_, err := starter.PrepareFork(context.Background(), hypervisor.ForkPrepareRequest{
SnapshotConfigPath: filepath.Join(targetDir, "snapshots", "snapshot-latest", "config.json"),
SourceDataDir: filepath.Join(tmp, "source") + "\x00",
TargetDataDir: targetDir,
})
require.NoError(t, err)

meta, err := loadRestoreMetadata(targetDir)
require.NoError(t, err)
assert.Equal(t, upstreamDir, meta.SnapshotSourceDataDir)
assert.True(t, meta.RetainSnapshotSourceDataDirAlias)
}

func TestPrepareFork_PreservesRetainedUpstreamAlias(t *testing.T) {
starter := NewStarter()
tmp := t.TempDir()
Expand Down Expand Up @@ -137,3 +230,11 @@ func TestPrepareFork_NetworkRewritePreservesRetainedAlias(t *testing.T) {
assert.Equal(t, upstreamDir, meta.SnapshotSourceDataDir)
assert.True(t, meta.RetainSnapshotSourceDataDirAlias)
}

func writeSnapshotStateForTest(t *testing.T, path string, body []byte) {
t.Helper()
data := make([]byte, len(body)+8)
copy(data, body)
binary.LittleEndian.PutUint64(data[len(data)-8:], firecrackerSnapshotCRC64(body))
require.NoError(t, os.WriteFile(path, data, 0644))
}
Loading
Loading