From 5d69b3cc0c5e56dd7055c1b6e6e6adf5df7df422 Mon Sep 17 00:00:00 2001 From: Kamran Biglari Date: Fri, 5 Jun 2026 00:12:22 +0100 Subject: [PATCH] feat(pricing): add hcloud_pricing data source Add a new hcloud_pricing data source exposing the prices for the resources offered by the Hetzner Cloud (images, floating IPs, primary IPs, server backups, server types, load balancer types and volumes). The Pricing API does not support server side filtering, so the full response is fetched and filtered locally via the optional location, server_type and load_balancer_type attributes. --- docs/data-sources/pricing.md | 268 ++++++++ .../hcloud_pricing/data-source.tf | 5 + hcloud/plugin_provider.go | 2 + internal/pricing/data_source.go | 264 ++++++++ internal/pricing/data_source_test.go | 47 ++ internal/pricing/model.go | 634 ++++++++++++++++++ internal/pricing/testing.go | 17 + internal/testdata/d/hcloud_pricing.tf.tmpl | 3 + 8 files changed, 1240 insertions(+) create mode 100644 docs/data-sources/pricing.md create mode 100644 examples/data-sources/hcloud_pricing/data-source.tf create mode 100644 internal/pricing/data_source.go create mode 100644 internal/pricing/data_source_test.go create mode 100644 internal/pricing/model.go create mode 100644 internal/pricing/testing.go create mode 100644 internal/testdata/d/hcloud_pricing.tf.tmpl diff --git a/docs/data-sources/pricing.md b/docs/data-sources/pricing.md new file mode 100644 index 000000000..be9aa124b --- /dev/null +++ b/docs/data-sources/pricing.md @@ -0,0 +1,268 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "hcloud_pricing Data Source - hcloud" +subcategory: "" +description: |- + Provides prices for the resources offered by the Hetzner Cloud. + The prices are returned for the Hetzner Cloud project the API token used to configure the provider belongs to. + See the Pricing API documentation https://docs.hetzner.cloud/reference/cloud#pricing for more details. +--- + +# hcloud_pricing (Data Source) + +Provides prices for the resources offered by the Hetzner Cloud. + +The prices are returned for the Hetzner Cloud project the API token used to configure the provider belongs to. + +See the [Pricing API documentation](https://docs.hetzner.cloud/reference/cloud#pricing) for more details. + +## Example Usage + +```terraform +data "hcloud_pricing" "prices" {} + +output "monthly_traffic_price_per_tb" { + value = data.hcloud_pricing.prices.server_types[0].prices[0].per_tb_traffic.gross +} +``` + + +## Schema + +### Optional + +- `load_balancer_type` (String) Only return prices for the Load Balancer Type with this name (e.g. `lb11`). +- `location` (String) Only return per-location prices for the location with this name (e.g. `fsn1`). +- `server_type` (String) Only return prices for the Server Type with this name (e.g. `cx22`). + +### Read-Only + +- `currency` (String) Currency all prices are denominated in. +- `floating_ips` (Attributes List) Prices of the Floating IPs, grouped by type. (see [below for nested schema](#nestedatt--floating_ips)) +- `image` (Attributes) Price of the disk space used by Images (Snapshots and Backups). (see [below for nested schema](#nestedatt--image)) +- `load_balancer_types` (Attributes List) Prices of the Load Balancer Types, grouped by type. (see [below for nested schema](#nestedatt--load_balancer_types)) +- `primary_ips` (Attributes List) Prices of the Primary IPs, grouped by type. (see [below for nested schema](#nestedatt--primary_ips)) +- `server_backup` (Attributes) Will increase base server costs by specific percentage if server backups are enabled. (see [below for nested schema](#nestedatt--server_backup)) +- `server_types` (Attributes List) Prices of the Server Types, grouped by type. (see [below for nested schema](#nestedatt--server_types)) +- `vat_rate` (String) VAT rate in percent used to calculate the gross prices from the net prices. +- `volume` (Attributes) Price of the disk space used by Volumes. (see [below for nested schema](#nestedatt--volume)) + + +### Nested Schema for `floating_ips` + +Read-Only: + +- `prices` (Attributes List) Prices of the Floating IP type, per location. (see [below for nested schema](#nestedatt--floating_ips--prices)) +- `type` (String) Type of the Floating IP (`ipv4` or `ipv6`). + + +### Nested Schema for `floating_ips.prices` + +Read-Only: + +- `location` (String) Name of the location the price applies to. +- `monthly` (Attributes) Monthly costs for a Floating IP type in this location. (see [below for nested schema](#nestedatt--floating_ips--prices--monthly)) + + +### Nested Schema for `floating_ips.prices.monthly` + +Read-Only: + +- `currency` (String) Currency the price is denominated in. +- `gross` (String) Price with VAT added. +- `net` (String) Price without VAT. +- `vat_rate` (String) VAT rate in percent used to calculate the gross price from the net price. + + + + + +### Nested Schema for `image` + +Read-Only: + +- `per_gb_month` (Attributes) Price per GB and month. (see [below for nested schema](#nestedatt--image--per_gb_month)) + + +### Nested Schema for `image.per_gb_month` + +Read-Only: + +- `currency` (String) Currency the price is denominated in. +- `gross` (String) Price with VAT added. +- `net` (String) Price without VAT. +- `vat_rate` (String) VAT rate in percent used to calculate the gross price from the net price. + + + + +### Nested Schema for `load_balancer_types` + +Read-Only: + +- `id` (Number) ID of the Load Balancer Type. +- `name` (String) Name of the Load Balancer Type. +- `prices` (Attributes List) Prices of the Load Balancer Type, per location. (see [below for nested schema](#nestedatt--load_balancer_types--prices)) + + +### Nested Schema for `load_balancer_types.prices` + +Read-Only: + +- `hourly` (Attributes) Hourly costs for a Load Balancer Type in this location. (see [below for nested schema](#nestedatt--load_balancer_types--prices--hourly)) +- `included_traffic` (Number) Free traffic per month in bytes. +- `location` (String) Name of the location the price applies to. +- `monthly` (Attributes) Monthly costs for a Load Balancer Type in this location. (see [below for nested schema](#nestedatt--load_balancer_types--prices--monthly)) +- `per_tb_traffic` (Attributes) Costs per additional TB of traffic in this location. (see [below for nested schema](#nestedatt--load_balancer_types--prices--per_tb_traffic)) + + +### Nested Schema for `load_balancer_types.prices.hourly` + +Read-Only: + +- `currency` (String) Currency the price is denominated in. +- `gross` (String) Price with VAT added. +- `net` (String) Price without VAT. +- `vat_rate` (String) VAT rate in percent used to calculate the gross price from the net price. + + + +### Nested Schema for `load_balancer_types.prices.monthly` + +Read-Only: + +- `currency` (String) Currency the price is denominated in. +- `gross` (String) Price with VAT added. +- `net` (String) Price without VAT. +- `vat_rate` (String) VAT rate in percent used to calculate the gross price from the net price. + + + +### Nested Schema for `load_balancer_types.prices.per_tb_traffic` + +Read-Only: + +- `currency` (String) Currency the price is denominated in. +- `gross` (String) Price with VAT added. +- `net` (String) Price without VAT. +- `vat_rate` (String) VAT rate in percent used to calculate the gross price from the net price. + + + + + +### Nested Schema for `primary_ips` + +Read-Only: + +- `prices` (Attributes List) Prices of the Primary IP type, per location. (see [below for nested schema](#nestedatt--primary_ips--prices)) +- `type` (String) Type of the Primary IP (`ipv4` or `ipv6`). + + +### Nested Schema for `primary_ips.prices` + +Read-Only: + +- `hourly` (Attributes) Hourly costs for a Primary IP type in this location. (see [below for nested schema](#nestedatt--primary_ips--prices--hourly)) +- `location` (String) Name of the location the price applies to. +- `monthly` (Attributes) Monthly costs for a Primary IP type in this location. (see [below for nested schema](#nestedatt--primary_ips--prices--monthly)) + + +### Nested Schema for `primary_ips.prices.hourly` + +Read-Only: + +- `gross` (String) Price with VAT added. +- `net` (String) Price without VAT. + + + +### Nested Schema for `primary_ips.prices.monthly` + +Read-Only: + +- `gross` (String) Price with VAT added. +- `net` (String) Price without VAT. + + + + + +### Nested Schema for `server_backup` + +Read-Only: + +- `percentage` (String) Percentage by which the base server costs increase if server backups are enabled. + + + +### Nested Schema for `server_types` + +Read-Only: + +- `id` (Number) ID of the Server Type. +- `name` (String) Name of the Server Type. +- `prices` (Attributes List) Prices of the Server Type, per location. (see [below for nested schema](#nestedatt--server_types--prices)) + + +### Nested Schema for `server_types.prices` + +Read-Only: + +- `hourly` (Attributes) Hourly costs for a Server Type in this location. (see [below for nested schema](#nestedatt--server_types--prices--hourly)) +- `included_traffic` (Number) Free traffic per month in bytes. +- `location` (String) Name of the location the price applies to. +- `monthly` (Attributes) Monthly costs for a Server Type in this location. (see [below for nested schema](#nestedatt--server_types--prices--monthly)) +- `per_tb_traffic` (Attributes) Costs per additional TB of traffic in this location. (see [below for nested schema](#nestedatt--server_types--prices--per_tb_traffic)) + + +### Nested Schema for `server_types.prices.hourly` + +Read-Only: + +- `currency` (String) Currency the price is denominated in. +- `gross` (String) Price with VAT added. +- `net` (String) Price without VAT. +- `vat_rate` (String) VAT rate in percent used to calculate the gross price from the net price. + + + +### Nested Schema for `server_types.prices.monthly` + +Read-Only: + +- `currency` (String) Currency the price is denominated in. +- `gross` (String) Price with VAT added. +- `net` (String) Price without VAT. +- `vat_rate` (String) VAT rate in percent used to calculate the gross price from the net price. + + + +### Nested Schema for `server_types.prices.per_tb_traffic` + +Read-Only: + +- `currency` (String) Currency the price is denominated in. +- `gross` (String) Price with VAT added. +- `net` (String) Price without VAT. +- `vat_rate` (String) VAT rate in percent used to calculate the gross price from the net price. + + + + + +### Nested Schema for `volume` + +Read-Only: + +- `per_gb_month` (Attributes) Price per GB and month. (see [below for nested schema](#nestedatt--volume--per_gb_month)) + + +### Nested Schema for `volume.per_gb_month` + +Read-Only: + +- `currency` (String) Currency the price is denominated in. +- `gross` (String) Price with VAT added. +- `net` (String) Price without VAT. +- `vat_rate` (String) VAT rate in percent used to calculate the gross price from the net price. diff --git a/examples/data-sources/hcloud_pricing/data-source.tf b/examples/data-sources/hcloud_pricing/data-source.tf new file mode 100644 index 000000000..ec0269659 --- /dev/null +++ b/examples/data-sources/hcloud_pricing/data-source.tf @@ -0,0 +1,5 @@ +data "hcloud_pricing" "prices" {} + +output "monthly_traffic_price_per_tb" { + value = data.hcloud_pricing.prices.server_types[0].prices[0].per_tb_traffic.gross +} diff --git a/hcloud/plugin_provider.go b/hcloud/plugin_provider.go index 5319a3f8a..b33404887 100644 --- a/hcloud/plugin_provider.go +++ b/hcloud/plugin_provider.go @@ -24,6 +24,7 @@ import ( "github.com/hetznercloud/terraform-provider-hcloud/internal/loadbalancer" "github.com/hetznercloud/terraform-provider-hcloud/internal/loadbalancertype" "github.com/hetznercloud/terraform-provider-hcloud/internal/location" + "github.com/hetznercloud/terraform-provider-hcloud/internal/pricing" "github.com/hetznercloud/terraform-provider-hcloud/internal/primaryip" "github.com/hetznercloud/terraform-provider-hcloud/internal/rdns" "github.com/hetznercloud/terraform-provider-hcloud/internal/server" @@ -213,6 +214,7 @@ func (p *PluginProvider) DataSources(_ context.Context) []func() datasource.Data loadbalancertype.NewDataSourceList, location.NewDataSource, location.NewDataSourceList, + pricing.NewDataSource, primaryip.NewDataSource, primaryip.NewDataSourceList, servertype.NewDataSource, diff --git a/internal/pricing/data_source.go b/internal/pricing/data_source.go new file mode 100644 index 000000000..b6d0ad84b --- /dev/null +++ b/internal/pricing/data_source.go @@ -0,0 +1,264 @@ +package pricing + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/diag" + + "github.com/hetznercloud/hcloud-go/v2/hcloud" + "github.com/hetznercloud/terraform-provider-hcloud/internal/util/hcloudutil" +) + +// DataSourceType is the type name of the Hetzner Cloud Pricing data source. +const DataSourceType = "hcloud_pricing" + +var _ datasource.DataSource = (*DataSource)(nil) +var _ datasource.DataSourceWithConfigure = (*DataSource)(nil) + +type DataSource struct { + client *hcloud.Client +} + +func NewDataSource() datasource.DataSource { + return &DataSource{} +} + +// Metadata should return the full name of the data source. +func (d *DataSource) Metadata(_ context.Context, _ datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = DataSourceType +} + +// Configure enables provider-level data or clients to be set in the +// provider-defined DataSource type. It is separately executed for each +// ReadDataSource RPC. +func (d *DataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + var newDiags diag.Diagnostics + + d.client, newDiags = hcloudutil.ConfigureClient(req.ProviderData) + resp.Diagnostics.Append(newDiags...) + if resp.Diagnostics.HasError() { + return + } +} + +// priceSchema returns the schema for a net/gross price including the currency +// and VAT rate. +func priceSchema(description string) schema.SingleNestedAttribute { + return schema.SingleNestedAttribute{ + MarkdownDescription: description, + Computed: true, + Attributes: map[string]schema.Attribute{ + "currency": schema.StringAttribute{ + MarkdownDescription: "Currency the price is denominated in.", + Computed: true, + }, + "vat_rate": schema.StringAttribute{ + MarkdownDescription: "VAT rate in percent used to calculate the gross price from the net price.", + Computed: true, + }, + "net": schema.StringAttribute{ + MarkdownDescription: "Price without VAT.", + Computed: true, + }, + "gross": schema.StringAttribute{ + MarkdownDescription: "Price with VAT added.", + Computed: true, + }, + }, + } +} + +// primaryIPPriceSchema returns the schema for a net/gross Primary IP price. +func primaryIPPriceSchema(description string) schema.SingleNestedAttribute { + return schema.SingleNestedAttribute{ + MarkdownDescription: description, + Computed: true, + Attributes: map[string]schema.Attribute{ + "net": schema.StringAttribute{ + MarkdownDescription: "Price without VAT.", + Computed: true, + }, + "gross": schema.StringAttribute{ + MarkdownDescription: "Price with VAT added.", + Computed: true, + }, + }, + } +} + +// perGBPricingSchema returns the schema for a per GB and month price. +func perGBPricingSchema(description string) schema.SingleNestedAttribute { + return schema.SingleNestedAttribute{ + MarkdownDescription: description, + Computed: true, + Attributes: map[string]schema.Attribute{ + "per_gb_month": priceSchema("Price per GB and month."), + }, + } +} + +// Schema should return the schema for this data source. +func (d *DataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema.MarkdownDescription = ` +Provides prices for the resources offered by the Hetzner Cloud. + +The prices are returned for the Hetzner Cloud project the API token used to configure the provider belongs to. + +See the [Pricing API documentation](https://docs.hetzner.cloud/reference/cloud#pricing) for more details. +` + + resp.Schema.Attributes = map[string]schema.Attribute{ + "location": schema.StringAttribute{ + MarkdownDescription: "Only return per-location prices for the location with this name (e.g. `fsn1`).", + Optional: true, + }, + "server_type": schema.StringAttribute{ + MarkdownDescription: "Only return prices for the Server Type with this name (e.g. `cx22`).", + Optional: true, + }, + "load_balancer_type": schema.StringAttribute{ + MarkdownDescription: "Only return prices for the Load Balancer Type with this name (e.g. `lb11`).", + Optional: true, + }, + "currency": schema.StringAttribute{ + MarkdownDescription: "Currency all prices are denominated in.", + Computed: true, + }, + "vat_rate": schema.StringAttribute{ + MarkdownDescription: "VAT rate in percent used to calculate the gross prices from the net prices.", + Computed: true, + }, + "image": perGBPricingSchema("Price of the disk space used by Images (Snapshots and Backups)."), + "floating_ips": schema.ListNestedAttribute{ + MarkdownDescription: "Prices of the Floating IPs, grouped by type.", + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "type": schema.StringAttribute{ + MarkdownDescription: "Type of the Floating IP (`ipv4` or `ipv6`).", + Computed: true, + }, + "prices": schema.ListNestedAttribute{ + MarkdownDescription: "Prices of the Floating IP type, per location.", + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "location": schema.StringAttribute{ + MarkdownDescription: "Name of the location the price applies to.", + Computed: true, + }, + "monthly": priceSchema("Monthly costs for a Floating IP type in this location."), + }, + }, + }, + }, + }, + }, + "primary_ips": schema.ListNestedAttribute{ + MarkdownDescription: "Prices of the Primary IPs, grouped by type.", + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "type": schema.StringAttribute{ + MarkdownDescription: "Type of the Primary IP (`ipv4` or `ipv6`).", + Computed: true, + }, + "prices": schema.ListNestedAttribute{ + MarkdownDescription: "Prices of the Primary IP type, per location.", + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "location": schema.StringAttribute{ + MarkdownDescription: "Name of the location the price applies to.", + Computed: true, + }, + "hourly": primaryIPPriceSchema("Hourly costs for a Primary IP type in this location."), + "monthly": primaryIPPriceSchema("Monthly costs for a Primary IP type in this location."), + }, + }, + }, + }, + }, + }, + "server_backup": schema.SingleNestedAttribute{ + MarkdownDescription: "Will increase base server costs by specific percentage if server backups are enabled.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "percentage": schema.StringAttribute{ + MarkdownDescription: "Percentage by which the base server costs increase if server backups are enabled.", + Computed: true, + }, + }, + }, + "server_types": typePricingSchema("Server Type"), + "load_balancer_types": typePricingSchema("Load Balancer Type"), + "volume": perGBPricingSchema("Price of the disk space used by Volumes."), + } +} + +// typePricingSchema returns the schema for the per-location pricing of a Server +// Type or Load Balancer Type. +func typePricingSchema(name string) schema.ListNestedAttribute { + return schema.ListNestedAttribute{ + MarkdownDescription: "Prices of the " + name + "s, grouped by type.", + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "id": schema.Int64Attribute{ + MarkdownDescription: "ID of the " + name + ".", + Computed: true, + }, + "name": schema.StringAttribute{ + MarkdownDescription: "Name of the " + name + ".", + Computed: true, + }, + "prices": schema.ListNestedAttribute{ + MarkdownDescription: "Prices of the " + name + ", per location.", + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "location": schema.StringAttribute{ + MarkdownDescription: "Name of the location the price applies to.", + Computed: true, + }, + "hourly": priceSchema("Hourly costs for a " + name + " in this location."), + "monthly": priceSchema("Monthly costs for a " + name + " in this location."), + "included_traffic": schema.Int64Attribute{ + MarkdownDescription: "Free traffic per month in bytes.", + Computed: true, + }, + "per_tb_traffic": priceSchema("Costs per additional TB of traffic in this location."), + }, + }, + }, + }, + }, + } +} + +// Read is called when the provider must read data source values in +// order to update state. Config values should be read from the +// ReadRequest and new state values set on the ReadResponse. +func (d *DataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var data model + + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + result, _, err := d.client.Pricing.Get(ctx) + if err != nil { + resp.Diagnostics.Append(hcloudutil.APIErrorDiagnostics(err)...) + return + } + + resp.Diagnostics.Append(data.FromAPI(ctx, filterPricing(result, data))...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} diff --git a/internal/pricing/data_source_test.go b/internal/pricing/data_source_test.go new file mode 100644 index 000000000..782c9a7d2 --- /dev/null +++ b/internal/pricing/data_source_test.go @@ -0,0 +1,47 @@ +package pricing_test + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + + "github.com/hetznercloud/terraform-provider-hcloud/internal/pricing" + "github.com/hetznercloud/terraform-provider-hcloud/internal/teste2e" + "github.com/hetznercloud/terraform-provider-hcloud/internal/testmux" + "github.com/hetznercloud/terraform-provider-hcloud/internal/testtemplate" +) + +func TestAccPricingDataSource(t *testing.T) { + tmplMan := testtemplate.Manager{} + + res := &pricing.DData{} + res.SetRName("test") + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: teste2e.PreCheck(t), + ProtoV6ProviderFactories: testmux.ProtoV6ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: tmplMan.Render(t, + "testdata/d/hcloud_pricing", res, + ), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet(res.TFID(), "currency"), + resource.TestCheckResourceAttrSet(res.TFID(), "vat_rate"), + resource.TestCheckResourceAttrSet(res.TFID(), "image.per_gb_month.net"), + resource.TestCheckResourceAttrSet(res.TFID(), "server_backup.percentage"), + resource.TestCheckResourceAttrSet(res.TFID(), "volume.per_gb_month.net"), + resource.TestCheckResourceAttrSet(res.TFID(), "floating_ips.0.type"), + resource.TestCheckResourceAttrSet(res.TFID(), "floating_ips.0.prices.0.location"), + resource.TestCheckResourceAttrSet(res.TFID(), "primary_ips.0.type"), + resource.TestCheckResourceAttrSet(res.TFID(), "primary_ips.0.prices.0.hourly.net"), + resource.TestCheckResourceAttrSet(res.TFID(), "server_types.0.name"), + resource.TestCheckResourceAttrSet(res.TFID(), "server_types.0.prices.0.monthly.net"), + resource.TestCheckResourceAttrSet(res.TFID(), "server_types.0.prices.0.included_traffic"), + resource.TestCheckResourceAttrSet(res.TFID(), "load_balancer_types.0.name"), + resource.TestCheckResourceAttrSet(res.TFID(), "load_balancer_types.0.prices.0.monthly.net"), + ), + }, + }, + }) +} diff --git a/internal/pricing/model.go b/internal/pricing/model.go new file mode 100644 index 000000000..789d1924e --- /dev/null +++ b/internal/pricing/model.go @@ -0,0 +1,634 @@ +package pricing + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + + "github.com/hetznercloud/hcloud-go/v2/hcloud" + "github.com/hetznercloud/terraform-provider-hcloud/internal/util" +) + +// model is the top level model for the Hetzner Cloud pricing data source. +type model struct { + // Filters + Location types.String `tfsdk:"location"` + ServerType types.String `tfsdk:"server_type"` + LoadBalancerType types.String `tfsdk:"load_balancer_type"` + + Currency types.String `tfsdk:"currency"` + VATRate types.String `tfsdk:"vat_rate"` + Image types.Object `tfsdk:"image"` + FloatingIPs types.List `tfsdk:"floating_ips"` + PrimaryIPs types.List `tfsdk:"primary_ips"` + ServerBackup types.Object `tfsdk:"server_backup"` + ServerTypes types.List `tfsdk:"server_types"` + LoadBalancerTypes types.List `tfsdk:"load_balancer_types"` + Volume types.Object `tfsdk:"volume"` +} + +var _ util.ModelFromAPI[hcloud.Pricing] = &model{} + +var ( + _ util.ModelFromAPI[hcloud.Price] = (*priceModel)(nil) + _ util.ModelToTerraform[types.Object] = (*priceModel)(nil) + _ util.ModelFromAPI[hcloud.PrimaryIPPrice] = (*primaryIPPriceModel)(nil) + _ util.ModelToTerraform[types.Object] = (*primaryIPPriceModel)(nil) + _ util.ModelFromAPI[hcloud.Price] = (*perGBPricingModel)(nil) + _ util.ModelToTerraform[types.Object] = (*perGBPricingModel)(nil) + _ util.ModelFromAPI[hcloud.ServerBackupPricing] = (*serverBackupPricingModel)(nil) + _ util.ModelToTerraform[types.Object] = (*serverBackupPricingModel)(nil) + _ util.ModelFromAPI[hcloud.FloatingIPTypePricing] = (*floatingIPPricingModel)(nil) + _ util.ModelToTerraform[types.Object] = (*floatingIPPricingModel)(nil) + _ util.ModelFromAPI[hcloud.FloatingIPTypeLocationPricing] = (*floatingIPLocationPricingModel)(nil) + _ util.ModelToTerraform[types.Object] = (*floatingIPLocationPricingModel)(nil) + _ util.ModelFromAPI[hcloud.PrimaryIPPricing] = (*primaryIPPricingModel)(nil) + _ util.ModelToTerraform[types.Object] = (*primaryIPPricingModel)(nil) + _ util.ModelFromAPI[hcloud.PrimaryIPTypePricing] = (*primaryIPLocationPricingModel)(nil) + _ util.ModelToTerraform[types.Object] = (*primaryIPLocationPricingModel)(nil) + _ util.ModelToTerraform[types.Object] = (*typePricingModel)(nil) + _ util.ModelToTerraform[types.Object] = (*typeLocationPricingModel)(nil) +) + +func (m *model) FromAPI(ctx context.Context, hc hcloud.Pricing) diag.Diagnostics { + var diags diag.Diagnostics + var newDiags diag.Diagnostics + + m.Currency = types.StringValue(hc.Currency) + m.VATRate = types.StringValue(hc.VATRate) + + { + value := perGBPricingModel{} + diags.Append(value.FromAPI(ctx, hc.Image.PerGBMonth)...) + + m.Image, newDiags = value.ToTerraform(ctx) + diags.Append(newDiags...) + } + + { + value := perGBPricingModel{} + diags.Append(value.FromAPI(ctx, hc.Volume.PerGBMonthly)...) + + m.Volume, newDiags = value.ToTerraform(ctx) + diags.Append(newDiags...) + } + + { + value := serverBackupPricingModel{} + diags.Append(value.FromAPI(ctx, hc.ServerBackup)...) + + m.ServerBackup, newDiags = value.ToTerraform(ctx) + diags.Append(newDiags...) + } + + m.FloatingIPs, newDiags = listValueFromAPI(ctx, (&floatingIPPricingModel{}).tfType(), hc.FloatingIPs, floatingIPPricingObjectFromAPI) + diags.Append(newDiags...) + + m.PrimaryIPs, newDiags = listValueFromAPI(ctx, (&primaryIPPricingModel{}).tfType(), hc.PrimaryIPs, primaryIPPricingObjectFromAPI) + diags.Append(newDiags...) + + m.ServerTypes, newDiags = listValueFromAPI(ctx, (&typePricingModel{}).tfType(), hc.ServerTypes, serverTypePricingObjectFromAPI) + diags.Append(newDiags...) + + m.LoadBalancerTypes, newDiags = listValueFromAPI(ctx, (&typePricingModel{}).tfType(), hc.LoadBalancerTypes, loadBalancerTypePricingObjectFromAPI) + diags.Append(newDiags...) + + return diags +} + +// priceModel represents a net/gross price including the currency and VAT rate. +type priceModel struct { + Currency types.String `tfsdk:"currency"` + VATRate types.String `tfsdk:"vat_rate"` + Net types.String `tfsdk:"net"` + Gross types.String `tfsdk:"gross"` +} + +func (m *priceModel) tfAttributesTypes() map[string]attr.Type { + return map[string]attr.Type{ + "currency": types.StringType, + "vat_rate": types.StringType, + "net": types.StringType, + "gross": types.StringType, + } +} + +func (m *priceModel) tfType() attr.Type { + return basetypes.ObjectType{AttrTypes: m.tfAttributesTypes()} +} + +func (m *priceModel) FromAPI(_ context.Context, hc hcloud.Price) diag.Diagnostics { + m.Currency = types.StringValue(hc.Currency) + m.VATRate = types.StringValue(hc.VATRate) + m.Net = types.StringValue(hc.Net) + m.Gross = types.StringValue(hc.Gross) + + return nil +} + +func (m *priceModel) ToTerraform(ctx context.Context) (types.Object, diag.Diagnostics) { + return types.ObjectValueFrom(ctx, m.tfAttributesTypes(), m) +} + +// priceObjectFromAPI converts a single hcloud.Price into its Terraform object. +func priceObjectFromAPI(ctx context.Context, hc hcloud.Price) (types.Object, diag.Diagnostics) { + value := priceModel{} + diags := value.FromAPI(ctx, hc) + + obj, newDiags := value.ToTerraform(ctx) + diags.Append(newDiags...) + + return obj, diags +} + +// primaryIPPriceModel represents a net/gross price for Primary IPs. Unlike +// priceModel it does not include the currency or VAT rate. +type primaryIPPriceModel struct { + Net types.String `tfsdk:"net"` + Gross types.String `tfsdk:"gross"` +} + +func (m *primaryIPPriceModel) tfAttributesTypes() map[string]attr.Type { + return map[string]attr.Type{ + "net": types.StringType, + "gross": types.StringType, + } +} + +func (m *primaryIPPriceModel) tfType() attr.Type { + return basetypes.ObjectType{AttrTypes: m.tfAttributesTypes()} +} + +func (m *primaryIPPriceModel) FromAPI(_ context.Context, hc hcloud.PrimaryIPPrice) diag.Diagnostics { + m.Net = types.StringValue(hc.Net) + m.Gross = types.StringValue(hc.Gross) + + return nil +} + +func (m *primaryIPPriceModel) ToTerraform(ctx context.Context) (types.Object, diag.Diagnostics) { + return types.ObjectValueFrom(ctx, m.tfAttributesTypes(), m) +} + +func primaryIPPriceObjectFromAPI(ctx context.Context, hc hcloud.PrimaryIPPrice) (types.Object, diag.Diagnostics) { + value := primaryIPPriceModel{} + diags := value.FromAPI(ctx, hc) + + obj, newDiags := value.ToTerraform(ctx) + diags.Append(newDiags...) + + return obj, diags +} + +// perGBPricingModel represents a per GB and month price, used by Images and +// Volumes. +type perGBPricingModel struct { + PerGBMonth types.Object `tfsdk:"per_gb_month"` +} + +func (m *perGBPricingModel) tfAttributesTypes() map[string]attr.Type { + return map[string]attr.Type{ + "per_gb_month": (&priceModel{}).tfType(), + } +} + +func (m *perGBPricingModel) FromAPI(ctx context.Context, hc hcloud.Price) diag.Diagnostics { + var diags diag.Diagnostics + var newDiags diag.Diagnostics + + m.PerGBMonth, newDiags = priceObjectFromAPI(ctx, hc) + diags.Append(newDiags...) + + return diags +} + +func (m *perGBPricingModel) ToTerraform(ctx context.Context) (types.Object, diag.Diagnostics) { + return types.ObjectValueFrom(ctx, m.tfAttributesTypes(), m) +} + +// serverBackupPricingModel represents the surcharge for server backups. +type serverBackupPricingModel struct { + Percentage types.String `tfsdk:"percentage"` +} + +func (m *serverBackupPricingModel) tfAttributesTypes() map[string]attr.Type { + return map[string]attr.Type{ + "percentage": types.StringType, + } +} + +func (m *serverBackupPricingModel) FromAPI(_ context.Context, hc hcloud.ServerBackupPricing) diag.Diagnostics { + m.Percentage = types.StringValue(hc.Percentage) + + return nil +} + +func (m *serverBackupPricingModel) ToTerraform(ctx context.Context) (types.Object, diag.Diagnostics) { + return types.ObjectValueFrom(ctx, m.tfAttributesTypes(), m) +} + +// floatingIPPricingModel represents the pricing of a Floating IP type across +// all locations. +type floatingIPPricingModel struct { + Type types.String `tfsdk:"type"` + Prices types.List `tfsdk:"prices"` +} + +func (m *floatingIPPricingModel) tfAttributesTypes() map[string]attr.Type { + return map[string]attr.Type{ + "type": types.StringType, + "prices": types.ListType{ElemType: (&floatingIPLocationPricingModel{}).tfType()}, + } +} + +func (m *floatingIPPricingModel) tfType() attr.Type { + return basetypes.ObjectType{AttrTypes: m.tfAttributesTypes()} +} + +func (m *floatingIPPricingModel) FromAPI(ctx context.Context, hc hcloud.FloatingIPTypePricing) diag.Diagnostics { + var diags diag.Diagnostics + var newDiags diag.Diagnostics + + m.Type = types.StringValue(string(hc.Type)) + + m.Prices, newDiags = listValueFromAPI(ctx, (&floatingIPLocationPricingModel{}).tfType(), hc.Pricings, floatingIPLocationPricingObjectFromAPI) + diags.Append(newDiags...) + + return diags +} + +func (m *floatingIPPricingModel) ToTerraform(ctx context.Context) (types.Object, diag.Diagnostics) { + return types.ObjectValueFrom(ctx, m.tfAttributesTypes(), m) +} + +func floatingIPPricingObjectFromAPI(ctx context.Context, hc hcloud.FloatingIPTypePricing) (types.Object, diag.Diagnostics) { + value := floatingIPPricingModel{} + diags := value.FromAPI(ctx, hc) + + obj, newDiags := value.ToTerraform(ctx) + diags.Append(newDiags...) + + return obj, diags +} + +// floatingIPLocationPricingModel represents the pricing of a Floating IP type +// at a single location. +type floatingIPLocationPricingModel struct { + Location types.String `tfsdk:"location"` + Monthly types.Object `tfsdk:"monthly"` +} + +func (m *floatingIPLocationPricingModel) tfAttributesTypes() map[string]attr.Type { + return map[string]attr.Type{ + "location": types.StringType, + "monthly": (&priceModel{}).tfType(), + } +} + +func (m *floatingIPLocationPricingModel) tfType() attr.Type { + return basetypes.ObjectType{AttrTypes: m.tfAttributesTypes()} +} + +func (m *floatingIPLocationPricingModel) FromAPI(ctx context.Context, hc hcloud.FloatingIPTypeLocationPricing) diag.Diagnostics { + var diags diag.Diagnostics + var newDiags diag.Diagnostics + + m.Location = types.StringValue(locationName(hc.Location)) + + m.Monthly, newDiags = priceObjectFromAPI(ctx, hc.Monthly) + diags.Append(newDiags...) + + return diags +} + +func (m *floatingIPLocationPricingModel) ToTerraform(ctx context.Context) (types.Object, diag.Diagnostics) { + return types.ObjectValueFrom(ctx, m.tfAttributesTypes(), m) +} + +func floatingIPLocationPricingObjectFromAPI(ctx context.Context, hc hcloud.FloatingIPTypeLocationPricing) (types.Object, diag.Diagnostics) { + value := floatingIPLocationPricingModel{} + diags := value.FromAPI(ctx, hc) + + obj, newDiags := value.ToTerraform(ctx) + diags.Append(newDiags...) + + return obj, diags +} + +// primaryIPPricingModel represents the pricing of a Primary IP type across all +// locations. +type primaryIPPricingModel struct { + Type types.String `tfsdk:"type"` + Prices types.List `tfsdk:"prices"` +} + +func (m *primaryIPPricingModel) tfAttributesTypes() map[string]attr.Type { + return map[string]attr.Type{ + "type": types.StringType, + "prices": types.ListType{ElemType: (&primaryIPLocationPricingModel{}).tfType()}, + } +} + +func (m *primaryIPPricingModel) tfType() attr.Type { + return basetypes.ObjectType{AttrTypes: m.tfAttributesTypes()} +} + +func (m *primaryIPPricingModel) FromAPI(ctx context.Context, hc hcloud.PrimaryIPPricing) diag.Diagnostics { + var diags diag.Diagnostics + var newDiags diag.Diagnostics + + m.Type = types.StringValue(hc.Type) + + m.Prices, newDiags = listValueFromAPI(ctx, (&primaryIPLocationPricingModel{}).tfType(), hc.Pricings, primaryIPLocationPricingObjectFromAPI) + diags.Append(newDiags...) + + return diags +} + +func (m *primaryIPPricingModel) ToTerraform(ctx context.Context) (types.Object, diag.Diagnostics) { + return types.ObjectValueFrom(ctx, m.tfAttributesTypes(), m) +} + +func primaryIPPricingObjectFromAPI(ctx context.Context, hc hcloud.PrimaryIPPricing) (types.Object, diag.Diagnostics) { + value := primaryIPPricingModel{} + diags := value.FromAPI(ctx, hc) + + obj, newDiags := value.ToTerraform(ctx) + diags.Append(newDiags...) + + return obj, diags +} + +// primaryIPLocationPricingModel represents the pricing of a Primary IP type at +// a single location. +type primaryIPLocationPricingModel struct { + Location types.String `tfsdk:"location"` + Hourly types.Object `tfsdk:"hourly"` + Monthly types.Object `tfsdk:"monthly"` +} + +func (m *primaryIPLocationPricingModel) tfAttributesTypes() map[string]attr.Type { + return map[string]attr.Type{ + "location": types.StringType, + "hourly": (&primaryIPPriceModel{}).tfType(), + "monthly": (&primaryIPPriceModel{}).tfType(), + } +} + +func (m *primaryIPLocationPricingModel) tfType() attr.Type { + return basetypes.ObjectType{AttrTypes: m.tfAttributesTypes()} +} + +func (m *primaryIPLocationPricingModel) FromAPI(ctx context.Context, hc hcloud.PrimaryIPTypePricing) diag.Diagnostics { + var diags diag.Diagnostics + var newDiags diag.Diagnostics + + m.Location = types.StringValue(hc.Location) + + m.Hourly, newDiags = primaryIPPriceObjectFromAPI(ctx, hc.Hourly) + diags.Append(newDiags...) + + m.Monthly, newDiags = primaryIPPriceObjectFromAPI(ctx, hc.Monthly) + diags.Append(newDiags...) + + return diags +} + +func (m *primaryIPLocationPricingModel) ToTerraform(ctx context.Context) (types.Object, diag.Diagnostics) { + return types.ObjectValueFrom(ctx, m.tfAttributesTypes(), m) +} + +func primaryIPLocationPricingObjectFromAPI(ctx context.Context, hc hcloud.PrimaryIPTypePricing) (types.Object, diag.Diagnostics) { + value := primaryIPLocationPricingModel{} + diags := value.FromAPI(ctx, hc) + + obj, newDiags := value.ToTerraform(ctx) + diags.Append(newDiags...) + + return obj, diags +} + +// typePricingModel represents the pricing of a Server Type or Load Balancer +// Type across all locations. +type typePricingModel struct { + ID types.Int64 `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Prices types.List `tfsdk:"prices"` +} + +func (m *typePricingModel) tfAttributesTypes() map[string]attr.Type { + return map[string]attr.Type{ + "id": types.Int64Type, + "name": types.StringType, + "prices": types.ListType{ElemType: (&typeLocationPricingModel{}).tfType()}, + } +} + +func (m *typePricingModel) tfType() attr.Type { + return basetypes.ObjectType{AttrTypes: m.tfAttributesTypes()} +} + +func (m *typePricingModel) ToTerraform(ctx context.Context) (types.Object, diag.Diagnostics) { + return types.ObjectValueFrom(ctx, m.tfAttributesTypes(), m) +} + +func serverTypePricingObjectFromAPI(ctx context.Context, hc hcloud.ServerTypePricing) (types.Object, diag.Diagnostics) { + var diags diag.Diagnostics + + value := typePricingModel{} + if hc.ServerType != nil { + value.ID = types.Int64Value(hc.ServerType.ID) + value.Name = types.StringValue(hc.ServerType.Name) + } + + prices, newDiags := listValueFromAPI(ctx, (&typeLocationPricingModel{}).tfType(), hc.Pricings, + func(ctx context.Context, p hcloud.ServerTypeLocationPricing) (types.Object, diag.Diagnostics) { + item := typeLocationPricingModel{} + diags := item.FromAPI(ctx, locationName(p.Location), p.Hourly, p.Monthly, p.IncludedTraffic, p.PerTBTraffic) + + obj, newDiags := item.ToTerraform(ctx) + diags.Append(newDiags...) + + return obj, diags + }, + ) + diags.Append(newDiags...) + value.Prices = prices + + obj, newDiags := value.ToTerraform(ctx) + diags.Append(newDiags...) + + return obj, diags +} + +func loadBalancerTypePricingObjectFromAPI(ctx context.Context, hc hcloud.LoadBalancerTypePricing) (types.Object, diag.Diagnostics) { + var diags diag.Diagnostics + + value := typePricingModel{} + if hc.LoadBalancerType != nil { + value.ID = types.Int64Value(hc.LoadBalancerType.ID) + value.Name = types.StringValue(hc.LoadBalancerType.Name) + } + + prices, newDiags := listValueFromAPI(ctx, (&typeLocationPricingModel{}).tfType(), hc.Pricings, + func(ctx context.Context, p hcloud.LoadBalancerTypeLocationPricing) (types.Object, diag.Diagnostics) { + item := typeLocationPricingModel{} + diags := item.FromAPI(ctx, locationName(p.Location), p.Hourly, p.Monthly, p.IncludedTraffic, p.PerTBTraffic) + + obj, newDiags := item.ToTerraform(ctx) + diags.Append(newDiags...) + + return obj, diags + }, + ) + diags.Append(newDiags...) + value.Prices = prices + + obj, newDiags := value.ToTerraform(ctx) + diags.Append(newDiags...) + + return obj, diags +} + +// typeLocationPricingModel represents the pricing of a Server Type or Load +// Balancer Type at a single location. +type typeLocationPricingModel struct { + Location types.String `tfsdk:"location"` + Hourly types.Object `tfsdk:"hourly"` + Monthly types.Object `tfsdk:"monthly"` + IncludedTraffic types.Int64 `tfsdk:"included_traffic"` + PerTBTraffic types.Object `tfsdk:"per_tb_traffic"` +} + +func (m *typeLocationPricingModel) tfAttributesTypes() map[string]attr.Type { + return map[string]attr.Type{ + "location": types.StringType, + "hourly": (&priceModel{}).tfType(), + "monthly": (&priceModel{}).tfType(), + "included_traffic": types.Int64Type, + "per_tb_traffic": (&priceModel{}).tfType(), + } +} + +func (m *typeLocationPricingModel) tfType() attr.Type { + return basetypes.ObjectType{AttrTypes: m.tfAttributesTypes()} +} + +func (m *typeLocationPricingModel) FromAPI(ctx context.Context, location string, hourly, monthly hcloud.Price, includedTraffic uint64, perTBTraffic hcloud.Price) diag.Diagnostics { + var diags diag.Diagnostics + var newDiags diag.Diagnostics + + m.Location = types.StringValue(location) + + m.Hourly, newDiags = priceObjectFromAPI(ctx, hourly) + diags.Append(newDiags...) + + m.Monthly, newDiags = priceObjectFromAPI(ctx, monthly) + diags.Append(newDiags...) + + m.IncludedTraffic = types.Int64Value(int64(includedTraffic)) //nolint:gosec // traffic in bytes always fits in int64 + + m.PerTBTraffic, newDiags = priceObjectFromAPI(ctx, perTBTraffic) + diags.Append(newDiags...) + + return diags +} + +func (m *typeLocationPricingModel) ToTerraform(ctx context.Context) (types.Object, diag.Diagnostics) { + return types.ObjectValueFrom(ctx, m.tfAttributesTypes(), m) +} + +// listValueFromAPI converts a slice of API values into a Terraform list of +// objects, using the provided conversion function for each element. +func listValueFromAPI[API any]( + ctx context.Context, + elemType attr.Type, + items []API, + convert func(context.Context, API) (types.Object, diag.Diagnostics), +) (types.List, diag.Diagnostics) { + var diags diag.Diagnostics + var newDiags diag.Diagnostics + + values := make([]attr.Value, 0, len(items)) + for _, item := range items { + obj, newDiags := convert(ctx, item) + diags.Append(newDiags...) + + values = append(values, obj) + } + + list, newDiags := types.ListValue(elemType, values) + diags.Append(newDiags...) + + return list, diags +} + +// locationName returns the name of the given location, or an empty string if it +// is not set. +func locationName(location *hcloud.Location) string { + if location == nil { + return "" + } + return location.Name +} + +// filterPricing applies the optional filters configured on the data source. The +// Pricing API does not support server side filtering, so the full response is +// fetched and filtered locally. +func filterPricing(p hcloud.Pricing, data model) hcloud.Pricing { + location := data.Location.ValueString() + serverType := data.ServerType.ValueString() + loadBalancerType := data.LoadBalancerType.ValueString() + + if location != "" { + for i := range p.FloatingIPs { + p.FloatingIPs[i].Pricings = filterSlice(p.FloatingIPs[i].Pricings, func(v hcloud.FloatingIPTypeLocationPricing) bool { + return locationName(v.Location) == location + }) + } + for i := range p.PrimaryIPs { + p.PrimaryIPs[i].Pricings = filterSlice(p.PrimaryIPs[i].Pricings, func(v hcloud.PrimaryIPTypePricing) bool { + return v.Location == location + }) + } + for i := range p.ServerTypes { + p.ServerTypes[i].Pricings = filterSlice(p.ServerTypes[i].Pricings, func(v hcloud.ServerTypeLocationPricing) bool { + return locationName(v.Location) == location + }) + } + for i := range p.LoadBalancerTypes { + p.LoadBalancerTypes[i].Pricings = filterSlice(p.LoadBalancerTypes[i].Pricings, func(v hcloud.LoadBalancerTypeLocationPricing) bool { + return locationName(v.Location) == location + }) + } + } + + if serverType != "" { + p.ServerTypes = filterSlice(p.ServerTypes, func(v hcloud.ServerTypePricing) bool { + return v.ServerType != nil && v.ServerType.Name == serverType + }) + } + + if loadBalancerType != "" { + p.LoadBalancerTypes = filterSlice(p.LoadBalancerTypes, func(v hcloud.LoadBalancerTypePricing) bool { + return v.LoadBalancerType != nil && v.LoadBalancerType.Name == loadBalancerType + }) + } + + return p +} + +// filterSlice returns a new slice containing only the items for which keep +// returns true. +func filterSlice[T any](items []T, keep func(T) bool) []T { + result := make([]T, 0, len(items)) + for _, item := range items { + if keep(item) { + result = append(result, item) + } + } + return result +} diff --git a/internal/pricing/testing.go b/internal/pricing/testing.go new file mode 100644 index 000000000..9e0150d91 --- /dev/null +++ b/internal/pricing/testing.go @@ -0,0 +1,17 @@ +package pricing + +import ( + "fmt" + + "github.com/hetznercloud/terraform-provider-hcloud/internal/testtemplate" +) + +// DData defines the fields for the "testdata/d/hcloud_pricing" template. +type DData struct { + testtemplate.DataCommon +} + +// TFID returns the data source identifier. +func (d *DData) TFID() string { + return fmt.Sprintf("data.%s.%s", DataSourceType, d.RName()) +} diff --git a/internal/testdata/d/hcloud_pricing.tf.tmpl b/internal/testdata/d/hcloud_pricing.tf.tmpl new file mode 100644 index 000000000..0e3dc837b --- /dev/null +++ b/internal/testdata/d/hcloud_pricing.tf.tmpl @@ -0,0 +1,3 @@ +{{- /* vim: set ft=terraform: */ -}} + +data "hcloud_pricing" "{{ .RName }}" {}