diff --git a/cgroup/cgroup.go b/cgroup/cgroup.go index bd05d406..5416822b 100644 --- a/cgroup/cgroup.go +++ b/cgroup/cgroup.go @@ -45,6 +45,8 @@ const ( maxName = "cpu.max" burstName = "cpu.max.burst" statName = "cpu.stat" + cpusetName = "cpuset.cpus" + cpusetEffName = "cpuset.cpus.effective" removeWait = time.Second removePollInterval = 10 * time.Millisecond @@ -56,9 +58,10 @@ type Knobs struct { QuotaUs int64 PeriodUs int64 BurstUs int64 + CPUSet string } -// ResolveKnobs applies the Guaranteed-at-N defaults: weight = vCPU count, quota = vCPU count x period, burst = 0. +// ResolveKnobs applies the Guaranteed-at-N defaults: weight = vCPU count, quota = vCPU count x period, burst = 0, no placement. func ResolveKnobs(cfg *types.Config) Knobs { period := cmp.Or(cfg.CPUPeriodUs, int64(DefaultPeriodUs)) return Knobs{ @@ -66,6 +69,7 @@ func ResolveKnobs(cfg *types.Config) Knobs { QuotaUs: cmp.Or(cfg.CPUQuotaUs, int64(cfg.CPU)*period), PeriodUs: period, BurstUs: cfg.CPUBurstUs, + CPUSet: cfg.CPUSetCPUs, } } @@ -83,20 +87,58 @@ func (k Knobs) Validate() error { if k.BurstUs < 0 || k.BurstUs > k.QuotaUs { return fmt.Errorf("--cpu-burst-us must be 0..quota (%d), got %d", k.QuotaUs, k.BurstUs) } + if _, err := ParseCPUList(k.CPUSet); err != nil { + return fmt.Errorf("--cpuset-cpus: %w", err) + } return nil } +// EffectiveCPUs resolves the cpu set a VM may run on — explicit placement wins over the machine fence; nil means all cores. +func EffectiveCPUs(placement, fence string) []int { + cpus, err := ParseCPUList(cmp.Or(placement, fence)) + if err != nil { + return nil + } + return cpus +} + +// ParseCPUList parses a kernel cpu-list ("0-14", "0,2-4"); empty input is nil (no placement). +func ParseCPUList(s string) ([]int, error) { + if s == "" { + return nil, nil + } + var cpus []int + for part := range strings.SplitSeq(s, ",") { + lo, hi, ok := strings.Cut(strings.TrimSpace(part), "-") + start, err := strconv.Atoi(lo) + if err != nil || start < 0 { + return nil, fmt.Errorf("invalid cpu list %q", s) + } + end := start + if ok { + if end, err = strconv.Atoi(hi); err != nil || end < start { + return nil, fmt.Errorf("invalid cpu list %q", s) + } + } + for c := start; c <= end; c++ { + cpus = append(cpus, c) + } + } + slices.Sort(cpus) + return slices.Compact(cpus), nil +} + // ScopeDir returns vmID's scope directory under parentDir. func ScopeDir(parentDir, vmID string) string { return filepath.Join(parentDir, scopePrefix+vmID+scopeSuffix) } // Prepare creates or reconfigures vmID's scope and returns its opened directory for CLONE_INTO_CGROUP; idempotent, so a relaunch reuses a scope its dying predecessor still occupies. -func Prepare(parentDir, vmID string, k Knobs) (*os.File, error) { +func Prepare(parentDir, fence, vmID string, k Knobs) (*os.File, error) { if vmID == "" { return nil, errors.New("cgroup scope: empty vm id") } - if err := ensureParent(parentDir); err != nil { + if err := ensureParent(parentDir, fence, k.CPUSet != ""); err != nil { return nil, err } dir := ScopeDir(parentDir, vmID) @@ -104,6 +146,9 @@ func Prepare(parentDir, vmID string, k Knobs) (*os.File, error) { if mkErr != nil && !errors.Is(mkErr, fs.ErrExist) { return nil, fmt.Errorf("create scope: %w", mkErr) } + if err := placeScope(parentDir, dir, k.CPUSet); err != nil { + return nil, err + } if err := writeControl(dir, weightName, strconv.Itoa(k.Weight)); err != nil { return nil, err } @@ -199,8 +244,8 @@ func parseStat(data string) map[string]int64 { return stat } -// ensureParent enables cpu at every ancestor, not just the leaf — cgroup v2 subtree delegation is hierarchical. -func ensureParent(parentDir string) error { +// ensureParent enables the needed controllers at every ancestor, not just the leaf — cgroup v2 subtree delegation is hierarchical. +func ensureParent(parentDir, fence string, placed bool) error { rel, err := filepath.Rel(Root, parentDir) if err != nil || rel == "." || strings.HasPrefix(rel, "..") { return fmt.Errorf("cgroup parent %q must be under %s", parentDir, Root) @@ -208,26 +253,137 @@ func ensureParent(parentDir string) error { if err := utils.EnsureDirs(parentDir); err != nil { return err } - if err := enableCPU(Root); err != nil { + ctrls := []string{"cpu"} + if fence != "" || placed { + ctrls = append(ctrls, "cpuset") + } + if err := forEachLevel(rel, func(dir string) error { return enableControllers(dir, ctrls) }); err != nil { + return err + } + return reconcileFence(parentDir, fence) +} + +// reconcileFence converges the parent's cpuset.cpus to the configured fence; a cleared config resets a stale fence once, and the cpuset controller is never disabled. +func reconcileFence(parentDir, fence string) error { + current, err := readControl(parentDir, cpusetName) + if fence == "" { + if err == nil && current != "" { + return writeControl(parentDir, cpusetName, "\n") + } + return nil + } + // The kernel echoes cpu lists canonicalized ("0-3,4-7" reads back "0-7"): compare parsed sets, or a non-canonical config string re-runs the scan and the serialized cpuset write on every launch. + if cur, curErr := ParseCPUList(current); curErr == nil { + if want, wantErr := ParseCPUList(fence); wantErr == nil && slices.Equal(cur, want) { + return nil + } + } + if err := checkSubset(fence, filepath.Dir(parentDir), "cgroup_cpus fence"); err != nil { + return err + } + if err := checkScopePlacements(parentDir, fence); err != nil { + return err + } + return writeControl(parentDir, cpusetName, fence) +} + +// placeScope applies a per-VM placement; the kernel silently degrades ungrantable requests to the parent set, so the subset check is cocoon's. +func placeScope(parentDir, dir, cpuset string) error { + if cpuset == "" { + return nil + } + if current, err := readControl(dir, cpusetName); err == nil { + if cur, curErr := ParseCPUList(current); curErr == nil { + if want, wantErr := ParseCPUList(cpuset); wantErr == nil && slices.Equal(cur, want) { + return nil + } + } + } + if err := checkSubset(cpuset, parentDir, "--cpuset-cpus"); err != nil { + return err + } + return writeControl(dir, cpusetName, cpuset) +} + +func checkSubset(cpuset, dir, what string) error { + want, err := ParseCPUList(cpuset) + if err != nil { + return fmt.Errorf("%s: %w", what, err) + } + effRaw, err := readControl(dir, cpusetEffName) + if err != nil { + return fmt.Errorf("read effective cpuset: %w", err) + } + eff, err := ParseCPUList(effRaw) + if err != nil { + return fmt.Errorf("parse %s: %w", filepath.Join(dir, cpusetEffName), err) + } + for _, c := range want { + if !slices.Contains(eff, c) { + return fmt.Errorf("%s %q: cpu %d not in %s effective set %q", what, cpuset, c, dir, effRaw) + } + } + return nil +} + +// checkScopePlacements refuses a fence shrink that would invalidate a live VM's explicit placement. +func checkScopePlacements(parentDir, fence string) error { + ids, err := ListScopeVMIDs(parentDir) + if err != nil { + return err + } + fenceCPUs, _ := ParseCPUList(fence) + for _, id := range ids { + placement, err := readControl(ScopeDir(parentDir, id), cpusetName) + if err != nil || placement == "" { + continue + } + cpus, err := ParseCPUList(placement) + if err != nil { + continue + } + for _, c := range cpus { + if !slices.Contains(fenceCPUs, c) { + return fmt.Errorf("fence %q excludes cpu %d used by vm %s placement %q; stop that VM or widen the fence", fence, c, id, placement) + } + } + } + return nil +} + +func forEachLevel(rel string, fn func(dir string) error) error { + if err := fn(Root); err != nil { return err } dir := Root for part := range strings.SplitSeq(rel, string(filepath.Separator)) { dir = filepath.Join(dir, part) - if err := enableCPU(dir); err != nil { + if err := fn(dir); err != nil { return err } } return nil } -// enableCPU reads before writing: subtree_control writes take the kernel's hierarchy-wide cgroup_mutex, so steady-state launches must not contend on a no-op write. -func enableCPU(dir string) error { - path := filepath.Join(dir, subtreeControlName) - if data, err := os.ReadFile(path); err == nil && slices.Contains(strings.Fields(string(data)), "cpu") { //nolint:gosec // fixed name under the config-derived parent +// enableControllers reads before writing: subtree_control writes take the kernel's hierarchy-wide cgroup_mutex, so steady-state launches must not contend on a no-op write. Missing controllers go in one combined write. +func enableControllers(dir string, ctrls []string) error { + have, _ := readControl(dir, subtreeControlName) + enabled := strings.Fields(have) + var missing []string + for _, ctrl := range ctrls { + if !slices.Contains(enabled, ctrl) { + missing = append(missing, "+"+ctrl) + } + } + if len(missing) == 0 { return nil } - return writeControl(dir, subtreeControlName, "+cpu") + return writeControl(dir, subtreeControlName, strings.Join(missing, " ")) +} + +func readControl(dir, name string) (string, error) { + data, err := os.ReadFile(filepath.Join(dir, name)) //nolint:gosec // fixed name under the config-derived parent + return strings.TrimSpace(string(data)), err } func writeControl(dir, name, value string) error { diff --git a/cgroup/cgroup_test.go b/cgroup/cgroup_test.go index 0eb2af69..e210f459 100644 --- a/cgroup/cgroup_test.go +++ b/cgroup/cgroup_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "slices" + "strings" "testing" "github.com/cocoonstack/cocoon/types" @@ -109,3 +110,128 @@ func TestParseStat(t *testing.T) { t.Errorf("got %v", stat) } } + +func TestParseCPUList(t *testing.T) { + tests := []struct { + in string + want []int + wantErr bool + }{ + {in: "", want: nil}, + {in: "3", want: []int{3}}, + {in: "0-3", want: []int{0, 1, 2, 3}}, + {in: "0,2-4,7", want: []int{0, 2, 3, 4, 7}}, + {in: "4-2", wantErr: true}, + {in: "a-b", wantErr: true}, + {in: "-1", wantErr: true}, + {in: "1,,2", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.in, func(t *testing.T) { + got, err := ParseCPUList(tt.in) + if (err != nil) != tt.wantErr { + t.Fatalf("err = %v, wantErr %v", err, tt.wantErr) + } + if !tt.wantErr && !slices.Equal(got, tt.want) { + t.Errorf("got %v, want %v", got, tt.want) + } + }) + } +} + +func TestKnobsValidateRejectsBadCPUSet(t *testing.T) { + k := Knobs{Weight: 1, QuotaUs: 100000, PeriodUs: 100000, CPUSet: "9-1"} + if err := k.Validate(); err == nil { + t.Error("want error for invalid cpu list") + } +} + +func TestCheckSubset(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "cpuset.cpus.effective"), []byte("0-14\n"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + if err := checkSubset("2-4", dir, "fence"); err != nil { + t.Errorf("subset rejected: %v", err) + } + if err := checkSubset("14-15", dir, "fence"); err == nil { + t.Error("want error: cpu 15 outside effective 0-14") + } +} + +func TestCheckScopePlacements(t *testing.T) { + parent := t.TempDir() + mk := func(id, placement string) { + dir := ScopeDir(parent, id) + if err := os.Mkdir(dir, 0o755); err != nil { + t.Fatalf("setup: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "cpuset.cpus"), []byte(placement+"\n"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + } + mk("A", "") + mk("B", "2-3") + + if err := checkScopePlacements(parent, "0-7"); err != nil { + t.Errorf("fence covering placements rejected: %v", err) + } + if err := checkScopePlacements(parent, "0-2"); err == nil { + t.Error("want error: fence 0-2 excludes cpu 3 used by B") + } +} + +func TestReconcileFenceClearsStaleValue(t *testing.T) { + parent := t.TempDir() + path := filepath.Join(parent, "cpuset.cpus") + if err := os.WriteFile(path, []byte("0-14\n"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + if err := reconcileFence(parent, ""); err != nil { + t.Fatalf("reconcile: %v", err) + } + data, err := os.ReadFile(path) + if err != nil || strings.TrimSpace(string(data)) != "" { + t.Errorf("stale fence not cleared: %q err=%v", data, err) + } + if err := reconcileFence(parent, ""); err != nil { + t.Errorf("steady-state empty reconcile: %v", err) + } +} + +func TestReconcileFenceCanonicalEquality(t *testing.T) { + parent := t.TempDir() + if err := os.WriteFile(filepath.Join(parent, "cpuset.cpus"), []byte("0-7\n"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + // No cpuset.cpus.effective fixture exists: reaching checkSubset would fail, so success proves the parsed-set gate short-circuited. + if err := reconcileFence(parent, "0-3,4-7"); err != nil { + t.Errorf("canonically-equal fence rewrote: %v", err) + } +} + +func TestPlaceScopeReadGate(t *testing.T) { + parent := t.TempDir() + dir := ScopeDir(parent, "X") + if err := os.Mkdir(dir, 0o755); err != nil { + t.Fatalf("setup: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "cpuset.cpus"), []byte("2-3\n"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + if err := placeScope(parent, dir, "2,3"); err != nil { + t.Errorf("equal placement rewrote: %v", err) + } +} + +func TestEffectiveCPUs(t *testing.T) { + if got := EffectiveCPUs("2-3", "0-14"); !slices.Equal(got, []int{2, 3}) { + t.Errorf("placement wins: got %v", got) + } + if got := EffectiveCPUs("", "0-1"); !slices.Equal(got, []int{0, 1}) { + t.Errorf("fence fallback: got %v", got) + } + if EffectiveCPUs("", "") != nil { + t.Error("no constraint: want nil") + } +} diff --git a/cmd/core/metastore.go b/cmd/core/metastore.go index c5f81fb5..f70c07e4 100644 --- a/cmd/core/metastore.go +++ b/cmd/core/metastore.go @@ -37,8 +37,7 @@ var ( metaStore meta.Store metaErr error - // vmTables maps the legacy vms.json fields onto the vms-namespace tables; - // the json field names are engine knowledge and live only here. + // vmTables maps the legacy vms.json fields onto the vms-namespace tables; the json field names are engine knowledge and live only here. vmTables = metajson.TableCodec{Specs: []metajson.TableSpec{ {Key: "vms", Table: hypervisor.TableRecords}, {Key: "names", Table: hypervisor.TableNames}, @@ -52,8 +51,7 @@ var ( }} ) -// MetaNamespaces lists every namespace with its tables — the engine-neutral -// declaration both engines consume. +// MetaNamespaces lists every namespace with its tables — the engine-neutral declaration both engines consume. func MetaNamespaces() []metasqlite.Namespace { return []metasqlite.Namespace{ {Name: hypervisor.VMNamespaceName(string(config.HypervisorCloudHypervisor)), Tables: []string{hypervisor.TableRecords, hypervisor.TableNames, hypervisor.TableOrphanDirs, tombstone.TableName}}, @@ -66,8 +64,7 @@ func MetaNamespaces() []metasqlite.Namespace { } } -// MetaJSONNamespaces declares the json engine's namespace set: legacy file -// locations and field mappings, consumed by open and by conversion. +// MetaJSONNamespaces declares the json engine's namespace set: legacy file locations and field mappings, consumed by open and by conversion. func MetaJSONNamespaces(conf *config.Config) []metajson.Namespace { chCfg := cloudhypervisor.NewConfig(conf) fcCfg := firecracker.NewConfig(conf) @@ -93,9 +90,7 @@ func MetaDBPath(conf *config.Config) string { return filepath.Join(conf.RootDir, "meta", metasqlite.DBFileName) } -// ResolveMetaBackend returns the effective engine: an explicit setting wins, -// then an existing store binds (meta.db → sqlite, legacy json files → json), -// and a fresh root gets sqlite. +// ResolveMetaBackend returns the effective engine: an explicit setting wins, then an existing store binds (meta.db → sqlite, legacy json files → json), and a fresh root gets sqlite. func ResolveMetaBackend(conf *config.Config) string { if conf.MetaBackend != "" { return conf.MetaBackend @@ -109,33 +104,26 @@ func ResolveMetaBackend(conf *config.Config) string { return config.MetaBackendSQLite } -// LegacyJSONPresent reports whether any json-engine namespace file exists -// under the root — data a fresh sqlite store must never shadow. +// LegacyJSONPresent reports whether any json-engine namespace file exists under the root — data a fresh sqlite store must never shadow. func LegacyJSONPresent(conf *config.Config) bool { return slices.ContainsFunc(MetaJSONNamespaces(conf), func(ns metajson.Namespace) bool { return utils.FileExists(ns.FilePath) }) } -// MetaStore builds the process-wide meta store once — one store, every -// namespace — and injects it into every backend (design §10 P0 boundary). -// The engine follows ResolveMetaBackend; a fresh sqlite root bootstraps -// itself. +// MetaStore builds the process-wide meta store once — one store, every namespace — and injects it into every backend (design §10 P0 boundary). The engine follows ResolveMetaBackend; a fresh sqlite root bootstraps itself. func MetaStore(conf *config.Config) (meta.Store, error) { metaOnce.Do(func() { - // Bootstrap owns its context: the store outlives any single caller, - // and a canceled first caller must not poison the Once for everyone. + // Bootstrap owns its context: the store outlives any single caller, and a canceled first caller must not poison the Once for everyone. ctx, cancel := context.WithTimeout(context.Background(), metaBootstrapTimeout) defer cancel() - // Ordinary opens of EITHER engine refuse while a conversion is in - // flight (§6); the json engine cannot see the manifest itself. + // Ordinary opens of EITHER engine refuse while a conversion is in flight (§6); the json engine cannot see the manifest itself. dbPath := MetaDBPath(conf) if err := metasqlite.RefuseManifest(dbPath); err != nil { metaErr = err return } - // Assign the interface only on success: a typed-nil store would pass - // CloseMetaStore's nil check and panic. + // Assign the interface only on success: a typed-nil store would pass CloseMetaStore's nil check and panic. if ResolveMetaBackend(conf) == config.MetaBackendSQLite { if s, err := openSQLiteStore(ctx, conf, dbPath); err != nil { metaErr = err @@ -156,8 +144,7 @@ func MetaStore(conf *config.Config) (meta.Store, error) { return metaStore, metaErr } -// CloseMetaStore ends the store's unified lifecycle at command teardown -// (design §10 P0); a process that never opened it is a no-op. +// CloseMetaStore ends the store's unified lifecycle at command teardown (design §10 P0); a process that never opened it is a no-op. func CloseMetaStore(ctx context.Context) { if metaStore == nil { return @@ -167,9 +154,7 @@ func CloseMetaStore(ctx context.Context) { } } -// openSQLiteStore opens the sqlite engine, bootstrapping a fresh root or -// repairing a crashed bootstrap; a legacy json root never bootstraps — that -// would shadow its data. +// openSQLiteStore opens the sqlite engine, bootstrapping a fresh root or repairing a crashed bootstrap; a legacy json root never bootstraps — that would shadow its data. func openSQLiteStore(ctx context.Context, conf *config.Config, dbPath string) (meta.Store, error) { if !LegacyJSONPresent(conf) { if err := metasqlite.InitIfMissing(ctx, dbPath, MetaNamespaces()...); err != nil { diff --git a/cmd/core/utils.go b/cmd/core/utils.go index c2301e76..ff4d0bb7 100644 --- a/cmd/core/utils.go +++ b/cmd/core/utils.go @@ -167,9 +167,7 @@ func EnsureSnapshotNameFree(ctx context.Context, snapBackend snapshot.Snapshot, if name == "" { return nil } - // The name index, not Inspect: a save killed mid-flight leaves a pending record - // still holding the name, which Inspect reports as not-found. Passing that - // preflight means the whole capture is written before the insert rejects it. + // The name index, not Inspect: a save killed mid-flight leaves a pending record still holding the name, which Inspect reports as not-found. Passing that preflight means the whole capture is written before the insert rejects it. if nh, ok := snapBackend.(snapshot.NameHolder); ok { id, held, err := nh.NameOwner(ctx, name) if err != nil { diff --git a/cmd/core/vmconfig.go b/cmd/core/vmconfig.go index 3913dc37..e9eca739 100644 --- a/cmd/core/vmconfig.go +++ b/cmd/core/vmconfig.go @@ -27,6 +27,7 @@ func VMConfigFromFlags(cmd *cobra.Command, image string) (*types.VMConfig, error cpuQuotaUs, _ := cmd.Flags().GetInt64("cpu-quota-us") cpuPeriodUs, _ := cmd.Flags().GetInt64("cpu-period-us") cpuBurstUs, _ := cmd.Flags().GetInt64("cpu-burst-us") + cpusetCPUs, _ := cmd.Flags().GetString("cpuset-cpus") network, _ := cmd.Flags().GetString("network") user, _ := cmd.Flags().GetString("user") password, _ := cmd.Flags().GetString("password") @@ -70,6 +71,7 @@ func VMConfigFromFlags(cmd *cobra.Command, image string) (*types.VMConfig, error CPUQuotaUs: cpuQuotaUs, CPUPeriodUs: cpuPeriodUs, CPUBurstUs: cpuBurstUs, + CPUSetCPUs: cpusetCPUs, }, User: user, Password: password, @@ -97,6 +99,7 @@ func CloneVMConfigFromFlags(cmd *cobra.Command, snapCfg types.SnapshotConfig) (* flagCPUQuotaUs, _ := cmd.Flags().GetInt64("cpu-quota-us") flagCPUPeriodUs, _ := cmd.Flags().GetInt64("cpu-period-us") flagCPUBurstUs, _ := cmd.Flags().GetInt64("cpu-burst-us") + flagCPUSetCPUs, _ := cmd.Flags().GetString("cpuset-cpus") noDirectIO := snapCfg.NoDirectIO if cmd.Flags().Changed("no-direct-io") { noDirectIO, _ = cmd.Flags().GetBool("no-direct-io") @@ -132,6 +135,7 @@ func CloneVMConfigFromFlags(cmd *cobra.Command, snapCfg types.SnapshotConfig) (* CPUQuotaUs: flagCPUQuotaUs, CPUPeriodUs: flagCPUPeriodUs, CPUBurstUs: flagCPUBurstUs, + CPUSetCPUs: flagCPUSetCPUs, }, DataDisks: dataDisks, RestoreMode: restoreMode, @@ -150,11 +154,11 @@ func RestoreVMConfigFromFlags(cmd *cobra.Command, vm *types.VM, snapCfg types.Sn } cfg := snapCfg.Config cfg.Network = vm.Config.Network - // Host-side policy stays with the VM, like Network; the snapshot's knobs describe its source VM. cfg.CPUWeight = vm.Config.CPUWeight cfg.CPUQuotaUs = vm.Config.CPUQuotaUs cfg.CPUPeriodUs = vm.Config.CPUPeriodUs cfg.CPUBurstUs = vm.Config.CPUBurstUs + cfg.CPUSetCPUs = vm.Config.CPUSetCPUs restoreMode, err := restoreModeFromFlags(cmd) if err != nil { return nil, err @@ -210,7 +214,7 @@ func sanitizeVMName(image string) string { repo := strings.TrimPrefix(ref.Context().RepositoryStr(), "library/") n := "cocoon-" + strings.ReplaceAll(repo, "/", "-") - // Skip digest (too long); use tag if not latest. + // Skip digest — too long for a VM name. if tag, ok := ref.(name.Tag); ok && tag.TagStr() != "latest" { n += "-" + tag.TagStr() } diff --git a/cmd/meta/convert/convert.go b/cmd/meta/convert/convert.go index cf21e9ed..364b474f 100644 --- a/cmd/meta/convert/convert.go +++ b/cmd/meta/convert/convert.go @@ -1,6 +1,4 @@ -// Package convert is the explicit offline engine cutover (design §6): a -// standalone fsync-first manifest is the only recovery authority, targets -// are fresh, sources retire aside, and every step is crash-rerunnable. +// Package convert is the explicit offline engine cutover (design §6): a standalone fsync-first manifest is the only recovery authority, targets are fresh, sources retire aside, and every step is crash-rerunnable. package convert import ( @@ -141,13 +139,10 @@ func newManifest(ctx context.Context, spec Spec, target string, src meta.Store) return m, nil } -// checkQuiesced is §6's advisory activity check: an in-flight source write -// means live cocoon processes, and converting under them could lose writes -// landing on a namespace already marked done. +// checkQuiesced is §6's advisory activity check: an in-flight source write means live cocoon processes, and converting under them could lose writes landing on a namespace already marked done. func checkQuiesced(ctx context.Context, spec Spec, target string, src meta.Store) error { if target == config.MetaBackendJSON { - // sqlite source: an empty durable transaction fails ErrBusy while a - // writer is mid-flight. + // sqlite source: an empty durable transaction fails ErrBusy while a writer is mid-flight. probeCtx, cancel := context.WithTimeout(ctx, time.Second) defer cancel() err := src.Update(probeCtx, meta.Scope{Write: spec.Decls[0].Name}, meta.CommitDurable, func(meta.Writer) error { return nil }) @@ -157,8 +152,7 @@ func checkQuiesced(ctx context.Context, spec Spec, target string, src meta.Store return nil } for _, jns := range spec.JSON { - // Persistent form: the engine's own locks are persistent, and a - // transient probe would delete the shared lock file on release. + // Persistent form: the engine's own locks are persistent, and a transient probe would delete the shared lock file on release. l := flock.New(jns.LockPath) ok, err := l.TryLock(ctx) if err != nil { @@ -174,8 +168,7 @@ func checkQuiesced(ctx context.Context, spec Spec, target string, src meta.Store return nil } -// ensureJSONDirs creates namespace dirs for subsystems that never ran — -// their absence means empty, and the engine's flock needs the dir to exist. +// ensureJSONDirs creates namespace dirs for subsystems that never ran — their absence means empty, and the engine's flock needs the dir to exist. func ensureJSONDirs(spec Spec) error { dirs := make([]string, 0, len(spec.JSON)) for _, jns := range spec.JSON { @@ -189,8 +182,7 @@ func openSource(spec Spec, target string) (meta.Store, error) { if target == config.MetaBackendSQLite { return metajson.Open(spec.JSON...) } - // The driver would create an empty file on first touch; a missing source - // must fail before that. + // The driver would create an empty file on first touch; a missing source must fail before that. if !utils.FileExists(spec.DBPath) { return nil, fmt.Errorf("no sqlite store at %s to convert from", spec.DBPath) } @@ -208,8 +200,7 @@ func openTarget(ctx context.Context, spec Spec, target string) (meta.Store, erro } func convertNamespace(ctx context.Context, src, dst meta.Store, ns metasqlite.Namespace, rec *NSRecord, m *Manifest, spec Spec) error { - // A committed-but-unmarked target (crash window) is re-verified and - // claimed, never redone (§6). + // A committed-but-unmarked target (crash window) is re-verified and claimed, never redone (§6). dstDigest, dstCount, err := canonicalDigest(ctx, dst, ns) if err != nil { return err @@ -239,9 +230,7 @@ func convertNamespace(ctx context.Context, src, dst meta.Store, ns metasqlite.Na return crashStep("ns-done") } -// duplicateGeneration copies a freshly written json target to its .prev so -// the imported data survives a later torn main (§9: never less resilient -// than steady state). +// duplicateGeneration copies a freshly written json target to its .prev so the imported data survives a later torn main (§9: never less resilient than steady state). func duplicateGeneration(spec Spec, nsName string) error { jns, ok := findJSON(spec, nsName) if !ok { @@ -319,9 +308,7 @@ func copyNamespace(ctx context.Context, src, dst meta.Store, ns metasqlite.Names }) } -// verifyNames is §6's referential name check: every name entry must point at -// an existing record. Table names are the record-SPI convention shared by -// every namespace declaration. +// verifyNames is §6's referential name check: every name entry must point at an existing record. Table names are the record-SPI convention shared by every namespace declaration. func verifyNames(ctx context.Context, s meta.Store, ns metasqlite.Namespace) error { if !slices.Contains(ns.Tables, "names") { return nil @@ -342,8 +329,7 @@ func verifyNames(ctx context.Context, s meta.Store, ns metasqlite.Namespace) err }) } -// canonicalDigest is the engine-neutral namespace fingerprint (§6): rows -// sorted by id per table, tables in declaration order; count is records only. +// canonicalDigest is the engine-neutral namespace fingerprint (§6): rows sorted by id per table, tables in declaration order; count is records only. func canonicalDigest(ctx context.Context, s meta.Store, ns metasqlite.Namespace) (string, int, error) { h := sha256.New() count := 0 @@ -371,9 +357,7 @@ func canonicalDigest(ctx context.Context, s meta.Store, ns metasqlite.Namespace) return hex.EncodeToString(h.Sum(nil)), count, nil } -// retireSources aside-renames every source file after full verification; a -// sqlite source checkpoints to a single file first (§6). Runs with both -// engines closed; already-renamed files are skipped, so a crash here reruns. +// retireSources aside-renames every source file after full verification; a sqlite source checkpoints to a single file first (§6). Runs with both engines closed; already-renamed files are skipped, so a crash here reruns. func retireSources(ctx context.Context, spec Spec, target string) error { if target == config.MetaBackendJSON && utils.FileExists(spec.DBPath) { if err := metasqlite.Checkpoint(ctx, spec.DBPath); err != nil { diff --git a/cmd/meta/handler.go b/cmd/meta/handler.go index da17cad7..0d32adae 100644 --- a/cmd/meta/handler.go +++ b/cmd/meta/handler.go @@ -36,8 +36,7 @@ func (h Handler) InitStore(cmd *cobra.Command, _ []string) error { func (h Handler) Convert(cmd *cobra.Command, _ []string) error { ctx, conf := h.Init(cmd) - // The configured backend is the sole target authority (§6): convert - // always moves the OTHER engine's data into the effective backend. + // The configured backend is the sole target authority (§6): convert always moves the OTHER engine's data into the effective backend. target := cmp.Or(conf.MetaBackend, config.MetaBackendSQLite) dbPath := cmdcore.MetaDBPath(conf) spec := convert.Spec{ diff --git a/cmd/root.go b/cmd/root.go index c1e63f7b..388a46c3 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -81,6 +81,7 @@ func newRootCmd() *cobra.Command { // Empty default keeps the key registered — AutomaticEnv only binds registered keys. viper.SetDefault("meta_backend", "") viper.SetDefault("cgroup_parent", cgroup.DefaultParent) + viper.SetDefault("cgroup_cpus", "") viper.SetDefault("log.level", "info") viper.SetDefault("log.max_size", 500) viper.SetDefault("log.max_age", 28) diff --git a/cmd/storebench/main.go b/cmd/storebench/main.go index 226caf08..7d17dec9 100644 --- a/cmd/storebench/main.go +++ b/cmd/storebench/main.go @@ -37,6 +37,7 @@ func (c benchConfig) LogDir() string { return c.dir } func (c benchConfig) VMRunDir(id string) string { return filepath.Join(c.dir, id) } func (c benchConfig) VMLogDir(id string) string { return filepath.Join(c.dir, id) } func (c benchConfig) CgroupParentDir() string { return filepath.Join(c.dir, "cgroup") } +func (c benchConfig) CgroupCPUFence() string { return "" } func main() { if len(os.Args) < 3 { @@ -142,8 +143,7 @@ func runCreate(ctx context.Context, engine string, workers, per, resident int, d return nil } -// runWorker performs `per` creates (reserve placeholder + finalize to -// running) — the meta half of one VM creation each. +// runWorker performs `per` creates (reserve placeholder + finalize to running) — the meta half of one VM creation each. func runWorker(ctx context.Context, engine, prefix string, per int, dir string) error { b, err := openBackend(ctx, engine, dir) if err != nil { @@ -164,8 +164,7 @@ func runWorker(ctx context.Context, engine, prefix string, per int, dir string) return nil } -// runMicro times one engine primitive per durable (or relaxed) transaction: -// the §9 microbench matrix. Seeding batches 1000 rows per transaction. +// runMicro times one engine primitive per durable (or relaxed) transaction: the §9 microbench matrix. Seeding batches 1000 rows per transaction. func runMicro(ctx context.Context, engine, op string, n, ops int, dir string) error { store, err := openStore(ctx, engine, dir) if err != nil { @@ -284,8 +283,7 @@ func argDir(i int) string { return dir } -// uniquePayload defeats any identical-bytes write elision so replace ops -// measure a REAL durable commit. +// uniquePayload defeats any identical-bytes write elision so replace ops measure a REAL durable commit. func uniquePayload(i int) json.RawMessage { return json.RawMessage(fmt.Sprintf(`{"name":"bench","state":"running","seq":%d}`, i)) } diff --git a/cmd/vm/commands.go b/cmd/vm/commands.go index 31f9c32b..8ea2d35b 100644 --- a/cmd/vm/commands.go +++ b/cmd/vm/commands.go @@ -330,6 +330,7 @@ func addVMFlags(cmd *cobra.Command) { cmd.Flags().Int64("cpu-quota-us", 0, "cgroup cpu.max quota in us per period (0 = vCPU count x period)") cmd.Flags().Int64("cpu-period-us", 0, "cgroup cpu.max period in us (0 = 100000)") cmd.Flags().Int64("cpu-burst-us", 0, "cgroup cpu.max.burst credit in us (0 = none)") + cmd.Flags().String("cpuset-cpus", "", "pin the VM to host cpus (kernel cpu-list, e.g. 0-3); non-work-conserving, empty = anywhere inside the cgroup_cpus fence") cmd.Flags().String("network", "", "CNI conflist name (empty = default); mutually exclusive with --bridge") cmd.Flags().String("bridge", "", "use TAP-on-bridge instead of CNI (value is bridge device, e.g. cni0); VM gets IP via DHCP from the bridge") cmd.Flags().String("user", "root", "guest username for cloud-init (cloudimg only)") @@ -350,6 +351,7 @@ func addCloneFlags(cmd *cobra.Command) { cmd.Flags().Int64("cpu-quota-us", 0, "cgroup cpu.max quota in us per period (0 = vCPU count x period)") cmd.Flags().Int64("cpu-period-us", 0, "cgroup cpu.max period in us (0 = 100000)") cmd.Flags().Int64("cpu-burst-us", 0, "cgroup cpu.max.burst credit in us (0 = none)") + cmd.Flags().String("cpuset-cpus", "", "pin the clone to host cpus (kernel cpu-list; empty = anywhere inside the cgroup_cpus fence)") cmd.Flags().String("network", "", "CNI conflist name (empty = inherit from source VM)") cmd.Flags().String("bridge", "", "use TAP-on-bridge instead of CNI (value is bridge device, e.g. cni0)") cmd.Flags().Bool("no-direct-io", false, "disable O_DIRECT on writable disks (inherit from snapshot if not set)") diff --git a/cmd/vm/debug.go b/cmd/vm/debug.go index 2e709944..3b02d565 100644 --- a/cmd/vm/debug.go +++ b/cmd/vm/debug.go @@ -8,7 +8,9 @@ import ( "github.com/spf13/cobra" + "github.com/cocoonstack/cocoon/cgroup" cmdcore "github.com/cocoonstack/cocoon/cmd/core" + "github.com/cocoonstack/cocoon/config" "github.com/cocoonstack/cocoon/hypervisor" "github.com/cocoonstack/cocoon/hypervisor/cloudhypervisor" "github.com/cocoonstack/cocoon/hypervisor/firecracker" @@ -23,6 +25,7 @@ type chDebugSpec struct { CHBin string MaxCPU int Balloon int + Allowed []int } func (h Handler) Debug(cmd *cobra.Command, args []string) error { @@ -65,7 +68,7 @@ func (h Handler) Debug(cmd *cobra.Command, args []string) error { return nil } - printCHDebug(buildCHDebugSpec(cmd, storageConfigs, boot, vmCfg)) + printCHDebug(buildCHDebugSpec(cmd, conf, storageConfigs, boot, vmCfg)) return nil } @@ -131,7 +134,7 @@ func printFCDebug(configs []*types.StorageConfig, boot *types.BootConfig, vmCfg fmt.Println(" -d '{\"action_type\": \"InstanceStart\"}'") } -func buildCHDebugSpec(cmd *cobra.Command, storageConfigs []*types.StorageConfig, boot *types.BootConfig, vmCfg *types.VMConfig) chDebugSpec { +func buildCHDebugSpec(cmd *cobra.Command, conf *config.Config, storageConfigs []*types.StorageConfig, boot *types.BootConfig, vmCfg *types.VMConfig) chDebugSpec { maxCPU, _ := cmd.Flags().GetInt("max-cpu") balloon, _ := cmd.Flags().GetInt("balloon") cowPath, _ := cmd.Flags().GetString("cow") @@ -144,10 +147,12 @@ func buildCHDebugSpec(cmd *cobra.Command, storageConfigs []*types.StorageConfig, case balloon == 0: balloon = int(size >> 20) //nolint:mnd } + allowed := cgroup.EffectiveCPUs(vmCfg.CPUSetCPUs, conf.CgroupCPUs) return chDebugSpec{ Configs: storageConfigs, Boot: boot, VMCfg: vmCfg, + Allowed: allowed, CowPath: cowPath, CHBin: chBin, MaxCPU: maxCPU, @@ -167,7 +172,7 @@ func printCHDebug(s chDebugSpec) { debugConfigs := slices.Concat(s.Configs, []*types.StorageConfig{ {Path: s.CowPath, RO: false, Serial: hypervisor.CowSerial}, }) - diskArgs := cloudhypervisor.DebugDiskCLIArgs(debugConfigs, cpu, diskQueueSize, noDirectIO) + diskArgs := cloudhypervisor.DebugDiskCLIArgs(debugConfigs, cpu, diskQueueSize, noDirectIO, s.Allowed) cocoonLayers := strings.Join(cloudhypervisor.ReverseLayerSerials(s.Configs), ",") cmdline := hypervisor.BuildBaseCmdline("console=hvc0 loglevel=3", cocoonLayers, hypervisor.CowSerial, nil, s.VMCfg.Name, nil) @@ -198,7 +203,7 @@ func printCHDebug(s chDebugSpec) { fmt.Printf("%s \\\n", s.CHBin) fmt.Printf(" --firmware %s \\\n", s.Boot.FirmwarePath) fmt.Print(" --disk \\\n") - diskArgs := cloudhypervisor.DebugDiskCLIArgs([]*types.StorageConfig{{Path: s.CowPath, RO: false}}, cpu, diskQueueSize, noDirectIO) + diskArgs := cloudhypervisor.DebugDiskCLIArgs([]*types.StorageConfig{{Path: s.CowPath, RO: false}}, cpu, diskQueueSize, noDirectIO, s.Allowed) fmt.Printf(" \"%s\" \\\n", diskArgs[0]) } printCommonCHArgs(s) diff --git a/cmd/vm/reseed.go b/cmd/vm/reseed.go index b0e9162b..4498f016 100644 --- a/cmd/vm/reseed.go +++ b/cmd/vm/reseed.go @@ -37,8 +37,7 @@ func (h Handler) Reseed(cmd *cobra.Command, args []string) error { return reseedVM(ctx, vm, regenMachineID) } -// reseedAfterResume fires the best-effort reseed, re-inspecting only when the in-process record lacks VsockSocket — a zero value would silently no-op. -// It hands the reseed to a detached child by default: the vsock dial waits out the guest's post-resume wakeup (tens of ms, growing with snapshot age), which would otherwise sit on every clone/restore critical path. +// reseedAfterResume fires the best-effort reseed, re-inspecting only when the in-process record lacks VsockSocket — a zero value would silently no-op. It hands the reseed to a detached child by default: the vsock dial waits out the guest's post-resume wakeup (tens of ms, growing with snapshot age), which would otherwise sit on every clone/restore critical path. func (h Handler) reseedAfterResume(ctx context.Context, conf *config.Config, hyper hypervisor.Hypervisor, vm *types.VM, regenMachineID bool) { if vm.VsockSocket == "" { vm = refreshVM(ctx, hyper, vm) diff --git a/cmd/vm/run.go b/cmd/vm/run.go index 3bac69f2..b81e3fe2 100644 --- a/cmd/vm/run.go +++ b/cmd/vm/run.go @@ -234,8 +234,7 @@ func (h Handler) restoreFromDir(ctx context.Context, cmd *cobra.Command, conf *c if err != nil { return err } - // The envelope's pins land on the VM record inside restore; the digest - // locks keep image GC away until they are committed. + // The envelope's pins land on the VM record inside restore; the digest locks keep image GC away until they are committed. releasePins, err := cmdcore.PinEnvelopeBlobs(ctx, conf, cfg.ImageBlobIDs) if err != nil { return err @@ -505,8 +504,7 @@ func validateBootCompat(conf *config.Config, vmCfg *types.VMConfig, bootCfg *typ return nil } -// pinResolvedBlobs holds the resolved image's digest locks until the reserve -// commits; the empty set (bridge/dataless) pins nothing. +// pinResolvedBlobs holds the resolved image's digest locks until the reserve commits; the empty set (bridge/dataless) pins nothing. func pinResolvedBlobs(ctx context.Context, backends []imagebackend.Images, ref string, blobIDs map[string]struct{}) (func(), error) { if len(blobIDs) == 0 { return func() {}, nil diff --git a/config/config.go b/config/config.go index 5a39b09c..a1c881ec 100644 --- a/config/config.go +++ b/config/config.go @@ -63,8 +63,10 @@ type Config struct { // TerminateGracePeriodSeconds: SIGTERM→SIGKILL window when force-killing CH. Default: 5. TerminateGracePeriodSeconds int `json:"terminate_grace_period_seconds" mapstructure:"terminate_grace_period_seconds"` // CgroupParent: cgroup v2 slice under /sys/fs/cgroup holding per-VM CPU scopes. Default: cocoon.slice. - CgroupParent string `json:"cgroup_parent" mapstructure:"cgroup_parent"` - Log *coretypes.ServerLogConfig `json:"log" mapstructure:"log"` + CgroupParent string `json:"cgroup_parent" mapstructure:"cgroup_parent"` + // CgroupCPUs: host cpu list fencing the whole VM population (e.g. "0-14" reserves core 15); empty = all cores. + CgroupCPUs string `json:"cgroup_cpus,omitempty" mapstructure:"cgroup_cpus"` + Log *coretypes.ServerLogConfig `json:"log" mapstructure:"log"` // Metering selects the lifecycle-event recorder backend. Metering MeteringConfig `json:"metering,omitzero" mapstructure:"metering"` } @@ -92,6 +94,9 @@ func (c *Config) CgroupParentDir() string { return filepath.Join(cgroup.Root, cmp.Or(c.CgroupParent, cgroup.DefaultParent)) } +// CgroupCPUFence returns the configured fence cpu list (empty = all cores). +func (c *Config) CgroupCPUFence() string { return c.CgroupCPUs } + // Validate checks that all config fields are within acceptable ranges. // Should be called once at startup after unmarshalling. func (c *Config) Validate() error { @@ -116,6 +121,9 @@ func (c *Config) Validate() error { if c.MetaBackend != "" && c.MetaBackend != MetaBackendJSON && c.MetaBackend != MetaBackendSQLite { return fmt.Errorf("meta_backend %q is not one of json|sqlite", c.MetaBackend) } + if _, err := cgroup.ParseCPUList(c.CgroupCPUs); err != nil { + return fmt.Errorf("cgroup_cpus: %w", err) + } return nil } diff --git a/docs/cli.md b/docs/cli.md index fc00b93f..e95e4a38 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -79,6 +79,8 @@ Config-file / env-only keys (no CLI flag): | Key | Env Variable | Default | Description | | ------------ | ------------------- | ------- | ---------------------------------------------------------------------- | | `pull_conns` | `COCOON_PULL_CONNS` | `8` | Concurrent HTTP Range connections per cloud-image download (`image pull`); raise for fat pipes, lower to be gentle on the registry | +| `cgroup_parent` | `COCOON_CGROUP_PARENT` | `cocoon.slice` | cgroup v2 slice under `/sys/fs/cgroup` holding the per-VM CPU scopes; see [CPU Isolation](vm.md#cpu-isolation-cgroup-v2) | +| `cgroup_cpus` | `COCOON_CGROUP_CPUS` | empty (all cores) | Host cpu list fencing the whole VM population (e.g. `0-14` reserves core 15 for the host); kernel cpu-list syntax | ## VM Flags @@ -88,7 +90,7 @@ Applies to `cocoon vm create`, `cocoon vm run`, and `cocoon vm debug`: | ----------- | ---------------- | --------------------------------------------- | | `--fc` | `false` | Use Firecracker backend (OCI images only) | | `--name` | `cocoon-` | VM name | -| `--cpu` | `2` | Boot CPUs (must not exceed host core count) | +| `--cpu` | `2` | Boot CPUs — also the VM's hard CPU cap (quota = N cores unless overridden; see [CPU Isolation](vm.md#cpu-isolation-cgroup-v2)) | | `--memory` | `1G` | Memory size (e.g., 512M, 2G) | | `--storage` | `10G` | COW disk size (e.g., 10G, 20G) | | `--nics` | `1` | Number of network interfaces (0 = no network) | @@ -103,6 +105,11 @@ Applies to `cocoon vm create`, `cocoon vm run`, and `cocoon vm debug`: | `--windows` | `false` | Windows guest (UEFI boot, kvm_hyperv=on, no cidata) | | `--shared-memory` | `false` | Enable CH `memory shared=on`; required for later `vm fs attach` (CH only, fixed for VM lifetime) | | `--hugepages` | `false` | Back guest memory with hugetlbfs (CH only, fixed for VM lifetime); snapshots of such a VM restore via eager copy, never mmap | +| `--cpu-weight` | `0` (= vCPU count) | cgroup `cpu.weight` 1..10000 — work-conserving share under host contention | +| `--cpu-quota-us` | `0` (= vCPU count × period) | cgroup `cpu.max` quota in µs per period — the hard CPU ceiling | +| `--cpu-period-us` | `0` (= 100000) | cgroup `cpu.max` period in µs | +| `--cpu-burst-us` | `0` (none) | cgroup `cpu.max.burst` credit in µs; kernel requires burst ≤ quota | +| `--cpuset-cpus` | empty (anywhere in fence) | Pin the VM to specific host cpus (kernel cpu-list, e.g. `0-3`); non-work-conserving, explicit opt-in | ### Clone Flags @@ -117,6 +124,7 @@ Applies to `cocoon vm clone`: | `--network` | empty (inherit) | CNI conflist name (empty = inherit from source VM) | | `--bridge` | empty | TAP-on-bridge mode (value is bridge device); mutually exclusive with `--network` | | `--no-direct-io` | `false` (inherit) | Disable O_DIRECT on writable disks (inherit from snapshot if not set) | +| `--cpu-weight` / `--cpu-quota-us` / `--cpu-period-us` / `--cpu-burst-us` / `--cpuset-cpus` | `0` / empty (defaults, **not** inherited) | The clone's cgroup CPU policy; a snapshot's knobs record its source VM and are never applied — omit for Guaranteed-at-N defaults | | `--restore-mode` | `mmap` for plain private-anon snapshots, else `copy` | Memory restore mode: `copy`, `ondemand` (UFFD) or `mmap` (CoW map, shares page cache across clones); CH only, non-copy modes require a CH build with matching support — an older CH silently ignores the field and restores by copy; hugepages/shared snapshots degrade `mmap` to `copy` with a warning | | `--pull` | `false` | Auto-pull base image if not found locally (for cross-node clone) | | `--from-dir` | empty | Clone from a snapshot directory (must contain `snapshot.json`); mutually exclusive with positional `SNAPSHOT` | diff --git a/docs/index.md b/docs/index.md index c7568eda..606b21c5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -48,6 +48,7 @@ cocoon CLI ──► images: OCI (EROFS layers, direct boot) | cloudimg (qcow2, - **UEFI boot** — CLOUDHV.fd firmware by default; direct kernel boot for OCI images (auto-detected) - **COW overlays** — copy-on-write disks backed by shared base images (raw for OCI, qcow2 for cloud images) - **CNI networking** — automatic NIC creation via CNI plugins, multi-NIC support, per-VM IP allocation +- **CPU isolation** — every VM runs in its own cgroup v2 scope with Guaranteed-at-N defaults (`--cpu` is a hard cap); raw weight/quota/burst knobs, an optional host-core fence (`cgroup_cpus`), and per-VM pinning (`--cpuset-cpus`); see [CPU Isolation](vm.md#cpu-isolation-cgroup-v2) - **Multi-queue virtio-net** — TAP devices created with per-vCPU queue pairs; configurable ring depth (`--queue-size`, default 512); TSO/UFO/csum offload enabled by default - **TC redirect I/O path** — veth ↔ TAP wired via ingress qdisc + mirred redirect (no bridge in the data path) - **DNS configuration** — custom DNS servers injected into VMs via kernel cmdline (OCI) or cloud-init network-config (cloudimg) @@ -58,7 +59,7 @@ cocoon CLI ──► images: OCI (EROFS layers, direct boot) | cloudimg (qcow2, - **Memory balloon** — 25% of memory returned via virtio-balloon (deflate-on-OOM, free-page reporting) when memory >= 256 MiB - **Graceful shutdown** — ACPI power-button for UEFI VMs with configurable timeout, fallback to SIGTERM → SIGKILL - **Interactive console** — `cocoon vm console` with bidirectional PTY relay, SSH-style escape sequences (`~.` disconnect, `~?` help), configurable escape character, SIGWINCH propagation -- **Snapshot & clone** — `cocoon snapshot save` captures a running VM's full state (memory, disks, config); `cocoon vm clone` restores it as a new VM with fresh network and identity; all resources (CPU, memory, storage, NIC count) inherit verbatim from the snapshot +- **Snapshot & clone** — `cocoon snapshot save` captures a running VM's full state (memory, disks, config); `cocoon vm clone` restores it as a new VM with fresh network and identity; guest resources (CPU, memory, storage, NIC count) inherit verbatim from the snapshot, while host-side cgroup CPU policy comes from clone flags (see [CPU Isolation](vm.md#cpu-isolation-cgroup-v2)) - **Snapshot export & import** — `cocoon snapshot export` packages a snapshot into a portable `.tar` archive (`.tar.gz` with `--gzip`, sparse-aware pax headers); `cocoon snapshot import` restores it on another host or cluster; supports piping via stdout/stdin for direct host-to-host transfer; `--to-dir` writes a directory form (with `snapshot.json` envelope) for NFS / rsync-friendly handoff - **Clone / restore from a directory** — `cocoon vm clone --from-dir DIR` and `cocoon vm restore --from-dir DIR` consume any directory containing a `snapshot.json` envelope without first registering the snapshot in the local DB; the dir is treated as read-only so multi-VM golden-image use cases work without copying - **Live status monitoring** — `cocoon vm status` watches VM state changes in real time via fsnotify, with refresh mode (top-like) and event-stream mode (append-only, for scripting and vk-cocoon integration) diff --git a/docs/vm.md b/docs/vm.md index df21efce..0af6c229 100644 --- a/docs/vm.md +++ b/docs/vm.md @@ -29,6 +29,58 @@ States, shutdown behavior, cloud-init first boot, data disks, performance tuning | `--force` | `false` | Skip graceful ACPI shutdown, immediate kill | | `--timeout` | `0` (use config default) | ACPI shutdown timeout in seconds | +## CPU Isolation (cgroup v2) + +Every VM's hypervisor process is spawned directly into its own cgroup v2 scope (`/vm-.scope`, default parent `cocoon.slice`) via `CLONE_INTO_CGROUP` — vCPU threads, virtio queue workers, and io_uring kernel workers all land inside. The vCPU count alone does not bound host consumption (a 1-vCPU VM under I/O measures 111–113% of a core); the scope does. + +Defaults are Kubernetes-style Guaranteed at N for `--cpu N`: quota = N cores (`--cpu` is a hard cap, not just a topology hint), weight = N (proportional share under contention), no burst. Override any raw knob: lower `--cpu-weight` for burstable overcommit, raise `--cpu-quota-us` to give the VMM's I/O service headroom beyond the guest's budget, add `--cpu-burst-us` for bounded spikes. Two caveats at defaults: a saturated VM doing I/O pays its virtio service out of the N-core budget (~13% floor case), and `cpu.weight` only arbitrates real runqueue contention — a parent bandwidth limit is consumed first-come-first-served, not by weight. + +`cgroup_cpus` fences the whole VM population onto a host cpu subset (e.g. `0-14` on a 16-core host keeps core 15 for the OS, the API consumer, and clone/wake execution). `--cpuset-cpus` pins one VM to specific cores inside the fence. Both are validated by cocoon against the effective sets — the kernel silently degrades ungrantable cpuset requests rather than failing — and shrinking the fence is refused while a running VM's placement conflicts. Clearing `cgroup_cpus` converges: the stale fence is reset on the next launch. + +cgroup knobs are host-side policy, like networking: snapshots record the source VM's values but never apply them — a clone takes its policy from flags (defaults otherwise), restore keeps the target VM's. Scopes are removed when the VMM dies (stop, hibernate, delete, crash convergence) and orphans are swept by `cocoon gc`. `cocoon vm list` shows per-VM throttling as `THROTTLED` (`nr_throttled/throttled_usec` from `cpu.stat`). + +Requirements: cgroup v2 unified hierarchy with the `cpu` controller (kernel ≥ 5.14 for burst), running cocoon as root (production shape). Non-root works inside a systemd user slice with delegated controllers (`systemd-run --user --scope`), where user slices typically delegate `cpu` but not `cpuset` — fence/placement then fail preflight with the exact missing file named. + +### Recipes + +```bash +# Guaranteed at N (default): hard cap 2 cores, share 2, no burst +cocoon vm run --cpu 2 --memory 2G --name vm1 ghcr.io/cocoonstack/cocoon/ubuntu:24.04 + +# Burstable overcommit: reach 2 cores when idle, shrink by weight under pressure +cocoon vm run --cpu 2 --cpu-weight 25 --name burst1 ... + +# Headroom for virtio I/O service: guest keeps its full 2 cores under load +cocoon vm run --cpu 2 --cpu-quota-us 230000 --name io-heavy ... + +# Metered: 0.5-core long-run average, bounded 1-core spikes +cocoon vm run --cpu 1 --cpu-quota-us 50000 --cpu-burst-us 50000 --name metered ... + +# Pinning (NUMA / isolation-sensitive only — wastes idle cores) +cocoon vm run --cpu 2 --cpuset-cpus 2-3 --name pinned ... + +# Machine fence (config, not a flag): the fleet never touches core 15 +COCOON_CGROUP_CPUS=0-14 cocoon vm run ... + +# Clones never inherit snapshot policy — give it explicitly or get defaults +cocoon vm clone golden --name c1 # Guaranteed at N +cocoon vm clone golden --name c2 --cpu-weight 10 # explicit share +``` + +Rules of thumb: density → weight overcommit; single-VM performance → raised quota; billing semantics → quota + burst; pinning only when NUMA or isolation demands it. `cocoon vm list`'s `THROTTLED` column (count/total time from `cpu.stat`) shows who is hitting their cap. + +### Reserving CPU for the control plane + +The fence bounds the VMs; the caller's own work (clone, restore, the API consumer) is deliberately not cocoon's to manage — set it on the invoking service's systemd unit, which writes the same cgroup v2 files: + +```ini +# /etc/systemd/system/.service.d/cpu.conf +[Service] +CPUWeight=1000 # management plane wins contention on the VM cores +``` + +With `cgroup_cpus=0-14`, the reserved core 15 has no VM competition and acts as the control plane's fast lane without pinning; prefer that over `AllowedCPUs=15`, which would confine clone's multi-core memory restore to one core. One-off commands: `systemd-run --scope -p CPUWeight=1000 cocoon vm clone ...`. + ## Performance Tuning - **Hugepages** (Cloud Hypervisor only): opt-in via `vm create --hugepages`; VM memory is backed by 2 MiB hugepages for reduced TLB pressure, and in exchange snapshots of that VM restore via eager copy only (the mmap fast path needs plain private-anon memory). Firecracker rejects `--hugepages`: FC cannot restore a hugetlbfs-backed snapshot, which would break hibernate/clone diff --git a/hypervisor/backend.go b/hypervisor/backend.go index b26d0999..d033eb70 100644 --- a/hypervisor/backend.go +++ b/hypervisor/backend.go @@ -61,6 +61,7 @@ type BackendConfig interface { VMRunDir(id string) string VMLogDir(id string) string CgroupParentDir() string + CgroupCPUFence() string } var _ Supervisable = (*Backend)(nil) diff --git a/hypervisor/cloudhypervisor/args.go b/hypervisor/cloudhypervisor/args.go index a3d95c1b..db7bd32b 100644 --- a/hypervisor/cloudhypervisor/args.go +++ b/hypervisor/cloudhypervisor/args.go @@ -31,15 +31,15 @@ func (b *kvBuilder) addIf(cond bool, kv string) { } // DebugDiskCLIArgs uses the same storage-to-disk mapping as launch. -func DebugDiskCLIArgs(storageConfigs []*types.StorageConfig, cpuCount, diskQueueSize int, noDirectIO bool) []string { +func DebugDiskCLIArgs(storageConfigs []*types.StorageConfig, cpuCount, diskQueueSize int, noDirectIO bool, allowed []int) []string { args := make([]string, 0, len(storageConfigs)) for _, storageConfig := range storageConfigs { - args = append(args, diskToCLIArg(storageConfigToDisk(storageConfig, cpuCount, diskQueueSize, noDirectIO))) + args = append(args, diskToCLIArg(storageConfigToDisk(storageConfig, cpuCount, diskQueueSize, noDirectIO, allowed))) } return args } -func buildVMConfig(rec *hypervisor.VMRecord, consoleSockPath string) *chVMConfig { +func buildVMConfig(rec *hypervisor.VMRecord, consoleSockPath string, allowed []int) *chVMConfig { cpu := rec.Config.CPU mem := rec.Config.Memory @@ -64,7 +64,7 @@ func buildVMConfig(rec *hypervisor.VMRecord, consoleSockPath string) *chVMConfig } for _, storageConfig := range activeDisks(rec) { - cfg.Disks = append(cfg.Disks, storageConfigToDisk(storageConfig, cpu, rec.Config.DiskQueueSize, rec.Config.NoDirectIO)) + cfg.Disks = append(cfg.Disks, storageConfigToDisk(storageConfig, cpu, rec.Config.DiskQueueSize, rec.Config.NoDirectIO, allowed)) } for _, nc := range rec.NetworkConfigs { @@ -190,7 +190,7 @@ func effectiveDirectIO(sc *types.StorageConfig, noDirectIO bool) bool { return !sc.RO && !noDirectIO } -func storageConfigToDisk(storageConfig *types.StorageConfig, cpuCount, diskQueueSize int, noDirectIO bool) chDisk { +func storageConfigToDisk(storageConfig *types.StorageConfig, cpuCount, diskQueueSize int, noDirectIO bool, allowed []int) chDisk { if diskQueueSize <= 0 { diskQueueSize = defaultDiskQueueSize } @@ -215,14 +215,24 @@ func storageConfigToDisk(storageConfig *types.StorageConfig, cpuCount, diskQueue } if cpuCount > 1 && !storageConfig.RO { - d.QueueAffinity = make([]chQueueAffinity, cpuCount) - for i := range d.QueueAffinity { - d.QueueAffinity[i] = chQueueAffinity{QueueIndex: i, HostCPUs: []int{i}} - } + d.QueueAffinity = queueAffinity(cpuCount, allowed) } return d } +// queueAffinity spreads queue i over host CPUs, clamped to the allowed set (fence/placement) so no target lands on a core the scope cannot run on; nil allowed keeps the identity mapping. +func queueAffinity(cpuCount int, allowed []int) []chQueueAffinity { + qa := make([]chQueueAffinity, cpuCount) + for i := range qa { + host := i + if len(allowed) > 0 { + host = allowed[i%len(allowed)] + } + qa[i] = chQueueAffinity{QueueIndex: i, HostCPUs: []int{host}} + } + return qa +} + func diskToCLIArg(d chDisk) string { var b kvBuilder b.add("path=" + d.Path) diff --git a/hypervisor/cloudhypervisor/args_cpuset_test.go b/hypervisor/cloudhypervisor/args_cpuset_test.go new file mode 100644 index 00000000..67914981 --- /dev/null +++ b/hypervisor/cloudhypervisor/args_cpuset_test.go @@ -0,0 +1,28 @@ +package cloudhypervisor + +import ( + "slices" + "testing" +) + +func TestQueueAffinityClamp(t *testing.T) { + tests := []struct { + name string + cpu int + allowed []int + want [][]int + }{ + {name: "identity without allowance", cpu: 3, want: [][]int{{0}, {1}, {2}}}, + {name: "clamped round-robin", cpu: 4, allowed: []int{8, 9}, want: [][]int{{8}, {9}, {8}, {9}}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + qa := queueAffinity(tt.cpu, tt.allowed) + for i, a := range qa { + if a.QueueIndex != i || !slices.Equal(a.HostCPUs, tt.want[i]) { + t.Errorf("queue %d: got %v, want %v", i, a.HostCPUs, tt.want[i]) + } + } + }) + } +} diff --git a/hypervisor/cloudhypervisor/clone.go b/hypervisor/cloudhypervisor/clone.go index 408b8615..f7327ee1 100644 --- a/hypervisor/cloudhypervisor/clone.go +++ b/hypervisor/cloudhypervisor/clone.go @@ -12,6 +12,7 @@ import ( "github.com/projecteru2/core/log" + "github.com/cocoonstack/cocoon/cgroup" "github.com/cocoonstack/cocoon/hypervisor" "github.com/cocoonstack/cocoon/network" "github.com/cocoonstack/cocoon/types" @@ -28,6 +29,7 @@ type cloneResumeOpts struct { dataDisks []*types.StorageConfig networkConfigs []*types.NetworkConfig snapshotCfg *chVMConfig + allowedCPUs []int } func (ch *CloudHypervisor) Clone(ctx context.Context, vmID string, vmCfg *types.VMConfig, net types.NetSetup, snapshotConfig *types.SnapshotConfig, snapshot io.Reader) (*types.VM, error) { @@ -97,6 +99,7 @@ func (ch *CloudHypervisor) cloneAfterExtractParsed(ctx context.Context, vmID str } consoleSock := hypervisor.ConsoleSockPath(runDir) + allowedCPUs := cgroup.EffectiveCPUs(vmCfg.CPUSetCPUs, ch.conf.CgroupCPUs) if err = patchCHConfig(chConfigPath, &patchOptions{ storageConfigs: patchStorageConfigs, netTAPs: netTAPs, @@ -105,6 +108,8 @@ func (ch *CloudHypervisor) cloneAfterExtractParsed(ctx context.Context, vmID str directBoot: directBoot, diskQueueSize: vmCfg.DiskQueueSize, noDirectIO: vmCfg.NoDirectIO, + cpu: vmCfg.CPU, + allowedCPUs: allowedCPUs, }); err != nil { return nil, fmt.Errorf("patch CH config: %w", err) } @@ -139,6 +144,7 @@ func (ch *CloudHypervisor) cloneAfterExtractParsed(ctx context.Context, vmID str dataDisks: newDataDisks, networkConfigs: networkConfigs, snapshotCfg: chCfg, + allowedCPUs: allowedCPUs, }); err != nil { return nil, err } @@ -182,13 +188,13 @@ func (ch *CloudHypervisor) restoreAndResumeClone(ctx context.Context, pid int, s if i < 0 { return fmt.Errorf("vm.add-disk (cidata): missing storage config") } - cidataDisk := storageConfigToDisk(opts.storageConfigs[i], opts.vmCfg.CPU, opts.vmCfg.DiskQueueSize, opts.vmCfg.NoDirectIO) + cidataDisk := storageConfigToDisk(opts.storageConfigs[i], opts.vmCfg.CPU, opts.vmCfg.DiskQueueSize, opts.vmCfg.NoDirectIO, opts.allowedCPUs) if err = addDiskVM(ctx, hc, cidataDisk); err != nil { return fmt.Errorf("vm.add-disk (cidata): %w", err) } } for _, sc := range opts.dataDisks { - if err = addDiskVM(ctx, hc, storageConfigToDisk(sc, opts.vmCfg.CPU, opts.vmCfg.DiskQueueSize, opts.vmCfg.NoDirectIO)); err != nil { + if err = addDiskVM(ctx, hc, storageConfigToDisk(sc, opts.vmCfg.CPU, opts.vmCfg.DiskQueueSize, opts.vmCfg.NoDirectIO, opts.allowedCPUs)); err != nil { return fmt.Errorf("vm.add-disk (data %s): %w", sc.Serial, err) } } diff --git a/hypervisor/cloudhypervisor/clone_test.go b/hypervisor/cloudhypervisor/clone_test.go index 02695bc5..632b3df3 100644 --- a/hypervisor/cloudhypervisor/clone_test.go +++ b/hypervisor/cloudhypervisor/clone_test.go @@ -11,6 +11,7 @@ import ( "sync" "testing" + "github.com/cocoonstack/cocoon/config" "github.com/cocoonstack/cocoon/hypervisor" "github.com/cocoonstack/cocoon/types" ) @@ -362,7 +363,7 @@ func TestRestoreAndResumeCloneHotplugsCidataByRole(t *testing.T) { {Path: "/run/cidata.img", RO: true, Role: types.StorageRoleCidata}, {Path: "/run/extra.raw", Role: types.StorageRoleData, Serial: "extra"}, } - ch := &CloudHypervisor{} + ch := &CloudHypervisor{conf: NewConfig(&config.Config{})} if err := ch.restoreAndResumeClone(t.Context(), 0, sock, t.TempDir(), &cloneResumeOpts{ vmCfg: &types.VMConfig{Config: types.Config{CPU: 2}}, storageConfigs: storageConfigs, diff --git a/hypervisor/cloudhypervisor/extend.go b/hypervisor/cloudhypervisor/extend.go index 0e95473f..ef62d8ef 100644 --- a/hypervisor/cloudhypervisor/extend.go +++ b/hypervisor/cloudhypervisor/extend.go @@ -13,6 +13,7 @@ import ( "github.com/projecteru2/core/log" + "github.com/cocoonstack/cocoon/cgroup" "github.com/cocoonstack/cocoon/extend/disk" "github.com/cocoonstack/cocoon/extend/fs" "github.com/cocoonstack/cocoon/extend/vfio" @@ -45,7 +46,7 @@ func (ch *CloudHypervisor) DiskAttach(ctx context.Context, vmRef string, spec di makeBody := func(rec *hypervisor.VMRecord) any { d := storageConfigToDisk(&types.StorageConfig{ Role: types.StorageRoleData, Path: path, Serial: spec.Name, RO: spec.ReadOnly, DirectIO: spec.DirectIO, - }, rec.Config.CPU, rec.Config.DiskQueueSize, rec.Config.NoDirectIO) + }, rec.Config.CPU, rec.Config.DiskQueueSize, rec.Config.NoDirectIO, cgroup.EffectiveCPUs(rec.Config.CPUSetCPUs, ch.conf.CgroupCPUs)) d.ID = id return d } diff --git a/hypervisor/cloudhypervisor/netresize.go b/hypervisor/cloudhypervisor/netresize.go index 9864dcd2..65554b16 100644 --- a/hypervisor/cloudhypervisor/netresize.go +++ b/hypervisor/cloudhypervisor/netresize.go @@ -33,9 +33,7 @@ func (ch *CloudHypervisor) NetResize(ctx context.Context, vmRef string, spec net return netresize.Result{}, err } defer unlock() - // Entrypoint discipline (design §5): a resize must not plumb NICs onto a - // VM whose delete was interrupted. Reload under the lock: a resize that - // won the lock first may have changed the NIC set after this one loaded. + // Entrypoint discipline (design §5): a resize must not plumb NICs onto a VM whose delete was interrupted. Reload under the lock: a resize that won the lock first may have changed the NIC set after this one loaded. if rec, err = ch.EntryGuardLoad(ctx, vmID); err != nil { return netresize.Result{}, err } diff --git a/hypervisor/cloudhypervisor/patch.go b/hypervisor/cloudhypervisor/patch.go index 83f0bd51..4cd12326 100644 --- a/hypervisor/cloudhypervisor/patch.go +++ b/hypervisor/cloudhypervisor/patch.go @@ -17,6 +17,8 @@ type patchOptions struct { directBoot bool diskQueueSize int noDirectIO bool + cpu int + allowedCPUs []int } // patchCHConfig patches specific fields in config.json while preserving all unknown fields that CH adds internally (platform, cpus.topology, etc.). @@ -77,6 +79,10 @@ func patchCHConfig(path string, opts *patchOptions) error { func patchDisks(diskRaw json.RawMessage, opts *patchOptions) (json.RawMessage, error) { diskQueueSize := utils.OrDefault(opts.diskQueueSize, defaultDiskQueueSize) + var affinity []chQueueAffinity + if opts.cpu > 1 { + affinity = queueAffinity(opts.cpu, opts.allowedCPUs) + } return patchRawArray(diskRaw, len(opts.storageConfigs), func(i int, elem map[string]json.RawMessage) error { sc := opts.storageConfigs[i] if e := setField(elem, "path", sc.Path); e != nil { @@ -85,6 +91,12 @@ func patchDisks(diskRaw json.RawMessage, opts *patchOptions) (json.RawMessage, e if e := setField(elem, "queue_size", diskQueueSize); e != nil { return e } + // A snapshot's affinity targets are the source host's; re-derive so restore under a different fence/placement cannot aim at cores the scope no longer owns. + if affinity != nil && !sc.RO { + if e := setField(elem, "queue_affinity", affinity); e != nil { + return e + } + } return setField(elem, "direct", effectiveDirectIO(sc, opts.noDirectIO)) }) } diff --git a/hypervisor/cloudhypervisor/restore.go b/hypervisor/cloudhypervisor/restore.go index 9ff3abe1..b2ab8db9 100644 --- a/hypervisor/cloudhypervisor/restore.go +++ b/hypervisor/cloudhypervisor/restore.go @@ -11,6 +11,7 @@ import ( "github.com/projecteru2/core/log" + "github.com/cocoonstack/cocoon/cgroup" "github.com/cocoonstack/cocoon/hypervisor" "github.com/cocoonstack/cocoon/types" "github.com/cocoonstack/cocoon/utils" @@ -83,6 +84,8 @@ func (ch *CloudHypervisor) restoreAfterExtract(ctx context.Context, vmID string, directBoot: directBoot, diskQueueSize: vmCfg.DiskQueueSize, noDirectIO: vmCfg.NoDirectIO, + cpu: vmCfg.CPU, + allowedCPUs: cgroup.EffectiveCPUs(vmCfg.CPUSetCPUs, ch.conf.CgroupCPUs), }); err != nil { return nil, fmt.Errorf("patch config: %w", err) } diff --git a/hypervisor/cloudhypervisor/start.go b/hypervisor/cloudhypervisor/start.go index d97b6e5e..59c81d9e 100644 --- a/hypervisor/cloudhypervisor/start.go +++ b/hypervisor/cloudhypervisor/start.go @@ -8,6 +8,7 @@ import ( "github.com/projecteru2/core/log" + "github.com/cocoonstack/cocoon/cgroup" "github.com/cocoonstack/cocoon/hypervisor" ) @@ -19,7 +20,7 @@ func (ch *CloudHypervisor) startOne(ctx context.Context, id string) error { return ch.StartSequence(ctx, id, hypervisor.StartSpec{ RuntimeFiles: runtimeFiles, Launch: func(ctx context.Context, rec *hypervisor.VMRecord, sockPath string) (int, error) { - vmCfg := buildVMConfig(rec, hypervisor.ConsoleSockPath(rec.RunDir)) + vmCfg := buildVMConfig(rec, hypervisor.ConsoleSockPath(rec.RunDir), cgroup.EffectiveCPUs(rec.Config.CPUSetCPUs, ch.conf.CgroupCPUs)) args := buildCLIArgs(vmCfg, sockPath) ch.saveCmdline(ctx, rec, args) return ch.launchProcess(ctx, rec, args, rec.ResolvedNetnsPath()) diff --git a/hypervisor/firecracker/api.go b/hypervisor/firecracker/api.go index d30b0b2d..c0687c99 100644 --- a/hypervisor/firecracker/api.go +++ b/hypervisor/firecracker/api.go @@ -24,8 +24,7 @@ const ( ioEngineAsync = "Async" // io_uring ) -// fcMachineConfig and the request types below follow Firecracker's pre-boot -// config model: start empty, configure via PUT/PATCH, then InstanceStart. +// fcMachineConfig and the request types below follow Firecracker's pre-boot config model: start empty, configure via PUT/PATCH, then InstanceStart. type fcMachineConfig struct { VCPUCount int `json:"vcpu_count"` MemSizeMiB int `json:"mem_size_mib"` diff --git a/hypervisor/start.go b/hypervisor/start.go index 5ed4d66d..01113d1d 100644 --- a/hypervisor/start.go +++ b/hypervisor/start.go @@ -134,7 +134,7 @@ func (b *Backend) LaunchVMProcess(ctx context.Context, spec LaunchSpec) (pid int } }() - scope, err := cgroup.Prepare(b.Conf.CgroupParentDir(), spec.Rec.ID, cgroup.ResolveKnobs(&spec.Rec.Config.Config)) + scope, err := cgroup.Prepare(b.Conf.CgroupParentDir(), b.Conf.CgroupCPUFence(), spec.Rec.ID, cgroup.ResolveKnobs(&spec.Rec.Config.Config)) if err != nil { return 0, fmt.Errorf("prepare cgroup scope: %w", err) } diff --git a/hypervisor/state_test.go b/hypervisor/state_test.go index 4a3b7b53..7a07d7a1 100644 --- a/hypervisor/state_test.go +++ b/hypervisor/state_test.go @@ -646,6 +646,8 @@ func (stubBackendConfig) VMLogDir(string) string { panic("VMLogDir: not implemen func (c stubBackendConfig) CgroupParentDir() string { return filepath.Join(c.rootDir, "cgroup") } +func (stubBackendConfig) CgroupCPUFence() string { return "" } + // meteringStubConfig gives the metering stub a real VMRunDir so sequences // can take the per-VM ops lock and MkdirTemp under it. type meteringStubConfig struct { diff --git a/hypervisor/supervisor.go b/hypervisor/supervisor.go index 23e58e51..c24f6312 100644 --- a/hypervisor/supervisor.go +++ b/hypervisor/supervisor.go @@ -98,8 +98,7 @@ func (b *Backend) TryLockVMOps(ctx context.Context, vmID string) (unlock func(), return func() { _ = l.Unlock(ctx) }, true, nil } -// ConvergeDead lands the stop transition for a VMM that exited outside a cocoon stop, then runs the quiesce it schedules; the caller holds the ops lock and observed no live VMM. -// gen fences the write so a stale observation cannot date or re-label a newer transition. +// ConvergeDead lands the stop transition for a VMM that exited outside a cocoon stop, then runs the quiesce it schedules; the caller holds the ops lock and observed no live VMM. gen fences the write so a stale observation cannot date or re-label a newer transition. func (b *Backend) ConvergeDead(ctx context.Context, id string, gen uint64, observedAt time.Time) error { if err := b.convergeDeadRecord(ctx, id, gen, observedAt); err != nil { return err diff --git a/hypervisor/teardown.go b/hypervisor/teardown.go index 460ec386..083a1117 100644 --- a/hypervisor/teardown.go +++ b/hypervisor/teardown.go @@ -11,8 +11,7 @@ import ( "github.com/cocoonstack/cocoon/meta/tombstone" ) -// vmCleanup is the vms-namespace tombstone payload: everything teardown -// needs once the record is gone. +// vmCleanup is the vms-namespace tombstone payload: everything teardown needs once the record is gone. type vmCleanup struct { Name string `json:"name,omitempty"` RunDir string `json:"run_dir,omitempty"` @@ -71,8 +70,7 @@ func (b *Backend) deleteVMProtocol(ctx context.Context, id string, rec *VMRecord return b.finishVMTeardown(ctx, id, leaseID, cl) } -// finishVMTeardown runs the slow cleanup outside any transaction, then the -// fenced finalize that deletes record, name and tombstone together. +// finishVMTeardown runs the slow cleanup outside any transaction, then the fenced finalize that deletes record, name and tombstone together. func (b *Backend) finishVMTeardown(ctx context.Context, id, leaseID string, cl vmCleanup) error { if err := b.cleanupNetwork(ctx, id); err != nil { return fmt.Errorf("vm %s network teardown (tombstone kept, retry or gc resumes): %w", id, err) @@ -98,9 +96,7 @@ func (b *Backend) finishVMTeardown(ctx context.Context, id, leaseID string, cl v return err } -// recoverVMTombstone drives id's tombstone to completion under the held ops -// lock: leased rolls back (record stays live), deleting rolls forward from -// the payload. done reports the entity was finalized (record gone). +// recoverVMTombstone drives id's tombstone to completion under the held ops lock: leased rolls back (record stays live), deleting rolls forward from the payload. done reports the entity was finalized (record gone). func (b *Backend) recoverVMTombstone(ctx context.Context, id string) (done bool, err error) { //nolint:unparam // done is asserted by the protocol gates ts := b.tombstones() var ( diff --git a/images/cloudimg/cloudimg.go b/images/cloudimg/cloudimg.go index ed2ea4b3..b47187cf 100644 --- a/images/cloudimg/cloudimg.go +++ b/images/cloudimg/cloudimg.go @@ -57,8 +57,7 @@ func (c *CloudImg) Type() string { return typ } func (c *CloudImg) Pull(ctx context.Context, url string, force bool, tracker progress.Tracker) error { key := url if force { - // A forced refresh must not dedup onto an in-flight non-force pull — - // it would return success without ever refreshing the cached blob. + // A forced refresh must not dedup onto an in-flight non-force pull — it would return success without ever refreshing the cached blob. key += "\x00force" } return images.SingleflightDo(ctx, &c.pullGroup, key, func() error { diff --git a/images/images.go b/images/images.go index 32d21966..b91d4d38 100644 --- a/images/images.go +++ b/images/images.go @@ -25,7 +25,6 @@ type Images interface { Config(context.Context, []*types.VMConfig) ([][]*types.StorageConfig, []*types.BootConfig, error) - // PinBlobs holds the digest locks while the caller commits a pin on the - // resolved blobs (design §5: every re-pinning flow takes the digest lock). + // PinBlobs holds the digest locks while the caller commits a pin on the resolved blobs (design §5: every re-pinning flow takes the digest lock). PinBlobs(ctx context.Context, blobIDs map[string]struct{}) (release func(), err error) } diff --git a/images/index.go b/images/index.go index 5951103f..b63de8a8 100644 --- a/images/index.go +++ b/images/index.go @@ -105,9 +105,7 @@ func refsShareDigest[E Entry](images map[string]*E, refs []string) bool { return true } -// deleteByID removes every ref returned by lookup, so "delete " sweeps -// all refs pointing at it. A prefix spanning distinct digests is rejected — -// guessing on a destructive op would delete unrelated images. +// deleteByID removes every ref returned by lookup, so "delete " sweeps all refs pointing at it. A prefix spanning distinct digests is rejected — guessing on a destructive op would delete unrelated images. func deleteByID[E Entry](ctx context.Context, logPrefix string, images map[string]*E, lookup func(string) []string, ids []string) ([]string, error) { logger := log.WithFunc(logPrefix) var deleted []string diff --git a/images/oci/boot.go b/images/oci/boot.go index e6325b4c..7c075988 100644 --- a/images/oci/boot.go +++ b/images/oci/boot.go @@ -149,8 +149,7 @@ func scanBootFiles(ctx context.Context, r io.Reader, workDir, namePrefix string) if createErr != nil { return "", "", fmt.Errorf("create %s: %w", filepath.Base(dstPath), createErr) } - // CH on arm64 direct-boots only a raw kernel Image, but Ubuntu ships the - // arm64 vmlinuz gzip-compressed; decompress it (x86 bzImage is not gzip). + // CH on arm64 direct-boots only a raw kernel Image, but Ubuntu ships the arm64 vmlinuz gzip-compressed; decompress it (x86 bzImage is not gzip). src := io.Reader(tr) var gz *gzip.Reader if isKernel && runtime.GOARCH == "arm64" { diff --git a/images/oci/commit.go b/images/oci/commit.go index 1e987f49..5d502687 100644 --- a/images/oci/commit.go +++ b/images/oci/commit.go @@ -81,8 +81,7 @@ func commitBlobs(conf *Config, results []pullLayerResult) error { return nil } -// buildEntry assembles the index entry from committed blobs; read-only on the -// filesystem, so the enclosing transaction closure stays pure and retryable. +// buildEntry assembles the index entry from committed blobs; read-only on the filesystem, so the enclosing transaction closure stays pure and retryable. func buildEntry(conf *Config, ref string, manifestDigest images.Digest, results []pullLayerResult) (*imageEntry, error) { var ( layerEntries []layerEntry diff --git a/images/oci/erofs.go b/images/oci/erofs.go index 7681965c..8004c47c 100644 --- a/images/oci/erofs.go +++ b/images/oci/erofs.go @@ -76,8 +76,7 @@ func runErofsConversion(ctx context.Context, src io.Reader, scanDir, namePrefix, } _ = stdin.Close() - // Join scanErr: a scan abort truncates mkfs.erofs' stdin, so waitErr alone - // would mask the real cause (e.g. an oversized kernel). + // Join scanErr: a scan abort truncates mkfs.erofs' stdin, so waitErr alone would mask the real cause (e.g. an oversized kernel). if waitErr := cmd.Wait(); waitErr != nil { return "", "", errors.Join(fmt.Errorf("mkfs.erofs failed: %w (output: %s)", waitErr, output.String()), scanErr) } diff --git a/images/oci/import.go b/images/oci/import.go index 3f4cb69d..1d756284 100644 --- a/images/oci/import.go +++ b/images/oci/import.go @@ -105,7 +105,6 @@ func processTarReader(ctx context.Context, j tarImportJob, r io.Reader) error { hasher := sha256.New() teeForHash := io.TeeReader(r, hasher) - // Write EROFS to a temp path until the digest is known. tmpErofsPath := filepath.Join(layerDir, fmt.Sprintf("layer-%d.erofs", j.idx)) tmpUUID := utils.UUIDv5(fmt.Sprintf("import-%s-%d", j.label, j.idx)) diff --git a/images/op.go b/images/op.go index b9186eba..62222d3d 100644 --- a/images/op.go +++ b/images/op.go @@ -16,8 +16,7 @@ type Ops[E Entry] struct { Sizer func(*E) int64 } -// Inspect returns (nil, nil) when no entry matches id or the id is an -// ambiguous prefix spanning distinct digests (LookupOne semantics). +// Inspect returns (nil, nil) when no entry matches id or the id is an ambiguous prefix spanning distinct digests (LookupOne semantics). func (ops Ops[E]) Inspect(ctx context.Context, id string) (result *types.Image, err error) { err = ops.Store.View(ctx, func(idx *Index[E]) error { refs := ops.LookupRefs(idx.Images, id) @@ -51,9 +50,7 @@ func (ops Ops[E]) Delete(ctx context.Context, ids []string) (deleted []string, e return deleted, err } -// SingleflightDo collapses concurrent same-key operations (e.g. pulls) into -// one execution. A waiter's ctx cancellation detaches that waiter only; the -// shared work keeps running under the winner's ctx. +// SingleflightDo collapses concurrent same-key operations (e.g. pulls) into one execution. A waiter's ctx cancellation detaches that waiter only; the shared work keeps running under the winner's ctx. func SingleflightDo(ctx context.Context, g *singleflight.Group, key string, fn func() error) error { ch := g.DoChan(key, func() (any, error) { return nil, fn() }) select { diff --git a/images/store.go b/images/store.go index 5547a21a..b50b5f7f 100644 --- a/images/store.go +++ b/images/store.go @@ -15,9 +15,7 @@ import ( // TableRecords is the image-namespace records table. const TableRecords = "records" -// Store is one image namespace on the shared meta engine, presenting the -// legacy whole-index closure shape over record primitives: image lookups are -// whole-map by nature (ref normalization, digest-prefix matching). +// Store is one image namespace on the shared meta engine, presenting the legacy whole-index closure shape over record primitives: image lookups are whole-map by nature (ref normalization, digest-prefix matching). type Store[E any] struct { meta meta.Store ns string @@ -39,8 +37,7 @@ func (s *Store[E]) View(ctx context.Context, fn func(*Index[E]) error) error { }) } -// Update runs a pure fn against the materialized index and writes the -// difference back as record operations. +// Update runs a pure fn against the materialized index and writes the difference back as record operations. func (s *Store[E]) Update(ctx context.Context, fn func(*Index[E]) error) error { return s.meta.Update(ctx, meta.Scope{Write: s.ns}, meta.CommitDurable, func(w meta.Writer) error { return s.applyDiff(ctx, w, fn) @@ -99,9 +96,7 @@ func (s *Store[E]) materialize(ctx context.Context, r meta.Reader) (*Index[E], m return idx, before, nil } -// BlobLocks holds per-digest blob locks for a publish critical section: -// sorted acquisition so concurrent multi-digest publishes cannot deadlock; -// Release never removes the lock files (flock synchronizes on the inode). +// BlobLocks holds per-digest blob locks for a publish critical section: sorted acquisition so concurrent multi-digest publishes cannot deadlock; Release never removes the lock files (flock synchronizes on the inode). type BlobLocks struct { held []*gofrsflock.Flock } diff --git a/meta/broadcast.go b/meta/broadcast.go index 7802be98..0a7e03fa 100644 --- a/meta/broadcast.go +++ b/meta/broadcast.go @@ -12,9 +12,7 @@ const ( eventsSafetyPoll = 5 * time.Second ) -// Broadcaster is the engine-shared half of an Events notifier: it owns the -// subscriber set and the debounce/poll loop, funneling every trigger into -// the engine's check func — which calls Broadcast only when state moved. +// Broadcaster is the engine-shared half of an Events notifier: it owns the subscriber set and the debounce/poll loop, funneling every trigger into the engine's check func — which calls Broadcast only when state moved. type Broadcaster struct { watcher *fsnotify.Watcher done chan struct{} diff --git a/meta/json/codec.go b/meta/json/codec.go index 3fecdd69..7903b8ce 100644 --- a/meta/json/codec.go +++ b/meta/json/codec.go @@ -7,9 +7,7 @@ import ( "slices" ) -// Codec maps between a namespace file's bytes and its Model. Legacy -// namespaces provide codecs reproducing today's exact formats; Encode output -// is the complete file content including the trailing newline. +// Codec maps between a namespace file's bytes and its Model. Legacy namespaces provide codecs reproducing today's exact formats; Encode output is the complete file content including the trailing newline. type Codec interface { // Decode parses file bytes; data == nil means the file does not exist. Decode(data []byte) (*Model, error) @@ -22,9 +20,7 @@ type genericFile struct { var _ Codec = GenericCodec{} -// GenericCodec persists a Model as {"tables":{...}} for -// namespaces with no legacy format (contract tests, future additions). -// Insertion order is not preserved across reload: tables refill sorted by id. +// GenericCodec persists a Model as {"tables":{...}} for namespaces with no legacy format (contract tests, future additions). Insertion order is not preserved across reload: tables refill sorted by id. type GenericCodec struct{} func (GenericCodec) Decode(data []byte) (*Model, error) { diff --git a/meta/json/model.go b/meta/json/model.go index 64f184bb..4af98e6f 100644 --- a/meta/json/model.go +++ b/meta/json/model.go @@ -1,5 +1,4 @@ -// Package json is the meta engine over today's per-namespace JSON files: -// same formats, same .prev crash story, same flocks (design §8). +// Package json is the meta engine over today's per-namespace JSON files: same formats, same .prev crash story, same flocks (design §8). package json import ( @@ -13,9 +12,7 @@ type table struct { recs map[string]json.RawMessage } -// Model is one namespace's decoded state: named tables preserving insertion -// order — loaded file order first, new ids appended — which legacy codecs -// rely on for order-sensitive fields. +// Model is one namespace's decoded state: named tables preserving insertion order — loaded file order first, new ids appended — which legacy codecs rely on for order-sensitive fields. type Model struct { tables map[string]*table dirty bool diff --git a/meta/json/raw.go b/meta/json/raw.go index 93c2cb86..ad0df2af 100644 --- a/meta/json/raw.go +++ b/meta/json/raw.go @@ -22,8 +22,7 @@ func AppendStringSlice(dst []byte, s []string) ([]byte, error) { return append(dst, ']'), nil } -// AppendTable appends tbl as a compact JSON object with sorted keys, values -// verbatim — no intermediate value-map copy on the commit path. +// AppendTable appends tbl as a compact JSON object with sorted keys, values verbatim — no intermediate value-map copy on the commit path. func AppendTable(dst []byte, m *Model, tbl string) ([]byte, error) { t := m.tables[tbl] dst = append(dst, '{') diff --git a/meta/json/store.go b/meta/json/store.go index 51aeab3e..80a27ce4 100644 --- a/meta/json/store.go +++ b/meta/json/store.go @@ -21,8 +21,7 @@ import ( const prevSuffix = ".prev" -// testCrashStep aborts a commit right after the named write step when set; -// testWatchErrs injects a forced fsnotify overflow/watch error (§7 gate). +// testCrashStep aborts a commit right after the named write step when set; testWatchErrs injects a forced fsnotify overflow/watch error (§7 gate). var ( testCrashStep func(step string) error testWatchErrs chan struct{} @@ -43,15 +42,13 @@ type nsState struct { type loaded struct { model *Model - // recovered means main was undecodable and .prev was served; commit must - // not rotate, or it would destroy the only good generation. + // recovered means main was undecodable and .prev was served; commit must not rotate, or it would destroy the only good generation. recovered bool } var _ meta.Store = (*Store)(nil) -// Store is the json engine: one flocked file per namespace, legacy write -// order (rotate .prev under lock, atomic rename, post-release fsyncs). +// Store is the json engine: one flocked file per namespace, legacy write order (rotate .prev under lock, atomic rename, post-release fsyncs). type Store struct { nss map[string]*nsState @@ -113,9 +110,7 @@ func (s *Store) Update(ctx context.Context, sc meta.Scope, mode meta.CommitMode, if err := fn(w); err != nil { return err } - // A clean transaction commits nothing — the encode/rotate/fsync tail - // would rewrite identical bytes on every read-only guard. A recovered - // generation still commits: that is the read-repair of a torn main. + // A clean transaction commits nothing — the encode/rotate/fsync tail would rewrite identical bytes on every read-only guard. A recovered generation still commits: that is the read-repair of a torn main. if !models[sc.Write].model.Dirty() && !models[sc.Write].recovered { return nil } @@ -157,9 +152,7 @@ func (s *Store) resolve(nss []string) ([]*nsState, error) { return states, nil } -// withLocked holds every namespace flock (sorted names = fixed global order). -// Unlock errors log only: joining them would make callers roll back an -// already-durable commit; a leaked flock fails the next Lock loudly instead. +// withLocked holds every namespace flock (sorted names = fixed global order). Unlock errors log only: joining them would make callers roll back an already-durable commit; a leaked flock fails the next Lock loudly instead. func (s *Store) withLocked(ctx context.Context, states []*nsState, fn func() error) error { logger := log.WithFunc("meta.json.withLocked") for i, st := range states { @@ -206,8 +199,7 @@ func (r *txReader) GetRaw(_ context.Context, ns, table, id string) (json.RawMess return nil, false, fmt.Errorf("read %s: %w", ns, meta.ErrScope) } raw, ok := l.model.Get(table, id) - // Detached values (contract clause 4): aliasing model bytes would let a - // caller mutate committed state without PutRaw's scope/durability checks. + // Detached values (contract clause 4): aliasing model bytes would let a caller mutate committed state without PutRaw's scope/durability checks. return slices.Clone(raw), ok, nil } @@ -255,8 +247,7 @@ type coded struct { func (c *coded) Error() string { return c.err.Error() } func (c *coded) Unwrap() []error { return []error{c.err, c.mark} } -// loadNamespace ports the legacy load: a missing file is empty, an -// undecodable main falls back to the .prev generation, read errors fail closed. +// loadNamespace ports the legacy load: a missing file is empty, an undecodable main falls back to the .prev generation, read errors fail closed. func loadNamespace(ctx context.Context, def Namespace) (*loaded, error) { raw, err := os.ReadFile(def.FilePath) //nolint:gosec switch { @@ -289,8 +280,7 @@ func loadNamespace(ctx context.Context, def Namespace) (*loaded, error) { return &loaded{model: prev, recovered: true}, nil } -// commitLocked rotates .prev via link+rename (one exists at every instant), -// then renames the fresh bytes in; syncCommitted makes them durable. +// commitLocked rotates .prev via link+rename (one exists at every instant), then renames the fresh bytes in; syncCommitted makes them durable. func commitLocked(st *nsState, l *loaded) error { data, err := st.def.Codec.Encode(l.model) if err != nil { @@ -324,9 +314,7 @@ func commitLocked(st *nsState, l *loaded) error { return crash("main-renamed") } -// syncCommitted runs after the flock is released: main first so a .prev sync -// failure still leaves the caller's own generation durable; the parent-dir -// sync is what CommitRelaxed relinquishes. +// syncCommitted runs after the flock is released: main first so a .prev sync failure still leaves the caller's own generation durable; the parent-dir sync is what CommitRelaxed relinquishes. func syncCommitted(st *nsState, mode meta.CommitMode) error { path := st.def.FilePath if err := utils.SyncFile(path); err != nil { diff --git a/meta/json/tables.go b/meta/json/tables.go index fb0390db..5c270dff 100644 --- a/meta/json/tables.go +++ b/meta/json/tables.go @@ -13,9 +13,7 @@ import ( // stringListMarker backs StringList presence rows; the value never surfaces. var stringListMarker = json.RawMessage("{}") -// TableSpec maps one legacy top-level object field to a model table; -// Optional omits the field from encoded output while the table is empty; -// StringList marks an ordered []string field stored as presence markers. +// TableSpec maps one legacy top-level object field to a model table; Optional omits the field from encoded output while the table is empty; StringList marks an ordered []string field stored as presence markers. type TableSpec struct { Key string Table string @@ -25,8 +23,7 @@ type TableSpec struct { var _ Codec = TableCodec{} -// TableCodec is the declaration-only codec for pure table-shaped namespaces: -// a subsystem states its legacy field layout and owns no codec code. +// TableCodec is the declaration-only codec for pure table-shaped namespaces: a subsystem states its legacy field layout and owns no codec code. type TableCodec struct { Specs []TableSpec } @@ -39,9 +36,7 @@ func (c TableCodec) Encode(m *Model) ([]byte, error) { return EncodeTables(m, c.Specs) } -// DecodeTables loads specs' map fields into a fresh Model (sorted insertion, -// matching what encoding/json always wrote). Single streaming pass — a whole-file -// unmarshal into raw messages tokenizes the payload twice. +// DecodeTables loads specs' map fields into a fresh Model (sorted insertion, matching what encoding/json always wrote). Single streaming pass — a whole-file unmarshal into raw messages tokenizes the payload twice. func DecodeTables(data []byte, specs []TableSpec) (*Model, error) { m := NewModel() if data == nil { @@ -91,8 +86,7 @@ func DecodeTables(data []byte, specs []TableSpec) (*Model, error) { if _, err := dec.Token(); err != nil { return nil, err } - // Legacy json.Unmarshal rejected trailing bytes; a truncated-then-appended - // main must fall back to .prev, not decode (§9 format fidelity). + // Legacy json.Unmarshal rejected trailing bytes; a truncated-then-appended main must fall back to .prev, not decode (§9 format fidelity). if _, err := dec.Token(); !errors.Is(err, io.EOF) { return nil, fmt.Errorf("namespace file: trailing data after document") } diff --git a/meta/log.go b/meta/log.go index f9081692..1a80d6fe 100644 --- a/meta/log.go +++ b/meta/log.go @@ -12,12 +12,10 @@ import ( // seqCursorID is the reserved in-table row holding the last assigned Seq. const seqCursorID = "cursor" -// Seq numbers committed log entries: unique and strictly increasing. -// Rolled-back numbers may be reused, and engines may leave gaps. +// Seq numbers committed log entries: unique and strictly increasing. Rolled-back numbers may be reused, and engines may leave gaps. type Seq uint64 -// Log is an append-only typed sequence over one namespace table; the cursor -// shares the table under a reserved id, so a rollback releases its number. +// Log is an append-only typed sequence over one namespace table; the cursor shares the table under a reserved id, so a rollback releases its number. type Log[R any] struct { ns string table string diff --git a/meta/meta.go b/meta/meta.go index 199de3d4..2b7f0d9c 100644 --- a/meta/meta.go +++ b/meta/meta.go @@ -1,5 +1,4 @@ -// Package meta is the unified metadata layer: record-granularity -// transactions over per-deployment engines (sqlite default, json legacy). +// Package meta is the unified metadata layer: record-granularity transactions over per-deployment engines (sqlite default, json legacy). package meta import ( @@ -39,18 +38,13 @@ type CommitMode uint8 // WriteOpt modifies a single write; see RelaxedOK. type WriteOpt uint8 -// Scope declares, before the closure runs, every namespace a transaction -// touches: Write is the single namespace it may modify, Read the others it -// may read. Engines acquire all of them in one fixed global order before -// invoking the closure. +// Scope declares, before the closure runs, every namespace a transaction touches: Write is the single namespace it may modify, Read the others it may read. Engines acquire all of them in one fixed global order before invoking the closure. type Scope struct { Write string Read []string } -// Store is the engine-neutral transaction boundary. Closures must be pure -// and retryable: they may run more than once, all effects go through the -// handle, and results are published only after the transaction returns nil. +// Store is the engine-neutral transaction boundary. Closures must be pure and retryable: they may run more than once, all effects go through the handle, and results are published only after the transaction returns nil. type Store interface { // View runs fn over a consistent snapshot of the given namespaces. View(ctx context.Context, nss []string, fn func(Reader) error) error @@ -61,8 +55,7 @@ type Store interface { Close() error } -// Reader is the raw read SPI transactions hand to Collection; values -// returned are detached from engine state. +// Reader is the raw read SPI transactions hand to Collection; values returned are detached from engine state. type Reader interface { GetRaw(ctx context.Context, ns, table, id string) (json.RawMessage, bool, error) // ScanRaw yields records in the engine's stable order (json: insertion). diff --git a/meta/namedtx.go b/meta/namedtx.go index 388f41af..b11ed6cb 100644 --- a/meta/namedtx.go +++ b/meta/namedtx.go @@ -65,8 +65,7 @@ type NamedTx[R any] struct { names *Collection[string] } -// NewNamedTx binds the pattern to (ns, recordsTable, namesTable); w is nil in -// read-only transactions. +// NewNamedTx binds the pattern to (ns, recordsTable, namesTable); w is nil in read-only transactions. func NewNamedTx[R any](ctx context.Context, ns, recordsTable, namesTable string, r Reader, w Writer) *NamedTx[R] { return &NamedTx[R]{ RecordTx: NewRecordTx[R](ctx, ns, recordsTable, r, w), diff --git a/meta/sqlite/events.go b/meta/sqlite/events.go index e55f3753..8128470e 100644 --- a/meta/sqlite/events.go +++ b/meta/sqlite/events.go @@ -10,10 +10,7 @@ import ( "github.com/cocoonstack/cocoon/meta" ) -// Events subscribes to committed-change signals: fsnotify on the DB's parent -// dir confirmed via data_version on a pinned connection — the counter is -// only comparable across calls on ONE connection and never moves for that -// connection's own commits (§7). +// Events subscribes to committed-change signals: fsnotify on the DB's parent dir confirmed via data_version on a pinned connection — the counter is only comparable across calls on ONE connection and never moves for that connection's own commits (§7). func (s *Store) Events(ctx context.Context) (<-chan struct{}, func(), error) { s.mu.Lock() if s.notifier == nil { @@ -31,9 +28,7 @@ func (s *Store) Events(ctx context.Context) (<-chan struct{}, func(), error) { return ch, func() { stop(); release() }, nil } -// notifier holds the pinned data_version connection; version is touched only -// by the init call and the Run goroutine. ctx is the notifier's own lifetime -// — it outlives every Events caller and ends at stop. +// notifier holds the pinned data_version connection; version is touched only by the init call and the Run goroutine. ctx is the notifier's own lifetime — it outlives every Events caller and ends at stop. type notifier struct { b *meta.Broadcaster watcher *fsnotify.Watcher // kept for the severed-watch test seam diff --git a/meta/sqlite/fscheck.go b/meta/sqlite/fscheck.go index 3656b168..debac74e 100644 --- a/meta/sqlite/fscheck.go +++ b/meta/sqlite/fscheck.go @@ -7,8 +7,7 @@ import ( "github.com/cocoonstack/cocoon/meta" ) -// checkFS refuses unsupported filesystems before any WAL work (§4); the env -// seam lets every entry path (open, init, convert) be asserted in tests. +// checkFS refuses unsupported filesystems before any WAL work (§4); the env seam lets every entry path (open, init, convert) be asserted in tests. func checkFS(dbPath string) error { if name := os.Getenv("COCOON_TEST_UNSUPPORTED_FS"); name != "" { return fsRefusal(dbPath, name) diff --git a/meta/sqlite/init.go b/meta/sqlite/init.go index e30a0b51..a1569da1 100644 --- a/meta/sqlite/init.go +++ b/meta/sqlite/init.go @@ -16,10 +16,7 @@ import ( const initLockName = "init.lock" -// Init creates a fresh store: schema DDL, identity pragmas and one -// initialized meta_state row per namespace, all in ONE transaction — a crash -// mid-init leaves nothing or a complete store (§6). An empty database is a -// failed init and is restarted; anything else is left for the operator. +// Init creates a fresh store: schema DDL, identity pragmas and one initialized meta_state row per namespace, all in ONE transaction — a crash mid-init leaves nothing or a complete store (§6). An empty database is a failed init and is restarted; anything else is left for the operator. func Init(ctx context.Context, dbPath string, namespaces ...Namespace) error { if err := RefuseManifest(dbPath); err != nil { return err @@ -27,8 +24,7 @@ func Init(ctx context.Context, dbPath string, namespaces ...Namespace) error { return initStore(ctx, dbPath, namespaces) } -// InitForRecovery is Init without the manifest guard — the conversion tool -// creates its target while the manifest is necessarily present (§6). +// InitForRecovery is Init without the manifest guard — the conversion tool creates its target while the manifest is necessarily present (§6). func InitForRecovery(ctx context.Context, dbPath string, namespaces ...Namespace) error { return initStore(ctx, dbPath, namespaces) } @@ -92,9 +88,7 @@ func initStore(ctx context.Context, dbPath string, namespaces []Namespace) (err if err := createSchema(ctx, tx, namespaces); err != nil { return err } - // Identity pragmas ride the same transaction as the schema: a crash - // leaves nothing (or an empty file) or a complete store, never a - // half-identified one (§6). + // Identity pragmas ride the same transaction as the schema: a crash leaves nothing (or an empty file) or a complete store, never a half-identified one (§6). if _, err := tx.ExecContext(ctx, fmt.Sprintf("PRAGMA application_id = %d", ApplicationID)); err != nil { return mapErr(err) } @@ -130,9 +124,7 @@ func initNeeded(dbPath string) (bool, error) { return failedInit(dbPath) } -// failedInit reports whether dbPath is a crashed init. Init is atomic, so -// the only restartable state is an empty database (driver lazy-touch or a -// crash before the commit); anything populated is refused, never deleted. +// failedInit reports whether dbPath is a crashed init. Init is atomic, so the only restartable state is an empty database (driver lazy-touch or a crash before the commit); anything populated is refused, never deleted. func failedInit(dbPath string) (bool, error) { db, err := open(dbPath, "FULL", false) if err != nil { diff --git a/meta/sqlite/maintenance.go b/meta/sqlite/maintenance.go index 2729004e..3cb77465 100644 --- a/meta/sqlite/maintenance.go +++ b/meta/sqlite/maintenance.go @@ -23,8 +23,7 @@ func MarkConverted(ctx context.Context, dbPath, ns, source, sha256 string, recor }) } -// Checkpoint folds the WAL back into the main file (TRUNCATE) so the -// database is a single self-contained file (§6 aside rule). +// Checkpoint folds the WAL back into the main file (TRUNCATE) so the database is a single self-contained file (§6 aside rule). func Checkpoint(ctx context.Context, dbPath string) error { return withDB(dbPath, func(db *sql.DB) error { _, err := db.ExecContext(ctx, "PRAGMA wal_checkpoint(TRUNCATE)") @@ -32,9 +31,7 @@ func Checkpoint(ctx context.Context, dbPath string) error { }) } -// Backup replaces destPath with a consistent single-file copy: VACUUM INTO -// a temp file, integrity-check, fsync, atomic rename, parent-dir sync (§4). -// A previously published backup stays intact until the rename commits (§9). +// Backup replaces destPath with a consistent single-file copy: VACUUM INTO a temp file, integrity-check, fsync, atomic rename, parent-dir sync (§4). A previously published backup stays intact until the rename commits (§9). func Backup(ctx context.Context, dbPath, destPath string) error { if !utils.FileExists(dbPath) { return fmt.Errorf("no sqlite store at %s to back up", dbPath) @@ -50,8 +47,7 @@ func Backup(ctx context.Context, dbPath, destPath string) error { if merr := os.MkdirAll(filepath.Dir(destPath), 0o750); merr != nil { return merr } - // Concurrent backups to one destination share the tmp path; without - // mutual exclusion one run's cleanup yanks the other's tmp mid-verify. + // Concurrent backups to one destination share the tmp path; without mutual exclusion one run's cleanup yanks the other's tmp mid-verify. return withFlock(ctx, flock.New(destPath+".lock"), func() error { return backupLocked(ctx, dbPath, destPath) }) @@ -59,8 +55,7 @@ func Backup(ctx context.Context, dbPath, destPath string) error { func backupLocked(ctx context.Context, dbPath, destPath string) (err error) { tmp := destPath + ".tmp" - // A stale temp from a crashed run would block VACUUM INTO; the published - // backup is untouched, so clearing it is safe. + // A stale temp from a crashed run would block VACUUM INTO; the published backup is untouched, so clearing it is safe. if rerr := os.Remove(tmp); rerr != nil && !errors.Is(rerr, os.ErrNotExist) { return rerr } diff --git a/meta/sqlite/store.go b/meta/sqlite/store.go index ff581027..3aab7ad5 100644 --- a/meta/sqlite/store.go +++ b/meta/sqlite/store.go @@ -1,5 +1,4 @@ -// Package sqlite is the meta scale engine: one WAL database, namespace = -// table group, generic (id, data) rows per root-declared table (§2-§4, v2.28). +// Package sqlite is the meta scale engine: one WAL database, namespace = table group, generic (id, data) rows per root-declared table (§2-§4, v2.28). package sqlite import ( @@ -25,42 +24,35 @@ import ( ) const ( - // ApplicationID marks a cocoon DB ("COCN"); UserVersion is the schema - // generation — verified on every open, written only at init (§6). + // ApplicationID marks a cocoon DB ("COCN"); UserVersion is the schema generation — verified on every open, written only at init (§6). ApplicationID = 0x434F434E UserVersion = 1 - // DBFileName is the single database under the meta root; ManifestName - // beside it marks an in-flight conversion, which ordinary opens refuse (§6). + // DBFileName is the single database under the meta root; ManifestName beside it marks an in-flight conversion, which ordinary opens refuse (§6). DBFileName = "meta.db" ManifestName = "meta-convert.manifest" - // busyRetryPause caps the jittered pause between BEGIN IMMEDIATE retries; - // the in-driver busy_timeout already did the real waiting (§4). + // busyRetryPause caps the jittered pause between BEGIN IMMEDIATE retries; the in-driver busy_timeout already did the real waiting (§4). busyRetryCeiling = 5 * time.Second busyRetryPause = 2 * time.Millisecond slowTxnWarn = 500 * time.Millisecond checkpointInterval = time.Second ) -// Namespace declares one namespace's table set; Tables lists the record -// tables (satellites included) the SPI may address. +// Namespace declares one namespace's table set; Tables lists the record tables (satellites included) the SPI may address. type Namespace struct { Name string Tables []string } -// tableStmts holds one table's prepared statements; scan/put/del are nil on -// read-only handles. +// tableStmts holds one table's prepared statements; scan/put/del are nil on read-only handles. type tableStmts struct { get, scan, put, del *sql.Stmt } var _ meta.Store = (*Store)(nil) -// Store is the sqlite engine: writerDurable/writerRelaxed single-conn -// handles, a bounded reader pool, and a pinned notifier connection (§4). -// Statements are prepared per handle at Open — the table set is static. +// Store is the sqlite engine: writerDurable/writerRelaxed single-conn handles, a bounded reader pool, and a pinned notifier connection (§4). Statements are prepared per handle at Open — the table set is static. type Store struct { path string nss map[string]Namespace @@ -76,8 +68,7 @@ type Store struct { notifier *notifier } -// Open verifies identity, version and per-namespace meta_state, then builds -// the connection set. It never creates or migrates — that is Init's job. +// Open verifies identity, version and per-namespace meta_state, then builds the connection set. It never creates or migrates — that is Init's job. func Open(dbPath string, namespaces ...Namespace) (*Store, error) { if err := RefuseManifest(dbPath); err != nil { return nil, err @@ -85,8 +76,7 @@ func Open(dbPath string, namespaces ...Namespace) (*Store, error) { return openStore(dbPath, namespaces) } -// OpenForRecovery is Open without the manifest guard, for the conversion -// tool itself (§6) — never for ordinary callers. +// OpenForRecovery is Open without the manifest guard, for the conversion tool itself (§6) — never for ordinary callers. func OpenForRecovery(dbPath string, namespaces ...Namespace) (*Store, error) { return openStore(dbPath, namespaces) } @@ -101,8 +91,7 @@ func RefuseManifest(dbPath string) error { } func openStore(dbPath string, namespaces []Namespace) (*Store, error) { - // The driver creates a file on first touch; Open never creates — that is - // Init's job (§6) — and §4 refuses network filesystems before WAL work. + // The driver creates a file on first touch; Open never creates — that is Init's job (§6) — and §4 refuses network filesystems before WAL work. if !utils.FileExists(dbPath) { return nil, fmt.Errorf("no sqlite store at %s: run `cocoon meta init` or `cocoon meta convert`", dbPath) } @@ -142,8 +131,7 @@ func openStore(dbPath string, namespaces []Namespace) (*Store, error) { if s.stmtsReaders, err = prepareStmts(s.readers, s.nss, false); err != nil { return nil, errors.Join(err, s.Close()) } - // A background PASSIVE checkpoint keeps the WAL short so committing - // writers rarely pay the autocheckpoint stall themselves. + // A background PASSIVE checkpoint keeps the WAL short so committing writers rarely pay the autocheckpoint stall themselves. s.cpDone = make(chan struct{}) go checkpointLoop(s.readers, dbPath, s.cpDone) return s, nil @@ -231,10 +219,7 @@ func (s *Store) Close() error { return errors.Join(errs...) } -// beginImmediate retries BEGIN IMMEDIATE under a ctx-bounded loop: the short -// in-driver busy_timeout does the real waiting (and bounds ctx latency, -// clause 6), so between attempts only a tiny jittered pause bounds spin — -// an exponential backoff here would idle past a freed lock. +// beginImmediate retries BEGIN IMMEDIATE under a ctx-bounded loop: the short in-driver busy_timeout does the real waiting (and bounds ctx latency, clause 6), so between attempts only a tiny jittered pause bounds spin — an exponential backoff here would idle past a freed lock. func (s *Store) beginImmediate(ctx context.Context, db *sql.DB) (*sql.Tx, error) { deadline := time.Now().Add(busyRetryCeiling) for { @@ -269,9 +254,7 @@ func (s *Store) checkScope(nss []string) error { return nil } -// verifyIdentity reads application_id, user_version and meta_state — never -// writes them (§6): wrong id or a newer version fails closed; a namespace -// with no meta_state row is uninitialized, never empty. +// verifyIdentity reads application_id, user_version and meta_state — never writes them (§6): wrong id or a newer version fails closed; a namespace with no meta_state row is uninitialized, never empty. func (s *Store) verifyIdentity() error { var appID, version int64 if err := s.readers.QueryRow("PRAGMA application_id").Scan(&appID); err != nil { @@ -304,8 +287,7 @@ var ( _ meta.Writer = (*txHandle)(nil) ) -// txHandle implements Reader/Writer over one transaction; values are -// detached by construction (every read allocates from row scans). +// txHandle implements Reader/Writer over one transaction; values are detached by construction (every read allocates from row scans). type txHandle struct { ctx context.Context tx *sql.Tx @@ -416,8 +398,7 @@ func scopeSet(nss []string) map[string]struct{} { return set } -// open applies the per-connection runtime contract (§4); cache_size is KB -// (negative form) and mmap_size covers the whole file at meta scale. +// open applies the per-connection runtime contract (§4); cache_size is KB (negative form) and mmap_size covers the whole file at meta scale. func open(dbPath, sync string, writer bool) (*sql.DB, error) { dsn := "file:" + dbPath + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(50)&_pragma=foreign_keys(1)&_pragma=trusted_schema(0)" + "&_pragma=cache_size(-16384)&_pragma=mmap_size(268435456)&_pragma=synchronous(" + sync + ")" diff --git a/meta/tombstone/tombstone.go b/meta/tombstone/tombstone.go index 7079fdba..636c3364 100644 --- a/meta/tombstone/tombstone.go +++ b/meta/tombstone/tombstone.go @@ -1,5 +1,4 @@ -// Package tombstone is the §5 phase protocol: leased rolls back, deleting -// rolls forward, every mutation fenced by the holder's lease id. +// Package tombstone is the §5 phase protocol: leased rolls back, deleting rolls forward, every mutation fenced by the holder's lease id. package tombstone import ( @@ -37,12 +36,10 @@ type Phase string // Kind distinguishes a record-backed candidate from a recordless orphan. type Kind string -// Mode distinguishes a full teardown from an explicit subset; recovering a -// subset as an aggregate would destroy healthy resources. +// Mode distinguishes a full teardown from an explicit subset; recovering a subset as an aggregate would destroy healthy resources. type Mode string -// Payload is written whole at lease time and immutable after: recovery -// reads it and nothing else. +// Payload is written whole at lease time and immutable after: recovery reads it and nothing else. type Payload struct { Kind Kind `json:"kind"` Mode Mode `json:"mode"` @@ -82,8 +79,7 @@ func (t *Table) Scan(ctx context.Context, r meta.Reader, fn func(id string, rec return t.recs.Scan(ctx, r, fn) } -// Lease inserts id's tombstone with a fresh lease and its complete payload; -// an existing tombstone is ErrConflict — another worker owns the candidate. +// Lease inserts id's tombstone with a fresh lease and its complete payload; an existing tombstone is ErrConflict — another worker owns the candidate. func (t *Table) Lease(ctx context.Context, w meta.Writer, id string, p Payload) (string, error) { leaseID := utils.GenerateID() if err := t.recs.Insert(ctx, w, id, &Record{LeaseID: leaseID, Phase: PhaseLeased, LeasedAt: time.Now(), Payload: p}); err != nil { @@ -92,8 +88,7 @@ func (t *Table) Lease(ctx context.Context, w meta.Writer, id string, p Payload) return leaseID, nil } -// TakeOver replaces a dead owner's lease with a fresh one, preserving phase -// and payload; the caller must hold the entity lock. +// TakeOver replaces a dead owner's lease with a fresh one, preserving phase and payload; the caller must hold the entity lock. func (t *Table) TakeOver(ctx context.Context, w meta.Writer, id string) (*Record, error) { rec, err := t.Get(ctx, w, id) if err != nil || rec == nil { @@ -151,8 +146,7 @@ func (t *Table) PendingIDs(ctx context.Context, r meta.Reader) ([]string, error) return ids, nil } -// Acquire starts protocol work on id under the held entity lock: takes over -// an existing tombstone (resumed reports it), or leases fresh from build. +// Acquire starts protocol work on id under the held entity lock: takes over an existing tombstone (resumed reports it), or leases fresh from build. func (t *Table) Acquire(ctx context.Context, w meta.Writer, id string, build func() (Payload, error)) (leaseID string, resumed *Record, err error) { existing, err := t.Get(ctx, w, id) if err != nil { @@ -173,9 +167,7 @@ func (t *Table) Acquire(ctx context.Context, w meta.Writer, id string, build fun return leaseID, nil, err } -// Resume takes over id's tombstone for recovery under the held entity lock: -// a leased entry rolls back in place; a deleting one gets a fresh lease for -// the caller to roll forward. +// Resume takes over id's tombstone for recovery under the held entity lock: a leased entry rolls back in place; a deleting one gets a fresh lease for the caller to roll forward. func (t *Table) Resume(ctx context.Context, w meta.Writer, id string) (rec *Record, leaseID string, err error) { rec, err = t.Get(ctx, w, id) if err != nil || rec == nil { diff --git a/network/bridge/bridge_linux.go b/network/bridge/bridge_linux.go index 7cdd12db..6b3f591f 100644 --- a/network/bridge/bridge_linux.go +++ b/network/bridge/bridge_linux.go @@ -55,8 +55,7 @@ func New(conf *config.Config, bridgeDev string) (*Bridge, error) { func (b *Bridge) Type() string { return typ } func (b *Bridge) Verify(_ context.Context, vmID string, expected []*types.NetworkConfig) error { - // Legacy records persisted no NetworkConfigs, so empty means "assume tap0" - // — callers that legitimately resized to zero NICs must not call Verify. + // Legacy records persisted no NetworkConfigs, so empty means "assume tap0" — callers that legitimately resized to zero NICs must not call Verify. if len(expected) == 0 { if _, err := netlink.LinkByName(tapName(vmID, 0)); err != nil { return fmt.Errorf("tap %s: %w", tapName(vmID, 0), err) diff --git a/network/cni/gc.go b/network/cni/gc.go index e8e762f9..674478dd 100644 --- a/network/cni/gc.go +++ b/network/cni/gc.go @@ -58,8 +58,7 @@ func (c *CNI) GCModule() gc.Module[cniSnapshot] { logger := log.WithFunc("gc.cni") var errs []error for _, vmID := range ids { - // The owning VM's lock covers network teardown (design §5); - // a held lock means an in-flight lifecycle operation — skip. + // The owning VM's lock covers network teardown (design §5); a held lock means an in-flight lifecycle operation — skip. lk, lockErr := vmlock.New(c.conf.RootDir, vmID) if lockErr != nil { errs = append(errs, lockErr) @@ -89,8 +88,7 @@ func (c *CNI) RegisterGC(orch *gc.Orchestrator) { gc.Register(orch, c.GCModule()) } -// gcRecover resumes existing network tombstones by phase before discovery, -// each under its owning VM's lock (design §5 recovery-precedes-discovery). +// gcRecover resumes existing network tombstones by phase before discovery, each under its owning VM's lock (design §5 recovery-precedes-discovery). func (c *CNI) gcRecover(ctx context.Context) []error { var ids []string if err := c.view(ctx, func(t *netTx) error { diff --git a/network/cni/metans.go b/network/cni/metans.go index 4423b858..1811d969 100644 --- a/network/cni/metans.go +++ b/network/cni/metans.go @@ -26,8 +26,7 @@ func (c *Config) JSONNamespace() metajson.Namespace { } } -// netTx is the CNI view of one meta transaction: a records-only namespace -// with the vm_id secondary lookup served by scan. +// netTx is the CNI view of one meta transaction: a records-only namespace with the vm_id secondary lookup served by scan. type netTx struct { *meta.RecordTx[networkRecord] } @@ -58,8 +57,7 @@ func (c *CNI) update(ctx context.Context, fn func(*netTx) error) error { }) } -// deleteRecords removes the given record rows in one transaction; used by -// the Add-path stale-NIC reclaim, which runs under the VM lock. +// deleteRecords removes the given record rows in one transaction; used by the Add-path stale-NIC reclaim, which runs under the VM lock. func (c *CNI) deleteRecords(ctx context.Context, ids []string) error { if len(ids) == 0 { return nil diff --git a/network/cni/teardown.go b/network/cni/teardown.go index 70736a39..b4dac031 100644 --- a/network/cni/teardown.go +++ b/network/cni/teardown.go @@ -13,9 +13,7 @@ import ( "github.com/cocoonstack/cocoon/meta/tombstone" ) -// netCleanup is the networks-namespace tombstone payload: aggregate removes -// every listed record plus the netns; subset removes only the named record -// IDs (never NIC indices — they cannot disambiguate duplicate rows). +// netCleanup is the networks-namespace tombstone payload: aggregate removes every listed record plus the netns; subset removes only the named record IDs (never NIC indices — they cannot disambiguate duplicate rows). type netCleanup struct { Netns string `json:"netns,omitempty"` Records []netCleanupRecord `json:"records"` @@ -85,23 +83,19 @@ func (c *CNI) teardownProtocol(ctx context.Context, vmID string, subset []string return c.finishTeardown(ctx, vmID, leaseID, mode, cl, deleteTAP) } -// finishTeardown runs the slow CNI DEL / netns work outside any transaction, -// driven by the payload, then the fenced finalize. +// finishTeardown runs the slow CNI DEL / netns work outside any transaction, driven by the payload, then the fenced finalize. func (c *CNI) finishTeardown(ctx context.Context, vmID, leaseID string, mode tombstone.Mode, cl netCleanup, deleteTAP bool) error { ts := c.tombstones() records := make([]networkRecord, 0, len(cl.Records)) for _, r := range cl.Records { records = append(records, networkRecord{ID: r.ID, Type: r.Type, VMID: vmID, IfName: r.IfName}) } - // A retry after the netns already went (crash between netns removal and - // the sweep) skips TAP deletion — the TAPs died with the ns; CNI DEL still - // runs, releasing IPAM by container ID without entering the ns. + // A retry after the netns already went (crash between netns removal and the sweep) skips TAP deletion — the TAPs died with the ns; CNI DEL still runs, releasing IPAM by container ID without entering the ns. if _, err := statNetnsFn(netnsPath(vmID)); errors.Is(err, fs.ErrNotExist) { deleteTAP = false } downIDs, tdErr := c.tearDownNICs(ctx, vmID, netnsPath(vmID), records, deleteTAP) - // Slow cleanup stays outside the transaction (clause 1): the netns goes - // before the commit so a pure retryable closure never carries side effects. + // Slow cleanup stays outside the transaction (clause 1): the netns goes before the commit so a pure retryable closure never carries side effects. if tdErr == nil && mode == tombstone.ModeAggregate && cl.Netns != "" { if err := deleteNetnsFn(ctx, netnsName(vmID)); err != nil && !errors.Is(err, fs.ErrNotExist) { return fmt.Errorf("remove netns %s (tombstone kept, retry resumes): %w", cl.Netns, err) @@ -131,9 +125,7 @@ func (c *CNI) finishTeardown(ctx context.Context, vmID, leaseID string, mode tom return nil } -// recoverTombstone drives vmID's tombstone under the held VM lock; -// rolledForward reports a completed deleting recovery so entrypoints refuse -// the current operation (design §5 binding rule). +// recoverTombstone drives vmID's tombstone under the held VM lock; rolledForward reports a completed deleting recovery so entrypoints refuse the current operation (design §5 binding rule). func (c *CNI) recoverTombstone(ctx context.Context, vmID string) (rolledForward bool, err error) { ts := c.tombstones() var ( @@ -154,9 +146,7 @@ func (c *CNI) recoverTombstone(ctx context.Context, vmID string) (rolledForward if err := json.Unmarshal(rec.Payload.Cleanup, &cl); err != nil { return false, fmt.Errorf("tombstone %s payload: %w", vmID, err) } - // Subset teardown (vm net remove) creates its TAPs independently of the - // netns lifetime, so recovery restores Remove's deleteTAP; an aggregate's - // TAPs die with the netns. + // Subset teardown (vm net remove) creates its TAPs independently of the netns lifetime, so recovery restores Remove's deleteTAP; an aggregate's TAPs die with the netns. deleteTAP := rec.Payload.Mode == tombstone.ModeSubset if err := c.finishTeardown(ctx, vmID, leaseID, rec.Payload.Mode, cl, deleteTAP); err != nil { return false, err diff --git a/snapshot/localfile/teardown.go b/snapshot/localfile/teardown.go index 59464811..1b0745b0 100644 --- a/snapshot/localfile/teardown.go +++ b/snapshot/localfile/teardown.go @@ -103,8 +103,7 @@ func (lf *LocalFile) finishSnapTeardown(ctx context.Context, id, leaseID string, return err } -// recoverSnapTombstone drives id's tombstone under a freshly acquired -// exclusive lease: leased rolls back, deleting rolls forward. +// recoverSnapTombstone drives id's tombstone under a freshly acquired exclusive lease: leased rolls back, deleting rolls forward. func (lf *LocalFile) recoverSnapTombstone(ctx context.Context, id string) error { fl, ok, err := lf.tryExclusiveLease(id) if err != nil { @@ -152,8 +151,7 @@ func (lf *LocalFile) guardSnapTombstone(ctx context.Context, id string, releaseS present = rec != nil return err }); err != nil { - // The guard consumes the lease on every path: leaking a shared flock - // here would block exclusive delete/GC for the process lifetime. + // The guard consumes the lease on every path: leaking a shared flock here would block exclusive delete/GC for the process lifetime. releaseShared() return err } diff --git a/types/config.go b/types/config.go index ef41d39b..9cd79714 100644 --- a/types/config.go +++ b/types/config.go @@ -24,9 +24,10 @@ type Config struct { // HugePages backs CH guest memory with hugetlbfs (costs snapshots the mmap fast path); fixed at create, persists through clone/restore. HugePages bool `json:"hugepages,omitempty"` - // Raw cgroup v2 CPU knobs; zero derives the Guaranteed-at-N defaults from CPU. - CPUWeight int `json:"cpu_weight,omitempty"` - CPUQuotaUs int64 `json:"cpu_quota_us,omitempty"` - CPUPeriodUs int64 `json:"cpu_period_us,omitempty"` - CPUBurstUs int64 `json:"cpu_burst_us,omitempty"` + // Raw cgroup v2 CPU knobs; zero derives the Guaranteed-at-N defaults from CPU (CPUSetCPUs empty = no placement). + CPUWeight int `json:"cpu_weight,omitempty"` + CPUQuotaUs int64 `json:"cpu_quota_us,omitempty"` + CPUPeriodUs int64 `json:"cpu_period_us,omitempty"` + CPUBurstUs int64 `json:"cpu_burst_us,omitempty"` + CPUSetCPUs string `json:"cpuset_cpus,omitempty"` } diff --git a/types/snapshot.go b/types/snapshot.go index 37da8eee..1b21df11 100644 --- a/types/snapshot.go +++ b/types/snapshot.go @@ -5,8 +5,7 @@ import ( "time" ) -// SnapshotConfig carries the parameters for creating a snapshot. -// The hypervisor fills ID, Image, ImageBlobIDs, Hypervisor, and resource fields; the CLI adds Name and Description. +// SnapshotConfig carries the parameters for creating a snapshot. The hypervisor fills ID, Image, ImageBlobIDs, Hypervisor, and resource fields; the CLI adds Name and Description. type SnapshotConfig struct { Config diff --git a/utils/process.go b/utils/process.go index e892fc95..2281739a 100644 --- a/utils/process.go +++ b/utils/process.go @@ -13,8 +13,7 @@ import ( const killWaitTimeout = 5 * time.Second -// WritePIDFile writes pid to path atomically, so a concurrent ReadPIDFile -// never observes a truncated or empty file mid-write. +// WritePIDFile writes pid to path atomically, so a concurrent ReadPIDFile never observes a truncated or empty file mid-write. func WritePIDFile(path string, pid int) error { return AtomicWriteFileNoSync(path, []byte(strconv.Itoa(pid)+"\n"), 0o600) } diff --git a/utils/process_linux.go b/utils/process_linux.go index daae4cd9..b7176c8f 100644 --- a/utils/process_linux.go +++ b/utils/process_linux.go @@ -59,8 +59,6 @@ func scanProcsByBinary(binaryName string, readFile func(string) ([]byte, error), } data, readErr := readFile(fmt.Sprintf("/proc/%d/cmdline", pid)) if readErr != nil { - // A vanished (dead) process is expected churn; a read failure on a - // live one poisons the scan. if alive(pid) { return nil, readErr }