diff --git a/docs/data-sources/image_v2.md b/docs/data-sources/image_v2.md new file mode 100644 index 00000000..12ad0f02 --- /dev/null +++ b/docs/data-sources/image_v2.md @@ -0,0 +1,150 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_image_v2 Data Source - stackit" +subcategory: "" +description: |- + Image datasource schema. Must have a region specified in the provider configuration. + ~> Important: When using the name, name_regex, or filter attributes to select images dynamically, be aware that image IDs may change frequently. Each OS patch or update results in a new unique image ID. If this data source is used to populate fields like boot_volume.source_id in a server resource, it may cause Terraform to detect changes and recreate the associated resource. + To avoid unintended updates or resource replacements: + Prefer using a static image_id to pin a specific image version.If you accept automatic image updates but wish to suppress resource changes, use a lifecycle block to ignore relevant changes. For example: + + resource "stackit_server" "example" { + boot_volume = { + size = 64 + source_type = "image" + source_id = data.stackit_image.latest.id + } + + lifecycle { + ignore_changes = [boot_volume[0].source_id] + } + } + + ~> This datasource 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_v2 (Data Source) + +Image datasource schema. Must have a `region` specified in the provider configuration. + +~> Important: When using the `name`, `name_regex`, or `filter` attributes to select images dynamically, be aware that image IDs may change frequently. Each OS patch or update results in a new unique image ID. If this data source is used to populate fields like `boot_volume.source_id` in a server resource, it may cause Terraform to detect changes and recreate the associated resource. + +To avoid unintended updates or resource replacements: + - Prefer using a static `image_id` to pin a specific image version. + - If you accept automatic image updates but wish to suppress resource changes, use a `lifecycle` block to ignore relevant changes. For example: + +```hcl +resource "stackit_server" "example" { + boot_volume = { + size = 64 + source_type = "image" + source_id = data.stackit_image.latest.id + } + + lifecycle { + ignore_changes = [boot_volume[0].source_id] + } +} +``` + +~> This datasource 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_v2" "default" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + image_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} + +data "stackit_image_v2" "name_match" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "Ubuntu 22.04" +} + +data "stackit_image_v2" "name_regex_latest" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name_regex = "^Ubuntu .*" +} + +data "stackit_image_v2" "name_regex_oldest" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name_regex = "^Ubuntu .*" + sort_ascending = true +} + +data "stackit_image_v2" "filter_distro_version" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + filter = { + distro = "debian" + version = "11" + } +} +``` + + +## Schema + +### Required + +- `project_id` (String) STACKIT project ID to which the image is associated. + +### Optional + +- `filter` (Attributes) Additional filtering options based on image properties. Can be used independently or in conjunction with `name` or `name_regex`. (see [below for nested schema](#nestedatt--filter)) +- `image_id` (String) Image ID to fetch directly +- `name` (String) Exact image name to match. Optionally applies a `filter` block to further refine results in case multiple images share the same name. The first match is returned, optionally sorted by name in ascending order. Cannot be used together with `name_regex`. +- `name_regex` (String) Regular expression to match against image names. Optionally applies a `filter` block to narrow down results when multiple image names match the regex. The first match is returned, optionally sorted by name in ascending order. Cannot be used together with `name`. +- `sort_ascending` (Boolean) If set to `true`, images are sorted in ascending lexicographical order by image name (such as `Ubuntu 18.04`, `Ubuntu 20.04`, `Ubuntu 22.04`) before selecting the first match. Defaults to `false` (descending such as `Ubuntu 22.04`, `Ubuntu 20.04`, `Ubuntu 18.04`). + +### 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. +- `protected` (Boolean) Whether the image is protected. +- `scope` (String) The scope of the image. + + +### Nested Schema for `filter` + +Optional: + +- `distro` (String) Filter images by operating system distribution. For example: `ubuntu`, `ubuntu-arm64`, `debian`, `rhel`, etc. +- `os` (String) Filter images by operating system type, such as `linux` or `windows`. +- `secure_boot` (Boolean) Filter images with Secure Boot support. Set to `true` to match images that support Secure Boot. +- `uefi` (Boolean) Filter images based on UEFI support. Set to `true` to match images that support UEFI. +- `version` (String) Filter images by OS distribution version, such as `22.04`, `11`, or `9.1`. + + + +### 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/examples/data-sources/stackit_image_v2/data-source.tf b/examples/data-sources/stackit_image_v2/data-source.tf new file mode 100644 index 00000000..401488c4 --- /dev/null +++ b/examples/data-sources/stackit_image_v2/data-source.tf @@ -0,0 +1,28 @@ +data "stackit_image_v2" "default" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + image_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} + +data "stackit_image_v2" "name_match" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "Ubuntu 22.04" +} + +data "stackit_image_v2" "name_regex_latest" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name_regex = "^Ubuntu .*" +} + +data "stackit_image_v2" "name_regex_oldest" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name_regex = "^Ubuntu .*" + sort_ascending = true +} + +data "stackit_image_v2" "filter_distro_version" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + filter = { + distro = "debian" + version = "11" + } +} \ No newline at end of file diff --git a/stackit/internal/services/iaas/iaas_acc_test.go b/stackit/internal/services/iaas/iaas_acc_test.go index d0f91249..1ae62384 100644 --- a/stackit/internal/services/iaas/iaas_acc_test.go +++ b/stackit/internal/services/iaas/iaas_acc_test.go @@ -35,6 +35,9 @@ var ( //go:embed testdata/resource-security-group-max.tf resourceSecurityGroupMaxConfig string + //go:embed testdata/datasource-image-v2-variants.tf + dataSourceImageVariants string + //go:embed testdata/resource-image-min.tf resourceImageMinConfig string @@ -4029,6 +4032,133 @@ func TestAccImageMax(t *testing.T) { }) } +func TestAccImageV2DatasourceSearchVariants(t *testing.T) { + t.Log("TestDataSource Image V2 Variants") + resource.ParallelTest(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Creation + { + ConfigVariables: config.Variables{"project_id": config.StringVariable(testutil.ProjectId)}, + Config: fmt.Sprintf("%s\n%s", dataSourceImageVariants, testutil.IaaSProviderConfigWithBetaResourcesEnabled()), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("data.stackit_image_v2.name_match_ubuntu_22_04", "project_id", testutil.ConvertConfigVariable(testConfigImageVarsMax["project_id"])), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.name_match_ubuntu_22_04", "image_id"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.name_match_ubuntu_22_04", "name"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.name_match_ubuntu_22_04", "min_disk_size"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.name_match_ubuntu_22_04", "min_ram"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.name_match_ubuntu_22_04", "protected"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.name_match_ubuntu_22_04", "scope"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.name_match_ubuntu_22_04", "checksum.algorithm"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.name_match_ubuntu_22_04", "checksum.digest"), + + resource.TestCheckResourceAttr("data.stackit_image_v2.ubuntu_by_image_id", "project_id", testutil.ConvertConfigVariable(testConfigImageVarsMax["project_id"])), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.ubuntu_by_image_id", "image_id"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.ubuntu_by_image_id", "name"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.ubuntu_by_image_id", "min_disk_size"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.ubuntu_by_image_id", "min_ram"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.ubuntu_by_image_id", "protected"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.ubuntu_by_image_id", "scope"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.ubuntu_by_image_id", "checksum.algorithm"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.ubuntu_by_image_id", "checksum.digest"), + + resource.TestCheckResourceAttr("data.stackit_image_v2.regex_match_ubuntu_22_04", "project_id", testutil.ConvertConfigVariable(testConfigImageVarsMax["project_id"])), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.regex_match_ubuntu_22_04", "image_id"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.regex_match_ubuntu_22_04", "name"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.regex_match_ubuntu_22_04", "min_disk_size"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.regex_match_ubuntu_22_04", "min_ram"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.regex_match_ubuntu_22_04", "protected"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.regex_match_ubuntu_22_04", "scope"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.regex_match_ubuntu_22_04", "checksum.algorithm"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.regex_match_ubuntu_22_04", "checksum.digest"), + + resource.TestCheckResourceAttr("data.stackit_image_v2.filter_debian_11", "project_id", testutil.ConvertConfigVariable(testConfigImageVarsMax["project_id"])), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.filter_debian_11", "image_id"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.filter_debian_11", "name"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.filter_debian_11", "min_disk_size"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.filter_debian_11", "min_ram"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.filter_debian_11", "protected"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.filter_debian_11", "scope"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.filter_debian_11", "checksum.algorithm"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.filter_debian_11", "checksum.digest"), + + resource.TestCheckResourceAttr("data.stackit_image_v2.filter_uefi_ubuntu", "project_id", testutil.ConvertConfigVariable(testConfigImageVarsMax["project_id"])), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.filter_uefi_ubuntu", "image_id"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.filter_uefi_ubuntu", "name"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.filter_uefi_ubuntu", "min_disk_size"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.filter_uefi_ubuntu", "min_ram"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.filter_uefi_ubuntu", "protected"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.filter_uefi_ubuntu", "scope"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.filter_uefi_ubuntu", "checksum.algorithm"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.filter_uefi_ubuntu", "checksum.digest"), + + resource.TestCheckResourceAttr("data.stackit_image_v2.name_regex_and_filter_rhel_9_1", "project_id", testutil.ConvertConfigVariable(testConfigImageVarsMax["project_id"])), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.name_regex_and_filter_rhel_9_1", "image_id"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.name_regex_and_filter_rhel_9_1", "name"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.name_regex_and_filter_rhel_9_1", "min_disk_size"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.name_regex_and_filter_rhel_9_1", "min_ram"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.name_regex_and_filter_rhel_9_1", "protected"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.name_regex_and_filter_rhel_9_1", "scope"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.name_regex_and_filter_rhel_9_1", "checksum.algorithm"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.name_regex_and_filter_rhel_9_1", "checksum.digest"), + + resource.TestCheckResourceAttr("data.stackit_image_v2.name_windows_2022_standard", "project_id", testutil.ConvertConfigVariable(testConfigImageVarsMax["project_id"])), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.name_windows_2022_standard", "image_id"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.name_windows_2022_standard", "name"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.name_windows_2022_standard", "min_disk_size"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.name_windows_2022_standard", "min_ram"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.name_windows_2022_standard", "protected"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.name_windows_2022_standard", "scope"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.name_windows_2022_standard", "checksum.algorithm"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.name_windows_2022_standard", "checksum.digest"), + + resource.TestCheckResourceAttr("data.stackit_image_v2.ubuntu_arm64_latest", "project_id", testutil.ConvertConfigVariable(testConfigImageVarsMax["project_id"])), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.ubuntu_arm64_latest", "image_id"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.ubuntu_arm64_latest", "name"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.ubuntu_arm64_latest", "min_disk_size"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.ubuntu_arm64_latest", "min_ram"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.ubuntu_arm64_latest", "protected"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.ubuntu_arm64_latest", "scope"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.ubuntu_arm64_latest", "checksum.algorithm"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.ubuntu_arm64_latest", "checksum.digest"), + + resource.TestCheckResourceAttr("data.stackit_image_v2.ubuntu_arm64_oldest", "project_id", testutil.ConvertConfigVariable(testConfigImageVarsMax["project_id"])), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.ubuntu_arm64_oldest", "image_id"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.ubuntu_arm64_oldest", "name"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.ubuntu_arm64_oldest", "min_disk_size"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.ubuntu_arm64_oldest", "min_ram"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.ubuntu_arm64_oldest", "protected"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.ubuntu_arm64_oldest", "scope"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.ubuntu_arm64_oldest", "checksum.algorithm"), + resource.TestCheckResourceAttrSet("data.stackit_image_v2.ubuntu_arm64_oldest", "checksum.digest"), + + // e2e test that ascending sort is working + func(s *terraform.State) error { + latest := s.RootModule().Resources["data.stackit_image_v2.ubuntu_arm64_latest"] + oldest := s.RootModule().Resources["data.stackit_image_v2.ubuntu_arm64_oldest"] + + if latest == nil { + return fmt.Errorf("datasource 'data.stackit_image_v2.ubuntu_arm64_latest' not found") + } + if oldest == nil { + return fmt.Errorf("datasource 'data.stackit_image_v2.ubuntu_arm64_oldest' not found") + } + + nameLatest := latest.Primary.Attributes["name"] + nameOldest := oldest.Primary.Attributes["name"] + + if nameLatest == nameOldest { + return fmt.Errorf("expected image names to differ, but both are %q", nameLatest) + } + + return nil + }, + ), + }, + }, + }) +} + func TestAccProject(t *testing.T) { projectId := testutil.ProjectId resource.ParallelTest(t, resource.TestCase{ diff --git a/stackit/internal/services/iaas/imagev2/datasource.go b/stackit/internal/services/iaas/imagev2/datasource.go new file mode 100644 index 00000000..ab364bb0 --- /dev/null +++ b/stackit/internal/services/iaas/imagev2/datasource.go @@ -0,0 +1,627 @@ +package image + +import ( + "context" + "fmt" + "net/http" + "regexp" + "sort" + + "github.com/hashicorp/terraform-plugin-framework-validators/datasourcevalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "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/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" + + "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" + + iaasUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/utils" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ datasource.DataSource = &imageDataV2Source{} +) + +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"` + NameRegex types.String `tfsdk:"name_regex"` + SortAscending types.Bool `tfsdk:"sort_ascending"` + Filter types.Object `tfsdk:"filter"` + + 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"` +} + +type Filter struct { + OS types.String `tfsdk:"os"` + Distro types.String `tfsdk:"distro"` + Version types.String `tfsdk:"version"` + UEFI types.Bool `tfsdk:"uefi"` + SecureBoot types.Bool `tfsdk:"secure_boot"` +} + +// 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{}, +} + +// NewImageV2DataSource is a helper function to simplify the provider implementation. +func NewImageV2DataSource() datasource.DataSource { + return &imageDataV2Source{} +} + +// imageDataV2Source is the data source implementation. +type imageDataV2Source struct { + client *iaas.APIClient +} + +// Metadata returns the data source type name. +func (d *imageDataV2Source) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_image_v2" +} + +func (d *imageDataV2Source) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + providerData, ok := conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics) + if !ok { + return + } + + features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_image_v2", "datasource") + if resp.Diagnostics.HasError() { + return + } + + apiClient := iaasUtils.ConfigureClient(ctx, &providerData, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + d.client = apiClient + tflog.Info(ctx, "iaas client configured") +} + +func (d *imageDataV2Source) ConfigValidators(_ context.Context) []datasource.ConfigValidator { + return []datasource.ConfigValidator{ + datasourcevalidator.Conflicting( + path.MatchRoot("name"), + path.MatchRoot("name_regex"), + path.MatchRoot("image_id"), + ), + datasourcevalidator.AtLeastOneOf( + path.MatchRoot("name"), + path.MatchRoot("name_regex"), + path.MatchRoot("image_id"), + path.MatchRoot("filter"), + ), + } +} + +// Schema defines the schema for the datasource. +func (d *imageDataV2Source) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + description := features.AddBetaDescription(fmt.Sprintf( + "%s\n\n~> %s", + "Image datasource schema. Must have a `region` specified in the provider configuration.", + "Important: When using the `name`, `name_regex`, or `filter` attributes to select images dynamically, be aware that image IDs may change frequently. Each OS patch or update results in a new unique image ID. If this data source is used to populate fields like `boot_volume.source_id` in a server resource, it may cause Terraform to detect changes and recreate the associated resource.\n\n"+ + "To avoid unintended updates or resource replacements:\n"+ + " - Prefer using a static `image_id` to pin a specific image version.\n"+ + " - If you accept automatic image updates but wish to suppress resource changes, use a `lifecycle` block to ignore relevant changes. For example:\n\n"+ + "```hcl\n"+ + "resource \"stackit_server\" \"example\" {\n"+ + " boot_volume = {\n"+ + " size = 64\n"+ + " source_type = \"image\"\n"+ + " source_id = data.stackit_image.latest.id\n"+ + " }\n"+ + "\n"+ + " lifecycle {\n"+ + " ignore_changes = [boot_volume[0].source_id]\n"+ + " }\n"+ + "}\n"+ + "```", + ), core.Datasource) + resp.Schema = schema.Schema{ + MarkdownDescription: description, + Description: description, + 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: "Image ID to fetch directly", + Optional: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "name": schema.StringAttribute{ + Description: "Exact image name to match. Optionally applies a `filter` block to further refine results in case multiple images share the same name. The first match is returned, optionally sorted by name in ascending order. Cannot be used together with `name_regex`.", + Optional: true, + }, + "name_regex": schema.StringAttribute{ + Description: "Regular expression to match against image names. Optionally applies a `filter` block to narrow down results when multiple image names match the regex. The first match is returned, optionally sorted by name in ascending order. Cannot be used together with `name`.", + Optional: true, + }, + "sort_ascending": schema.BoolAttribute{ + Description: "If set to `true`, images are sorted in ascending lexicographical order by image name (such as `Ubuntu 18.04`, `Ubuntu 20.04`, `Ubuntu 22.04`) before selecting the first match. Defaults to `false` (descending such as `Ubuntu 22.04`, `Ubuntu 20.04`, `Ubuntu 18.04`).", + Optional: true, + }, + "filter": schema.SingleNestedAttribute{ + Optional: true, + Description: "Additional filtering options based on image properties. Can be used independently or in conjunction with `name` or `name_regex`.", + Attributes: map[string]schema.Attribute{ + "os": schema.StringAttribute{ + Optional: true, + Description: "Filter images by operating system type, such as `linux` or `windows`.", + }, + "distro": schema.StringAttribute{ + Optional: true, + Description: "Filter images by operating system distribution. For example: `ubuntu`, `ubuntu-arm64`, `debian`, `rhel`, etc.", + }, + "version": schema.StringAttribute{ + Optional: true, + Description: "Filter images by OS distribution version, such as `22.04`, `11`, or `9.1`.", + }, + "uefi": schema.BoolAttribute{ + Optional: true, + Description: "Filter images based on UEFI support. Set to `true` to match images that support UEFI.", + }, + "secure_boot": schema.BoolAttribute{ + Optional: true, + Description: "Filter images with Secure Boot support. Set to `true` to match images that support Secure Boot.", + }, + }, + }, + "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 (d *imageDataV2Source) 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() + name := model.Name.ValueString() + nameRegex := model.NameRegex.ValueString() + sortAscending := model.SortAscending.ValueBool() + + var filter Filter + if !model.Filter.IsNull() && !model.Filter.IsUnknown() { + if diagnostics := model.Filter.As(ctx, &filter, basetypes.ObjectAsOptions{}); diagnostics.HasError() { + resp.Diagnostics.Append(diagnostics...) + return + } + } + + ctx = tflog.SetField(ctx, "project_id", projectID) + ctx = tflog.SetField(ctx, "image_id", imageID) + ctx = tflog.SetField(ctx, "name", name) + ctx = tflog.SetField(ctx, "name_regex", nameRegex) + ctx = tflog.SetField(ctx, "sort_ascending", sortAscending) + + var imageResp *iaas.Image + var err error + + // Case 1: Direct lookup by image ID + if imageID != "" { + imageResp, err = d.client.GetImage(ctx, projectID, imageID).Execute() + if err != nil { + utils.LogError(ctx, &resp.Diagnostics, err, "Reading image", + fmt.Sprintf("Image with ID %q does not exist in project %q.", imageID, projectID), + map[int]string{ + http.StatusForbidden: fmt.Sprintf("Project with ID %q not found or forbidden access", projectID), + }) + resp.State.RemoveResource(ctx) + return + } + } else { + // Case 2: Lookup by name or name_regex + + // Compile regex + var compiledRegex *regexp.Regexp + if nameRegex != "" { + compiledRegex, err = regexp.Compile(nameRegex) + if err != nil { + core.LogAndAddWarning(ctx, &resp.Diagnostics, "Invalid name_regex", err.Error()) + return + } + } + + // Fetch all available images + imageList, err := d.client.ListImages(ctx, projectID).Execute() + if err != nil { + utils.LogError(ctx, &resp.Diagnostics, err, "List images", "Unable to fetch images", nil) + return + } + + // Step 1: Match images by name or regular expression (name or name_regex, if provided) + var matchedImages []*iaas.Image + for i := range *imageList.Items { + img := &(*imageList.Items)[i] + if name != "" && img.Name != nil && *img.Name == name { + matchedImages = append(matchedImages, img) + } + if compiledRegex != nil && img.Name != nil && compiledRegex.MatchString(*img.Name) { + matchedImages = append(matchedImages, img) + } + // If neither name nor name_regex is specified, include all images for filter evaluation later + if name == "" && nameRegex == "" { + matchedImages = append(matchedImages, img) + } + } + + // Step 2: Sort matched images by name (optional, based on sortAscending flag) + if len(matchedImages) > 1 { + sortImagesByName(matchedImages, sortAscending) + } + + // Step 3: Apply additional filtering based on OS, distro, version, UEFI, secure boot, etc. + var filteredImages []*iaas.Image + for _, img := range matchedImages { + if imageMatchesFilter(img, &filter) { + filteredImages = append(filteredImages, img) + } + } + + // Check if any images passed all filters; warn if no matching image was found + if len(filteredImages) == 0 { + core.LogAndAddWarning(ctx, &resp.Diagnostics, "No match", + "No matching image found using name, name_regex, and filter criteria.") + return + } + + // Step 4: Use the first image from the filtered and sorted result list + imageResp = filteredImages[0] + } + + 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") + } + + model.Id = utils.BuildInternalTerraformId(model.ProjectId.ValueString(), imageId) + + // 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(iaas.PtrBool(imageResp.Config.GetVirtioScsi())) + + 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, err := iaasUtils.MapLabels(ctx, imageResp.Labels, model.Labels) + if err != nil { + return err + } + + 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 +} + +// imageMatchesFilter checks whether a given image matches all specified filter conditions. +// It returns true only if all non-null fields in the filter match corresponding fields in the image's config. +func imageMatchesFilter(img *iaas.Image, filter *Filter) bool { + if filter == nil { + return true + } + + if img.Config == nil { + return false + } + + cfg := img.Config + + if !filter.OS.IsNull() && + (cfg.OperatingSystem == nil || filter.OS.ValueString() != *cfg.OperatingSystem) { + return false + } + + if !filter.Distro.IsNull() && + (cfg.OperatingSystemDistro == nil || cfg.OperatingSystemDistro.Get() == nil || + filter.Distro.ValueString() != *cfg.OperatingSystemDistro.Get()) { + return false + } + + if !filter.Version.IsNull() && + (cfg.OperatingSystemVersion == nil || cfg.OperatingSystemVersion.Get() == nil || + filter.Version.ValueString() != *cfg.OperatingSystemVersion.Get()) { + return false + } + + if !filter.UEFI.IsNull() && + (cfg.Uefi == nil || filter.UEFI.ValueBool() != *cfg.Uefi) { + return false + } + + if !filter.SecureBoot.IsNull() && + (cfg.SecureBoot == nil || filter.SecureBoot.ValueBool() != *cfg.SecureBoot) { + return false + } + + return true +} + +// sortImagesByName sorts a slice of images by name, respecting nils and order direction. +func sortImagesByName(images []*iaas.Image, sortAscending bool) { + if len(images) <= 1 { + return + } + + sort.SliceStable(images, func(i, j int) bool { + a, b := images[i].Name, images[j].Name + + switch { + case a == nil && b == nil: + return false // Equal + case a == nil: + return false // Nil goes after non-nil + case b == nil: + return true // Non-nil goes before nil + case sortAscending: + return *a < *b + default: + return *a > *b + } + }) +} diff --git a/stackit/internal/services/iaas/imagev2/datasource_test.go b/stackit/internal/services/iaas/imagev2/datasource_test.go new file mode 100644 index 00000000..56b9930b --- /dev/null +++ b/stackit/internal/services/iaas/imagev2/datasource_test.go @@ -0,0 +1,471 @@ +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) + } + } + }) + } +} + +func TestImageMatchesFilter(t *testing.T) { + testCases := []struct { + name string + img *iaas.Image + filter *Filter + expected bool + }{ + { + name: "nil filter - always match", + img: &iaas.Image{Config: &iaas.ImageConfig{}}, + filter: nil, + expected: true, + }, + { + name: "nil config - always false", + img: &iaas.Image{Config: nil}, + filter: &Filter{}, + expected: false, + }, + { + name: "all fields match", + img: &iaas.Image{ + Config: &iaas.ImageConfig{ + OperatingSystem: utils.Ptr("linux"), + OperatingSystemDistro: iaas.NewNullableString(utils.Ptr("ubuntu")), + OperatingSystemVersion: iaas.NewNullableString(utils.Ptr("22.04")), + Uefi: utils.Ptr(true), + SecureBoot: utils.Ptr(true), + }, + }, + filter: &Filter{ + OS: types.StringValue("linux"), + Distro: types.StringValue("ubuntu"), + Version: types.StringValue("22.04"), + UEFI: types.BoolValue(true), + SecureBoot: types.BoolValue(true), + }, + expected: true, + }, + { + name: "OS mismatch", + img: &iaas.Image{ + Config: &iaas.ImageConfig{ + OperatingSystem: utils.Ptr("windows"), + }, + }, + filter: &Filter{ + OS: types.StringValue("linux"), + }, + expected: false, + }, + { + name: "Distro mismatch", + img: &iaas.Image{ + Config: &iaas.ImageConfig{ + OperatingSystemDistro: iaas.NewNullableString(utils.Ptr("debian")), + }, + }, + filter: &Filter{ + Distro: types.StringValue("ubuntu"), + }, + expected: false, + }, + { + name: "Version mismatch", + img: &iaas.Image{ + Config: &iaas.ImageConfig{ + OperatingSystemVersion: iaas.NewNullableString(utils.Ptr("20.04")), + }, + }, + filter: &Filter{ + Version: types.StringValue("22.04"), + }, + expected: false, + }, + { + name: "UEFI mismatch", + img: &iaas.Image{ + Config: &iaas.ImageConfig{ + Uefi: utils.Ptr(false), + }, + }, + filter: &Filter{ + UEFI: types.BoolValue(true), + }, + expected: false, + }, + { + name: "SecureBoot mismatch", + img: &iaas.Image{ + Config: &iaas.ImageConfig{ + SecureBoot: utils.Ptr(false), + }, + }, + filter: &Filter{ + SecureBoot: types.BoolValue(true), + }, + expected: false, + }, + { + name: "SecureBoot match - true", + img: &iaas.Image{ + Config: &iaas.ImageConfig{ + SecureBoot: utils.Ptr(true), + }, + }, + filter: &Filter{ + SecureBoot: types.BoolValue(true), + }, + expected: true, + }, + { + name: "SecureBoot match - false", + img: &iaas.Image{ + Config: &iaas.ImageConfig{ + SecureBoot: utils.Ptr(false), + }, + }, + filter: &Filter{ + SecureBoot: types.BoolValue(false), + }, + expected: true, + }, + { + name: "SecureBoot field missing in image but required in filter", + img: &iaas.Image{ + Config: &iaas.ImageConfig{ + SecureBoot: nil, + }, + }, + filter: &Filter{ + SecureBoot: types.BoolValue(true), + }, + expected: false, + }, + { + name: "partial filter match - only distro set and match", + img: &iaas.Image{ + Config: &iaas.ImageConfig{ + OperatingSystemDistro: iaas.NewNullableString(utils.Ptr("ubuntu")), + }, + }, + filter: &Filter{ + Distro: types.StringValue("ubuntu"), + }, + expected: true, + }, + { + name: "partial filter match - distro mismatch", + img: &iaas.Image{ + Config: &iaas.ImageConfig{ + OperatingSystemDistro: iaas.NewNullableString(utils.Ptr("centos")), + }, + }, + filter: &Filter{ + Distro: types.StringValue("ubuntu"), + }, + expected: false, + }, + { + name: "filter provided but attribute is null in image", + img: &iaas.Image{ + Config: &iaas.ImageConfig{ + OperatingSystemDistro: nil, + }, + }, + filter: &Filter{ + Distro: types.StringValue("ubuntu"), + }, + expected: false, + }, + { + name: "image has valid config, but filter has null values", + img: &iaas.Image{ + Config: &iaas.ImageConfig{ + OperatingSystem: utils.Ptr("linux"), + OperatingSystemDistro: iaas.NewNullableString(utils.Ptr("ubuntu")), + OperatingSystemVersion: iaas.NewNullableString(utils.Ptr("22.04")), + Uefi: utils.Ptr(false), + SecureBoot: utils.Ptr(false), + }, + }, + filter: &Filter{ + OS: types.StringNull(), + Distro: types.StringNull(), + Version: types.StringNull(), + UEFI: types.BoolNull(), + SecureBoot: types.BoolNull(), + }, + expected: true, + }, + { + name: "image has nil fields in config, filter expects values", + img: &iaas.Image{ + Config: &iaas.ImageConfig{ + OperatingSystem: nil, + OperatingSystemDistro: nil, + OperatingSystemVersion: nil, + Uefi: nil, + SecureBoot: nil, + }, + }, + filter: &Filter{ + OS: types.StringValue("linux"), + Distro: types.StringValue("ubuntu"), + Version: types.StringValue("22.04"), + UEFI: types.BoolValue(true), + SecureBoot: types.BoolValue(true), + }, + expected: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := imageMatchesFilter(tc.img, tc.filter) + if result != tc.expected { + t.Errorf("Expected match = %v, got %v", tc.expected, result) + } + }) + } +} + +func TestSortImagesByName(t *testing.T) { + tests := []struct { + desc string + input []*iaas.Image + ascending bool + wantSorted []string + }{ + { + desc: "ascending by name", + ascending: true, + input: []*iaas.Image{ + {Name: utils.Ptr("Ubuntu 22.04")}, + {Name: utils.Ptr("Ubuntu 18.04")}, + {Name: utils.Ptr("Ubuntu 20.04")}, + }, + wantSorted: []string{"Ubuntu 18.04", "Ubuntu 20.04", "Ubuntu 22.04"}, + }, + { + desc: "descending by name", + ascending: false, + input: []*iaas.Image{ + {Name: utils.Ptr("Ubuntu 22.04")}, + {Name: utils.Ptr("Ubuntu 18.04")}, + {Name: utils.Ptr("Ubuntu 20.04")}, + }, + wantSorted: []string{"Ubuntu 22.04", "Ubuntu 20.04", "Ubuntu 18.04"}, + }, + { + desc: "nil names go last ascending", + ascending: true, + input: []*iaas.Image{ + {Name: nil}, + {Name: utils.Ptr("Ubuntu 18.04")}, + {Name: nil}, + {Name: utils.Ptr("Ubuntu 20.04")}, + }, + wantSorted: []string{"Ubuntu 18.04", "Ubuntu 20.04", "", ""}, + }, + { + desc: "nil names go last descending", + ascending: false, + input: []*iaas.Image{ + {Name: nil}, + {Name: utils.Ptr("Ubuntu 18.04")}, + {Name: utils.Ptr("Ubuntu 20.04")}, + {Name: nil}, + }, + wantSorted: []string{"Ubuntu 20.04", "Ubuntu 18.04", "", ""}, + }, + { + desc: "empty slice", + ascending: true, + input: []*iaas.Image{}, + wantSorted: []string{}, + }, + { + desc: "single element slice", + ascending: true, + input: []*iaas.Image{ + {Name: utils.Ptr("Ubuntu 22.04")}, + }, + wantSorted: []string{"Ubuntu 22.04"}, + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + sortImagesByName(tc.input, tc.ascending) + + gotNames := make([]string, len(tc.input)) + for i, img := range tc.input { + if img.Name == nil { + gotNames[i] = "" + } else { + gotNames[i] = *img.Name + } + } + + if diff := cmp.Diff(tc.wantSorted, gotNames); diff != "" { + t.Fatalf("incorrect sort order (-want +got):\n%s", diff) + } + }) + } +} diff --git a/stackit/internal/services/iaas/testdata/datasource-image-v2-variants.tf b/stackit/internal/services/iaas/testdata/datasource-image-v2-variants.tf new file mode 100644 index 00000000..379bae3f --- /dev/null +++ b/stackit/internal/services/iaas/testdata/datasource-image-v2-variants.tf @@ -0,0 +1,62 @@ +variable "project_id" {} + +data "stackit_image_v2" "name_match_ubuntu_22_04" { + project_id = var.project_id + name = "Ubuntu 22.04" +} + +data "stackit_image_v2" "ubuntu_by_image_id" { + project_id = var.project_id + image_id = data.stackit_image_v2.name_match_ubuntu_22_04.image_id +} + +data "stackit_image_v2" "regex_match_ubuntu_22_04" { + project_id = var.project_id + name_regex = "(?i)^ubuntu 22.04$" +} + +data "stackit_image_v2" "filter_debian_11" { + project_id = var.project_id + filter = { + distro = "debian" + version = "11" + } +} + +data "stackit_image_v2" "filter_uefi_ubuntu" { + project_id = var.project_id + filter = { + distro = "ubuntu" + uefi = true + } +} + +data "stackit_image_v2" "name_regex_and_filter_rhel_9_1" { + project_id = var.project_id + name_regex = "^Red Hat Enterprise Linux 9.1$" + filter = { + distro = "rhel" + version = "9.1" + uefi = true + } +} + +data "stackit_image_v2" "name_windows_2022_standard" { + project_id = var.project_id + name = "Windows Server 2022 Standard" +} + +data "stackit_image_v2" "ubuntu_arm64_latest" { + project_id = var.project_id + filter = { + distro = "ubuntu-arm64" + } +} + +data "stackit_image_v2" "ubuntu_arm64_oldest" { + project_id = var.project_id + filter = { + distro = "ubuntu-arm64" + } + sort_ascending = true +} \ No newline at end of file diff --git a/stackit/internal/testutil/testutil.go b/stackit/internal/testutil/testutil.go index 4c1d46a6..aee5ee68 100644 --- a/stackit/internal/testutil/testutil.go +++ b/stackit/internal/testutil/testutil.go @@ -38,9 +38,6 @@ var ( Region = os.Getenv("TF_ACC_REGION") // ServerId is the id of a server used for some tests ServerId = getenv("TF_ACC_SERVER_ID", "") - // IaaSImageId is the id of an image used for IaaS acceptance tests. - // Default image is ubuntu 24.04 - IaaSImageId = getenv("TF_ACC_IMAGE_ID", "59838a89-51b1-4892-b57f-b3caf598ee2f") // TestProjectParentContainerID is the container id of the parent resource under which projects are created as part of the resource-manager acceptance tests TestProjectParentContainerID = os.Getenv("TF_ACC_TEST_PROJECT_PARENT_CONTAINER_ID") // TestProjectParentUUID is the uuid of the parent resource under which projects are created as part of the resource-manager acceptance tests @@ -532,8 +529,7 @@ func getenv(key, defaultValue string) string { return val } -// helper for local_file_path -// no real data is created +// CreateDefaultLocalFile is a helper for local_file_path. No real data is created func CreateDefaultLocalFile() os.File { // Define the file name and size fileName := "test-512k.img" diff --git a/stackit/provider.go b/stackit/provider.go index 698af5c8..c3ac8f33 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -26,6 +26,7 @@ import ( gitInstance "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/git/instance" iaasAffinityGroup "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/affinitygroup" iaasImage "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/image" + iaasImageV2 "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/imagev2" iaasKeyPair "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/keypair" machineType "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/machinetype" iaasNetwork "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/network" @@ -457,6 +458,7 @@ func (p *Provider) DataSources(_ context.Context) []func() datasource.DataSource gitInstance.NewGitDataSource, iaasAffinityGroup.NewAffinityGroupDatasource, iaasImage.NewImageDataSource, + iaasImageV2.NewImageV2DataSource, iaasNetwork.NewNetworkDataSource, iaasNetworkArea.NewNetworkAreaDataSource, iaasNetworkAreaRoute.NewNetworkAreaRouteDataSource,