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 1/3] 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 +} From d46be7a8e3ba1806c344f3133b9e4c14aea35783 Mon Sep 17 00:00:00 2001 From: sjmiller609 <7516283+sjmiller609@users.noreply.github.com> Date: Fri, 8 May 2026 13:04:33 +0000 Subject: [PATCH 2/3] fork: share template mem-file via symlink for fan-out forks When ForkInstanceRequest.TemplateID is set, the fork resolves the source instance from the templates registry, skips the per-fork mem-file copy, and installs a symlink to the template's snapshot mem-file instead. firecracker mmaps the symlinked mem-file MAP_PRIVATE during restore, so many concurrent forks COW from the same backing file rather than each holding a private copy. Also wires: - templateGuard on Start/Restore so a template parent is never resumed while live forks share its mem-file. - Refcount lifecycle: bump on fork creation, decrement on fork delete. - Delete safety: deleting a template instance refuses while ForkCount>0 via templates.ErrInUse. - forkvm.CopyOptions.SkipRelPaths so callers can opt out of specific files in the source dir without breaking the existing copy semantics. --- lib/forkvm/copy.go | 29 ++++++++++++ lib/forkvm/copy_test.go | 19 ++++++++ lib/instances/delete.go | 18 +++++++ lib/instances/fork.go | 49 +++++++++++++++++-- lib/instances/fork_test.go | 2 +- lib/instances/manager.go | 8 +++- lib/instances/restore.go | 4 ++ lib/instances/start.go | 4 ++ lib/instances/templates.go | 97 ++++++++++++++++++++++++++++++++++++++ lib/instances/types.go | 7 +++ 10 files changed, 231 insertions(+), 6 deletions(-) diff --git a/lib/forkvm/copy.go b/lib/forkvm/copy.go index 6dc6eecc..0d389f8d 100644 --- a/lib/forkvm/copy.go +++ b/lib/forkvm/copy.go @@ -11,10 +11,28 @@ import ( var ErrSparseCopyUnsupported = errors.New("sparse copy unsupported") +// CopyOptions tunes CopyGuestDirectory behavior. The zero value reproduces +// the original full-copy semantics; callers can opt into skipping specific +// paths when the consumer arranges its own substitute (e.g. a symlink to a +// template-shared mem-file). +type CopyOptions struct { + // SkipRelPaths lists relative paths under srcDir that should not be + // materialized in dstDir. Comparison is exact and uses forward-slash + // separators on all platforms. + SkipRelPaths []string +} + // CopyGuestDirectory recursively copies a guest directory to a new destination. // Regular files are copied using sparse extent copy only (SEEK_DATA/SEEK_HOLE). // Runtime sockets and logs are skipped because they are host-runtime artifacts. func CopyGuestDirectory(srcDir, dstDir string) error { + return CopyGuestDirectoryWithOptions(srcDir, dstDir, CopyOptions{}) +} + +// CopyGuestDirectoryWithOptions is the option-taking variant of +// CopyGuestDirectory. Use this when forking with template-shared assets, so +// the caller can install a symlink in place of a heavy copied file. +func CopyGuestDirectoryWithOptions(srcDir, dstDir string, opts CopyOptions) error { srcInfo, err := os.Stat(srcDir) if err != nil { return fmt.Errorf("stat source directory: %w", err) @@ -27,6 +45,11 @@ func CopyGuestDirectory(srcDir, dstDir string) error { return fmt.Errorf("create destination directory: %w", err) } + skipSet := make(map[string]struct{}, len(opts.SkipRelPaths)) + for _, p := range opts.SkipRelPaths { + skipSet[filepath.ToSlash(p)] = struct{}{} + } + return filepath.WalkDir(srcDir, func(path string, d fs.DirEntry, walkErr error) error { if walkErr != nil { return walkErr @@ -39,6 +62,12 @@ func CopyGuestDirectory(srcDir, dstDir string) error { if relPath == "." { return nil } + if _, skip := skipSet[filepath.ToSlash(relPath)]; skip { + if d.IsDir() { + return filepath.SkipDir + } + return nil + } if d.IsDir() && shouldSkipDirectory(relPath) { return filepath.SkipDir } diff --git a/lib/forkvm/copy_test.go b/lib/forkvm/copy_test.go index c71f6c4e..56fb6caf 100644 --- a/lib/forkvm/copy_test.go +++ b/lib/forkvm/copy_test.go @@ -44,6 +44,25 @@ func TestCopyGuestDirectory(t *testing.T) { assert.Equal(t, "metadata.json", linkTarget) } +func TestCopyGuestDirectory_SkipRelPaths(t *testing.T) { + src := filepath.Join(t.TempDir(), "src") + dst := filepath.Join(t.TempDir(), "dst") + + require.NoError(t, os.MkdirAll(filepath.Join(src, "snapshots", "snapshot-latest"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(src, "snapshots", "snapshot-latest", "config.json"), []byte(`{}`), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(src, "snapshots", "snapshot-latest", "memory"), []byte("the heavy mem-file"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(src, "snapshots", "snapshot-latest", "state"), []byte("device state"), 0644)) + + err := CopyGuestDirectoryWithOptions(src, dst, CopyOptions{ + SkipRelPaths: []string{"snapshots/snapshot-latest/memory"}, + }) + require.NoError(t, err) + + assert.NoFileExists(t, filepath.Join(dst, "snapshots", "snapshot-latest", "memory")) + assert.FileExists(t, filepath.Join(dst, "snapshots", "snapshot-latest", "config.json")) + assert.FileExists(t, filepath.Join(dst, "snapshots", "snapshot-latest", "state")) +} + func TestCopyGuestDirectory_DoesNotSkipTmpSuffixedDirectories(t *testing.T) { src := filepath.Join(t.TempDir(), "src") dst := filepath.Join(t.TempDir(), "dst") diff --git a/lib/instances/delete.go b/lib/instances/delete.go index 283e0b19..79b679ef 100644 --- a/lib/instances/delete.go +++ b/lib/instances/delete.go @@ -35,6 +35,18 @@ func (m *manager) deleteInstance( stored := &meta.StoredMetadata log.DebugContext(ctx, "loaded instance", "instance_id", id, "state", inst.State) + // If this instance was promoted to a template parent, refuse to delete + // it while live forks reference it. Removing the registry entry now + // (instead of after the data wipe) gives us a single transactional + // "in-use" check via templates.ErrInUse. + if stored.IsTemplate && stored.TemplateID != "" && m.templateRegistry != nil { + if err := m.templateRegistry.Delete(ctx, stored.TemplateID); err != nil { + return fmt.Errorf("delete template registry entry for instance %s: %w", id, err) + } + stored.IsTemplate = false + stored.TemplateID = "" + } + target, err := m.cancelAndWaitCompressionJob(ctx, m.snapshotJobKeyForInstance(id)) if err != nil { return fmt.Errorf("wait for instance compression to stop: %w", err) @@ -136,6 +148,12 @@ func (m *manager) deleteInstance( return fmt.Errorf("delete instance data: %w", err) } + // 9. If this instance was a fork of a template, drop the template's + // fork refcount so the template can eventually be deleted. + if stored.ForkOfTemplate != "" { + m.dropTemplateForkRefcount(ctx, stored.ForkOfTemplate) + } + log.InfoContext(ctx, "instance deleted successfully", "instance_id", id) return nil } diff --git a/lib/instances/fork.go b/lib/instances/fork.go index 4ce7ee6e..bae4182b 100644 --- a/lib/instances/fork.go +++ b/lib/instances/fork.go @@ -15,6 +15,7 @@ import ( "github.com/kernel/hypeman/lib/hypervisor" "github.com/kernel/hypeman/lib/logger" "github.com/kernel/hypeman/lib/network" + "github.com/kernel/hypeman/lib/templates" "github.com/nrednav/cuid2" "go.opentelemetry.io/otel/attribute" "gvisor.dev/gvisor/pkg/cleanup" @@ -36,11 +37,22 @@ func (m *manager) forkInstance(ctx context.Context, id string, req ForkInstanceR return nil, "", err } + resolvedID, tpl, err := m.resolveForkFromTemplateRequest(ctx, id, req) + if err != nil { + return nil, "", err + } + id = resolvedID + meta, err := m.loadMetadata(id) if err != nil { return nil, "", err } source := m.toInstance(ctx, meta) + if tpl != nil { + if err := validateForkResolvedFromTemplate(tpl, source.HypervisorType); err != nil { + return nil, "", err + } + } targetState, err := resolveForkTargetState(req.TargetState, source.State) if err != nil { return nil, "", err @@ -65,7 +77,7 @@ func (m *manager) forkInstance(ctx context.Context, id string, req ForkInstanceR return nil, "", fmt.Errorf("standby source instance: %w", err) } - forked, forkErr := m.forkInstanceFromStoppedOrStandby(ctx, id, req, true) + forked, forkErr := m.forkInstanceFromStoppedOrStandby(ctx, id, req, true, tpl) if forkErr == nil { if err := m.rotateSourceVsockForRestore(ctx, id, forked.Id); err != nil { forkErr = fmt.Errorf("prepare source snapshot for restore: %w", err) @@ -104,7 +116,7 @@ func (m *manager) forkInstance(ctx context.Context, id string, req ForkInstanceR } return forked, targetState, nil case StateStopped, StateStandby: - forked, err := m.forkInstanceFromStoppedOrStandby(ctx, id, req, false) + forked, err := m.forkInstanceFromStoppedOrStandby(ctx, id, req, false, tpl) if err != nil { return nil, "", err } @@ -192,7 +204,7 @@ func generateForkSourceVsockCID(sourceID, forkID string, current int64) int64 { return cid } -func (m *manager) forkInstanceFromStoppedOrStandby(ctx context.Context, id string, req ForkInstanceRequest, supportValidated bool) (*Instance, error) { +func (m *manager) forkInstanceFromStoppedOrStandby(ctx context.Context, id string, req ForkInstanceRequest, supportValidated bool, tpl *templates.Template) (*Instance, error) { log := logger.FromContext(ctx) meta, err := m.loadMetadata(id) @@ -202,6 +214,9 @@ func (m *manager) forkInstanceFromStoppedOrStandby(ctx context.Context, id strin source := m.toInstance(ctx, meta) stored := &meta.StoredMetadata + if tpl != nil && !stored.IsTemplate { + return nil, fmt.Errorf("%w: template %s source instance %s is not flagged as a template parent", ErrInvalidState, tpl.ID, id) + } switch source.State { case StateStopped, StateStandby: @@ -255,12 +270,21 @@ func (m *manager) forkInstanceFromStoppedOrStandby(ctx context.Context, id strin } } - if err := forkvm.CopyGuestDirectory(srcDir, dstDir); err != nil { + copyOpts := forkvm.CopyOptions{} + if tpl != nil { + copyOpts.SkipRelPaths = []string{templateSharedMemFileRelPath} + } + if err := forkvm.CopyGuestDirectoryWithOptions(srcDir, dstDir, copyOpts); err != nil { if errors.Is(err, forkvm.ErrSparseCopyUnsupported) { return nil, fmt.Errorf("fork requires sparse-capable filesystem (SEEK_DATA/SEEK_HOLE unsupported): %w", err) } return nil, fmt.Errorf("clone guest directory: %w", err) } + if tpl != nil { + if err := m.installForkSharedMemFile(dstDir, tpl); err != nil { + return nil, fmt.Errorf("install shared mem-file: %w", err) + } + } starter, err := m.getVMStarter(stored.HypervisorType) if err != nil { @@ -280,6 +304,15 @@ func (m *manager) forkInstanceFromStoppedOrStandby(ctx context.Context, id strin forkMeta.VsockSocket = m.paths.InstanceSocket(forkID, hypervisor.VsockSocketNameForType(forkMeta.HypervisorType)) forkMeta.ExitCode = nil forkMeta.ExitMessage = "" + // Forks of a template carry the template id but never inherit the + // IsTemplate flag — they are working copies. + forkMeta.IsTemplate = false + forkMeta.TemplateID = "" + if tpl != nil { + forkMeta.ForkOfTemplate = tpl.ID + } else { + forkMeta.ForkOfTemplate = stored.ForkOfTemplate + } // Keep the original CID for snapshot-based forks. // Rewriting CID in restored memory snapshots is not reliable across @@ -324,6 +357,14 @@ func (m *manager) forkInstanceFromStoppedOrStandby(ctx context.Context, id strin return nil, fmt.Errorf("save fork metadata: %w", err) } + if tpl != nil { + // Bumped before cu.Release so a refcount failure leaves no orphan + // fork directory (deferred cu.Clean removes the data dir + metadata). + if err := m.bumpTemplateForkRefcount(ctx, tpl); err != nil { + return nil, fmt.Errorf("record template fork refcount: %w", err) + } + } + cu.Release() forked := m.toInstance(ctx, newMeta) log.InfoContext(ctx, "instance forked successfully", diff --git a/lib/instances/fork_test.go b/lib/instances/fork_test.go index f73e892b..d983c1bc 100644 --- a/lib/instances/fork_test.go +++ b/lib/instances/fork_test.go @@ -265,7 +265,7 @@ func TestForkInstanceFromStandbyCancelsCompressionJobAndCopiesRawMemory(t *testi forked, err := manager.forkInstanceFromStoppedOrStandby(ctx, sourceID, ForkInstanceRequest{ Name: "fork-standby-compressed-copy", TargetState: StateStopped, - }, true) + }, true, nil) require.NoError(t, err) require.NotNil(t, forked) diff --git a/lib/instances/manager.go b/lib/instances/manager.go index d62f88ef..1a2215c0 100644 --- a/lib/instances/manager.go +++ b/lib/instances/manager.go @@ -381,7 +381,13 @@ func (m *manager) DeleteSnapshot(ctx context.Context, snapshotID string) error { // ForkInstance creates a forked copy of an instance. func (m *manager) ForkInstance(ctx context.Context, id string, req ForkInstanceRequest) (*Instance, error) { - lock := m.getInstanceLock(id) + // Resolve TemplateID outside the lock so we hold the source instance + // lock — not an empty string lock — when forking from a template. + resolvedID, _, err := m.resolveForkFromTemplateRequest(ctx, id, req) + if err != nil { + return nil, err + } + lock := m.getInstanceLock(resolvedID) lock.Lock() forked, targetState, err := m.forkInstance(ctx, id, req) lock.Unlock() diff --git a/lib/instances/restore.go b/lib/instances/restore.go index 268f769b..3957e97f 100644 --- a/lib/instances/restore.go +++ b/lib/instances/restore.go @@ -57,6 +57,10 @@ func (m *manager) restoreInstance( log.ErrorContext(ctx, "no snapshot available", "instance_id", id) return nil, fmt.Errorf("no snapshot available for instance %s", id) } + if err := m.templateGuard(stored, "restore"); err != nil { + log.ErrorContext(ctx, "refusing to restore template instance", "instance_id", id, "template_id", stored.TemplateID) + return nil, err + } // 2b. Validate aggregate resource limits before allocating resources (if configured) reservedResources := false diff --git a/lib/instances/start.go b/lib/instances/start.go index 8da3026e..79b855d7 100644 --- a/lib/instances/start.go +++ b/lib/instances/start.go @@ -47,6 +47,10 @@ func (m *manager) startInstance( log.ErrorContext(ctx, "invalid state for start", "instance_id", id, "state", inst.State) return nil, fmt.Errorf("%w: cannot start from state %s, must be Stopped", ErrInvalidState, inst.State) } + if err := m.templateGuard(stored, "start"); err != nil { + log.ErrorContext(ctx, "refusing to start template instance", "instance_id", id, "template_id", stored.TemplateID) + return nil, err + } // 2a. Clear stale exit info from previous run and apply command overrides stored.ExitCode = nil diff --git a/lib/instances/templates.go b/lib/instances/templates.go index 5fda1c10..91383615 100644 --- a/lib/instances/templates.go +++ b/lib/instances/templates.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + "os" + "path/filepath" "time" "github.com/kernel/hypeman/lib/hypervisor" @@ -219,3 +221,98 @@ func (m *manager) nowOrDefault() time.Time { } return m.now() } + +// resolveForkFromTemplateRequest expands a ForkInstanceRequest with a +// non-empty TemplateID into (sourceInstanceID, *Template). Returns +// (instanceID, nil, nil) when TemplateID is empty so callers fall through +// to the ordinary fork path. Returns an error when the caller passed both +// instanceID and TemplateID, when the registry is unconfigured, or when +// the template cannot be resolved. +func (m *manager) resolveForkFromTemplateRequest(ctx context.Context, instanceID string, req ForkInstanceRequest) (string, *templates.Template, error) { + if req.TemplateID == "" { + return instanceID, nil, nil + } + if instanceID != "" { + return "", nil, fmt.Errorf("%w: pass either an instance id or a template id, not both", ErrInvalidRequest) + } + if m.templateRegistry == nil { + return "", nil, fmt.Errorf("%w: template registry not configured", ErrNotSupported) + } + tpl, err := m.templateForFork(ctx, req.TemplateID) + if err != nil { + return "", nil, fmt.Errorf("resolve template %q: %w", req.TemplateID, err) + } + if tpl == nil { + return "", nil, fmt.Errorf("%w: template %q not found", ErrNotFound, req.TemplateID) + } + if tpl.SourceInstanceID == "" { + return "", nil, fmt.Errorf("%w: template %s has no source instance", ErrInvalidState, tpl.ID) + } + return tpl.SourceInstanceID, tpl, nil +} + +// installForkSharedMemFile arranges the fork's snapshot directory so the +// guest mem-file is a symlink into the template's snapshot directory +// instead of a per-fork copy. firecracker mmaps the mem-file MAP_PRIVATE +// during restore, so all forks COW from the same backing file. +// +// Layout: dst is the fork's data dir. The snapshot dir is at +// /snapshots/snapshot-latest, and the mem-file lives at +// /memory. The symlink target is the template's source +// instance's standby snapshot mem-file. +func (m *manager) installForkSharedMemFile(forkDataDir string, tpl *templates.Template) error { + if tpl == nil { + return nil + } + srcMem := filepath.Join(m.paths.InstanceSnapshotLatest(tpl.SourceInstanceID), templateSharedMemFileName) + if _, err := os.Stat(srcMem); err != nil { + return fmt.Errorf("stat template mem-file: %w", err) + } + dstSnapshotDir := filepath.Join(forkDataDir, "snapshots", "snapshot-latest") + if err := os.MkdirAll(dstSnapshotDir, 0o755); err != nil { + return fmt.Errorf("ensure fork snapshot dir: %w", err) + } + dstMem := filepath.Join(dstSnapshotDir, templateSharedMemFileName) + // Tolerate a leftover entry (e.g. from a partial copy that wasn't fully + // skipped on a different filesystem layout). + _ = os.Remove(dstMem) + if err := os.Symlink(srcMem, dstMem); err != nil { + return fmt.Errorf("symlink shared mem-file: %w", err) + } + return nil +} + +// templateSharedMemFileRelPath is the relative path under the source data +// dir that points at the snapshotted guest mem-file. Encoded here so the +// fork copy step can skip it without importing firecracker internals. +const ( + templateSharedMemFileName = "memory" + templateSharedMemFileRelPath = "snapshots/snapshot-latest/memory" +) + +// bumpTemplateForkRefcount records that a fork now depends on a template. +// Best-effort touch of LastUsedAt happens alongside. +func (m *manager) bumpTemplateForkRefcount(ctx context.Context, tpl *templates.Template) error { + if tpl == nil || m.templateRegistry == nil { + return nil + } + if _, err := m.templateRegistry.IncrementForkCount(ctx, tpl.ID); err != nil { + return fmt.Errorf("increment template fork count: %w", err) + } + m.touchTemplateUsage(ctx, tpl.ID) + return nil +} + +// dropTemplateForkRefcount mirrors bumpTemplateForkRefcount and is called +// when a fork instance is deleted. Missing templates are tolerated so +// orphaned forks don't block delete. +func (m *manager) dropTemplateForkRefcount(ctx context.Context, templateID string) { + if templateID == "" || m.templateRegistry == nil { + return + } + if _, err := m.templateRegistry.DecrementForkCount(ctx, templateID); err != nil { + log := logger.FromContext(ctx) + log.WarnContext(ctx, "failed to decrement template fork refcount", + "template_id", templateID, "error", err) + } +} diff --git a/lib/instances/types.go b/lib/instances/types.go index 6d415da0..0f5cda9f 100644 --- a/lib/instances/types.go +++ b/lib/instances/types.go @@ -257,6 +257,13 @@ type ForkInstanceRequest struct { Name string // Required: name for the new forked instance FromRunning bool // Optional: allow forking from Running by auto standby/fork/restore TargetState State // Optional: desired final state of forked instance (Stopped, Standby, Running). Empty means inherit source state. + + // TemplateID resolves the source instance from the template registry by + // id-or-name. When set, the source instance id passed to ForkInstance is + // ignored (must be empty). The fork's mem-file is shared with the + // template's mem-file via symlink instead of being copied per-fork, so + // many forks fan out from the same warm guest memory. + TemplateID string } // SnapshotKind determines how snapshot data is captured and restored. From f0de7519a344e6bc1f94786d92fa126b73d9fc37 Mon Sep 17 00:00:00 2001 From: sjmiller609 <7516283+sjmiller609@users.noreply.github.com> Date: Fri, 8 May 2026 13:06:50 +0000 Subject: [PATCH 3/3] templates: reconcile fork refcounts and sweep orphans on boot Drift between the templates registry and the actual fork population can appear after a crash, an out-of-band fork delete, or any path that bypassed the increment/decrement pair. The new Registry.Reconcile takes a map of observed fork counts and rewrites every template's ForkCount in place. The instances manager wires this in two ways: - On NewManager, walks instance metadata, sums ForkOfTemplate, and calls Reconcile so a restart heals stale counts. - Sweeps orphaned registry entries whose source instance no longer exists and that have zero live forks; entries with live forks are left alone because the forks still hold the mem-file open via the shared symlink. Exposes ReconcileTemplateState on the manager so a host process can schedule periodic GC ticks alongside other sweepers. --- lib/instances/manager.go | 12 +++++++ lib/instances/storage.go | 15 ++++++++ lib/instances/templates.go | 65 ++++++++++++++++++++++++++++++++++ lib/templates/registry.go | 35 ++++++++++++++++++ lib/templates/registry_test.go | 29 +++++++++++++++ 5 files changed, 156 insertions(+) diff --git a/lib/instances/manager.go b/lib/instances/manager.go index 1a2215c0..71bb75b4 100644 --- a/lib/instances/manager.go +++ b/lib/instances/manager.go @@ -226,9 +226,21 @@ func NewManagerWithConfig(p *paths.Paths, imageManager images.Manager, systemMan logger.FromContext(context.Background()).WarnContext(context.Background(), "failed to recover pending standby compression jobs", "error", err) } + // Heal any drift between the templates registry and on-disk + // instances after a crash or out-of-band fork delete. + if err := m.reconcileTemplateState(context.Background()); err != nil { + logger.FromContext(context.Background()).WarnContext(context.Background(), "failed to reconcile template state at boot", "error", err) + } + return m } +// ReconcileTemplateState heals registry/instances drift on demand. Useful +// for tests and for periodic GC tickers driven by the host process. +func (m *manager) ReconcileTemplateState(ctx context.Context) error { + return m.reconcileTemplateState(ctx) +} + // SetResourceValidator sets the resource validator for aggregate limit checking. // This is called after initialization to avoid circular dependencies. func (m *manager) SetResourceValidator(v ResourceValidator) { diff --git a/lib/instances/storage.go b/lib/instances/storage.go index f290298c..73eaf6d6 100644 --- a/lib/instances/storage.go +++ b/lib/instances/storage.go @@ -77,6 +77,21 @@ func (m *manager) loadMetadata(id string) (*metadata, error) { return &meta, nil } +// loadMetadataFromFile reads a metadata file by path. Used by sweepers +// that already have the path from listMetadataFiles and don't want to +// reverse-derive an instance id. +func loadMetadataFromFile(path string) (*metadata, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read metadata: %w", err) + } + var meta metadata + if err := json.Unmarshal(data, &meta); err != nil { + return nil, fmt.Errorf("unmarshal metadata: %w", err) + } + return &meta, nil +} + // saveMetadata saves instance metadata to disk func (m *manager) saveMetadata(meta *metadata) error { metaPath := m.paths.InstanceMetadata(meta.Id) diff --git a/lib/instances/templates.go b/lib/instances/templates.go index 91383615..e5f00a42 100644 --- a/lib/instances/templates.go +++ b/lib/instances/templates.go @@ -316,3 +316,68 @@ func (m *manager) dropTemplateForkRefcount(ctx context.Context, templateID strin "template_id", templateID, "error", err) } } + +// reconcileTemplateState heals drift between the templates registry and +// the instances directory. It rewrites ForkCount on every template using +// the actual ForkOfTemplate population on disk, and removes registry +// entries whose source instance no longer exists. Safe to call at boot +// and on a periodic GC tick. Bestow the work on a background goroutine +// when the cost matters; this method is synchronous and modest. +func (m *manager) reconcileTemplateState(ctx context.Context) error { + if m.templateRegistry == nil { + return nil + } + log := logger.FromContext(ctx) + + metaFiles, err := m.listMetadataFiles() + if err != nil { + return fmt.Errorf("list instance metadata for template GC: %w", err) + } + + observedForks := make(map[string]int) + templateSourceInstances := make(map[string]struct{}) + for _, path := range metaFiles { + meta, err := loadMetadataFromFile(path) + if err != nil { + log.WarnContext(ctx, "skip metadata during template GC", "path", path, "error", err) + continue + } + stored := meta.StoredMetadata + if stored.ForkOfTemplate != "" { + observedForks[stored.ForkOfTemplate]++ + } + if stored.IsTemplate { + templateSourceInstances[stored.Id] = struct{}{} + } + } + + if err := m.templateRegistry.Reconcile(ctx, observedForks); err != nil { + return fmt.Errorf("reconcile template fork counts: %w", err) + } + + all, err := m.templateRegistry.List(ctx, nil) + if err != nil { + return fmt.Errorf("list templates for orphan sweep: %w", err) + } + for _, tpl := range all { + if _, ok := templateSourceInstances[tpl.SourceInstanceID]; ok { + continue + } + if tpl.ForkCount > 0 { + // Source gone but forks still reference the template; leave + // the registry entry so future fork GC can find it. The + // underlying mem-file is still alive on disk because forks + // hold open file descriptors via the symlink. + log.WarnContext(ctx, "template has live forks but no source instance", + "template_id", tpl.ID, "fork_count", tpl.ForkCount) + continue + } + log.InfoContext(ctx, "deleting orphaned template registry entry", + "template_id", tpl.ID, "source_instance_id", tpl.SourceInstanceID) + if err := m.templateRegistry.Delete(ctx, tpl.ID); err != nil { + log.WarnContext(ctx, "failed to delete orphaned template", + "template_id", tpl.ID, "error", err) + } + } + return nil +} diff --git a/lib/templates/registry.go b/lib/templates/registry.go index fed3f169..0cb2cb9c 100644 --- a/lib/templates/registry.go +++ b/lib/templates/registry.go @@ -45,6 +45,13 @@ type Registry interface { // 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) + + // Reconcile walks the registry and rewrites ForkCount on every + // template using observedForks: the count of live forks per + // template id. Templates not present in observedForks fall to + // zero. Used to heal drift after a crash, an out-of-band fork + // delete, or any other path that bypassed Increment/Decrement. + Reconcile(ctx context.Context, observedForks map[string]int) error } // ListFilter narrows the templates returned by Registry.List. @@ -231,6 +238,34 @@ func (r *FileRegistry) IncrementForkCount(ctx context.Context, id string) (*Temp return t, nil } +// Reconcile rewrites ForkCount on every persisted template using +// observedForks as the authority. Templates not present in observedForks +// are treated as having zero live forks. Errors on individual templates +// are returned as a wrapped multi-error so the caller can decide whether +// to treat partial reconciliation as fatal; reconciliation is best-effort +// and never deletes templates by itself. +func (r *FileRegistry) Reconcile(ctx context.Context, observedForks map[string]int) error { + _ = ctx + r.mu.Lock() + defer r.mu.Unlock() + all, err := r.listLocked() + if err != nil { + return err + } + var firstErr error + for _, t := range all { + want := observedForks[t.ID] + if t.ForkCount == want { + continue + } + t.ForkCount = want + if err := r.writeLocked(t); err != nil && firstErr == nil { + firstErr = fmt.Errorf("reconcile template %s: %w", t.ID, err) + } + } + return firstErr +} + func (r *FileRegistry) DecrementForkCount(ctx context.Context, id string) (*Template, error) { _ = ctx r.mu.Lock() diff --git a/lib/templates/registry_test.go b/lib/templates/registry_test.go index 79a93031..164fe13d 100644 --- a/lib/templates/registry_test.go +++ b/lib/templates/registry_test.go @@ -123,3 +123,32 @@ func TestFileRegistry_SaveValidates(t *testing.T) { err := r.Save(context.Background(), &Template{Name: "x"}) assert.True(t, errors.Is(err, ErrInvalid)) } + +func TestFileRegistry_Reconcile(t *testing.T) { + r := newTestRegistry(t) + a := sampleTemplate("a", "alpha") + a.ForkCount = 5 + b := sampleTemplate("b", "beta") + b.ForkCount = 0 + c := sampleTemplate("c", "gamma") + c.ForkCount = 7 + require.NoError(t, r.Save(context.Background(), a)) + require.NoError(t, r.Save(context.Background(), b)) + require.NoError(t, r.Save(context.Background(), c)) + + require.NoError(t, r.Reconcile(context.Background(), map[string]int{ + "a": 2, + "b": 3, + // c omitted -> should fall to 0 + })) + + got, err := r.Get(context.Background(), "a") + require.NoError(t, err) + assert.Equal(t, 2, got.ForkCount) + got, err = r.Get(context.Background(), "b") + require.NoError(t, err) + assert.Equal(t, 3, got.ForkCount) + got, err = r.Get(context.Background(), "c") + require.NoError(t, err) + assert.Equal(t, 0, got.ForkCount) +}