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
6 changes: 6 additions & 0 deletions data/data/install.openshift.io_installconfigs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ spec:
type: string
hostName:
type: string
macAddress:
type: string
password:
type: string
username:
Expand Down Expand Up @@ -1743,6 +1745,8 @@ spec:
type: string
hostName:
type: string
macAddress:
type: string
password:
type: string
username:
Expand Down Expand Up @@ -3305,6 +3309,8 @@ spec:
type: string
hostName:
type: string
macAddress:
type: string
password:
type: string
username:
Expand Down
44 changes: 0 additions & 44 deletions pkg/asset/agent/agentconfig/fencingcredentials.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
goyaml "gopkg.in/yaml.v2"
"k8s.io/apimachinery/pkg/util/validation/field"
k8syaml "sigs.k8s.io/yaml"

"github.com/openshift/installer/pkg/asset"
Expand Down Expand Up @@ -126,11 +125,6 @@ func (f *FencingCredentials) finish() error {
return nil
}

// Validate all fencing credentials
if err := f.validateFencingCredentials().ToAggregate(); err != nil {
return errors.Wrapf(err, "invalid fencing credentials configuration")
}

// Use gopkg.in/yaml.v2 for marshaling to respect YAML struct tags (lowercase field names).
// This is critical for compatibility with assisted-service, which expects lowercase keys.
data, err := goyaml.Marshal(f.Config)
Expand All @@ -146,41 +140,3 @@ func (f *FencingCredentials) finish() error {
logrus.Debugf("Generated fencing credentials file at %s", fencingCredentialsFilename)
return nil
}

// validateFencingCredentials validates that all required fields are present
// and that there are no duplicate hostnames.
func (f *FencingCredentials) validateFencingCredentials() field.ErrorList {
var allErrs field.ErrorList
basePath := field.NewPath("controlPlane", "fencing", "credentials")

seenHostnames := make(map[string]int)

for i, cred := range f.Config.Credentials {
credPath := basePath.Index(i)

if cred.HostName == "" {
allErrs = append(allErrs, field.Required(credPath.Child("hostname"), "hostname is required for fencing credential"))
} else {
// Check for duplicate hostnames
if prevIdx, exists := seenHostnames[cred.HostName]; exists {
allErrs = append(allErrs, field.Duplicate(credPath.Child("hostname"),
fmt.Sprintf("hostname %q already defined at index %d", cred.HostName, prevIdx)))
}
seenHostnames[cred.HostName] = i
}

if cred.Address == "" {
allErrs = append(allErrs, field.Required(credPath.Child("address"), "BMC address is required for fencing credential"))
}

if cred.Username == "" {
allErrs = append(allErrs, field.Required(credPath.Child("username"), "BMC username is required for fencing credential"))
}

if cred.Password == "" {
allErrs = append(allErrs, field.Required(credPath.Child("password"), "BMC password is required for fencing credential"))
}
}

return allErrs
}
226 changes: 121 additions & 105 deletions pkg/asset/agent/agentconfig/fencingcredentials_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,60 +104,24 @@ func getValidOptionalInstallConfigWithEmptyFencingCredentials() *agent.OptionalI
return installConfig
}

