From 19bb03d1c24051a78619082462ee60b1523a2ef6 Mon Sep 17 00:00:00 2001 From: fracappa Date: Mon, 13 Jul 2026 16:07:18 +0200 Subject: [PATCH 1/2] agent: split fencing credentials placement by identification key Hostname-keyed credentials stay at the top-level /etc/assisted/hostconfig/fencing-credentials.yaml, while MAC-keyed credentials (no hostname) are placed in per-host directories matched by MAC address, e.g. host-0/fencing-credentials.yaml. This allows assisted-service to discover MAC-keyed fencing credentials alongside other host-scoped config files. --- pkg/asset/agent/agentconfig/agenthosts.go | 74 ++++++- .../agent/agentconfig/agenthosts_test.go | 203 ++++++++++++++++++ .../agentconfig/fencingcredentials_test.go | 2 + pkg/asset/agent/image/ignition.go | 41 ++-- pkg/asset/agent/image/ignition_test.go | 69 ++++++ 5 files changed, 375 insertions(+), 14 deletions(-) diff --git a/pkg/asset/agent/agentconfig/agenthosts.go b/pkg/asset/agent/agentconfig/agenthosts.go index 652d6ead436..565c4628860 100644 --- a/pkg/asset/agent/agentconfig/agenthosts.go +++ b/pkg/asset/agent/agentconfig/agenthosts.go @@ -8,6 +8,7 @@ import ( "github.com/pkg/errors" "github.com/sirupsen/logrus" + goyaml "gopkg.in/yaml.v2" "k8s.io/apimachinery/pkg/util/validation/field" "sigs.k8s.io/yaml" @@ -16,6 +17,7 @@ import ( agentAsset "github.com/openshift/installer/pkg/asset/agent" "github.com/openshift/installer/pkg/asset/agent/joiner" "github.com/openshift/installer/pkg/asset/agent/workflow" + "github.com/openshift/installer/pkg/types" "github.com/openshift/installer/pkg/types/agent" "github.com/openshift/installer/pkg/types/baremetal/validation" "github.com/openshift/installer/pkg/validate" @@ -38,11 +40,18 @@ type nmStateInterface struct { } `yaml:"interfaces,omitempty"` } +// FencingCredentialHost holds MAC-keyed fencing credentials matched to a host directory. +type FencingCredentialHost struct { + DirName string + Credentials []*types.Credential +} + // AgentHosts generates the hosts information from the AgentConfig and // OptionalInstallConfig assets. type AgentHosts struct { - Hosts []agent.Host - rendezvousIP string + Hosts []agent.Host + FencingCredentialsByHost []FencingCredentialHost + rendezvousIP string } // Name returns a human friendly name. @@ -93,6 +102,10 @@ func (a *AgentHosts) Generate(_ context.Context, dependencies asset.Parents) err } } + if err := a.populateFencingCredentialHosts(installConfig); err != nil { + return err + } + case workflow.AgentWorkflowTypeAddNodes: a.Hosts = append(a.Hosts, addNodesConfig.Config.Hosts...) for i, host := range a.Hosts { @@ -367,5 +380,62 @@ func (a *AgentHosts) HostConfigFiles() (HostConfigFileMap, error) { files[filepath.Join(name, "role")] = []byte(host.Role) } } + + for _, fch := range a.FencingCredentialsByHost { + cfg := &FencingCredentialsConfig{Credentials: fch.Credentials} + data, err := goyaml.Marshal(cfg) + if err != nil { + return nil, err + } + files[filepath.Join(fch.DirName, "fencing-credentials.yaml")] = data + } + return files, nil } + +func (a *AgentHosts) populateFencingCredentialHosts(installConfig *agentAsset.OptionalInstallConfig) error { + if installConfig.Config == nil || installConfig.Config.ControlPlane == nil || + installConfig.Config.ControlPlane.Fencing == nil { + return nil + } + + credsByDir := map[string][]*types.Credential{} + var dirOrder []string + + for _, cred := range installConfig.Config.ControlPlane.Fencing.Credentials { + if cred.HostName != "" || cred.MACAddress == "" { + continue + } + dirName, err := a.findHostDirForMAC(cred.MACAddress) + if err != nil { + return err + } + if _, ok := credsByDir[dirName]; !ok { + dirOrder = append(dirOrder, dirName) + } + credsByDir[dirName] = append(credsByDir[dirName], cred) + } + + for _, dir := range dirOrder { + a.FencingCredentialsByHost = append(a.FencingCredentialsByHost, FencingCredentialHost{ + DirName: dir, + Credentials: credsByDir[dir], + }) + } + return nil +} + +func (a *AgentHosts) findHostDirForMAC(macAddress string) (string, error) { + normalizedMAC := strings.ToLower(macAddress) + for i, host := range a.Hosts { + for _, iface := range host.Interfaces { + if strings.ToLower(iface.MacAddress) == normalizedMAC { + if host.Hostname != "" { + return host.Hostname, nil + } + return fmt.Sprintf("host-%d", i), nil + } + } + } + return "", fmt.Errorf("fencing credential references MAC address %s which does not match any configured host interface", macAddress) +} diff --git a/pkg/asset/agent/agentconfig/agenthosts_test.go b/pkg/asset/agent/agentconfig/agenthosts_test.go index 36727af3926..0a4364a6c7f 100644 --- a/pkg/asset/agent/agentconfig/agenthosts_test.go +++ b/pkg/asset/agent/agentconfig/agenthosts_test.go @@ -955,6 +955,209 @@ func (ib *InterfacetBuilder) build() *aiv1beta1.Interface { return &ib.Interface } +func TestAgentHosts_FencingCredentialsByHost(t *testing.T) { + cases := []struct { + name string + dependencies []asset.Asset + expectedError string + expectedFencingByHost []FencingCredentialHost + }{ + { + name: "mac-keyed credentials populate FencingCredentialsByHost", + dependencies: []asset.Asset{ + &workflow.AgentWorkflow{Workflow: workflow.AgentWorkflowTypeInstall}, + &joiner.AddNodesConfig{}, + getInstallConfigWithFencing([]*types.Credential{ + {MACAddress: "28:d2:44:d2:b2:1a", Username: "admin", Password: "pass", Address: "redfish+https://10.0.0.1/redfish/v1/Systems/1"}, + }), + getAgentConfigMultiHost("worker"), + }, + expectedFencingByHost: []FencingCredentialHost{ + { + DirName: "test", + Credentials: []*types.Credential{ + {MACAddress: "28:d2:44:d2:b2:1a", Username: "admin", Password: "pass", Address: "redfish+https://10.0.0.1/redfish/v1/Systems/1"}, + }, + }, + }, + }, + { + name: "hostname-keyed credentials are excluded from FencingCredentialsByHost", + dependencies: []asset.Asset{ + &workflow.AgentWorkflow{Workflow: workflow.AgentWorkflowTypeInstall}, + &joiner.AddNodesConfig{}, + getInstallConfigWithFencing([]*types.Credential{ + {HostName: "master-0", Username: "admin", Password: "pass", Address: "redfish+https://10.0.0.1/redfish/v1/Systems/1"}, + }), + getAgentConfigMultiHost("worker"), + }, + expectedFencingByHost: nil, + }, + { + name: "mixed credentials only include mac-keyed in FencingCredentialsByHost", + dependencies: []asset.Asset{ + &workflow.AgentWorkflow{Workflow: workflow.AgentWorkflowTypeInstall}, + &joiner.AddNodesConfig{}, + getInstallConfigWithFencing([]*types.Credential{ + {HostName: "master-0", Username: "admin", Password: "pass", Address: "redfish+https://10.0.0.1/redfish/v1/Systems/1"}, + {MACAddress: "28:d2:44:d2:b2:1b", Username: "admin2", Password: "pass2", Address: "redfish+https://10.0.0.2/redfish/v1/Systems/1"}, + }), + getAgentConfigMultiHost("worker"), + }, + expectedFencingByHost: []FencingCredentialHost{ + { + DirName: "test-2", + Credentials: []*types.Credential{ + {MACAddress: "28:d2:44:d2:b2:1b", Username: "admin2", Password: "pass2", Address: "redfish+https://10.0.0.2/redfish/v1/Systems/1"}, + }, + }, + }, + }, + { + name: "credential with both hostname and mac is treated as hostname-keyed", + dependencies: []asset.Asset{ + &workflow.AgentWorkflow{Workflow: workflow.AgentWorkflowTypeInstall}, + &joiner.AddNodesConfig{}, + getInstallConfigWithFencing([]*types.Credential{ + {HostName: "master-0", MACAddress: "28:d2:44:d2:b2:1a", Username: "admin", Password: "pass", Address: "redfish+https://10.0.0.1/redfish/v1/Systems/1"}, + }), + getAgentConfigMultiHost("worker"), + }, + expectedFencingByHost: nil, + }, + { + name: "mac with no matching host returns error", + dependencies: []asset.Asset{ + &workflow.AgentWorkflow{Workflow: workflow.AgentWorkflowTypeInstall}, + &joiner.AddNodesConfig{}, + getInstallConfigWithFencing([]*types.Credential{ + {MACAddress: "FF:FF:FF:FF:FF:FF", Username: "admin", Password: "pass", Address: "redfish+https://10.0.0.1/redfish/v1/Systems/1"}, + }), + getAgentConfigMultiHost("worker"), + }, + expectedError: "does not match any configured host interface", + }, + { + name: "host without hostname uses host-i directory", + dependencies: []asset.Asset{ + &workflow.AgentWorkflow{Workflow: workflow.AgentWorkflowTypeInstall}, + &joiner.AddNodesConfig{}, + getInstallConfigWithFencing([]*types.Credential{ + {MACAddress: "28:d2:44:d2:b2:1a", Username: "admin", Password: "pass", Address: "redfish+https://10.0.0.1/redfish/v1/Systems/1"}, + }), + getAgentConfigNoHostname(), + }, + expectedFencingByHost: []FencingCredentialHost{ + { + DirName: "host-0", + Credentials: []*types.Credential{ + {MACAddress: "28:d2:44:d2:b2:1a", Username: "admin", Password: "pass", Address: "redfish+https://10.0.0.1/redfish/v1/Systems/1"}, + }, + }, + }, + }, + { + name: "case-insensitive mac matching", + dependencies: []asset.Asset{ + &workflow.AgentWorkflow{Workflow: workflow.AgentWorkflowTypeInstall}, + &joiner.AddNodesConfig{}, + getInstallConfigWithFencing([]*types.Credential{ + {MACAddress: "28:D2:44:D2:B2:1A", Username: "admin", Password: "pass", Address: "redfish+https://10.0.0.1/redfish/v1/Systems/1"}, + }), + getAgentConfigMultiHost("worker"), + }, + expectedFencingByHost: []FencingCredentialHost{ + { + DirName: "test", + Credentials: []*types.Credential{ + {MACAddress: "28:D2:44:D2:B2:1A", Username: "admin", Password: "pass", Address: "redfish+https://10.0.0.1/redfish/v1/Systems/1"}, + }, + }, + }, + }, + { + name: "no fencing config produces empty FencingCredentialsByHost", + dependencies: []asset.Asset{ + &workflow.AgentWorkflow{Workflow: workflow.AgentWorkflowTypeInstall}, + &joiner.AddNodesConfig{}, + getInstallConfigSingleHost(), + getAgentConfigSingleHost(), + }, + expectedFencingByHost: nil, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + parents := asset.Parents{} + parents.Add(tc.dependencies...) + + ah := &AgentHosts{} + err := ah.Generate(context.Background(), parents) + + if tc.expectedError != "" { + assert.ErrorContains(t, err, tc.expectedError) + return + } + assert.NoError(t, err) + assert.Equal(t, tc.expectedFencingByHost, ah.FencingCredentialsByHost) + }) + } +} + +func TestHostConfigFiles_FencingCredentials(t *testing.T) { + ah := &AgentHosts{ + Hosts: []agent.Host{ + { + Hostname: "master-0", + Interfaces: []*aiv1beta1.Interface{ + {MacAddress: "aa:bb:cc:dd:ee:01"}, + }, + }, + }, + FencingCredentialsByHost: []FencingCredentialHost{ + { + DirName: "master-0", + Credentials: []*types.Credential{ + {MACAddress: "AA:BB:CC:DD:EE:01", Username: "admin", Password: "pass", Address: "redfish+https://10.0.0.1/redfish/v1/Systems/1"}, + }, + }, + }, + } + + files, err := ah.HostConfigFiles() + assert.NoError(t, err) + + data, ok := files["master-0/fencing-credentials.yaml"] + assert.True(t, ok, "expected fencing-credentials.yaml in master-0 directory") + assert.Contains(t, string(data), "credentials:") + assert.Contains(t, string(data), "username: admin") +} + +func getInstallConfigWithFencing(credentials []*types.Credential) *agentAsset.OptionalInstallConfig { + ic := getNoHostsInstallConfig() + ic.Config.ControlPlane.Fencing = &types.Fencing{ + Credentials: credentials, + } + return ic +} + +func getAgentConfigNoHostname() *AgentConfig { + a := getNoHostsAgentConfig() + a.Config.Hosts = []agent.Host{ + { + Role: "master", + Interfaces: []*aiv1beta1.Interface{ + { + Name: "eth0", + MacAddress: "28:d2:44:d2:b2:1a", + }, + }, + }, + } + return a +} + func getInstallConfigWithMismatchedNetworkConfig() *agentAsset.OptionalInstallConfig { a := getInstallConfigSingleHost() a.Config.Platform.BareMetal.Hosts[0].NetworkConfig = &apiextv1.JSON{ diff --git a/pkg/asset/agent/agentconfig/fencingcredentials_test.go b/pkg/asset/agent/agentconfig/fencingcredentials_test.go index a18f4095f6d..d3b4f80a9f1 100644 --- a/pkg/asset/agent/agentconfig/fencingcredentials_test.go +++ b/pkg/asset/agent/agentconfig/fencingcredentials_test.go @@ -442,6 +442,8 @@ func TestFencingCredentials_YAMLFormat(t *testing.T) { assert.NotContains(t, yamlContent, "macaddress:") }) + // This subtest verifies YAML format only. Placement into per-host directories + // for MAC-keyed credentials is handled by addFencingCredentials() in ignition.go. t.Run("with macaddress", func(t *testing.T) { parents := asset.Parents{} parents.Add( diff --git a/pkg/asset/agent/image/ignition.go b/pkg/asset/agent/image/ignition.go index 1727b672636..7770a194abc 100644 --- a/pkg/asset/agent/image/ignition.go +++ b/pkg/asset/agent/image/ignition.go @@ -353,7 +353,9 @@ func (a *Ignition) Generate(ctx context.Context, dependencies asset.Parents) err return err } - addFencingCredentials(&config, fencingCredentials) + if err = addFencingCredentialsByHostname(&config, fencingCredentials); err != nil { + return err + } err = addExtraManifests(&config, extraManifests) if err != nil { @@ -638,20 +640,35 @@ func addHostConfig(config *igntypes.Config, agentHosts *agentconfig.AgentHosts) return nil } -// addFencingCredentials adds the fencing credentials file to the ignition config. -// Fencing credentials are host-scoped, so they go under /etc/assisted/hostconfig/ -// rather than /etc/assisted/manifests/ which is for cluster-scoped manifests. -func addFencingCredentials(config *igntypes.Config, fencingCredentials *agentconfig.FencingCredentials) { - if fencingCredentials == nil || fencingCredentials.File == nil { - return +// addFencingCredentialsByHostname adds hostname-keyed fencing credentials to the ignition config +// at /etc/assisted/hostconfig/fencing-credentials.yaml. MAC-keyed credentials are handled +// separately through AgentHosts.HostConfigFiles() and addHostConfig(). +func addFencingCredentialsByHostname(config *igntypes.Config, fencingCredentials *agentconfig.FencingCredentials) error { + if fencingCredentials == nil || fencingCredentials.Config == nil || len(fencingCredentials.Config.Credentials) == 0 { + return nil + } + + var hostnameCredentials []*types.Credential + for _, cred := range fencingCredentials.Config.Credentials { + if cred.HostName != "" { + hostnameCredentials = append(hostnameCredentials, cred) + } } - fencingFile := ignition.FileFromBytes( + if len(hostnameCredentials) == 0 { + return nil + } + + cfg := &agentconfig.FencingCredentialsConfig{Credentials: hostnameCredentials} + data, err := yaml.Marshal(cfg) + if err != nil { + return errors.Wrap(err, "failed to marshal hostname fencing credentials") + } + config.Storage.Files = append(config.Storage.Files, ignition.FileFromBytes( path.Join("/etc/assisted/hostconfig", "fencing-credentials.yaml"), - "root", 0644, - fencingCredentials.File.Data, - ) - config.Storage.Files = append(config.Storage.Files, fencingFile) + "root", 0644, data, + )) + return nil } func addDay2ClusterConfigFiles(config *igntypes.Config, clusterInfo joiner.ClusterInfo, importClusterConfig joiner.ImportClusterConfig) error { diff --git a/pkg/asset/agent/image/ignition_test.go b/pkg/asset/agent/image/ignition_test.go index a6534245d09..f8399605090 100644 --- a/pkg/asset/agent/image/ignition_test.go +++ b/pkg/asset/agent/image/ignition_test.go @@ -988,3 +988,72 @@ func TestIgnition_getPublicContainerRegistries(t *testing.T) { }) } } + +func TestAddFencingCredentialsByHostname(t *testing.T) { + cases := []struct { + name string + credentials []*types.Credential + expectedFiles int + }{ + { + name: "hostname-only credentials go to top-level file", + credentials: []*types.Credential{ + {HostName: "master-0", Username: "admin", Password: "pass", Address: "redfish+https://10.0.0.1/redfish/v1/Systems/1"}, + {HostName: "master-1", Username: "admin", Password: "pass", Address: "redfish+https://10.0.0.2/redfish/v1/Systems/1"}, + }, + expectedFiles: 1, + }, + { + name: "mac-only credentials are excluded", + credentials: []*types.Credential{ + {MACAddress: "AA:BB:CC:DD:EE:01", Username: "admin", Password: "pass", Address: "redfish+https://10.0.0.1/redfish/v1/Systems/1"}, + }, + expectedFiles: 0, + }, + { + name: "hostname takes precedence when both set", + credentials: []*types.Credential{ + {HostName: "master-0", MACAddress: "AA:BB:CC:DD:EE:01", Username: "admin", Password: "pass", Address: "redfish+https://10.0.0.1/redfish/v1/Systems/1"}, + }, + expectedFiles: 1, + }, + { + name: "mixed credentials only includes hostname-keyed", + credentials: []*types.Credential{ + {HostName: "master-0", Username: "admin", Password: "pass", Address: "redfish+https://10.0.0.1/redfish/v1/Systems/1"}, + {MACAddress: "AA:BB:CC:DD:EE:02", Username: "admin", Password: "pass", Address: "redfish+https://10.0.0.2/redfish/v1/Systems/1"}, + }, + expectedFiles: 1, + }, + { + name: "nil credentials produces no files", + credentials: nil, + expectedFiles: 0, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + config := &igntypes.Config{} + var fencingCreds *agentconfig.FencingCredentials + if tc.credentials != nil { + fencingCreds = &agentconfig.FencingCredentials{ + Config: &agentconfig.FencingCredentialsConfig{ + Credentials: tc.credentials, + }, + } + } + + err := addFencingCredentialsByHostname(config, fencingCreds) + assert.NoError(t, err) + assert.Equal(t, tc.expectedFiles, len(config.Storage.Files)) + + for _, f := range config.Storage.Files { + assert.Equal(t, "/etc/assisted/hostconfig/fencing-credentials.yaml", f.Path) + actualData, err := dataurl.DecodeString(*f.FileEmbedded1.Contents.Source) + assert.NoError(t, err) + assert.Contains(t, string(actualData.Data), "credentials:") + } + }) + } +} From efd22853dad198de366e85745368d8c4d4de6d73 Mon Sep 17 00:00:00 2001 From: fracappa Date: Sat, 25 Jul 2026 11:52:07 +0200 Subject: [PATCH 2/2] refactor: deleting FencingCredentialHost struct and change host dir - FC map logic --- pkg/asset/agent/agentconfig/agenthosts.go | 106 +++++---- .../agent/agentconfig/agenthosts_test.go | 205 +++++++++--------- 2 files changed, 165 insertions(+), 146 deletions(-) diff --git a/pkg/asset/agent/agentconfig/agenthosts.go b/pkg/asset/agent/agentconfig/agenthosts.go index 565c4628860..31c638a58cf 100644 --- a/pkg/asset/agent/agentconfig/agenthosts.go +++ b/pkg/asset/agent/agentconfig/agenthosts.go @@ -40,18 +40,12 @@ type nmStateInterface struct { } `yaml:"interfaces,omitempty"` } -// FencingCredentialHost holds MAC-keyed fencing credentials matched to a host directory. -type FencingCredentialHost struct { - DirName string - Credentials []*types.Credential -} - // AgentHosts generates the hosts information from the AgentConfig and // OptionalInstallConfig assets. type AgentHosts struct { - Hosts []agent.Host - FencingCredentialsByHost []FencingCredentialHost - rendezvousIP string + Hosts []agent.Host + FencingCredentialsHost []types.Credential + rendezvousIP string } // Name returns a human friendly name. @@ -102,9 +96,8 @@ func (a *AgentHosts) Generate(_ context.Context, dependencies asset.Parents) err } } - if err := a.populateFencingCredentialHosts(installConfig); err != nil { - return err - } + // store per host fencing-credentials only when MAC address is used + a.populateFencingCredentialHosts(installConfig) case workflow.AgentWorkflowTypeAddNodes: a.Hosts = append(a.Hosts, addNodesConfig.Config.Hosts...) @@ -381,61 +374,80 @@ func (a *AgentHosts) HostConfigFiles() (HostConfigFileMap, error) { } } - for _, fch := range a.FencingCredentialsByHost { - cfg := &FencingCredentialsConfig{Credentials: fch.Credentials} + credsByDir := map[string][]*types.Credential{} + var dirOrder []string + + for i := range a.FencingCredentialsHost { + cred := &a.FencingCredentialsHost[i] + dirName := findHostDirForMAC(files, cred.MACAddress) + if dirName == "" { + dirName = nextAvailableHostDir(files, len(a.Hosts)) + files[filepath.Join(dirName, "mac_addresses")] = []byte(strings.ToLower(cred.MACAddress) + "\n") + } + if _, ok := credsByDir[dirName]; !ok { + dirOrder = append(dirOrder, dirName) + } + credsByDir[dirName] = append(credsByDir[dirName], cred) + } + + for _, dir := range dirOrder { + cfg := &FencingCredentialsConfig{Credentials: credsByDir[dir]} data, err := goyaml.Marshal(cfg) if err != nil { return nil, err } - files[filepath.Join(fch.DirName, "fencing-credentials.yaml")] = data + files[filepath.Join(dir, "fencing-credentials.yaml")] = data } return files, nil } -func (a *AgentHosts) populateFencingCredentialHosts(installConfig *agentAsset.OptionalInstallConfig) error { +func (a *AgentHosts) populateFencingCredentialHosts(installConfig *agentAsset.OptionalInstallConfig) { if installConfig.Config == nil || installConfig.Config.ControlPlane == nil || installConfig.Config.ControlPlane.Fencing == nil { - return nil + return } - credsByDir := map[string][]*types.Credential{} - var dirOrder []string - for _, cred := range installConfig.Config.ControlPlane.Fencing.Credentials { - if cred.HostName != "" || cred.MACAddress == "" { - continue - } - dirName, err := a.findHostDirForMAC(cred.MACAddress) - if err != nil { - return err + if cred.HostName == "" && cred.MACAddress != "" { + a.FencingCredentialsHost = append(a.FencingCredentialsHost, *cred) } - if _, ok := credsByDir[dirName]; !ok { - dirOrder = append(dirOrder, dirName) - } - credsByDir[dirName] = append(credsByDir[dirName], cred) } - - for _, dir := range dirOrder { - a.FencingCredentialsByHost = append(a.FencingCredentialsByHost, FencingCredentialHost{ - DirName: dir, - Credentials: credsByDir[dir], - }) - } - return nil } -func (a *AgentHosts) findHostDirForMAC(macAddress string) (string, error) { +func findHostDirForMAC(files HostConfigFileMap, macAddress string) string { normalizedMAC := strings.ToLower(macAddress) - for i, host := range a.Hosts { - for _, iface := range host.Interfaces { - if strings.ToLower(iface.MacAddress) == normalizedMAC { - if host.Hostname != "" { - return host.Hostname, nil - } - return fmt.Sprintf("host-%d", i), nil + for key, content := range files { + if !strings.HasSuffix(key, "/mac_addresses") { + continue + } + dirName := strings.TrimSuffix(key, "/mac_addresses") + for _, mac := range strings.Split(strings.TrimSpace(string(content)), "\n") { + if strings.TrimSpace(mac) == normalizedMAC { + return dirName } } } - return "", fmt.Errorf("fencing credential references MAC address %s which does not match any configured host interface", macAddress) + return "" +} + +func nextAvailableHostDir(files HostConfigFileMap, hostCount int) string { + existing := map[string]bool{} + for key := range files { + parts := strings.SplitN(key, "/", 2) + if len(parts) > 0 { + existing[parts[0]] = true + } + } + // Reserve host- for every host slot, even those that emitted no + // files, so a MAC-only fencing credential never reuses that name. + for i := 0; i < hostCount; i++ { + existing[fmt.Sprintf("host-%d", i)] = true + } + for i := 0; ; i++ { + name := fmt.Sprintf("host-%d", i) + if !existing[name] { + return name + } + } } diff --git a/pkg/asset/agent/agentconfig/agenthosts_test.go b/pkg/asset/agent/agentconfig/agenthosts_test.go index 0a4364a6c7f..1a71be305bc 100644 --- a/pkg/asset/agent/agentconfig/agenthosts_test.go +++ b/pkg/asset/agent/agentconfig/agenthosts_test.go @@ -955,15 +955,14 @@ func (ib *InterfacetBuilder) build() *aiv1beta1.Interface { return &ib.Interface } -func TestAgentHosts_FencingCredentialsByHost(t *testing.T) { +func TestAgentHosts_FencingCredentialsHost(t *testing.T) { cases := []struct { - name string - dependencies []asset.Asset - expectedError string - expectedFencingByHost []FencingCredentialHost + name string + dependencies []asset.Asset + expectedCredentials []types.Credential }{ { - name: "mac-keyed credentials populate FencingCredentialsByHost", + name: "mac-keyed credentials are collected", dependencies: []asset.Asset{ &workflow.AgentWorkflow{Workflow: workflow.AgentWorkflowTypeInstall}, &joiner.AddNodesConfig{}, @@ -972,17 +971,12 @@ func TestAgentHosts_FencingCredentialsByHost(t *testing.T) { }), getAgentConfigMultiHost("worker"), }, - expectedFencingByHost: []FencingCredentialHost{ - { - DirName: "test", - Credentials: []*types.Credential{ - {MACAddress: "28:d2:44:d2:b2:1a", Username: "admin", Password: "pass", Address: "redfish+https://10.0.0.1/redfish/v1/Systems/1"}, - }, - }, + expectedCredentials: []types.Credential{ + {MACAddress: "28:d2:44:d2:b2:1a", Username: "admin", Password: "pass", Address: "redfish+https://10.0.0.1/redfish/v1/Systems/1"}, }, }, { - name: "hostname-keyed credentials are excluded from FencingCredentialsByHost", + name: "hostname-keyed credentials are excluded", dependencies: []asset.Asset{ &workflow.AgentWorkflow{Workflow: workflow.AgentWorkflowTypeInstall}, &joiner.AddNodesConfig{}, @@ -991,10 +985,10 @@ func TestAgentHosts_FencingCredentialsByHost(t *testing.T) { }), getAgentConfigMultiHost("worker"), }, - expectedFencingByHost: nil, + expectedCredentials: nil, }, { - name: "mixed credentials only include mac-keyed in FencingCredentialsByHost", + name: "mixed credentials only collect mac-keyed", dependencies: []asset.Asset{ &workflow.AgentWorkflow{Workflow: workflow.AgentWorkflowTypeInstall}, &joiner.AddNodesConfig{}, @@ -1004,13 +998,8 @@ func TestAgentHosts_FencingCredentialsByHost(t *testing.T) { }), getAgentConfigMultiHost("worker"), }, - expectedFencingByHost: []FencingCredentialHost{ - { - DirName: "test-2", - Credentials: []*types.Credential{ - {MACAddress: "28:d2:44:d2:b2:1b", Username: "admin2", Password: "pass2", Address: "redfish+https://10.0.0.2/redfish/v1/Systems/1"}, - }, - }, + expectedCredentials: []types.Credential{ + {MACAddress: "28:d2:44:d2:b2:1b", Username: "admin2", Password: "pass2", Address: "redfish+https://10.0.0.2/redfish/v1/Systems/1"}, }, }, { @@ -1023,67 +1012,17 @@ func TestAgentHosts_FencingCredentialsByHost(t *testing.T) { }), getAgentConfigMultiHost("worker"), }, - expectedFencingByHost: nil, - }, - { - name: "mac with no matching host returns error", - dependencies: []asset.Asset{ - &workflow.AgentWorkflow{Workflow: workflow.AgentWorkflowTypeInstall}, - &joiner.AddNodesConfig{}, - getInstallConfigWithFencing([]*types.Credential{ - {MACAddress: "FF:FF:FF:FF:FF:FF", Username: "admin", Password: "pass", Address: "redfish+https://10.0.0.1/redfish/v1/Systems/1"}, - }), - getAgentConfigMultiHost("worker"), - }, - expectedError: "does not match any configured host interface", + expectedCredentials: nil, }, { - name: "host without hostname uses host-i directory", - dependencies: []asset.Asset{ - &workflow.AgentWorkflow{Workflow: workflow.AgentWorkflowTypeInstall}, - &joiner.AddNodesConfig{}, - getInstallConfigWithFencing([]*types.Credential{ - {MACAddress: "28:d2:44:d2:b2:1a", Username: "admin", Password: "pass", Address: "redfish+https://10.0.0.1/redfish/v1/Systems/1"}, - }), - getAgentConfigNoHostname(), - }, - expectedFencingByHost: []FencingCredentialHost{ - { - DirName: "host-0", - Credentials: []*types.Credential{ - {MACAddress: "28:d2:44:d2:b2:1a", Username: "admin", Password: "pass", Address: "redfish+https://10.0.0.1/redfish/v1/Systems/1"}, - }, - }, - }, - }, - { - name: "case-insensitive mac matching", - dependencies: []asset.Asset{ - &workflow.AgentWorkflow{Workflow: workflow.AgentWorkflowTypeInstall}, - &joiner.AddNodesConfig{}, - getInstallConfigWithFencing([]*types.Credential{ - {MACAddress: "28:D2:44:D2:B2:1A", Username: "admin", Password: "pass", Address: "redfish+https://10.0.0.1/redfish/v1/Systems/1"}, - }), - getAgentConfigMultiHost("worker"), - }, - expectedFencingByHost: []FencingCredentialHost{ - { - DirName: "test", - Credentials: []*types.Credential{ - {MACAddress: "28:D2:44:D2:B2:1A", Username: "admin", Password: "pass", Address: "redfish+https://10.0.0.1/redfish/v1/Systems/1"}, - }, - }, - }, - }, - { - name: "no fencing config produces empty FencingCredentialsByHost", + name: "no fencing config produces nil", dependencies: []asset.Asset{ &workflow.AgentWorkflow{Workflow: workflow.AgentWorkflowTypeInstall}, &joiner.AddNodesConfig{}, getInstallConfigSingleHost(), getAgentConfigSingleHost(), }, - expectedFencingByHost: nil, + expectedCredentials: nil, }, } @@ -1094,44 +1033,112 @@ func TestAgentHosts_FencingCredentialsByHost(t *testing.T) { ah := &AgentHosts{} err := ah.Generate(context.Background(), parents) - - if tc.expectedError != "" { - assert.ErrorContains(t, err, tc.expectedError) - return - } assert.NoError(t, err) - assert.Equal(t, tc.expectedFencingByHost, ah.FencingCredentialsByHost) + assert.Equal(t, tc.expectedCredentials, ah.FencingCredentialsHost) }) } } func TestHostConfigFiles_FencingCredentials(t *testing.T) { - ah := &AgentHosts{ - Hosts: []agent.Host{ - { - Hostname: "master-0", - Interfaces: []*aiv1beta1.Interface{ - {MacAddress: "aa:bb:cc:dd:ee:01"}, + cases := []struct { + name string + hosts []agent.Host + credentials []types.Credential + expectedFiles map[string]string + }{ + { + name: "mac matches existing host directory", + hosts: []agent.Host{ + { + Hostname: "master-0", + Interfaces: []*aiv1beta1.Interface{{MacAddress: "aa:bb:cc:dd:ee:01"}}, }, }, + credentials: []types.Credential{ + {MACAddress: "AA:BB:CC:DD:EE:01", Username: "admin", Password: "pass", Address: "redfish+https://10.0.0.1/redfish/v1/Systems/1"}, + }, + expectedFiles: map[string]string{ + "master-0/fencing-credentials.yaml": "username: admin", + }, }, - FencingCredentialsByHost: []FencingCredentialHost{ - { - DirName: "master-0", - Credentials: []*types.Credential{ - {MACAddress: "AA:BB:CC:DD:EE:01", Username: "admin", Password: "pass", Address: "redfish+https://10.0.0.1/redfish/v1/Systems/1"}, + { + name: "mac with no matching host creates new directory", + hosts: []agent.Host{ + { + Hostname: "master-0", + Interfaces: []*aiv1beta1.Interface{{MacAddress: "aa:bb:cc:dd:ee:01"}}, + }, + }, + credentials: []types.Credential{ + {MACAddress: "FF:FF:FF:FF:FF:FF", Username: "admin", Password: "pass", Address: "redfish+https://10.0.0.1/redfish/v1/Systems/1"}, + }, + expectedFiles: map[string]string{ + "host-1/fencing-credentials.yaml": "username: admin", + "host-1/mac_addresses": "ff:ff:ff:ff:ff:ff", + }, + }, + { + name: "case-insensitive mac matching", + hosts: []agent.Host{ + { + Hostname: "master-0", + Interfaces: []*aiv1beta1.Interface{{MacAddress: "aa:bb:cc:dd:ee:01"}}, + }, + }, + credentials: []types.Credential{ + {MACAddress: "AA:BB:CC:DD:EE:01", Username: "admin", Password: "pass", Address: "redfish+https://10.0.0.1/redfish/v1/Systems/1"}, + }, + expectedFiles: map[string]string{ + "master-0/fencing-credentials.yaml": "username: admin", + }, + }, + { + name: "new dir avoids collision with existing host-0", + hosts: []agent.Host{ + { + Interfaces: []*aiv1beta1.Interface{{MacAddress: "aa:bb:cc:dd:ee:01"}}, }, }, + credentials: []types.Credential{ + {MACAddress: "FF:FF:FF:FF:FF:FF", Username: "admin", Password: "pass", Address: "redfish+https://10.0.0.1/redfish/v1/Systems/1"}, + }, + expectedFiles: map[string]string{ + "host-1/fencing-credentials.yaml": "username: admin", + "host-1/mac_addresses": "ff:ff:ff:ff:ff:ff", + }, + }, + { + name: "new dir avoids collision with empty host that has no files", + hosts: []agent.Host{ + {}, + }, + credentials: []types.Credential{ + {MACAddress: "FF:FF:FF:FF:FF:FF", Username: "admin", Password: "pass", Address: "redfish+https://10.0.0.1/redfish/v1/Systems/1"}, + }, + expectedFiles: map[string]string{ + "host-1/fencing-credentials.yaml": "username: admin", + "host-1/mac_addresses": "ff:ff:ff:ff:ff:ff", + }, }, } - files, err := ah.HostConfigFiles() - assert.NoError(t, err) + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ah := &AgentHosts{ + Hosts: tc.hosts, + FencingCredentialsHost: tc.credentials, + } + + files, err := ah.HostConfigFiles() + assert.NoError(t, err) - data, ok := files["master-0/fencing-credentials.yaml"] - assert.True(t, ok, "expected fencing-credentials.yaml in master-0 directory") - assert.Contains(t, string(data), "credentials:") - assert.Contains(t, string(data), "username: admin") + for path, expectedContent := range tc.expectedFiles { + data, ok := files[path] + assert.True(t, ok, "expected file at %s", path) + assert.Contains(t, string(data), expectedContent) + } + }) + } } func getInstallConfigWithFencing(credentials []*types.Credential) *agentAsset.OptionalInstallConfig {