diff --git a/data/data/ovirt/main.tf b/data/data/ovirt/main.tf index 5080734864b..1e024e0d219 100644 --- a/data/data/ovirt/main.tf +++ b/data/data/ovirt/main.tf @@ -16,6 +16,7 @@ module "template" { ovirt_template_mem = var.ovirt_template_mem disk_size_gib = var.ovirt_template_disk_size_gib ovirt_network_name = var.ovirt_network_name + ovirt_vnic_profile_id = var.ovirt_vnic_profile_id } module "bootstrap" { diff --git a/data/data/ovirt/template/main.tf b/data/data/ovirt/template/main.tf index 702be9e29c8..d6dacf4c7cc 100644 --- a/data/data/ovirt/template/main.tf +++ b/data/data/ovirt/template/main.tf @@ -22,12 +22,6 @@ data "ovirt_clusters" "clusters" { } } -// default vnic profile of ovirt's cluster network -data "ovirt_vnic_profiles" "vnic_profiles" { - name_regex = var.ovirt_network_name - network_id = local.network_id -} - // work around the missing regexall in terraform < 0.12.9 // if length(regexall("^Blank.*$", t.name) locals { @@ -59,7 +53,7 @@ resource "ovirt_vm" "tmp_import_vm" { } nics { name = "nic1" - vnic_profile_id = data.ovirt_vnic_profiles.vnic_profiles.vnic_profiles.0.id + vnic_profile_id = var.ovirt_vnic_profile_id } depends_on = [ovirt_image_transfer.releaseimage] } diff --git a/data/data/ovirt/template/variables.tf b/data/data/ovirt/template/variables.tf index 8200b420ad3..3e3c50e200d 100644 --- a/data/data/ovirt/template/variables.tf +++ b/data/data/ovirt/template/variables.tf @@ -30,10 +30,14 @@ variable "openstack_base_image_local_file_path" { variable "ovirt_network_name" { type = string - default = "ovirtmgmt" description = "The name of ovirt's logical network for the selected ovirt cluster." } +variable "ovirt_vnic_profile_id" { + type = string + description = "The ID of the vnic profile of ovirt's logical network." +} + variable "ovirt_template_mem" { type = string } diff --git a/data/data/ovirt/variables-ovirt.tf b/data/data/ovirt/variables-ovirt.tf index 6f363bef6a4..c0c49144d13 100644 --- a/data/data/ovirt/variables-ovirt.tf +++ b/data/data/ovirt/variables-ovirt.tf @@ -46,6 +46,11 @@ variable "ovirt_network_name" { description = "The name of ovirt's logical network for the selected ovirt cluster." } +variable "ovirt_vnic_profile_id" { + type = string + description = "The ID of the vnic profile of ovirt's logical network." +} + variable "ovirt_master_mem" { type = string default = "8192" diff --git a/pkg/asset/cluster/tfvars.go b/pkg/asset/cluster/tfvars.go index c1c2b1eef01..2a6950b3db5 100644 --- a/pkg/asset/cluster/tfvars.go +++ b/pkg/asset/cluster/tfvars.go @@ -414,6 +414,25 @@ func (t *TerraformVariables) Generate(parents asset.Parents) error { if err != nil { return err } + con, err := ovirtconfig.NewConnection() + if err != nil { + return err + } + defer con.Close() + + if installConfig.Config.Platform.Ovirt.VNICProfileID == "" { + profiles, err := ovirtconfig.FetchVNICProfileByClusterNetwork( + con, + installConfig.Config.Platform.Ovirt.ClusterID, + installConfig.Config.Platform.Ovirt.NetworkName) + if err != nil { + return errors.Wrapf(err, "failed to compute values for oVirt platform") + } + if len(profiles) != 1 { + return errors.Wrapf(err, "failed to compute values for oVirt platform, there are multiple vNic profiles.") + } + installConfig.Config.Platform.Ovirt.VNICProfileID = profiles[0].MustId() + } data, err := ovirttfvars.TFVars( config.URL, @@ -423,6 +442,7 @@ func (t *TerraformVariables) Generate(parents asset.Parents) error { installConfig.Config.Platform.Ovirt.ClusterID, installConfig.Config.Platform.Ovirt.StorageDomainID, installConfig.Config.Platform.Ovirt.NetworkName, + installConfig.Config.Platform.Ovirt.VNICProfileID, string(*rhcosImage), clusterID.InfraID, ) diff --git a/pkg/asset/installconfig/installconfig.go b/pkg/asset/installconfig/installconfig.go index 75875e2aa14..b12cc0d17e0 100644 --- a/pkg/asset/installconfig/installconfig.go +++ b/pkg/asset/installconfig/installconfig.go @@ -14,6 +14,7 @@ import ( icazure "github.com/openshift/installer/pkg/asset/installconfig/azure" icgcp "github.com/openshift/installer/pkg/asset/installconfig/gcp" icopenstack "github.com/openshift/installer/pkg/asset/installconfig/openstack" + icovirt "github.com/openshift/installer/pkg/asset/installconfig/ovirt" "github.com/openshift/installer/pkg/types" "github.com/openshift/installer/pkg/types/conversion" "github.com/openshift/installer/pkg/types/defaults" @@ -173,5 +174,8 @@ func (a *InstallConfig) platformValidation() error { if a.Config.Platform.AWS != nil { return aws.Validate(context.TODO(), a.AWS, a.Config) } + if a.Config.Platform.Ovirt != nil { + return icovirt.Validate(a.Config) + } return field.ErrorList{}.ToAggregate() } diff --git a/pkg/asset/installconfig/ovirt/client.go b/pkg/asset/installconfig/ovirt/client.go index ccbbad3437a..589ed5c4ae0 100644 --- a/pkg/asset/installconfig/ovirt/client.go +++ b/pkg/asset/installconfig/ovirt/client.go @@ -1,12 +1,15 @@ package ovirt import ( + "fmt" + ovirtsdk "github.com/ovirt/go-ovirt" + "github.com/pkg/errors" ) -// GetConnection is a convenience method to get a connection to ovirt api +// getConnection is a convenience method to get a connection to ovirt api // form a Config Object. -func GetConnection(ovirtConfig Config) (*ovirtsdk.Connection, error) { +func getConnection(ovirtConfig Config) (*ovirtsdk.Connection, error) { con, err := ovirtsdk.NewConnectionBuilder(). URL(ovirtConfig.URL). Username(ovirtConfig.Username). @@ -19,3 +22,49 @@ func GetConnection(ovirtConfig Config) (*ovirtsdk.Connection, error) { } return con, nil } + +// NewConnection returns a new client connection to oVirt's API endpoint. +// It is the responsibility of the caller to close the connection. +func NewConnection() (*ovirtsdk.Connection, error) { + ovirtConfig, err := NewConfig() + if err != nil { + return nil, errors.Wrap(err, "getting ovirt configuration") + } + con, err := getConnection(ovirtConfig) + if err != nil { + return nil, errors.Wrap(err, "establishing ovirt connection") + } + return con, nil +} + +// FetchVNICProfileByClusterNetwork returns a list of profiles for the given cluster and network name. +func FetchVNICProfileByClusterNetwork(con *ovirtsdk.Connection, clusterID string, networkName string) ([]*ovirtsdk.VnicProfile, error) { + clusterResponse, err := con.SystemService().ClustersService().ClusterService(clusterID).Get().Follow("networks").Send() + if err != nil { + return nil, err + } + + cluster, ok := clusterResponse.Cluster() + if !ok { + return nil, fmt.Errorf("failed to find cluster with id %s", clusterID) + } + + networks, ok := cluster.Networks() + if !ok { + return nil, fmt.Errorf("no cluster networks for cluster %s [%s]", cluster.MustName(), clusterID) + } + + for _, n := range networks.Slice() { + if n.MustName() != networkName { + continue + } + + profilesGet, err := con.SystemService().NetworksService().NetworkService(n.MustId()).VnicProfilesService().List().Send() + if err != nil { + return nil, fmt.Errorf("failed to fetch vNic profiles") + } + + return profilesGet.MustProfiles().Slice(), nil + } + return nil, fmt.Errorf("there are no vNic profiles for the given cluster ID %s and network name %s", clusterID, networkName) +} diff --git a/pkg/asset/installconfig/ovirt/network.go b/pkg/asset/installconfig/ovirt/network.go index 6690a3aa757..e57f7e1a7d8 100644 --- a/pkg/asset/installconfig/ovirt/network.go +++ b/pkg/asset/installconfig/ovirt/network.go @@ -50,3 +50,46 @@ func askNetwork(c *ovirtsdk4.Connection, p *ovirt.Platform) error { }) return err } + +func askVNICProfileID(c *ovirtsdk4.Connection, p *ovirt.Platform) error { + var profileID string + var profilesByNames = make(map[string]*ovirtsdk4.VnicProfile) + var profileNames []string + profiles, err := FetchVNICProfileByClusterNetwork(c, p.ClusterID, p.NetworkName) + if err != nil { + return err + } + + for _, profile := range profiles { + profilesByNames[profile.MustName()] = profile + profileNames = append(profileNames, profile.MustName()) + } + + if len(profilesByNames) == 1 { + p.VNICProfileID = profilesByNames[profileNames[0]].MustId() + return nil + } + + // we have multiple vnic profile for the selected network + err = survey.AskOne(&survey.Select{ + Message: "VNIC Profile", + Help: "The oVirt VNIC profile of the VMs.", + Options: profileNames, + }, + &profileID, + func(ans interface{}) error { + choice := ans.(string) + sort.Strings(profileNames) + i := sort.SearchStrings(profileNames, choice) + if i == len(profileNames) || profileNames[i] != choice { + return fmt.Errorf("invalid VNIC profile %s", choice) + } + profile, ok := profilesByNames[choice] + if !ok { + return fmt.Errorf("cannot find a VNIC profile id by the name %s", choice) + } + p.VNICProfileID = profile.MustId() + return nil + }) + return err +} diff --git a/pkg/asset/installconfig/ovirt/ovirt.go b/pkg/asset/installconfig/ovirt/ovirt.go index 705d069df44..567ebb46cca 100644 --- a/pkg/asset/installconfig/ovirt/ovirt.go +++ b/pkg/asset/installconfig/ovirt/ovirt.go @@ -52,6 +52,11 @@ func Platform() (*ovirt.Platform, error) { return &p, err } + err = askVNICProfileID(c, &p) + if err != nil { + return &p, err + } + err = survey.Ask([]*survey.Question{ { Prompt: &survey.Input{ diff --git a/pkg/asset/installconfig/ovirt/validaton.go b/pkg/asset/installconfig/ovirt/validaton.go new file mode 100644 index 00000000000..1dcd192410d --- /dev/null +++ b/pkg/asset/installconfig/ovirt/validaton.go @@ -0,0 +1,96 @@ +package ovirt + +import ( + "fmt" + + ovirtsdk "github.com/ovirt/go-ovirt" + "github.com/pkg/errors" + "gopkg.in/AlecAivazis/survey.v1" + "k8s.io/apimachinery/pkg/util/validation/field" + + "github.com/openshift/installer/pkg/types" + "github.com/openshift/installer/pkg/types/ovirt" + "github.com/openshift/installer/pkg/types/ovirt/validation" +) + +// Validate executes ovirt specific validation +func Validate(ic *types.InstallConfig) error { + allErrs := field.ErrorList{} + ovirtPlatformPath := field.NewPath("platform", "ovirt") + + if ic.Platform.Ovirt == nil { + return errors.New(field.Required( + ovirtPlatformPath, + "oVirt validation requires a oVirt platform configuration").Error()) + } + + allErrs = append( + allErrs, + validation.ValidatePlatform(ic.Platform.Ovirt, ovirtPlatformPath)...) + + con, err := NewConnection() + if err != nil { + return err + } + defer con.Close() + + if err := validateVNICProfile(*ic.Ovirt, con); err != nil { + allErrs = append( + allErrs, + field.Invalid(ovirtPlatformPath.Child("vnicProfileID"), ic.Ovirt.VNICProfileID, err.Error())) + } + + return allErrs.ToAggregate() +} + +// authenticated takes an ovirt platform and validates +// its connection to the API by establishing +// the connection and authenticating successfully. +// The API connection is closed in the end and must leak +// or be reused in any way. +func authenticated(c *Config) survey.Validator { + return func(val interface{}) error { + connection, err := ovirtsdk.NewConnectionBuilder(). + URL(c.URL). + Username(c.Username). + Password(fmt.Sprint(val)). + CAFile(c.CAFile). + Insecure(c.Insecure). + Build() + + if err != nil { + return errors.Errorf("failed to construct connection to oVirt platform %s", err) + } + + defer connection.Close() + + err = connection.Test() + if err != nil { + return errors.Errorf("failed to connect to oVirt platform %s", err) + } + return nil + } + +} + +// validate the provided vnic profile exists and belongs the the cluster network +func validateVNICProfile(platform ovirt.Platform, con *ovirtsdk.Connection) error { + if platform.VNICProfileID != "" { + profiles, err := FetchVNICProfileByClusterNetwork(con, platform.ClusterID, platform.NetworkName) + if err != nil { + return err + } + + for _, p := range profiles { + if platform.VNICProfileID == p.MustId() { + return nil + } + } + + return fmt.Errorf( + "vNic profile ID %s does not belong to cluster network %s", + platform.VNICProfileID, + platform.NetworkName) + } + return nil +} diff --git a/pkg/asset/installconfig/ovirt/validators_test.go b/pkg/asset/installconfig/ovirt/validaton_test.go similarity index 100% rename from pkg/asset/installconfig/ovirt/validators_test.go rename to pkg/asset/installconfig/ovirt/validaton_test.go diff --git a/pkg/asset/installconfig/ovirt/validators.go b/pkg/asset/installconfig/ovirt/validators.go deleted file mode 100644 index 4bbb3fe29fa..00000000000 --- a/pkg/asset/installconfig/ovirt/validators.go +++ /dev/null @@ -1,39 +0,0 @@ -package ovirt - -import ( - "fmt" - - ovirtsdk "github.com/ovirt/go-ovirt" - "github.com/pkg/errors" - "gopkg.in/AlecAivazis/survey.v1" -) - -// authenticated takes an ovirt platform and validates -// its connection to the API by establishing -// the connection and authenticating successfully. -// The API connection is closed in the end and must leak -// or be reused in any way. -func authenticated(c *Config) survey.Validator { - return func(val interface{}) error { - connection, err := ovirtsdk.NewConnectionBuilder(). - URL(c.URL). - Username(c.Username). - Password(fmt.Sprint(val)). - CAFile(c.CAFile). - Insecure(c.Insecure). - Build() - - if err != nil { - return errors.Errorf("failed to construct connection to oVirt platform %s", err) - } - - defer connection.Close() - - err = connection.Test() - if err != nil { - return errors.Errorf("failed to connect to oVirt platform %s", err) - } - return nil - } - -} diff --git a/pkg/asset/installconfig/platformcredscheck.go b/pkg/asset/installconfig/platformcredscheck.go index b0da015d032..72439114a7c 100644 --- a/pkg/asset/installconfig/platformcredscheck.go +++ b/pkg/asset/installconfig/platformcredscheck.go @@ -65,13 +65,9 @@ func (a *PlatformCredsCheck) Generate(dependencies asset.Parents) error { return errors.Wrap(err, "creating Azure session") } case ovirt.Name: - ovirtConfig, err := ovirtconfig.NewConfig() + con, err := ovirtconfig.NewConnection() if err != nil { - return errors.Wrap(err, "getting ovirt configuration") - } - con, err := ovirtconfig.GetConnection(ovirtConfig) - if err != nil { - return errors.Wrap(err, "establishing ovirt connection") + return errors.Wrap(err, "creating oVirt connection") } err = con.Test() if err != nil { diff --git a/pkg/destroy/ovirt/destroyer.go b/pkg/destroy/ovirt/destroyer.go index 693e7fd4c7d..cf9c70d9dba 100644 --- a/pkg/destroy/ovirt/destroyer.go +++ b/pkg/destroy/ovirt/destroyer.go @@ -21,13 +21,7 @@ type ClusterUninstaller struct { // Run is the entrypoint to start the uninstall process. func (uninstaller *ClusterUninstaller) Run() error { - config, err := ovirt.NewConfig() - if err != nil { - return err - } - - con, err := ovirt.GetConnection(config) - + con, err := ovirt.NewConnection() if err != nil { return fmt.Errorf("failed to initialize connection to ovirt-engine's %s", err) } diff --git a/pkg/tfvars/ovirt/ovirt.go b/pkg/tfvars/ovirt/ovirt.go index 33270b400db..00ff791e7be 100644 --- a/pkg/tfvars/ovirt/ovirt.go +++ b/pkg/tfvars/ovirt/ovirt.go @@ -16,6 +16,7 @@ type config struct { ClusterID string `json:"ovirt_cluster_id"` StorageDomainID string `json:"ovirt_storage_domain_id"` NetworkName string `json:"ovirt_network_name,omitempty"` + VNICProfileID string `json:"ovirt_vnic_profile_id,omitempty"` BaseImageName string `json:"openstack_base_image_name,omitempty"` BaseImageLocalFilePath string `json:"openstack_base_image_local_file_path,omitempty"` } @@ -29,6 +30,7 @@ func TFVars( clusterID string, stoarageDomainID string, networkName string, + vnicProfileID string, baseImage string, infraID string) ([]byte, error) { @@ -40,6 +42,7 @@ func TFVars( ClusterID: clusterID, StorageDomainID: stoarageDomainID, NetworkName: networkName, + VNICProfileID: vnicProfileID, BaseImageName: baseImage, } diff --git a/pkg/types/defaults/installconfig.go b/pkg/types/defaults/installconfig.go index ba9ed26c96a..b728f08d941 100644 --- a/pkg/types/defaults/installconfig.go +++ b/pkg/types/defaults/installconfig.go @@ -10,6 +10,7 @@ import ( libvirtdefaults "github.com/openshift/installer/pkg/types/libvirt/defaults" nonedefaults "github.com/openshift/installer/pkg/types/none/defaults" openstackdefaults "github.com/openshift/installer/pkg/types/openstack/defaults" + ovirtdefaults "github.com/openshift/installer/pkg/types/ovirt/defaults" vspheredefaults "github.com/openshift/installer/pkg/types/vsphere/defaults" ) @@ -81,6 +82,8 @@ func SetInstallConfigDefaults(c *types.InstallConfig) { vspheredefaults.SetPlatformDefaults(c.Platform.VSphere, c) case c.Platform.BareMetal != nil: baremetaldefaults.SetPlatformDefaults(c.Platform.BareMetal, c) + case c.Platform.Ovirt != nil: + ovirtdefaults.SetPlatformDefaults(c.Platform.Ovirt) case c.Platform.None != nil: nonedefaults.SetPlatformDefaults(c.Platform.None) } diff --git a/pkg/types/ovirt/defaults/platform.go b/pkg/types/ovirt/defaults/platform.go index eb89db0a4e9..5921ca44026 100644 --- a/pkg/types/ovirt/defaults/platform.go +++ b/pkg/types/ovirt/defaults/platform.go @@ -4,8 +4,12 @@ import ( "github.com/openshift/installer/pkg/types/ovirt" ) +// DefaultNetworkName is the default network name to use in a cluster +const DefaultNetworkName = "ovirtmgmt" + // SetPlatformDefaults sets the defaults for the platform. func SetPlatformDefaults(p *ovirt.Platform) { - // no platform defaults - return + if p.NetworkName == "" { + p.NetworkName = DefaultNetworkName + } } diff --git a/pkg/types/ovirt/defaults/platform_test.go b/pkg/types/ovirt/defaults/platform_test.go index 5f329734779..a3be7c5cbfc 100644 --- a/pkg/types/ovirt/defaults/platform_test.go +++ b/pkg/types/ovirt/defaults/platform_test.go @@ -9,7 +9,9 @@ import ( ) func defaultPlatform() *ovirt.Platform { - return &ovirt.Platform{} + return &ovirt.Platform{ + NetworkName: DefaultNetworkName, + } } func TestSetPlatformDefaults(t *testing.T) { diff --git a/pkg/types/ovirt/platform.go b/pkg/types/ovirt/platform.go index ed46d8464c4..dcb95f96db7 100644 --- a/pkg/types/ovirt/platform.go +++ b/pkg/types/ovirt/platform.go @@ -7,9 +7,15 @@ type Platform struct { ClusterID string `json:"ovirt_cluster_id"` // The target storage domain under which all VM disk would be created. StorageDomainID string `json:"ovirt_storage_domain_id"` - // The target network of all the network interfaces of the nodes. Omitting defaults to ovirtmgmt - // network which is a default network for evert ovirt cluster. + // The target network of all the network interfaces of the nodes. + // +optional + //Omitting defaults to ovirtmgmt network which is a default network for every ovirt cluster. NetworkName string `json:"ovirt_network_name,omitempty"` + //VNICProfileID defines the VNIC profile ID to use the the VM network interfaces. + // +optional + // Default will set the vnic profile id to the profile of the network. If there are multiple + // profiles for that network the installation exits. + VNICProfileID string `json:"vnicProfileID,omitempty"` // APIVIP is an IP which will be served by bootstrap and then pivoted masters, using keepalived APIVIP string `json:"api_vip"` // DNSVIP is the IP of the internal DNS which will be operated by the cluster diff --git a/pkg/types/ovirt/validation/platform.go b/pkg/types/ovirt/validation/platform.go index 84fc9be6e34..b66a99bd3b3 100644 --- a/pkg/types/ovirt/validation/platform.go +++ b/pkg/types/ovirt/validation/platform.go @@ -11,19 +11,24 @@ import ( func ValidatePlatform(p *ovirt.Platform, fldPath *field.Path) field.ErrorList { allErrs := field.ErrorList{} if err := validate.UUID(p.ClusterID); err != nil { - allErrs = append(allErrs, field.Invalid(fldPath.Child("clusterID"), p.ClusterID, err.Error())) + allErrs = append(allErrs, field.Invalid(fldPath.Child("ovirt_cluster_id"), p.ClusterID, err.Error())) } if err := validate.UUID(p.StorageDomainID); err != nil { - allErrs = append(allErrs, field.Invalid(fldPath.Child("storageDomainID"), p.StorageDomainID, err.Error())) + allErrs = append(allErrs, field.Invalid(fldPath.Child("ovirt_storage_domain_id"), p.StorageDomainID, err.Error())) } if err := validate.IP(p.APIVIP); err != nil { - allErrs = append(allErrs, field.Invalid(fldPath.Child("apivip"), p.APIVIP, err.Error())) + allErrs = append(allErrs, field.Invalid(fldPath.Child("api_vip"), p.APIVIP, err.Error())) } if err := validate.IP(p.DNSVIP); err != nil { - allErrs = append(allErrs, field.Invalid(fldPath.Child("dnsvip"), p.DNSVIP, err.Error())) + allErrs = append(allErrs, field.Invalid(fldPath.Child("dns_vip"), p.DNSVIP, err.Error())) } if err := validate.IP(p.IngressVIP); err != nil { - allErrs = append(allErrs, field.Invalid(fldPath.Child("ingressvip"), p.IngressVIP, err.Error())) + allErrs = append(allErrs, field.Invalid(fldPath.Child("ingress_vip"), p.IngressVIP, err.Error())) + } + if p.VNICProfileID != "" { + if err := validate.UUID(p.VNICProfileID); err != nil { + allErrs = append(allErrs, field.Invalid(fldPath.Child("vnicProfileID"), p.IngressVIP, err.Error())) + } } if p.DefaultMachinePlatform != nil { allErrs = append(allErrs, ValidateMachinePool(p.DefaultMachinePlatform, fldPath.Child("defaultMachinePlatform"))...) diff --git a/pkg/types/ovirt/validation/platform_test.go b/pkg/types/ovirt/validation/platform_test.go index 68d05961286..3743fdffe26 100644 --- a/pkg/types/ovirt/validation/platform_test.go +++ b/pkg/types/ovirt/validation/platform_test.go @@ -13,6 +13,8 @@ func validPlatform() *ovirt.Platform { return &ovirt.Platform{ ClusterID: "0953a3fb-6682-49a6-8767-43f561045a59", StorageDomainID: "57e42205-02ac-46e1-a6d1-07459d94bc51", + VNICProfileID: "57e42205-02ac-46e1-a6d1-07459d94bc52", + NetworkName: "ocp-blue", APIVIP: "10.0.0.1", DNSVIP: "10.0.0.2", IngressVIP: "10.0.0.3", @@ -76,6 +78,24 @@ func TestValidatePlatform(t *testing.T) { }(), valid: false, }, + { + name: "malformed vnic profile id", + platform: func() *ovirt.Platform { + p := validPlatform() + p.VNICProfileID = "abcd-sdf" + return p + }(), + valid: false, + }, + { + name: "valid empty vnic profile id", + platform: func() *ovirt.Platform { + p := validPlatform() + p.VNICProfileID = "" + return p + }(), + valid: true, + }, { name: "valid machine pool", platform: func() *ovirt.Platform {