From 648ed762465d601e5795872769a4e9098777d125 Mon Sep 17 00:00:00 2001
From: Michal Maciejczak <127428486+mmaciejc@users.noreply.github.com>
Date: Mon, 18 Nov 2024 11:52:06 +0100
Subject: [PATCH] Add support for bulk url resource and data source (#182)
---
docs/data-sources/urls.md | 44 ++
docs/resources/urls.md | 63 ++
examples/data-sources/fmc_urls/data-source.tf | 6 +
examples/resources/fmc_urls/import.sh | 1 +
examples/resources/fmc_urls/resource.tf | 8 +
gen/definitions/urls.yaml | 40 ++
internal/provider/data_source_fmc_urls.go | 142 +++++
.../provider/data_source_fmc_urls_test.go | 76 +++
internal/provider/model_fmc_urls.go | 251 ++++++++
internal/provider/provider.go | 2 +
internal/provider/resource_fmc_urls.go | 536 ++++++++++++++++++
internal/provider/resource_fmc_urls_test.go | 87 +++
12 files changed, 1256 insertions(+)
create mode 100644 docs/data-sources/urls.md
create mode 100644 docs/resources/urls.md
create mode 100644 examples/data-sources/fmc_urls/data-source.tf
create mode 100644 examples/resources/fmc_urls/import.sh
create mode 100644 examples/resources/fmc_urls/resource.tf
create mode 100644 gen/definitions/urls.yaml
create mode 100644 internal/provider/data_source_fmc_urls.go
create mode 100644 internal/provider/data_source_fmc_urls_test.go
create mode 100644 internal/provider/model_fmc_urls.go
create mode 100644 internal/provider/resource_fmc_urls.go
create mode 100644 internal/provider/resource_fmc_urls_test.go
diff --git a/docs/data-sources/urls.md b/docs/data-sources/urls.md
new file mode 100644
index 00000000..cb8524c9
--- /dev/null
+++ b/docs/data-sources/urls.md
@@ -0,0 +1,44 @@
+---
+# generated by https://github.com/hashicorp/terraform-plugin-docs
+page_title: "fmc_urls Data Source - terraform-provider-fmc"
+subcategory: "Objects"
+description: |-
+ This data source can read the URLs.
+---
+
+# fmc_urls (Data Source)
+
+This data source can read the URLs.
+
+## Example Usage
+
+```terraform
+data "fmc_urls" "example" {
+ items = {
+ "url_1" = {
+ }
+ }
+}
+```
+
+
+## Schema
+
+### Optional
+
+- `domain` (String) The name of the FMC domain
+- `items` (Attributes Map) Map of security zones. The key of the map is the name of the individual URL object. Renaming URL object in bulk is not yet implemented. (see [below for nested schema](#nestedatt--items))
+
+### Read-Only
+
+- `id` (String) The id of the object
+
+
+### Nested Schema for `items`
+
+Read-Only:
+
+- `description` (String) Optional user-created description.
+- `id` (String) UUID of the managed URL object.
+- `overridable` (Boolean) Indicates whether object values can be overridden.
+- `url` (String) URL value.
diff --git a/docs/resources/urls.md b/docs/resources/urls.md
new file mode 100644
index 00000000..046e4623
--- /dev/null
+++ b/docs/resources/urls.md
@@ -0,0 +1,63 @@
+---
+# generated by https://github.com/hashicorp/terraform-plugin-docs
+page_title: "fmc_urls Resource - terraform-provider-fmc"
+subcategory: "Objects"
+description: |-
+ This plural resource manages a bulk of URL objects. The FMC API supports quick bulk creation of this resource. Deletion of this resource is done one-by-one or in bulk, depending of FMC version. Modification is always done one-by-one. Updating/deleting fmc_urls can thus take much more time than creating it
+---
+
+# fmc_urls (Resource)
+
+This plural resource manages a bulk of URL objects. The FMC API supports quick bulk creation of this resource. Deletion of this resource is done one-by-one or in bulk, depending of FMC version. Modification is always done one-by-one. Updating/deleting `fmc_urls` can thus take much more time than creating it
+
+## Example Usage
+
+```terraform
+resource "fmc_urls" "example" {
+ items = {
+ url_1 = {
+ url = "https://www.example.com/app"
+ description = "My URL"
+ }
+ }
+}
+```
+
+
+## Schema
+
+### Required
+
+- `items` (Attributes Map) Map of security zones. The key of the map is the name of the individual URL object. Renaming URL object in bulk is not yet implemented. (see [below for nested schema](#nestedatt--items))
+
+### Optional
+
+- `domain` (String) The name of the FMC domain
+
+### Read-Only
+
+- `id` (String) The id of the object
+
+
+### Nested Schema for `items`
+
+Required:
+
+- `url` (String) URL value.
+
+Optional:
+
+- `description` (String) Optional user-created description.
+- `overridable` (Boolean) Indicates whether object values can be overridden.
+
+Read-Only:
+
+- `id` (String) UUID of the managed URL object.
+
+## Import
+
+Import is supported using the following syntax:
+
+```shell
+terraform import fmc_urls.example ",[]"
+```
diff --git a/examples/data-sources/fmc_urls/data-source.tf b/examples/data-sources/fmc_urls/data-source.tf
new file mode 100644
index 00000000..38e352dd
--- /dev/null
+++ b/examples/data-sources/fmc_urls/data-source.tf
@@ -0,0 +1,6 @@
+data "fmc_urls" "example" {
+ items = {
+ "url_1" = {
+ }
+ }
+}
diff --git a/examples/resources/fmc_urls/import.sh b/examples/resources/fmc_urls/import.sh
new file mode 100644
index 00000000..1f05b029
--- /dev/null
+++ b/examples/resources/fmc_urls/import.sh
@@ -0,0 +1 @@
+terraform import fmc_urls.example ",[]"
diff --git a/examples/resources/fmc_urls/resource.tf b/examples/resources/fmc_urls/resource.tf
new file mode 100644
index 00000000..4c49d2b1
--- /dev/null
+++ b/examples/resources/fmc_urls/resource.tf
@@ -0,0 +1,8 @@
+resource "fmc_urls" "example" {
+ items = {
+ url_1 = {
+ url = "https://www.example.com/app"
+ description = "My URL"
+ }
+ }
+}
diff --git a/gen/definitions/urls.yaml b/gen/definitions/urls.yaml
new file mode 100644
index 00000000..028ccaf6
--- /dev/null
+++ b/gen/definitions/urls.yaml
@@ -0,0 +1,40 @@
+---
+name: URLs
+rest_endpoint: /api/fmc_config/v1/domain/{DOMAIN_UUID}/object/urls
+is_bulk: true
+data_source_name_query: true
+import_name_query: yes
+res_description: >-
+ This plural resource manages a bulk of URL objects.
+ The FMC API supports quick bulk creation of this resource. Deletion of this resource is done one-by-one or in bulk, depending of FMC version. Modification is always done one-by-one.
+ Updating/deleting `fmc_urls` can thus take much more time than creating it
+doc_category: Objects
+attributes:
+ - model_name: items
+ type: Map
+ description: >-
+ Map of security zones. The key of the map is the name of the individual URL object. Renaming URL object in bulk
+ is not yet implemented.
+ map_key_example: url_1
+ mandatory: true
+ attributes:
+ - model_name: id
+ type: String
+ resource_id: true
+ description: UUID of the managed URL object.
+ exclude_example: true
+ exclude_test: true
+ - model_name: url
+ type: String
+ description: URL value.
+ mandatory: true
+ example: "https://www.example.com/app"
+ - model_name: description
+ type: String
+ description: Optional user-created description.
+ example: "My URL"
+ - model_name: overridable
+ type: Bool
+ description: Indicates whether object values can be overridden.
+ exclude_example: true
+ test_value: "true"
diff --git a/internal/provider/data_source_fmc_urls.go b/internal/provider/data_source_fmc_urls.go
new file mode 100644
index 00000000..ff015b8a
--- /dev/null
+++ b/internal/provider/data_source_fmc_urls.go
@@ -0,0 +1,142 @@
+// Copyright © 2023 Cisco Systems, Inc. and its affiliates.
+// All rights reserved.
+//
+// Licensed under the Mozilla Public License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// https://mozilla.org/MPL/2.0/
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: MPL-2.0
+
+package provider
+
+// Section below is generated&owned by "gen/generator.go". //template:begin imports
+import (
+ "context"
+ "fmt"
+
+ "github.com/hashicorp/terraform-plugin-framework/datasource"
+ "github.com/hashicorp/terraform-plugin-framework/datasource/schema"
+ "github.com/hashicorp/terraform-plugin-log/tflog"
+ "github.com/netascode/go-fmc"
+)
+
+// End of section. //template:end imports
+
+// Section below is generated&owned by "gen/generator.go". //template:begin model
+
+// Ensure the implementation satisfies the expected interfaces.
+var (
+ _ datasource.DataSource = &URLsDataSource{}
+ _ datasource.DataSourceWithConfigure = &URLsDataSource{}
+)
+
+func NewURLsDataSource() datasource.DataSource {
+ return &URLsDataSource{}
+}
+
+type URLsDataSource struct {
+ client *fmc.Client
+}
+
+func (d *URLsDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
+ resp.TypeName = req.ProviderTypeName + "_urls"
+}
+
+func (d *URLsDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) {
+ resp.Schema = schema.Schema{
+ // This description is used by the documentation generator and the language server.
+ MarkdownDescription: "This data source can read the URLs.",
+
+ Attributes: map[string]schema.Attribute{
+ "id": schema.StringAttribute{
+ MarkdownDescription: "The id of the object",
+ Computed: true,
+ },
+ "domain": schema.StringAttribute{
+ MarkdownDescription: "The name of the FMC domain",
+ Optional: true,
+ },
+ "items": schema.MapNestedAttribute{
+ MarkdownDescription: "Map of security zones. The key of the map is the name of the individual URL object. Renaming URL object in bulk is not yet implemented.",
+ Optional: true,
+ Computed: true,
+ NestedObject: schema.NestedAttributeObject{
+ Attributes: map[string]schema.Attribute{
+ "id": schema.StringAttribute{
+ MarkdownDescription: "UUID of the managed URL object.",
+ Computed: true,
+ },
+ "url": schema.StringAttribute{
+ MarkdownDescription: "URL value.",
+ Computed: true,
+ },
+ "description": schema.StringAttribute{
+ MarkdownDescription: "Optional user-created description.",
+ Computed: true,
+ },
+ "overridable": schema.BoolAttribute{
+ MarkdownDescription: "Indicates whether object values can be overridden.",
+ Computed: true,
+ },
+ },
+ },
+ },
+ },
+ }
+}
+
+func (d *URLsDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, _ *datasource.ConfigureResponse) {
+ if req.ProviderData == nil {
+ return
+ }
+
+ d.client = req.ProviderData.(*FmcProviderData).Client
+}
+
+// End of section. //template:end model
+
+// Section below is generated&owned by "gen/generator.go". //template:begin read
+
+func (d *URLsDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) {
+ var config URLs
+
+ // Read config
+ diags := req.Config.Get(ctx, &config)
+ resp.Diagnostics.Append(diags...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ // Set request domain if provided
+ reqMods := [](func(*fmc.Req)){}
+ if !config.Domain.IsNull() && config.Domain.ValueString() != "" {
+ reqMods = append(reqMods, fmc.DomainName(config.Domain.ValueString()))
+ }
+
+ tflog.Debug(ctx, fmt.Sprintf("%s: Beginning Read", config.Id.String()))
+
+ // Get all objects from FMC
+ urlPath := config.getPath() + "?expanded=true"
+ res, err := d.client.Get(urlPath, reqMods...)
+ if err != nil {
+ resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to retrieve object, got error: %s", err))
+ return
+ }
+
+ config.fromBody(ctx, res)
+
+ tflog.Debug(ctx, fmt.Sprintf("%s: Read finished successfully", config.Id.ValueString()))
+
+ diags = resp.State.Set(ctx, &config)
+ resp.Diagnostics.Append(diags...)
+}
+
+// End of section. //template:end read
diff --git a/internal/provider/data_source_fmc_urls_test.go b/internal/provider/data_source_fmc_urls_test.go
new file mode 100644
index 00000000..b4d56091
--- /dev/null
+++ b/internal/provider/data_source_fmc_urls_test.go
@@ -0,0 +1,76 @@
+// Copyright © 2023 Cisco Systems, Inc. and its affiliates.
+// All rights reserved.
+//
+// Licensed under the Mozilla Public License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// https://mozilla.org/MPL/2.0/
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: MPL-2.0
+
+package provider
+
+// Section below is generated&owned by "gen/generator.go". //template:begin imports
+import (
+ "testing"
+
+ "github.com/hashicorp/terraform-plugin-testing/helper/resource"
+)
+
+// End of section. //template:end imports
+
+// Section below is generated&owned by "gen/generator.go". //template:begin testAccDataSource
+
+func TestAccDataSourceFmcURLs(t *testing.T) {
+ var checks []resource.TestCheckFunc
+ checks = append(checks, resource.TestCheckResourceAttrSet("data.fmc_urls.test", "items.url_1.id"))
+ checks = append(checks, resource.TestCheckResourceAttr("data.fmc_urls.test", "items.url_1.url", "https://www.example.com/app"))
+ checks = append(checks, resource.TestCheckResourceAttr("data.fmc_urls.test", "items.url_1.description", "My URL"))
+ resource.Test(t, resource.TestCase{
+ PreCheck: func() { testAccPreCheck(t) },
+ ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
+ Steps: []resource.TestStep{
+ {
+ Config: testAccDataSourceFmcURLsConfig(),
+ Check: resource.ComposeTestCheckFunc(checks...),
+ },
+ },
+ })
+}
+
+// End of section. //template:end testAccDataSource
+
+// Section below is generated&owned by "gen/generator.go". //template:begin testPrerequisites
+// End of section. //template:end testPrerequisites
+
+// Section below is generated&owned by "gen/generator.go". //template:begin testAccDataSourceConfig
+
+func testAccDataSourceFmcURLsConfig() string {
+ config := `resource "fmc_urls" "test" {` + "\n"
+ config += ` items = { "url_1" = {` + "\n"
+ config += ` url = "https://www.example.com/app"` + "\n"
+ config += ` description = "My URL"` + "\n"
+ config += ` overridable = true` + "\n"
+ config += ` }}` + "\n"
+ config += `}` + "\n"
+
+ config += `
+ data "fmc_urls" "test" {
+ depends_on = [fmc_urls.test]
+ items = {
+ "url_1" = {
+ }
+ }
+ }
+ `
+ return config
+}
+
+// End of section. //template:end testAccDataSourceConfig
diff --git a/internal/provider/model_fmc_urls.go b/internal/provider/model_fmc_urls.go
new file mode 100644
index 00000000..0ae6cc95
--- /dev/null
+++ b/internal/provider/model_fmc_urls.go
@@ -0,0 +1,251 @@
+// Copyright © 2023 Cisco Systems, Inc. and its affiliates.
+// All rights reserved.
+//
+// Licensed under the Mozilla Public License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// https://mozilla.org/MPL/2.0/
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: MPL-2.0
+
+package provider
+
+// Section below is generated&owned by "gen/generator.go". //template:begin imports
+import (
+ "context"
+ "fmt"
+ "maps"
+
+ "github.com/hashicorp/terraform-plugin-framework/types"
+ "github.com/hashicorp/terraform-plugin-log/tflog"
+ "github.com/tidwall/gjson"
+ "github.com/tidwall/sjson"
+)
+
+// End of section. //template:end imports
+
+// Section below is generated&owned by "gen/generator.go". //template:begin types
+
+type URLs struct {
+ Id types.String `tfsdk:"id"`
+ Domain types.String `tfsdk:"domain"`
+ Items map[string]URLsItems `tfsdk:"items"`
+}
+
+type URLsItems struct {
+ Id types.String `tfsdk:"id"`
+ Url types.String `tfsdk:"url"`
+ Description types.String `tfsdk:"description"`
+ Overridable types.Bool `tfsdk:"overridable"`
+}
+
+// End of section. //template:end types
+
+// Section below is generated&owned by "gen/generator.go". //template:begin getPath
+
+func (data URLs) getPath() string {
+ return "/api/fmc_config/v1/domain/{DOMAIN_UUID}/object/urls"
+}
+
+// End of section. //template:end getPath
+
+// Section below is generated&owned by "gen/generator.go". //template:begin toBody
+
+func (data URLs) toBody(ctx context.Context, state URLs) string {
+ body := ""
+ if data.Id.ValueString() != "" {
+ body, _ = sjson.Set(body, "id", data.Id.ValueString())
+ }
+ if len(data.Items) > 0 {
+ body, _ = sjson.Set(body, "items", []interface{}{})
+ for key, item := range data.Items {
+ itemBody, _ := sjson.Set("{}", "name", key)
+ if !item.Id.IsNull() && !item.Id.IsUnknown() {
+ itemBody, _ = sjson.Set(itemBody, "id", item.Id.ValueString())
+ }
+ if !item.Url.IsNull() {
+ itemBody, _ = sjson.Set(itemBody, "url", item.Url.ValueString())
+ }
+ if !item.Description.IsNull() {
+ itemBody, _ = sjson.Set(itemBody, "description", item.Description.ValueString())
+ }
+ if !item.Overridable.IsNull() {
+ itemBody, _ = sjson.Set(itemBody, "overridable", item.Overridable.ValueBool())
+ }
+ body, _ = sjson.SetRaw(body, "items.-1", itemBody)
+ }
+ }
+ return gjson.Get(body, "items").String()
+}
+
+// End of section. //template:end toBody
+
+// Section below is generated&owned by "gen/generator.go". //template:begin fromBody
+
+func (data *URLs) fromBody(ctx context.Context, res gjson.Result) {
+ for k := range data.Items {
+ parent := &data
+ data := (*parent).Items[k]
+ parentRes := &res
+ var res gjson.Result
+
+ parentRes.Get("items").ForEach(
+ func(_, v gjson.Result) bool {
+ if v.Get("name").String() == k {
+ res = v
+ return false // break ForEach
+ }
+ return true
+ },
+ )
+ if !res.Exists() {
+ tflog.Debug(ctx, fmt.Sprintf("subresource not found, removing: name=%v", k))
+ delete((*parent).Items, k)
+ continue
+ }
+ if value := res.Get("id"); value.Exists() {
+ data.Id = types.StringValue(value.String())
+ } else {
+ data.Id = types.StringNull()
+ }
+ if value := res.Get("url"); value.Exists() {
+ data.Url = types.StringValue(value.String())
+ } else {
+ data.Url = types.StringNull()
+ }
+ if value := res.Get("description"); value.Exists() {
+ data.Description = types.StringValue(value.String())
+ } else {
+ data.Description = types.StringNull()
+ }
+ if value := res.Get("overridable"); value.Exists() {
+ data.Overridable = types.BoolValue(value.Bool())
+ } else {
+ data.Overridable = types.BoolNull()
+ }
+ (*parent).Items[k] = data
+ }
+}
+
+// End of section. //template:end fromBody
+
+// Section below is generated&owned by "gen/generator.go". //template:begin fromBodyPartial
+
+// fromBodyPartial reads values from a gjson.Result into a tfstate model. It ignores null attributes in order to
+// uncouple the provider from the exact values that the backend API might summon to replace nulls. (Such behavior might
+// easily change across versions of the backend API.) For List/Set/Map attributes, the func only updates the
+// "managed" elements, instead of all elements.
+func (data *URLs) fromBodyPartial(ctx context.Context, res gjson.Result) {
+ for i := range data.Items {
+ parent := &data
+ data := (*parent).Items[i]
+ parentRes := &res
+ var res gjson.Result
+
+ parentRes.Get("items").ForEach(
+ func(_, v gjson.Result) bool {
+ if v.Get("id").String() == data.Id.ValueString() && data.Id.ValueString() != "" {
+ res = v
+ return false // break ForEach
+ }
+ return true
+ },
+ )
+ if value := res.Get("id"); value.Exists() {
+ data.Id = types.StringValue(value.String())
+ } else {
+ data.Id = types.StringNull()
+ }
+ if value := res.Get("url"); value.Exists() && !data.Url.IsNull() {
+ data.Url = types.StringValue(value.String())
+ } else {
+ data.Url = types.StringNull()
+ }
+ if value := res.Get("description"); value.Exists() && !data.Description.IsNull() {
+ data.Description = types.StringValue(value.String())
+ } else {
+ data.Description = types.StringNull()
+ }
+ if value := res.Get("overridable"); value.Exists() && !data.Overridable.IsNull() {
+ data.Overridable = types.BoolValue(value.Bool())
+ } else {
+ data.Overridable = types.BoolNull()
+ }
+ (*parent).Items[i] = data
+ }
+}
+
+// End of section. //template:end fromBodyPartial
+
+// Section below is generated&owned by "gen/generator.go". //template:begin fromBodyUnknowns
+
+// fromBodyUnknowns updates the Unknown Computed tfstate values from a JSON.
+// Known values are not changed (usual for Computed attributes with UseStateForUnknown or with Default).
+func (data *URLs) fromBodyUnknowns(ctx context.Context, res gjson.Result) {
+ for i, val := range data.Items {
+ var r gjson.Result
+ res.Get("items").ForEach(
+ func(_, v gjson.Result) bool {
+ if val.Id.IsUnknown() {
+ if v.Get("name").String() == i {
+ r = v
+ return false // break ForEach
+ }
+ } else {
+ if v.Get("id").String() == val.Id.ValueString() && val.Id.ValueString() != "" {
+ r = v
+ return false // break ForEach
+ }
+ }
+
+ return true
+ },
+ )
+ if v := data.Items[i]; v.Id.IsUnknown() {
+ if value := r.Get("id"); value.Exists() {
+ v.Id = types.StringValue(value.String())
+ } else {
+ v.Id = types.StringNull()
+ }
+ data.Items[i] = v
+ }
+ }
+}
+
+// End of section. //template:end fromBodyUnknowns
+
+// Section below is generated&owned by "gen/generator.go". //template:begin Clone
+
+func (data *URLs) Clone() URLs {
+ ret := *data
+ ret.Items = maps.Clone(data.Items)
+
+ return ret
+}
+
+// End of section. //template:end Clone
+
+// Section below is generated&owned by "gen/generator.go". //template:begin toBodyNonBulk
+
+// Updates done one-by-one require different API body
+func (data URLs) toBodyNonBulk(ctx context.Context, state URLs) string {
+ // This is one-by-one update, so only one element to update is expected
+ if len(data.Items) > 1 {
+ tflog.Error(ctx, "Found more than one element to chage. Only one will be changed.")
+ }
+
+ // Utilize existing toBody function
+ body := data.toBody(ctx, state)
+
+ // Get first element only
+ return gjson.Get(body, "0").String()
+}
+
+// End of section. //template:end toBodyNonBulk
diff --git a/internal/provider/provider.go b/internal/provider/provider.go
index 0f0dc4a7..796633be 100644
--- a/internal/provider/provider.go
+++ b/internal/provider/provider.go
@@ -322,6 +322,7 @@ func (p *FmcProvider) Resources(ctx context.Context) []func() resource.Resource
NewStandardACLResource,
NewURLResource,
NewURLGroupResource,
+ NewURLsResource,
NewVLANTagResource,
NewVLANTagGroupResource,
}
@@ -362,6 +363,7 @@ func (p *FmcProvider) DataSources(ctx context.Context) []func() datasource.DataS
NewStandardACLDataSource,
NewURLDataSource,
NewURLGroupDataSource,
+ NewURLsDataSource,
NewVLANTagDataSource,
NewVLANTagGroupDataSource,
}
diff --git a/internal/provider/resource_fmc_urls.go b/internal/provider/resource_fmc_urls.go
new file mode 100644
index 00000000..fc229be8
--- /dev/null
+++ b/internal/provider/resource_fmc_urls.go
@@ -0,0 +1,536 @@
+// Copyright © 2023 Cisco Systems, Inc. and its affiliates.
+// All rights reserved.
+//
+// Licensed under the Mozilla Public License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// https://mozilla.org/MPL/2.0/
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: MPL-2.0
+
+package provider
+
+// Section below is generated&owned by "gen/generator.go". //template:begin imports
+import (
+ "context"
+ "fmt"
+ "net/url"
+ "regexp"
+ "strings"
+
+ "github.com/google/uuid"
+ "github.com/hashicorp/terraform-plugin-framework/diag"
+ "github.com/hashicorp/terraform-plugin-framework/path"
+ "github.com/hashicorp/terraform-plugin-framework/resource"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
+ "github.com/hashicorp/terraform-plugin-framework/types"
+ "github.com/hashicorp/terraform-plugin-log/tflog"
+ "github.com/netascode/go-fmc"
+ "github.com/netascode/terraform-provider-fmc/internal/provider/helpers"
+)
+
+// End of section. //template:end imports
+
+// Section below is generated&owned by "gen/generator.go". //template:begin model
+
+// Ensure provider defined types fully satisfy framework interfaces
+var (
+ _ resource.Resource = &URLsResource{}
+ _ resource.ResourceWithImportState = &URLsResource{}
+)
+
+func NewURLsResource() resource.Resource {
+ return &URLsResource{}
+}
+
+type URLsResource struct {
+ client *fmc.Client
+}
+
+func (r *URLsResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
+ resp.TypeName = req.ProviderTypeName + "_urls"
+}
+
+func (r *URLsResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
+ resp.Schema = schema.Schema{
+ // This description is used by the documentation generator and the language server.
+ MarkdownDescription: helpers.NewAttributeDescription("This plural resource manages a bulk of URL objects. The FMC API supports quick bulk creation of this resource. Deletion of this resource is done one-by-one or in bulk, depending of FMC version. Modification is always done one-by-one. Updating/deleting `fmc_urls` can thus take much more time than creating it").String,
+
+ Attributes: map[string]schema.Attribute{
+ "id": schema.StringAttribute{
+ MarkdownDescription: "The id of the object",
+ Computed: true,
+ PlanModifiers: []planmodifier.String{
+ stringplanmodifier.UseStateForUnknown(),
+ },
+ },
+ "domain": schema.StringAttribute{
+ MarkdownDescription: "The name of the FMC domain",
+ Optional: true,
+ PlanModifiers: []planmodifier.String{
+ stringplanmodifier.RequiresReplace(),
+ },
+ },
+ "items": schema.MapNestedAttribute{
+ MarkdownDescription: helpers.NewAttributeDescription("Map of security zones. The key of the map is the name of the individual URL object. Renaming URL object in bulk is not yet implemented.").String,
+ Required: true,
+ NestedObject: schema.NestedAttributeObject{
+ Attributes: map[string]schema.Attribute{
+ "id": schema.StringAttribute{
+ MarkdownDescription: helpers.NewAttributeDescription("UUID of the managed URL object.").String,
+ Computed: true,
+ PlanModifiers: []planmodifier.String{
+ stringplanmodifier.UseStateForUnknown(),
+ },
+ },
+ "url": schema.StringAttribute{
+ MarkdownDescription: helpers.NewAttributeDescription("URL value.").String,
+ Required: true,
+ },
+ "description": schema.StringAttribute{
+ MarkdownDescription: helpers.NewAttributeDescription("Optional user-created description.").String,
+ Optional: true,
+ },
+ "overridable": schema.BoolAttribute{
+ MarkdownDescription: helpers.NewAttributeDescription("Indicates whether object values can be overridden.").String,
+ Optional: true,
+ },
+ },
+ },
+ },
+ },
+ }
+}
+
+func (r *URLsResource) Configure(_ context.Context, req resource.ConfigureRequest, _ *resource.ConfigureResponse) {
+ if req.ProviderData == nil {
+ return
+ }
+
+ r.client = req.ProviderData.(*FmcProviderData).Client
+}
+
+// End of section. //template:end model
+
+// Section below is generated&owned by "gen/generator.go". //template:begin create
+
+func (r *URLsResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
+ var plan URLs
+
+ // Read plan
+ diags := req.Plan.Get(ctx, &plan)
+ if resp.Diagnostics.Append(diags...); resp.Diagnostics.HasError() {
+ return
+ }
+
+ // Set request domain if provided
+ reqMods := [](func(*fmc.Req)){}
+ if !plan.Domain.IsNull() && plan.Domain.ValueString() != "" {
+ reqMods = append(reqMods, fmc.DomainName(plan.Domain.ValueString()))
+ }
+
+ tflog.Debug(ctx, fmt.Sprintf("%s: Beginning Create", plan.Id.ValueString()))
+
+ // Prepare state to track creation process
+ // Create request is split to multiple requests, where just subset of them may be successful
+ state := URLs{}
+ state.Items = make(map[string]URLsItems, len(plan.Items))
+ state.Id = types.StringValue(uuid.New().String())
+ state.Domain = plan.Domain
+
+ // Create object
+ // Creation process is put in a separate function, as that same proces will be needed with `Update`
+ plan, diags = r.createSubresources(ctx, state, plan, reqMods...)
+ resp.Diagnostics.Append(diags...)
+
+ tflog.Debug(ctx, fmt.Sprintf("%s: Create finished successfully", plan.Id.ValueString()))
+
+ diags = resp.State.Set(ctx, &plan)
+ resp.Diagnostics.Append(diags...)
+
+ helpers.SetFlagImporting(ctx, false, resp.Private, &resp.Diagnostics)
+}
+
+// End of section. //template:end create
+
+// Section below is generated&owned by "gen/generator.go". //template:begin read
+
+func (r *URLsResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
+ var state URLs
+
+ // Read state
+ diags := req.State.Get(ctx, &state)
+ if resp.Diagnostics.Append(diags...); resp.Diagnostics.HasError() {
+ return
+ }
+
+ // Set request domain if provided
+ reqMods := [](func(*fmc.Req)){}
+ if !state.Domain.IsNull() && state.Domain.ValueString() != "" {
+ reqMods = append(reqMods, fmc.DomainName(state.Domain.ValueString()))
+ }
+
+ tflog.Debug(ctx, fmt.Sprintf("%s: Beginning Read", state.Id.String()))
+
+ // Get all objects from FMC
+ urlPath := state.getPath() + "?expanded=true"
+ res, err := r.client.Get(urlPath, reqMods...)
+ if err != nil {
+ resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to retrieve object (GET), got error: %s, %s", err, res.String()))
+ return
+ }
+
+ imp, diags := helpers.IsFlagImporting(ctx, req)
+ if resp.Diagnostics.Append(diags...); resp.Diagnostics.HasError() {
+ return
+ }
+
+ // After `terraform import` we switch to a full read.
+ if imp {
+ state.fromBody(ctx, res)
+ } else {
+ state.fromBodyPartial(ctx, res)
+ }
+
+ tflog.Debug(ctx, fmt.Sprintf("%s: Read finished successfully", state.Id.ValueString()))
+
+ diags = resp.State.Set(ctx, &state)
+ resp.Diagnostics.Append(diags...)
+
+ helpers.SetFlagImporting(ctx, false, resp.Private, &resp.Diagnostics)
+}
+
+// End of section. //template:end read
+
+// Section below is generated&owned by "gen/generator.go". //template:begin update
+
+func (r *URLsResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
+ var plan, state URLs
+
+ // Read plan
+ diags := req.Plan.Get(ctx, &plan)
+ if resp.Diagnostics.Append(diags...); resp.Diagnostics.HasError() {
+ return
+ }
+
+ // Read state
+ diags = req.State.Get(ctx, &state)
+ if resp.Diagnostics.Append(diags...); resp.Diagnostics.HasError() {
+ return
+ }
+
+ // Set request domain if provided
+ reqMods := [](func(*fmc.Req)){}
+ if !plan.Domain.IsNull() && plan.Domain.ValueString() != "" {
+ reqMods = append(reqMods, fmc.DomainName(plan.Domain.ValueString()))
+ }
+
+ tflog.Debug(ctx, fmt.Sprintf("%s: Beginning Update", plan.Id.ValueString()))
+
+ // DELETE
+ // Delete objects (that are present in state, but missing in plan)
+ var toDelete URLs
+ toDelete.Items = make(map[string]URLsItems)
+ planOwnedIDs := make(map[string]string, len(plan.Items))
+
+ // Prepare list of ID that are in plan
+ for k, v := range plan.Items {
+ planOwnedIDs[v.Id.ValueString()] = k
+ }
+
+ // Check if ID from state list is in plan as well. If not, mark it for delete
+ for k, v := range state.Items {
+ if _, ok := planOwnedIDs[v.Id.ValueString()]; !ok {
+ toDelete.Items[k] = v
+ }
+ }
+
+ // If there are objects marked to be deleted
+ if len(toDelete.Items) > 0 {
+ tflog.Debug(ctx, fmt.Sprintf("%s: Number of items to delete: %d", state.Id.ValueString(), len(toDelete.Items)))
+ state, diags = r.deleteSubresources(ctx, state, toDelete, reqMods...)
+ if diags != nil {
+ resp.Diagnostics.Append(diags...)
+ diags = resp.State.Set(ctx, &state)
+ resp.Diagnostics.Append(diags...)
+ return
+ }
+ }
+
+ // CREATE
+ // Create new objects (objects that have missing IDs in plan)
+ var toCreate URLs
+ toCreate.Items = make(map[string]URLsItems)
+ // Scan plan for items with no ID
+ for k, v := range plan.Items {
+ if v.Id.IsUnknown() || v.Id.IsNull() {
+ toCreate.Items[k] = v
+ }
+ }
+
+ // If there are objects marked for create
+ if len(toCreate.Items) > 0 {
+ tflog.Debug(ctx, fmt.Sprintf("%s: Number of items to create: %d", state.Id.ValueString(), len(toCreate.Items)))
+ state, diags = r.createSubresources(ctx, state, toCreate, reqMods...)
+ if diags != nil {
+ resp.Diagnostics.Append(diags...)
+ diags = resp.State.Set(ctx, &state)
+ resp.Diagnostics.Append(diags...)
+ return
+ }
+ }
+
+ // UPDATE
+ // Update objects (objects that have different definition in plan and state)
+ var notEqual bool
+ var toUpdate URLs
+ toUpdate.Items = make(map[string]URLsItems)
+
+ for _, valueState := range state.Items {
+
+ // Check if the ID from plan exists on list of ID owned by state
+ if keyState, ok := planOwnedIDs[valueState.Id.ValueString()]; ok {
+
+ // Check if items in state and plan are qual
+ notEqual, diags = helpers.IsConfigUpdatingAt(ctx, req.Plan, req.State, path.Root("items").AtMapKey(keyState))
+ if diags != nil {
+ resp.Diagnostics.Append(diags...)
+ diags = resp.State.Set(ctx, &state)
+ resp.Diagnostics.Append(diags...)
+ return
+ }
+
+ // If definitions differ, add object to update list
+ if notEqual {
+ toUpdate.Items[keyState] = plan.Items[keyState]
+ }
+ }
+ }
+
+ // If there are objects marked for update
+ if len(toUpdate.Items) > 0 {
+ tflog.Debug(ctx, fmt.Sprintf("%s: Number of items to update: %d", state.Id.ValueString(), len(toUpdate.Items)))
+ state, diags = r.updateSubresources(ctx, state, toUpdate, reqMods...)
+ if diags != nil {
+ resp.Diagnostics.Append(diags...)
+ diags = resp.State.Set(ctx, &state)
+ resp.Diagnostics.Append(diags...)
+ return
+ }
+ }
+ plan = state
+
+ tflog.Debug(ctx, fmt.Sprintf("%s: Update finished successfully", plan.Id.ValueString()))
+
+ diags = resp.State.Set(ctx, &plan)
+ resp.Diagnostics.Append(diags...)
+}
+
+// End of section. //template:end update
+
+// Section below is generated&owned by "gen/generator.go". //template:begin delete
+
+func (r *URLsResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
+ var state URLs
+
+ // Read state
+ diags := req.State.Get(ctx, &state)
+ if resp.Diagnostics.Append(diags...); resp.Diagnostics.HasError() {
+ return
+ }
+
+ // Set request domain if provided
+ reqMods := [](func(*fmc.Req)){}
+ if !state.Domain.IsNull() && state.Domain.ValueString() != "" {
+ reqMods = append(reqMods, fmc.DomainName(state.Domain.ValueString()))
+ }
+
+ tflog.Debug(ctx, fmt.Sprintf("%s: Beginning Delete", state.Id.ValueString()))
+
+ // Execute delete
+ state, diags = r.deleteSubresources(ctx, state, state, reqMods...)
+ resp.Diagnostics.Append(diags...)
+
+ // Check if every element was removed
+ if len(state.Items) > 0 {
+ tflog.Debug(ctx, fmt.Sprintf("%s: Not all elements have been removed", state.Id.ValueString()))
+ diags = resp.State.Set(ctx, &state)
+ resp.Diagnostics.Append(diags...)
+ return
+ }
+
+ tflog.Debug(ctx, fmt.Sprintf("%s: Delete finished successfully", state.Id.ValueString()))
+
+ resp.State.RemoveResource(ctx)
+}
+
+// End of section. //template:end delete
+
+// Section below is generated&owned by "gen/generator.go". //template:begin import
+func (r *URLsResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
+ // Import looks for string in the following format: ,[,,...]
+ // is optional
+ // ,,... is coma-separated list of object names
+ var config URLs
+
+ // Compile pattern for import command parsing
+ var inputPattern = regexp.MustCompile(`^(?P[^\s,]*),*\[(?P.*?),*\]`)
+
+ // Parse parameter
+ match := inputPattern.FindStringSubmatch(req.ID)
+
+ // Check if regex matched
+ if match == nil {
+ resp.Diagnostics.AddError("Import error", "Failed to parse import parameters. Please provide import string in the following format: ,[,,...]")
+ return
+ }
+
+ // Extract values
+ if tmpDomain := match[inputPattern.SubexpIndex("domain")]; tmpDomain != "" {
+ config.Domain = types.StringValue(tmpDomain)
+ }
+ names := strings.Split(match[inputPattern.SubexpIndex("names")], ",")
+
+ // Fill state with names of objects to import
+ config.Items = make(map[string]URLsItems, len(names))
+ for _, v := range names {
+ config.Items[v] = URLsItems{}
+ }
+
+ // Generate new ID
+ config.Id = types.StringValue(uuid.New().String())
+
+ // Set filled in structure
+ diags := resp.State.Set(ctx, config)
+ resp.Diagnostics.Append(diags...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ // Set import flag
+ helpers.SetFlagImporting(ctx, true, resp.Private, &resp.Diagnostics)
+}
+
+// End of section. //template:end import
+
+// Section below is generated&owned by "gen/generator.go". //template:begin createSubresources
+// createSubresources takes list of objects, splits them into bulks and creates them
+// We want to save the state after each create event, to be able track already created resources
+func (r *URLsResource) createSubresources(ctx context.Context, state, plan URLs, reqMods ...func(*fmc.Req)) (URLs, diag.Diagnostics) {
+ var idx = 0
+ var bulk URLs
+ bulk.Items = make(map[string]URLsItems, bulkSizeCreate)
+
+ tflog.Debug(ctx, fmt.Sprintf("%s: Creating bulk of objects", state.Id.ValueString()))
+
+ // iterate over all items
+ for k, v := range plan.Items {
+ // count loops
+ idx++
+
+ // add object to current bulk
+ bulk.Items[k] = v
+
+ // If bulk size was reached or all entries have been processed
+ if idx%bulkSizeCreate == 0 || idx == len(plan.Items) {
+
+ // Parse body of the request to string
+ body := bulk.toBody(ctx, URLs{})
+
+ // Execute request
+ urlPath := bulk.getPath() + "?bulk=true"
+ res, err := r.client.Post(urlPath, body, reqMods...)
+ if err != nil {
+ return state, diag.Diagnostics{
+ diag.NewErrorDiagnostic("Client Error", fmt.Sprintf("Failed to create a bulk (POST) id: %s, got error: %s, %s", state.Id.ValueString(), err, res.String())),
+ }
+ }
+
+ // Read result and save it to the state
+ bulk.fromBodyUnknowns(ctx, res)
+ for k, v := range bulk.Items {
+ state.Items[k] = v
+ }
+
+ // Clear bulk item for next run
+ bulk.Items = make(map[string]URLsItems, bulkSizeCreate)
+ }
+ }
+
+ return state, nil
+}
+
+// End of section. //template:end createSubresources
+
+// Section below is generated&owned by "gen/generator.go". //template:begin deleteSubresources
+// deleteSubresources takes list of objects and deletes them either in bulk, or one-by-one, depending on FMC version
+func (r *URLsResource) deleteSubresources(ctx context.Context, state, plan URLs, reqMods ...func(*fmc.Req)) (URLs, diag.Diagnostics) {
+ objectsToRemove := plan.Clone()
+
+ tflog.Debug(ctx, fmt.Sprintf("%s: Deleting bulk of objects", state.Id.ValueString()))
+ tflog.Debug(ctx, fmt.Sprintf("%s: One-by-one deletion mode", state.Id.ValueString()))
+ for k, v := range objectsToRemove.Items {
+ // Check if the object was not already deleted
+ if v.Id.IsNull() {
+ delete(state.Items, k)
+ continue
+ }
+
+ urlPath := state.getPath() + "/" + url.QueryEscape(v.Id.ValueString())
+ res, err := r.client.Delete(urlPath, reqMods...)
+ if err != nil {
+ return state, diag.Diagnostics{
+ diag.NewErrorDiagnostic("Client Error", fmt.Sprintf("%s: Failed to delete object (DELETE) id %s, got error: %s, %s", state.Id.ValueString(), v.Id.ValueString(), err, res.String())),
+ }
+ }
+
+ // Remove deleted item from state
+ delete(state.Items, k)
+ }
+
+ return state, nil
+}
+
+// End of section. //template:end deleteSubresources
+
+// Section below is generated&owned by "gen/generator.go". //template:begin updateSubresources
+
+// updateSubresources take elements one-by-one and updates them, as bulks are not supported
+func (r *URLsResource) updateSubresources(ctx context.Context, state, plan URLs, reqMods ...func(*fmc.Req)) (URLs, diag.Diagnostics) {
+ var tmpObject URLs
+ tmpObject.Items = make(map[string]URLsItems, 1)
+
+ tflog.Debug(ctx, fmt.Sprintf("%s: Updating bulk of objects", state.Id.ValueString()))
+
+ for k, v := range plan.Items {
+ tmpObject.Items[k] = v
+
+ body := tmpObject.toBodyNonBulk(ctx, state)
+ urlPath := tmpObject.getPath() + "/" + url.QueryEscape(v.Id.ValueString())
+ res, err := r.client.Put(urlPath, body, reqMods...)
+ if err != nil {
+ return state, diag.Diagnostics{
+ diag.NewErrorDiagnostic("Client Error", fmt.Sprintf("Failed to update object (PUT) id %s, got error: %s, %s", state.Id.ValueString(), err, res.String())),
+ }
+ }
+
+ // Update state
+ state.Items[k] = v
+
+ // Clear tmpObject.Items
+ delete(tmpObject.Items, k)
+ }
+
+ return state, nil
+}
+
+// End of section. //template:end updateSubresources
diff --git a/internal/provider/resource_fmc_urls_test.go b/internal/provider/resource_fmc_urls_test.go
new file mode 100644
index 00000000..1d86ce37
--- /dev/null
+++ b/internal/provider/resource_fmc_urls_test.go
@@ -0,0 +1,87 @@
+// Copyright © 2023 Cisco Systems, Inc. and its affiliates.
+// All rights reserved.
+//
+// Licensed under the Mozilla Public License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// https://mozilla.org/MPL/2.0/
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: MPL-2.0
+
+package provider
+
+// Section below is generated&owned by "gen/generator.go". //template:begin imports
+import (
+ "os"
+ "testing"
+
+ "github.com/hashicorp/terraform-plugin-testing/helper/resource"
+)
+
+// End of section. //template:end imports
+
+// Section below is generated&owned by "gen/generator.go". //template:begin testAcc
+
+func TestAccFmcURLs(t *testing.T) {
+ var checks []resource.TestCheckFunc
+ checks = append(checks, resource.TestCheckResourceAttrSet("fmc_urls.test", "items.url_1.id"))
+ checks = append(checks, resource.TestCheckResourceAttr("fmc_urls.test", "items.url_1.url", "https://www.example.com/app"))
+ checks = append(checks, resource.TestCheckResourceAttr("fmc_urls.test", "items.url_1.description", "My URL"))
+
+ var steps []resource.TestStep
+ if os.Getenv("SKIP_MINIMUM_TEST") == "" {
+ steps = append(steps, resource.TestStep{
+ Config: testAccFmcURLsConfig_minimum(),
+ })
+ }
+ steps = append(steps, resource.TestStep{
+ Config: testAccFmcURLsConfig_all(),
+ Check: resource.ComposeTestCheckFunc(checks...),
+ })
+
+ resource.Test(t, resource.TestCase{
+ PreCheck: func() { testAccPreCheck(t) },
+ ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
+ Steps: steps,
+ })
+}
+
+// End of section. //template:end testAcc
+
+// Section below is generated&owned by "gen/generator.go". //template:begin testPrerequisites
+// End of section. //template:end testPrerequisites
+
+// Section below is generated&owned by "gen/generator.go". //template:begin testAccConfigMinimal
+
+func testAccFmcURLsConfig_minimum() string {
+ config := `resource "fmc_urls" "test" {` + "\n"
+ config += ` items = { "url_1" = {` + "\n"
+ config += ` url = "https://www.example.com/app"` + "\n"
+ config += ` }}` + "\n"
+ config += `}` + "\n"
+ return config
+}
+
+// End of section. //template:end testAccConfigMinimal
+
+// Section below is generated&owned by "gen/generator.go". //template:begin testAccConfigAll
+
+func testAccFmcURLsConfig_all() string {
+ config := `resource "fmc_urls" "test" {` + "\n"
+ config += ` items = { "url_1" = {` + "\n"
+ config += ` url = "https://www.example.com/app"` + "\n"
+ config += ` description = "My URL"` + "\n"
+ config += ` overridable = true` + "\n"
+ config += ` }}` + "\n"
+ config += `}` + "\n"
+ return config
+}
+
+// End of section. //template:end testAccConfigAll