Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,4 @@ scripts/utm/images/
.refs/

# Build artifacts
api
/api
209 changes: 209 additions & 0 deletions cmd/api/api/snapshots.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
package api

import (
"context"
"errors"

"github.com/kernel/hypeman/lib/hypervisor"
"github.com/kernel/hypeman/lib/instances"
"github.com/kernel/hypeman/lib/logger"
mw "github.com/kernel/hypeman/lib/middleware"
"github.com/kernel/hypeman/lib/network"
"github.com/kernel/hypeman/lib/oapi"
"github.com/samber/lo"
)

// CreateInstanceSnapshot creates a snapshot for the resolved instance.
func (s *ApiService) CreateInstanceSnapshot(ctx context.Context, request oapi.CreateInstanceSnapshotRequestObject) (oapi.CreateInstanceSnapshotResponseObject, error) {
inst := mw.GetResolvedInstance[instances.Instance](ctx)
if inst == nil {
return oapi.CreateInstanceSnapshot500JSONResponse{Code: "internal_error", Message: "resource not resolved"}, nil
}
if request.Body == nil {
return oapi.CreateInstanceSnapshot400JSONResponse{Code: "invalid_request", Message: "request body is required"}, nil
}

var name string
if request.Body.Name != nil {
name = *request.Body.Name
}

result, err := s.InstanceManager.CreateSnapshot(ctx, inst.Id, instances.CreateSnapshotRequest{
Kind: instances.SnapshotKind(request.Body.Kind),
Name: name,
})
if err != nil {
log := logger.FromContext(ctx)
switch {
case errors.Is(err, instances.ErrNotFound):
return oapi.CreateInstanceSnapshot404JSONResponse{Code: "not_found", Message: "instance not found"}, nil
case errors.Is(err, instances.ErrInvalidRequest):
return oapi.CreateInstanceSnapshot400JSONResponse{Code: "invalid_request", Message: err.Error()}, nil
case errors.Is(err, instances.ErrInvalidState), errors.Is(err, instances.ErrAlreadyExists):
return oapi.CreateInstanceSnapshot409JSONResponse{Code: "conflict", Message: err.Error()}, nil
case errors.Is(err, instances.ErrNotSupported):
return oapi.CreateInstanceSnapshot501JSONResponse{Code: "not_supported", Message: err.Error()}, nil
default:
log.ErrorContext(ctx, "failed to create snapshot", "error", err)
return oapi.CreateInstanceSnapshot500JSONResponse{Code: "internal_error", Message: "failed to create snapshot"}, nil
}
}

return oapi.CreateInstanceSnapshot201JSONResponse(snapshotToOAPI(*result)), nil
}

// RestoreInstanceSnapshot restores an instance from a snapshot in-place.
func (s *ApiService) RestoreInstanceSnapshot(ctx context.Context, request oapi.RestoreInstanceSnapshotRequestObject) (oapi.RestoreInstanceSnapshotResponseObject, error) {
inst := mw.GetResolvedInstance[instances.Instance](ctx)
if inst == nil {
return oapi.RestoreInstanceSnapshot500JSONResponse{Code: "internal_error", Message: "resource not resolved"}, nil
}
if request.Body == nil {
return oapi.RestoreInstanceSnapshot400JSONResponse{Code: "invalid_request", Message: "request body is required"}, nil
}

domainReq := instances.RestoreSnapshotRequest{}
if request.Body.TargetState != nil {
domainReq.TargetState = instances.State(*request.Body.TargetState)
}
if request.Body.TargetHypervisor != nil {
domainReq.TargetHypervisor = hypervisor.Type(*request.Body.TargetHypervisor)
}

result, err := s.InstanceManager.RestoreSnapshot(ctx, inst.Id, request.SnapshotId, domainReq)
if err != nil {
log := logger.FromContext(ctx)
switch {
case errors.Is(err, instances.ErrNotFound), errors.Is(err, instances.ErrSnapshotNotFound):
return oapi.RestoreInstanceSnapshot404JSONResponse{Code: "not_found", Message: "instance or snapshot not found"}, nil
case errors.Is(err, instances.ErrInvalidRequest):
return oapi.RestoreInstanceSnapshot400JSONResponse{Code: "invalid_request", Message: err.Error()}, nil
case errors.Is(err, instances.ErrInvalidState):
return oapi.RestoreInstanceSnapshot409JSONResponse{Code: "invalid_state", Message: err.Error()}, nil
case errors.Is(err, instances.ErrNotSupported):
return oapi.RestoreInstanceSnapshot501JSONResponse{Code: "not_supported", Message: err.Error()}, nil
default:
log.ErrorContext(ctx, "failed to restore snapshot", "error", err)
return oapi.RestoreInstanceSnapshot500JSONResponse{Code: "internal_error", Message: "failed to restore snapshot"}, nil
}
}

return oapi.RestoreInstanceSnapshot200JSONResponse(instanceToOAPI(*result)), nil
}