// getOptionalInstallConfigWithMissingHostname returns an install config
// with a fencing credential missing the hostname field.
func getOptionalInstallConfigWithMissingHostname() *agent.OptionalInstallConfig {
// getValidOptionalInstallConfigWithMACAddress returns an install config
// with valid MAC addresses in fencing credentials.
func getValidOptionalInstallConfigWithMACAddress() *agent.OptionalInstallConfig {
installConfig := getValidOptionalInstallConfig()
installConfig.Config.ControlPlane.Replicas = ptr.To(int64(2))
installConfig.Config.ControlPlane.Fencing = &types.Fencing{
Credentials: []*types.Credential{
{
HostName: "", // Missing hostname
Address: "redfish+https://192.168.111.1:8000/redfish/v1/Systems/abc",
Username: "admin",
Password: "password123",
},
},
}
return installConfig
}

// getOptionalInstallConfigWithMissingAddress returns an install config
// with a fencing credential missing the address field.
func getOptionalInstallConfigWithMissingAddress() *agent.OptionalInstallConfig {
installConfig := getValidOptionalInstallConfig()
installConfig.Config.ControlPlane.Replicas = ptr.To(int64(2))
installConfig.Config.ControlPlane.Fencing = &types.Fencing{
Credentials: []*types.Credential{
{
HostName: "master-0",
Address: "", // Missing address
Username: "admin",
Password: "password123",
},
},
}
return installConfig
}

// getOptionalInstallConfigWithDuplicateHostnames returns an install config
// with duplicate hostnames in fencing credentials.
func getOptionalInstallConfigWithDuplicateHostnames() *agent.OptionalInstallConfig {
installConfig := getValidOptionalInstallConfig()
installConfig.Config.ControlPlane.Replicas = ptr.To(int64(2))
installConfig.Config.ControlPlane.Fencing = &types.Fencing{
Credentials: []*types.Credential{
{
HostName: "master-0",
Address: "redfish+https://192.168.111.1:8000/redfish/v1/Systems/abc",
Username: "admin",
Password: "password123",
MACAddress: "AA:BB:CC:DD:EE:01",
Address: "redfish+https://192.168.111.1:8000/redfish/v1/Systems/abc",
Username: "admin",
Password: "password123",
},
{
HostName: "master-0", // Duplicate hostname
Address: "redfish+https://192.168.111.1:8000/redfish/v1/Systems/def",
Username: "admin",
Password: "password456",
MACAddress: "AA:BB:CC:DD:EE:02",
Address: "redfish+https://192.168.111.1:8000/redfish/v1/Systems/def",
Username: "admin",
Password: "password456",
},
},
}
Expand All @@ -166,11 +130,12 @@ func getOptionalInstallConfigWithDuplicateHostnames() *agent.OptionalInstallConf

func TestFencingCredentials_Generate(t *testing.T) {
cases := []struct {
name string
dependencies []asset.Asset
expectedError string
expectedConfig *FencingCredentialsConfig
expectedFiles int
name string
dependencies []asset.Asset
expectedError string
expectedConfig *FencingCredentialsConfig
expectedFiles int
expectedYAMLFields []string
}{
{
name: "valid fencing credentials - install workflow",
Expand All @@ -196,7 +161,8 @@ func TestFencingCredentials_Generate(t *testing.T) {
},
},
},
expectedFiles: 1,
expectedFiles: 1,
expectedYAMLFields: []string{"hostname:", "address:", "username:", "password:"},
},
{
name: "no fencing credentials - non-TNF cluster",
Expand Down Expand Up @@ -235,46 +201,48 @@ func TestFencingCredentials_Generate(t *testing.T) {
expectedFiles: 0,
},
{
name: "fencing without certificateVerification field",
name: "valid fencing credentials with MAC address only",
dependencies: []asset.Asset{
getValidOptionalInstallConfigWithFencingNoCertVerification(),
getValidOptionalInstallConfigWithMACAddress(),
&workflow.AgentWorkflow{Workflow: workflow.AgentWorkflowTypeInstall},
},
expectedConfig: &FencingCredentialsConfig{
Credentials: []*types.Credential{
{
HostName: "master-0",
Address: "redfish+https://192.168.111.1:8000/redfish/v1/Systems/abc",
Username: "admin",
Password: "password123",
MACAddress: "AA:BB:CC:DD:EE:01",
Address: "redfish+https://192.168.111.1:8000/redfish/v1/Systems/abc",
Username: "admin",
Password: "password123",
},
{
MACAddress: "AA:BB:CC:DD:EE:02",
Address: "redfish+https://192.168.111.1:8000/redfish/v1/Systems/def",
Username: "admin",
Password: "password456",
},
},
},
expectedFiles: 1,
expectedFiles: 1,
expectedYAMLFields: []string{"macaddress:", "address:", "username:", "password:"},
},
{
name: "validation error - missing hostname",
dependencies: []asset.Asset{
getOptionalInstallConfigWithMissingHostname(),
&workflow.AgentWorkflow{Workflow: workflow.AgentWorkflowTypeInstall},
},
expectedError: "hostname is required",
},
{
name: "validation error - missing address",
name: "fencing without certificateVerification field",
dependencies: []asset.Asset{
getOptionalInstallConfigWithMissingAddress(),
getValidOptionalInstallConfigWithFencingNoCertVerification(),
&workflow.AgentWorkflow{Workflow: workflow.AgentWorkflowTypeInstall},
},
expectedError: "BMC address is required",
},
{
name: "validation error - duplicate hostnames",
dependencies: []asset.Asset{
getOptionalInstallConfigWithDuplicateHostnames(),
&workflow.AgentWorkflow{Workflow: workflow.AgentWorkflowTypeInstall},
expectedConfig: &FencingCredentialsConfig{
Credentials: []*types.Credential{
{
HostName: "master-0",
Address: "redfish+https://192.168.111.1:8000/redfish/v1/Systems/abc",
Username: "admin",
Password: "password123",
},
},
},
expectedError: "already defined at index",
expectedFiles: 1,
expectedYAMLFields: []string{"hostname:", "address:", "username:", "password:"},
},
}

Expand All @@ -300,10 +268,9 @@ func TestFencingCredentials_Generate(t *testing.T) {

// Verify YAML contains expected structure
assert.Contains(t, string(configFile.Data), "credentials:")
assert.Contains(t, string(configFile.Data), "hostname:")
assert.Contains(t, string(configFile.Data), "address:")
assert.Contains(t, string(configFile.Data), "username:")
assert.Contains(t, string(configFile.Data), "password:")
for _, field := range tc.expectedYAMLFields {
assert.Contains(t, string(configFile.Data), field)
}
}
}
})
Expand Down Expand Up @@ -371,6 +338,26 @@ func TestFencingCredentials_Load(t *testing.T) {
},
},
},
{
name: "valid fencing credentials with macaddress",
data: `credentials:
- macaddress: AA:BB:CC:DD:EE:01
address: redfish+https://192.168.111.1:8000/redfish/v1/Systems/abc
username: admin
password: password123`,
expectedFound: true,
expectedConfig: &FencingCredentialsConfig{
Credentials: []*types.Credential{
{
MACAddress: "AA:BB:CC:DD:EE:01",
Address: "redfish+https://192.168.111.1:8000/redfish/v1/Systems/abc",
Username: "admin",
Password: "password123",
},
},
},
},

