diff --git a/CHANGELOG.md b/CHANGELOG.md index 0491d5a14..f15be58cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ ## 2.8.0 (Unreleased) +FEATURES: + +* `resource/vsphere_offline_software_depot`: Adds resource to the provider for offline software + depots. Support for online depots can be added at a later time. Only depots with source type + "PULL" are supported. This is intentional and aims to discourage the use of the deprecated VUM + functionality. ([#2143](https://github.com/terraform-providers/terraform-provider-vsphere/pull/2143)) +* `data/vsphere_host_base_images`: Adds data source to the provider for base images. Declaring this + data source allows users to retrieve the full list of available ESXi versions for their + environment. ([#2143](https://github.com/terraform-providers/terraform-provider-vsphere/pull/2143)) +* `resource/vsphere_compute_cluster`: Adds property that serves as an entry point for the vLCM + configuration. Allows selection of a base image and a list of custom components from a depot. + Configuring this property for the first time also enables vLCM on the cluster. + ([#2143](https://github.com/terraform-providers/terraform-provider-vsphere/pull/2143)) + CHORES: * `provider`: Updates `vmware/govmomi` to v0.36.0. ([#2149](https://github.com/terraform-providers/terraform-provider-vsphere/pull/2149)) diff --git a/vsphere/data_source_vsphere_host_base_images.go b/vsphere/data_source_vsphere_host_base_images.go new file mode 100644 index 000000000..47b774df7 --- /dev/null +++ b/vsphere/data_source_vsphere_host_base_images.go @@ -0,0 +1,39 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package vsphere + +import ( + "github.com/vmware/govmomi/vapi/esx/settings/depots" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func dataSourceVSphereHostBaseImages() *schema.Resource { + return &schema.Resource{ + Read: dataSourceVSphereHostBaseImagesRead, + Schema: map[string]*schema.Schema{ + "version": { + Type: schema.TypeList, + Computed: true, + Description: "The available ESXi versions.", + Elem: &schema.Schema{Type: schema.TypeString}, + }, + }, + } +} + +func dataSourceVSphereHostBaseImagesRead(d *schema.ResourceData, meta interface{}) error { + client := meta.(*Client).restClient + if images, err := depots.NewManager(client).ListBaseImages(); err != nil { + return err + } else { + versions := make([]string, len(images)) + for i, image := range images { + versions[i] = image.Version + } + + d.SetId(versions[0]) + return d.Set("version", versions) + } +} diff --git a/vsphere/internal/helper/testhelper/testing.go b/vsphere/internal/helper/testhelper/testing.go index dff568197..6d148e25e 100644 --- a/vsphere/internal/helper/testhelper/testing.go +++ b/vsphere/internal/helper/testhelper/testing.go @@ -241,3 +241,11 @@ data "vsphere_host" "roothost3" { } `, os.Getenv("TF_VSPHERE_VSAN_WITNESS_HOST")) } + +func ConfigDataSoftwareDepot() string { + return fmt.Sprintf(` +resource "vsphere_offline_software_depot" "depot" { + location = "%s" +} +`, os.Getenv("TF_VAR_VSPHERE_SOFTWARE_DEPOT_LOCATION")) +} diff --git a/vsphere/provider.go b/vsphere/provider.go index 0bf0c365c..7ff63b92e 100644 --- a/vsphere/provider.go +++ b/vsphere/provider.go @@ -125,6 +125,7 @@ func Provider() *schema.Provider { "vsphere_host_port_group": resourceVSphereHostPortGroup(), "vsphere_host_virtual_switch": resourceVSphereHostVirtualSwitch(), "vsphere_license": resourceVSphereLicense(), + "vsphere_offline_software_depot": resourceVsphereOfflineSoftwareDepot(), "vsphere_resource_pool": resourceVSphereResourcePool(), "vsphere_tag": resourceVSphereTag(), "vsphere_tag_category": resourceVSphereTagCategory(), @@ -158,6 +159,7 @@ func Provider() *schema.Provider { "vsphere_dynamic": dataSourceVSphereDynamic(), "vsphere_folder": dataSourceVSphereFolder(), "vsphere_host": dataSourceVSphereHost(), + "vsphere_host_base_images": dataSourceVSphereHostBaseImages(), "vsphere_host_pci_device": dataSourceVSphereHostPciDevice(), "vsphere_host_thumbprint": dataSourceVSphereHostThumbprint(), "vsphere_host_vgpu_profile": dataSourceVSphereHostVGpuProfile(), diff --git a/vsphere/resource_vsphere_compute_cluster.go b/vsphere/resource_vsphere_compute_cluster.go index 29398d46b..aeab6170d 100644 --- a/vsphere/resource_vsphere_compute_cluster.go +++ b/vsphere/resource_vsphere_compute_cluster.go @@ -6,6 +6,9 @@ package vsphere import ( "context" "fmt" + "github.com/vmware/govmomi/vapi/cis/tasks" + "github.com/vmware/govmomi/vapi/esx/settings/clusters" + "github.com/vmware/govmomi/vapi/rest" "log" "sort" "strings" @@ -448,6 +451,44 @@ func resourceVSphereComputeCluster() *schema.Resource { Description: "Advanced configuration options for vSphere HA.", Elem: &schema.Schema{Type: schema.TypeString}, }, + // vLCM + "host_image": { + Type: schema.TypeList, + Optional: true, + MaxItems: 1, + Description: "Details about the host image which should be applied to the cluster.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "esx_version": { + Type: schema.TypeString, + Description: "The ESXi version which the image is based on.", + Optional: true, + ValidateFunc: validation.NoZeroValues, + }, + "component": { + Type: schema.TypeList, + Description: "List of custom components.", + Optional: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "key": { + Type: schema.TypeString, + Description: "The identifier for the component.", + Optional: true, + ValidateFunc: validation.NoZeroValues, + }, + "version": { + Type: schema.TypeString, + Description: "The version to use.", + Optional: true, + ValidateFunc: validation.NoZeroValues, + }, + }, + }, + }, + }, + }, + }, // Proactive HA "proactive_ha_enabled": { Type: schema.TypeBool, @@ -691,6 +732,11 @@ func resourceVSphereComputeClusterCreate(d *schema.ResourceData, meta interface{ return err } + // Apply vLCM settings + if err := resourceVSphereComputeClusterApplyHostImage(d, meta, cluster); err != nil { + return err + } + // All done! log.Printf("[DEBUG] %s: Create finished successfully", resourceVSphereComputeClusterIDString(d)) return resourceVSphereComputeClusterRead(d, meta) @@ -766,6 +812,10 @@ func resourceVSphereComputeClusterUpdate(d *schema.ResourceData, meta interface{ return err } + if err := resourceVSphereComputeClusterApplyHostImage(d, meta, cluster); err != nil { + return err + } + log.Printf("[DEBUG] %s: Update finished successfully", resourceVSphereComputeClusterIDString(d)) return resourceVSphereComputeClusterRead(d, meta) } @@ -1092,6 +1142,119 @@ func resourceVSphereComputeClusterApplyCustomAttributes( return attrsProcessor.ProcessDiff(cluster) } +func resourceVSphereComputeClusterApplyHostImage( + d *schema.ResourceData, + meta interface{}, + cluster *object.ClusterComputeResource, +) error { + if !d.HasChange("host_image") { + return nil + } + + if d.Get("host_image") == nil { + return fmt.Errorf("disabling vLCM is not allowed") + } + + client := meta.(*Client).restClient + + m := clusters.NewManager(client) + if vlcmEnabled, err := m.GetSoftwareManagement(d.Id()); err != nil { + return err + } else if !vlcmEnabled.Enabled { + if err := resourceVsphereComputeClusterEnableSoftwareManagement(d, client); err != nil { + return err + } + } + + if draftId, err := m.CreateSoftwareDraft(d.Id()); err != nil { + return err + } else { + if err := m.SetSoftwareDraftBaseImage(d.Id(), draftId, d.Get("host_image.0.esx_version").(string)); err != nil { + return err + } + + spec := clusters.SoftwareComponentsUpdateSpec{ComponentsToSet: make(map[string]string)} + oldComponents, newComponents := d.GetChange("host_image.0.component") + oldComponentsMap := getComponentsMap(oldComponents.([]interface{})) + newComponentsMap := getComponentsMap(newComponents.([]interface{})) + + spec.ComponentsToSet = getComponentsToAdd(oldComponentsMap, newComponentsMap) + componentsToRemove := getComponentsToRemove(oldComponentsMap, newComponentsMap) + + if err = m.UpdateSoftwareDraftComponents(d.Id(), draftId, spec); err != nil { + return err + } else if len(componentsToRemove) > 0 { + for _, componentId := range componentsToRemove { + if err := m.RemoveSoftwareDraftComponents(d.Id(), draftId, componentId); err != nil { + return err + } + } + } + + if taskId, err := m.CommitSoftwareDraft(d.Id(), draftId, clusters.SettingsClustersSoftwareDraftsCommitSpec{}); err != nil { + return err + } else { + _, err := tasks.NewManager(client).WaitForCompletion(context.Background(), taskId) + return err + } + } +} + +func resourceVsphereComputeClusterEnableSoftwareManagement(d *schema.ResourceData, client *rest.Client) error { + m := clusters.NewManager(client) + + if draftId, err := m.CreateSoftwareDraft(d.Id()); err != nil { + return err + } else if err := m.SetSoftwareDraftBaseImage(d.Id(), draftId, d.Get("host_image.0.esx_version").(string)); err != nil { + return err + } else if taskId, err := m.CommitSoftwareDraft(d.Id(), draftId, clusters.SettingsClustersSoftwareDraftsCommitSpec{}); err != nil { + return err + } else if _, err := tasks.NewManager(client).WaitForCompletion(context.Background(), taskId); err != nil { + return err + } else if taskId, err := m.EnableSoftwareManagement(d.Id(), false); err != nil { + return err + } else if _, err := tasks.NewManager(client).WaitForCompletion(context.Background(), taskId); err != nil { + return err + } else { + return nil + } +} + +func getComponentsToAdd(old, new map[string]interface{}) map[string]string { + result := make(map[string]string) + + for k, v := range new { + if _, contains := old[k]; !contains { + version, _ := v.(map[string]interface{})["version"].(string) + result[k] = version + } + } + + return result +} + +func getComponentsToRemove(old, new map[string]interface{}) []string { + result := make([]string, 0) + + for k, _ := range old { + if _, contains := new[k]; !contains { + result = append(result, k) + } + } + + return result +} + +func getComponentsMap(components []interface{}) map[string]interface{} { + result := make(map[string]interface{}) + + for _, component := range components { + result[component.(map[string]interface{})["key"].(string)] = component + } + + return result +} + // resourceVSphereComputeClusterReadCustomAttributes reads the custom // attributes for vsphere_compute_cluster. func resourceVSphereComputeClusterReadCustomAttributes( diff --git a/vsphere/resource_vsphere_compute_cluster_test.go b/vsphere/resource_vsphere_compute_cluster_test.go index 3edf4dc97..e6b090d62 100644 --- a/vsphere/resource_vsphere_compute_cluster_test.go +++ b/vsphere/resource_vsphere_compute_cluster_test.go @@ -105,6 +105,31 @@ func TestAccResourceVSphereComputeCluster_drsHAEnabled(t *testing.T) { }) } +func TestAccResourceVSphereComputeCluster_vlcm(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { + RunSweepers() + testAccPreCheck(t) + testAccResourceVSphereComputeClusterPreCheck(t) + }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: testAccResourceVSphereComputeClusterConfigVlcm(""), + Check: resource.ComposeTestCheckFunc(), + }, + { + Config: testAccResourceVSphereComputeClusterConfigVlcm(testAccResourceVSphereComputeClusterImageConfig()), + Check: resource.ComposeTestCheckFunc(), + }, + { + Config: testAccResourceVSphereComputeClusterConfigVlcm(""), + Check: resource.ComposeTestCheckFunc(), + }, + }, + }) +} + func TestAccResourceVSphereComputeCluster_vsanDedupEnabled(t *testing.T) { resource.Test(t, resource.TestCase{ PreCheck: func() { @@ -1271,6 +1296,44 @@ resource "vsphere_compute_cluster" "compute_cluster" { ) } +func testAccResourceVSphereComputeClusterConfigVlcm(imageConfig string) string { + return fmt.Sprintf(` +data "vsphere_host_base_images" "base_images" {} + +%s + +resource "vsphere_compute_cluster" "compute_cluster" { + name = "testacc-compute-cluster" + datacenter_id = "${data.vsphere_datacenter.rootdc1.id}" + host_system_ids = [data.vsphere_host.roothost3.id] + + force_evacuate_on_destroy = true + + %s +} +`, + testhelper.CombineConfigs( + testhelper.ConfigDataRootDC1(), + testhelper.ConfigDataRootHost2(), + testhelper.ConfigDataRootHost3(), + testhelper.ConfigDataSoftwareDepot(), + ), + imageConfig, + ) +} + +func testAccResourceVSphereComputeClusterImageConfig() string { + return ` +host_image { + esx_version = "${data.vsphere_host_base_images.base_images.version.0}" + component { + key = vsphere_offline_software_depot.depot.component.0.key + version = vsphere_offline_software_depot.depot.component.0.version.0 + } +} +` +} + func testAccResourceVSphereComputeClusterConfigDRSHABasicExplicitFailoverHost() string { return fmt.Sprintf(` %s diff --git a/vsphere/resource_vsphere_offline_software_depot.go b/vsphere/resource_vsphere_offline_software_depot.go new file mode 100644 index 000000000..c2f36c6f6 --- /dev/null +++ b/vsphere/resource_vsphere_offline_software_depot.go @@ -0,0 +1,142 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package vsphere + +import ( + "context" + "errors" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" + "github.com/vmware/govmomi/vapi/cis/tasks" + "github.com/vmware/govmomi/vapi/esx/settings/depots" +) + +func resourceVsphereOfflineSoftwareDepot() *schema.Resource { + s := map[string]*schema.Schema{ + "location": { + Type: schema.TypeString, + Description: "The remote location where the contents for this depot are served.", + Required: true, + ForceNew: true, + ValidateFunc: validation.NoZeroValues, + }, + "component": { + Type: schema.TypeList, + Description: "The list of components in this depot.", + Computed: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "key": { + Type: schema.TypeString, + Description: "The key of the component.", + Computed: true, + }, + "display_name": { + Type: schema.TypeString, + Description: "The name of the component.", + Computed: true, + }, + "version": { + Type: schema.TypeList, + Description: "The list of versions of the component.", + Computed: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + }, + }, + }, + } + + return &schema.Resource{ + Create: resourceVsphereOfflineSoftwareDepotCreate, + Read: resourceVsphereOfflineSoftwareDepotRead, + Delete: resourceVsphereOfflineSoftwareDepotDelete, + Schema: s, + } +} + +func resourceVsphereOfflineSoftwareDepotCreate(d *schema.ResourceData, meta interface{}) error { + client := meta.(*Client).restClient + + location := d.Get("location").(string) + + spec := depots.SettingsDepotsOfflineCreateSpec{ + SourceType: string(depots.SourceTypePull), + Location: location, + } + + m := depots.NewManager(client) + + if taskId, err := m.CreateOfflineDepot(spec); err != nil { + return err + } else if _, err = tasks.NewManager(client).WaitForCompletion(context.Background(), taskId); err != nil { + return err + } else if offlineDepots, err := m.GetOfflineDepots(); err != nil { + return err + } else { + for id, depot := range offlineDepots { + if depot.Location == location { + d.SetId(id) + return resourceVsphereOfflineSoftwareDepotRead(d, meta) + } + } + + return errors.New("failed to create offline software depot") + } +} + +func resourceVsphereOfflineSoftwareDepotRead(d *schema.ResourceData, meta interface{}) error { + client := meta.(*Client).restClient + m := depots.NewManager(client) + + if data, err := m.GetOfflineDepotContent(d.Id()); err != nil { + return err + } else { + d.SetId(d.Id()) + return d.Set("component", readComponents(data)) + } +} + +func resourceVsphereOfflineSoftwareDepotDelete(d *schema.ResourceData, meta interface{}) error { + client := meta.(*Client).restClient + m := depots.NewManager(client) + + if taskId, err := m.DeleteOfflineDepot(d.Id()); err != nil { + return err + } else if _, err = tasks.NewManager(client).WaitForCompletion(context.Background(), taskId); err != nil { + return err + } else { + return nil + } +} + +func readComponents(data depots.SettingsDepotsOfflineContentInfo) []map[string]interface{} { + components := make([]map[string]interface{}, 0) + for _, srcBundles := range data.MetadataBundles { + for _, srcBundle := range srcBundles { + components = append(components, readIndependentComponents(srcBundle.IndependentComponents)...) + } + } + + return components +} + +func readIndependentComponents(data map[string]depots.SettingsDepotsComponentSummary) []map[string]interface{} { + independentComponents := make([]map[string]interface{}, len(data)) + count := 0 + for key, srcComponent := range data { + component := make(map[string]interface{}) + component["key"] = key + component["display_name"] = srcComponent.DisplayName + versions := make([]string, len(srcComponent.Versions)) + component["version"] = versions + for i, version := range srcComponent.Versions { + versions[i] = version.Version + } + independentComponents[count] = component + count++ + } + + return independentComponents +} diff --git a/vsphere/resource_vsphere_offline_software_depot_test.go b/vsphere/resource_vsphere_offline_software_depot_test.go new file mode 100644 index 000000000..a2f5996c8 --- /dev/null +++ b/vsphere/resource_vsphere_offline_software_depot_test.go @@ -0,0 +1,54 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package vsphere + +import ( + "fmt" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + "github.com/hashicorp/terraform-provider-vsphere/vsphere/internal/helper/testhelper" + "os" + "testing" +) + +func TestAccResourceVSphereOfflineSoftwareDepot_basic(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { + RunSweepers() + testAccPreCheck(t) + testAccResourceVSphereOfflineSoftwareDepotPreCheck(t) + }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: testhelper.ConfigDataSoftwareDepot(), + Check: resource.ComposeTestCheckFunc( + testAccResourceVSphereOfflineSoftwareDepotCheckFunc(), + ), + }, + }, + }) +} + +func testAccResourceVSphereOfflineSoftwareDepotCheckFunc() resource.TestCheckFunc { + return func(s *terraform.State) error { + if tVars, err := testClientVariablesForResource(s, "vsphere_offline_software_depot.depot"); err != nil { + return err + } else { + location, _ := tVars.resourceAttributes["location"] + expected := os.Getenv("TF_VAR_VSPHERE_SOFTWARE_DEPOT_LOCATION") + if location != expected { + return fmt.Errorf("depot location is incorrect. Expected %s but got %s", expected, location) + } + } + + return nil + } +} + +func testAccResourceVSphereOfflineSoftwareDepotPreCheck(t *testing.T) { + if os.Getenv("TF_VAR_VSPHERE_SOFTWARE_DEPOT_LOCATION") == "" { + t.Skip("set TF_VAR_VSPHERE_SOFTWARE_DEPOT_LOCATION to run vsphere_offline_software_depot acceptance tests") + } +} diff --git a/website/docs/d/host_base_images.html.markdown b/website/docs/d/host_base_images.html.markdown new file mode 100644 index 000000000..3b4105ac8 --- /dev/null +++ b/website/docs/d/host_base_images.html.markdown @@ -0,0 +1,27 @@ +--- +subcategory: "Lifecycle" +layout: "vsphere" +page_title: "VMware vSphere: vsphere_host_base_images" +sidebar_current: "docs-vsphere-data-source-host-base-images" +description: |- + Provides a VMware vSphere ESXi base images data source. This can be used to get the + list of ESXi base images available for cluster software management. +--- + +# host\_base\_images + +The `vsphere_host_base_images` data source can be used to get the list of ESXi base images available +for cluster software management. + +## Example Usage + +```hcl +data "vsphere_host_base_images" "baseimages" {} +``` + +## Attribute Reference + +The following attributes are exported: + +* `base_images` - List of available images. + * `version` - The ESXi version identifier for the image diff --git a/website/docs/r/compute_cluster.html.markdown b/website/docs/r/compute_cluster.html.markdown index 434c31040..273b6f23e 100644 --- a/website/docs/r/compute_cluster.html.markdown +++ b/website/docs/r/compute_cluster.html.markdown @@ -548,6 +548,16 @@ resource "vsphere_compute_cluster" "compute_cluster" { } ``` +### vLCM Settings + +After vLCM is enabled on a cluster it is not possible to disable it. + +* `host_image` - (Optional) Enables vLCM on the cluster and applies an ESXi image with the provided configuration. + * `esx_version` - The ESXi version which will be used as a base for the image. See [`host_base_images`][docs-d-host-base-images]. + * `component` - A custom component to add to the base image. TODO - add link to offline depot resource docs + * `key` - The identifier of the component + * `version` - The version of the component + ## Attribute Reference The following attributes are exported: @@ -561,6 +571,7 @@ The following attributes are exported: [docs-r-vsphere-virtual-machine-resource-pool-id]: /docs/providers/vsphere/r/virtual_machine.html#resource_pool_id [docs-r-vsphere-virtual-machine]: /docs/providers/vsphere/r/virtual_machine.html +[docs-d-host-base-images]: /docs/providers/vsphere/d/host_base_images.html ## Importing diff --git a/website/docs/r/offline_software_depot.html.markdown b/website/docs/r/offline_software_depot.html.markdown new file mode 100644 index 000000000..5c22d3a20 --- /dev/null +++ b/website/docs/r/offline_software_depot.html.markdown @@ -0,0 +1,33 @@ +--- +subcategory: "Lifecycle" +layout: "vsphere" +page_title: "VMware vSphere: vsphere_offline_software_depot" +sidebar_current: "docs-vsphere-resource-offline-software-depot" +description: |- + Provides a VMware vSphere offline software depot resource.. +--- + +# vsphere\_offline\_software\_depot + +Provides a VMware vSphere offline software depot resource. + +## Example Usages + +**Create an offline depot** + +```hcl +data "vsphere_offline_software_depot" "depot" { + location = "https://your.company.server/path/to/your/files" +} +``` + +## Argument Reference + +* `location` - The URL where the depot source is hosted. + +## Attribute Reference + +* `component` - The list of custom components in the depot. + * `key` - The identifier of the component. + * `version` - The list of available versions of the component. + * `display_name` - The name of the component. Useful for easier identification.