Skip to content
Draft
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
8 changes: 8 additions & 0 deletions lib/instances/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/kernel/hypeman/lib/paths"
"github.com/kernel/hypeman/lib/resources"
"github.com/kernel/hypeman/lib/system"
"github.com/kernel/hypeman/lib/templates"
"github.com/kernel/hypeman/lib/volumes"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
Expand Down Expand Up @@ -147,6 +148,12 @@ type manager struct {
vmStarters map[hypervisor.Type]hypervisor.VMStarter
defaultHypervisor hypervisor.Type // Default hypervisor type when not specified in request
guestMemoryPolicy guestmemory.Policy

// Template registry. Owned by the manager because template lifecycle
// is coupled to instance lifecycle (promotion + refcount on
// fork/delete). Constructed lazily so existing managers without
// template support keep working unchanged.
templateRegistry templates.Registry
}

// platformStarters is populated by platform-specific init functions.
Expand Down Expand Up @@ -201,6 +208,7 @@ func NewManagerWithConfig(p *paths.Paths, imageManager images.Manager, systemMan
compressionJobs: make(map[string]*compressionJob),
nativeCodecPaths: make(map[string]string),
lifecycleEvents: newLifecycleSubscribersWithBufferSize(managerConfig.LifecycleEventBufferSize),
templateRegistry: templates.NewFileRegistry(p.TemplatesDir()),
}
m.deleteSnapshotFn = m.deleteSnapshot

Expand Down
221 changes: 221 additions & 0 deletions lib/instances/templates.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
package instances

import (
"context"
"errors"
"fmt"
"time"

"github.com/kernel/hypeman/lib/hypervisor"
"github.com/kernel/hypeman/lib/logger"
"github.com/kernel/hypeman/lib/templates"
"github.com/nrednav/cuid2"
)

// PromoteToTemplateRequest configures a Standby instance promotion into a
// fork-only template parent.
type PromoteToTemplateRequest struct {
// Name is the template's user-facing label. Must be unique. Required.
Name string
// Tags is optional user metadata.
Tags map[string]string
}

// promoteToTemplate marks a Standby instance as a fork-only template parent
// and registers its metadata in the templates registry. The instance itself
// stays where it is on disk; what changes is the StoredMetadata flag and
// the new entry in the registry. Subsequent forks descend from this
// instance's snapshot directory.
//
// PR 2 ships only the lifecycle plumbing. PR 3 wires the resulting template
// into the firecracker fork path so forks share the template's mem-file
// instead of copying it.
func (m *manager) promoteToTemplate(ctx context.Context, instanceID string, req PromoteToTemplateRequest) (*templates.Template, error) {
log := logger.FromContext(ctx)
if m.templateRegistry == nil {
return nil, fmt.Errorf("%w: template registry not configured", ErrNotSupported)
}
if req.Name == "" {
return nil, fmt.Errorf("%w: template name is required", ErrInvalidRequest)
}

meta, err := m.loadMetadata(instanceID)
if err != nil {
return nil, err
}
stored := &meta.StoredMetadata
inst := m.toInstance(ctx, meta)

if inst.State != StateStandby {
return nil, fmt.Errorf("%w: can only promote a Standby instance to a template (got %s)", ErrInvalidState, inst.State)
}
if !inst.HasSnapshot {
return nil, fmt.Errorf("%w: instance %s has no snapshot to promote", ErrInvalidState, instanceID)
}
if stored.IsTemplate {
return nil, fmt.Errorf("%w: instance %s is already a template", ErrAlreadyExists, instanceID)
}
if existing, err := m.templateRegistry.GetByName(ctx, req.Name); err == nil {
return nil, fmt.Errorf("%w: template name %q already registered as id=%s", ErrAlreadyExists, req.Name, existing.ID)
} else if !errors.Is(err, templates.ErrNotFound) {
return nil, fmt.Errorf("check template name: %w", err)
}

templateID := cuid2.Generate()

tpl := &templates.Template{
ID: templateID,
Name: req.Name,
SourceInstanceID: instanceID,
Image: stored.Image,
HypervisorType: stored.HypervisorType,
HypervisorVersion: stored.HypervisorVersion,
MemoryBytes: stored.Size + stored.HotplugSize,
VCPUs: stored.Vcpus,
CreatedAt: m.now().UTC(),
}
for k, v := range req.Tags {
if tpl.Tags == nil {
tpl.Tags = map[string]string{}
}
tpl.Tags[k] = v
}

if err := m.templateRegistry.Save(ctx, tpl); err != nil {
return nil, fmt.Errorf("save template: %w", err)
}

stored.IsTemplate = true
stored.TemplateID = templateID
if err := m.saveMetadata(meta); err != nil {
// Best-effort rollback of the registry entry. If this fails the
// operator can manually delete the orphan via DeleteTemplate.
if delErr := m.templateRegistry.Delete(ctx, templateID); delErr != nil {
log.WarnContext(ctx, "failed to roll back template registry entry after metadata save failure",
"template_id", templateID, "error", delErr)
}
return nil, fmt.Errorf("persist template flag on instance: %w", err)
}

log.InfoContext(ctx, "promoted instance to template",
"instance_id", instanceID, "template_id", templateID, "name", req.Name)
return tpl, nil
}

// listTemplates returns all templates, optionally filtered.
func (m *manager) listTemplates(ctx context.Context, filter *templates.ListFilter) ([]*templates.Template, error) {
if m.templateRegistry == nil {
return nil, nil
}
return m.templateRegistry.List(ctx, filter)
}