// ListSnapshots lists centrally managed snapshots with optional filters.
func (s *ApiService) ListSnapshots(ctx context.Context, request oapi.ListSnapshotsRequestObject) (oapi.ListSnapshotsResponseObject, error) {
filter := &instances.ListSnapshotsFilter{}
if request.Params.SourceInstanceId != nil {
filter.SourceInstanceID = request.Params.SourceInstanceId
}
if request.Params.Kind != nil {
kind := instances.SnapshotKind(*request.Params.Kind)
filter.Kind = &kind
}
if request.Params.Name != nil {
filter.Name = request.Params.Name
}
if filter.SourceInstanceID == nil && filter.Kind == nil && filter.Name == nil {
filter = nil
}

snaps, err := s.InstanceManager.ListSnapshots(ctx, filter)
if err != nil {
log := logger.FromContext(ctx)
log.ErrorContext(ctx, "failed to list snapshots", "error", err)
return oapi.ListSnapshots500JSONResponse{Code: "internal_error", Message: "failed to list snapshots"}, nil
}

resp := make([]oapi.Snapshot, len(snaps))
for i := range snaps {
resp[i] = snapshotToOAPI(snaps[i])
}
return oapi.ListSnapshots200JSONResponse(resp), nil
}

// GetSnapshot returns details for a snapshot.
func (s *ApiService) GetSnapshot(ctx context.Context, request oapi.GetSnapshotRequestObject) (oapi.GetSnapshotResponseObject, error) {
snap, err := s.InstanceManager.GetSnapshot(ctx, request.SnapshotId)
if err != nil {
log := logger.FromContext(ctx)
switch {
case errors.Is(err, instances.ErrSnapshotNotFound):
return oapi.GetSnapshot404JSONResponse{Code: "not_found", Message: "snapshot not found"}, nil
default:
log.ErrorContext(ctx, "failed to get snapshot", "error", err)
return oapi.GetSnapshot500JSONResponse{Code: "internal_error", Message: "failed to get snapshot"}, nil
}
}
return oapi.GetSnapshot200JSONResponse(snapshotToOAPI(*snap)), nil
}

// DeleteSnapshot deletes a snapshot.
func (s *ApiService) DeleteSnapshot(ctx context.Context, request oapi.DeleteSnapshotRequestObject) (oapi.DeleteSnapshotResponseObject, error) {
err := s.InstanceManager.DeleteSnapshot(ctx, request.SnapshotId)
if err != nil {
log := logger.FromContext(ctx)
switch {
case errors.Is(err, instances.ErrSnapshotNotFound):
return oapi.DeleteSnapshot404JSONResponse{Code: "not_found", Message: "snapshot not found"}, nil
default:
log.ErrorContext(ctx, "failed to delete snapshot", "error", err)
return oapi.DeleteSnapshot500JSONResponse{Code: "internal_error", Message: "failed to delete snapshot"}, nil
}
}
return oapi.DeleteSnapshot204Response{}, nil
}

