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