diff --git a/docs/data-sources/image.md b/docs/data-sources/image.md new file mode 100644 index 00000000..50a2fe16 --- /dev/null +++ b/docs/data-sources/image.md @@ -0,0 +1,72 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_image Data Source - stackit" +subcategory: "" +description: |- + Image datasource schema. Must have a region specified in the provider configuration. + ~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our guide https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources for how to opt-in to use beta resources. +--- + +# stackit_image (Data Source) + +Image datasource schema. Must have a `region` specified in the provider configuration. + +~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our [guide](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources) for how to opt-in to use beta resources. + +## Example Usage + +```terraform +data "stackit_image" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + image_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} +``` + + +## Schema + +### Required + +- `image_id` (String) The image ID. +- `project_id` (String) STACKIT project ID to which the image is associated. + +### Read-Only + +- `checksum` (Attributes) Representation of an image checksum. (see [below for nested schema](#nestedatt--checksum)) +- `config` (Attributes) Properties to set hardware and scheduling settings for an image. (see [below for nested schema](#nestedatt--config)) +- `disk_format` (String) The disk format of the image. +- `id` (String) Terraform's internal resource ID. It is structured as "`project_id`,`image_id`". +- `labels` (Map of String) Labels are key-value string pairs which can be attached to a resource container +- `min_disk_size` (Number) The minimum disk size of the image in GB. +- `min_ram` (Number) The minimum RAM of the image in MB. +- `name` (String) The name of the image. +- `protected` (Boolean) Whether the image is protected. +- `scope` (String) The scope of the image. + + +### Nested Schema for `checksum` + +Read-Only: + +- `algorithm` (String) Algorithm for the checksum of the image data. +- `digest` (String) Hexdigest of the checksum of the image data. + + + +### Nested Schema for `config` + +Read-Only: + +- `boot_menu` (Boolean) Enables the BIOS bootmenu. +- `cdrom_bus` (String) Sets CDROM bus controller type. +- `disk_bus` (String) Sets Disk bus controller type. +- `nic_model` (String) Sets virtual network interface model. +- `operating_system` (String) Enables operating system specific optimizations. +- `operating_system_distro` (String) Operating system distribution. +- `operating_system_version` (String) Version of the operating system. +- `rescue_bus` (String) Sets the device bus when the image is used as a rescue image. +- `rescue_device` (String) Sets the device when the image is used as a rescue image. +- `secure_boot` (Boolean) Enables Secure Boot. +- `uefi` (Boolean) Enables UEFI boot. +- `video_model` (String) Sets Graphic device model. +- `virtio_scsi` (Boolean) Enables the use of VirtIO SCSI to provide block device access. By default instances use VirtIO Block. diff --git a/docs/resources/image.md b/docs/resources/image.md new file mode 100644 index 00000000..eca41fac --- /dev/null +++ b/docs/resources/image.md @@ -0,0 +1,77 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_image Resource - stackit" +subcategory: "" +description: |- + Image resource schema. Must have a region specified in the provider configuration. +--- + +# stackit_image (Resource) + +Image resource schema. Must have a `region` specified in the provider configuration. + +## Example Usage + +```terraform +resource "stackit_image" "example_image" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example-image" + disk_format = "qcow2" + local_file_path = "./path/to/image.qcow2" + min_disk_size = 10 + min_ram = 5 +} +``` + + +## Schema + +### Required + +- `disk_format` (String) The disk format of the image. +- `local_file_path` (String) The filepath of the raw image file to be uploaded. +- `name` (String) The name of the image. +- `project_id` (String) STACKIT project ID to which the image is associated. + +### Optional + +- `config` (Attributes) Properties to set hardware and scheduling settings for an image. (see [below for nested schema](#nestedatt--config)) +- `labels` (Map of String) Labels are key-value string pairs which can be attached to a resource container +- `min_disk_size` (Number) The minimum disk size of the image in GB. +- `min_ram` (Number) The minimum RAM of the image in MB. + +### Read-Only + +- `checksum` (Attributes) Representation of an image checksum. (see [below for nested schema](#nestedatt--checksum)) +- `id` (String) Terraform's internal resource ID. It is structured as "`project_id`,`image_id`". +- `image_id` (String) The image ID. +- `protected` (Boolean) Whether the image is protected. +- `scope` (String) The scope of the image. + + +### Nested Schema for `config` + +Optional: + +- `boot_menu` (Boolean) Enables the BIOS bootmenu. +- `cdrom_bus` (String) Sets CDROM bus controller type. +- `disk_bus` (String) Sets Disk bus controller type. +- `nic_model` (String) Sets virtual network interface model. +- `operating_system` (String) Enables operating system specific optimizations. +- `operating_system_distro` (String) Operating system distribution. +- `operating_system_version` (String) Version of the operating system. +- `rescue_bus` (String) Sets the device bus when the image is used as a rescue image. +- `rescue_device` (String) Sets the device when the image is used as a rescue image. +- `secure_boot` (Boolean) Enables Secure Boot. +- `uefi` (Boolean) Enables UEFI boot. +- `video_model` (String) Sets Graphic device model. +- `virtio_scsi` (Boolean) Enables the use of VirtIO SCSI to provide block device access. By default instances use VirtIO Block. + + + +### Nested Schema for `checksum` + +Read-Only: + +- `algorithm` (String) Algorithm for the checksum of the image data. +- `digest` (String) Hexdigest of the checksum of the image data. diff --git a/examples/data-sources/stackit_image/data-source.tf b/examples/data-sources/stackit_image/data-source.tf new file mode 100644 index 00000000..adc05587 --- /dev/null +++ b/examples/data-sources/stackit_image/data-source.tf @@ -0,0 +1,4 @@ +data "stackit_image" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + image_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} diff --git a/examples/resources/stackit_image/resource.tf b/examples/resources/stackit_image/resource.tf new file mode 100644 index 00000000..de3253b0 --- /dev/null +++ b/examples/resources/stackit_image/resource.tf @@ -0,0 +1,8 @@ +resource "stackit_image" "example_image" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example-image" + disk_format = "qcow2" + local_file_path = "./path/to/image.qcow2" + min_disk_size = 10 + min_ram = 5 +} diff --git a/go.mod b/go.mod index 37a89c02..91489a7d 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,6 @@ require ( github.com/stackitcloud/stackit-sdk-go/services/argus v0.11.0 github.com/stackitcloud/stackit-sdk-go/services/dns v0.12.1 github.com/stackitcloud/stackit-sdk-go/services/iaas v0.18.0 - github.com/stackitcloud/stackit-sdk-go/services/iaasalpha v0.1.12-alpha github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v0.17.0 github.com/stackitcloud/stackit-sdk-go/services/logme v0.20.1 github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.20.1 diff --git a/go.sum b/go.sum index 02bdc125..945d86a5 100644 --- a/go.sum +++ b/go.sum @@ -157,8 +157,6 @@ github.com/stackitcloud/stackit-sdk-go/services/dns v0.12.1 h1:nzOZQ2X6joM2iSteP github.com/stackitcloud/stackit-sdk-go/services/dns v0.12.1/go.mod h1:mv8U7kuclXo+0VpDHtBCkve/3i9h1yT+RAId/MUi+C8= github.com/stackitcloud/stackit-sdk-go/services/iaas v0.18.0 h1:7QPYi7OZXUSO1uOtp1UXeCbxK0BGJbmjp71kaQOrMa8= github.com/stackitcloud/stackit-sdk-go/services/iaas v0.18.0/go.mod h1:YfuN+eXuqr846xeRyW2Vf1JM2jU0ikeQa76dDI66RsM= -github.com/stackitcloud/stackit-sdk-go/services/iaasalpha v0.1.12-alpha h1:jwpif4t2gthmKmCXsQ84rmtDdcZkw4QQTFiCd7nTW8M= -github.com/stackitcloud/stackit-sdk-go/services/iaasalpha v0.1.12-alpha/go.mod h1:nW/6vvumUHA7o1/JOOqsrEOBNrRHombEKB1U4jmg2wU= github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v0.17.0 h1:06CGP64CEk3Zg6i9kZCMRdmCzLLiyMWQqGK1teBr9Oc= github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v0.17.0/go.mod h1:JL94zc8K0ebWs+DBGXR28vNCF0EFV54ZLUtrlXOvWgA= github.com/stackitcloud/stackit-sdk-go/services/logme v0.20.1 h1:ogo7Ce4wA9ln/Z0VwvckH0FT5/i7d9/34bG85aayHn8= diff --git a/stackit/internal/services/iaas/iaas_acc_test.go b/stackit/internal/services/iaas/iaas_acc_test.go index bef29e09..27d55de2 100644 --- a/stackit/internal/services/iaas/iaas_acc_test.go +++ b/stackit/internal/services/iaas/iaas_acc_test.go @@ -12,7 +12,6 @@ import ( "github.com/stackitcloud/stackit-sdk-go/core/config" "github.com/stackitcloud/stackit-sdk-go/core/utils" "github.com/stackitcloud/stackit-sdk-go/services/iaas" - "github.com/stackitcloud/stackit-sdk-go/services/iaasalpha" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/testutil" ) @@ -110,6 +109,18 @@ var keyPairResource = map[string]string{ "label1-updated": "value1-updated", } +// Image resource data +var imageResource = map[string]string{ + "project_id": testutil.ProjectId, + "name": fmt.Sprintf("tf-acc-%s", acctest.RandStringFromCharSet(5, acctest.CharSetAlpha)), + "disk_format": "qcow2", + "local_file_path": testutil.TestImageLocalFilePath, + "min_disk_size": "1", + "min_ram": "1", + "label1": "value1", + "boot_menu": "true", +} + func networkResourceConfig(name, nameservers string) string { return fmt.Sprintf(` resource "stackit_network" "network" { @@ -304,6 +315,34 @@ func serviceAccountAttachmentResourceConfig() string { ) } +func imageResourceConfig(name string) string { + return fmt.Sprintf(` + resource "stackit_image" "image" { + project_id = "%s" + name = "%s" + disk_format = "%s" + local_file_path = "%s" + min_disk_size = %s + min_ram = %s + labels = { + "label1" = "%s" + } + config = { + boot_menu = %s + } + } + `, + imageResource["project_id"], + name, + imageResource["disk_format"], + imageResource["local_file_path"], + imageResource["min_disk_size"], + imageResource["min_ram"], + imageResource["label1"], + imageResource["boot_menu"], + ) +} + func testAccNetworkAreaConfig(areaname, networkranges, routeLabelValue string) string { return fmt.Sprintf("%s\n\n%s\n\n%s", testutil.IaaSProviderConfig(), @@ -354,6 +393,13 @@ func testAccKeyPairConfig(keyPairResourceConfig string) string { ) } +func testAccImageConfig(name string) string { + return fmt.Sprintf("%s\n\n%s", + testutil.IaaSProviderConfig(), + imageResourceConfig(name), + ) +} + func TestAccNetworkArea(t *testing.T) { resource.Test(t, resource.TestCase{ ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, @@ -1282,6 +1328,95 @@ func TestAccKeyPair(t *testing.T) { }) } +func TestAccImage(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckIaaSImageDestroy, + Steps: []resource.TestStep{ + + // Creation + { + Config: testAccImageConfig(imageResource["name"]), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_image.image", "project_id", imageResource["project_id"]), + resource.TestCheckResourceAttrSet("stackit_image.image", "image_id"), + resource.TestCheckResourceAttr("stackit_image.image", "name", imageResource["name"]), + resource.TestCheckResourceAttr("stackit_image.image", "disk_format", imageResource["disk_format"]), + resource.TestCheckResourceAttr("stackit_image.image", "min_disk_size", imageResource["min_disk_size"]), + resource.TestCheckResourceAttr("stackit_image.image", "min_ram", imageResource["min_ram"]), + resource.TestCheckResourceAttr("stackit_image.image", "labels.label1", imageResource["label1"]), + resource.TestCheckResourceAttr("stackit_image.image", "config.boot_menu", imageResource["boot_menu"]), + resource.TestCheckResourceAttrSet("stackit_image.image", "checksum.algorithm"), + resource.TestCheckResourceAttrSet("stackit_image.image", "checksum.digest"), + ), + }, + // Data source + { + Config: fmt.Sprintf(` + %s + + data "stackit_image" "image" { + project_id = stackit_image.image.project_id + image_id = stackit_image.image.image_id + } + `, + testAccImageConfig( + fmt.Sprintf(` + resource "stackit_image" "image" { + project_id = "%s" + labels = { + "label1" = "%s" + } + } + `, + imageResource["project_id"], + imageResource["label1"], + ), + ), + ), + Check: resource.ComposeAggregateTestCheckFunc( + // Instance + resource.TestCheckResourceAttr("data.stackit_image.image", "project_id", imageResource["project_id"]), + resource.TestCheckResourceAttrPair("data.stackit_image.image", "image_id", "stackit_image.image", "image_id"), + resource.TestCheckResourceAttrPair("data.stackit_image.image", "name", "stackit_image.image", "name"), + resource.TestCheckResourceAttrPair("data.stackit_image.image", "disk_format", "stackit_image.image", "disk_format"), + resource.TestCheckResourceAttrPair("data.stackit_image.image", "min_disk_size", "stackit_image.image", "min_disk_size"), + resource.TestCheckResourceAttrPair("data.stackit_image.image", "min_ram", "stackit_image.image", "min_ram"), + resource.TestCheckResourceAttrPair("data.stackit_image.image", "protected", "stackit_image.image", "protected"), + resource.TestCheckResourceAttrPair("data.stackit_image.image", "labels.label1", "stackit_image.image", "labels.label1"), + ), + }, + // Import + { + ResourceName: "stackit_image.image", + ImportStateIdFunc: func(s *terraform.State) (string, error) { + r, ok := s.RootModule().Resources["stackit_image.image"] + if !ok { + return "", fmt.Errorf("couldn't find resource stackit_image.image") + } + imageId, ok := r.Primary.Attributes["image_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute image_id") + } + return fmt.Sprintf("%s,%s", testutil.ProjectId, imageId), nil + }, + ImportState: true, + ImportStateVerify: true, + }, + // Update + { + Config: testAccImageConfig(fmt.Sprintf("%s-updated", imageResource["name"])), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_image.image", "name", fmt.Sprintf("%s-updated", imageResource["name"])), + resource.TestCheckResourceAttr("stackit_image.image", "project_id", imageResource["project_id"]), + resource.TestCheckResourceAttr("stackit_image.image", "labels.label1", imageResource["label1"]), + ), + }, + // Deletion is done by the framework implicitly + }, + }) +} + func testAccCheckNetworkAreaDestroy(s *terraform.State) error { ctx := context.Background() var client *iaas.APIClient @@ -1560,14 +1695,14 @@ func testAccCheckIaaSPublicIpDestroy(s *terraform.State) error { func testAccCheckIaaSKeyPairDestroy(s *terraform.State) error { ctx := context.Background() - var client *iaasalpha.APIClient + var client *iaas.APIClient var err error if testutil.IaaSCustomEndpoint == "" { - client, err = iaasalpha.NewAPIClient( + client, err = iaas.NewAPIClient( config.WithRegion("eu01"), ) } else { - client, err = iaasalpha.NewAPIClient( + client, err = iaas.NewAPIClient( config.WithEndpoint(testutil.IaaSCustomEndpoint), ) } @@ -1603,3 +1738,50 @@ func testAccCheckIaaSKeyPairDestroy(s *terraform.State) error { } return nil } + +func testAccCheckIaaSImageDestroy(s *terraform.State) error { + ctx := context.Background() + var client *iaas.APIClient + var err error + if testutil.IaaSCustomEndpoint == "" { + client, err = iaas.NewAPIClient( + config.WithRegion("eu01"), + ) + } else { + client, err = iaas.NewAPIClient( + config.WithEndpoint(testutil.IaaSCustomEndpoint), + ) + } + if err != nil { + return fmt.Errorf("creating client: %w", err) + } + + imagesToDestroy := []string{} + for _, rs := range s.RootModule().Resources { + if rs.Type != "stackit_image" { + continue + } + // Image terraform ID: "[project_id],[image_id]" + imageId := strings.Split(rs.Primary.ID, core.Separator)[1] + imagesToDestroy = append(imagesToDestroy, imageId) + } + + imagesResp, err := client.ListImagesExecute(ctx, testutil.ProjectId) + if err != nil { + return fmt.Errorf("getting images: %w", err) + } + + images := *imagesResp.Items + for i := range images { + if images[i].Id == nil { + continue + } + if utils.Contains(imagesToDestroy, *images[i].Id) { + err := client.DeleteImageExecute(ctx, testutil.ProjectId, *images[i].Id) + if err != nil { + return fmt.Errorf("destroying image %s during CheckDestroy: %w", *images[i].Id, err) + } + } + } + return nil +} diff --git a/stackit/internal/services/iaas/image/datasource.go b/stackit/internal/services/iaas/image/datasource.go new file mode 100644 index 00000000..b12e0527 --- /dev/null +++ b/stackit/internal/services/iaas/image/datasource.go @@ -0,0 +1,387 @@ +package image + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" +) + +// imageDataSourceBetaCheckDone is used to prevent multiple checks for beta resources. +// This is a workaround for the lack of a global state in the provider and +// needs to exist because the Configure method is called twice. +var imageDataSourceBetaCheckDone bool + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ datasource.DataSource = &imageDataSource{} +) + +type DataSourceModel struct { + Id types.String `tfsdk:"id"` // needed by TF + ProjectId types.String `tfsdk:"project_id"` + ImageId types.String `tfsdk:"image_id"` + Name types.String `tfsdk:"name"` + DiskFormat types.String `tfsdk:"disk_format"` + MinDiskSize types.Int64 `tfsdk:"min_disk_size"` + MinRAM types.Int64 `tfsdk:"min_ram"` + Protected types.Bool `tfsdk:"protected"` + Scope types.String `tfsdk:"scope"` + Config types.Object `tfsdk:"config"` + Checksum types.Object `tfsdk:"checksum"` + Labels types.Map `tfsdk:"labels"` +} + +// NewImageDataSource is a helper function to simplify the provider implementation. +func NewImageDataSource() datasource.DataSource { + return &imageDataSource{} +} + +// imageDataSource is the data source implementation. +type imageDataSource struct { + client *iaas.APIClient +} + +// Metadata returns the data source type name. +func (d *imageDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_image" +} + +func (d *imageDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + var apiClient *iaas.APIClient + var err error + + providerData, ok := req.ProviderData.(core.ProviderData) + if !ok { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", req.ProviderData)) + return + } + + if !imageDataSourceBetaCheckDone { + features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_image", "data source") + if resp.Diagnostics.HasError() { + return + } + imageDataSourceBetaCheckDone = true + } + + if providerData.IaaSCustomEndpoint != "" { + apiClient, err = iaas.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithEndpoint(providerData.IaaSCustomEndpoint), + ) + } else { + apiClient, err = iaas.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithRegion(providerData.Region), + ) + } + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the data source configuration", err)) + return + } + + d.client = apiClient + tflog.Info(ctx, "iaas client configured") +} + +// Schema defines the schema for the datasource. +func (r *imageDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: features.AddBetaDescription("Image datasource schema. Must have a `region` specified in the provider configuration."), + Description: "Image datasource schema. Must have a `region` specified in the provider configuration.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "Terraform's internal resource ID. It is structured as \"`project_id`,`image_id`\".", + Computed: true, + }, + "project_id": schema.StringAttribute{ + Description: "STACKIT project ID to which the image is associated.", + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "image_id": schema.StringAttribute{ + Description: "The image ID.", + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "name": schema.StringAttribute{ + Description: "The name of the image.", + Computed: true, + }, + "disk_format": schema.StringAttribute{ + Description: "The disk format of the image.", + Computed: true, + }, + "min_disk_size": schema.Int64Attribute{ + Description: "The minimum disk size of the image in GB.", + Computed: true, + }, + "min_ram": schema.Int64Attribute{ + Description: "The minimum RAM of the image in MB.", + Computed: true, + }, + "protected": schema.BoolAttribute{ + Description: "Whether the image is protected.", + Computed: true, + }, + "scope": schema.StringAttribute{ + Description: "The scope of the image.", + Computed: true, + }, + "config": schema.SingleNestedAttribute{ + Description: "Properties to set hardware and scheduling settings for an image.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "boot_menu": schema.BoolAttribute{ + Description: "Enables the BIOS bootmenu.", + Computed: true, + }, + "cdrom_bus": schema.StringAttribute{ + Description: "Sets CDROM bus controller type.", + Computed: true, + }, + "disk_bus": schema.StringAttribute{ + Description: "Sets Disk bus controller type.", + Computed: true, + }, + "nic_model": schema.StringAttribute{ + Description: "Sets virtual network interface model.", + Computed: true, + }, + "operating_system": schema.StringAttribute{ + Description: "Enables operating system specific optimizations.", + Computed: true, + }, + "operating_system_distro": schema.StringAttribute{ + Description: "Operating system distribution.", + Computed: true, + }, + "operating_system_version": schema.StringAttribute{ + Description: "Version of the operating system.", + Computed: true, + }, + "rescue_bus": schema.StringAttribute{ + Description: "Sets the device bus when the image is used as a rescue image.", + Computed: true, + }, + "rescue_device": schema.StringAttribute{ + Description: "Sets the device when the image is used as a rescue image.", + Computed: true, + }, + "secure_boot": schema.BoolAttribute{ + Description: "Enables Secure Boot.", + Computed: true, + }, + "uefi": schema.BoolAttribute{ + Description: "Enables UEFI boot.", + Computed: true, + }, + "video_model": schema.StringAttribute{ + Description: "Sets Graphic device model.", + Computed: true, + }, + "virtio_scsi": schema.BoolAttribute{ + Description: "Enables the use of VirtIO SCSI to provide block device access. By default instances use VirtIO Block.", + Computed: true, + }, + }, + }, + "checksum": schema.SingleNestedAttribute{ + Description: "Representation of an image checksum.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "algorithm": schema.StringAttribute{ + Description: "Algorithm for the checksum of the image data.", + Computed: true, + }, + "digest": schema.StringAttribute{ + Description: "Hexdigest of the checksum of the image data.", + Computed: true, + }, + }, + }, + "labels": schema.MapAttribute{ + Description: "Labels are key-value string pairs which can be attached to a resource container", + ElementType: types.StringType, + Computed: true, + }, + }, + } +} + +// // Read refreshes the Terraform state with the latest data. +func (r *imageDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + var model DataSourceModel + diags := req.Config.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + projectId := model.ProjectId.ValueString() + imageId := model.ImageId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "image_id", imageId) + + imageResp, err := r.client.GetImage(ctx, projectId, imageId).Execute() + if err != nil { + oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped + if ok && oapiErr.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading image", fmt.Sprintf("Calling API: %v", err)) + return + } + + // Map response body to schema + err = mapDataSourceFields(ctx, imageResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading image", fmt.Sprintf("Processing API payload: %v", err)) + return + } + // Set refreshed state + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "image read") +} + +func mapDataSourceFields(ctx context.Context, imageResp *iaas.Image, model *DataSourceModel) error { + if imageResp == nil { + return fmt.Errorf("response input is nil") + } + if model == nil { + return fmt.Errorf("model input is nil") + } + + var imageId string + if model.ImageId.ValueString() != "" { + imageId = model.ImageId.ValueString() + } else if imageResp.Id != nil { + imageId = *imageResp.Id + } else { + return fmt.Errorf("image id not present") + } + + idParts := []string{ + model.ProjectId.ValueString(), + imageId, + } + model.Id = types.StringValue( + strings.Join(idParts, core.Separator), + ) + + // Map config + var configModel = &configModel{} + var configObject basetypes.ObjectValue + diags := diag.Diagnostics{} + if imageResp.Config != nil { + configModel.BootMenu = types.BoolPointerValue(imageResp.Config.BootMenu) + configModel.CDROMBus = types.StringPointerValue(imageResp.Config.GetCdromBus()) + configModel.DiskBus = types.StringPointerValue(imageResp.Config.GetDiskBus()) + configModel.NICModel = types.StringPointerValue(imageResp.Config.GetNicModel()) + configModel.OperatingSystem = types.StringPointerValue(imageResp.Config.OperatingSystem) + configModel.OperatingSystemDistro = types.StringPointerValue(imageResp.Config.GetOperatingSystemDistro()) + configModel.OperatingSystemVersion = types.StringPointerValue(imageResp.Config.GetOperatingSystemVersion()) + configModel.RescueBus = types.StringPointerValue(imageResp.Config.GetRescueBus()) + configModel.RescueDevice = types.StringPointerValue(imageResp.Config.GetRescueDevice()) + configModel.SecureBoot = types.BoolPointerValue(imageResp.Config.SecureBoot) + configModel.UEFI = types.BoolPointerValue(imageResp.Config.Uefi) + configModel.VideoModel = types.StringPointerValue(imageResp.Config.GetVideoModel()) + configModel.VirtioScsi = types.BoolPointerValue(imageResp.Config.VirtioScsi) + + configObject, diags = types.ObjectValue(configTypes, map[string]attr.Value{ + "boot_menu": configModel.BootMenu, + "cdrom_bus": configModel.CDROMBus, + "disk_bus": configModel.DiskBus, + "nic_model": configModel.NICModel, + "operating_system": configModel.OperatingSystem, + "operating_system_distro": configModel.OperatingSystemDistro, + "operating_system_version": configModel.OperatingSystemVersion, + "rescue_bus": configModel.RescueBus, + "rescue_device": configModel.RescueDevice, + "secure_boot": configModel.SecureBoot, + "uefi": configModel.UEFI, + "video_model": configModel.VideoModel, + "virtio_scsi": configModel.VirtioScsi, + }) + } else { + configObject = types.ObjectNull(configTypes) + } + if diags.HasError() { + return fmt.Errorf("creating config: %w", core.DiagsToError(diags)) + } + + // Map checksum + var checksumModel = &checksumModel{} + var checksumObject basetypes.ObjectValue + if imageResp.Checksum != nil { + checksumModel.Algorithm = types.StringPointerValue(imageResp.Checksum.Algorithm) + checksumModel.Digest = types.StringPointerValue(imageResp.Checksum.Digest) + checksumObject, diags = types.ObjectValue(checksumTypes, map[string]attr.Value{ + "algorithm": checksumModel.Algorithm, + "digest": checksumModel.Digest, + }) + } else { + checksumObject = types.ObjectNull(checksumTypes) + } + if diags.HasError() { + return fmt.Errorf("creating checksum: %w", core.DiagsToError(diags)) + } + + // Map labels + labels, diags := types.MapValueFrom(ctx, types.StringType, map[string]interface{}{}) + if diags.HasError() { + return fmt.Errorf("convert labels to StringValue map: %w", core.DiagsToError(diags)) + } + if imageResp.Labels != nil && len(*imageResp.Labels) != 0 { + var diags diag.Diagnostics + labels, diags = types.MapValueFrom(ctx, types.StringType, *imageResp.Labels) + if diags.HasError() { + return fmt.Errorf("convert labels to StringValue map: %w", core.DiagsToError(diags)) + } + } else if model.Labels.IsNull() { + labels = types.MapNull(types.StringType) + } + + model.ImageId = types.StringValue(imageId) + model.Name = types.StringPointerValue(imageResp.Name) + model.DiskFormat = types.StringPointerValue(imageResp.DiskFormat) + model.MinDiskSize = types.Int64PointerValue(imageResp.MinDiskSize) + model.MinRAM = types.Int64PointerValue(imageResp.MinRam) + model.Protected = types.BoolPointerValue(imageResp.Protected) + model.Scope = types.StringPointerValue(imageResp.Scope) + model.Labels = labels + model.Config = configObject + model.Checksum = checksumObject + return nil +} diff --git a/stackit/internal/services/iaas/image/datasource_test.go b/stackit/internal/services/iaas/image/datasource_test.go new file mode 100644 index 00000000..a16120ac --- /dev/null +++ b/stackit/internal/services/iaas/image/datasource_test.go @@ -0,0 +1,163 @@ +package image + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +func TestMapDataSourceFields(t *testing.T) { + tests := []struct { + description string + state DataSourceModel + input *iaas.Image + expected DataSourceModel + isValid bool + }{ + { + "default_values", + DataSourceModel{ + ProjectId: types.StringValue("pid"), + ImageId: types.StringValue("iid"), + }, + &iaas.Image{ + Id: utils.Ptr("iid"), + }, + DataSourceModel{ + Id: types.StringValue("pid,iid"), + ProjectId: types.StringValue("pid"), + ImageId: types.StringValue("iid"), + Labels: types.MapNull(types.StringType), + }, + true, + }, + { + "simple_values", + DataSourceModel{ + ProjectId: types.StringValue("pid"), + ImageId: types.StringValue("iid"), + }, + &iaas.Image{ + Id: utils.Ptr("iid"), + Name: utils.Ptr("name"), + DiskFormat: utils.Ptr("format"), + MinDiskSize: utils.Ptr(int64(1)), + MinRam: utils.Ptr(int64(1)), + Protected: utils.Ptr(true), + Scope: utils.Ptr("scope"), + Config: &iaas.ImageConfig{ + BootMenu: utils.Ptr(true), + CdromBus: iaas.NewNullableString(utils.Ptr("cdrom_bus")), + DiskBus: iaas.NewNullableString(utils.Ptr("disk_bus")), + NicModel: iaas.NewNullableString(utils.Ptr("model")), + OperatingSystem: utils.Ptr("os"), + OperatingSystemDistro: iaas.NewNullableString(utils.Ptr("os_distro")), + OperatingSystemVersion: iaas.NewNullableString(utils.Ptr("os_version")), + RescueBus: iaas.NewNullableString(utils.Ptr("rescue_bus")), + RescueDevice: iaas.NewNullableString(utils.Ptr("rescue_device")), + SecureBoot: utils.Ptr(true), + Uefi: utils.Ptr(true), + VideoModel: iaas.NewNullableString(utils.Ptr("model")), + VirtioScsi: utils.Ptr(true), + }, + Checksum: &iaas.ImageChecksum{ + Algorithm: utils.Ptr("algorithm"), + Digest: utils.Ptr("digest"), + }, + Labels: &map[string]interface{}{ + "key": "value", + }, + }, + DataSourceModel{ + Id: types.StringValue("pid,iid"), + ProjectId: types.StringValue("pid"), + ImageId: types.StringValue("iid"), + Name: types.StringValue("name"), + DiskFormat: types.StringValue("format"), + MinDiskSize: types.Int64Value(1), + MinRAM: types.Int64Value(1), + Protected: types.BoolValue(true), + Scope: types.StringValue("scope"), + Config: types.ObjectValueMust(configTypes, map[string]attr.Value{ + "boot_menu": types.BoolValue(true), + "cdrom_bus": types.StringValue("cdrom_bus"), + "disk_bus": types.StringValue("disk_bus"), + "nic_model": types.StringValue("model"), + "operating_system": types.StringValue("os"), + "operating_system_distro": types.StringValue("os_distro"), + "operating_system_version": types.StringValue("os_version"), + "rescue_bus": types.StringValue("rescue_bus"), + "rescue_device": types.StringValue("rescue_device"), + "secure_boot": types.BoolValue(true), + "uefi": types.BoolValue(true), + "video_model": types.StringValue("model"), + "virtio_scsi": types.BoolValue(true), + }), + Checksum: types.ObjectValueMust(checksumTypes, map[string]attr.Value{ + "algorithm": types.StringValue("algorithm"), + "digest": types.StringValue("digest"), + }), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), + }, + true, + }, + { + "empty_labels", + DataSourceModel{ + ProjectId: types.StringValue("pid"), + ImageId: types.StringValue("iid"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{}), + }, + &iaas.Image{ + Id: utils.Ptr("iid"), + }, + DataSourceModel{ + Id: types.StringValue("pid,iid"), + ProjectId: types.StringValue("pid"), + ImageId: types.StringValue("iid"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{}), + }, + true, + }, + { + "response_nil_fail", + DataSourceModel{}, + nil, + DataSourceModel{}, + false, + }, + { + "no_resource_id", + DataSourceModel{ + ProjectId: types.StringValue("pid"), + }, + &iaas.Image{}, + DataSourceModel{}, + false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := mapDataSourceFields(context.Background(), tt.input, &tt.state) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(tt.state, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} diff --git a/stackit/internal/services/iaas/image/resource.go b/stackit/internal/services/iaas/image/resource.go new file mode 100644 index 00000000..425b563f --- /dev/null +++ b/stackit/internal/services/iaas/image/resource.go @@ -0,0 +1,859 @@ +package image + +import ( + "bytes" + "context" + "fmt" + "net/http" + "os" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/objectplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-sdk-go/services/iaas/wait" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" +) + +// resourceBetaCheckDone is used to prevent multiple checks for beta resources. +// This is a workaround for the lack of a global state in the provider and +// needs to exist because the Configure method is called twice. +var resourceBetaCheckDone bool + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ resource.Resource = &imageResource{} + _ resource.ResourceWithConfigure = &imageResource{} + _ resource.ResourceWithImportState = &imageResource{} +) + +type Model struct { + Id types.String `tfsdk:"id"` // needed by TF + ProjectId types.String `tfsdk:"project_id"` + ImageId types.String `tfsdk:"image_id"` + Name types.String `tfsdk:"name"` + DiskFormat types.String `tfsdk:"disk_format"` + MinDiskSize types.Int64 `tfsdk:"min_disk_size"` + MinRAM types.Int64 `tfsdk:"min_ram"` + Protected types.Bool `tfsdk:"protected"` + Scope types.String `tfsdk:"scope"` + Config types.Object `tfsdk:"config"` + Checksum types.Object `tfsdk:"checksum"` + Labels types.Map `tfsdk:"labels"` + LocalFilePath types.String `tfsdk:"local_file_path"` +} + +// Struct corresponding to Model.Config +type configModel struct { + BootMenu types.Bool `tfsdk:"boot_menu"` + CDROMBus types.String `tfsdk:"cdrom_bus"` + DiskBus types.String `tfsdk:"disk_bus"` + NICModel types.String `tfsdk:"nic_model"` + OperatingSystem types.String `tfsdk:"operating_system"` + OperatingSystemDistro types.String `tfsdk:"operating_system_distro"` + OperatingSystemVersion types.String `tfsdk:"operating_system_version"` + RescueBus types.String `tfsdk:"rescue_bus"` + RescueDevice types.String `tfsdk:"rescue_device"` + SecureBoot types.Bool `tfsdk:"secure_boot"` + UEFI types.Bool `tfsdk:"uefi"` + VideoModel types.String `tfsdk:"video_model"` + VirtioScsi types.Bool `tfsdk:"virtio_scsi"` +} + +// Types corresponding to configModel +var configTypes = map[string]attr.Type{ + "boot_menu": basetypes.BoolType{}, + "cdrom_bus": basetypes.StringType{}, + "disk_bus": basetypes.StringType{}, + "nic_model": basetypes.StringType{}, + "operating_system": basetypes.StringType{}, + "operating_system_distro": basetypes.StringType{}, + "operating_system_version": basetypes.StringType{}, + "rescue_bus": basetypes.StringType{}, + "rescue_device": basetypes.StringType{}, + "secure_boot": basetypes.BoolType{}, + "uefi": basetypes.BoolType{}, + "video_model": basetypes.StringType{}, + "virtio_scsi": basetypes.BoolType{}, +} + +// Struct corresponding to Model.Checksum +type checksumModel struct { + Algorithm types.String `tfsdk:"algorithm"` + Digest types.String `tfsdk:"digest"` +} + +// Types corresponding to checksumModel +var checksumTypes = map[string]attr.Type{ + "algorithm": basetypes.StringType{}, + "digest": basetypes.StringType{}, +} + +// NewImageResource is a helper function to simplify the provider implementation. +func NewImageResource() resource.Resource { + return &imageResource{} +} + +// imageResource is the resource implementation. +type imageResource struct { + client *iaas.APIClient +} + +// Metadata returns the resource type name. +func (r *imageResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_image" +} + +// Configure adds the provider configured client to the resource. +func (r *imageResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + providerData, ok := req.ProviderData.(core.ProviderData) + if !ok { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", req.ProviderData)) + return + } + + if !resourceBetaCheckDone { + features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_image", "resource") + if resp.Diagnostics.HasError() { + return + } + resourceBetaCheckDone = true + } + + var apiClient *iaas.APIClient + var err error + if providerData.IaaSCustomEndpoint != "" { + ctx = tflog.SetField(ctx, "iaas_custom_endpoint", providerData.IaaSCustomEndpoint) + apiClient, err = iaas.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithEndpoint(providerData.IaaSCustomEndpoint), + ) + } else { + apiClient, err = iaas.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithRegion(providerData.Region), + ) + } + + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the resource configuration", err)) + return + } + + r.client = apiClient + tflog.Info(ctx, "iaas client configured") +} + +// Schema defines the schema for the resource. +func (r *imageResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Image resource schema. Must have a `region` specified in the provider configuration.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "Terraform's internal resource ID. It is structured as \"`project_id`,`image_id`\".", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "project_id": schema.StringAttribute{ + Description: "STACKIT project ID to which the image is associated.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "image_id": schema.StringAttribute{ + Description: "The image ID.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "name": schema.StringAttribute{ + Description: "The name of the image.", + Required: true, + }, + "disk_format": schema.StringAttribute{ + Description: "The disk format of the image.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "local_file_path": schema.StringAttribute{ + Description: "The filepath of the raw image file to be uploaded.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + // Validating that the file exists in the plan is useful to avoid + // creating an image resource where the local image upload will fail + validate.FileExists(), + }, + }, + "min_disk_size": schema.Int64Attribute{ + Description: "The minimum disk size of the image in GB.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + }, + }, + "min_ram": schema.Int64Attribute{ + Description: "The minimum RAM of the image in MB.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + }, + }, + "protected": schema.BoolAttribute{ + Description: "Whether the image is protected.", + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + "scope": schema.StringAttribute{ + Description: "The scope of the image.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "config": schema.SingleNestedAttribute{ + Description: "Properties to set hardware and scheduling settings for an image.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Object{ + objectplanmodifier.UseStateForUnknown(), + }, + Attributes: map[string]schema.Attribute{ + "boot_menu": schema.BoolAttribute{ + Description: "Enables the BIOS bootmenu.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + "cdrom_bus": schema.StringAttribute{ + Description: "Sets CDROM bus controller type.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "disk_bus": schema.StringAttribute{ + Description: "Sets Disk bus controller type.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "nic_model": schema.StringAttribute{ + Description: "Sets virtual network interface model.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "operating_system": schema.StringAttribute{ + Description: "Enables operating system specific optimizations.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "operating_system_distro": schema.StringAttribute{ + Description: "Operating system distribution.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "operating_system_version": schema.StringAttribute{ + Description: "Version of the operating system.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "rescue_bus": schema.StringAttribute{ + Description: "Sets the device bus when the image is used as a rescue image.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "rescue_device": schema.StringAttribute{ + Description: "Sets the device when the image is used as a rescue image.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "secure_boot": schema.BoolAttribute{ + Description: "Enables Secure Boot.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + "uefi": schema.BoolAttribute{ + Description: "Enables UEFI boot.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + "video_model": schema.StringAttribute{ + Description: "Sets Graphic device model.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "virtio_scsi": schema.BoolAttribute{ + Description: "Enables the use of VirtIO SCSI to provide block device access. By default instances use VirtIO Block.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + }, + }, + "checksum": schema.SingleNestedAttribute{ + Description: "Representation of an image checksum.", + Computed: true, + PlanModifiers: []planmodifier.Object{ + objectplanmodifier.UseStateForUnknown(), + }, + Attributes: map[string]schema.Attribute{ + "algorithm": schema.StringAttribute{ + Description: "Algorithm for the checksum of the image data.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "digest": schema.StringAttribute{ + Description: "Hexdigest of the checksum of the image data.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + }, + }, + "labels": schema.MapAttribute{ + Description: "Labels are key-value string pairs which can be attached to a resource container", + ElementType: types.StringType, + Optional: true, + }, + }, + } +} + +// Create creates the resource and sets the initial Terraform state. +func (r *imageResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve values from plan + var model Model + diags := req.Plan.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + projectId := model.ProjectId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + + // Generate API request body from model + payload, err := toCreatePayload(ctx, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating image", fmt.Sprintf("Creating API payload: %v", err)) + return + } + + // Create new image + imageCreateResp, err := r.client.CreateImage(ctx, projectId).CreateImagePayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating image", fmt.Sprintf("Calling API: %v", err)) + return + } + ctx = tflog.SetField(ctx, "image_id", *imageCreateResp.Id) + + // Get the image object, as the create response does not contain all fields + image, err := r.client.GetImage(ctx, projectId, *imageCreateResp.Id).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating image", fmt.Sprintf("Calling API: %v", err)) + return + } + + // Map response body to schema + err = mapFields(ctx, image, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating image", fmt.Sprintf("Processing API payload: %v", err)) + return + } + + // Set state to partially populated data + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Upload image + err = uploadImage(ctx, &resp.Diagnostics, model.LocalFilePath.ValueString(), *imageCreateResp.UploadUrl) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating image", fmt.Sprintf("Uploading image: %v", err)) + return + } + + // Wait for image to become available + waitResp, err := wait.UploadImageWaitHandler(ctx, r.client, projectId, *imageCreateResp.Id).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating image", fmt.Sprintf("Waiting for image to become available: %v", err)) + return + } + + // Map response body to schema + err = mapFields(ctx, waitResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating image", fmt.Sprintf("Processing API payload: %v", err)) + return + } + + // Set state to fully populated data + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Image created") +} + +// // Read refreshes the Terraform state with the latest data. +func (r *imageResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + diags := req.State.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + projectId := model.ProjectId.ValueString() + imageId := model.ImageId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "image_id", imageId) + + imageResp, err := r.client.GetImage(ctx, projectId, imageId).Execute() + if err != nil { + oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped + if ok && oapiErr.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading image", fmt.Sprintf("Calling API: %v", err)) + return + } + + // Map response body to schema + err = mapFields(ctx, imageResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading image", fmt.Sprintf("Processing API payload: %v", err)) + return + } + // Set refreshed state + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Image read") +} + +// Update updates the resource and sets the updated Terraform state on success. +func (r *imageResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve values from plan + var model Model + diags := req.Plan.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + projectId := model.ProjectId.ValueString() + imageId := model.ImageId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "image_id", imageId) + + // Retrieve values from state + var stateModel Model + diags = req.State.Get(ctx, &stateModel) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Generate API request body from model + payload, err := toUpdatePayload(ctx, &model, stateModel.Labels) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating image", fmt.Sprintf("Creating API payload: %v", err)) + return + } + // Update existing image + updatedImage, err := r.client.UpdateImage(ctx, projectId, imageId).UpdateImagePayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating image", fmt.Sprintf("Calling API: %v", err)) + return + } + + err = mapFields(ctx, updatedImage, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating image", fmt.Sprintf("Processing API payload: %v", err)) + return + } + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Image updated") +} + +// Delete deletes the resource and removes the Terraform state on success. +func (r *imageResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve values from state + var model Model + diags := req.State.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + projectId := model.ProjectId.ValueString() + imageId := model.ImageId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "image_id", imageId) + + // Delete existing image + err := r.client.DeleteImage(ctx, projectId, imageId).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting image", fmt.Sprintf("Calling API: %v", err)) + return + } + _, err = wait.DeleteImageWaitHandler(ctx, r.client, projectId, imageId).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting image", fmt.Sprintf("image deletion waiting: %v", err)) + return + } + + tflog.Info(ctx, "Image deleted") +} + +// ImportState imports a resource into the Terraform state on success. +// The expected format of the resource import identifier is: project_id,image_id +func (r *imageResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + idParts := strings.Split(req.ID, core.Separator) + + if len(idParts) != 2 || idParts[0] == "" || idParts[1] == "" { + core.LogAndAddError(ctx, &resp.Diagnostics, + "Error importing image", + fmt.Sprintf("Expected import identifier with format: [project_id],[image_id] Got: %q", req.ID), + ) + return + } + + projectId := idParts[0] + imageId := idParts[1] + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "image_id", imageId) + + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), projectId)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("image_id"), imageId)...) + tflog.Info(ctx, "Image state imported") +} + +func mapFields(ctx context.Context, imageResp *iaas.Image, model *Model) error { + if imageResp == nil { + return fmt.Errorf("response input is nil") + } + if model == nil { + return fmt.Errorf("model input is nil") + } + + var imageId string + if model.ImageId.ValueString() != "" { + imageId = model.ImageId.ValueString() + } else if imageResp.Id != nil { + imageId = *imageResp.Id + } else { + return fmt.Errorf("image id not present") + } + + idParts := []string{ + model.ProjectId.ValueString(), + imageId, + } + model.Id = types.StringValue( + strings.Join(idParts, core.Separator), + ) + + // Map config + var configModel = &configModel{} + var configObject basetypes.ObjectValue + diags := diag.Diagnostics{} + if imageResp.Config != nil { + configModel.BootMenu = types.BoolPointerValue(imageResp.Config.BootMenu) + configModel.CDROMBus = types.StringPointerValue(imageResp.Config.GetCdromBus()) + configModel.DiskBus = types.StringPointerValue(imageResp.Config.GetDiskBus()) + configModel.NICModel = types.StringPointerValue(imageResp.Config.GetNicModel()) + configModel.OperatingSystem = types.StringPointerValue(imageResp.Config.OperatingSystem) + configModel.OperatingSystemDistro = types.StringPointerValue(imageResp.Config.GetOperatingSystemDistro()) + configModel.OperatingSystemVersion = types.StringPointerValue(imageResp.Config.GetOperatingSystemVersion()) + configModel.RescueBus = types.StringPointerValue(imageResp.Config.GetRescueBus()) + configModel.RescueDevice = types.StringPointerValue(imageResp.Config.GetRescueDevice()) + configModel.SecureBoot = types.BoolPointerValue(imageResp.Config.SecureBoot) + configModel.UEFI = types.BoolPointerValue(imageResp.Config.Uefi) + configModel.VideoModel = types.StringPointerValue(imageResp.Config.GetVideoModel()) + configModel.VirtioScsi = types.BoolPointerValue(imageResp.Config.VirtioScsi) + + configObject, diags = types.ObjectValue(configTypes, map[string]attr.Value{ + "boot_menu": configModel.BootMenu, + "cdrom_bus": configModel.CDROMBus, + "disk_bus": configModel.DiskBus, + "nic_model": configModel.NICModel, + "operating_system": configModel.OperatingSystem, + "operating_system_distro": configModel.OperatingSystemDistro, + "operating_system_version": configModel.OperatingSystemVersion, + "rescue_bus": configModel.RescueBus, + "rescue_device": configModel.RescueDevice, + "secure_boot": configModel.SecureBoot, + "uefi": configModel.UEFI, + "video_model": configModel.VideoModel, + "virtio_scsi": configModel.VirtioScsi, + }) + } else { + configObject = types.ObjectNull(configTypes) + } + if diags.HasError() { + return fmt.Errorf("creating config: %w", core.DiagsToError(diags)) + } + + // Map checksum + var checksumModel = &checksumModel{} + var checksumObject basetypes.ObjectValue + if imageResp.Checksum != nil { + checksumModel.Algorithm = types.StringPointerValue(imageResp.Checksum.Algorithm) + checksumModel.Digest = types.StringPointerValue(imageResp.Checksum.Digest) + checksumObject, diags = types.ObjectValue(checksumTypes, map[string]attr.Value{ + "algorithm": checksumModel.Algorithm, + "digest": checksumModel.Digest, + }) + } else { + checksumObject = types.ObjectNull(checksumTypes) + } + if diags.HasError() { + return fmt.Errorf("creating checksum: %w", core.DiagsToError(diags)) + } + + // Map labels + labels, diags := types.MapValueFrom(ctx, types.StringType, map[string]interface{}{}) + if diags.HasError() { + return fmt.Errorf("convert labels to StringValue map: %w", core.DiagsToError(diags)) + } + if imageResp.Labels != nil && len(*imageResp.Labels) != 0 { + var diags diag.Diagnostics + labels, diags = types.MapValueFrom(ctx, types.StringType, *imageResp.Labels) + if diags.HasError() { + return fmt.Errorf("convert labels to StringValue map: %w", core.DiagsToError(diags)) + } + } else if model.Labels.IsNull() { + labels = types.MapNull(types.StringType) + } + + model.ImageId = types.StringValue(imageId) + model.Name = types.StringPointerValue(imageResp.Name) + model.DiskFormat = types.StringPointerValue(imageResp.DiskFormat) + model.MinDiskSize = types.Int64PointerValue(imageResp.MinDiskSize) + model.MinRAM = types.Int64PointerValue(imageResp.MinRam) + model.Protected = types.BoolPointerValue(imageResp.Protected) + model.Scope = types.StringPointerValue(imageResp.Scope) + model.Labels = labels + model.Config = configObject + model.Checksum = checksumObject + return nil +} + +func toCreatePayload(ctx context.Context, model *Model) (*iaas.CreateImagePayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + + var configModel = &configModel{} + if !(model.Config.IsNull() || model.Config.IsUnknown()) { + diags := model.Config.As(ctx, configModel, basetypes.ObjectAsOptions{}) + if diags.HasError() { + return nil, fmt.Errorf("convert boot volume object to struct: %w", core.DiagsToError(diags)) + } + } + + configPayload := &iaas.ImageConfig{ + BootMenu: conversion.BoolValueToPointer(configModel.BootMenu), + CdromBus: iaas.NewNullableString(conversion.StringValueToPointer(configModel.CDROMBus)), + DiskBus: iaas.NewNullableString(conversion.StringValueToPointer(configModel.DiskBus)), + NicModel: iaas.NewNullableString(conversion.StringValueToPointer(configModel.NICModel)), + OperatingSystem: conversion.StringValueToPointer(configModel.OperatingSystem), + OperatingSystemDistro: iaas.NewNullableString(conversion.StringValueToPointer(configModel.OperatingSystemDistro)), + OperatingSystemVersion: iaas.NewNullableString(conversion.StringValueToPointer(configModel.OperatingSystemVersion)), + RescueBus: iaas.NewNullableString(conversion.StringValueToPointer(configModel.RescueBus)), + RescueDevice: iaas.NewNullableString(conversion.StringValueToPointer(configModel.RescueDevice)), + SecureBoot: conversion.BoolValueToPointer(configModel.SecureBoot), + Uefi: conversion.BoolValueToPointer(configModel.UEFI), + VideoModel: iaas.NewNullableString(conversion.StringValueToPointer(configModel.VideoModel)), + VirtioScsi: conversion.BoolValueToPointer(configModel.VirtioScsi), + } + + labels, err := conversion.ToStringInterfaceMap(ctx, model.Labels) + if err != nil { + return nil, fmt.Errorf("converting to Go map: %w", err) + } + + return &iaas.CreateImagePayload{ + Name: conversion.StringValueToPointer(model.Name), + DiskFormat: conversion.StringValueToPointer(model.DiskFormat), + MinDiskSize: conversion.Int64ValueToPointer(model.MinDiskSize), + MinRam: conversion.Int64ValueToPointer(model.MinRAM), + Protected: conversion.BoolValueToPointer(model.Protected), + Config: configPayload, + Labels: &labels, + }, nil +} + +func toUpdatePayload(ctx context.Context, model *Model, currentLabels types.Map) (*iaas.UpdateImagePayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + + var configModel = &configModel{} + if !(model.Config.IsNull() || model.Config.IsUnknown()) { + diags := model.Config.As(ctx, configModel, basetypes.ObjectAsOptions{}) + if diags.HasError() { + return nil, fmt.Errorf("convert boot volume object to struct: %w", core.DiagsToError(diags)) + } + } + + configPayload := &iaas.ImageConfig{ + BootMenu: conversion.BoolValueToPointer(configModel.BootMenu), + CdromBus: iaas.NewNullableString(conversion.StringValueToPointer(configModel.CDROMBus)), + DiskBus: iaas.NewNullableString(conversion.StringValueToPointer(configModel.DiskBus)), + NicModel: iaas.NewNullableString(conversion.StringValueToPointer(configModel.NICModel)), + OperatingSystem: conversion.StringValueToPointer(configModel.OperatingSystem), + OperatingSystemDistro: iaas.NewNullableString(conversion.StringValueToPointer(configModel.OperatingSystemDistro)), + OperatingSystemVersion: iaas.NewNullableString(conversion.StringValueToPointer(configModel.OperatingSystemVersion)), + RescueBus: iaas.NewNullableString(conversion.StringValueToPointer(configModel.RescueBus)), + RescueDevice: iaas.NewNullableString(conversion.StringValueToPointer(configModel.RescueDevice)), + SecureBoot: conversion.BoolValueToPointer(configModel.SecureBoot), + Uefi: conversion.BoolValueToPointer(configModel.UEFI), + VideoModel: iaas.NewNullableString(conversion.StringValueToPointer(configModel.VideoModel)), + VirtioScsi: conversion.BoolValueToPointer(configModel.VirtioScsi), + } + + labels, err := conversion.ToJSONMapPartialUpdatePayload(ctx, currentLabels, model.Labels) + if err != nil { + return nil, fmt.Errorf("converting to go map: %w", err) + } + + // DiskFormat is not sent in the update payload as does not have effect after image upload, + // and the field has RequiresReplace set + return &iaas.UpdateImagePayload{ + Name: conversion.StringValueToPointer(model.Name), + MinDiskSize: conversion.Int64ValueToPointer(model.MinDiskSize), + MinRam: conversion.Int64ValueToPointer(model.MinRAM), + Protected: conversion.BoolValueToPointer(model.Protected), + Config: configPayload, + Labels: &labels, + }, nil +} + +func uploadImage(ctx context.Context, diags *diag.Diagnostics, filePath, uploadURL string) error { + if filePath == "" { + return fmt.Errorf("file path is empty") + } + if uploadURL == "" { + return fmt.Errorf("upload URL is empty") + } + + fileContents, err := os.ReadFile(filePath) + if err != nil { + return fmt.Errorf("read file: %w", err) + } + + req, err := http.NewRequest(http.MethodPut, uploadURL, bytes.NewReader(fileContents)) + if err != nil { + return fmt.Errorf("create upload request: %w", err) + } + req.Header.Set("Content-Type", "application/octet-stream") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("upload image: %w", err) + } + defer func() { + err = resp.Body.Close() + if err != nil { + core.LogAndAddError(ctx, diags, "Error uploading image", fmt.Sprintf("Closing response body: %v", err)) + } + }() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("upload image: %s", resp.Status) + } + + return nil +} diff --git a/stackit/internal/services/iaas/image/resource_test.go b/stackit/internal/services/iaas/image/resource_test.go new file mode 100644 index 00000000..23b894df --- /dev/null +++ b/stackit/internal/services/iaas/image/resource_test.go @@ -0,0 +1,391 @@ +package image + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +func TestMapFields(t *testing.T) { + tests := []struct { + description string + state Model + input *iaas.Image + expected Model + isValid bool + }{ + { + "default_values", + Model{ + ProjectId: types.StringValue("pid"), + ImageId: types.StringValue("iid"), + }, + &iaas.Image{ + Id: utils.Ptr("iid"), + }, + Model{ + Id: types.StringValue("pid,iid"), + ProjectId: types.StringValue("pid"), + ImageId: types.StringValue("iid"), + Labels: types.MapNull(types.StringType), + }, + true, + }, + { + "simple_values", + Model{ + ProjectId: types.StringValue("pid"), + ImageId: types.StringValue("iid"), + }, + &iaas.Image{ + Id: utils.Ptr("iid"), + Name: utils.Ptr("name"), + DiskFormat: utils.Ptr("format"), + MinDiskSize: utils.Ptr(int64(1)), + MinRam: utils.Ptr(int64(1)), + Protected: utils.Ptr(true), + Scope: utils.Ptr("scope"), + Config: &iaas.ImageConfig{ + BootMenu: utils.Ptr(true), + CdromBus: iaas.NewNullableString(utils.Ptr("cdrom_bus")), + DiskBus: iaas.NewNullableString(utils.Ptr("disk_bus")), + NicModel: iaas.NewNullableString(utils.Ptr("model")), + OperatingSystem: utils.Ptr("os"), + OperatingSystemDistro: iaas.NewNullableString(utils.Ptr("os_distro")), + OperatingSystemVersion: iaas.NewNullableString(utils.Ptr("os_version")), + RescueBus: iaas.NewNullableString(utils.Ptr("rescue_bus")), + RescueDevice: iaas.NewNullableString(utils.Ptr("rescue_device")), + SecureBoot: utils.Ptr(true), + Uefi: utils.Ptr(true), + VideoModel: iaas.NewNullableString(utils.Ptr("model")), + VirtioScsi: utils.Ptr(true), + }, + Checksum: &iaas.ImageChecksum{ + Algorithm: utils.Ptr("algorithm"), + Digest: utils.Ptr("digest"), + }, + Labels: &map[string]interface{}{ + "key": "value", + }, + }, + Model{ + Id: types.StringValue("pid,iid"), + ProjectId: types.StringValue("pid"), + ImageId: types.StringValue("iid"), + Name: types.StringValue("name"), + DiskFormat: types.StringValue("format"), + MinDiskSize: types.Int64Value(1), + MinRAM: types.Int64Value(1), + Protected: types.BoolValue(true), + Scope: types.StringValue("scope"), + Config: types.ObjectValueMust(configTypes, map[string]attr.Value{ + "boot_menu": types.BoolValue(true), + "cdrom_bus": types.StringValue("cdrom_bus"), + "disk_bus": types.StringValue("disk_bus"), + "nic_model": types.StringValue("model"), + "operating_system": types.StringValue("os"), + "operating_system_distro": types.StringValue("os_distro"), + "operating_system_version": types.StringValue("os_version"), + "rescue_bus": types.StringValue("rescue_bus"), + "rescue_device": types.StringValue("rescue_device"), + "secure_boot": types.BoolValue(true), + "uefi": types.BoolValue(true), + "video_model": types.StringValue("model"), + "virtio_scsi": types.BoolValue(true), + }), + Checksum: types.ObjectValueMust(checksumTypes, map[string]attr.Value{ + "algorithm": types.StringValue("algorithm"), + "digest": types.StringValue("digest"), + }), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), + }, + true, + }, + { + "empty_labels", + Model{ + ProjectId: types.StringValue("pid"), + ImageId: types.StringValue("iid"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{}), + }, + &iaas.Image{ + Id: utils.Ptr("iid"), + }, + Model{ + Id: types.StringValue("pid,iid"), + ProjectId: types.StringValue("pid"), + ImageId: types.StringValue("iid"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{}), + }, + true, + }, + { + "response_nil_fail", + Model{}, + nil, + Model{}, + false, + }, + { + "no_resource_id", + Model{ + ProjectId: types.StringValue("pid"), + }, + &iaas.Image{}, + Model{}, + false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := mapFields(context.Background(), tt.input, &tt.state) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(tt.state, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} + +func TestToCreatePayload(t *testing.T) { + tests := []struct { + description string + input *Model + expected *iaas.CreateImagePayload + isValid bool + }{ + { + "ok", + &Model{ + Id: types.StringValue("pid,iid"), + ProjectId: types.StringValue("pid"), + ImageId: types.StringValue("iid"), + Name: types.StringValue("name"), + DiskFormat: types.StringValue("format"), + MinDiskSize: types.Int64Value(1), + MinRAM: types.Int64Value(1), + Protected: types.BoolValue(true), + Config: types.ObjectValueMust(configTypes, map[string]attr.Value{ + "boot_menu": types.BoolValue(true), + "cdrom_bus": types.StringValue("cdrom_bus"), + "disk_bus": types.StringValue("disk_bus"), + "nic_model": types.StringValue("nic_model"), + "operating_system": types.StringValue("os"), + "operating_system_distro": types.StringValue("os_distro"), + "operating_system_version": types.StringValue("os_version"), + "rescue_bus": types.StringValue("rescue_bus"), + "rescue_device": types.StringValue("rescue_device"), + "secure_boot": types.BoolValue(true), + "uefi": types.BoolValue(true), + "video_model": types.StringValue("video_model"), + "virtio_scsi": types.BoolValue(true), + }), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), + }, + &iaas.CreateImagePayload{ + Name: utils.Ptr("name"), + DiskFormat: utils.Ptr("format"), + MinDiskSize: utils.Ptr(int64(1)), + MinRam: utils.Ptr(int64(1)), + Protected: utils.Ptr(true), + Config: &iaas.ImageConfig{ + BootMenu: utils.Ptr(true), + CdromBus: iaas.NewNullableString(utils.Ptr("cdrom_bus")), + DiskBus: iaas.NewNullableString(utils.Ptr("disk_bus")), + NicModel: iaas.NewNullableString(utils.Ptr("nic_model")), + OperatingSystem: utils.Ptr("os"), + OperatingSystemDistro: iaas.NewNullableString(utils.Ptr("os_distro")), + OperatingSystemVersion: iaas.NewNullableString(utils.Ptr("os_version")), + RescueBus: iaas.NewNullableString(utils.Ptr("rescue_bus")), + RescueDevice: iaas.NewNullableString(utils.Ptr("rescue_device")), + SecureBoot: utils.Ptr(true), + Uefi: utils.Ptr(true), + VideoModel: iaas.NewNullableString(utils.Ptr("video_model")), + VirtioScsi: utils.Ptr(true), + }, + Labels: &map[string]interface{}{ + "key": "value", + }, + }, + true, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output, err := toCreatePayload(context.Background(), tt.input) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(output, tt.expected, cmp.AllowUnexported(iaas.NullableString{})) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} + +func TestToUpdatePayload(t *testing.T) { + tests := []struct { + description string + input *Model + expected *iaas.UpdateImagePayload + isValid bool + }{ + { + "default_ok", + &Model{ + Id: types.StringValue("pid,iid"), + ProjectId: types.StringValue("pid"), + ImageId: types.StringValue("iid"), + Name: types.StringValue("name"), + DiskFormat: types.StringValue("format"), + MinDiskSize: types.Int64Value(1), + MinRAM: types.Int64Value(1), + Protected: types.BoolValue(true), + Config: types.ObjectValueMust(configTypes, map[string]attr.Value{ + "boot_menu": types.BoolValue(true), + "cdrom_bus": types.StringValue("cdrom_bus"), + "disk_bus": types.StringValue("disk_bus"), + "nic_model": types.StringValue("nic_model"), + "operating_system": types.StringValue("os"), + "operating_system_distro": types.StringValue("os_distro"), + "operating_system_version": types.StringValue("os_version"), + "rescue_bus": types.StringValue("rescue_bus"), + "rescue_device": types.StringValue("rescue_device"), + "secure_boot": types.BoolValue(true), + "uefi": types.BoolValue(true), + "video_model": types.StringValue("video_model"), + "virtio_scsi": types.BoolValue(true), + }), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), + }, + &iaas.UpdateImagePayload{ + Name: utils.Ptr("name"), + MinDiskSize: utils.Ptr(int64(1)), + MinRam: utils.Ptr(int64(1)), + Protected: utils.Ptr(true), + Config: &iaas.ImageConfig{ + BootMenu: utils.Ptr(true), + CdromBus: iaas.NewNullableString(utils.Ptr("cdrom_bus")), + DiskBus: iaas.NewNullableString(utils.Ptr("disk_bus")), + NicModel: iaas.NewNullableString(utils.Ptr("nic_model")), + OperatingSystem: utils.Ptr("os"), + OperatingSystemDistro: iaas.NewNullableString(utils.Ptr("os_distro")), + OperatingSystemVersion: iaas.NewNullableString(utils.Ptr("os_version")), + RescueBus: iaas.NewNullableString(utils.Ptr("rescue_bus")), + RescueDevice: iaas.NewNullableString(utils.Ptr("rescue_device")), + SecureBoot: utils.Ptr(true), + Uefi: utils.Ptr(true), + VideoModel: iaas.NewNullableString(utils.Ptr("video_model")), + VirtioScsi: utils.Ptr(true), + }, + Labels: &map[string]interface{}{ + "key": "value", + }, + }, + true, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output, err := toUpdatePayload(context.Background(), tt.input, types.MapNull(types.StringType)) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(output, tt.expected, cmp.AllowUnexported(iaas.NullableString{})) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} + +func Test_UploadImage(t *testing.T) { + tests := []struct { + name string + filePath string + uploadFails bool + wantErr bool + }{ + { + name: "ok", + filePath: "testdata/mock-image.txt", + uploadFails: false, + wantErr: false, + }, + { + name: "upload_fails", + filePath: "testdata/mock-image.txt", + uploadFails: true, + wantErr: true, + }, + { + name: "file_not_found", + filePath: "testdata/non-existing-file.txt", + uploadFails: false, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Setup a test server + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if tt.uploadFails { + w.WriteHeader(http.StatusInternalServerError) + _, _ = fmt.Fprintln(w, `{"status":"some error occurred"}`) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = fmt.Fprintln(w, `{"status":"ok"}`) + }) + server := httptest.NewServer(handler) + defer server.Close() + uploadURL, err := url.Parse(server.URL) + if err != nil { + t.Error(err) + return + } + + // Call the function + err = uploadImage(context.Background(), &diag.Diagnostics{}, tt.filePath, uploadURL.String()) + if (err != nil) != tt.wantErr { + t.Errorf("uploadImage() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/stackit/internal/services/iaas/image/testdata/mock-image.txt b/stackit/internal/services/iaas/image/testdata/mock-image.txt new file mode 100644 index 00000000..eaa3529c --- /dev/null +++ b/stackit/internal/services/iaas/image/testdata/mock-image.txt @@ -0,0 +1 @@ +I am a mock image file \ No newline at end of file diff --git a/stackit/internal/testutil/testutil.go b/stackit/internal/testutil/testutil.go index 0b0786c7..a5ddf321 100644 --- a/stackit/internal/testutil/testutil.go +++ b/stackit/internal/testutil/testutil.go @@ -46,6 +46,8 @@ var ( TestProjectServiceAccountEmail = os.Getenv("TF_ACC_TEST_PROJECT_SERVICE_ACCOUNT_EMAIL") // TestProjectUserEmail is the e-mail of a user for the project created as part of the resource-manager acceptance tests TestProjectUserEmail = os.Getenv("TF_ACC_TEST_PROJECT_USER_EMAIL") + // TestImageLocalFilePath is the local path to an image file used for image acceptance tests + TestImageLocalFilePath = os.Getenv("TF_ACC_TEST_IMAGE_LOCAL_FILE_PATH") ArgusCustomEndpoint = os.Getenv("TF_ACC_ARGUS_CUSTOM_ENDPOINT") DnsCustomEndpoint = os.Getenv("TF_ACC_DNS_CUSTOM_ENDPOINT") diff --git a/stackit/internal/validate/testdata/file.txt b/stackit/internal/validate/testdata/file.txt new file mode 100644 index 00000000..996393d8 --- /dev/null +++ b/stackit/internal/validate/testdata/file.txt @@ -0,0 +1 @@ +I am a test file \ No newline at end of file diff --git a/stackit/internal/validate/validate.go b/stackit/internal/validate/validate.go index 3633c6b5..7dd1a286 100644 --- a/stackit/internal/validate/validate.go +++ b/stackit/internal/validate/validate.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net" + "os" "regexp" "strings" "time" @@ -272,3 +273,21 @@ func Rrule() *Validator { }, } } + +func FileExists() *Validator { + description := "file must exist" + + return &Validator{ + description: description, + validate: func(_ context.Context, req validator.StringRequest, resp *validator.StringResponse) { + _, err := os.Stat(req.ConfigValue.ValueString()) + if err != nil { + resp.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic( + req.Path, + description, + req.ConfigValue.ValueString(), + )) + } + }, + } +} diff --git a/stackit/internal/validate/validate_test.go b/stackit/internal/validate/validate_test.go index 584adf2e..428799a4 100644 --- a/stackit/internal/validate/validate_test.go +++ b/stackit/internal/validate/validate_test.go @@ -682,3 +682,42 @@ func TestRrule(t *testing.T) { }) } } + +func TestFileExists(t *testing.T) { + tests := []struct { + description string + input string + isValid bool + }{ + { + "ok", + "testdata/file.txt", + true, + }, + { + "not ok", + "testdata/non-existing-file.txt", + false, + }, + { + "empty", + "", + false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + r := validator.StringResponse{} + FileExists().ValidateString(context.Background(), validator.StringRequest{ + ConfigValue: types.StringValue(tt.input), + }, &r) + + if !tt.isValid && !r.Diagnostics.HasError() { + t.Fatalf("Should have failed") + } + if tt.isValid && r.Diagnostics.HasError() { + t.Fatalf("Should not have failed: %v", r.Diagnostics.Errors()) + } + }) + } +} diff --git a/stackit/provider.go b/stackit/provider.go index 4b26cf31..cfe4d1ac 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -14,6 +14,7 @@ import ( argusScrapeConfig "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/argus/scrapeconfig" dnsRecordSet "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/dns/recordset" dnsZone "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/dns/zone" + iaasImage "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/image" iaasKeyPair "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/keypair" iaasNetwork "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/network" iaasNetworkArea "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/networkarea" @@ -412,6 +413,7 @@ func (p *Provider) DataSources(_ context.Context) []func() datasource.DataSource argusScrapeConfig.NewScrapeConfigDataSource, dnsZone.NewZoneDataSource, dnsRecordSet.NewRecordSetDataSource, + iaasImage.NewImageDataSource, iaasNetwork.NewNetworkDataSource, iaasNetworkArea.NewNetworkAreaDataSource, iaasNetworkAreaRoute.NewNetworkAreaRouteDataSource, @@ -465,6 +467,7 @@ func (p *Provider) Resources(_ context.Context) []func() resource.Resource { argusScrapeConfig.NewScrapeConfigResource, dnsZone.NewZoneResource, dnsRecordSet.NewRecordSetResource, + iaasImage.NewImageResource, iaasNetwork.NewNetworkResource, iaasNetworkArea.NewNetworkAreaResource, iaasNetworkAreaRoute.NewNetworkAreaRouteResource,