Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions cmd/api/api/instances.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -1209,6 +1209,11 @@ func instanceToOAPI(inst instances.Instance) oapi.Instance {
if inst.Platform != "" {
oapiInst.Platform = lo.ToPtr(inst.Platform)
}

if inst.ForkMode != "" {
oapiInst.ForkMode = lo.ToPtr(oapi.InstanceForkMode(inst.ForkMode))
}

if inst.ExitMessage != "" {
oapiInst.ExitMessage = lo.ToPtr(inst.ExitMessage)
}
Expand Down
3 changes: 3 additions & 0 deletions cmd/api/api/snapshots.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -179,6 +179,9 @@ func (s *ApiService) ForkSnapshot(ctx context.Context, request oapi.ForkSnapshot
}

domainReq := instances.ForkSnapshotRequest{Name: request.Body.Name}
if request.Body.Tags != nil {
domainReq.Tags = toMapTags(request.Body.Tags)
}
if request.Body.TargetState != nil {
domainReq.TargetState = instances.State(*request.Body.TargetState)
}
Expand Down
8 changes: 8 additions & 0 deletions lib/instances/fork.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -291,6 +291,14 @@ func (m *manager) forkInstanceFromStoppedOrStandby(ctx context.Context, id strin
forkMeta.FirecrackerUFFDSessionID = ""
forkMeta.FirecrackerUFFDPagerVersion = ""
forkMeta.FirecrackerUseUFFDOnNextRestore = useFirecrackerUFFDOnNextRestore(forkMeta.HypervisorType, source.State == StateStandby, targetState)
// Record the actual fork mode so the API can report it rather than have a
// caller infer it: a shared mem-file is copy-on-write, anything else is a
// full copy. Mirrors the shareMemFile decision above exactly.
if shareMemFile {
forkMeta.ForkMode = ForkModeShared
} else {
forkMeta.ForkMode = ForkModeCopied
}
Comment thread
cursor[bot] marked this conversation as resolved.
if source.State != StateStandby {
forkMeta.FirecrackerSnapshotCacheKey = ""
}
Expand Down
3 changes: 3 additions & 0 deletions lib/instances/fork_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -60,6 +60,9 @@ func TestForkInstance_VZStoppedSourceSupported(t *testing.T) {
assert.Equal(t, StateStopped, forked.State)
assert.Equal(t, hypervisor.TypeVZ, forked.HypervisorType)
assert.NotEqual(t, sourceID, forked.Id)
// A VZ stopped-source fork copies memory rather than sharing it, and the
// instance reports that measured mode (barista-046 §3.4 consumes this).
assert.Equal(t, ForkModeCopied, forked.ForkMode)
}

func TestResolveForkTargetState_DefaultsToSourceState(t *testing.T) {
Expand Down
24 changes: 24 additions & 0 deletions lib/instances/snapshot.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -422,6 +422,24 @@ func (m *manager) forkSnapshot(ctx context.Context, snapshotID string, req ForkS
forkMeta := cloneStoredMetadataWithoutPendingStandbyCompression(rec.StoredMetadata)
forkMeta.Id = forkID
forkMeta.Name = req.Name
// Caller-supplied tags override the cloned source tags (request wins per
// key), so a fork can be re-identified rather than inherit the source's
// identity labels. Unrelated source tags are preserved.
if len(req.Tags) > 0 {
if forkMeta.Tags == nil {
forkMeta.Tags = make(map[string]string, len(req.Tags))
}
for k, v := range req.Tags {
forkMeta.Tags[k] = v
}
}
Comment thread
cursor[bot] marked this conversation as resolved.
// Record the measured fork mode, mirroring the instance-fork path: a shared
// mem-file is copy-on-write, anything else is a full copy.
if shareMemFile {
forkMeta.ForkMode = ForkModeShared
} else {
forkMeta.ForkMode = ForkModeCopied
}
Comment thread
cursor[bot] marked this conversation as resolved.
forkMeta.CreatedAt = now
forkMeta.ExpiresAt = nil
forkMeta.StartedAt = nil
Expand DownExpand Up@@ -589,6 +607,12 @@ func validateForkSnapshotRequest(req ForkSnapshotRequest) error {
if req.TargetState != "" && req.TargetState != StateStopped && req.TargetState != StateStandby && req.TargetState != StateRunning {
return fmt.Errorf("%w: invalid target_state %q", ErrInvalidRequest, req.TargetState)
}
// Caller-supplied tags are written into instance metadata below, so they must
// clear the same validation as create-instance and create-snapshot — the
// OpenAPI layer does not guard this direct manager path.
if err := tags.Validate(req.Tags); err != nil {
return fmt.Errorf("%w: %v", ErrInvalidRequest, err)
}
return nil
}

Expand Down
18 changes: 18 additions & 0 deletions lib/instances/snapshot_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -463,3 +463,21 @@ func createStandbySnapshotSourceFixture(t *testing.T, mgr *manager, id, name str
require.NoError(t, os.MkdirAll(snapshotDir, 0755))
require.NoError(t, os.WriteFile(filepath.Join(snapshotDir, "state"), []byte("snapshot"), 0644))
}

// A fork-snapshot request carries caller-supplied tags that get written into
// instance metadata, so it must reject invalid tags exactly like the create
// paths — the OpenAPI layer does not guard this direct manager entry point.
func TestValidateForkSnapshotRequestValidatesTags(t *testing.T) {
t.Parallel()
require.NoError(t, validateForkSnapshotRequest(ForkSnapshotRequest{
Name: "fork-ok",
Tags: map[string]string{"env": "prod"},
}), "a well-formed tag set must pass")

err := validateForkSnapshotRequest(ForkSnapshotRequest{
Name: "fork-bad",
Tags: map[string]string{"bad!key": "v"}, // "!" is not an allowed key char
})
require.Error(t, err, "an invalid tag key must be refused")
assert.ErrorIs(t, err, ErrInvalidRequest)
}
24 changes: 24 additions & 0 deletions lib/instances/types.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,6 +26,18 @@ const (
StateUnknown State = "Unknown" // Failed to determine state (VMM query failed)
)

// Fork mode values reported on Instance.ForkMode (see StoredMetadata.ForkMode).
const (
// ForkModeShared: the fork attaches to the source's memory copy-on-write via
// a shared mem-file. Firecracker forks from a Standby source (including the
// standby cycle of a Running-source fork).
ForkModeShared = "shared"
// ForkModeCopied: the fork was given a full private copy of the source's
// memory image (stopped-source forks, or hypervisors without shared-memory
// fork).
ForkModeCopied = "copied"
)

type EgressEnforcementMode string

const (
Expand DownExpand Up@@ -137,6 +149,13 @@ type StoredMetadata struct {
FirecrackerUFFDSessionID string
FirecrackerUFFDPagerVersion string

// ForkMode records how this instance's memory image was produced when it
// was created by a fork: ForkModeShared (copy-on-write via a shared mem-file)
// or ForkModeCopied (a full private copy). Empty for instances that were not
// created by a fork. Read-only; echoed on the instance API so a caller can
// record the actual mode rather than infer it from the hypervisor.
ForkMode string

// Paths
SocketPath string // Path to API socket
DataDir string // Instance data directory
Expand DownExpand Up@@ -346,6 +365,11 @@ type ForkSnapshotRequest struct {
Name string // Required: name for the new instance
TargetState State // Optional
TargetHypervisor hypervisor.Type // Optional, allowed only for Stopped snapshots
// Tags override the tags cloned from the snapshot's source onto the fork
// (request wins per key; unrelated source tags are kept). A consumer forks
// many instances from one source and needs to re-identify each fork rather
// than inherit the source's identity labels.
Tags map[string]string
}

// SnapshotPolicy defines default snapshot behavior for an instance.
Expand Down
Loading