{
name: "invalid yaml",
data: `this is not valid yaml: [`,
Expand Down Expand Up @@ -425,30 +412,59 @@ func TestFencingCredentials_Load(t *testing.T) {
func TestFencingCredentials_YAMLFormat(t *testing.T) {
// This test verifies that the YAML format produced by the installer
// matches what assisted-service expects to consume.
parents := asset.Parents{}
parents.Add(
getValidOptionalInstallConfigWithFencing(),
&workflow.AgentWorkflow{Workflow: workflow.AgentWorkflowTypeInstall},
)
t.Run("with hostname", func(t *testing.T) {
parents := asset.Parents{}
parents.Add(
getValidOptionalInstallConfigWithFencing(),
&workflow.AgentWorkflow{Workflow: workflow.AgentWorkflowTypeInstall},
)

fencingAsset := &FencingCredentials{}
err := fencingAsset.Generate(context.Background(), parents)
assert.NoError(t, err)
assert.NotEmpty(t, fencingAsset.Files())

yamlContent := string(fencingAsset.Files()[0].Data)

// Verify YAML uses lowercase field names (yaml tags, not JSON tags)
assert.Contains(t, yamlContent, "hostname: master-0")
assert.Contains(t, yamlContent, "hostname: master-1")
assert.Contains(t, yamlContent, "username: admin")
assert.Contains(t, yamlContent, "password: password123")
assert.Contains(t, yamlContent, "password: password456")
assert.Contains(t, yamlContent, "certificateVerification: Disabled")
assert.Contains(t, yamlContent, "certificateVerification: Enabled")

// Verify it does NOT use camelCase (JSON tag format)
assert.NotContains(t, yamlContent, "hostName:")
fencingAsset := &FencingCredentials{}
err := fencingAsset.Generate(context.Background(), parents)
assert.NoError(t, err)
assert.NotEmpty(t, fencingAsset.Files())

yamlContent := string(fencingAsset.Files()[0].Data)

// Verify YAML uses lowercase field names (yaml tags, not JSON tags)
assert.Contains(t, yamlContent, "hostname: master-0")
assert.Contains(t, yamlContent, "hostname: master-1")
assert.Contains(t, yamlContent, "username: admin")
assert.Contains(t, yamlContent, "password: password123")
assert.Contains(t, yamlContent, "password: password456")
assert.Contains(t, yamlContent, "certificateVerification: Disabled")
assert.Contains(t, yamlContent, "certificateVerification: Enabled")

// Verify it does NOT use camelCase (JSON tag format)
assert.NotContains(t, yamlContent, "hostName:")

// MACAddress should not appear when not set (omitempty)
assert.NotContains(t, yamlContent, "macaddress:")
})

t.Run("with macaddress", func(t *testing.T) {
parents := asset.Parents{}
parents.Add(
getValidOptionalInstallConfigWithMACAddress(),
&workflow.AgentWorkflow{Workflow: workflow.AgentWorkflowTypeInstall},
)

fencingAsset := &FencingCredentials{}
err := fencingAsset.Generate(context.Background(), parents)
assert.NoError(t, err)
assert.NotEmpty(t, fencingAsset.Files())

yamlContent := string(fencingAsset.Files()[0].Data)

// Verify macAddress appears with correct yaml tag
assert.Contains(t, yamlContent, "macaddress: AA:BB:CC:DD:EE:01")
assert.Contains(t, yamlContent, "macaddress: AA:BB:CC:DD:EE:02")
assert.Contains(t, yamlContent, "username: admin")
assert.Contains(t, yamlContent, "address: redfish+https://")

// hostname should not appear when not set (omitempty)
assert.NotContains(t, yamlContent, "hostname:")
})
}

func TestFencingCredentials_Name(t *testing.T) {
Expand Down
2 changes: 1 addition & 1 deletion pkg/asset/agent/image/ignition.go
Original file line number Diff line number Diff line change
Expand Up @@ -638,7 +638,7 @@ func addHostConfig(config *igntypes.Config, agentHosts *agentconfig.AgentHosts)
}

// addFencingCredentials adds the fencing credentials file to the ignition config.
// Fencing credentials are host-scoped (matched by hostname), so they go under /etc/assisted/hostconfig/
// 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 {
Expand Down
Loading