diff --git a/docs/data-sources/tag.md b/docs/data-sources/tag.md new file mode 100644 index 000000000..aeb7f4f07 --- /dev/null +++ b/docs/data-sources/tag.md @@ -0,0 +1,33 @@ +--- +page_title: "Linode: linode_tag" +description: |- + Provides details about a Linode Tag. +--- + +# Data Source: linode\_tag + +Provides information about a Linode Tag, including the objects associated with it. + +For more information, see the [Linode APIv4 documentation](https://techdocs.akamai.com/linode-api/reference/get-tagged-objects). + +## Example Usage + +```hcl +data "linode_tag" "example" { + label = "my-tag" +} +``` + +## Argument Reference + +* `label` - (Required) The label of the tag to look up. + +## Attributes Reference + +* `id` - The label of the tag. + +* `objects` - A list of objects associated with this tag. Each object has the following attributes: + + * `type` - The type of the tagged object (e.g. `linode`, `domain`, `volume`, `nodebalancer`, `reserved_ipv4_address`). + + * `id` - The ID of the tagged object. For `reserved_ipv4_address` objects, this is the IP address string. diff --git a/linode/framework_provider.go b/linode/framework_provider.go index ba479246e..f18ccf670 100644 --- a/linode/framework_provider.go +++ b/linode/framework_provider.go @@ -101,6 +101,7 @@ import ( "github.com/linode/terraform-provider-linode/v3/linode/sshkeys" "github.com/linode/terraform-provider-linode/v3/linode/stackscript" "github.com/linode/terraform-provider-linode/v3/linode/stackscripts" + "github.com/linode/terraform-provider-linode/v3/linode/tag" "github.com/linode/terraform-provider-linode/v3/linode/token" "github.com/linode/terraform-provider-linode/v3/linode/user" "github.com/linode/terraform-provider-linode/v3/linode/users" @@ -381,5 +382,6 @@ func (p *FrameworkProvider) DataSources(ctx context.Context) []func() datasource regionvpcavailability.NewDataSource, regionsvpcavailability.NewDataSource, reservediptypes.NewDataSource, + tag.NewDataSource, } } diff --git a/linode/tag/datasource_test.go b/linode/tag/datasource_test.go new file mode 100644 index 000000000..7b0980fd5 --- /dev/null +++ b/linode/tag/datasource_test.go @@ -0,0 +1,157 @@ +//go:build integration || tag + +package tag_test + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/linode/linodego" + "github.com/linode/terraform-provider-linode/v3/linode/acceptance" + "github.com/linode/terraform-provider-linode/v3/linode/tag/tmpl" +) + +func init() { + resource.AddTestSweepers("linode_tag", &resource.Sweeper{ + Name: "linode_tag", + F: sweep, + }) +} + +func sweep(prefix string) error { + client, err := acceptance.GetTestClient() + if err != nil { + return fmt.Errorf("Error getting client: %s", err) + } + + tags, err := client.ListTags(context.Background(), nil) + if err != nil { + return fmt.Errorf("Error getting tags: %s", err) + } + + for _, tag := range tags { + if !acceptance.ShouldSweep(prefix, tag.Label) { + continue + } + if err := client.DeleteTag(context.Background(), tag.Label); err != nil { + return fmt.Errorf("Error destroying tag %q during sweep: %s", tag.Label, err) + } + } + + return nil +} + +func TestAccDataSourceTag_basic(t *testing.T) { + t.Parallel() + + dsName := "data.linode_tag.test" + tagLabel := acctest.RandomWithPrefix("tf_test") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + ProtoV6ProviderFactories: acceptance.ProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + PreConfig: func() { + client, err := acceptance.GetTestClient() + if err != nil { + t.Fatalf("Error getting client: %s", err) + } + t.Cleanup(func() { + _ = client.DeleteTag(context.Background(), tagLabel) + }) + if _, err := client.CreateTag(context.Background(), linodego.TagCreateOptions{Label: tagLabel}); err != nil { + t.Fatalf("Error creating tag: %s", err) + } + }, + Config: tmpl.DataSource(t, tagLabel), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(dsName, "label", tagLabel), + resource.TestCheckResourceAttr(dsName, "id", tagLabel), + ), + }, + }, + }) +} + +func TestAccDataSourceTag_reservedIP(t *testing.T) { + t.Parallel() + + dsName := "data.linode_tag.test" + tagLabel := acctest.RandomWithPrefix("tf_test") + var reservedIPAddress string + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + ProtoV6ProviderFactories: acceptance.ProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + PreConfig: func() { + region, err := acceptance.GetRandomRegionWithCaps(nil, "core") + if err != nil { + t.Fatalf("Error finding region: %s", err) + } + client, err := acceptance.GetTestClient() + if err != nil { + t.Fatalf("Error getting client: %s", err) + } + ip, err := client.AllocateReserveIP(context.Background(), linodego.AllocateReserveIPOptions{ + Type: "ipv4", + Public: true, + Reserved: true, + Region: region, + }) + if err != nil { + t.Fatalf("Error reserving IP: %s", err) + } + reservedIPAddress = ip.Address + t.Cleanup(func() { + _ = client.DeleteReservedIPAddress(context.Background(), ip.Address) + _ = client.DeleteTag(context.Background(), tagLabel) + }) + if _, err := client.CreateTag(context.Background(), linodego.TagCreateOptions{ + Label: tagLabel, + ReservedIPv4Addresses: []string{ip.Address}, + }); err != nil { + t.Fatalf("Error creating tag: %s", err) + } + + // Poll until the reserved IP association is visible in the API + // before letting Terraform proceed (eventual consistency). + // Skip if the API environment does not support reserved_ipv4_addresses + // in POST /tags (feature may not be rolled out yet). + deadline := time.Now().Add(30 * time.Second) + visible := false + for time.Now().Before(deadline) { + objects, err := client.ListTaggedObjects(context.Background(), tagLabel, nil) + if err == nil && len(objects) > 0 { + visible = true + break + } + time.Sleep(2 * time.Second) + } + if !visible { + t.Skip("reserved_ipv4_addresses tag association not visible via API; skipping (feature may not be available in this environment)") + } + }, + Config: tmpl.DataSource(t, tagLabel), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(dsName, "label", tagLabel), + resource.TestCheckResourceAttr(dsName, "id", tagLabel), + resource.TestCheckResourceAttr(dsName, "objects.#", "1"), + resource.TestCheckResourceAttr(dsName, "objects.0.type", "reserved_ipv4_address"), + resource.TestCheckResourceAttrWith(dsName, "objects.0.id", func(val string) error { + if val != reservedIPAddress { + return fmt.Errorf("expected objects.0.id to be %q, got %q", reservedIPAddress, val) + } + return nil + }), + ), + }, + }, + }) +} diff --git a/linode/tag/framework_datasource.go b/linode/tag/framework_datasource.go new file mode 100644 index 000000000..4d2c6ff7d --- /dev/null +++ b/linode/tag/framework_datasource.go @@ -0,0 +1,69 @@ +package tag + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/linode/linodego" + "github.com/linode/terraform-provider-linode/v3/linode/helper" +) + +func NewDataSource() datasource.DataSource { + return &DataSource{ + BaseDataSource: helper.NewBaseDataSource( + helper.BaseDataSourceConfig{ + Name: "linode_tag", + Schema: &frameworkDatasourceSchema, + }, + ), + } +} + +type DataSource struct { + helper.BaseDataSource +} + +func (d *DataSource) Read( + ctx context.Context, + req datasource.ReadRequest, + resp *datasource.ReadResponse, +) { + tflog.Debug(ctx, "Read data."+d.Config.Name) + + var data DataSourceModel + + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + label := data.Label.ValueString() + ctx = tflog.SetField(ctx, "tag_label", label) + + objects, err := d.Meta.Client.ListTaggedObjects(ctx, label, nil) + if err != nil { + if linodego.IsNotFound(err) { + resp.Diagnostics.AddError( + fmt.Sprintf("Tag %q not found", label), + err.Error(), + ) + return + } + resp.Diagnostics.AddError( + fmt.Sprintf("Failed to list objects for Tag %q", label), + err.Error(), + ) + return + } + + data.ID = types.StringValue(label) + data.FlattenTaggedObjects(ctx, objects, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} diff --git a/linode/tag/framework_datasource_model.go b/linode/tag/framework_datasource_model.go new file mode 100644 index 000000000..55f4b9eee --- /dev/null +++ b/linode/tag/framework_datasource_model.go @@ -0,0 +1,75 @@ +package tag + +import ( + "context" + "fmt" + "strconv" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/linode/linodego" +) + +type DataSourceModel struct { + ID types.String `tfsdk:"id"` + Label types.String `tfsdk:"label"` + Objects types.List `tfsdk:"objects"` +} + +type TaggedObjectModel struct { + Type types.String `tfsdk:"type"` + ID types.String `tfsdk:"id"` +} + +func (data *DataSourceModel) FlattenTaggedObjects( + ctx context.Context, + objects linodego.TaggedObjectList, + diags *diag.Diagnostics, +) { + models := make([]TaggedObjectModel, 0, len(objects)) + + for _, obj := range objects { + m := TaggedObjectModel{ + Type: types.StringValue(obj.Type), + } + + switch obj.Type { + case "linode": + if inst, ok := obj.Data.(linodego.Instance); ok { + m.ID = types.StringValue(strconv.Itoa(inst.ID)) + } + case "domain": + if d, ok := obj.Data.(linodego.Domain); ok { + m.ID = types.StringValue(strconv.Itoa(d.ID)) + } + case "volume": + if v, ok := obj.Data.(linodego.Volume); ok { + m.ID = types.StringValue(strconv.Itoa(v.ID)) + } + case "nodebalancer": + if n, ok := obj.Data.(linodego.NodeBalancer); ok { + m.ID = types.StringValue(strconv.Itoa(n.ID)) + } + case "reserved_ipv4_address": + if ip, ok := obj.Data.(linodego.InstanceIP); ok { + m.ID = types.StringValue(ip.Address) + } + default: + diags.AddWarning("Unknown tagged object type", + fmt.Sprintf("tagged object type %q is not recognised; ID will be empty", obj.Type)) + m.ID = types.StringValue("") + } + + models = append(models, m) + } + + listVal, d := types.ListValueFrom( + ctx, + types.ObjectType{AttrTypes: tagObjectAttrTypes}, + models, + ) + diags.Append(d...) + if !d.HasError() { + data.Objects = listVal + } +} diff --git a/linode/tag/framework_datasource_schema.go b/linode/tag/framework_datasource_schema.go new file mode 100644 index 000000000..808457245 --- /dev/null +++ b/linode/tag/framework_datasource_schema.go @@ -0,0 +1,42 @@ +package tag + +import ( + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var tagObjectAttrTypes = map[string]attr.Type{ + "type": types.StringType, + "id": types.StringType, +} + +var frameworkDatasourceSchema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "The label of this Tag.", + Computed: true, + }, + "label": schema.StringAttribute{ + Description: "A label used to categorize resources. For display purposes only.", + Required: true, + }, + "objects": schema.ListNestedAttribute{ + Description: "The objects associated with this tag.", + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "type": schema.StringAttribute{ + Description: "The type of the tagged object " + + "(e.g. linode, domain, volume, nodebalancer, reserved_ipv4_address).", + Computed: true, + }, + "id": schema.StringAttribute{ + Description: "The ID (or address for reserved_ipv4_address) of the tagged object.", + Computed: true, + }, + }, + }, + }, + }, +} diff --git a/linode/tag/framework_models_unit_test.go b/linode/tag/framework_models_unit_test.go new file mode 100644 index 000000000..0b07d28f3 --- /dev/null +++ b/linode/tag/framework_models_unit_test.go @@ -0,0 +1,41 @@ +//go:build unit + +package tag + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/linode/linodego" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFlattenTaggedObjects_DataSource(t *testing.T) { + objects := linodego.TaggedObjectList{ + { + Type: "reserved_ipv4_address", + Data: linodego.InstanceIP{Address: "198.51.100.5"}, + }, + { + Type: "linode", + Data: linodego.Instance{ID: 999}, + }, + } + + model := &DataSourceModel{} + diags := diag.Diagnostics{} + model.FlattenTaggedObjects(context.Background(), objects, &diags) + assert.False(t, diags.HasError()) + + var items []TaggedObjectModel + diags.Append(model.Objects.ElementsAs(context.Background(), &items, false)...) + require.False(t, diags.HasError()) + require.Len(t, items, 2) + + assert.Equal(t, "reserved_ipv4_address", items[0].Type.ValueString()) + assert.Equal(t, "198.51.100.5", items[0].ID.ValueString()) + assert.Equal(t, "linode", items[1].Type.ValueString()) + assert.Equal(t, "999", items[1].ID.ValueString()) +} diff --git a/linode/tag/tmpl/datasource.gotf b/linode/tag/tmpl/datasource.gotf new file mode 100644 index 000000000..4ad5a786e --- /dev/null +++ b/linode/tag/tmpl/datasource.gotf @@ -0,0 +1,7 @@ +{{ define "tag_datasource" }} + +data "linode_tag" "test" { + label = "{{ .Label }}" +} + +{{ end }} diff --git a/linode/tag/tmpl/template.go b/linode/tag/tmpl/template.go new file mode 100644 index 000000000..317fce6fd --- /dev/null +++ b/linode/tag/tmpl/template.go @@ -0,0 +1,18 @@ +package tmpl + +import ( + "testing" + + "github.com/linode/terraform-provider-linode/v3/linode/acceptance" +) + +type TemplateData struct { + Label string +} + +func DataSource(t testing.TB, label string) string { + return acceptance.ExecuteTemplate(t, + "tag_datasource", TemplateData{ + Label: label, + }) +}