// ForkSnapshot creates a new instance from a snapshot.
func (s *ApiService) ForkSnapshot(ctx context.Context, request oapi.ForkSnapshotRequestObject) (oapi.ForkSnapshotResponseObject, error) {
if request.Body == nil {
return oapi.ForkSnapshot400JSONResponse{Code: "invalid_request", Message: "request body is required"}, nil
}

domainReq := instances.ForkSnapshotRequest{Name: request.Body.Name}
if request.Body.TargetState != nil {
domainReq.TargetState = instances.State(*request.Body.TargetState)
}
if request.Body.TargetHypervisor != nil {
domainReq.TargetHypervisor = hypervisor.Type(*request.Body.TargetHypervisor)
}

result, err := s.InstanceManager.ForkSnapshot(ctx, request.SnapshotId, domainReq)
if err != nil {
log := logger.FromContext(ctx)
switch {
case errors.Is(err, instances.ErrSnapshotNotFound):
return oapi.ForkSnapshot404JSONResponse{Code: "not_found", Message: "snapshot not found"}, nil
case errors.Is(err, instances.ErrInvalidRequest):
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.ErrNotSupported):
return oapi.ForkSnapshot501JSONResponse{Code: "not_supported", Message: err.Error()}, nil
default:
log.ErrorContext(ctx, "failed to fork snapshot", "error", err)
return oapi.ForkSnapshot500JSONResponse{Code: "internal_error", Message: "failed to fork snapshot"}, nil
}
}

return oapi.ForkSnapshot201JSONResponse(instanceToOAPI(*result)), nil
}

func snapshotToOAPI(snapshot instances.Snapshot) oapi.Snapshot {
kind := oapi.SnapshotKind(snapshot.Kind)
sourceHypervisor := oapi.SnapshotSourceHypervisor(snapshot.SourceHypervisor)
out := oapi.Snapshot{
Id: snapshot.Id,
Kind: kind,
SourceInstanceId: snapshot.SourceInstanceID,
SourceInstanceName: snapshot.SourceName,
SourceHypervisor: sourceHypervisor,
CreatedAt: snapshot.CreatedAt,
SizeBytes: snapshot.SizeBytes,
Name: lo.ToPtr(snapshot.Name),
}
if snapshot.Name == "" {
out.Name = nil
}
return out
}
24 changes: 24 additions & 0 deletions lib/builds/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ func (m *mockInstanceManager) ListInstances(ctx context.Context, filter *instanc
return result, nil
}

func (m *mockInstanceManager) ListSnapshots(ctx context.Context, filter *instances.ListSnapshotsFilter) ([]instances.Snapshot, error) {
return nil, nil
}

func (m *mockInstanceManager) GetSnapshot(ctx context.Context, snapshotID string) (*instances.Snapshot, error) {
return nil, instances.ErrSnapshotNotFound
}

