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 b928decda..4009695d5 100644 --- a/hcloud/plugin_provider.go +++ b/hcloud/plugin_provider.go @@ -25,6 +25,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" @@ -216,6 +217,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 }}" {}