Skip to content

Gating New Behavior

Put a behavior behind a feature gate when it should ship before it becomes part of Mokka's default contract. Mokka uses the Kubernetes component-base feature gate through internal/features, the way Kubernetes components, Cluster API and Kueue do. See Feature Gates for how operators set gates and what each stage means.

Gate or chart value

A feature gate is a temporary escape hatch for a behavior inside a running service, removed once the behavior is stable. A permanent choice, such as whether the chart deploys a component (nri.enabled) or which mode it runs in (nri.deviceInjectionMode), is a chart value instead: the chart decides what to render, and a gate inside a binary cannot.

Add a gate

Declare the gate and its first stage in internal/features/features.go. The name is PascalCase and describes the behavior, not its implementation:

const (
    // owner: @github-handle
    // issue: https://github.com/NVIDIA/k8s-test-infra/issues/1234
    //
    // Selects the alternate placement planner.
    AlternatePlanner featuregate.Feature = "AlternatePlanner"
)

var versionedSpecs = map[featuregate.Feature]featuregate.VersionedSpecs{
    AlternatePlanner: {
        {Version: version.MajorMinor(0, 5), Default: false, PreRelease: featuregate.Alpha},
    },
}

Version is the Mokka minor release that introduces the stage.

In the same pull request:

  • Add the gate to featureGates.properties in the chart's values.schema.json as { "type": "boolean" }. TestChartSchemaListsRegisteredGates fails until the schema matches the registered gates.
  • List the gate under Available gates.
  • Add an Added changelog fragment that names the gate.

Check a gate

Only node-agent, nri-plugin and control-plane apply gates. nvml-mock-ctl, which also runs as the chart's allocation-watcher sidecar, and the mock NVML and CUDA libraries loaded into workloads never do: features.Enabled there always returns the gate's default, without an error. The packages under pkg/gpu/, and the internal packages they import such as internal/gpuarch, run in those processes too, so they must not call features.Enabled.

Read the gate in the service that owns the behavior and pass the result down as a value, for example as a field of the configuration the service builds at startup:

cfg.Planner = planner.Current
if features.Enabled(features.AlternatePlanner) {
    cfg.Planner = planner.Alternate
}

Behavior inside the mock libraries needs a service to hand over the decision the same way, for example through the configuration the library reads. A package that runs only inside one service, such as internal/agent, internal/nri or internal/controlplane, may instead check the gate at the point where the old and new behavior diverge. To check a package, make sure go list -deps ./cmd/nvml-mock-ctl ./pkg/gpu/mocknvml/bridge does not list it.

Do not read flags, environment variables or Helm values for a gate; internal/features owns them, and the three services apply them the same way.

Test both states

Because most code receives the decision as a value, most tests need no gate at all. Where a test must flip the process-wide gate, use features.SetFeatureGateDuringTest, which restores the previous value when the test ends:

func TestAlternatePlanner(t *testing.T) {
    features.SetFeatureGateDuringTest(t, features.AlternatePlanner, true)
    // ...
}

A test that sets a gate must not call t.Parallel(). Never pass a value the gate rejects to features.ConfigureFromCLI in a test: the process-wide gate keeps the rejected entry, and every later change to it in the same test binary fails. Test rejection on a fresh gate, as internal/features does.

Change a gate's stage

Append a spec for the release that changes the stage; keep the earlier ones as history:

AlternatePlanner: {
    {Version: version.MajorMinor(0, 5), Default: false, PreRelease: featuregate.Alpha},
    {Version: version.MajorMinor(0, 6), Default: true, PreRelease: featuregate.Beta},
    {Version: version.MajorMinor(0, 7), Default: true, PreRelease: featuregate.GA, LockToDefault: true},
},
  • A GA (generally available) gate sets LockToDefault: true, so the behavior can no longer be turned off.
  • A deprecated gate defaults to false. Set LockToDefault: true once the behavior can no longer be turned back on.
  • A gate that was locked stays locked, so a GA gate is removed, not deprecated.
  • When a gate becomes locked, change its schema entry to { "const": <default> }, so Helm rejects an override before rollout.
  • Update the gate's entry under Available gates and add a changelog fragment: Changed for a promotion, Deprecated when the gate is deprecated.

A gate that retires behavior which never had a gate starts with an unlocked version.MajorMinor(0, 0) entry recording that behavior:

LegacyPlanner: {
    {Version: version.MajorMinor(0, 0), Default: true, PreRelease: featuregate.GA},
    {Version: version.MajorMinor(0, 6), Default: false, PreRelease: featuregate.Deprecated},
},

Without it, component-base rejects a first spec that is deprecated at a version such as 0.6; its error asks for a "1.0 entry", which for Mokka's 0.x releases means 0.0.

Remove a gate

Remove a GA or deprecated gate once the removal policy allows it; an alpha gate can go in any release. Nothing checks the policy yet; the first gate to reach GA should add a test that compares its stage version with the chart's appVersion. Delete the constant, its specs, its schema entry, the branch that can no longer run, the tests of the other state, and its entry under Available gates. Add a Removed changelog fragment, so operators drop the gate from their values before the upgrade that rejects it.

To read about See
Setting gates and what each stage means Feature Gates
Running the tests Testing
Writing the changelog fragment Pull Requests