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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1067,7 +1067,8 @@ nicConfigurationOperator:
rdmaPrefix: "rdma_r%rail%" # RDMA device name template (%rail% substituted per rail)
netdevPrefix: "eth_r%rail%" # Network interface name template (%rail% substituted per rail)
spectrumX:
# nicSelector.nicType is derived from the shared east-west PF deviceID.
# NicConfigurationTemplate nicType and pciAddresses are derived from the
# east-west PF inventory of each source group.
overlay: none
singlePlane:
netdevPrefix: "eth_r%rail_id%"
Expand Down Expand Up @@ -1331,7 +1332,7 @@ clusterConfig:

During cluster discovery, the tool automatically identifies BlueField DPU devices (as opposed to SuperNICs or ConnectX NICs) by matching each device's `partNumber` against a known list of DPU product codes in [pkg/networkoperatorplugin/discovery/ns-product-ids](pkg/networkoperatorplugin/discovery/ns-product-ids). Devices matching a DPU product code are classified as **north-south** traffic (management/external), while all other devices are classified as **east-west** traffic (GPU interconnect).

North-south PFs are included in the saved cluster configuration for visibility, but are **automatically filtered out** during template rendering so that only east-west PFs appear in the generated manifests. Each east-west PF is assigned a sequential rail number (rail-0, rail-1, rail-2, ...) used for naming resources like SriovNetworkNodePolicy and IPPool entries.
North-south PFs are included in the saved cluster configuration for visibility, but are **automatically filtered out** during template rendering so that only east-west PFs appear in the generated manifests. Spectrum-X renders one `NicConfigurationTemplate` per source hardware group and selects the intersection of that group's east-west NIC type and PCI addresses. This prevents a north-south DPU from receiving Spectrum-X settings when it reports the same device ID as an east-west SuperNIC. Each east-west PF is assigned a sequential rail number (rail-0, rail-1, rail-2, ...) used for naming resources like SriovNetworkNodePolicy and IPPool entries.

