Repository: ovh/terraform-provider-ovh

Issue:
[NEW] Resource: ovh_cloud_project_instance_snapshot

### Description

Goal: Snapshot an instance


category: Instance Backup

API endpoint:
https://eu.api.ovh.com/console/?section=%2Fcloud&branch=v1#post-/cloud/project/-serviceName-/instance/-instanceId-/snapshot

### Requested Resource(s) and/or Data Source(s)


* ovh_cloud_project_instance_snapshot
OR
* ovh_cloud_project_instance_backup

As you want.
The rule is to follow the API endpoint so "snapshot" will be the right naming but the product is called "instance backup".



Discussion:

Solution patch:
diff --git a/docs/resources/cloud_project_instance_snapshot.md b/docs/resources/cloud_project_instance_snapshot.md
new file mode 100644
index 00000000..cf3b8baa
--- /dev/null
+++ b/docs/resources/cloud_project_instance_snapshot.md
@@ -0,0 +1,40 @@
+---
+subcategory : "Instances"
+---
+
+# ovh_cloud_project_instance_snapshot (Resource)
+
+Create and manage snapshots for an instance in a public cloud project.
+
+```terraform
+resource "ovh_cloud_project_instance_snapshot" "snapshot" {
+  service_name  = "<public cloud project ID>"
+  instance_id   = "<instance ID>"
+  name          = "SnapshotExample"
+}
+```
+
+<!-- schema generated by tfplugindocs -->
+## Schema
+
+### Required
+
+- `instance_id` (String, Forces new resource) Instance ID
+- `service_name` (String, Forces new resource) Service name
+- `name` (String, Forces new resource) Snapshot name
+
+### Read-Only
+
+- `creation_date` (String) Image creation date
+- `flavor_type` (String) Image usable only for this type of flavor if not null
+- `id` (String) Image id
+- `min_disk` (Number) Minimum disks required to use image
+- `min_ram` (Number) Minimum RAM required to use image
+- `plan_code` (String) Order plan code
+- `region` (String) Image region
+- `size` (Number) Image size (in GiB)
+- `status` (String) Image status
+- `tags` (List of String) Tags about the image
+- `type` (String) Image type
+- `user` (String) User to connect with
+- `visibility` (String) Image visibility
diff --git a/examples/resources/cloud_project_instance_snapshot/resource.tf b/examples/resources/cloud_project_instance_snapshot/resource.tf
new file mode 100644
index 00000000..52400064
--- /dev/null
+++ b/examples/resources/cloud_project_instance_snapshot/resource.tf
@@ -0,0 +1,5 @@
+resource "ovh_cloud_project_instance_snapshot" "snapshot" {
+  service_name  = "<public cloud project ID>"
+  instance_id   = "<instance ID>"
+  name          = "SnapshotExample"
+}
\ No newline at end of file
diff --git a/ovh/provider_new.go b/ovh/provider_new.go
index af989bd4..db04d524 100644
--- a/ovh/provider_new.go
+++ b/ovh/provider_new.go
@@ -232,6 +232,7 @@ func (p *OvhProvider) Resources(_ context.Context) []func() resource.Resource {
 	return []func() resource.Resource{
 		NewCloudProjectAlertingResource,
 		NewCloudProjectGatewayInterfaceResource,
+		NewCloudProjectInstanceSnapshotResource,
 		NewCloudProjectLoadbalancerResource,
 		NewCloudProjectRancherResource,
 		NewCloudProjectRegionNetworkResource,
diff --git a/ovh/resource_cloud_project_instance_snapshot.go b/ovh/resource_cloud_project_instance_snapshot.go
new file mode 100644
index 00000000..0ccc4b31
--- /dev/null
+++ b/ovh/resource_cloud_project_instance_snapshot.go
@@ -0,0 +1,141 @@
+package ovh
+
+import (
+	"context"
+	"fmt"
+	"net/url"
+	"strings"
+
+	"github.com/hashicorp/terraform-plugin-framework/path"
+	"github.com/hashicorp/terraform-plugin-framework/resource"
+)
+
+var _ resource.ResourceWithConfigure = (*cloudProjectInstanceSnapshotResource)(nil)
+var _ resource.ResourceWithImportState = (*cloudProjectInstanceSnapshotResource)(nil)
+
+func NewCloudProjectInstanceSnapshotResource() resource.Resource {
+	return &cloudProjectInstanceSnapshotResource{}
+}
+
+type cloudProjectInstanceSnapshotResource struct {
+	config *Config
+}
+
+func (r *cloudProjectInstanceSnapshotResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
+	resp.TypeName = req.ProviderTypeName + "_cloud_project_instance_snapshot"
+}
+
+func (d *cloudProjectInstanceSnapshotResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
+	if req.ProviderData == nil {
+		return
+	}
+
+	config, ok := req.ProviderData.(*Config)
+	if !ok {
+		resp.Diagnostics.AddError(
+			"Unexpected Resource Configure Type",
+			fmt.Sprintf("Expected *Config, got: %T. Please report this issue to the provider developers.", req.ProviderData),
+		)
+		return
+	}
+
+	d.config = config
+}
+
+func (d *cloudProjectInstanceSnapshotResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
+	resp.Schema = CloudProjectInstanceSnapshotResourceSchema(ctx)
+}
+
+func (d *cloudProjectInstanceSnapshotResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
+	splits := strings.Split(req.ID, "/")
+	if len(splits) != 2 {
+		resp.Diagnostics.AddError("Given ID is malformed", "ID must be formatted like the following: <serviceName>/<snapshotID>")
+		return
+	}
+
+	resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("service_name"), splits[0])...)
+	resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), splits[1])...)
+}
+
+func (r *cloudProjectInstanceSnapshotResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
+	var data, responseData CloudProjectInstanceSnapshotModel
+
+	// Read Terraform plan data into the model
+	resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
+	if resp.Diagnostics.HasError() {
+		return
+	}
+
+	// Create snapshot
+	endpoint := "/cloud/project/" + url.PathEscape(data.ServiceName.ValueString()) + "/instance/" + url.PathEscape(data.InstanceId.ValueString()) + "/snapshot"
+	if err := r.config.OVHClient.Post(endpoint, data.ToCreate(), &responseData); err != nil {
+		resp.Diagnostics.AddError(
+			fmt.Sprintf("Error calling Post %s", endpoint),
+			err.Error(),
+		)
+		return
+	}
+
+	// Read created snapshot
+	endpoint = "/cloud/project/" + url.PathEscape(data.ServiceName.ValueString()) + "/snapshot/" + url.PathEscape(responseData.SnapshotId.ValueString())
+	if err := r.config.OVHClient.Get(endpoint, &responseData); err != nil {
+		resp.Diagnostics.AddError(
+			fmt.Sprintf("Error calling Get %s", endpoint),
+			err.Error(),
+		)
+		return
+	}
+
+	responseData.MergeWith(&data)
+
+	// Save data into Terraform state
+	resp.Diagnostics.Append(resp.State.Set(ctx, &responseData)...)
+}
+
+func (r *cloudProjectInstanceSnapshotResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
+	var data, responseData CloudProjectInstanceSnapshotModel
+
+	// Read Terraform prior state data into the model
+	resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
+	if resp.Diagnostics.HasError() {
+		return
+	}
+
+	endpoint := "/cloud/project/" + url.PathEscape(data.ServiceName.ValueString()) + "/snapshot/" + url.PathEscape(data.Id.ValueString())
+	if err := r.config.OVHClient.Get(endpoint, &responseData); err != nil {
+		resp.Diagnostics.AddError(
+			fmt.Sprintf("Error calling Get %s", endpoint),
+			err.Error(),
+		)
+		return
+	}
+
+	data.MergeWith(&responseData)
+
+	// Save updated data into Terraform state
+	resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
+}
+
+func (r *cloudProjectInstanceSnapshotResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
+	resp.Diagnostics.AddError("not implemented", "update should never be called")
+}
+
+func (r *cloudProjectInstanceSnapshotResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
+	var data CloudProjectInstanceSnapshotModel
+
+	// Read Terraform prior state data into the model
+	resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
+
+	if resp.Diagnostics.HasError() {
+		return
+	}
+
+	// Delete API call logic
+	endpoint := "/cloud/project/" + url.PathEscape(data.ServiceName.ValueString()) + "/snapshot/" + url.PathEscape(data.Id.ValueString())
+	if err := r.config.OVHClient.Delete(endpoint, nil); err != nil {
+		resp.Diagnostics.AddError(
+			fmt.Sprintf("Error calling Delete %s", endpoint),
+			err.Error(),
+		)
+	}
+}
diff --git a/ovh/resource_cloud_project_instance_snapshot_gen.go b/ovh/resource_cloud_project_instance_snapshot_gen.go
new file mode 100644
index 00000000..3a7aa237
--- /dev/null
+++ b/ovh/resource_cloud_project_instance_snapshot_gen.go
@@ -0,0 +1,227 @@
+// Code generated by terraform-plugin-framework-generator DO NOT EDIT.
+
+package ovh
+
+import (
+	"context"
+
+	ovhtypes "github.com/ovh/terraform-provider-ovh/v2/ovh/types"
+
+	"github.com/hashicorp/terraform-plugin-framework/resource/schema"
+	"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
+	"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
+)
+
+func CloudProjectInstanceSnapshotResourceSchema(ctx context.Context) schema.Schema {
+	attrs := map[string]schema.Attribute{
+		"creation_date": schema.StringAttribute{
+			CustomType:          ovhtypes.TfStringType{},
+			Computed:            true,
+			Description:         "Image creation date",
+			MarkdownDescription: "Image creation date",
+		},
+		"flavor_type": schema.StringAttribute{
+			CustomType:          ovhtypes.TfStringType{},
+			Computed:            true,
+			Description:         "Image usable only for this type of flavor if not null",
+			MarkdownDescription: "Image usable only for this type of flavor if not null",
+		},
+		"id": schema.StringAttribute{
+			CustomType:          ovhtypes.TfStringType{},
+			Computed:            true,
+			Description:         "Image id",
+			MarkdownDescription: "Image id",
+		},
+		"instance_id": schema.StringAttribute{
+			CustomType:          ovhtypes.TfStringType{},
+			Required:            true,
+			Description:         "Instance ID",
+			MarkdownDescription: "Instance ID",
+			PlanModifiers: []planmodifier.String{
+				stringplanmodifier.RequiresReplace(),
+			},
+		},
+		"min_disk": schema.Int64Attribute{
+			CustomType:          ovhtypes.TfInt64Type{},
+			Computed:            true,
+			Description:         "Minimum disks required to use image",
+			MarkdownDescription: "Minimum disks required to use image",
+		},
+		"min_ram": schema.Int64Attribute{
+			CustomType:          ovhtypes.TfInt64Type{},
+			Computed:            true,
+			Description:         "Minimum RAM required to use image",
+			MarkdownDescription: "Minimum RAM required to use image",
+		},
+		"name": schema.StringAttribute{
+			CustomType:          ovhtypes.TfStringType{},
+			Required:            true,
+			Description:         "Image name",
+			MarkdownDescription: "Image name",
+			PlanModifiers: []planmodifier.String{
+				stringplanmodifier.RequiresReplace(),
+			},
+		},
+		"plan_code": schema.StringAttribute{
+			CustomType:          ovhtypes.TfStringType{},
+			Computed:            true,
+			Description:         "Order plan code",
+			MarkdownDescription: "Order plan code",
+		},
+		"region": schema.StringAttribute{
+			CustomType:          ovhtypes.TfStringType{},
+			Computed:            true,
+			Description:         "Image region",
+			MarkdownDescription: "Image region",
+		},
+		"service_name": schema.StringAttribute{
+			CustomType:          ovhtypes.TfStringType{},
+			Required:            true,
+			Description:         "Service name",
+			MarkdownDescription: "Service name",
+			PlanModifiers: []planmodifier.String{
+				stringplanmodifier.RequiresReplace(),
+			},
+		},
+		"size": schema.NumberAttribute{
+			CustomType:          ovhtypes.TfNumberType{},
+			Computed:            true,
+			Description:         "Image size (in GiB)",
+			MarkdownDescription: "Image size (in GiB)",
+		},
+		"status": schema.StringAttribute{
+			CustomType:          ovhtypes.TfStringType{},
+			Computed:            true,
+			Description:         "Image status",
+			MarkdownDescription: "Image status",
+		},
+		"tags": schema.ListAttribute{
+			CustomType:          ovhtypes.NewTfListNestedType[ovhtypes.TfStringValue](ctx),
+			Computed:            true,
+			Description:         "Tags about the image",
+			MarkdownDescription: "Tags about the image",
+		},
+		"type": schema.StringAttribute{
+			CustomType:          ovhtypes.TfStringType{},
+			Computed:            true,
+			Description:         "Image type",
+			MarkdownDescription: "Image type",
+		},
+		"user": schema.StringAttribute{
+			CustomType:          ovhtypes.TfStringType{},
+			Computed:            true,
+			Description:         "User to connect with",
+			MarkdownDescription: "User to connect with",
+		},
+		"visibility": schema.StringAttribute{
+			CustomType:          ovhtypes.TfStringType{},
+			Computed:            true,
+			Description:         "Image visibility",
+			MarkdownDescription: "Image visibility",
+		},
+	}
+
+	return schema.Schema{
+		Attributes: attrs,
+	}
+}
+
+type CloudProjectInstanceSnapshotModel struct {
+	CreationDate ovhtypes.TfStringValue                             `tfsdk:"creation_date" json:"creationDate"`
+	FlavorType   ovhtypes.TfStringValue                             `tfsdk:"flavor_type" json:"flavorType"`
+	Id           ovhtypes.TfStringValue                             `tfsdk:"id" json:"id"`
+	InstanceId   ovhtypes.TfStringValue                             `tfsdk:"instance_id" json:"instanceId"`
+	MinDisk      ovhtypes.TfInt64Value                              `tfsdk:"min_disk" json:"minDisk"`
+	MinRam       ovhtypes.TfInt64Value                              `tfsdk:"min_ram" json:"minRam"`
+	Name         ovhtypes.TfStringValue                             `tfsdk:"name" json:"name"`
+	PlanCode     ovhtypes.TfStringValue                             `tfsdk:"plan_code" json:"planCode"`
+	Region       ovhtypes.TfStringValue                             `tfsdk:"region" json:"region"`
+	ServiceName  ovhtypes.TfStringValue                             `tfsdk:"service_name" json:"serviceName"`
+	Size         ovhtypes.TfNumberValue                             `tfsdk:"size" json:"size"`
+	Status       ovhtypes.TfStringValue                             `tfsdk:"status" json:"status"`
+	Tags         ovhtypes.TfListNestedValue[ovhtypes.TfStringValue] `tfsdk:"tags" json:"tags"`
+	Type         ovhtypes.TfStringValue                             `tfsdk:"type" json:"type"`
+	User         ovhtypes.TfStringValue                             `tfsdk:"user" json:"user"`
+	Visibility   ovhtypes.TfStringValue                             `tfsdk:"visibility" json:"visibility"`
+	SnapshotId   ovhtypes.TfStringValue                             `tfsdk:"-" json:"snapshotId"`
+}
+
+func (v *CloudProjectInstanceSnapshotModel) MergeWith(other *CloudProjectInstanceSnapshotModel) {
+	if (v.CreationDate.IsUnknown() || v.CreationDate.IsNull()) && !other.CreationDate.IsUnknown() {
+		v.CreationDate = other.CreationDate
+	}
+
+	if (v.FlavorType.IsUnknown() || v.FlavorType.IsNull()) && !other.FlavorType.IsUnknown() {
+		v.FlavorType = other.FlavorType
+	}
+
+	if (v.Id.IsUnknown() || v.Id.IsNull()) && !other.Id.IsUnknown() {
+		v.Id = other.Id
+	}
+
+	if (v.InstanceId.IsUnknown() || v.InstanceId.IsNull()) && !other.InstanceId.IsUnknown() {
+		v.InstanceId = other.InstanceId
+	}
+
+	if (v.MinDisk.IsUnknown() || v.MinDisk.IsNull()) && !other.MinDisk.IsUnknown() {
+		v.MinDisk = other.MinDisk
+	}
+
+	if (v.MinRam.IsUnknown() || v.MinRam.IsNull()) && !other.MinRam.IsUnknown() {
+		v.MinRam = other.MinRam
+	}
+
+	if (v.Name.IsUnknown() || v.Name.IsNull()) && !other.Name.IsUnknown() {
+		v.Name = other.Name
+	}
+
+	if (v.PlanCode.IsUnknown() || v.PlanCode.IsNull()) && !other.PlanCode.IsUnknown() {
+		v.PlanCode = other.PlanCode
+	}
+
+	if (v.Region.IsUnknown() || v.Region.IsNull()) && !other.Region.IsUnknown() {
+		v.Region = other.Region
+	}
+
+	if (v.ServiceName.IsUnknown() || v.ServiceName.IsNull()) && !other.ServiceName.IsUnknown() {
+		v.ServiceName = other.ServiceName
+	}
+
+	if (v.Size.IsUnknown() || v.Size.IsNull()) && !other.Size.IsUnknown() {
+		v.Size = other.Size
+	}
+
+	if (v.Status.IsUnknown() || v.Status.IsNull()) && !other.Status.IsUnknown() {
+		v.Status = other.Status
+	}
+
+	if (v.Tags.IsUnknown() || v.Tags.IsNull()) && !other.Tags.IsUnknown() {
+		v.Tags = other.Tags
+	}
+
+	if (v.Type.IsUnknown() || v.Type.IsNull()) && !other.Type.IsUnknown() {
+		v.Type = other.Type
+	}
+
+	if (v.User.IsUnknown() || v.User.IsNull()) && !other.User.IsUnknown() {
+		v.User = other.User
+	}
+
+	if (v.Visibility.IsUnknown() || v.Visibility.IsNull()) && !other.Visibility.IsUnknown() {
+		v.Visibility = other.Visibility
+	}
+}
+
+type CloudProjectInstanceSnapshotWritableModel struct {
+	SnapshotName *ovhtypes.TfStringValue `tfsdk:"snapshot_name" json:"snapshotName,omitempty"`
+}
+
+func (v CloudProjectInstanceSnapshotModel) ToCreate() *CloudProjectInstanceSnapshotWritableModel {
+	res := &CloudProjectInstanceSnapshotWritableModel{}
+
+	if !v.Name.IsUnknown() {
+		res.SnapshotName = &v.Name
+	}
+
+	return res
+}
diff --git a/templates/resources/cloud_project_instance_snapshot.md.tmpl b/templates/resources/cloud_project_instance_snapshot.md.tmpl
new file mode 100644
index 00000000..4eb5421f
--- /dev/null
+++ b/templates/resources/cloud_project_instance_snapshot.md.tmpl
@@ -0,0 +1,34 @@
+---
+subcategory : "Instances"
+---
+
+# ovh_cloud_project_instance_snapshot (Resource)
+
+Create and manage snapshots for an instance in a public cloud project.
+
+{{tffile "examples/resources/cloud_project_instance_snapshot/resource.tf"}}
+
+<!-- schema generated by tfplugindocs -->
+## Schema
+
+### Required
+
+- `instance_id` (String, Forces new resource) Instance ID
+- `service_name` (String, Forces new resource) Service name
+- `name` (String, Forces new resource) Snapshot name
+
+### Read-Only
+
+- `creation_date` (String) Image creation date
+- `flavor_type` (String) Image usable only for this type of flavor if not null
+- `id` (String) Image id
+- `min_disk` (Number) Minimum disks required to use image
+- `min_ram` (Number) Minimum RAM required to use image
+- `plan_code` (String) Order plan code
+- `region` (String) Image region
+- `size` (Number) Image size (in GiB)
+- `status` (String) Image status
+- `tags` (List of String) Tags about the image
+- `type` (String) Image type
+- `user` (String) User to connect with
+- `visibility` (String) Image visibility



