diff --git a/docs/data-sources/networks.md b/docs/data-sources/networks.md new file mode 100644 index 000000000..9cb87b538 --- /dev/null +++ b/docs/data-sources/networks.md @@ -0,0 +1,55 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_networks Data Source - stackit" +subcategory: "" +description: |- + Lists STACKIT networks in a project, optionally filtered by name or labels. +--- + +# stackit_networks (Data Source) + +Lists STACKIT networks in a project, optionally filtered by name or labels. + +## Example Usage + +```terraform +# List all networks in a project +data "stackit_networks" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} + +# List all networks in a project with a specific name and label +data "stackit_networks" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name_regex = "example" + labels = { + foo = "bar" + } +} +``` + + +## Schema + +### Required + +- `project_id` (String) STACKIT project ID to which the networks are associated. + +### Optional + +- `labels` (Map of String) Optional label selector to filter networks by labels. +- `name_regex` (String) Optional regular expression to filter networks by name. +- `region` (String) The resource region. If not defined, the provider region is used. + +### Read-Only + +- `id` (String) Terraform's internal resource ID. It is structured as "`project_id`,`region`". +- `items` (Attributes List) List of networks matching the filters. (see [below for nested schema](#nestedatt--items)) + + +### Nested Schema for `items` + +Read-Only: + +- `name` (String) The name of the network. +- `network_id` (String) The network ID. diff --git a/examples/data-sources/stackit_networks/data-source.tf b/examples/data-sources/stackit_networks/data-source.tf new file mode 100644 index 000000000..1f6e5984a --- /dev/null +++ b/examples/data-sources/stackit_networks/data-source.tf @@ -0,0 +1,13 @@ +# List all networks in a project +data "stackit_networks" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} + +# List all networks in a project with a specific name and label +data "stackit_networks" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name_regex = "example" + labels = { + foo = "bar" + } +} diff --git a/stackit/internal/services/iaas/iaas_acc_test.go b/stackit/internal/services/iaas/iaas_acc_test.go index 9377db792..dc3e1d418 100644 --- a/stackit/internal/services/iaas/iaas_acc_test.go +++ b/stackit/internal/services/iaas/iaas_acc_test.go @@ -6374,3 +6374,87 @@ func validateImportedNameservers(expectedIPs []string) func(states []*terraform. return nil } } + +func TestAccNetworksDataSource(t *testing.T) { + name2 := fmt.Sprintf("tf-acc-%s", acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum)) + t.Logf("TestAccNetworksDataSource names: %s, %s", testutil.ConvertConfigVariable(testConfigNetworkVarsMin["name"]), name2) + + resource.ParallelTest(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckDestroy, + Steps: []resource.TestStep{ + { + ConfigVariables: testConfigNetworkVarsMin, + Config: fmt.Sprintf(` + %s + %s + + resource "stackit_network" "network2" { + project_id = stackit_network.network.project_id + name = "%s" + labels = { + foo = "bar" + baz = "qux" + } + } + + # No filter -> all networks returned + data "stackit_networks" "all" { + project_id = stackit_network.network.project_id + depends_on = [stackit_network.network2] + } + + # name_regex -> matching returned + data "stackit_networks" "regex" { + project_id = stackit_network.network.project_id + name_regex = "%s" + depends_on = [stackit_network.network2] + } + + # name_regex + labels -> both must be true + data "stackit_networks" "regex_and_labels" { + project_id = stackit_network.network.project_id + name_regex = "%s" + labels = { + foo = "bar" + } + depends_on = [stackit_network.network2] + } + + # 2 labels -> both must be true + data "stackit_networks" "two_labels" { + project_id = stackit_network.network.project_id + labels = { + foo = "bar" + baz = "qux" + } + depends_on = [stackit_network.network2] + } + `, + testutil.NewConfigBuilder().Experiments(testutil.ExperimentNetwork).BuildProviderConfig(), + resourceNetworkMinConfig, + name2, + name2, + name2, + ), + Check: resource.ComposeAggregateTestCheckFunc( + // All networks + resource.TestCheckResourceAttrSet("data.stackit_networks.all", "id"), + resource.TestCheckResourceAttr("data.stackit_networks.all", "items.#", "2"), + + // regex filter + resource.TestCheckResourceAttr("data.stackit_networks.regex", "items.#", "1"), + resource.TestCheckResourceAttr("data.stackit_networks.regex", "items.0.name", name2), + + // regex + label + resource.TestCheckResourceAttr("data.stackit_networks.regex_and_labels", "items.#", "1"), + resource.TestCheckResourceAttr("data.stackit_networks.regex_and_labels", "items.0.name", name2), + + // two labels + resource.TestCheckResourceAttr("data.stackit_networks.two_labels", "items.#", "1"), + resource.TestCheckResourceAttr("data.stackit_networks.two_labels", "items.0.name", name2), + ), + }, + }, + }) +} diff --git a/stackit/internal/services/iaas/networks/datasource.go b/stackit/internal/services/iaas/networks/datasource.go new file mode 100644 index 000000000..9d283fcbe --- /dev/null +++ b/stackit/internal/services/iaas/networks/datasource.go @@ -0,0 +1,224 @@ +package networks + +import ( + "context" + "fmt" + "net/http" + "regexp" + "sort" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + iaas "github.com/stackitcloud/stackit-sdk-go/services/iaas/v2api" + + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + iaasUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" +) + +var ( + _ datasource.DataSource = &networksDataSource{} +) + +// NetworkItem represents a single network in the list. +type NetworkItem struct { + NetworkId types.String `tfsdk:"network_id"` + Name types.String `tfsdk:"name"` +} + +// NetworksModel represents the model for the plural data source. +type NetworksModel struct { + Id types.String `tfsdk:"id"` + ProjectId types.String `tfsdk:"project_id"` + Region types.String `tfsdk:"region"` + NameRegex types.String `tfsdk:"name_regex"` + Labels types.Map `tfsdk:"labels"` + Items []NetworkItem `tfsdk:"items"` +} + +// NewNetworksDataSource creates a new instance of plural data source. +func NewNetworksDataSource() datasource.DataSource { + return &networksDataSource{} +} + +// networksDataSource is the data source implementation for querying multiple networks. +type networksDataSource struct { + client *iaas.APIClient + providerData core.ProviderData +} + +func (d *networksDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_networks" +} + +func (d *networksDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + var ok bool + d.providerData, ok = conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics) + if !ok { + return + } + + apiClient := iaasUtils.ConfigureClient(ctx, &d.providerData, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + d.client = apiClient + tflog.Info(ctx, "IaaS client configured") +} + +func (d *networksDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Lists STACKIT networks in a project, optionally filtered by name or labels.", + Description: "Lists STACKIT networks in a project, optionally filtered by name or labels.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "Terraform's internal resource ID. It is structured as \"`project_id`,`region`\".", + Computed: true, + }, + "project_id": schema.StringAttribute{ + Description: "STACKIT project ID to which the networks are associated.", + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "region": schema.StringAttribute{ + Description: "The resource region. If not defined, the provider region is used.", + Optional: true, + }, + "name_regex": schema.StringAttribute{ + Description: "Optional regular expression to filter networks by name.", + Optional: true, + }, + "labels": schema.MapAttribute{ + Description: "Optional label selector to filter networks by labels.", + ElementType: types.StringType, + Optional: true, + Validators: validate.LabelValidators(), + }, + "items": schema.ListNestedAttribute{ + Description: "List of networks matching the filters.", + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "network_id": schema.StringAttribute{ + Description: "The network ID.", + Computed: true, + }, + "name": schema.StringAttribute{ + Description: "The name of the network.", + Computed: true, + }, + }, + }, + }, + }, + } +} + +func (d *networksDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic + var model NetworksModel + diags := req.Config.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectId.ValueString() + region := d.providerData.GetRegionWithOverride(model.Region) + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "region", region) + + // Compile the regex if provided + var compiledRegex *regexp.Regexp + var err error + if !model.NameRegex.IsNull() && model.NameRegex.ValueString() != "" { + compiledRegex, err = regexp.Compile(model.NameRegex.ValueString()) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Invalid name_regex", err.Error()) + return + } + } + + // Fetch all networks for the given project and region, applying label selector if provided + listReq := d.client.DefaultAPI.ListNetworks(ctx, projectId, region) + if !model.Labels.IsNull() && !model.Labels.IsUnknown() { + var labels map[string]string + diags = model.Labels.ElementsAs(ctx, &labels, false) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + var pairs []string + for k, v := range labels { + if v == "" { + pairs = append(pairs, k) + } else { + pairs = append(pairs, fmt.Sprintf("%s=%s", k, v)) + } + } + sort.Strings(pairs) + labelSelector := strings.Join(pairs, ",") + if labelSelector != "" { + listReq = listReq.LabelSelector(labelSelector) + } + } + + networksResp, err := listReq.Execute() + if err != nil { + utils.LogError( + ctx, + &resp.Diagnostics, + err, + "Reading networks", + fmt.Sprintf("Networks could not be listed for project %q.", projectId), + map[int]string{ + http.StatusForbidden: fmt.Sprintf("Project with ID %q not found or forbidden access", projectId), + }, + ) + resp.State.RemoveResource(ctx) + return + } + + if networksResp == nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Reading networks", "API response is nil") + return + } + if networksResp.Items == nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Reading networks", "network items in the API response are nil") + return + } + + // Filter networks based on the compiled regex and populate the items list + items := make([]NetworkItem, 0) + for _, netResp := range networksResp.Items { + if compiledRegex != nil && !compiledRegex.MatchString(netResp.Name) { + continue + } + + item := NetworkItem{ + NetworkId: types.StringValue(netResp.Id), + Name: types.StringValue(netResp.Name), + } + items = append(items, item) + } + + model.Id = utils.BuildInternalTerraformId(projectId, region) + model.Items = items + + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Networks read") +} diff --git a/stackit/provider.go b/stackit/provider.go index f99c5eb3c..8e87d8f59 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -50,6 +50,7 @@ import ( iaasNetworkAreaRoute "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/networkarearoute" iaasNetworkInterface "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/networkinterface" iaasNetworkInterfaceAttach "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/networkinterfaceattach" + iaasNetworks "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/networks" iaasProject "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/project" iaasPublicIp "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/publicip" iaasPublicIpAssociate "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/publicipassociate" @@ -701,6 +702,7 @@ func (p *Provider) DataSources(_ context.Context) []func() datasource.DataSource iaasImage.NewImageDataSource, iaasImageV2.NewImageV2DataSource, iaasNetwork.NewNetworkDataSource, + iaasNetworks.NewNetworksDataSource, iaasNetworkArea.NewNetworkAreaDataSource, iaasNetworkAreaRegion.NewNetworkAreaRegionDataSource, iaasNetworkAreaRoute.NewNetworkAreaRouteDataSource,