Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,12 @@ l8k generate --user-config ./cluster-config.yaml \
The generated config already contains the resolved profile. Pass profile flags
to `generate` only when you want to override the saved values.

Every Kubernetes object in the generated bundle carries
`nvidia.kubernetes-launch-kit.version: <l8k-release-version>` in
`metadata.annotations`. This identifies the Launch Kit release that rendered
the object without replacing annotations already supplied by a profile or a
custom workload manifest.

Apply the generated manifests to the cluster:

```bash
Expand All @@ -346,6 +352,10 @@ caps the whole apply+reconcile phase end-to-end (e.g. `--deploy-timeout 90m`);
without it, deploy polls indefinitely — right for SR-IOV on large clusters
where reconciliation can take an hour.

When Launch Kit installs or upgrades the Network Operator chart, its Helm
post-renderer adds the same version annotation to chart-rendered resources.
Helm hooks and chart CRDs are outside Helm's post-renderer stream.

Verify the deployment end-to-end:

```bash
Expand Down
1 change: 1 addition & 0 deletions pkg/app/launcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ func (l *Launcher) RunContext(ctx context.Context) error {
switch pluginName {
case networkoperatorplugin.PluginName:
l.plugins[pluginName] = &networkoperatorplugin.NetworkOperatorPlugin{
LaunchKitVersion: l.options.LaunchKitVersion,
Groups: l.options.Groups,
GpuType: l.options.GpuType,
NodeSelector: parseNodeSelector(l.options.NodeSelector),
Expand Down
1 change: 1 addition & 0 deletions pkg/cmd/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ is used as the manifest directory.`,
Run: func(cmd *cobra.Command, args []string) {
runTargetCommand(cmd, target.Deploy, hosttarget.NewDeployAdapter(
hosttarget.DeployRequest{
LaunchKitVersion: Version,
Kubeconfig: kubeconfig,
DeploymentFiles: deploymentFiles,
UserConfig: userConfig,
Expand Down
1 change: 1 addition & 0 deletions pkg/cmd/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ Optionally deploy the generated manifests with --deploy.`,
--save-deployment-files ./output`,
Run: func(cmd *cobra.Command, args []string) {
opts := options.Options{
LaunchKitVersion: Version,
ConfigDir: configDir,
UserConfig: userConfig,
Fabric: fabric,
Expand Down
1 change: 1 addition & 0 deletions pkg/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ Use 'l8k schema' to discover tool capabilities programmatically.`,
enabledPlugins := parseEnabledPlugins(enabledPlugins)
// Create application options from CLI flags
opts := options.Options{
LaunchKitVersion: Version,
LogLevel: logLevel,
LogFile: logFile,
ConfigDir: configDir,
Expand Down
109 changes: 109 additions & 0 deletions pkg/networkoperatorplugin/annotations.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// Copyright 2026 NVIDIA CORPORATION & AFFILIATES.
//
// SPDX-License-Identifier: Apache-2.0

package networkoperatorplugin

import (
"bytes"
"fmt"
"io"

yaml "gopkg.in/yaml.v3"
)

const launchKitVersionAnnotation = "nvidia.kubernetes-launch-kit.version"

type annotationPostRenderer struct {
version string
}

func (r annotationPostRenderer) Run(renderedManifests *bytes.Buffer) (*bytes.Buffer, error) {
if renderedManifests == nil {
return nil, fmt.Errorf("rendered manifests must not be nil")
}
annotated, err := annotateResources(renderedManifests.Bytes(), r.version)
if err != nil {
return nil, err
}
return bytes.NewBuffer(annotated), nil
}

// annotateResources adds the Launch Kit version annotation to every resource
// in a YAML stream while preserving existing annotations.
func annotateResources(stream []byte, version string) ([]byte, error) {
decoder := yaml.NewDecoder(bytes.NewReader(stream))
documents := []yaml.Node{}
for {
var document yaml.Node
err := decoder.Decode(&document)
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("decode YAML document %d: %w", len(documents)+1, err)
}
if len(document.Content) == 0 ||
(document.Content[0].Kind == yaml.ScalarNode && document.Content[0].Tag == "!!null") {
continue
}
documents = append(documents, document)
}

for i := range documents {
root := documents[i].Content[0]
if root.Kind != yaml.MappingNode {
return nil, fmt.Errorf("YAML document %d must contain a Kubernetes resource mapping", i+1)
}
metadata, ok := yamlMappingValue(root, "metadata")
if !ok || metadata.Kind != yaml.MappingNode {
return nil, fmt.Errorf("YAML document %d must contain a metadata mapping", i+1)
}
annotations, ok := yamlMappingValue(metadata, "annotations")
if !ok {
annotations = &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
metadata.Content = append(metadata.Content,
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "annotations"},
annotations,
)
} else if annotations.Kind == yaml.ScalarNode && annotations.Tag == "!!null" {
*annotations = yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
} else if annotations.Kind != yaml.MappingNode {
return nil, fmt.Errorf("YAML document %d metadata.annotations must be a mapping", i+1)
}
setYAMLMappingString(annotations, launchKitVersionAnnotation, version)
}

var output bytes.Buffer
encoder := yaml.NewEncoder(&output)
encoder.SetIndent(2)
for i := range documents {
if err := encoder.Encode(&documents[i]); err != nil {
return nil, fmt.Errorf("encode annotated YAML document %d: %w", i+1, err)
}
}
if err := encoder.Close(); err != nil {
return nil, fmt.Errorf("close annotated YAML encoder: %w", err)
}
return output.Bytes(), nil
}

func yamlMappingValue(mapping *yaml.Node, key string) (*yaml.Node, bool) {
for i := 0; mapping != nil && mapping.Kind == yaml.MappingNode && i+1 < len(mapping.Content); i += 2 {
if mapping.Content[i].Value == key {
return mapping.Content[i+1], true
}
}
return nil, false
}

func setYAMLMappingString(mapping *yaml.Node, key, value string) {
if current, ok := yamlMappingValue(mapping, key); ok {
*current = yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value, Style: yaml.DoubleQuotedStyle}
return
}
mapping.Content = append(mapping.Content,
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key},
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value, Style: yaml.DoubleQuotedStyle},
)
}
49 changes: 49 additions & 0 deletions pkg/networkoperatorplugin/annotations_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Copyright 2026 NVIDIA CORPORATION & AFFILIATES.
//
// SPDX-License-Identifier: Apache-2.0