// getTemplate looks up a template by ID.
func (m *manager) getTemplate(ctx context.Context, templateID string) (*templates.Template, error) {
if m.templateRegistry == nil {
return nil, fmt.Errorf("%w: template registry not configured", ErrNotSupported)
}
return m.templateRegistry.Get(ctx, templateID)
}

// deleteTemplate removes a template from the registry. The underlying
// source instance is not deleted; callers can decide whether to delete it
// separately. Refuses when ForkCount > 0.
func (m *manager) deleteTemplate(ctx context.Context, templateID string) error {
if m.templateRegistry == nil {
return fmt.Errorf("%w: template registry not configured", ErrNotSupported)
}
tpl, err := m.templateRegistry.Get(ctx, templateID)
if err != nil {
return err
}

if err := m.templateRegistry.Delete(ctx, templateID); err != nil {
return err
}

// Best-effort: clear the IsTemplate flag on the source instance if it
// still exists, so the operator can resume/delete it normally.
if tpl != nil && tpl.SourceInstanceID != "" {
meta, err := m.loadMetadata(tpl.SourceInstanceID)
if err == nil {
meta.StoredMetadata.IsTemplate = false
meta.StoredMetadata.TemplateID = ""
_ = m.saveMetadata(meta)
}
}
return nil
}

// touchTemplateUsage updates LastUsedAt on a template. Cheap; called
// whenever a fork is created from the template.
func (m *manager) touchTemplateUsage(ctx context.Context, templateID string) {
if m.templateRegistry == nil || templateID == "" {
return
}
tpl, err := m.templateRegistry.Get(ctx, templateID)
if err != nil {
return
}
tpl.LastUsedAt = m.now().UTC()
_ = m.templateRegistry.Save(ctx, tpl)
}

// templateGuard returns an error when the instance is a template parent.
// Templates must not be Started or Restored — the snapshot is shared with
// live forks and resuming it would corrupt them. PR 3 hardens this further
// when forks rely on the template's mem-file directly.
func (m *manager) templateGuard(stored *StoredMetadata, op string) error {
if stored == nil || !stored.IsTemplate {
return nil
}
return fmt.Errorf("%w: cannot %s template instance %s (template_id=%s); fork from it instead", ErrNotSupported, op, stored.Id, stored.TemplateID)
}

// validateForkResolvedFromTemplate confirms a fork-from-template request
// targets a hypervisor compatible with the template. The actual fork
// mechanics live in PR 3.
func validateForkResolvedFromTemplate(tpl *templates.Template, hvType hypervisor.Type) error {
if tpl == nil {
return fmt.Errorf("%w: nil template", ErrInvalidRequest)
}
if hvType != "" && tpl.HypervisorType != hvType {
return fmt.Errorf(
"%w: template hypervisor %s does not match requested %s",
ErrInvalidRequest, tpl.HypervisorType, hvType,
)
}
return nil
}

// templateForFork resolves a template by id-or-name. Empty input returns
// (nil, nil) so callers can treat "no template" as the ordinary fork path.
func (m *manager) templateForFork(ctx context.Context, idOrName string) (*templates.Template, error) {
if idOrName == "" || m.templateRegistry == nil {
return nil, nil
}
tpl, err := m.templateRegistry.Get(ctx, idOrName)
if err == nil {
return tpl, nil
}
if !errors.Is(err, templates.ErrNotFound) {
return nil, err
}
return m.templateRegistry.GetByName(ctx, idOrName)
}

// templateRegistryRef exposes the registry to siblings within the package
// (e.g. fork.go for refcount bumps in PR 3/4). External packages must use
// the manager interface methods.
func (m *manager) templateRegistryRef() templates.Registry {
return m.templateRegistry
}

// nowOrDefault returns the configured clock or time.Now if unset. Useful
// in code paths that may be called before NewManager has stamped a clock.
func (m *manager) nowOrDefault() time.Time {
if m.now == nil {
return time.Now()
}
return m.now()
}
9 changes: 9 additions & 0 deletions lib/instances/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,15 @@ type StoredMetadata struct {
// Exit information (populated from serial console sentinel when VM stops)
ExitCode *int // App exit code, nil if VM hasn't exited
ExitMessage string // Human-readable description of exit (e.g., "command not found", "killed by signal 9 (SIGKILL) - OOM")

// Template-related fields. These are zero-valued for ordinary instances;
// the templates package owns the lifecycle and refcount. Forks and
// templates persist these fields so the manager can refuse to Start a
// template directly and so a deleted fork can decrement its template's
// refcount.
IsTemplate bool // true once an instance has been promoted to a template parent
TemplateID string // when set, this instance is the canonical source for the named template
ForkOfTemplate string // when set, this instance was forked from the named template
}

// Instance represents a virtual machine instance with derived runtime state
Expand Down
19 changes: 19 additions & 0 deletions lib/paths/paths.go
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,25 @@ func (p *Paths) SnapshotGuestDir(snapshotID string) string {
return filepath.Join(p.SnapshotDir(snapshotID), "guest")
}

// Template path methods

// TemplatesDir returns the root directory for VM templates.
// A template is a tagged Standby instance promoted to a "fork-only" parent
// whose snapshot can be reused for many forked instances.
func (p *Paths) TemplatesDir() string {
return filepath.Join(p.dataDir, "templates")
}

// TemplateDir returns the directory for a specific template's metadata.
func (p *Paths) TemplateDir(id string) string {
return filepath.Join(p.TemplatesDir(), id)
}

// TemplateMetadata returns the path to a template's metadata.json file.
func (p *Paths) TemplateMetadata(id string) string {
return filepath.Join(p.TemplateDir(id), "template.json")
}

// Device path methods

// DevicesDir returns the root devices directory.
Expand Down
Loading
Loading