Test patch:
diff --git a/ovh/resource_cloud_project_instance_snapshot_test.go b/ovh/resource_cloud_project_instance_snapshot_test.go
new file mode 100644
index 00000000..f2fc638f
--- /dev/null
+++ b/ovh/resource_cloud_project_instance_snapshot_test.go
@@ -0,0 +1,50 @@
+package ovh
+
+import (
+	"fmt"
+	"os"
+	"testing"
+
+	"github.com/hashicorp/terraform-plugin-testing/helper/acctest"
+	"github.com/hashicorp/terraform-plugin-testing/helper/resource"
+)
+
+func TestAccCloudProjectInstanceSnapshot_basic(t *testing.T) {
+	serviceName := os.Getenv("OVH_CLOUD_PROJECT_SERVICE_TEST")
+	instanceID := os.Getenv("OVH_CLOUD_PROJECT_INSTANCE_TEST")
+	snapshotName := acctest.RandomWithPrefix(test_prefix)
+
+	config := fmt.Sprintf(`
+	resource "ovh_cloud_project_instance_snapshot" "snapshot" {
+		service_name = "%s"
+		instance_id  = "%s"
+		name         = "%s"
+	  }
+	`, serviceName, instanceID, snapshotName)
+
+	resource.Test(t, resource.TestCase{
+		PreCheck:                 func() { testAccPreCheckCloud(t); testAccCheckCloudProjectExists(t) },
+		ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
+		Steps: []resource.TestStep{
+			{
+				Config: config,
+				Check: resource.ComposeTestCheckFunc(
+					resource.TestCheckResourceAttr("ovh_cloud_project_instance_snapshot.snapshot", "service_name", serviceName),
+					resource.TestCheckResourceAttr("ovh_cloud_project_instance_snapshot.snapshot", "instance_id", instanceID),
+					resource.TestCheckResourceAttr("ovh_cloud_project_instance_snapshot.snapshot", "name", snapshotName),
+					resource.TestCheckResourceAttr("ovh_cloud_project_instance_snapshot.snapshot", "visibility", "private"),
+					resource.TestCheckResourceAttrSet("ovh_cloud_project_instance_snapshot.snapshot", "status"),
+					resource.TestCheckResourceAttrSet("ovh_cloud_project_instance_snapshot.snapshot", "size"),
+					resource.TestCheckResourceAttrSet("ovh_cloud_project_instance_snapshot.snapshot", "type"),
+				),
+			},
+			{
+				ImportState:             true,
+				ImportStateVerify:       true,
+				ImportStateIdPrefix:     serviceName + "/",
+				ResourceName:            "ovh_cloud_project_instance_snapshot.snapshot",
+				ImportStateVerifyIgnore: []string{"instance_id"},
+			},
+		},
+	})
+}