package networkoperatorplugin

import (
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

const testLaunchKitVersion = "v26.7.0-test"

func countVersionAnnotations(t *testing.T, stream []byte, version string) int {
t.Helper()
return strings.Count(string(stream), launchKitVersionAnnotation+`: "`+version+`"`)
}

func TestAnnotateResources(t *testing.T) {
input := []byte(`apiVersion: example.io/v1
kind: First
metadata:
name: first
annotations:
existing: keep
nvidia.kubernetes-launch-kit.version: "old"
spec: {}
---
apiVersion: example.io/v1
kind: Second
metadata:
name: second
spec: {}
`)

annotated, err := annotateResources(input, "v26.7.0-rc.1")
require.NoError(t, err)
assert.Equal(t, 2, countVersionAnnotations(t, annotated, "v26.7.0-rc.1"))
assert.Contains(t, string(annotated), "existing: keep")
assert.NotContains(t, string(annotated), `nvidia.kubernetes-launch-kit.version: "old"`)
}

func TestAnnotateResourcesRejectsMissingMetadata(t *testing.T) {
_, err := annotateResources([]byte("apiVersion: v1\nkind: ConfigMap\n"), "v1.0.0")
require.ErrorContains(t, err, "metadata mapping")
}
6 changes: 5 additions & 1 deletion pkg/networkoperatorplugin/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ const defaultHelmInstallTimeout = 10 * time.Minute
// callsite readable as Phase 0 (helm install) and the existing four phases
// grow more parameters over time.
type DeployOptions struct {
// LaunchKitVersion is applied to resources rendered by Helm.
LaunchKitVersion string

// DryRun threads through to server-side dry-run for apply and to
// action.Install.DryRun / action.Upgrade.DryRun for helm.
DryRun bool
Expand Down Expand Up @@ -111,6 +114,7 @@ type appliedManifest struct {
func (p *NetworkOperatorPlugin) DeployProfile(ctx context.Context, profile *profiles.Profile, kubeClient client.Client, manifestsDir string) error {
_ = profile
return ApplyManifestsFromDir(ctx, kubeClient, manifestsDir, DeployOptions{
LaunchKitVersion: p.LaunchKitVersion,
DryRun: p.DryRun,
OverwriteExisting: p.OverwriteExisting,
RestConfig: p.RESTConfig,
Expand Down Expand Up @@ -752,7 +756,7 @@ func runHelmInstallPhase(ctx context.Context, manifestsDir string, opts DeployOp
}
}

err = InstallOrUpgrade(ctx, opts.RestConfig, opts.NetworkOperator, valuesYAML, opts.OverwriteExisting, timeout, opts.DryRun)
err = InstallOrUpgrade(ctx, opts.RestConfig, opts.NetworkOperator, valuesYAML, opts.LaunchKitVersion, opts.OverwriteExisting, timeout, opts.DryRun)
if err == nil {
if opts.DryRun {
uiOutput.Success("Dry-run: helm install would create network-operator release in namespace %s",
Expand Down
14 changes: 10 additions & 4 deletions pkg/networkoperatorplugin/helm.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ func InstallOrUpgrade(
restConfig *rest.Config,
cfg *config.NetworkOperatorConfig,
valuesYAML []byte,
launchKitVersion string,
overwriteExisting bool,
timeout time.Duration,
dryRun bool,
Expand Down Expand Up @@ -225,7 +226,7 @@ func InstallOrUpgrade(
return chrt, nil
}

return installOrUpgradeWithLoader(ctx, actionCfg, loadChart, generated, chartVersion, namespace, overwriteExisting, timeout, dryRun)
return installOrUpgradeWithLoader(ctx, actionCfg, loadChart, generated, chartVersion, namespace, launchKitVersion, overwriteExisting, timeout, dryRun)
}

// installOrUpgradeWithLoader is the test seam for InstallOrUpgrade: it owns
Expand All @@ -245,6 +246,7 @@ func installOrUpgradeWithLoader(
loadChart func() (*chart.Chart, error),
generated map[string]interface{},
chartVersion, namespace string,
launchKitVersion string,
overwriteExisting bool,
timeout time.Duration,
dryRun bool,
Expand Down Expand Up @@ -278,7 +280,7 @@ func installOrUpgradeWithLoader(
if lerr != nil {
return lerr
}
return runUpgrade(ctx, actionCfg, chrt, generated, chartVersion, namespace, timeout, dryRun)
return runUpgrade(ctx, actionCfg, chrt, generated, chartVersion, namespace, launchKitVersion, timeout, dryRun)
}

// Values gate — same diff logic the preflight values check
Expand All @@ -297,14 +299,14 @@ func installOrUpgradeWithLoader(
if lerr != nil {
return lerr
}
return runUpgrade(ctx, actionCfg, chrt, generated, chartVersion, namespace, timeout, dryRun)
return runUpgrade(ctx, actionCfg, chrt, generated, chartVersion, namespace, launchKitVersion, timeout, dryRun)

case errors.Is(getErr, driver.ErrReleaseNotFound):
chrt, lerr := loadChart()
if lerr != nil {
return lerr
}
return runInstall(ctx, actionCfg, chrt, generated, chartVersion, namespace, timeout, dryRun)
return runInstall(ctx, actionCfg, chrt, generated, chartVersion, namespace, launchKitVersion, timeout, dryRun)

default:
return pkgerrors.NewClusterError(
Expand All @@ -321,6 +323,7 @@ func runInstall(
chrt *chart.Chart,
values map[string]interface{},
chartVersion, namespace string,
launchKitVersion string,
timeout time.Duration,
dryRun bool,
) error {
Expand All @@ -332,6 +335,7 @@ func runInstall(
inst.Timeout = timeout
inst.DryRun = dryRun
inst.Version = chartVersion
inst.PostRenderer = annotationPostRenderer{version: launchKitVersion}

if _, err := inst.RunWithContext(ctx, chrt, values); err != nil {
return pkgerrors.NewDeploymentError(
Expand All @@ -349,6 +353,7 @@ func runUpgrade(
chrt *chart.Chart,
values map[string]interface{},
chartVersion, namespace string,
launchKitVersion string,
timeout time.Duration,
dryRun bool,
) error {
Expand All @@ -359,6 +364,7 @@ func runUpgrade(
upg.Timeout = timeout
upg.DryRun = dryRun
upg.Version = chartVersion
upg.PostRenderer = annotationPostRenderer{version: launchKitVersion}

if _, err := upg.RunWithContext(ctx, helmclient.DefaultReleaseName, chrt, values); err != nil {
return pkgerrors.NewDeploymentError(
Expand Down
Loading