From 24107c820c1ea615f06dd0672f175dc13493d459 Mon Sep 17 00:00:00 2001 From: Arthur Amstutz Date: Thu, 26 Dec 2024 09:33:55 +0000 Subject: [PATCH] feat: Add ovh_savings_plan resource --- .cds/terraform-provider-ovh.yml | 14 ++ ovh/provider.go | 13 +- ovh/resource_savings_plan.go | 259 ++++++++++++++++++++++ ovh/resource_savings_plan_test.go | 48 ++++ ovh/services.go | 32 +++ website/docs/r/savings_plan.html.markdown | 58 +++++ 6 files changed, 418 insertions(+), 6 deletions(-) create mode 100644 ovh/resource_savings_plan.go create mode 100644 ovh/resource_savings_plan_test.go create mode 100644 website/docs/r/savings_plan.html.markdown diff --git a/.cds/terraform-provider-ovh.yml b/.cds/terraform-provider-ovh.yml index c20cc5e4..b7e8663d 100644 --- a/.cds/terraform-provider-ovh.yml +++ b/.cds/terraform-provider-ovh.yml @@ -737,10 +737,23 @@ workflow: pipeline: terraform-provider-ovh-testacc when: - success + Tests_SavingsPlan: + application: terraform-provider-ovh + depends_on: + - terraform-provider-ovh-pre-sweepers + environment: acctests + one_at_a_time: true + parameters: + testargs: -run SavingsPlan + skipthispipeline: "{{.workflow.terraform-provider-ovh.pip.skipthistest.savingsplan}}" + pipeline: terraform-provider-ovh-testacc + when: + - success terraform-provider-ovh: application: terraform-provider-ovh parameters: skipthistest.cloudprojectdatabase: "true" + skipthistest.savingsplan: "true" pipeline: terraform-provider-ovh payload: git.branch: master @@ -815,6 +828,7 @@ workflow: - Tests_TestAccIpMove_basic - Tests_IPFirewall - Tests_Okms + - Tests_SavingsPlan environment: acctests one_at_a_time: true pipeline: terraform-provider-ovh-run-sweepers diff --git a/ovh/provider.go b/ovh/provider.go index 7904e5c1..7ef19831 100644 --- a/ovh/provider.go +++ b/ovh/provider.go @@ -267,12 +267,13 @@ func Provider() *schema.Provider { "ovh_me_installation_template_partition_scheme": resourceMeInstallationTemplatePartitionScheme(), "ovh_me_installation_template_partition_scheme_hardware_raid": resourceMeInstallationTemplatePartitionSchemeHardwareRaid(), "ovh_me_installation_template_partition_scheme_partition": resourceMeInstallationTemplatePartitionSchemePartition(), - "ovh_vrack": resourceVrack(), - "ovh_vrack_cloudproject": resourceVrackCloudProject(), - "ovh_vrack_dedicated_server": resourceVrackDedicatedServer(), - "ovh_vrack_dedicated_server_interface": resourceVrackDedicatedServerInterface(), - "ovh_vrack_ip": resourceVrackIp(), - "ovh_vrack_iploadbalancing": resourceVrackIpLoadbalancing(), + "ovh_savings_plan": resourceSavingsPlan(), + "ovh_vrack": resourceVrack(), + "ovh_vrack_cloudproject": resourceVrackCloudProject(), + "ovh_vrack_dedicated_server": resourceVrackDedicatedServer(), + "ovh_vrack_dedicated_server_interface": resourceVrackDedicatedServerInterface(), + "ovh_vrack_ip": resourceVrackIp(), + "ovh_vrack_iploadbalancing": resourceVrackIpLoadbalancing(), }, ConfigureContextFunc: ConfigureContextFunc, diff --git a/ovh/resource_savings_plan.go b/ovh/resource_savings_plan.go new file mode 100644 index 00000000..38310058 --- /dev/null +++ b/ovh/resource_savings_plan.go @@ -0,0 +1,259 @@ +package ovh + +import ( + "errors" + "fmt" + "log" + "net/url" + "strings" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourceSavingsPlan() *schema.Resource { + return &schema.Resource{ + Create: resourceSavingsPlanCreate, + Read: resourceSavingsPlanRead, + Update: resourceSavingsPlanUpdate, + Delete: resourceSavingsPlanDelete, + Importer: &schema.ResourceImporter{ + State: resourceSavingsPlanImport, + }, + Schema: map[string]*schema.Schema{ + "service_name": { + Type: schema.TypeString, + Description: "ID of the public cloud project", + ForceNew: true, + Required: true, + }, + "flavor": { + Type: schema.TypeString, + Description: "Savings Plan flavor (e.g. Rancher, C3-4, any instance flavor, ...)", + ForceNew: true, + Required: true, + }, + "period": { + Type: schema.TypeString, + Description: "Periodicity of the Savings Plan", + ForceNew: true, + Required: true, + }, + "size": { + Type: schema.TypeInt, + Description: "Size of the Savings Plan", + Required: true, + }, + "display_name": { + Type: schema.TypeString, + Description: "Custom display name, used in invoices", + Required: true, + }, + "auto_renewal": { + Type: schema.TypeBool, + Description: "Whether Savings Plan should be renewed at the end of the period (defaults to false)", + Optional: true, + Computed: true, + }, + + // computed + "service_id": { + Type: schema.TypeInt, + Description: "ID of the service", + Computed: true, + }, + "status": { + Type: schema.TypeString, + Description: "Status of the Savings Plan", + Computed: true, + }, + "start_date": { + Type: schema.TypeString, + Description: "Start date of the Savings Plan", + Computed: true, + }, + "end_date": { + Type: schema.TypeString, + Description: "End date of the Savings Plan", + Computed: true, + }, + "period_end_action": { + Type: schema.TypeString, + Description: "Action performed when reaching the end of the period", + Computed: true, + }, + "period_start_date": { + Type: schema.TypeString, + Description: "Start date of the current period", + Computed: true, + }, + "period_end_date": { + Type: schema.TypeString, + Description: "End date of the current period", + Computed: true, + }, + }, + } +} + +func resourceSavingsPlanImport(d *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) { + config := meta.(*Config) + + importID := d.Id() + parts := strings.Split(importID, "/") + if len(parts) != 2 { + return nil, fmt.Errorf("import ID is not correctly formatted, expected 'serviceName/savingsPlanID'") + } + + serviceName := parts[0] + savingsPlanID := parts[1] + + // Retrieve service ID + serviceId, err := serviceIdFromResourceName(config.OVHClient, serviceName) + if err != nil { + return nil, err + } + d.Set("service_id", serviceId) + d.SetId(savingsPlanID) + + return []*schema.ResourceData{d}, nil +} + +func resourceSavingsPlanCreate(d *schema.ResourceData, meta interface{}) error { + serviceName := d.Get("service_name").(string) + config := meta.(*Config) + + // Retrieve service ID + serviceId, err := serviceIdFromResourceName(config.OVHClient, serviceName) + if err != nil { + return err + } + d.Set("service_id", serviceId) + + // Get subscribables savings plans + log.Print("[DEBUG] Will fetch subscribables savings plans") + endpoint := fmt.Sprintf("/services/%d/savingsPlans/subscribable", serviceId) + subscribables := []savingsPlansSubscribable{} + if err := config.OVHClient.Get(endpoint, &subscribables); err != nil { + return fmt.Errorf("error calling GET %s:\n\t %q", endpoint, err) + } + + // Search for a savings plan corresponding to the given parameters + endpoint = fmt.Sprintf("/services/%d/savingsPlans/subscribe/simulate", serviceId) + for _, subscribable := range subscribables { + var ( + req = savingsPlansSimulateRequest{ + DisplayName: d.Get("display_name").(string), + OfferID: subscribable.OfferID, + Size: d.Get("size").(int), + } + resp savingsPlansSimulateResponse + ) + + if err := config.OVHClient.Post(endpoint, req, &resp); err != nil { + return fmt.Errorf("error calling POST %s:\n\t %q", endpoint, err) + } + + if d.Get("flavor").(string) == resp.Flavor && + d.Get("period").(string) == resp.Period && + d.Get("size").(int) == resp.Size { + // We found the right savings plan, execute subscription + endpoint = fmt.Sprintf("/services/%d/savingsPlans/subscribe/execute", serviceId) + if err := config.OVHClient.Post(endpoint, req, &resp); err != nil { + return fmt.Errorf("error calling POST %s:\n\t %q", endpoint, err) + } + + // Then update the action at end of period if renewal was asked + autoRenewalConfig := d.Get("auto_renewal").(bool) + if autoRenewalConfig { + endpoint = fmt.Sprintf("/services/%d/savingsPlans/subscribed/%s/changePeriodEndAction", serviceId, url.PathEscape(resp.ID)) + if err := config.OVHClient.Post(endpoint, savingsPlanPeriodEndActionRequest{ + PeriodEndAction: "REACTIVATE", + }, nil); err != nil { + return fmt.Errorf("error calling POST %s:\n\t %q", endpoint, err) + } + } + + d.SetId(resp.ID) + d.Set("status", resp.Status) + d.Set("start_date", resp.StartDate) + d.Set("end_date", resp.EndDate) + d.Set("period_end_action", resp.PeriodEndAction) + d.Set("period_start_date", resp.PeriodStartDate) + d.Set("period_end_date", resp.PeriodEndDate) + + return nil + } + } + + return errors.New("no savings plan available with the given parameters") +} + +func resourceSavingsPlanRead(d *schema.ResourceData, meta interface{}) error { + serviceID := d.Get("service_id").(int) + config := meta.(*Config) + + endpoint := fmt.Sprintf("/services/%d/savingsPlans/subscribed/%s", serviceID, url.PathEscape(d.Id())) + var resp savingsPlansSimulateResponse + if err := config.OVHClient.Get(endpoint, &resp); err != nil { + return fmt.Errorf("error calling GET %s:\n\t %q", endpoint, err) + } + + d.Set("status", resp.Status) + d.Set("start_date", resp.StartDate) + d.Set("end_date", resp.EndDate) + d.Set("period_end_action", resp.PeriodEndAction) + d.Set("period_start_date", resp.PeriodStartDate) + d.Set("period_end_date", resp.PeriodEndDate) + d.Set("auto_renewal", resp.PeriodEndAction == "REACTIVATE") + + return nil +} + +func resourceSavingsPlanUpdate(d *schema.ResourceData, meta interface{}) error { + serviceID := d.Get("service_id").(int) + config := meta.(*Config) + + // Update display name if needed + if d.HasChange("display_name") { + endpoint := fmt.Sprintf("/services/%d/savingsPlans/subscribed/%s", serviceID, url.PathEscape(d.Id())) + if err := config.OVHClient.Put(endpoint, map[string]string{ + "displayName": d.Get("display_name").(string), + }, nil); err != nil { + return fmt.Errorf("error calling PUT %s:\n\t %q", endpoint, err) + } + } + + // Update size if needed + if d.HasChange("size") { + endpoint := fmt.Sprintf("/services/%d/savingsPlans/subscribed/%s/changeSize", serviceID, url.PathEscape(d.Id())) + if err := config.OVHClient.Post(endpoint, map[string]int{ + "size": d.Get("size").(int), + }, nil); err != nil { + return fmt.Errorf("error calling POST %s:\n\t %q", endpoint, err) + } + } + + // Update auto renewal if needed + if d.HasChange("auto_renewal") { + newValue := d.Get("auto_renewal").(bool) + endAction := "TERMINATE" + if newValue { + endAction = "REACTIVATE" + } + + endpoint := fmt.Sprintf("/services/%d/savingsPlans/subscribed/%s/changePeriodEndAction", serviceID, url.PathEscape(d.Id())) + if err := config.OVHClient.Post(endpoint, map[string]string{ + "periodEndAction": endAction, + }, nil); err != nil { + return fmt.Errorf("error calling POST %s:\n\t %q", endpoint, err) + } + } + + return nil +} + +func resourceSavingsPlanDelete(d *schema.ResourceData, meta interface{}) error { + // Does nothing, savings plans cannot be deleted + d.SetId("") + return nil +} diff --git a/ovh/resource_savings_plan_test.go b/ovh/resource_savings_plan_test.go new file mode 100644 index 00000000..8e678331 --- /dev/null +++ b/ovh/resource_savings_plan_test.go @@ -0,0 +1,48 @@ +package ovh + +import ( + "fmt" + "os" + "testing" + "time" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func TestAccResourceSavingsPlan_basic(t *testing.T) { + displayName := acctest.RandomWithPrefix(test_prefix) + serviceName := os.Getenv("OVH_CLOUD_PROJECT_SERVICE_TEST") + + config := fmt.Sprintf( + `resource "ovh_savings_plan" "sp" { + service_name = "%s" + flavor = "Rancher" + period = "P1M" + size = 1 + display_name = "%s" + }`, + serviceName, + displayName, + ) + + endDate := time.Now().AddDate(0, 1, 1).Format(time.DateOnly) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheckCloud(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("ovh_savings_plan.sp", "display_name", displayName), + resource.TestCheckResourceAttr("ovh_savings_plan.sp", "flavor", "Rancher"), + resource.TestCheckResourceAttr("ovh_savings_plan.sp", "size", "1"), + resource.TestCheckResourceAttr("ovh_savings_plan.sp", "end_date", endDate), + resource.TestCheckResourceAttr("ovh_savings_plan.sp", "period_end_date", endDate), + resource.TestCheckResourceAttr("ovh_savings_plan.sp", "period_end_action", "TERMINATE"), + ), + }, + }, + }) +} diff --git a/ovh/services.go b/ovh/services.go index cf21af81..63a2e6b3 100644 --- a/ovh/services.go +++ b/ovh/services.go @@ -14,6 +14,38 @@ import ( "github.com/ovh/go-ovh/ovh" ) +type savingsPlansSubscribable struct { + OfferID string `json:"offerId"` +} + +type savingsPlansSimulateRequest struct { + DisplayName string `json:"displayName"` + OfferID string `json:"offerId"` + Size int `json:"size"` +} + +type savingsPlansSimulateResponse struct { + DisplayName string `json:"displayName"` + OfferID string `json:"offerId"` + Size int `json:"size"` + + Flavor string `json:"flavor"` + ID string `json:"id"` + Period string `json:"period"` + PeriodEndAction string `json:"periodEndAction"` + PeriodStartDate string `json:"periodStartDate"` + PeriodEndDate string `json:"periodEndDate"` + Status string `json:"status"` + + StartDate string `json:"startDate"` + EndDate string `json:"endDate"` + TerminationDate string `json:"terminationDate"` +} + +type savingsPlanPeriodEndActionRequest struct { + PeriodEndAction string `json:"periodEndAction"` +} + func serviceIdFromResourceName(c *ovh.Client, resourceName string) (int, error) { var serviceIds []int endpoint := fmt.Sprintf("/services?resourceName=%s", url.PathEscape(resourceName)) diff --git a/website/docs/r/savings_plan.html.markdown b/website/docs/r/savings_plan.html.markdown new file mode 100644 index 00000000..74dca0a3 --- /dev/null +++ b/website/docs/r/savings_plan.html.markdown @@ -0,0 +1,58 @@ +--- +subcategory : "Account Management" +--- + +# ovh_savings_plan + +Create and manage an OVHcloud Savings Plan + +## Example Usage + +```hcl +resource "ovh_savings_plan" "plan" { + service_name = "" + flavor = "Rancher" + period = "P1M" + size = 2 + display_name = "one_month_rancher_savings_plan" + auto_renewal = true +} +``` + +## Argument Reference + +The following arguments are supported: + +* `service_name` - (Required) ID of the public cloud project +* `flavor` - (Required) Savings Plan flavor (e.g. Rancher, C3-4, any instance flavor, ...) +* `period` - (Required) Periodicity of the Savings Plan +* `size` - (Required) Size of the Savings Plan +* `display_name` - (Required) Custom display name, used in invoices +* `auto_renewal` - Whether Savings Plan should be renewed at the end of the period (defaults to false) + +## Attributes Reference + +The following attributes are exported: + +* `id` - ID of the Savings Plan +* `service_name` - ID of the public cloud project +* `flavor` - Savings Plan flavor (e.g. Rancher, C3-4, any instance flavor, ...) +* `period` - Periodicity of the Savings Plan +* `size` - Size of the Savings Plan +* `display_name` - Custom display name, used in invoices +* `auto_renewal` - Whether Savings Plan should be renewed at the end of the period +* `service_id` - Billing ID of the service +* `status` - Status of the Savings Plan +* `start_date` - Start date of the Savings Plan +* `end_date` - End date of the Savings Plan +* `period_end_action` - Action performed when reaching the end of the period (controles by the `auto_renewal` parameter) +* `period_start_date` - Start date of the current period +* `period_end_date` - End date of the current period + +## Import + +A savings plan can be imported using the following format: `service_name` and `id` of the savings plan, separated by "/" e.g. + +```bash +$ terraform import ovh_savings_plan.plan service_name/savings_plan_id +``` \ No newline at end of file