github.com/openshift/installer@v1.4.17/pkg/asset/agent/manifests/nmstateconfig_test.go (about)

     1  package manifests
     2  
     3  import (
     4  	"context"
     5  	"errors"
     6  	"fmt"
     7  	"os"
     8  	"os/exec"
     9  	"testing"
    10  
    11  	"github.com/golang/mock/gomock"
    12  	"github.com/stretchr/testify/assert"
    13  	v1 "k8s.io/api/core/v1"
    14  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    15  
    16  	aiv1beta1 "github.com/openshift/assisted-service/api/v1beta1"
    17  	"github.com/openshift/assisted-service/models"
    18  	"github.com/openshift/installer/pkg/asset"
    19  	agentconfig "github.com/openshift/installer/pkg/asset/agent"
    20  	"github.com/openshift/installer/pkg/asset/agent/joiner"
    21  	"github.com/openshift/installer/pkg/asset/agent/workflow"
    22  	"github.com/openshift/installer/pkg/asset/mock"
    23  	"github.com/openshift/installer/pkg/types/agent"
    24  )
    25  
    26  func TestNMStateConfig_Generate(t *testing.T) {
    27  	cases := []struct {
    28  		name               string
    29  		dependencies       []asset.Asset
    30  		requiresNmstatectl bool
    31  		expectedConfig     []*aiv1beta1.NMStateConfig
    32  		expectedError      string
    33  	}{
    34  		{
    35  			name: "add-nodes workflow",
    36  			dependencies: []asset.Asset{
    37  				&workflow.AgentWorkflow{Workflow: workflow.AgentWorkflowTypeAddNodes},
    38  				&joiner.ClusterInfo{},
    39  				getAgentHostsNoHosts(),
    40  				&agentconfig.OptionalInstallConfig{},
    41  			},
    42  			requiresNmstatectl: false,
    43  			expectedConfig:     nil,
    44  			expectedError:      "",
    45  		},
    46  		{
    47  			name: "add-nodes workflow - agentHosts with some hosts without networkconfig",
    48  			dependencies: []asset.Asset{
    49  				&workflow.AgentWorkflow{Workflow: workflow.AgentWorkflowTypeAddNodes},
    50  				&joiner.ClusterInfo{
    51  					Namespace:   "cluster0",
    52  					ClusterName: "ostest",
    53  					Nodes:       &v1.NodeList{},
    54  				},
    55  				getAgentHostsWithSomeHostsWithoutNetworkConfig(),
    56  				&agentconfig.OptionalInstallConfig{},
    57  			},
    58  			requiresNmstatectl: true,
    59  			expectedConfig: []*aiv1beta1.NMStateConfig{
    60  				{
    61  					TypeMeta: metav1.TypeMeta{
    62  						Kind:       "NMStateConfig",
    63  						APIVersion: "agent-install.openshift.io/v1beta1",
    64  					},
    65  					ObjectMeta: metav1.ObjectMeta{
    66  						Name:      "ostest-0",
    67  						Namespace: "cluster0",
    68  						Labels:    getNMStateConfigLabels("ostest"),
    69  					},
    70  					Spec: aiv1beta1.NMStateConfigSpec{
    71  						Interfaces: []*aiv1beta1.Interface{
    72  							{
    73  								Name:       "enp2t0",
    74  								MacAddress: "98:af:65:a5:8d:02",
    75  							},
    76  						},
    77  						NetConfig: aiv1beta1.NetConfig{
    78  							Raw: unmarshalJSON([]byte(rawNMStateConfigNoIP)),
    79  						},
    80  					},
    81  				},
    82  			},
    83  			expectedError: "",
    84  		},
    85  		{
    86  			name: "add-nodes workflow - invalid ip",
    87  			dependencies: []asset.Asset{
    88  				&workflow.AgentWorkflow{Workflow: workflow.AgentWorkflowTypeAddNodes},
    89  				&joiner.ClusterInfo{
    90  					Namespace:   "cluster0",
    91  					ClusterName: "ostest",
    92  					Nodes: &v1.NodeList{
    93  						Items: []v1.Node{
    94  							{
    95  								ObjectMeta: metav1.ObjectMeta{
    96  									Name: "master-0",
    97  								},
    98  								Status: v1.NodeStatus{
    99  									Addresses: []v1.NodeAddress{
   100  										{
   101  											Address: "192.168.122.21", // configured by getValidAgentHostsConfig()
   102  										},
   103  									},
   104  								},
   105  							},
   106  						},
   107  					},
   108  				},
   109  				getValidAgentHostsConfig(),
   110  				&agentconfig.OptionalInstallConfig{},
   111  			},
   112  			requiresNmstatectl: false,
   113  			expectedError:      "address conflict found. The configured address 192.168.122.21 is already used by the cluster node master-0",
   114  		},
   115  		{
   116  			name: "add-nodes workflow - invalid hostname",
   117  			dependencies: []asset.Asset{
   118  				&workflow.AgentWorkflow{Workflow: workflow.AgentWorkflowTypeAddNodes},
   119  				&joiner.ClusterInfo{
   120  					Namespace:   "cluster0",
   121  					ClusterName: "ostest",
   122  					Nodes: &v1.NodeList{
   123  						Items: []v1.Node{
   124  							{
   125  								ObjectMeta: metav1.ObjectMeta{
   126  									Name: "control-0.example.org",
   127  								},
   128  								Status: v1.NodeStatus{
   129  									Addresses: []v1.NodeAddress{
   130  										{
   131  											Address: "control-0.example.org", // configured by getValidAgentHostsConfig()
   132  										},
   133  									},
   134  								},
   135  							},
   136  						},
   137  					},
   138  				},
   139  				getValidAgentHostsConfig(),
   140  				&agentconfig.OptionalInstallConfig{},
   141  			},
   142  			requiresNmstatectl: false,
   143  			expectedError:      "hostname conflict found. The configured hostname control-0.example.org is already used in the cluster",
   144  		},
   145  		{
   146  			name: "agentHosts does not contain networkConfig",
   147  			dependencies: []asset.Asset{
   148  				&workflow.AgentWorkflow{Workflow: workflow.AgentWorkflowTypeInstall},
   149  				&joiner.ClusterInfo{},
   150  				getAgentHostsNoHosts(),
   151  				getValidOptionalInstallConfig(),
   152  			},
   153  			requiresNmstatectl: false,
   154  			expectedConfig:     nil,
   155  			expectedError:      "",
   156  		},
   157  		{
   158  			name: "agentHosts with some hosts without networkconfig",
   159  			dependencies: []asset.Asset{
   160  				&workflow.AgentWorkflow{Workflow: workflow.AgentWorkflowTypeInstall},
   161  				&joiner.ClusterInfo{},
   162  				getAgentHostsWithSomeHostsWithoutNetworkConfig(),
   163  				getValidOptionalInstallConfig(),
   164  			},
   165  			requiresNmstatectl: true,
   166  			expectedConfig: []*aiv1beta1.NMStateConfig{
   167  				{
   168  					TypeMeta: metav1.TypeMeta{
   169  						Kind:       "NMStateConfig",
   170  						APIVersion: "agent-install.openshift.io/v1beta1",
   171  					},
   172  					ObjectMeta: metav1.ObjectMeta{
   173  						Name:      fmt.Sprint(getValidOptionalInstallConfig().ClusterName(), "-0"),
   174  						Namespace: getValidOptionalInstallConfig().ClusterNamespace(),
   175  						Labels:    getNMStateConfigLabels(getValidOptionalInstallConfig().ClusterName()),
   176  					},
   177  					Spec: aiv1beta1.NMStateConfigSpec{
   178  						Interfaces: []*aiv1beta1.Interface{
   179  							{
   180  								Name:       "enp2t0",
   181  								MacAddress: "98:af:65:a5:8d:02",
   182  							},
   183  						},
   184  						NetConfig: aiv1beta1.NetConfig{
   185  							Raw: unmarshalJSON([]byte(rawNMStateConfigNoIP)),
   186  						},
   187  					},
   188  				},
   189  			},
   190  			expectedError: "",
   191  		},
   192  		{
   193  			name: "valid config",
   194  			dependencies: []asset.Asset{
   195  				&workflow.AgentWorkflow{Workflow: workflow.AgentWorkflowTypeInstall},
   196  				&joiner.ClusterInfo{},
   197  				getValidAgentHostsConfig(),
   198  				getValidOptionalInstallConfig(),
   199  			},
   200  			requiresNmstatectl: true,
   201  			expectedConfig: []*aiv1beta1.NMStateConfig{
   202  				{
   203  					TypeMeta: metav1.TypeMeta{
   204  						Kind:       "NMStateConfig",
   205  						APIVersion: "agent-install.openshift.io/v1beta1",
   206  					},
   207  					ObjectMeta: metav1.ObjectMeta{
   208  						Name:      fmt.Sprint(getValidOptionalInstallConfig().ClusterName(), "-0"),
   209  						Namespace: getValidOptionalInstallConfig().ClusterNamespace(),
   210  						Labels:    getNMStateConfigLabels(getValidOptionalInstallConfig().ClusterName()),
   211  					},
   212  					Spec: aiv1beta1.NMStateConfigSpec{
   213  						Interfaces: []*aiv1beta1.Interface{
   214  							{
   215  								Name:       "enp2s0",
   216  								MacAddress: "98:af:65:a5:8d:01",
   217  							},
   218  							{
   219  								Name:       "enp3s1",
   220  								MacAddress: "28:d2:44:d2:b2:1a",
   221  							},
   222  						},
   223  						NetConfig: aiv1beta1.NetConfig{
   224  							Raw: unmarshalJSON([]byte(rawNMStateConfig)),
   225  						},
   226  					},
   227  				},
   228  				{
   229  					TypeMeta: metav1.TypeMeta{
   230  						Kind:       "NMStateConfig",
   231  						APIVersion: "agent-install.openshift.io/v1beta1",
   232  					},
   233  					ObjectMeta: metav1.ObjectMeta{
   234  						Name:      fmt.Sprint(getValidOptionalInstallConfig().ClusterName(), "-1"),
   235  						Namespace: getValidOptionalInstallConfig().ClusterNamespace(),
   236  						Labels:    getNMStateConfigLabels(getValidOptionalInstallConfig().ClusterName()),
   237  					},
   238  					Spec: aiv1beta1.NMStateConfigSpec{
   239  						Interfaces: []*aiv1beta1.Interface{
   240  							{
   241  								Name:       "enp2t0",
   242  								MacAddress: "98:af:65:a5:8d:02",
   243  							},
   244  						},
   245  						NetConfig: aiv1beta1.NetConfig{
   246  							Raw: unmarshalJSON([]byte(rawNMStateConfig)),
   247  						},
   248  					},
   249  				},
   250  				{
   251  					TypeMeta: metav1.TypeMeta{
   252  						Kind:       "NMStateConfig",
   253  						APIVersion: "agent-install.openshift.io/v1beta1",
   254  					},
   255  					ObjectMeta: metav1.ObjectMeta{
   256  						Name:      fmt.Sprint(getValidOptionalInstallConfig().ClusterName(), "-2"),
   257  						Namespace: getValidOptionalInstallConfig().ClusterNamespace(),
   258  						Labels:    getNMStateConfigLabels(getValidOptionalInstallConfig().ClusterName()),
   259  					},
   260  					Spec: aiv1beta1.NMStateConfigSpec{
   261  						Interfaces: []*aiv1beta1.Interface{
   262  							{
   263  								Name:       "enp2u0",
   264  								MacAddress: "98:af:65:a5:8d:03",
   265  							},
   266  						},
   267  						NetConfig: aiv1beta1.NetConfig{
   268  							Raw: unmarshalJSON([]byte(rawNMStateConfig)),
   269  						},
   270  					},
   271  				},
   272  			},
   273  			expectedError: "",
   274  		},
   275  		{
   276  			name: "invalid networkConfig",
   277  			dependencies: []asset.Asset{
   278  				&workflow.AgentWorkflow{Workflow: workflow.AgentWorkflowTypeInstall},
   279  				&joiner.ClusterInfo{},
   280  				getInValidAgentHostsConfig(),
   281  				getValidOptionalInstallConfig(),
   282  			},
   283  			requiresNmstatectl: true,
   284  			expectedConfig:     nil,
   285  			expectedError:      "failed to validate network yaml",
   286  		},
   287  	}
   288  	for _, tc := range cases {
   289  		t.Run(tc.name, func(t *testing.T) {
   290  			parents := asset.Parents{}
   291  			parents.Add(tc.dependencies...)
   292  
   293  			asset := &NMStateConfig{}
   294  			err := asset.Generate(context.Background(), parents)
   295  
   296  			// Check if the test failed because nmstatectl is not available in CI
   297  			if tc.requiresNmstatectl {
   298  				_, execErr := exec.LookPath("nmstatectl")
   299  				if execErr != nil {
   300  					assert.ErrorContains(t, err, "executable file not found")
   301  					t.Skip("No nmstatectl binary available")
   302  				}
   303  			}
   304  
   305  			switch {
   306  			case tc.expectedError != "":
   307  				assert.ErrorContains(t, err, tc.expectedError)
   308  			case len(tc.expectedConfig) == 0:
   309  				assert.NoError(t, err)
   310  				assert.Equal(t, tc.expectedConfig, asset.Config)
   311  			default:
   312  				assert.NoError(t, err)
   313  				assert.Equal(t, tc.expectedConfig, asset.Config)
   314  				assert.NotEmpty(t, asset.Files())
   315  
   316  				configFile := asset.Files()[0]
   317  				assert.Equal(t, "cluster-manifests/nmstateconfig.yaml", configFile.Filename)
   318  
   319  				// Split up the file into multiple YAMLs if it contains NMStateConfig for more than one node
   320  				yamlList, err := GetMultipleYamls[aiv1beta1.NMStateConfig](configFile.Data)
   321  
   322  				assert.NoError(t, err)
   323  				assert.Equal(t, len(tc.expectedConfig), len(yamlList))
   324  
   325  				for i := range tc.expectedConfig {
   326  					assert.Equal(t, *tc.expectedConfig[i], yamlList[i])
   327  				}
   328  				assert.Equal(t, len(tc.expectedConfig), len(asset.StaticNetworkConfig))
   329  			}
   330  		})
   331  	}
   332  }
   333  
   334  func TestNMStateConfig_LoadedFromDisk(t *testing.T) {
   335  	cases := []struct {
   336  		name               string
   337  		data               string
   338  		fetchError         error
   339  		expectedFound      bool
   340  		expectedError      string
   341  		requiresNmstatectl bool
   342  		expectedConfig     []*models.HostStaticNetworkConfig
   343  	}{
   344  		{
   345  			name: "valid-config-file",
   346  			data: `
   347  metadata:
   348    name: mynmstateconfig
   349    namespace: spoke-cluster
   350    labels:
   351      cluster0-nmstate-label-name: cluster0-nmstate-label-value
   352  spec:
   353    config:
   354      interfaces:
   355        - name: eth0
   356          type: ethernet
   357          state: up
   358          mac-address: 52:54:01:aa:aa:a1
   359          ipv4:
   360            enabled: true
   361            address:
   362              - ip: 192.168.122.21
   363                prefix-length: 24
   364            dhcp: false
   365      dns-resolver:
   366        config:
   367          server:
   368            - 192.168.122.1
   369      routes:
   370        config:
   371          - destination: 0.0.0.0/0
   372            next-hop-address: 192.168.122.1
   373            next-hop-interface: eth0
   374            table-id: 254
   375    interfaces:
   376      - name: "eth0"
   377        macAddress: "52:54:01:aa:aa:a1"
   378      - name: "eth1"
   379        macAddress: "52:54:01:bb:bb:b1"`,
   380  			requiresNmstatectl: true,
   381  			expectedFound:      true,
   382  			expectedConfig: []*models.HostStaticNetworkConfig{
   383  				{
   384  					MacInterfaceMap: models.MacInterfaceMap{
   385  						{LogicalNicName: "eth0", MacAddress: "52:54:01:aa:aa:a1"},
   386  						{LogicalNicName: "eth1", MacAddress: "52:54:01:bb:bb:b1"},
   387  					},
   388  					NetworkYaml: "dns-resolver:\n  config:\n    server:\n    - 192.168.122.1\ninterfaces:\n- ipv4:\n    address:\n    - ip: 192.168.122.21\n      prefix-length: 24\n    dhcp: false\n    enabled: true\n  mac-address: 52:54:01:aa:aa:a1\n  name: eth0\n  state: up\n  type: ethernet\nroutes:\n  config:\n  - destination: 0.0.0.0/0\n    next-hop-address: 192.168.122.1\n    next-hop-interface: eth0\n    table-id: 254\n",
   389  				},
   390  			},
   391  		},
   392  
   393  		{
   394  			name: "valid-config-multiple-yamls",
   395  			data: `
   396  metadata:
   397    name: mynmstateconfig
   398    namespace: spoke-cluster
   399    labels:
   400      cluster0-nmstate-label-name: cluster0-nmstate-label-value
   401  spec:
   402    config:
   403      interfaces:
   404        - name: eth0
   405          type: ethernet
   406          state: up
   407          mac-address: 52:54:01:aa:aa:a1
   408          ipv4:
   409            enabled: true
   410            address:
   411              - ip: 192.168.122.21
   412                prefix-length: 24
   413    interfaces:
   414      - name: "eth0"
   415        macAddress: "52:54:01:aa:aa:a1"
   416  ---
   417  metadata:
   418    name: mynmstateconfig-2
   419    namespace: spoke-cluster
   420    labels:
   421      cluster0-nmstate-label-name: cluster0-nmstate-label-value
   422  spec:
   423    config:
   424      interfaces:
   425        - name: eth0
   426          type: ethernet
   427          state: up
   428          mac-address: 52:54:01:cc:cc:c1
   429          ipv4:
   430            enabled: true
   431            address:
   432              - ip: 192.168.122.22
   433                prefix-length: 24
   434    interfaces:
   435      - name: "eth0"
   436        macAddress: "52:54:01:cc:cc:c1"`,
   437  			requiresNmstatectl: true,
   438  			expectedFound:      true,
   439  			expectedConfig: []*models.HostStaticNetworkConfig{
   440  				{
   441  					MacInterfaceMap: models.MacInterfaceMap{
   442  						{LogicalNicName: "eth0", MacAddress: "52:54:01:aa:aa:a1"},
   443  					},
   444  					NetworkYaml: "interfaces:\n- ipv4:\n    address:\n    - ip: 192.168.122.21\n      prefix-length: 24\n    enabled: true\n  mac-address: 52:54:01:aa:aa:a1\n  name: eth0\n  state: up\n  type: ethernet\n",
   445  				},
   446  				{
   447  					MacInterfaceMap: models.MacInterfaceMap{
   448  						{LogicalNicName: "eth0", MacAddress: "52:54:01:cc:cc:c1"},
   449  					},
   450  					NetworkYaml: "interfaces:\n- ipv4:\n    address:\n    - ip: 192.168.122.22\n      prefix-length: 24\n    enabled: true\n  mac-address: 52:54:01:cc:cc:c1\n  name: eth0\n  state: up\n  type: ethernet\n",
   451  				},
   452  			},
   453  		},
   454  
   455  		{
   456  			name: "invalid-interfaces",
   457  			data: `
   458  metadata:
   459    name: mynmstateconfig
   460    namespace: spoke-cluster
   461    labels:
   462      cluster0-nmstate-label-name: cluster0-nmstate-label-value
   463  spec:
   464    interfaces:
   465      - name: "eth0"
   466        macAddress: "52:54:01:aa:aa:a1"
   467      - name: "eth0"
   468        macAddress: "52:54:01:bb:bb:b1"`,
   469  			requiresNmstatectl: true,
   470  			expectedError:      "staticNetwork configuration is not valid",
   471  		},
   472  
   473  		// This test case currently does not work for libnmstate 2.2.9,
   474  		// due a regression that will be fixed in https://github.com/nmstate/nmstate/issues/2311
   475  		// 		{
   476  		// 			name: "invalid-address-for-type",
   477  		// 			data: `
   478  		// metadata:
   479  		//   name: mynmstateconfig
   480  		//   namespace: spoke-cluster
   481  		//   labels:
   482  		//     cluster0-nmstate-label-name: cluster0-nmstate-label-value
   483  		// spec:
   484  		//   config:
   485  		//     interfaces:
   486  		//       - name: eth0
   487  		//         type: ethernet
   488  		//         state: up
   489  		//         mac-address: 52:54:01:aa:aa:a1
   490  		//         ipv6:
   491  		//           enabled: true
   492  		//           address:
   493  		//             - ip: 192.168.122.21
   494  		//               prefix-length: 24
   495  		//   interfaces:
   496  		//     - name: "eth0"
   497  		//       macAddress: "52:54:01:aa:aa:a1"`,
   498  		// 			requiresNmstatectl: true,
   499  		// 			expectedError:      "staticNetwork configuration is not valid",
   500  		// 		},
   501  
   502  		{
   503  			name: "missing-label",
   504  			data: `
   505  metadata:
   506    name: mynmstateconfig
   507    namespace: spoke-cluster
   508  spec:
   509    config:
   510      interfaces:
   511        - name: eth0
   512          type: ethernet
   513          state: up
   514          mac-address: 52:54:01:aa:aa:a1
   515          ipv4:
   516            enabled: true
   517            address:
   518              - ip: 192.168.122.21
   519                prefix-length: 24
   520    interfaces:
   521      - name: "eth0"
   522        macAddress: "52:54:01:aa:aa:a1"`,
   523  			requiresNmstatectl: true,
   524  			expectedError:      "invalid NMStateConfig configuration: ObjectMeta.Labels: Required value: mynmstateconfig does not have any label set",
   525  		},
   526  
   527  		{
   528  			name:          "not-yaml",
   529  			data:          `This is not a yaml file`,
   530  			expectedError: "could not decode YAML for cluster-manifests/nmstateconfig.yaml: Error reading multiple YAMLs: error unmarshaling JSON: while decoding JSON: json: cannot unmarshal string into Go value of type v1beta1.NMStateConfig",
   531  		},
   532  		{
   533  			name:       "file-not-found",
   534  			fetchError: &os.PathError{Err: os.ErrNotExist},
   535  		},
   536  		{
   537  			name:          "error-fetching-file",
   538  			fetchError:    errors.New("fetch failed"),
   539  			expectedError: "failed to load file cluster-manifests/nmstateconfig.yaml: fetch failed",
   540  		},
   541  	}
   542  	for _, tc := range cases {
   543  		t.Run(tc.name, func(t *testing.T) {
   544  			// nmstate may not be installed yet in CI so skip this test if not
   545  			if tc.requiresNmstatectl {
   546  				_, execErr := exec.LookPath("nmstatectl")
   547  				if execErr != nil {
   548  					t.Skip("No nmstatectl binary available")
   549  				}
   550  			}
   551  
   552  			mockCtrl := gomock.NewController(t)
   553  			defer mockCtrl.Finish()
   554  
   555  			fileFetcher := mock.NewMockFileFetcher(mockCtrl)
   556  			fileFetcher.EXPECT().FetchByName(nmStateConfigFilename).
   557  				Return(
   558  					&asset.File{
   559  						Filename: nmStateConfigFilename,
   560  						Data:     []byte(tc.data)},
   561  					tc.fetchError,
   562  				)
   563  
   564  			asset := &NMStateConfig{}
   565  			found, err := asset.Load(fileFetcher)
   566  			assert.Equal(t, tc.expectedFound, found, "unexpected found value returned from Load")
   567  			if tc.expectedError != "" {
   568  				assert.ErrorContains(t, err, tc.expectedError)
   569  			} else {
   570  				assert.NoError(t, err)
   571  			}
   572  			if tc.expectedFound {
   573  				assert.Equal(t, tc.expectedConfig, asset.StaticNetworkConfig, "unexpected Config in NMStateConfig")
   574  				assert.Equal(t, len(tc.expectedConfig), len(asset.Config))
   575  				for i := 0; i < len(tc.expectedConfig); i++ {
   576  
   577  					staticNetworkConfig := asset.StaticNetworkConfig[i]
   578  					nmStateConfig := asset.Config[i]
   579  
   580  					for n := 0; n < len(staticNetworkConfig.MacInterfaceMap); n++ {
   581  						macInterfaceMap := staticNetworkConfig.MacInterfaceMap[n]
   582  						iface := nmStateConfig.Spec.Interfaces[n]
   583  
   584  						assert.Equal(t, macInterfaceMap.LogicalNicName, iface.Name)
   585  						assert.Equal(t, macInterfaceMap.MacAddress, iface.MacAddress)
   586  					}
   587  					assert.YAMLEq(t, staticNetworkConfig.NetworkYaml, string(nmStateConfig.Spec.NetConfig.Raw))
   588  				}
   589  
   590  			}
   591  		})
   592  	}
   593  }
   594  
   595  func TestGetNodeZeroIP(t *testing.T) {
   596  	cases := []struct {
   597  		name          string
   598  		expectedIP    string
   599  		expectedError string
   600  		configs       []string
   601  		hosts         []agent.Host
   602  	}{
   603  		{
   604  			name:          "no interfaces",
   605  			expectedError: "no interface IPs set",
   606  		},
   607  		{
   608  			name:       "first interface",
   609  			expectedIP: "192.168.122.21",
   610  			configs: []string{
   611  				`
   612  interfaces:
   613    - name: eth0
   614      type: ethernet
   615      ipv4:
   616        address:
   617          - ip: 192.168.122.21
   618    - name: eth1
   619      type: ethernet
   620      ipv4:
   621        address:
   622          - ip: 192.168.122.22
   623  `,
   624  			},
   625  		},
   626  		{
   627  			name:       "second interface",
   628  			expectedIP: "192.168.122.22",
   629  			configs: []string{
   630  				`
   631  interfaces:
   632    - name: eth0
   633      type: ethernet
   634    - name: eth1
   635      type: ethernet
   636      ipv4:
   637        address:
   638          - ip: 192.168.122.22
   639  `,
   640  			},
   641  		},
   642  		{
   643  			name:       "second host",
   644  			expectedIP: "192.168.122.22",
   645  			configs: []string{
   646  				`
   647  interfaces:
   648    - name: eth0
   649      type: ethernet
   650    - name: eth1
   651      type: ethernet
   652  `,
   653  				`
   654  interfaces:
   655    - name: eth0
   656      type: ethernet
   657    - name: eth1
   658      type: ethernet
   659      ipv4:
   660        address:
   661          - ip: 192.168.122.22
   662  `,
   663  			},
   664  		},
   665  		{
   666  			name:       "ipv4 first",
   667  			expectedIP: "192.168.122.22",
   668  			configs: []string{
   669  				`
   670  interfaces:
   671    - name: eth0
   672      type: ethernet
   673      ipv6:
   674        address:
   675          - ip: "2001:0db8::0001"
   676      ipv4:
   677        address:
   678          - ip: 192.168.122.22
   679  `,
   680  			},
   681  		},
   682  		{
   683  			name:       "ipv6 host first",
   684  			expectedIP: "2001:0db8::0001",
   685  			configs: []string{
   686  				`
   687  interfaces:
   688    - name: eth0
   689      type: ethernet
   690      ipv6:
   691        address:
   692          - ip: "2001:0db8::0001"
   693  `,
   694  				`
   695  interfaces:
   696    - name: eth0
   697      type: ethernet
   698      ipv4:
   699        address:
   700          - ip: 192.168.122.31
   701  `,
   702  			},
   703  		},
   704  		{
   705  			name:       "ipv6 first",
   706  			expectedIP: "2001:0db8::0001",
   707  			configs: []string{
   708  				`
   709  interfaces:
   710    - name: eth0
   711      type: ethernet
   712      ipv6:
   713        address:
   714          - ip: "2001:0db8::0001"
   715    - name: eth1
   716      type: ethernet
   717      ipv4:
   718        address:
   719          - ip: 192.168.122.22
   720  `,
   721  			},
   722  		},
   723  		{
   724  			name:       "ipv6",
   725  			expectedIP: "2001:0db8::0001",
   726  			configs: []string{
   727  				`
   728  interfaces:
   729    - name: eth0
   730      type: ethernet
   731      ipv6:
   732        address:
   733          - ip: "2001:0db8::0001"
   734  `,
   735  			},
   736  		},
   737  		{
   738  			name:       "skip workers/nodes without role",
   739  			expectedIP: "192.168.122.22",
   740  			hosts: []agent.Host{
   741  				{
   742  					Role: "worker",
   743  					NetworkConfig: aiv1beta1.NetConfig{Raw: []byte(`
   744  interfaces:
   745  - name: eth0
   746    type: ethernet
   747    ipv4:
   748      address:
   749        - ip: 192.168.122.31`)},
   750  				},
   751  				{
   752  					Role: "",
   753  					NetworkConfig: aiv1beta1.NetConfig{Raw: []byte(`
   754  interfaces:
   755  - name: eth0
   756    type: ethernet
   757    ipv4:
   758      address:
   759        - ip: 192.168.122.32`)},
   760  				},
   761  				{
   762  					Role: "master",
   763  					NetworkConfig: aiv1beta1.NetConfig{Raw: []byte(`
   764  interfaces:
   765  - name: eth0
   766    type: ethernet
   767    ipv4:
   768      address:
   769        - ip: 192.168.122.22`)},
   770  				},
   771  			},
   772  		},
   773  		{
   774  			name:          "fail if only workers",
   775  			expectedError: "invalid NMState configurations provided, no interface IPs set",
   776  			hosts: []agent.Host{
   777  				{
   778  					Role: "worker",
   779  					NetworkConfig: aiv1beta1.NetConfig{Raw: []byte(`
   780  interfaces:
   781  - name: eth0
   782    type: ethernet
   783    ipv4:
   784      address:
   785        - ip: 192.168.122.31`)},
   786  				},
   787  			},
   788  		},
   789  		{
   790  			name:          "fail if only master without static configuration",
   791  			expectedError: "invalid NMState configurations provided, no interface IPs set",
   792  			hosts: []agent.Host{
   793  				{
   794  					Role: "master",
   795  				},
   796  			},
   797  		},
   798  		{
   799  			name:       "fallback on configs if missing host definition",
   800  			expectedIP: "192.168.122.22",
   801  			hosts: []agent.Host{
   802  				{
   803  					Role: "master",
   804  				},
   805  			},
   806  			configs: []string{`
   807  interfaces:
   808    - name: eth0
   809      type: ethernet
   810      ipv4:
   811        address:
   812          - ip: 192.168.122.22`,
   813  			},
   814  		},
   815  		{
   816  			name:       "implicit masters",
   817  			expectedIP: "192.168.122.32",
   818  			hosts: []agent.Host{
   819  				{
   820  					Role: "worker",
   821  					NetworkConfig: aiv1beta1.NetConfig{Raw: []byte(`
   822  interfaces:
   823  - name: eth0
   824    type: ethernet
   825    ipv4:
   826      address:
   827        - ip: 192.168.122.31`)},
   828  				},
   829  				{
   830  					Role: "",
   831  					NetworkConfig: aiv1beta1.NetConfig{Raw: []byte(`
   832  interfaces:
   833  - name: eth0
   834    type: ethernet
   835    ipv4:
   836      address:
   837        - ip: 192.168.122.32`)},
   838  				},
   839  			},
   840  		},
   841  	}
   842  	for _, tc := range cases {
   843  		t.Run(tc.name, func(t *testing.T) {
   844  			var configs []*aiv1beta1.NMStateConfig
   845  			for _, hostRaw := range tc.configs {
   846  				configs = append(configs, &aiv1beta1.NMStateConfig{
   847  					Spec: aiv1beta1.NMStateConfigSpec{
   848  						NetConfig: aiv1beta1.NetConfig{
   849  							Raw: aiv1beta1.RawNetConfig(hostRaw),
   850  						},
   851  					},
   852  				})
   853  			}
   854  
   855  			ip, err := GetNodeZeroIP(tc.hosts, configs)
   856  			if tc.expectedError == "" {
   857  				assert.NoError(t, err)
   858  				assert.Equal(t, tc.expectedIP, ip)
   859  			} else {
   860  				assert.ErrorContains(t, err, tc.expectedError)
   861  			}
   862  		})
   863  	}
   864  }