From c7f077646e3f7a3204d062ccca0c3d6e0803debe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Matavelli?= Date: Thu, 7 Mar 2024 09:06:39 +0000 Subject: [PATCH] Add Hosts resource and Pod data source (#69) --- cloudstack/data_source_cloudstack_pod.go | 281 ++++++++++++++++ cloudstack/data_source_cloudstack_pod_test.go | 50 +++ cloudstack/provider.go | 2 + cloudstack/resource_cloudstack_host.go | 316 ++++++++++++++++++ cloudstack/resource_cloudstack_host_test.go | 143 ++++++++ 5 files changed, 792 insertions(+) create mode 100644 cloudstack/data_source_cloudstack_pod.go create mode 100644 cloudstack/data_source_cloudstack_pod_test.go create mode 100644 cloudstack/resource_cloudstack_host.go create mode 100644 cloudstack/resource_cloudstack_host_test.go diff --git a/cloudstack/data_source_cloudstack_pod.go b/cloudstack/data_source_cloudstack_pod.go new file mode 100644 index 00000000..d6d211ef --- /dev/null +++ b/cloudstack/data_source_cloudstack_pod.go @@ -0,0 +1,281 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// + +package cloudstack + +import ( + "fmt" + "log" + "reflect" + "regexp" + "strings" + + "github.com/apache/cloudstack-go/v2/cloudstack" + "github.com/hashicorp/terraform/helper/schema" +) + +func dataSourceCloudstackPod() *schema.Resource { + return &schema.Resource{ + Read: datasourceCloudStackPodRead, + Schema: map[string]*schema.Schema{ + "filter": dataSourceFiltersSchema(), + + //Computed values + "pod_id": { + Type: schema.TypeString, + Computed: true, + }, + "name": { + Type: schema.TypeString, + Computed: true, + }, + "zone_id": { + Type: schema.TypeString, + Computed: true, + }, + "end_ip": { + Type: schema.TypeString, + Computed: true, + }, + "gateway": { + Type: schema.TypeString, + Computed: true, + }, + "netmask": { + Type: schema.TypeString, + Computed: true, + }, + "start_ip": { + Type: schema.TypeString, + Computed: true, + }, + "allocation_state": { + Type: schema.TypeString, + Computed: true, + }, + "zone_name": { + Type: schema.TypeString, + Computed: true, + }, + "vlan_id": { + Type: schema.TypeString, + Computed: true, + }, + "capacity": { + Type: schema.TypeList, + Computed: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "capacity_allocated": { + Type: schema.TypeInt, + Computed: true, + }, + "capacity_total": { + Type: schema.TypeInt, + Computed: true, + }, + "capacity_used": { + Type: schema.TypeInt, + Computed: true, + }, + "cluster_id": { + Type: schema.TypeString, + Computed: true, + }, + "cluster_name": { + Type: schema.TypeString, + Computed: true, + }, + "name": { + Type: schema.TypeString, + Computed: true, + }, + "percent_used": { + Type: schema.TypeInt, + Computed: true, + }, + "pod_id": { + Type: schema.TypeString, + Computed: true, + }, + "pod_name": { + Type: schema.TypeString, + Computed: true, + }, + "type": { + Type: schema.TypeString, + Computed: true, + }, + "zone_id": { + Type: schema.TypeString, + Computed: true, + }, + "zone_name": { + Type: schema.TypeString, + Computed: true, + }, + }, + }, + }, + "ip_ranges": { + Type: schema.TypeList, + Computed: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "end_ip": { + Type: schema.TypeString, + Computed: true, + }, + "gateway": { + Type: schema.TypeString, + Computed: true, + }, + "for_system_vms": { + Type: schema.TypeString, + Computed: true, + }, + "start_ip": { + Type: schema.TypeString, + Computed: true, + }, + "vlan_id": { + Type: schema.TypeString, + Computed: true, + }, + }, + }, + }, + }, + } +} + +func dsFlattenPodCapacity(capacity []cloudstack.PodCapacity) []map[string]interface{} { + cap := make([]map[string]interface{}, len(capacity)) + for i, c := range capacity { + cap[i] = map[string]interface{}{ + "capacity_allocated": c.Capacityallocated, + "capacity_total": c.Capacitytotal, + "capacity_used": c.Capacityused, + "cluster_id": c.Clusterid, + "cluster_name": c.Clustername, + "name": c.Name, + "percent_used": c.Percentused, + "pod_id": c.Podid, + "pod_name": c.Podname, + "type": c.Type, + "zone_id": c.Zoneid, + "zone_name": c.Zonename, + } + } + return cap +} + +func dsFlattenPodIpRanges(ip_ranges []cloudstack.PodIpranges) []map[string]interface{} { + ranges := make([]map[string]interface{}, len(ip_ranges)) + for i, ip_range := range ip_ranges { + ranges[i] = map[string]interface{}{ + "end_ip": ip_range.Endip, + "for_system_vms": ip_range.Forsystemvms, + "start_ip": ip_range.Startip, + "vlan_id": ip_range.Vlanid, + } + } + return ranges +} + +func datasourceCloudStackPodRead(d *schema.ResourceData, meta interface{}) error { + cs := meta.(*cloudstack.CloudStackClient) + p := cs.Pod.NewListPodsParams() + + csPods, err := cs.Pod.ListPods(p) + if err != nil { + return fmt.Errorf("failed to list pods: %s", err) + } + + filters := d.Get("filter") + + for _, pod := range csPods.Pods { + match, err := applyPodFilters(pod, filters.(*schema.Set)) + if err != nil { + return err + } + if match { + return podDescriptionAttributes(d, pod) + } + } + + return fmt.Errorf("no pods found") +} + +func podDescriptionAttributes(d *schema.ResourceData, pod *cloudstack.Pod) error { + d.SetId(pod.Id) + var end_ip string + if len(pod.Endip) > 0 { + end_ip = pod.Endip[0] + } + + fields := map[string]interface{}{ + "pod_id": pod.Id, + "name": pod.Name, + "allocation_state": pod.Allocationstate, + "gateway": pod.Gateway, + "netmask": pod.Netmask, + "start_ip": pod.Startip[0], + "vlan_id": pod.Vlanid[0], + "zone_id": pod.Zoneid, + "zone_name": pod.Zonename, + "end_ip": end_ip, + "ip_ranges": dsFlattenPodIpRanges(pod.Ipranges), + "capacity": dsFlattenPodCapacity(pod.Capacity), + } + + for k, v := range fields { + if err := d.Set(k, v); err != nil { + log.Printf("[WARN] Error setting %s: %s", k, err) + } + } + + return nil +} + +func applyPodFilters(pod *cloudstack.Pod, filters *schema.Set) (bool, error) { + val := reflect.ValueOf(pod).Elem() + + for _, f := range filters.List() { + filter := f.(map[string]interface{}) + r, err := regexp.Compile(filter["value"].(string)) + if err != nil { + return false, fmt.Errorf("invalid regex: %s", err) + } + updatedName := strings.ReplaceAll(filter["name"].(string), "_", "") + podField := val.FieldByNameFunc(func(fieldName string) bool { + if strings.EqualFold(fieldName, updatedName) { + updatedName = fieldName + return true + } + return false + }).String() + + if r.MatchString(podField) { + return true, nil + } + } + + return false, nil +} diff --git a/cloudstack/data_source_cloudstack_pod_test.go b/cloudstack/data_source_cloudstack_pod_test.go new file mode 100644 index 00000000..815e57e5 --- /dev/null +++ b/cloudstack/data_source_cloudstack_pod_test.go @@ -0,0 +1,50 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// + +package cloudstack + +import ( + "testing" + + "github.com/hashicorp/terraform/helper/resource" +) + +func TestAccPodDataSource_basic(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: testPodDataSourceConfig_basic, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("data.cloudstack_pod.test", "name", "POD0"), + ), + }, + }, + }) +} + +const testPodDataSourceConfig_basic = ` + data "cloudstack_pod" "test" { + filter { + name = "name" + value = "POD0" + } + } + ` diff --git a/cloudstack/provider.go b/cloudstack/provider.go index 3def7c95..c35a263c 100644 --- a/cloudstack/provider.go +++ b/cloudstack/provider.go @@ -89,6 +89,7 @@ func Provider() terraform.ResourceProvider { "cloudstack_ipaddress": dataSourceCloudstackIPAddress(), "cloudstack_user": dataSourceCloudstackUser(), "cloudstack_vpn_connection": dataSourceCloudstackVPNConnection(), + "cloudstack_pod": dataSourceCloudstackPod(), }, ResourcesMap: map[string]*schema.Resource{ @@ -98,6 +99,7 @@ func Provider() terraform.ResourceProvider { "cloudstack_disk": resourceCloudStackDisk(), "cloudstack_egress_firewall": resourceCloudStackEgressFirewall(), "cloudstack_firewall": resourceCloudStackFirewall(), + "cloudstack_host": resourceCloudStackHost(), "cloudstack_instance": resourceCloudStackInstance(), "cloudstack_ipaddress": resourceCloudStackIPAddress(), "cloudstack_kubernetes_cluster": resourceCloudStackKubernetesCluster(), diff --git a/cloudstack/resource_cloudstack_host.go b/cloudstack/resource_cloudstack_host.go new file mode 100644 index 00000000..46fc8e2b --- /dev/null +++ b/cloudstack/resource_cloudstack_host.go @@ -0,0 +1,316 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// + +package cloudstack + +import ( + "errors" + "fmt" + "log" + "sort" + "strings" + "time" + + "github.com/apache/cloudstack-go/v2/cloudstack" + "github.com/hashicorp/terraform/helper/schema" +) + +func resourceCloudStackHost() *schema.Resource { + return &schema.Resource{ + Read: resourceCloudStackHostRead, + Update: resourceCloudStackHostUpdate, + Create: resourceCloudStackHostCreate, + Delete: resourceCloudStackHostDelete, + Schema: map[string]*schema.Schema{ + "hypervisor": { + Type: schema.TypeString, + Required: true, + ValidateFunc: func(v interface{}, k string) (ws []string, errors []error) { + validHypervisors := []string{"xenserver", "kvm", "vmware", "baremetal", "simulator"} + + sort.Strings(validHypervisors) + + if sort.SearchStrings(validHypervisors, v.(string)) >= len(validHypervisors) { + errors = append(errors, fmt.Errorf("%q must be one of %v", k, validHypervisors)) + } + return + }, + ForceNew: true, + }, + "pod_id": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + "url": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + "zone_id": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + "cluster_id": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + ConflictsWith: []string{ + "cluster_name", + }, + }, + "cluster_name": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + ConflictsWith: []string{ + "cluster_id", + }, + }, + "host_tags": { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + "username": { + Type: schema.TypeString, + Optional: true, + }, + "password": { + Type: schema.TypeString, + Optional: true, + Sensitive: true, + }, + "prevent_destroy": { + Type: schema.TypeBool, + Description: "Prevent the host from being destroyed. This is useful when you want to avoid destroy the host in any change.", + Optional: true, + Default: false, + }, + "force_destroy": { + Type: schema.TypeBool, + Description: "Force the host to be destroyed.", + Optional: true, + Default: false, + }, + "allocation_state": { + Type: schema.TypeString, + Optional: true, + Default: "Enabled", + }, + "state": { + Type: schema.TypeString, + Computed: true, + }, + "resource_state": { + Type: schema.TypeString, + Computed: true, + }, + "name": { + Type: schema.TypeString, + Computed: true, + }, + // Creating a host can fail if the instance is still being created and Cloudsack + // user is created during cloud-init. This timeout is used to wait for the host + // to be created and Cloudstack user to be available. + "create_timeout": { + Type: schema.TypeInt, + Description: "Timeout in seconds to wait for the host to be created.", + Optional: true, + Default: 300, + }, + // Destroying a host will put it in Maintenance mode first. If the VMs are still + // being migrated, the host will be in state PrepareForMaintenance. This timeout + // is used to wait for the host to be in Maintenance state. + "destroy_timeout": { + Type: schema.TypeInt, + Description: "Timeout in seconds to wait for the host to be destroyed.", + Optional: true, + Default: 300, + }, + }, + } +} + +func resourceCloudStackHostCreate(d *schema.ResourceData, meta interface{}) error { + cs := meta.(*cloudstack.CloudStackClient) + hypervisor := d.Get("hypervisor").(string) + pod_id := d.Get("pod_id").(string) + url := d.Get("url").(string) + zone_id := d.Get("zone_id").(string) + + p := cs.Host.NewAddHostParams(hypervisor, pod_id, url, zone_id) + + if cluster_id, ok := d.GetOk("cluster_id"); ok { + p.SetClusterid(cluster_id.(string)) + } + + if cluster_name, ok := d.GetOk("cluster_name"); ok { + p.SetClustername(cluster_name.(string)) + } + + if host_tags, ok := d.GetOk("host_tags"); ok { + p.SetHosttags(host_tags.([]string)) + } + + if username, ok := d.GetOk("username"); ok { + p.SetUsername(username.(string)) + } + + if password, ok := d.GetOk("password"); ok { + p.SetPassword(password.(string)) + } + + timeout := time.After(time.Duration(d.Get("create_timeout").(int)) * time.Second) + tick := time.NewTicker(5 * time.Second) + var err error + var host *cloudstack.AddHostResponse + + for { + select { + case <-timeout: + return fmt.Errorf("timeout waiting for Host to be created, with error: %s", err) + case <-tick.C: + log.Printf("[DEBUG] Trying to create host %s", d.Get("url").(string)) + host, err = cs.Host.AddHost(p) + if err != nil { + log.Printf("[ERROR] Error creating host %s: %s. Will try again...", d.Get("url").(string), err) + continue + } + + if host.Id != "" { + log.Printf("[DEBUG] Host %s successfully created", url) + d.SetId(host.Id) + return resourceCloudStackHostRead(d, meta) + } + } + } +} + +func resourceCloudStackHostRead(d *schema.ResourceData, meta interface{}) error { + cs := meta.(*cloudstack.CloudStackClient) + log.Printf("[DEBUG] Retrieving Host %s", d.Get("url").(string)) + + h, count, err := cs.Host.GetHostByID(d.Id()) + + if err != nil { + if count == 0 { + log.Printf("[WARN] Host %s does no longer exist", d.Get("url").(string)) + d.SetId("") + return nil + } + return err + } + + d.SetId(h.Id) + + fields := map[string]interface{}{ + "hypervisor": h.Hypervisor, + "pod_id": h.Podid, + "zone_id": h.Zoneid, + "state": h.State, + "resource_state": h.Resourcestate, + "name": h.Name, + } + + for k, v := range fields { + if err := d.Set(k, v); err != nil { + return err + } + } + + if cluster_id := d.Get("cluster_id"); cluster_id != "" { + d.Set("cluster_id", h.Clusterid) + } else { + d.Set("cluster_name", h.Clustername) + } + + if h.Hosttags != "" { + d.Set("host_tags", strings.Split(h.Hosttags, ",")) + } + + return nil +} + +func resourceCloudStackHostUpdate(d *schema.ResourceData, meta interface{}) error { + log.Printf("[DEBUG] Updating Host: %s", d.Id()) + + cs := meta.(*cloudstack.CloudStackClient) + + p := cs.Host.NewUpdateHostParams(d.Id()) + + if d.HasChange("allocation_state") { + log.Printf("[DEBUG] Updating Host allocation state: %s", d.Id()) + p.SetAllocationstate(d.Get("allocation_state").(string)) + } + + if d.HasChange("host_tags") { + log.Printf("[DEBUG] Updating Host tags: %s", d.Id()) + p.SetHosttags(d.Get("host_tags").([]string)) + } + + return resourceCloudStackHostRead(d, meta) +} + +func resourceCloudStackHostDelete(d *schema.ResourceData, meta interface{}) error { + cs := meta.(*cloudstack.CloudStackClient) + + if d.Get("prevent_destroy").(bool) { + log.Printf("[INFO] Skipping Host deletion: %s", d.Id()) + return fmt.Errorf("host %s is marked to be protected from deletion", d.Id()) + } + + log.Printf("[INFO] Removing Host: %s", d.Id()) + mm := cs.Host.NewPrepareHostForMaintenanceParams(d.Id()) + _, err := cs.Host.PrepareHostForMaintenance(mm) + + if err != nil { + return fmt.Errorf("error preparing Host for maintenance: %s", err) + } + + timeout := time.After(time.Duration(d.Get("destroy_timeout").(int)) * time.Second) + tick := time.NewTicker(3 * time.Second) + + for { + select { + case <-timeout: + return errors.New("timeout waiting for Host to enter Maintenance state") + case <-tick.C: + log.Printf("[DEBUG] Checking Host state: %s", d.Id()) + err = resourceCloudStackHostRead(d, meta) + if err != nil { + return fmt.Errorf("error reading Host: %s", err) + } + + if d.Get("resource_state").(string) == "Maintenance" || d.Get("resource_state").(string) == "Disconnected" { + log.Printf("[INFO] Deleting Host: %s", d.Id()) + h := cs.Host.NewDeleteHostParams(d.Id()) + _, err = cs.Host.DeleteHost(h) + + if err != nil { + return fmt.Errorf("error deleting Host: %s", err) + } + return nil + } + } + } +} diff --git a/cloudstack/resource_cloudstack_host_test.go b/cloudstack/resource_cloudstack_host_test.go new file mode 100644 index 00000000..8da6f58c --- /dev/null +++ b/cloudstack/resource_cloudstack_host_test.go @@ -0,0 +1,143 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// + +package cloudstack + +import ( + "fmt" + "regexp" + "testing" + + "github.com/apache/cloudstack-go/v2/cloudstack" + "github.com/hashicorp/terraform/helper/resource" + "github.com/hashicorp/terraform/terraform" +) + +func TestAccCloudStackHost_basic(t *testing.T) { + var h cloudstack.Host + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: testAccCloudStackHost_basic, + Check: resource.ComposeTestCheckFunc( + testAccCheckCloudStackHostExists("cloudstack_host.test", &h), + resource.TestCheckResourceAttr("cloudstack_host.test", "hypervisor", "Simulator"), + resource.TestCheckResourceAttr("cloudstack_host.test", "cluster_name", "C1"), + resource.TestCheckResourceAttrSet("cloudstack_host.test", "state"), + resource.TestCheckResourceAttrSet("cloudstack_host.test", "name"), + ), + }, + }, + }) +} + +const testAccCloudStackHost_basic = ` +data "cloudstack_zone" "zone" { + filter { + name = "name" + value = "Sandbox-simulator" + } +} + +data "cloudstack_pod" "pod" { + filter { + name = "name" + value = "POD0" + } +} + +resource "cloudstack_host" "test" { + hypervisor = "Simulator" + pod_id = data.cloudstack_pod.pod.id + url = "http://sim/c1/h" + zone_id = data.cloudstack_zone.zone.id + cluster_name = "C1" + username = "root" + password = "password" +} +` + +func testAccCheckCloudStackHostExists(n string, h *cloudstack.Host) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[n] + if !ok { + return fmt.Errorf("Not found: %s", n) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("No host ID is set") + } + + cs := testAccProvider.Meta().(*cloudstack.CloudStackClient) + resp, _, err := cs.Host.GetHostByID(rs.Primary.ID) + if err != nil { + return err + } + + if resp.Id != rs.Primary.ID { + return fmt.Errorf("Host not found") + } + + *h = *resp + + return nil + } +} + +func TestAccCloudStackHost_fail(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: testAccCloudStackHost_fail, + ExpectError: regexp.MustCompile("timeout waiting for Host to be created, with error: .*Username and Password need to be provided.*"), + }, + }, + }) +} + +const testAccCloudStackHost_fail = ` +data "cloudstack_zone" "zone_fail" { + filter { + name = "name" + value = "Sandbox-simulator" + } +} + +data "cloudstack_pod" "pod_fail" { + filter { + name = "name" + value = "POD0" + } +} + +resource "cloudstack_host" "test_fail" { + hypervisor = "Simulator" + pod_id = data.cloudstack_pod.pod_fail.id + url = "http://sim/c1/h" + zone_id = data.cloudstack_zone.zone_fail.id + cluster_name = "C1" + username = "root" + password = "" + create_timeout = 10 +} +`