Example of mixed traffic types in the config:
```yaml
Expand Down
12 changes: 7 additions & 5 deletions docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,11 +245,13 @@ spectrumX:
| `spectrumX.hwplb` | Prefix block selected by `hwplb`; defaults RDMA to rail-only and NET to rail-plane names. |
| `spectrumX.swplb` | Prefix block selected by `swplb`; defaults both device types to rail-plane names. |

Spectrum-X `NicConfigurationTemplate.spec.nicSelector.nicType` is not a
separate configuration input. Launch Kit derives it for each generated group
from `clusterConfig[].pfs[].deviceID` entries whose `traffic` is `east-west`.
North-south PFs are ignored; every selected east-west PF must have the same
non-empty device ID or generation fails.
Spectrum-X `NicConfigurationTemplate.spec.nicSelector` is not a separate
configuration input. Launch Kit renders one template per source hardware group
and derives both `nicType` and `pciAddresses` from `clusterConfig[].pfs[]`
entries whose `traffic` is `east-west`. The NIC Configuration Operator matches
the intersection of those fields, so a north-south DPU with the same device ID
as an east-west SuperNIC is not selected. Every selected east-west PF must have
the same non-empty device ID and a non-empty PCI address or generation fails.

## Profile

Expand Down
20 changes: 15 additions & 5 deletions docs/user/spectrum-x.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,21 @@ The platform is read from `clusterConfig[].gpuType`, with `machineType` as a
fallback. `--for` presets participate in the same resolution before manifests
are rendered.

The generated `NicConfigurationTemplate` also derives
`spec.nicSelector.nicType` from the target group's east-west PF device IDs.
There is no separate `spectrumX.nicType` setting. North-south PFs are ignored,
and generation requires every selected east-west PF to have the same non-empty
`deviceID`.
The generated `NicConfigurationTemplate` derives both
`spec.nicSelector.nicType` and `spec.nicSelector.pciAddresses` from the target
source group's east-west PFs. There is no separate `spectrumX.nicType` setting.
Each source hardware group gets its own template, and the operator matches the
intersection of NIC type and PCI address. A north-south DPU is therefore not
selected even when it reports the same device ID as an east-west SuperNIC.
Generation requires every selected east-west PF to have the same non-empty
`deviceID` and a non-empty `pciAddress`.

Template names remain source-group based in both full and strict-subset
renders, so changing `--groups` does not rename retained templates. When
upgrading from a Launch Kit version that generated one merged, type-only
template, deploy preflight reports that legacy template as a stray resource.
Review the report, then use `--overwrite-existing` to delete the broad template
before applying the new PCI-scoped templates.

B300 and GB300 support both `swplb` and `hwplb`, so platform type does not
identify which load-balancing mechanism the fabric uses. l8k defaults to the
Expand Down
45 changes: 36 additions & 9 deletions pkg/networkoperatorplugin/grouping_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,9 +238,10 @@ func TestSpectrumXGrouping(t *testing.T) {
// north-south DPU PCIs. Merge keys on (gpuType, east-west rail count)
// — both match — and hasRailPciConflict only looks at east-west PFs, so
// no conflict. The merged bucket emits one rail-pool config and one
// CIDRPool per bucket. NicInterfaceNameTemplate is ScopePerSource —
// always one per source group regardless of whether PCIs agree, so each
// node gets its own udev-rule template even when bodies happen to match.
// CIDRPool per bucket. NicInterfaceNameTemplate and
// NicConfigurationTemplate are ScopePerSource — always one per source
// group regardless of whether PCIs agree, so each node gets selectors
// built from its own hardware inventory.
name: "same east-west PCIs different north-south: merges fully, per-source name templates",
configFile: "same-ew-different-ns.yaml",
planes: 1,
Expand All @@ -252,7 +253,8 @@ func TestSpectrumXGrouping(t *testing.T) {
},
wantCidrPools: []string{"60-cidrpool-gpu-model-x.yaml"},
wantNicCfgTmpls: []string{
"30-nicconfigurationtemplate-gpu-model-x.yaml",
"30-nicconfigurationtemplate-group-0.yaml",
"30-nicconfigurationtemplate-group-1.yaml",
},
wantNicType: "1023",
},
Expand All @@ -276,7 +278,9 @@ func TestSpectrumXGrouping(t *testing.T) {
},
wantCidrPools: []string{"60-cidrpool-gpu-model-y.yaml"},
wantNicCfgTmpls: []string{
"30-nicconfigurationtemplate-gpu-model-y.yaml",
"30-nicconfigurationtemplate-group-0.yaml",
"30-nicconfigurationtemplate-group-1.yaml",
"30-nicconfigurationtemplate-group-2.yaml",
},
wantNicType: "a2dc",
},
Expand Down Expand Up @@ -306,7 +310,8 @@ func TestSpectrumXGrouping(t *testing.T) {
"60-cidrpool-group-2.yaml",
},
wantNicCfgTmpls: []string{
"30-nicconfigurationtemplate-gpu-model-y.yaml",
"30-nicconfigurationtemplate-group-0.yaml",
"30-nicconfigurationtemplate-group-1.yaml",
"30-nicconfigurationtemplate-group-2.yaml",
},
wantNicType: "a2dc",
Expand Down Expand Up @@ -472,7 +477,7 @@ func TestSpectrumXRA23RendersProfileConfigMap(t *testing.T) {
require.Contains(t, cm, `docaCCVersion: "example"`)
require.NotContains(t, cm, "\r")

nct := rendered["30-nicconfigurationtemplate-gpu-model-y.yaml"]
nct := rendered["30-nicconfigurationtemplate-group-0.yaml"]
require.Contains(t, nct, `version: "site-ra23-profile"`)
}

Expand Down Expand Up @@ -592,9 +597,10 @@ func TestPCIeRoot(t *testing.T) {
// - ScopeBucketed (CIDRPool): one CR per bucket, named with bucket id
// - ScopeAggregate (DaemonSet): one CR per bucket; nodeAffinity
// emits an `In` list of source machine labels
// - ScopeSimpleSelect (SpectrumXRailPoolConfig, NicConfigurationTemplate):
// - ScopeSimpleSelect (SpectrumXRailPoolConfig):
// N CRs per bucket — one per source group
// - ScopePerSource (NicInterfaceNameTemplate): N CRs per bucket
// - ScopePerSource (NicInterfaceNameTemplate, NicConfigurationTemplate):
// N CRs per bucket
func TestSpectrumXModeBSubsetFilter(t *testing.T) {
ctrllog.SetLogger(zap.New(zap.UseDevMode(true)))
cfg, err := config.LoadFullConfig(
Expand All @@ -617,6 +623,9 @@ func TestSpectrumXModeBSubsetFilter(t *testing.T) {
},
}

fullRendered, err := (&NetworkOperatorPlugin{}).GenerateProfileDeploymentFiles(loadSpectrumXProfile(t), cfg)
require.NoError(t, err)

plugin := &NetworkOperatorPlugin{Groups: []string{"group-0", "group-1"}}
rendered, err := plugin.GenerateProfileDeploymentFiles(loadSpectrumXProfile(t), cfg)
require.NoError(t, err)
Expand All @@ -642,6 +651,24 @@ func TestSpectrumXModeBSubsetFilter(t *testing.T) {
"25-nicinterfacenametemplate-group-1.yaml",
}, ninTmpls)

// NicConfigurationTemplate (PerSource): source-based names are stable
// between full Mode A and strict-subset Mode B renders. A retained source
// must never be renamed just because the selection mode changed.
nicConfigTmpls := fileNamesMatching(rendered, "30-nicconfigurationtemplate")
require.Equal(t, []string{
"30-nicconfigurationtemplate-group-0.yaml",
"30-nicconfigurationtemplate-group-1.yaml",
}, nicConfigTmpls)
require.Equal(t, []string{
"30-nicconfigurationtemplate-group-0.yaml",
"30-nicconfigurationtemplate-group-1.yaml",
"30-nicconfigurationtemplate-group-2.yaml",
}, fileNamesMatching(fullRendered, "30-nicconfigurationtemplate"))
for _, fileName := range nicConfigTmpls {
require.Contains(t, fullRendered, fileName,
"retained NicConfigurationTemplate %s must keep its identity across render modes", fileName)
}

// Example DaemonSet (Aggregate): one CR per bucket, with In selector
// listing source machine labels.
dsFiles := fileNamesMatching(rendered, "90-example-daemonset")
Expand Down
154 changes: 154 additions & 0 deletions pkg/networkoperatorplugin/nic_configuration_template_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
// Copyright 2026 NVIDIA CORPORATION & AFFILIATES
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0

package networkoperatorplugin

import (
"fmt"
"path/filepath"
"testing"

"github.com/nvidia/k8s-launch-kit/pkg/config"
"github.com/stretchr/testify/require"
ctrllog "sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/log/zap"
"sigs.k8s.io/yaml"
)

func TestSpectrumXPCIAddresses(t *testing.T) {
group := &config.ClusterConfig{
Identifier: "machine-a",
PFs: []config.PFConfig{
{PciAddress: " 000A:19:00.0 ", Traffic: "east-west"},
{PciAddress: "000a:19:00.0", Traffic: "east-west"},
{PciAddress: "0000:03:00.0", Traffic: "north-south"},
},
}

addresses, err := spectrumXPCIAddresses(group)
require.NoError(t, err)
require.Equal(t, []string{"000a:19:00.0"}, addresses,
"the selector must normalize, deduplicate, and exclude north-south PCI addresses")

group.PFs[0].PciAddress = ""
group.PFs = group.PFs[:1]
_, err = spectrumXPCIAddresses(group)
require.ErrorContains(t, err, `group "machine-a" has an east-west PF without a pciAddress`)
}

func TestSpectrumXNicConfigurationTemplateExcludesSameTypeNorthSouthDevices(t *testing.T) {
tests := []struct {
name string
profileDir string
spcxVersion string
}{
{name: "RA2.1", profileDir: "spectrum-x-ra2.1", spcxVersion: "RA2.1"},
{name: "RA2.2", profileDir: "spectrum-x-ra2.2", spcxVersion: "RA2.2"},
{name: "RA2.3", profileDir: "spectrum-x", spcxVersion: "RA2.3"},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
ctrllog.SetLogger(zap.New(zap.UseDevMode(true)))
cfg, err := config.LoadFullConfig(
filepath.Join("testdata", "grouping", "same-ew-different-ns.yaml"),
ctrllog.Log,
)
require.NoError(t, err)

for groupIndex := range cfg.ClusterConfig {
machineLabel := "machine-a-gpu-model-x"
if groupIndex == 1 {
machineLabel = "machine-b-gpu-model-x"
}
cfg.ClusterConfig[groupIndex].NodeSelector = map[string]string{
config.MachineLabelKey: machineLabel,
}
for pfIndex := range cfg.ClusterConfig[groupIndex].PFs {
// Model the reported failure: both the east-west SuperNIC and
// north-south DPU expose the same BlueField-3 device ID.
cfg.ClusterConfig[groupIndex].PFs[pfIndex].DeviceID = "a2dc"
}
}
cfg.Profile = &config.Profile{
Fabric: "ethernet",
Deployment: "sriov",
Multirail: true,
SpectrumX: &config.ProfileSpectrumX{
Enable: true,
SPCXVersion: test.spcxVersion,
MultiplaneMode: "none",
NumberOfPlanes: 1,
ConfigMapName: "site-ra23-profile",
},
}

profile := loadProfileFromDir(t, test.profileDir)
var nicConfigurationTemplate string
for _, templatePath := range profile.Templates {
if filepath.Base(templatePath) == "30-nicconfigurationtemplate.yaml" {
nicConfigurationTemplate = templatePath
break
}
}
require.NotEmpty(t, nicConfigurationTemplate)
profile.Templates = []string{nicConfigurationTemplate}

rendered, err := (&NetworkOperatorPlugin{}).GenerateProfileDeploymentFiles(profile, cfg)
require.NoError(t, err)
require.Equal(t, []string{
"30-nicconfigurationtemplate-group-0.yaml",
"30-nicconfigurationtemplate-group-1.yaml",
}, fileNamesMatching(rendered, "30-nicconfigurationtemplate"),
"a merged hardware bucket must still emit one PCI-scoped template per source group")

for groupIndex, group := range cfg.ClusterConfig {
fileName := fmt.Sprintf("30-nicconfigurationtemplate-group-%d.yaml", groupIndex)
manifest := rendered[fileName]
require.NotEmpty(t, manifest)

var object struct {
Spec struct {
NodeSelector map[string]string `yaml:"nodeSelector"`
NicSelector struct {
NicType string `yaml:"nicType"`
PCIAddresses []string `yaml:"pciAddresses"`
} `yaml:"nicSelector"`
} `yaml:"spec"`
}
require.NoError(t, yaml.Unmarshal([]byte(manifest), &object))
require.Equal(t, "a2dc", object.Spec.NicSelector.NicType)
require.Equal(t, group.NodeSelector, object.Spec.NodeSelector)

var eastWestPCIs []string
var northSouthPCIs []string
for _, pf := range group.PFs {
switch pf.Traffic {
case "east-west":
eastWestPCIs = append(eastWestPCIs, pf.PciAddress)
case "north-south":
northSouthPCIs = append(northSouthPCIs, pf.PciAddress)
}
}
require.Equal(t, eastWestPCIs, object.Spec.NicSelector.PCIAddresses)
for _, pciAddress := range northSouthPCIs {
require.NotContains(t, object.Spec.NicSelector.PCIAddresses, pciAddress,
"north-south PCI address %s must not be selected", pciAddress)
}
}
})
}
}
15 changes: 8 additions & 7 deletions pkg/networkoperatorplugin/scopes.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,14 @@ const (
// poolName-equivalent) so companion ScopeBucketed / ScopeAggregate
// CRs can reference one stable name. Examples: NicNodePolicy,
// SriovNetworkNodePolicy, SriovNetworkPoolConfig,
// SpectrumXRailPoolConfig, NicConfigurationTemplate.
// SpectrumXRailPoolConfig.
ScopeSimpleSelect

// ScopePerSource: always one CR per source group, regardless of
// merge. Used for Kinds whose body depends on machine-specific data
// (e.g. PCI addresses) that cannot meaningfully merge across
// source machineTypes. Example: NicInterfaceNameTemplate.
// source machineTypes. Examples: NicInterfaceNameTemplate,
// NicConfigurationTemplate.
ScopePerSource
)

Expand Down Expand Up @@ -106,15 +107,15 @@ var crScopeByKind = map[string]CRScope{
// once per source under Mode B. Shared resources (resourceName,
// poolName, etc.) reference the merged bucket identifier so all
// per-source CRs register the same kubelet resource.
"NicNodePolicy": ScopeSimpleSelect,
"SriovNetworkNodePolicy": ScopeSimpleSelect,
"SriovNetworkPoolConfig": ScopeSimpleSelect,
"SpectrumXRailPoolConfig": ScopeSimpleSelect,
"NicConfigurationTemplate": ScopeSimpleSelect,
"NicNodePolicy": ScopeSimpleSelect,
"SriovNetworkNodePolicy": ScopeSimpleSelect,
"SriovNetworkPoolConfig": ScopeSimpleSelect,
"SpectrumXRailPoolConfig": ScopeSimpleSelect,

// Per-source — body depends on machine-specific data like PCI
// addresses; never merged across source groups.
"NicInterfaceNameTemplate": ScopePerSource,
"NicConfigurationTemplate": ScopePerSource,
}

// ScopeForKind returns the registered scope for a Kubernetes Kind.
Expand Down
Loading