Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

compute_instance: add destroy_protected attr #337

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## Unreleased

FEATURES:

- compute_instance: add destroy_protected attr #337
IMPROVEMENTS:

- Add note about multiple ports rules in security group migration guide #333
Expand Down
1 change: 1 addition & 0 deletions docs/resources/compute_instance.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ directory for complete configuration examples.

- `anti_affinity_group_ids` (Set of String) ❗ A list of [exoscale_anti_affinity_group](./anti_affinity_group.md) (IDs) to attach to the instance (may only be set at creation time).
- `deploy_target_id` (String) ❗ A deploy target ID.
- `destroy_protected` (Boolean) Mark the instance as protected, the Exoscale API will refuse to delete the instance until the protection is removed (boolean; default: `false`).
- `disk_size` (Number) The instance disk size (GiB; at least `10`). Can not be decreased after creation. **WARNING**: updating this attribute stops/restarts the instance.
- `elastic_ip_ids` (Set of String) A list of [exoscale_elastic_ip](./elastic_ip.md) (IDs) to attach to the instance.
- `ipv6` (Boolean) Enable IPv6 on the instance (boolean; default: `false`).
Expand Down
1 change: 1 addition & 0 deletions pkg/resources/instance/attributes.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const (

AttrAntiAffinityGroupIDs = "anti_affinity_group_ids"
AttrCreatedAt = "created_at"
AttrDestroyProtected = "destroy_protected"
AttrDeployTargetID = "deploy_target_id"
AttrDiskSize = "disk_size"
AttrElasticIPIDs = "elastic_ip_ids"
Expand Down
206 changes: 206 additions & 0 deletions pkg/resources/instance/destroy_protection_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
package instance_test

import (
"bytes"
"fmt"
"regexp"
"testing"
"text/template"

"github.com/hashicorp/terraform-plugin-testing/helper/acctest"
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
"github.com/hashicorp/terraform-plugin-testing/terraform"

"github.com/exoscale/terraform-provider-exoscale/pkg/testutils"
)

var computeInstanceResource = `
data "exoscale_template" "my_template" {
zone = "{{.Zone}}"
name = "Linux Ubuntu 22.04 LTS 64-bit"
}

data "exoscale_security_group" "default" {
name = "default"
}

{{ if not .DeleteInstanceResource }}
resource "exoscale_compute_instance" "my_instance" {
zone = "{{.Zone}}"
name = "{{.Name}}"

security_group_ids = [
data.exoscale_security_group.default.id,
]

template_id = data.exoscale_template.my_template.id
type = "standard.micro"
disk_size = 10

{{ if .SetDestroyProtected }}
destroy_protected = {{.DestroyProtected}}
{{ end }}
}
{{ end }}
`

var (
destroyProtectionTmpl = template.Must(template.New("compute_instance").Parse(computeInstanceResource))
destroyProtectionError = regexp.MustCompile(`invalid request: Operation delete-instance on resource .* is forbidden - reason: manual instance protection`)
)

type destroyProtectionTestData struct {
Zone string
SetDestroyProtected bool
DestroyProtected bool
Name string
DeleteInstanceResource bool
}

func buildTestConfig(t *testing.T, testData destroyProtectionTestData) string {
var tmplBuf bytes.Buffer

err := destroyProtectionTmpl.Execute(&tmplBuf, testData)
if err != nil {
t.Fatal(err)
}

return tmplBuf.String()
}

func checkDestroyProtection(expected string) func(s *terraform.State) error {
return func(s *terraform.State) error {
isDestroyProtected, err := testutils.AttrFromState(s, "exoscale_compute_instance.my_instance", "destroy_protected")
if err != nil {
return err
}

if expected != isDestroyProtected {
return fmt.Errorf("destroy_protected does not match expected value: %q; is %q", expected, isDestroyProtected)
}

return nil
}
}

func checkResourceDoesNotExist(name string) func(s *terraform.State) error {
return func(s *terraform.State) error {
if _, ok := s.RootModule().Resources[name]; ok {
return fmt.Errorf("compute instance was not deleted after destroy protection was removed")
}

return nil
}
}

func testExplicitDestroyProtection(t *testing.T) {
instanceName := acctest.RandomWithPrefix(testutils.Prefix)

resource.Test(t, resource.TestCase{
PreCheck: func() { testutils.AccPreCheck(t) },
ProviderFactories: testutils.Providers(),
Steps: []resource.TestStep{
{
// test instance creation with the destroy_protected field
Config: buildTestConfig(t, destroyProtectionTestData{
Zone: testutils.TestZoneName,
SetDestroyProtected: true,
DestroyProtected: true,
Name: instanceName,
}),
Check: checkDestroyProtection("true"),
},
{
// test that the API returns an error if we try to delete the protected instance
Config: buildTestConfig(t, destroyProtectionTestData{
Zone: testutils.TestZoneName,
SetDestroyProtected: true,
DestroyProtected: true,
Name: instanceName,
DeleteInstanceResource: true,
}),
Check: checkDestroyProtection("true"),
ExpectError: destroyProtectionError,
},
{
// test that we can remove the destroy protection
Config: buildTestConfig(t, destroyProtectionTestData{
Zone: testutils.TestZoneName,
SetDestroyProtected: true,
DestroyProtected: false,
Name: instanceName,
}),
Check: checkDestroyProtection("false"),
},
{
// test that we can delete the instance after removing the destroy protection
Config: buildTestConfig(t, destroyProtectionTestData{
Zone: testutils.TestZoneName,
SetDestroyProtected: true,
DestroyProtected: false,
Name: instanceName,
DeleteInstanceResource: true,
}),
Check: checkResourceDoesNotExist("exoscale_compute_instance.my-instance"),
},
},
})
}

func testDefaultDestroyProtection(t *testing.T) {
instanceName := acctest.RandomWithPrefix(testutils.Prefix)

resource.Test(t, resource.TestCase{
PreCheck: func() { testutils.AccPreCheck(t) },
ProviderFactories: testutils.Providers(),
Steps: []resource.TestStep{
{
// test instance creation without the destroy_protected field
Config: buildTestConfig(t, destroyProtectionTestData{
Zone: testutils.TestZoneName,
Name: instanceName,
}),
},

// test updating an instance to set the destroy_protected field
{
Config: buildTestConfig(t, destroyProtectionTestData{
Zone: testutils.TestZoneName,
SetDestroyProtected: true,
DestroyProtected: true,
Name: instanceName,
}),
Check: checkDestroyProtection("true"),
},
{
Config: buildTestConfig(t, destroyProtectionTestData{
Zone: testutils.TestZoneName,
SetDestroyProtected: true,
DestroyProtected: true,
Name: instanceName,
DeleteInstanceResource: true,
}),
Check: checkDestroyProtection("true"),
ExpectError: destroyProtectionError,
},

// test that removing the `destroy_protected` field removes the destroy protection
// behaving as if false were the default value.
{
Config: buildTestConfig(t, destroyProtectionTestData{
Zone: testutils.TestZoneName,
SetDestroyProtected: false,
Name: instanceName,
}),
},
{
Config: buildTestConfig(t, destroyProtectionTestData{
Zone: testutils.TestZoneName,
SetDestroyProtected: false,
Name: instanceName,
DeleteInstanceResource: true,
}),
},
},
})
}
2 changes: 2 additions & 0 deletions pkg/resources/instance/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,6 @@ func TestInstance(t *testing.T) {
t.Run("DataSource", testDataSource)
t.Run("DataSourceList", testListDataSource)
t.Run("Resource", testResource)
t.Run("DestroyProtection/ExplicitValue", testExplicitDestroyProtection)
t.Run("DestroyProtection/DefaultValue", testDefaultDestroyProtection)
}
32 changes: 32 additions & 0 deletions pkg/resources/instance/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ func Resource() *schema.Resource {
Type: schema.TypeString,
Computed: true,
},
AttrDestroyProtected: {
Description: "Mark the instance as protected, the Exoscale API will refuse to delete the instance until the protection is removed (boolean; default: `false`).",
Type: schema.TypeBool,
Optional: true,
},
AttrDeployTargetID: {
Description: "A deploy target ID.",
Type: schema.TypeString,
Expand Down Expand Up @@ -298,6 +303,13 @@ func rCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag
return diag.FromErr(err)
}

if isDestroyProtected, ok := d.GetOk(AttrDestroyProtected); ok && isDestroyProtected.(bool) {
_, err := client.AddInstanceProtectionWithResponse(ctx, *instance.ID)
if err != nil {
return diag.Errorf("unable to make instance %s destroy protected: %s", *instance.ID, err)
}
}

if set, ok := d.Get(AttrElasticIPIDs).(*schema.Set); ok {
if set.Len() > 0 {
for _, id := range set.List() {
Expand Down Expand Up @@ -632,6 +644,26 @@ func rUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag
}
}

// as we do not have a `get-instance-protection` API call,
// the tf state of the `destroy_protected` field cannot be reconciled
// and we cannot rely on d.HasChange to detect a change.
// Therefore we simply apply what the practitioner configured
// If the field is absent, the protection will be removed
isDestroyProtected := d.Get(AttrDestroyProtected)
if isDestroyProtected != nil {
if isDestroyProtected.(bool) {
_, err := client.AddInstanceProtectionWithResponse(ctx, *instance.ID)
if err != nil {
return diag.Errorf("unable to make instance %s destroy protected: %s", *instance.ID, err)
}
} else {
_, err := client.RemoveInstanceProtectionWithResponse(ctx, *instance.ID)
if err != nil {
return diag.Errorf("unable to remove destroy protection from instance %s: %s", *instance.ID, err)
}
}
}

tflog.Debug(ctx, "update finished successfully", map[string]interface{}{
"id": utils.IDString(d, Name),
})
Expand Down
Loading