func (m *mockInstanceManager) CreateInstance(ctx context.Context, req instances.CreateInstanceRequest) (*instances.Instance, error) {
m.createCallCount++
if m.createFunc != nil {
Expand All @@ -75,6 +83,10 @@ func (m *mockInstanceManager) GetInstance(ctx context.Context, id string) (*inst
return nil, instances.ErrNotFound
}

func (m *mockInstanceManager) CreateSnapshot(ctx context.Context, id string, req instances.CreateSnapshotRequest) (*instances.Snapshot, error) {
return nil, instances.ErrNotSupported
}

func (m *mockInstanceManager) DeleteInstance(ctx context.Context, id string) error {
m.deleteCallCount++
if m.deleteFunc != nil {
Expand All @@ -84,10 +96,18 @@ func (m *mockInstanceManager) DeleteInstance(ctx context.Context, id string) err
return nil
}

func (m *mockInstanceManager) DeleteSnapshot(ctx context.Context, snapshotID string) error {
return instances.ErrSnapshotNotFound
}

func (m *mockInstanceManager) ForkInstance(ctx context.Context, id string, req instances.ForkInstanceRequest) (*instances.Instance, error) {
return nil, instances.ErrNotFound
}

func (m *mockInstanceManager) ForkSnapshot(ctx context.Context, snapshotID string, req instances.ForkSnapshotRequest) (*instances.Instance, error) {
return nil, instances.ErrNotFound
}

func (m *mockInstanceManager) StandbyInstance(ctx context.Context, id string) (*instances.Instance, error) {
return nil, nil
}
Expand All @@ -96,6 +116,10 @@ func (m *mockInstanceManager) RestoreInstance(ctx context.Context, id string) (*
return nil, nil
}

func (m *mockInstanceManager) RestoreSnapshot(ctx context.Context, id string, snapshotID string, req instances.RestoreSnapshotRequest) (*instances.Instance, error) {
return nil, instances.ErrNotSupported
}

func (m *mockInstanceManager) StopInstance(ctx context.Context, id string) (*instances.Instance, error) {
if m.stopFunc != nil {
return m.stopFunc(ctx, id)
Expand Down
3 changes: 3 additions & 0 deletions lib/instances/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,7 @@ var (

// ErrNotSupported is returned when an operation is not supported for the instance hypervisor
ErrNotSupported = errors.New("operation not supported")

// ErrSnapshotNotFound is returned when a snapshot is not found.
ErrSnapshotNotFound = errors.New("snapshot not found")
)
12 changes: 12 additions & 0 deletions lib/instances/firecracker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -381,3 +381,15 @@ func TestFirecrackerForkFromRunningNetwork(t *testing.T) {
assert.NotEqual(t, sourceAfterFork.IP, forked.IP)
assert.NotEqual(t, sourceAfterFork.MAC, forked.MAC)
}

func TestFirecrackerSnapshotFeature(t *testing.T) {
requireFirecrackerIntegrationPrereqs(t)

mgr, tmpDir := setupTestManagerForFirecracker(t)
runStandbySnapshotScenario(t, mgr, tmpDir, snapshotScenarioConfig{
hypervisor: hypervisor.TypeFirecracker,
sourceName: "fc-snapshot-src",
snapshot: "fc-snapshot-1",
forkName: "fc-snapshot-fork",
})
}
26 changes: 19 additions & 7 deletions lib/instances/fork.go
Original file line number Diff line number Diff line change
Expand Up @@ -392,41 +392,53 @@ func (m *manager) applyForkTargetState(ctx context.Context, forkID string, targe
lock.Lock()
defer lock.Unlock()

returnWithReadiness := func(inst *Instance, err error) (*Instance, error) {
if err != nil {
return nil, err
}
if inst != nil && inst.State == StateRunning {
if err := ensureGuestAgentReadyForForkPhase(ctx, &inst.StoredMetadata, "before returning running fork instance"); err != nil {
return nil, fmt.Errorf("wait for forked guest agent readiness: %w", err)
}
}
return inst, nil
}

current, err := m.getInstance(ctx, forkID)
if err != nil {
return nil, err
}
if current.State == target {
return current, nil
return returnWithReadiness(current, nil)
}

switch current.State {
case StateStopped:
switch target {
case StateRunning:
return m.startInstance(ctx, forkID, StartInstanceRequest{})
return returnWithReadiness(m.startInstance(ctx, forkID, StartInstanceRequest{}))
case StateStandby:
if _, err := m.startInstance(ctx, forkID, StartInstanceRequest{}); err != nil {
return nil, fmt.Errorf("start forked instance for standby transition: %w", err)
}
return m.standbyInstance(ctx, forkID)
return returnWithReadiness(m.standbyInstance(ctx, forkID))
}
case StateStandby:
switch target {
case StateRunning:
return m.restoreInstance(ctx, forkID)
return returnWithReadiness(m.restoreInstance(ctx, forkID))
case StateStopped:
if err := os.RemoveAll(m.paths.InstanceSnapshotLatest(forkID)); err != nil {
return nil, fmt.Errorf("remove fork snapshot: %w", err)
}
return m.getInstance(ctx, forkID)
return returnWithReadiness(m.getInstance(ctx, forkID))
}
case StateRunning:
switch target {
case StateStandby:
return m.standbyInstance(ctx, forkID)
return returnWithReadiness(m.standbyInstance(ctx, forkID))
case StateStopped:
return m.stopInstance(ctx, forkID)
return returnWithReadiness(m.stopInstance(ctx, forkID))
}
}

Expand Down
Loading