Skip to content

Commit

Permalink
Merge pull request #793 from ovh/dev/aamstutz/savings-plan
Browse files Browse the repository at this point in the history
feat: Add ovh_savings_plan resource
  • Loading branch information
amstuta authored Dec 26, 2024
2 parents 674d5f4 + 24107c8 commit da6b9e3
Show file tree
Hide file tree
Showing 6 changed files with 418 additions and 6 deletions.
14 changes: 14 additions & 0 deletions .cds/terraform-provider-ovh.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
13 changes: 7 additions & 6 deletions ovh/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
259 changes: 259 additions & 0 deletions ovh/resource_savings_plan.go
Original file line number Diff line number Diff line change
@@ -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
}
48 changes: 48 additions & 0 deletions ovh/resource_savings_plan_test.go
Original file line number Diff line number Diff line change
@@ -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"),
),
},
},
})
}
32 changes: 32 additions & 0 deletions ovh/services.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Loading

0 comments on commit da6b9e3

Please sign in to comment.