Implement new stackit_image resource and datasource (#609)

* feat: Implement image resource and datasource

* feat: Add remaining config options

* feat: Make protected field only computed

* feat: Update dependency to use IaaS beta API

* fix: Minor fix in acc test

---------

Co-authored-by: Vicente Pinto <vicente.pinto@freiheit.com>
This commit is contained in:
João Palet 2025-01-09 11:57:25 +00:00 committed by GitHub
parent 7fcebacb21
commit 700bdc90d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 2212 additions and 4 deletions

View file

@ -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 generated by tfplugindocs -->
## 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.
<a id="nestedatt--checksum"></a>
### 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.
<a id="nestedatt--config"></a>
### 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.

77
docs/resources/image.md Normal file
View file

@ -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 generated by tfplugindocs -->
## 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.
<a id="nestedatt--config"></a>
### 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.
<a id="nestedatt--checksum"></a>
### 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.

View file

@ -0,0 +1,4 @@
data "stackit_image" "example" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
image_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}
}
})
}
}

View file

@ -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
}

View file

@ -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)
}
})
}
}

View file

@ -0,0 +1 @@
I am a mock image file

View file

@ -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")

View file

@ -0,0 +1 @@
I am a test file

View file

@ -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(),
))
}
},
}
}

View file

@ -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())
}
})
}
}

View file

@ -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,