From 27fb0b40a0dd81e80bd7d6de5a2d3408e519f509 Mon Sep 17 00:00:00 2001 From: sjmiller609 <7516283+sjmiller609@users.noreply.github.com> Date: Fri, 8 May 2026 12:48:51 +0000 Subject: [PATCH] templates: add fork-only template registry and promotion lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a templates package and Standby-instance promotion path so later PRs can wire fork-from-template into firecracker. The registry persists one JSON file per template under /templates//, with fork refcount accounting and Delete-blocked-when-in-use. The instance manager owns the registry because template lifecycle is coupled to instance lifecycle: promotion guards on Standby+HasSnapshot, deletion clears IsTemplate on the source instance, and forks (PR 3) will increment the refcount. Hypervisor wire-up is deferred — this PR ships the registry, the StoredMetadata flags, and the manager helpers (templateGuard, templateForFork, validateForkResolvedFromTemplate) so PR 3 can plug in. --- lib/instances/manager.go | 8 ++ lib/instances/templates.go | 221 +++++++++++++++++++++++++++++ lib/instances/types.go | 9 ++ lib/paths/paths.go | 19 +++ lib/templates/registry.go | 252 +++++++++++++++++++++++++++++++++ lib/templates/registry_test.go | 125 ++++++++++++++++ lib/templates/template.go | 108 ++++++++++++++ 7 files changed, 742 insertions(+) create mode 100644 lib/instances/templates.go create mode 100644 lib/templates/registry.go create mode 100644 lib/templates/registry_test.go create mode 100644 lib/templates/template.go diff --git a/lib/instances/manager.go b/lib/instances/manager.go index c52add78..d62f88ef 100644 --- a/lib/instances/manager.go +++ b/lib/instances/manager.go @@ -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" @@ -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. @@ -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 diff --git a/lib/instances/templates.go b/lib/instances/templates.go new file mode 100644 index 00000000..5fda1c10 --- /dev/null +++ b/lib/instances/templates.go @@ -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() +} diff --git a/lib/instances/types.go b/lib/instances/types.go index 56243a0d..6d415da0 100644 --- a/lib/instances/types.go +++ b/lib/instances/types.go @@ -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 diff --git a/lib/paths/paths.go b/lib/paths/paths.go index adc070c4..26662644 100644 --- a/lib/paths/paths.go +++ b/lib/paths/paths.go @@ -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. diff --git a/lib/templates/registry.go b/lib/templates/registry.go new file mode 100644 index 00000000..fed3f169 --- /dev/null +++ b/lib/templates/registry.go @@ -0,0 +1,252 @@ +package templates + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "sync" + + "github.com/kernel/hypeman/lib/hypervisor" +) + +// Registry persists and indexes templates. The default file-backed +// implementation stores one JSON file per template under +// paths.TemplatesDir(); higher-level callers (the instances manager) hold +// the registry and read it as a stable index. +// +// Registry is concurrency-safe; in-process locking keeps reads and writes +// consistent. Cross-process callers should not be writing to the same data +// dir simultaneously today; if/when that changes we'd add file locking. +type Registry interface { + // Save inserts or replaces a template record. + Save(ctx context.Context, t *Template) error + + // Get returns a template by its ID. ErrNotFound when missing. + Get(ctx context.Context, id string) (*Template, error) + + // GetByName resolves a template by its unique name. + GetByName(ctx context.Context, name string) (*Template, error) + + // List returns all templates, optionally filtered. + List(ctx context.Context, filter *ListFilter) ([]*Template, error) + + // Delete removes a template. Returns ErrInUse when ForkCount > 0. + Delete(ctx context.Context, id string) error + + // IncrementForkCount atomically bumps the fork refcount on a + // template. Used at fork creation time. + IncrementForkCount(ctx context.Context, id string) (*Template, error) + + // DecrementForkCount atomically drops the fork refcount on a + // template (floor 0). Used when a fork is deleted. Touching + // templates that were already deleted is a no-op. + DecrementForkCount(ctx context.Context, id string) (*Template, error) +} + +// ListFilter narrows the templates returned by Registry.List. +type ListFilter struct { + // HypervisorType, when non-empty, restricts results to templates that + // share the given hypervisor type. Forks must match the hypervisor + // of their template. + HypervisorType hypervisor.Type + + // ImageDigest, when non-empty, restricts results to templates whose + // resolved image digest equals the given value. Useful when picking + // a fan-out parent for a particular image revision. + ImageDigest string +} + +// FileRegistry is the default Registry implementation. It stores each +// template as a JSON file under TemplatesDir//template.json. +type FileRegistry struct { + dir string + mu sync.Mutex +} + +// NewFileRegistry returns a Registry that persists to dir. The directory is +// created on first write. +func NewFileRegistry(dir string) *FileRegistry { + return &FileRegistry{dir: dir} +} + +func (r *FileRegistry) path(id string) string { + return filepath.Join(r.dir, id, "template.json") +} + +func (r *FileRegistry) ensureDir(id string) error { + return os.MkdirAll(filepath.Join(r.dir, id), 0o755) +} + +func (r *FileRegistry) writeLocked(t *Template) error { + if err := t.Validate(); err != nil { + return fmt.Errorf("%w: %v", ErrInvalid, err) + } + if err := r.ensureDir(t.ID); err != nil { + return fmt.Errorf("create template dir: %w", err) + } + data, err := json.MarshalIndent(t, "", " ") + if err != nil { + return fmt.Errorf("marshal template: %w", err) + } + tmp := r.path(t.ID) + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return fmt.Errorf("write template tmp: %w", err) + } + if err := os.Rename(tmp, r.path(t.ID)); err != nil { + return fmt.Errorf("rename template tmp: %w", err) + } + return nil +} + +func (r *FileRegistry) readLocked(id string) (*Template, error) { + data, err := os.ReadFile(r.path(id)) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("%w: id=%s", ErrNotFound, id) + } + return nil, fmt.Errorf("read template: %w", err) + } + var t Template + if err := json.Unmarshal(data, &t); err != nil { + return nil, fmt.Errorf("unmarshal template %s: %w", id, err) + } + return &t, nil +} + +func (r *FileRegistry) Save(ctx context.Context, t *Template) error { + _ = ctx + r.mu.Lock() + defer r.mu.Unlock() + return r.writeLocked(t) +} + +func (r *FileRegistry) Get(ctx context.Context, id string) (*Template, error) { + _ = ctx + r.mu.Lock() + defer r.mu.Unlock() + return r.readLocked(id) +} + +func (r *FileRegistry) GetByName(ctx context.Context, name string) (*Template, error) { + _ = ctx + r.mu.Lock() + defer r.mu.Unlock() + all, err := r.listLocked() + if err != nil { + return nil, err + } + for _, t := range all { + if t.Name == name { + return t, nil + } + } + return nil, fmt.Errorf("%w: name=%s", ErrNotFound, name) +} + +func (r *FileRegistry) List(ctx context.Context, filter *ListFilter) ([]*Template, error) { + _ = ctx + r.mu.Lock() + defer r.mu.Unlock() + all, err := r.listLocked() + if err != nil { + return nil, err + } + if filter == nil { + return all, nil + } + out := make([]*Template, 0, len(all)) + for _, t := range all { + if filter.HypervisorType != "" && t.HypervisorType != filter.HypervisorType { + continue + } + if filter.ImageDigest != "" && t.ImageDigest != filter.ImageDigest { + continue + } + out = append(out, t) + } + return out, nil +} + +func (r *FileRegistry) listLocked() ([]*Template, error) { + entries, err := os.ReadDir(r.dir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, fmt.Errorf("read templates dir: %w", err) + } + out := make([]*Template, 0, len(entries)) + for _, e := range entries { + if !e.IsDir() { + continue + } + t, err := r.readLocked(e.Name()) + if err != nil { + if errors.Is(err, ErrNotFound) { + continue + } + return nil, err + } + out = append(out, t) + } + sort.Slice(out, func(i, j int) bool { + return out[i].CreatedAt.Before(out[j].CreatedAt) + }) + return out, nil +} + +func (r *FileRegistry) Delete(ctx context.Context, id string) error { + _ = ctx + r.mu.Lock() + defer r.mu.Unlock() + t, err := r.readLocked(id) + if err != nil { + return err + } + if t.ForkCount > 0 { + return fmt.Errorf("%w: %d live forks reference template %s", ErrInUse, t.ForkCount, id) + } + if err := os.RemoveAll(filepath.Join(r.dir, id)); err != nil { + return fmt.Errorf("remove template dir: %w", err) + } + return nil +} + +func (r *FileRegistry) IncrementForkCount(ctx context.Context, id string) (*Template, error) { + _ = ctx + r.mu.Lock() + defer r.mu.Unlock() + t, err := r.readLocked(id) + if err != nil { + return nil, err + } + t.ForkCount++ + if err := r.writeLocked(t); err != nil { + return nil, err + } + return t, nil +} + +func (r *FileRegistry) DecrementForkCount(ctx context.Context, id string) (*Template, error) { + _ = ctx + r.mu.Lock() + defer r.mu.Unlock() + t, err := r.readLocked(id) + if err != nil { + if errors.Is(err, ErrNotFound) { + return nil, nil + } + return nil, err + } + if t.ForkCount > 0 { + t.ForkCount-- + } + if err := r.writeLocked(t); err != nil { + return nil, err + } + return t, nil +} diff --git a/lib/templates/registry_test.go b/lib/templates/registry_test.go new file mode 100644 index 00000000..79a93031 --- /dev/null +++ b/lib/templates/registry_test.go @@ -0,0 +1,125 @@ +package templates + +import ( + "context" + "errors" + "path/filepath" + "testing" + "time" + + "github.com/kernel/hypeman/lib/hypervisor" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestRegistry(t *testing.T) *FileRegistry { + t.Helper() + return NewFileRegistry(filepath.Join(t.TempDir(), "templates")) +} + +func sampleTemplate(id, name string) *Template { + return &Template{ + ID: id, + Name: name, + SourceInstanceID: "src-" + id, + Image: "docker.io/library/alpine:latest", + ImageDigest: "sha256:deadbeef", + HypervisorType: hypervisor.TypeFirecracker, + HypervisorVersion: "v1.14.2", + MemoryBytes: 1 << 30, + VCPUs: 2, + CreatedAt: time.Now().UTC(), + } +} + +func TestFileRegistry_SaveGet(t *testing.T) { + r := newTestRegistry(t) + tpl := sampleTemplate("t1", "alpine-warm") + + require.NoError(t, r.Save(context.Background(), tpl)) + + got, err := r.Get(context.Background(), "t1") + require.NoError(t, err) + assert.Equal(t, "alpine-warm", got.Name) + assert.Equal(t, hypervisor.TypeFirecracker, got.HypervisorType) +} + +func TestFileRegistry_GetByName(t *testing.T) { + r := newTestRegistry(t) + require.NoError(t, r.Save(context.Background(), sampleTemplate("t1", "alpha"))) + require.NoError(t, r.Save(context.Background(), sampleTemplate("t2", "beta"))) + + got, err := r.GetByName(context.Background(), "beta") + require.NoError(t, err) + assert.Equal(t, "t2", got.ID) + + _, err = r.GetByName(context.Background(), "missing") + assert.True(t, errors.Is(err, ErrNotFound)) +} + +func TestFileRegistry_List_Filter(t *testing.T) { + r := newTestRegistry(t) + a := sampleTemplate("a", "a") + b := sampleTemplate("b", "b") + b.ImageDigest = "sha256:other" + c := sampleTemplate("c", "c") + c.HypervisorType = hypervisor.TypeCloudHypervisor + + require.NoError(t, r.Save(context.Background(), a)) + require.NoError(t, r.Save(context.Background(), b)) + require.NoError(t, r.Save(context.Background(), c)) + + all, err := r.List(context.Background(), nil) + require.NoError(t, err) + assert.Len(t, all, 3) + + byHV, err := r.List(context.Background(), &ListFilter{HypervisorType: hypervisor.TypeFirecracker}) + require.NoError(t, err) + assert.Len(t, byHV, 2) + + byDigest, err := r.List(context.Background(), &ListFilter{ImageDigest: "sha256:deadbeef"}) + require.NoError(t, err) + assert.Len(t, byDigest, 2) +} + +func TestFileRegistry_Refcount(t *testing.T) { + r := newTestRegistry(t) + require.NoError(t, r.Save(context.Background(), sampleTemplate("t1", "a"))) + + got, err := r.IncrementForkCount(context.Background(), "t1") + require.NoError(t, err) + assert.Equal(t, 1, got.ForkCount) + + got, err = r.IncrementForkCount(context.Background(), "t1") + require.NoError(t, err) + assert.Equal(t, 2, got.ForkCount) + + err = r.Delete(context.Background(), "t1") + assert.True(t, errors.Is(err, ErrInUse)) + + got, err = r.DecrementForkCount(context.Background(), "t1") + require.NoError(t, err) + assert.Equal(t, 1, got.ForkCount) + got, err = r.DecrementForkCount(context.Background(), "t1") + require.NoError(t, err) + assert.Equal(t, 0, got.ForkCount) + + err = r.Delete(context.Background(), "t1") + require.NoError(t, err) + + _, err = r.Get(context.Background(), "t1") + assert.True(t, errors.Is(err, ErrNotFound)) +} + +func TestFileRegistry_DecrementMissingIsNoop(t *testing.T) { + r := newTestRegistry(t) + got, err := r.DecrementForkCount(context.Background(), "missing") + require.NoError(t, err) + assert.Nil(t, got) +} + +func TestFileRegistry_SaveValidates(t *testing.T) { + r := newTestRegistry(t) + err := r.Save(context.Background(), &Template{Name: "x"}) + assert.True(t, errors.Is(err, ErrInvalid)) +} diff --git a/lib/templates/template.go b/lib/templates/template.go new file mode 100644 index 00000000..93968d90 --- /dev/null +++ b/lib/templates/template.go @@ -0,0 +1,108 @@ +// Package templates models VM templates: tagged Standby instances promoted +// to "fork-only" parents whose snapshot can be reused for many forked +// instances. Templates are the foundation for one-snapshot-to-N-forks +// fan-out: rather than every fork copying or diffing against its own private +// snapshot, forks descend from a shared template. The actual sharing of +// memory and rootfs CoW between fork and template is implemented in the +// hypervisor and forkvm packages; this package just owns the lifecycle and +// indexing primitives. +package templates + +import ( + "errors" + "time" + + "github.com/kernel/hypeman/lib/hypervisor" + "github.com/kernel/hypeman/lib/tags" +) + +// Common errors returned by the templates package. +var ( + ErrNotFound = errors.New("template not found") + ErrAlreadyExists = errors.New("template already exists") + ErrInUse = errors.New("template is in use by one or more forks") + ErrInvalid = errors.New("invalid template") +) + +// Template is the persisted record describing a fork-only parent instance. +// It points at a source instance directory whose snapshot artifacts are +// shared by many forks, and tracks how many live forks reference it so we +// don't GC the underlying memory file or rootfs out from under them. +type Template struct { + // ID is the template's stable identifier. It is independent of the + // source instance ID so a template can outlive its source. + ID string `json:"id"` + + // Name is a human-readable label, unique across templates. + Name string `json:"name"` + + // SourceInstanceID is the instance the template was promoted from. + // Its on-disk directory holds the canonical snapshot used by forks. + SourceInstanceID string `json:"source_instance_id"` + + // Image is the OCI reference the source instance was created from. + // Used for indexing templates by image when picking a fanout parent. + Image string `json:"image,omitempty"` + + // ImageDigest is the resolved image digest (sha256:…) at the time of + // promotion. Two templates with the same digest are interchangeable + // for the purposes of fan-out pool selection. + ImageDigest string `json:"image_digest,omitempty"` + + // HypervisorType records which hypervisor produced the snapshot. + // Templates can only be forked by the same hypervisor type. + HypervisorType hypervisor.Type `json:"hypervisor_type"` + + // HypervisorVersion is the hypervisor binary version used to take the + // snapshot. Restoring on a different version may work but isn't + // guaranteed; we store it so we can warn or refuse on mismatch. + HypervisorVersion string `json:"hypervisor_version,omitempty"` + + // MemoryBytes is the guest memory size the snapshot was taken at. + // Forks must be configured with at least this much memory. + MemoryBytes int64 `json:"memory_bytes,omitempty"` + + // VCPUs is the vCPU count the snapshot was taken at. Snapshots are + // vCPU-count-specific on most hypervisors. + VCPUs int `json:"vcpus,omitempty"` + + // Tags carries arbitrary user metadata, e.g. release identifiers. + Tags tags.Tags `json:"tags,omitempty"` + + // CreatedAt is when the template was first registered. + CreatedAt time.Time `json:"created_at"` + + // LastUsedAt is updated whenever a fork is created from the template. + // Useful as a proxy for popularity when GC-ing stale templates. + LastUsedAt time.Time `json:"last_used_at,omitempty"` + + // ForkCount is the number of live forks descended from this template. + // While > 0, the template (and its underlying snapshot files) must not + // be deleted. PR 4 owns reference counting; PR 2 just records the field. + ForkCount int `json:"fork_count"` + + // HotPagesPath optionally points at a baked "hot page list" used by + // the UFFD page server to prefetch known-touched pages before resume. + // PR 8 wires this in; PR 2 just reserves the field. + HotPagesPath string `json:"hot_pages_path,omitempty"` +} + +// Validate checks that required fields are populated. +func (t *Template) Validate() error { + if t == nil { + return errors.New("nil template") + } + if t.ID == "" { + return errors.New("template id is required") + } + if t.Name == "" { + return errors.New("template name is required") + } + if t.SourceInstanceID == "" { + return errors.New("template source_instance_id is required") + } + if t.HypervisorType == "" { + return errors.New("template hypervisor_type is required") + } + return nil +}