From f9cc402d580e8049904cf10cbc83e8696ed086a8 Mon Sep 17 00:00:00 2001 From: Wojciech Spyra Date: Thu, 31 Oct 2024 17:08:09 +0100 Subject: [PATCH 01/12] Add deploy resource (#87) --- docs/resources/deployment.md | 39 +++ examples/resources/fmc_deployment/resource.tf | 4 + gen/definitions/deploy_device.yaml | 42 +++ internal/provider/model_fmc_deployment.go | 167 +++++++++++ internal/provider/provider.go | 1 + internal/provider/resource_fmc_deployment.go | 275 ++++++++++++++++++ .../provider/resource_fmc_deployment_test.go | 81 ++++++ 7 files changed, 609 insertions(+) create mode 100644 docs/resources/deployment.md create mode 100644 examples/resources/fmc_deployment/resource.tf create mode 100644 gen/definitions/deploy_device.yaml create mode 100644 internal/provider/model_fmc_deployment.go create mode 100644 internal/provider/resource_fmc_deployment.go create mode 100644 internal/provider/resource_fmc_deployment_test.go diff --git a/docs/resources/deployment.md b/docs/resources/deployment.md new file mode 100644 index 00000000..1f2e87e6 --- /dev/null +++ b/docs/resources/deployment.md @@ -0,0 +1,39 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "fmc_deployment Resource - terraform-provider-fmc" +subcategory: "Deployment" +description: |- + This resource can manage a Deployment. +--- + +# fmc_deployment (Resource) + +This resource can manage a Deployment. + +## Example Usage + +```terraform +resource "fmc_deployment" "example" { + version = "1457566762351" + device_list = ["d94f7ada-d141-11e5-acf3-c41f7e67fb1b"] +} +``` + + +## Schema + +### Required + +- `device_list` (Set of String) List of device ids to be deployed. +- `version` (String) Epoch unix time stamp. + +### Optional + +- `deployment_note` (String) User note related to deployment. +- `domain` (String) The name of the FMC domain +- `force_deploy` (Boolean) Force deployment (even if there are no configuration changes). +- `ignore_warning` (Boolean) Ignore warnings during deployment. + +### Read-Only + +- `id` (String) The id of the object diff --git a/examples/resources/fmc_deployment/resource.tf b/examples/resources/fmc_deployment/resource.tf new file mode 100644 index 00000000..71ceba64 --- /dev/null +++ b/examples/resources/fmc_deployment/resource.tf @@ -0,0 +1,4 @@ +resource "fmc_deployment" "example" { + version = "1457566762351" + device_list = ["d94f7ada-d141-11e5-acf3-c41f7e67fb1b"] +} diff --git a/gen/definitions/deploy_device.yaml b/gen/definitions/deploy_device.yaml new file mode 100644 index 00000000..4190f490 --- /dev/null +++ b/gen/definitions/deploy_device.yaml @@ -0,0 +1,42 @@ +--- +name: Deployment +rest_endpoint: /api/fmc_config/v1/domain/{DOMAIN_UUID}/deployment/deploymentrequests +no_delete: true +no_update: true +no_data_source: true +no_import: true +doc_category: Deployment +attributes: + - model_name: type + type: String + value: "DeploymentRequest" + - model_name: version + type: String + description: Epoch unix time stamp. + mandatory: true + example: "1457566762351" + - model_name: ForceDeploy + type: Bool + description: Force deployment (even if there are no configuration changes). + mandatory: false + exclude_example: true + exclude_test: true + - model_name: ignoreWarning + type: Bool + description: Ignore warnings during deployment. + mandatory: false + exclude_example: true + exclude_test: true + - model_name: deviceList + type: Set + description: List of device ids to be deployed. + mandatory: true + element_type: String + example: d94f7ada-d141-11e5-acf3-c41f7e67fb1b + - model_name: deploymentNote + type: String + description: User note related to deployment. + mandatory: false + example: "yournotescomehere" + exclude_example: true + exclude_test: true diff --git a/internal/provider/model_fmc_deployment.go b/internal/provider/model_fmc_deployment.go new file mode 100644 index 00000000..d05ff88a --- /dev/null +++ b/internal/provider/model_fmc_deployment.go @@ -0,0 +1,167 @@ +// 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" + + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/netascode/terraform-provider-fmc/internal/provider/helpers" + "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 Deployment struct { + Id types.String `tfsdk:"id"` + Domain types.String `tfsdk:"domain"` + Version types.String `tfsdk:"version"` + ForceDeploy types.Bool `tfsdk:"force_deploy"` + IgnoreWarning types.Bool `tfsdk:"ignore_warning"` + DeviceList types.Set `tfsdk:"device_list"` + DeploymentNote types.String `tfsdk:"deployment_note"` +} + +// End of section. //template:end types + +// Section below is generated&owned by "gen/generator.go". //template:begin getPath + +func (data Deployment) getPath() string { + return "/api/fmc_config/v1/domain/{DOMAIN_UUID}/deployment/deploymentrequests" +} + +// End of section. //template:end getPath + +// Section below is generated&owned by "gen/generator.go". //template:begin toBody + +func (data Deployment) toBody(ctx context.Context, state Deployment) string { + body := "" + if data.Id.ValueString() != "" { + body, _ = sjson.Set(body, "id", data.Id.ValueString()) + } + body, _ = sjson.Set(body, "type", "DeploymentRequest") + if !data.Version.IsNull() { + body, _ = sjson.Set(body, "version", data.Version.ValueString()) + } + if !data.ForceDeploy.IsNull() { + body, _ = sjson.Set(body, "ForceDeploy", data.ForceDeploy.ValueBool()) + } + if !data.IgnoreWarning.IsNull() { + body, _ = sjson.Set(body, "ignoreWarning", data.IgnoreWarning.ValueBool()) + } + if !data.DeviceList.IsNull() { + var values []string + data.DeviceList.ElementsAs(ctx, &values, false) + body, _ = sjson.Set(body, "deviceList", values) + } + if !data.DeploymentNote.IsNull() { + body, _ = sjson.Set(body, "deploymentNote", data.DeploymentNote.ValueString()) + } + return body +} + +// End of section. //template:end toBody + +// Section below is generated&owned by "gen/generator.go". //template:begin fromBody + +func (data *Deployment) fromBody(ctx context.Context, res gjson.Result) { + if value := res.Get("version"); value.Exists() { + data.Version = types.StringValue(value.String()) + } else { + data.Version = types.StringNull() + } + if value := res.Get("ForceDeploy"); value.Exists() { + data.ForceDeploy = types.BoolValue(value.Bool()) + } else { + data.ForceDeploy = types.BoolNull() + } + if value := res.Get("ignoreWarning"); value.Exists() { + data.IgnoreWarning = types.BoolValue(value.Bool()) + } else { + data.IgnoreWarning = types.BoolNull() + } + if value := res.Get("deviceList"); value.Exists() { + data.DeviceList = helpers.GetStringSet(value.Array()) + } else { + data.DeviceList = types.SetNull(types.StringType) + } + if value := res.Get("deploymentNote"); value.Exists() { + data.DeploymentNote = types.StringValue(value.String()) + } else { + data.DeploymentNote = types.StringNull() + } +} + +// 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 *Deployment) fromBodyPartial(ctx context.Context, res gjson.Result) { + if value := res.Get("version"); value.Exists() && !data.Version.IsNull() { + data.Version = types.StringValue(value.String()) + } else { + data.Version = types.StringNull() + } + if value := res.Get("ForceDeploy"); value.Exists() && !data.ForceDeploy.IsNull() { + data.ForceDeploy = types.BoolValue(value.Bool()) + } else { + data.ForceDeploy = types.BoolNull() + } + if value := res.Get("ignoreWarning"); value.Exists() && !data.IgnoreWarning.IsNull() { + data.IgnoreWarning = types.BoolValue(value.Bool()) + } else { + data.IgnoreWarning = types.BoolNull() + } + if value := res.Get("deviceList"); value.Exists() && !data.DeviceList.IsNull() { + data.DeviceList = helpers.GetStringSet(value.Array()) + } else { + data.DeviceList = types.SetNull(types.StringType) + } + if value := res.Get("deploymentNote"); value.Exists() && !data.DeploymentNote.IsNull() { + data.DeploymentNote = types.StringValue(value.String()) + } else { + data.DeploymentNote = types.StringNull() + } +} + +// 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 *Deployment) fromBodyUnknowns(ctx context.Context, res gjson.Result) { +} + +// End of section. //template:end fromBodyUnknowns + +// Section below is generated&owned by "gen/generator.go". //template:begin Clone + +// End of section. //template:end Clone + +// Section below is generated&owned by "gen/generator.go". //template:begin toBodyNonBulk + +// End of section. //template:end toBodyNonBulk diff --git a/internal/provider/provider.go b/internal/provider/provider.go index df96f1de..09f2b9a3 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -289,6 +289,7 @@ func (p *FmcProvider) Configure(ctx context.Context, req provider.ConfigureReque func (p *FmcProvider) Resources(ctx context.Context) []func() resource.Resource { return []func() resource.Resource{ NewAccessControlPolicyResource, + NewDeploymentResource, NewDeviceResource, NewDeviceIPv4StaticRouteResource, NewDeviceIPv6StaticRouteResource, diff --git a/internal/provider/resource_fmc_deployment.go b/internal/provider/resource_fmc_deployment.go new file mode 100644 index 00000000..2a0a885a --- /dev/null +++ b/internal/provider/resource_fmc_deployment.go @@ -0,0 +1,275 @@ +// 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" + "strings" + + "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 = &DeploymentResource{} +) + +func NewDeploymentResource() resource.Resource { + return &DeploymentResource{} +} + +type DeploymentResource struct { + client *fmc.Client +} + +func (r *DeploymentResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_deployment" +} + +func (r *DeploymentResource) 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 resource can manage a Deployment.").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(), + }, + }, + "version": schema.StringAttribute{ + MarkdownDescription: helpers.NewAttributeDescription("Epoch unix time stamp.").String, + Required: true, + }, + "force_deploy": schema.BoolAttribute{ + MarkdownDescription: helpers.NewAttributeDescription("Force deployment (even if there are no configuration changes).").String, + Optional: true, + }, + "ignore_warning": schema.BoolAttribute{ + MarkdownDescription: helpers.NewAttributeDescription("Ignore warnings during deployment.").String, + Optional: true, + }, + "device_list": schema.SetAttribute{ + MarkdownDescription: helpers.NewAttributeDescription("List of device ids to be deployed.").String, + ElementType: types.StringType, + Required: true, + }, + "deployment_note": schema.StringAttribute{ + MarkdownDescription: helpers.NewAttributeDescription("User note related to deployment.").String, + Optional: true, + }, + }, + } +} + +func (r *DeploymentResource) 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 *DeploymentResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan Deployment + + // 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())) + + // Create object + body := plan.toBody(ctx, Deployment{}) + res, err := r.client.Post(plan.getPath(), body, reqMods...) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to configure object (POST/PUT), got error: %s, %s", err, res.String())) + return + } + plan.Id = types.StringValue(res.Get("id").String()) + + 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 *DeploymentResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state Deployment + + // 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())) + + urlPath := state.getPath() + "/" + url.QueryEscape(state.Id.ValueString()) + res, err := r.client.Get(urlPath, reqMods...) + + if err != nil && strings.Contains(err.Error(), "StatusCode 404") { + resp.State.RemoveResource(ctx) + return + } else 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 *DeploymentResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state Deployment + + // 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())) + + 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 *DeploymentResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state Deployment + + // 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())) + + 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 +// End of section. //template:end import + +// Section below is generated&owned by "gen/generator.go". //template:begin createSubresources + +// End of section. //template:end createSubresources + +// Section below is generated&owned by "gen/generator.go". //template:begin deleteSubresources + +// End of section. //template:end deleteSubresources + +// Section below is generated&owned by "gen/generator.go". //template:begin updateSubresources + +// End of section. //template:end updateSubresources diff --git a/internal/provider/resource_fmc_deployment_test.go b/internal/provider/resource_fmc_deployment_test.go new file mode 100644 index 00000000..30f1ceca --- /dev/null +++ b/internal/provider/resource_fmc_deployment_test.go @@ -0,0 +1,81 @@ +// 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 TestAccFmcDeployment(t *testing.T) { + var checks []resource.TestCheckFunc + checks = append(checks, resource.TestCheckResourceAttr("fmc_deployment.test", "version", "1457566762351")) + + var steps []resource.TestStep + if os.Getenv("SKIP_MINIMUM_TEST") == "" { + steps = append(steps, resource.TestStep{ + Config: testAccFmcDeploymentConfig_minimum(), + }) + } + steps = append(steps, resource.TestStep{ + Config: testAccFmcDeploymentConfig_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 testAccFmcDeploymentConfig_minimum() string { + config := `resource "fmc_deployment" "test" {` + "\n" + config += ` version = "1457566762351"` + "\n" + config += ` device_list = ["d94f7ada-d141-11e5-acf3-c41f7e67fb1b"]` + "\n" + config += `}` + "\n" + return config +} + +// End of section. //template:end testAccConfigMinimal + +// Section below is generated&owned by "gen/generator.go". //template:begin testAccConfigAll + +func testAccFmcDeploymentConfig_all() string { + config := `resource "fmc_deployment" "test" {` + "\n" + config += ` version = "1457566762351"` + "\n" + config += ` device_list = ["d94f7ada-d141-11e5-acf3-c41f7e67fb1b"]` + "\n" + config += `}` + "\n" + return config +} + +// End of section. //template:end testAccConfigAll From 27c727d854f458105f4e33dcc71d756c3368f997 Mon Sep 17 00:00:00 2001 From: Wojciech Spyra Date: Thu, 21 Nov 2024 13:27:07 +0100 Subject: [PATCH 02/12] WIP: maniputale TF stae based on FMC job history --- docs/resources/deployment.md | 4 +- .../fmc_deployment/data-source.tf | 3 + examples/resources/fmc_deployment/resource.tf | 2 +- gen/definitions/deploy_device.yaml | 21 +- .../provider/data_source_fmc_deployment.go | 138 +++++++++++++ .../data_source_fmc_deployment_test.go | 80 ++++++++ internal/provider/resource_fmc_deployment.go | 183 ++++++++++++++++-- .../provider/resource_fmc_deployment_test.go | 27 ++- main.go | 7 + 9 files changed, 437 insertions(+), 28 deletions(-) create mode 100644 examples/data-sources/fmc_deployment/data-source.tf create mode 100644 internal/provider/data_source_fmc_deployment.go create mode 100644 internal/provider/data_source_fmc_deployment_test.go diff --git a/docs/resources/deployment.md b/docs/resources/deployment.md index 1f2e87e6..2a17b224 100644 --- a/docs/resources/deployment.md +++ b/docs/resources/deployment.md @@ -15,7 +15,7 @@ This resource can manage a Deployment. ```terraform resource "fmc_deployment" "example" { version = "1457566762351" - device_list = ["d94f7ada-d141-11e5-acf3-c41f7e67fb1b"] + device_list = ["2fe9063e-8bd5-11ef-9475-e4aeac78cf37"] } ``` @@ -25,7 +25,7 @@ resource "fmc_deployment" "example" { ### Required - `device_list` (Set of String) List of device ids to be deployed. -- `version` (String) Epoch unix time stamp. +- `version` (String) Epoch unix time stamp (13 digits). ### Optional diff --git a/examples/data-sources/fmc_deployment/data-source.tf b/examples/data-sources/fmc_deployment/data-source.tf new file mode 100644 index 00000000..4c6e8ffa --- /dev/null +++ b/examples/data-sources/fmc_deployment/data-source.tf @@ -0,0 +1,3 @@ +data "fmc_deployment" "example" { + id = "76d24097-41c4-4558-a4d0-a8c07ac08470" +} diff --git a/examples/resources/fmc_deployment/resource.tf b/examples/resources/fmc_deployment/resource.tf index 71ceba64..74afe93f 100644 --- a/examples/resources/fmc_deployment/resource.tf +++ b/examples/resources/fmc_deployment/resource.tf @@ -1,4 +1,4 @@ resource "fmc_deployment" "example" { version = "1457566762351" - device_list = ["d94f7ada-d141-11e5-acf3-c41f7e67fb1b"] + device_list = ["2fe9063e-8bd5-11ef-9475-e4aeac78cf37"] } diff --git a/gen/definitions/deploy_device.yaml b/gen/definitions/deploy_device.yaml index 4190f490..942ca17c 100644 --- a/gen/definitions/deploy_device.yaml +++ b/gen/definitions/deploy_device.yaml @@ -6,37 +6,52 @@ no_update: true no_data_source: true no_import: true doc_category: Deployment +test_tags: [TF_VAR_timestamp,TF_VAR_device_id_list] attributes: - model_name: type type: String value: "DeploymentRequest" - model_name: version type: String - description: Epoch unix time stamp. + description: Epoch unix time stamp (13 digits). mandatory: true example: "1457566762351" + test_value: var.timestamp - model_name: ForceDeploy + tf_name: force_deploy type: Bool description: Force deployment (even if there are no configuration changes). mandatory: false exclude_example: true exclude_test: true - model_name: ignoreWarning + tf_name: ignore_warning type: Bool description: Ignore warnings during deployment. mandatory: false exclude_example: true - exclude_test: true + test_value: true + minimum_test_value: true - model_name: deviceList + tf_name: device_list type: Set description: List of device ids to be deployed. mandatory: true element_type: String - example: d94f7ada-d141-11e5-acf3-c41f7e67fb1b + example: 2fe9063e-8bd5-11ef-9475-e4aeac78cf37 + test_value: var.device_id_list - model_name: deploymentNote + tf_name: deployment_note type: String description: User note related to deployment. mandatory: false example: "yournotescomehere" exclude_example: true exclude_test: true + +test_prerequisites: |- + variable "timestamp" { default = null } // tests will set $TF_VAR_timestamp + variable "device_id_list" { // tests will set $TF_VAR_device_id_list + type = list(string) + default = null + } \ No newline at end of file diff --git a/internal/provider/data_source_fmc_deployment.go b/internal/provider/data_source_fmc_deployment.go new file mode 100644 index 00000000..21ba11e7 --- /dev/null +++ b/internal/provider/data_source_fmc_deployment.go @@ -0,0 +1,138 @@ +// 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" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + "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 = &DeploymentDataSource{} + _ datasource.DataSourceWithConfigure = &DeploymentDataSource{} +) + +func NewDeploymentDataSource() datasource.DataSource { + return &DeploymentDataSource{} +} + +type DeploymentDataSource struct { + client *fmc.Client +} + +func (d *DeploymentDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_deployment" +} + +func (d *DeploymentDataSource) 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 Deployment.", + + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + MarkdownDescription: "The id of the object", + Required: true, + }, + "domain": schema.StringAttribute{ + MarkdownDescription: "The name of the FMC domain", + Optional: true, + }, + "version": schema.StringAttribute{ + MarkdownDescription: "Epoch unix time stamp (13 digits).", + Computed: true, + }, + "force_deploy": schema.BoolAttribute{ + MarkdownDescription: "Force deployment (even if there are no configuration changes).", + Computed: true, + }, + "ignore_warning": schema.BoolAttribute{ + MarkdownDescription: "Ignore warnings during deployment.", + Computed: true, + }, + "device_list": schema.SetAttribute{ + MarkdownDescription: "List of device ids to be deployed.", + ElementType: types.StringType, + Computed: true, + }, + "deployment_note": schema.StringAttribute{ + MarkdownDescription: "User note related to deployment.", + Computed: true, + }, + }, + } +} + +func (d *DeploymentDataSource) 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 *DeploymentDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var config Deployment + + // 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())) + urlPath := config.getPath() + "/" + url.QueryEscape(config.Id.ValueString()) + 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_deployment_test.go b/internal/provider/data_source_fmc_deployment_test.go new file mode 100644 index 00000000..212ff0e5 --- /dev/null +++ b/internal/provider/data_source_fmc_deployment_test.go @@ -0,0 +1,80 @@ +// 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 testAccDataSource + +func TestAccDataSourceFmcDeployment(t *testing.T) { + if os.Getenv("TF_VAR_timestamp") == "" && os.Getenv("TF_VAR_device_id_list") == "" { + t.Skip("skipping test, set environment variable TF_VAR_timestamp or TF_VAR_device_id_list") + } + var checks []resource.TestCheckFunc + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccDataSourceFmcDeploymentPrerequisitesConfig + testAccDataSourceFmcDeploymentConfig(), + Check: resource.ComposeTestCheckFunc(checks...), + }, + }, + }) +} + +// End of section. //template:end testAccDataSource + +// Section below is generated&owned by "gen/generator.go". //template:begin testPrerequisites + +const testAccDataSourceFmcDeploymentPrerequisitesConfig = ` +variable "timestamp" { default = null } // tests will set $TF_VAR_timestamp +variable "device_id_list" { // tests will set $TF_VAR_device_id_list + type = list(string) + default = null +} +` + +// End of section. //template:end testPrerequisites + +// Section below is generated&owned by "gen/generator.go". //template:begin testAccDataSourceConfig + +func testAccDataSourceFmcDeploymentConfig() string { + config := `resource "fmc_deployment" "test" {` + "\n" + config += ` version = var.timestamp` + "\n" + config += ` ignore_warning = true` + "\n" + config += ` device_list = var.device_id_list` + "\n" + config += `}` + "\n" + + config += ` + data "fmc_deployment" "test" { + id = fmc_deployment.test.id + } + ` + return config +} + +// End of section. //template:end testAccDataSourceConfig diff --git a/internal/provider/resource_fmc_deployment.go b/internal/provider/resource_fmc_deployment.go index 2a0a885a..81789c96 100644 --- a/internal/provider/resource_fmc_deployment.go +++ b/internal/provider/resource_fmc_deployment.go @@ -17,13 +17,14 @@ package provider -// Section below is generated&owned by "gen/generator.go". //template:begin imports import ( "context" + "encoding/json" "fmt" - "net/url" "strings" + "github.com/tidwall/gjson" + "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" @@ -34,8 +35,6 @@ import ( "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 @@ -76,7 +75,7 @@ func (r *DeploymentResource) Schema(ctx context.Context, req resource.SchemaRequ }, }, "version": schema.StringAttribute{ - MarkdownDescription: helpers.NewAttributeDescription("Epoch unix time stamp.").String, + MarkdownDescription: helpers.NewAttributeDescription("Epoch unix time stamp (13 digits).").String, Required: true, }, "force_deploy": schema.BoolAttribute{ @@ -110,8 +109,6 @@ func (r *DeploymentResource) Configure(_ context.Context, req resource.Configure // End of section. //template:end model -// Section below is generated&owned by "gen/generator.go". //template:begin create - func (r *DeploymentResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { var plan Deployment @@ -121,6 +118,8 @@ func (r *DeploymentResource) Create(ctx context.Context, req resource.CreateRequ return } + myInterfaceUUID := strings.ReplaceAll(plan.DeviceList.Elements()[0].String(), `"`, "") + // Set request domain if provided reqMods := [](func(*fmc.Req)){} if !plan.Domain.IsNull() && plan.Domain.ValueString() != "" { @@ -136,7 +135,96 @@ func (r *DeploymentResource) Create(ctx context.Context, req resource.CreateRequ resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to configure object (POST/PUT), got error: %s, %s", err, res.String())) return } - plan.Id = types.StringValue(res.Get("id").String()) + + // BoCODE + // As there are no GET api call for /api/fmc_config/v1/domain/{domainUUID}/deployment/deploymentrequests + // We need to create abstraction state which reflects deployemnt + // We can use /api/fmc_config/v1/domain/{domainUUID}/deployment/jobhistories API + // and /api/fmc_config/v1/domain/{domainUUID}/job/taskstatuses/{objectId} (optionally, no jobID in taskstauses) + // First after POST /api/fmc_config/v1/domain/{domainUUID}/deployment/deploymentrequests + // we have to moinitor task status which available in response body of POST api call + // then, when deployment task is finished we need to store Deployment job into tf state + + urlPath := "/api/fmc_config/v1/domain/{DOMAIN_UUID}/deployment/jobhistories?expanded=true" + res, err = r.client.Get(urlPath, reqMods...) + jsonString := res.String() + var resMap map[string]interface{} + err = json.Unmarshal([]byte(jsonString), &resMap) + if err != nil { + tflog.Debug(ctx, fmt.Sprintf("%s: Error parsing JSON data (jobhistories)", "")) + } + + // Variable to store the matching item + var matchingItem map[string]interface{} + // Slice to collect matching data + var matchingData []interface{} + + // Access "items" + items, ok := resMap["items"].([]interface{}) + if !ok { + tflog.Debug(ctx, fmt.Sprintf("%s: Error: 'items' is not a valid array", "")) + } + + // Iterate over items to find the JobID based on devices UUID +JonbIdLookup: + for _, item := range items { + itemMap, ok := item.(map[string]interface{}) + if !ok { + continue + } + + // // Check if the ID matches + // if id, ok := itemMap["id"].(string); ok && id == my_job_id { + // matchingItem = itemMap + // break // Exit the loop as we found the match + // } + + if itemMap["jobType"].(interface{}).(string) != "DEPLOYMENT" { + continue + } + + deviceList, ok := itemMap["deviceList"].([]interface{}) + + jobId, ok := itemMap["id"].(string) + if !ok { + continue + } + println(jobId) + + // Iterate over deviceData to check deviceUUID + for _, device := range deviceList { + deviceMap, ok := device.(map[string]interface{}) + if !ok { + continue + } + + // deviceDetails, ok := deviceMap["data"].(map[string]interface{}) + // if !ok { + // continue + // } + deviceUUID, ok := deviceMap["deviceUUID"].(interface{}).(string) + if !ok { + continue + } + + if deviceUUID == myInterfaceUUID { + plan.Id = types.StringValue(jobId) + matchingData = append(matchingData, deviceMap) + break JonbIdLookup + } + } + } + + // Print the matching item + if matchingItem != nil { + tflog.Debug(ctx, fmt.Sprintf("Matching item: %+v\n", matchingItem)) + } else { + tflog.Debug(ctx, fmt.Sprintf("No matching item found. %+v\n", "")) + } + + // EoCODE + + // plan.Id = types.StringValue(res.Get("id").String()) tflog.Debug(ctx, fmt.Sprintf("%s: Create finished successfully", plan.Id.ValueString())) @@ -146,10 +234,6 @@ func (r *DeploymentResource) Create(ctx context.Context, req resource.CreateRequ 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 *DeploymentResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { var state Deployment @@ -167,8 +251,79 @@ func (r *DeploymentResource) Read(ctx context.Context, req resource.ReadRequest, tflog.Debug(ctx, fmt.Sprintf("%s: Beginning Read", state.Id.String())) - urlPath := state.getPath() + "/" + url.QueryEscape(state.Id.ValueString()) + // BoCODE + + // Target job ID to match + // my_job_id := "0050568A-2561-0ed3-0000-004294994451" + my_job_id := state.Id.ValueString() + + // https://10.50.202.94/api/fmc_config/v1/domain/e276abec-e0f2-11e3-8169-6d9ed49b625f/deployment/jobhistories?expanded=true + // urlPath := state.getPath() + "/" + url.QueryEscape(state.Id.ValueString()) + + // urlPath := "/api/fmc_config/v1/domain/{DOMAIN_UUID}/deployment/jobhistories?expanded=true" + urlPath := "/api/fmc_config/v1/domain/{DOMAIN_UUID}/deployment/jobhistories/" + my_job_id + res, err := r.client.Get(urlPath, reqMods...) + jsonString := res.String() + var resMap map[string]interface{} + err = json.Unmarshal([]byte(jsonString), &resMap) + if err != nil { + tflog.Debug(ctx, fmt.Sprintf("%s: Error parsing JSON data (jobhistories)", "")) + } + + // Get resource details from job history + resDeviceList := state.DeviceList.Elements() + // Modify matchingItem to be in resource format + resMap["deviceList"] = resDeviceList + + // Convert the modified resMap back to JSON + updatedJSON, err := json.Marshal(resMap) + if err != nil { + fmt.Printf("Error converting map to JSON: %v\n", err) + return + } + + res = gjson.Parse(string(updatedJSON)) + + matchingItem := resMap + + // // Variable to store the matching item + // var matchingItem map[string]interface{} + + // // Access "items" + // items, ok := resMap["items"].([]interface{}) + // if !ok { + // tflog.Debug(ctx, fmt.Sprintf("%s: Error: 'items' is not a valid array", "")) + // } + + // // Iterate over items to find the matching ID + // for _, item := range items { + // itemMap, ok := item.(map[string]interface{}) + // if !ok { + // continue + // } + + // // // Navigate to data + // // data, ok := itemMap["data"].(map[string]interface{}) + // // if !ok { + // // continue + // // } + + // // Check if the ID matches + // if id, ok := itemMap["id"].(string); ok && id == my_job_id { + // matchingItem = itemMap + // break // Exit the loop as we found the match + // } + // } + + // Print the matching item + if matchingItem != nil { + tflog.Debug(ctx, fmt.Sprintf("Matching item: %+v\n", matchingItem)) + } else { + tflog.Debug(ctx, fmt.Sprintf("No matching item found. %+v\n", "")) + } + + // EoCODE if err != nil && strings.Contains(err.Error(), "StatusCode 404") { resp.State.RemoveResource(ctx) @@ -198,8 +353,6 @@ func (r *DeploymentResource) Read(ctx context.Context, req resource.ReadRequest, 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 *DeploymentResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { diff --git a/internal/provider/resource_fmc_deployment_test.go b/internal/provider/resource_fmc_deployment_test.go index 30f1ceca..ce038c7f 100644 --- a/internal/provider/resource_fmc_deployment_test.go +++ b/internal/provider/resource_fmc_deployment_test.go @@ -30,17 +30,19 @@ import ( // Section below is generated&owned by "gen/generator.go". //template:begin testAcc func TestAccFmcDeployment(t *testing.T) { + if os.Getenv("TF_VAR_timestamp") == "" && os.Getenv("TF_VAR_device_id_list") == "" { + t.Skip("skipping test, set environment variable TF_VAR_timestamp or TF_VAR_device_id_list") + } var checks []resource.TestCheckFunc - checks = append(checks, resource.TestCheckResourceAttr("fmc_deployment.test", "version", "1457566762351")) var steps []resource.TestStep if os.Getenv("SKIP_MINIMUM_TEST") == "" { steps = append(steps, resource.TestStep{ - Config: testAccFmcDeploymentConfig_minimum(), + Config: testAccFmcDeploymentPrerequisitesConfig + testAccFmcDeploymentConfig_minimum(), }) } steps = append(steps, resource.TestStep{ - Config: testAccFmcDeploymentConfig_all(), + Config: testAccFmcDeploymentPrerequisitesConfig + testAccFmcDeploymentConfig_all(), Check: resource.ComposeTestCheckFunc(checks...), }) @@ -54,14 +56,24 @@ func TestAccFmcDeployment(t *testing.T) { // End of section. //template:end testAcc // Section below is generated&owned by "gen/generator.go". //template:begin testPrerequisites + +const testAccFmcDeploymentPrerequisitesConfig = ` +variable "timestamp" { default = null } // tests will set $TF_VAR_timestamp +variable "device_id_list" { // tests will set $TF_VAR_device_id_list + type = list(string) + default = null +} +` + // End of section. //template:end testPrerequisites // Section below is generated&owned by "gen/generator.go". //template:begin testAccConfigMinimal func testAccFmcDeploymentConfig_minimum() string { config := `resource "fmc_deployment" "test" {` + "\n" - config += ` version = "1457566762351"` + "\n" - config += ` device_list = ["d94f7ada-d141-11e5-acf3-c41f7e67fb1b"]` + "\n" + config += ` version = var.timestamp` + "\n" + config += ` ignore_warning = true` + "\n" + config += ` device_list = var.device_id_list` + "\n" config += `}` + "\n" return config } @@ -72,8 +84,9 @@ func testAccFmcDeploymentConfig_minimum() string { func testAccFmcDeploymentConfig_all() string { config := `resource "fmc_deployment" "test" {` + "\n" - config += ` version = "1457566762351"` + "\n" - config += ` device_list = ["d94f7ada-d141-11e5-acf3-c41f7e67fb1b"]` + "\n" + config += ` version = var.timestamp` + "\n" + config += ` ignore_warning = true` + "\n" + config += ` device_list = var.device_id_list` + "\n" config += `}` + "\n" return config } diff --git a/main.go b/main.go index 91a53da9..7daef432 100644 --- a/main.go +++ b/main.go @@ -19,6 +19,7 @@ package main import ( "context" + "flag" "log" "github.com/hashicorp/terraform-plugin-framework/providerserver" @@ -54,8 +55,14 @@ var ( ) func main() { + var debug bool + + flag.BoolVar(&debug, "debug", false, "set to true to run the provider with support for debuggers like delve") + flag.Parse() + opts := providerserver.ServeOpts{ Address: "registry.terraform.io/netascode/fmc", + Debug: debug, } err := providerserver.Serve(context.Background(), provider.New(version), opts) From 4bcae2bc2dd9e5b5397c74b06a494f9754d4b681 Mon Sep 17 00:00:00 2001 From: Wojciech Spyra Date: Wed, 27 Nov 2024 11:49:17 +0100 Subject: [PATCH 03/12] WIP: 2# maniputale TF stae based on FMC job history --- docs/resources/deployment.md | 5 +- gen/definitions/deploy_device.yaml | 10 +- internal/provider/model_fmc_deployment.go | 24 +++- internal/provider/resource_fmc_deployment.go | 125 ++++++++---------- .../provider/resource_fmc_deployment_test.go | 1 - 5 files changed, 83 insertions(+), 82 deletions(-) diff --git a/docs/resources/deployment.md b/docs/resources/deployment.md index 2a17b224..e42e5fc2 100644 --- a/docs/resources/deployment.md +++ b/docs/resources/deployment.md @@ -24,8 +24,7 @@ resource "fmc_deployment" "example" { ### Required -- `device_list` (Set of String) List of device ids to be deployed. -- `version` (String) Epoch unix time stamp (13 digits). +- `device_list` (List of String) List of device ids to be deployed. ### Optional @@ -33,6 +32,8 @@ resource "fmc_deployment" "example" { - `domain` (String) The name of the FMC domain - `force_deploy` (Boolean) Force deployment (even if there are no configuration changes). - `ignore_warning` (Boolean) Ignore warnings during deployment. +- `jobid` (String) JobId of deployment. +- `version` (String) Epoch unix time stamp (13 digits). ### Read-Only diff --git a/gen/definitions/deploy_device.yaml b/gen/definitions/deploy_device.yaml index 942ca17c..7e76244f 100644 --- a/gen/definitions/deploy_device.yaml +++ b/gen/definitions/deploy_device.yaml @@ -14,9 +14,15 @@ attributes: - model_name: version type: String description: Epoch unix time stamp (13 digits). - mandatory: true + mandatory: false example: "1457566762351" test_value: var.timestamp + - model_name: jobid + type: String + description: JobId of deployment. + mandatory: false + exclude_example: true + exclude_test: true - model_name: ForceDeploy tf_name: force_deploy type: Bool @@ -34,7 +40,7 @@ attributes: minimum_test_value: true - model_name: deviceList tf_name: device_list - type: Set + type: List description: List of device ids to be deployed. mandatory: true element_type: String diff --git a/internal/provider/model_fmc_deployment.go b/internal/provider/model_fmc_deployment.go index d05ff88a..5f475723 100644 --- a/internal/provider/model_fmc_deployment.go +++ b/internal/provider/model_fmc_deployment.go @@ -35,9 +35,10 @@ type Deployment struct { Id types.String `tfsdk:"id"` Domain types.String `tfsdk:"domain"` Version types.String `tfsdk:"version"` + Jobid types.String `tfsdk:"jobid"` ForceDeploy types.Bool `tfsdk:"force_deploy"` IgnoreWarning types.Bool `tfsdk:"ignore_warning"` - DeviceList types.Set `tfsdk:"device_list"` + DeviceList types.List `tfsdk:"device_list"` DeploymentNote types.String `tfsdk:"deployment_note"` } @@ -62,6 +63,9 @@ func (data Deployment) toBody(ctx context.Context, state Deployment) string { if !data.Version.IsNull() { body, _ = sjson.Set(body, "version", data.Version.ValueString()) } + if !data.Jobid.IsNull() { + body, _ = sjson.Set(body, "jobid", data.Jobid.ValueString()) + } if !data.ForceDeploy.IsNull() { body, _ = sjson.Set(body, "ForceDeploy", data.ForceDeploy.ValueBool()) } @@ -89,6 +93,11 @@ func (data *Deployment) fromBody(ctx context.Context, res gjson.Result) { } else { data.Version = types.StringNull() } + if value := res.Get("jobid"); value.Exists() { + data.Jobid = types.StringValue(value.String()) + } else { + data.Jobid = types.StringNull() + } if value := res.Get("ForceDeploy"); value.Exists() { data.ForceDeploy = types.BoolValue(value.Bool()) } else { @@ -100,9 +109,9 @@ func (data *Deployment) fromBody(ctx context.Context, res gjson.Result) { data.IgnoreWarning = types.BoolNull() } if value := res.Get("deviceList"); value.Exists() { - data.DeviceList = helpers.GetStringSet(value.Array()) + data.DeviceList = helpers.GetStringList(value.Array()) } else { - data.DeviceList = types.SetNull(types.StringType) + data.DeviceList = types.ListNull(types.StringType) } if value := res.Get("deploymentNote"); value.Exists() { data.DeploymentNote = types.StringValue(value.String()) @@ -125,6 +134,11 @@ func (data *Deployment) fromBodyPartial(ctx context.Context, res gjson.Result) { } else { data.Version = types.StringNull() } + if value := res.Get("jobid"); value.Exists() && !data.Jobid.IsNull() { + data.Jobid = types.StringValue(value.String()) + } else { + data.Jobid = types.StringNull() + } if value := res.Get("ForceDeploy"); value.Exists() && !data.ForceDeploy.IsNull() { data.ForceDeploy = types.BoolValue(value.Bool()) } else { @@ -136,9 +150,9 @@ func (data *Deployment) fromBodyPartial(ctx context.Context, res gjson.Result) { data.IgnoreWarning = types.BoolNull() } if value := res.Get("deviceList"); value.Exists() && !data.DeviceList.IsNull() { - data.DeviceList = helpers.GetStringSet(value.Array()) + data.DeviceList = helpers.GetStringList(value.Array()) } else { - data.DeviceList = types.SetNull(types.StringType) + data.DeviceList = types.ListNull(types.StringType) } if value := res.Get("deploymentNote"); value.Exists() && !data.DeploymentNote.IsNull() { data.DeploymentNote = types.StringValue(value.String()) diff --git a/internal/provider/resource_fmc_deployment.go b/internal/provider/resource_fmc_deployment.go index 81789c96..e726db8a 100644 --- a/internal/provider/resource_fmc_deployment.go +++ b/internal/provider/resource_fmc_deployment.go @@ -21,20 +21,26 @@ import ( "context" "encoding/json" "fmt" - "strings" - "github.com/tidwall/gjson" + // "net/url" + "strings" + "github.com/hashicorp/terraform-plugin-framework/attr" "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/tidwall/gjson" + + // "github.com/hashicorp/terraform-plugin-framework/types/basetypes" "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 @@ -76,7 +82,11 @@ func (r *DeploymentResource) Schema(ctx context.Context, req resource.SchemaRequ }, "version": schema.StringAttribute{ MarkdownDescription: helpers.NewAttributeDescription("Epoch unix time stamp (13 digits).").String, - Required: true, + Optional: true, + }, + "jobid": schema.StringAttribute{ + MarkdownDescription: helpers.NewAttributeDescription("JobId of deployment.").String, + Optional: true, }, "force_deploy": schema.BoolAttribute{ MarkdownDescription: helpers.NewAttributeDescription("Force deployment (even if there are no configuration changes).").String, @@ -86,7 +96,7 @@ func (r *DeploymentResource) Schema(ctx context.Context, req resource.SchemaRequ MarkdownDescription: helpers.NewAttributeDescription("Ignore warnings during deployment.").String, Optional: true, }, - "device_list": schema.SetAttribute{ + "device_list": schema.ListAttribute{ MarkdownDescription: helpers.NewAttributeDescription("List of device ids to be deployed.").String, ElementType: types.StringType, Required: true, @@ -254,76 +264,11 @@ func (r *DeploymentResource) Read(ctx context.Context, req resource.ReadRequest, // BoCODE // Target job ID to match - // my_job_id := "0050568A-2561-0ed3-0000-004294994451" - my_job_id := state.Id.ValueString() - - // https://10.50.202.94/api/fmc_config/v1/domain/e276abec-e0f2-11e3-8169-6d9ed49b625f/deployment/jobhistories?expanded=true - // urlPath := state.getPath() + "/" + url.QueryEscape(state.Id.ValueString()) - - // urlPath := "/api/fmc_config/v1/domain/{DOMAIN_UUID}/deployment/jobhistories?expanded=true" - urlPath := "/api/fmc_config/v1/domain/{DOMAIN_UUID}/deployment/jobhistories/" + my_job_id + jobId := state.Id.ValueString() + // state.Jobid = types.StringValue(jobId) + urlPath := "/api/fmc_config/v1/domain/{DOMAIN_UUID}/deployment/jobhistories/" + jobId res, err := r.client.Get(urlPath, reqMods...) - jsonString := res.String() - var resMap map[string]interface{} - err = json.Unmarshal([]byte(jsonString), &resMap) - if err != nil { - tflog.Debug(ctx, fmt.Sprintf("%s: Error parsing JSON data (jobhistories)", "")) - } - - // Get resource details from job history - resDeviceList := state.DeviceList.Elements() - // Modify matchingItem to be in resource format - resMap["deviceList"] = resDeviceList - - // Convert the modified resMap back to JSON - updatedJSON, err := json.Marshal(resMap) - if err != nil { - fmt.Printf("Error converting map to JSON: %v\n", err) - return - } - - res = gjson.Parse(string(updatedJSON)) - - matchingItem := resMap - - // // Variable to store the matching item - // var matchingItem map[string]interface{} - - // // Access "items" - // items, ok := resMap["items"].([]interface{}) - // if !ok { - // tflog.Debug(ctx, fmt.Sprintf("%s: Error: 'items' is not a valid array", "")) - // } - - // // Iterate over items to find the matching ID - // for _, item := range items { - // itemMap, ok := item.(map[string]interface{}) - // if !ok { - // continue - // } - - // // // Navigate to data - // // data, ok := itemMap["data"].(map[string]interface{}) - // // if !ok { - // // continue - // // } - - // // Check if the ID matches - // if id, ok := itemMap["id"].(string); ok && id == my_job_id { - // matchingItem = itemMap - // break // Exit the loop as we found the match - // } - // } - - // Print the matching item - if matchingItem != nil { - tflog.Debug(ctx, fmt.Sprintf("Matching item: %+v\n", matchingItem)) - } else { - tflog.Debug(ctx, fmt.Sprintf("No matching item found. %+v\n", "")) - } - - // EoCODE if err != nil && strings.Contains(err.Error(), "StatusCode 404") { resp.State.RemoveResource(ctx) @@ -338,6 +283,38 @@ func (r *DeploymentResource) Read(ctx context.Context, req resource.ReadRequest, return } + // Update res with required key (tfsate) + jsonString := res.String() + var resMap map[string]interface{} + err = json.Unmarshal([]byte(jsonString), &resMap) + if err != nil { + tflog.Debug(ctx, fmt.Sprintf("%s: Error parsing JSON data (jobhistories)", "")) + } + resMap["version"] = "1732274866000" + // resMap["ignore_warning"] = "true" + resMapJSON, err := json.Marshal(resMap) + if err != nil { + panic(fmt.Sprintf("Failed to marshal JSON: %v", err)) + } + res = gjson.Parse(string(resMapJSON)) + + // Read device list + resDeviceId := res.Get("deviceList.0.deviceUUID").String() + // resIgnoreWarning := res.Get("ignoreWarning").Bool() + // resVersion := res.Get("version").String() + + // Define the type of elements in the list + elementType := types.StringType + // Define the list values + values := []attr.Value{ + types.StringValue(resDeviceId), + } + // Create the ListValue + deviceList, diags := types.ListValue(elementType, values) + if diags.HasError() { + tflog.Debug(ctx, fmt.Sprintf("%s: Error creating deviceList (jobhistories)", "")) + } + // After `terraform import` we switch to a full read. if imp { state.fromBody(ctx, res) @@ -345,6 +322,10 @@ func (r *DeploymentResource) Read(ctx context.Context, req resource.ReadRequest, state.fromBodyPartial(ctx, res) } + state.DeviceList = deviceList + // state.IgnoreWarning = types.BoolValue(resIgnoreWarning) + // state.Version = types.StringValue(resVersion) + tflog.Debug(ctx, fmt.Sprintf("%s: Read finished successfully", state.Id.ValueString())) diags = resp.State.Set(ctx, &state) diff --git a/internal/provider/resource_fmc_deployment_test.go b/internal/provider/resource_fmc_deployment_test.go index ce038c7f..2b26caf1 100644 --- a/internal/provider/resource_fmc_deployment_test.go +++ b/internal/provider/resource_fmc_deployment_test.go @@ -71,7 +71,6 @@ variable "device_id_list" { // tests will set $TF_VAR_device_id_list func testAccFmcDeploymentConfig_minimum() string { config := `resource "fmc_deployment" "test" {` + "\n" - config += ` version = var.timestamp` + "\n" config += ` ignore_warning = true` + "\n" config += ` device_list = var.device_id_list` + "\n" config += `}` + "\n" From f1d11d758602a0f913bd6b0cdad9df3d582b93bf Mon Sep 17 00:00:00 2001 From: Wojciech Spyra Date: Thu, 28 Nov 2024 17:44:32 +0100 Subject: [PATCH 04/12] WIP: Working model with state modification method --- internal/provider/resource_fmc_deployment.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/internal/provider/resource_fmc_deployment.go b/internal/provider/resource_fmc_deployment.go index e726db8a..4738e6f4 100644 --- a/internal/provider/resource_fmc_deployment.go +++ b/internal/provider/resource_fmc_deployment.go @@ -176,7 +176,7 @@ func (r *DeploymentResource) Create(ctx context.Context, req resource.CreateRequ } // Iterate over items to find the JobID based on devices UUID -JonbIdLookup: +JobIdLookup: for _, item := range items { itemMap, ok := item.(map[string]interface{}) if !ok { @@ -220,7 +220,7 @@ JonbIdLookup: if deviceUUID == myInterfaceUUID { plan.Id = types.StringValue(jobId) matchingData = append(matchingData, deviceMap) - break JonbIdLookup + break JobIdLookup } } } @@ -283,15 +283,17 @@ func (r *DeploymentResource) Read(ctx context.Context, req resource.ReadRequest, return } - // Update res with required key (tfsate) + // Update res with required key (tfsate) becuse we are using different API call to read device state jsonString := res.String() var resMap map[string]interface{} err = json.Unmarshal([]byte(jsonString), &resMap) if err != nil { tflog.Debug(ctx, fmt.Sprintf("%s: Error parsing JSON data (jobhistories)", "")) } - resMap["version"] = "1732274866000" - // resMap["ignore_warning"] = "true" + + // Add hash values to res which are not exists in res but exists in state + resMap["version"] = state.Version.ValueString() + resMap["ignoreWarning"] = state.IgnoreWarning.ValueBool() resMapJSON, err := json.Marshal(resMap) if err != nil { panic(fmt.Sprintf("Failed to marshal JSON: %v", err)) @@ -300,15 +302,15 @@ func (r *DeploymentResource) Read(ctx context.Context, req resource.ReadRequest, // Read device list resDeviceId := res.Get("deviceList.0.deviceUUID").String() - // resIgnoreWarning := res.Get("ignoreWarning").Bool() - // resVersion := res.Get("version").String() // Define the type of elements in the list elementType := types.StringType + // Define the list values values := []attr.Value{ types.StringValue(resDeviceId), } + // Create the ListValue deviceList, diags := types.ListValue(elementType, values) if diags.HasError() { @@ -323,8 +325,6 @@ func (r *DeploymentResource) Read(ctx context.Context, req resource.ReadRequest, } state.DeviceList = deviceList - // state.IgnoreWarning = types.BoolValue(resIgnoreWarning) - // state.Version = types.StringValue(resVersion) tflog.Debug(ctx, fmt.Sprintf("%s: Read finished successfully", state.Id.ValueString())) From fe0196067c1b145aee221217af9a7a906ac3f802 Mon Sep 17 00:00:00 2001 From: Wojciech Spyra Date: Tue, 3 Dec 2024 11:00:54 +0100 Subject: [PATCH 05/12] WIP: Working model with apply and plan with resource state check --- internal/provider/resource_fmc_deployment.go | 194 +++++++++++++++---- 1 file changed, 155 insertions(+), 39 deletions(-) diff --git a/internal/provider/resource_fmc_deployment.go b/internal/provider/resource_fmc_deployment.go index 4738e6f4..2544d647 100644 --- a/internal/provider/resource_fmc_deployment.go +++ b/internal/provider/resource_fmc_deployment.go @@ -155,14 +155,58 @@ func (r *DeploymentResource) Create(ctx context.Context, req resource.CreateRequ // we have to moinitor task status which available in response body of POST api call // then, when deployment task is finished we need to store Deployment job into tf state + // Check deployemnt task status and waint until is finished + // Read task id from deployment response + resDeployment, err := resJson2Map(res) + if err != nil { + tflog.Debug(ctx, fmt.Sprintf("%s: Error getting task url", "")) + return + } + deploymentTaskId := resDeployment["metadata"].(map[string]interface{})["task"].(map[string]interface{})["id"].(interface{}).(string) + // deploymentTaskUrl := resDeployment["metdata"].(map[string]interface{})["task"].(map[string]interface{})["links"].(map[string]interface{})["self"].(interface{}).(string) + + // Get task status + for { + urlPath := "/api/fmc_config/v1/domain/{DOMAIN_UUID}/job/taskstatuses/" + deploymentTaskId + res, err = r.client.Get(urlPath, reqMods...) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to read job task status (POST/PUT), got error: %s, %s", err, res.String())) + return + } + resTaskstaus, err := resJson2Map(res) + if err != nil { + tflog.Debug(ctx, fmt.Sprintf("%s: Error getting task status", "")) + return + } + if resTaskstaus["status"].(interface{}).(string) == "Deployed" { + break + } + } + // jsonString := res.String() + // var resMap map[string]interface{} + // err = json.Unmarshal([]byte(jsonString), &resMap) + // if err != nil { + // tflog.Debug(ctx, fmt.Sprintf("%s: Error parsing JSON data", "")) + // } + + // Read deployment history urlPath := "/api/fmc_config/v1/domain/{DOMAIN_UUID}/deployment/jobhistories?expanded=true" res, err = r.client.Get(urlPath, reqMods...) - jsonString := res.String() - var resMap map[string]interface{} - err = json.Unmarshal([]byte(jsonString), &resMap) if err != nil { - tflog.Debug(ctx, fmt.Sprintf("%s: Error parsing JSON data (jobhistories)", "")) + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to read deployment history (POST/PUT), got error: %s, %s", err, res.String())) + return + } + resMap, err := resJson2Map(res) + if err != nil { + tflog.Debug(ctx, fmt.Sprintf("%s: Error getting job histories", "")) + return } + // jsonString := res.String() + // var resMap map[string]interface{} + // err = json.Unmarshal([]byte(jsonString), &resMap) + // if err != nil { + // tflog.Debug(ctx, fmt.Sprintf("%s: Error parsing JSON data", "")) + // } // Variable to store the matching item var matchingItem map[string]interface{} @@ -175,6 +219,11 @@ func (r *DeploymentResource) Create(ctx context.Context, req resource.CreateRequ tflog.Debug(ctx, fmt.Sprintf("%s: Error: 'items' is not a valid array", "")) } + // Wait until deployment task iis finished + + // urlPath := "/api/fmc_config/v1/domain/{DOMAIN_UUID}/deployment/jobhistories?expanded=true" + // resTask, err = r.client.Get(urlPath, reqMods...) + // Iterate over items to find the JobID based on devices UUID JobIdLookup: for _, item := range items { @@ -244,6 +293,24 @@ JobIdLookup: helpers.SetFlagImporting(ctx, false, resp.Private, &resp.Diagnostics) } +// Function to convert gjson.Result to a map +func resJson2Map(res gjson.Result) (map[string]interface{}, error) { + // Convert gjson.Result to string + jsonString := res.String() + + // Declare a map to hold the unmarshaled data + var resMap map[string]interface{} + + // Unmarshal the JSON string into the map + err := json.Unmarshal([]byte(jsonString), &resMap) + if err != nil { + return nil, fmt.Errorf("error parsing JSON data: %w", err) + } + + // Return the map and nil error + return resMap, nil +} + func (r *DeploymentResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { var state Deployment @@ -263,6 +330,32 @@ func (r *DeploymentResource) Read(ctx context.Context, req resource.ReadRequest, // BoCODE + // Read list of deployable devices + urlPathDeployable := "/api/fmc_config/v1/domain/{DOMAIN_UUID}/deployment/deployabledevices?expanded=true" + resDeployable, err := r.client.Get(urlPathDeployable, reqMods...) + jsonStringDeployable := resDeployable.String() + var resMapDeployable map[string]interface{} + err = json.Unmarshal([]byte(jsonStringDeployable), &resMapDeployable) + if err != nil { + tflog.Debug(ctx, fmt.Sprintf("%s: Error parsing JSON data (deployabledevices)", "")) + } + + // Access "items" + items, ok := resMapDeployable["items"].([]interface{}) + var deviceIdDeployable []string + if !ok { + tflog.Debug(ctx, fmt.Sprintf("%s: Error: 'items' is not a valid array", "")) + } + for _, item := range items { + itemMap, ok := item.(map[string]interface{}) + if !ok { + continue + } + deviceIdDeployable = append(deviceIdDeployable, itemMap["device"].(map[string]interface{})["id"].(interface{}).(string)) + } + + // EO Read List of deployable devices + // Target job ID to match jobId := state.Id.ValueString() // state.Jobid = types.StringValue(jobId) @@ -283,48 +376,62 @@ func (r *DeploymentResource) Read(ctx context.Context, req resource.ReadRequest, return } - // Update res with required key (tfsate) becuse we are using different API call to read device state - jsonString := res.String() - var resMap map[string]interface{} - err = json.Unmarshal([]byte(jsonString), &resMap) - if err != nil { - tflog.Debug(ctx, fmt.Sprintf("%s: Error parsing JSON data (jobhistories)", "")) - } + // Read device list ---- to be modified to parse list ---- + resDeviceId := res.Get("deviceList.0.deviceUUID").String() - // Add hash values to res which are not exists in res but exists in state - resMap["version"] = state.Version.ValueString() - resMap["ignoreWarning"] = state.IgnoreWarning.ValueBool() - resMapJSON, err := json.Marshal(resMap) - if err != nil { - panic(fmt.Sprintf("Failed to marshal JSON: %v", err)) - } - res = gjson.Parse(string(resMapJSON)) + // Check if deployemnt hasn't been rolbacked and device list is not showing up as deployable device + // Deployabale device list check + if contains(deviceIdDeployable, resDeviceId) { + // After `terraform import` we switch to a full read. + if imp { + state.fromBody(ctx, res) + } else { + state.fromBodyPartial(ctx, res) + } + } else { + // Update res with required key (tfsate) becuse we are using different API call to read device state + jsonString := res.String() + var resMap map[string]interface{} + err = json.Unmarshal([]byte(jsonString), &resMap) + if err != nil { + tflog.Debug(ctx, fmt.Sprintf("%s: Error parsing JSON data (jobhistories)", "")) + } - // Read device list - resDeviceId := res.Get("deviceList.0.deviceUUID").String() + // Add hash values to res which are not exists in res but exists in state + resMap["version"] = state.Version.ValueString() + resMap["ignoreWarning"] = state.IgnoreWarning.ValueBool() + resMapJSON, err := json.Marshal(resMap) + if err != nil { + panic(fmt.Sprintf("Failed to marshal JSON: %v", err)) + } + res = gjson.Parse(string(resMapJSON)) - // Define the type of elements in the list - elementType := types.StringType + // // Read device list + // resDeviceId := res.Get("deviceList.0.deviceUUID").String() - // Define the list values - values := []attr.Value{ - types.StringValue(resDeviceId), - } + // Define the type of elements in the list + elementType := types.StringType - // Create the ListValue - deviceList, diags := types.ListValue(elementType, values) - if diags.HasError() { - tflog.Debug(ctx, fmt.Sprintf("%s: Error creating deviceList (jobhistories)", "")) - } + // Define the list values + values := []attr.Value{ + types.StringValue(resDeviceId), + } - // After `terraform import` we switch to a full read. - if imp { - state.fromBody(ctx, res) - } else { - state.fromBodyPartial(ctx, res) - } + // Create the ListValue + deviceList, diags := types.ListValue(elementType, values) + if diags.HasError() { + tflog.Debug(ctx, fmt.Sprintf("%s: Error creating deviceList (jobhistories)", "")) + } + + // After `terraform import` we switch to a full read. + if imp { + state.fromBody(ctx, res) + } else { + state.fromBodyPartial(ctx, res) + } - state.DeviceList = deviceList + state.DeviceList = deviceList + } tflog.Debug(ctx, fmt.Sprintf("%s: Read finished successfully", state.Id.ValueString())) @@ -334,6 +441,15 @@ func (r *DeploymentResource) Read(ctx context.Context, req resource.ReadRequest, helpers.SetFlagImporting(ctx, false, resp.Private, &resp.Diagnostics) } +func contains(slice []string, item string) bool { + for _, v := range slice { + if v == item { + return true + } + } + return false +} + // Section below is generated&owned by "gen/generator.go". //template:begin update func (r *DeploymentResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { From 1a02c3939c026850eeae54a14dd374f9f6700053 Mon Sep 17 00:00:00 2001 From: Wojciech Spyra Date: Thu, 5 Dec 2024 15:36:54 +0100 Subject: [PATCH 06/12] WIP: working model: plan, apply, destroy(no delete) --- docs/resources/deployment.md | 1 - gen/definitions/deploy_device.yaml | 6 - internal/provider/model_fmc_deployment.go | 14 -- internal/provider/resource_fmc_deployment.go | 177 ++++++++++++------- 4 files changed, 115 insertions(+), 83 deletions(-) diff --git a/docs/resources/deployment.md b/docs/resources/deployment.md index e42e5fc2..3cd585bc 100644 --- a/docs/resources/deployment.md +++ b/docs/resources/deployment.md @@ -32,7 +32,6 @@ resource "fmc_deployment" "example" { - `domain` (String) The name of the FMC domain - `force_deploy` (Boolean) Force deployment (even if there are no configuration changes). - `ignore_warning` (Boolean) Ignore warnings during deployment. -- `jobid` (String) JobId of deployment. - `version` (String) Epoch unix time stamp (13 digits). ### Read-Only diff --git a/gen/definitions/deploy_device.yaml b/gen/definitions/deploy_device.yaml index 7e76244f..dcbe8ce9 100644 --- a/gen/definitions/deploy_device.yaml +++ b/gen/definitions/deploy_device.yaml @@ -17,12 +17,6 @@ attributes: mandatory: false example: "1457566762351" test_value: var.timestamp - - model_name: jobid - type: String - description: JobId of deployment. - mandatory: false - exclude_example: true - exclude_test: true - model_name: ForceDeploy tf_name: force_deploy type: Bool diff --git a/internal/provider/model_fmc_deployment.go b/internal/provider/model_fmc_deployment.go index 5f475723..81e10d15 100644 --- a/internal/provider/model_fmc_deployment.go +++ b/internal/provider/model_fmc_deployment.go @@ -35,7 +35,6 @@ type Deployment struct { Id types.String `tfsdk:"id"` Domain types.String `tfsdk:"domain"` Version types.String `tfsdk:"version"` - Jobid types.String `tfsdk:"jobid"` ForceDeploy types.Bool `tfsdk:"force_deploy"` IgnoreWarning types.Bool `tfsdk:"ignore_warning"` DeviceList types.List `tfsdk:"device_list"` @@ -63,9 +62,6 @@ func (data Deployment) toBody(ctx context.Context, state Deployment) string { if !data.Version.IsNull() { body, _ = sjson.Set(body, "version", data.Version.ValueString()) } - if !data.Jobid.IsNull() { - body, _ = sjson.Set(body, "jobid", data.Jobid.ValueString()) - } if !data.ForceDeploy.IsNull() { body, _ = sjson.Set(body, "ForceDeploy", data.ForceDeploy.ValueBool()) } @@ -93,11 +89,6 @@ func (data *Deployment) fromBody(ctx context.Context, res gjson.Result) { } else { data.Version = types.StringNull() } - if value := res.Get("jobid"); value.Exists() { - data.Jobid = types.StringValue(value.String()) - } else { - data.Jobid = types.StringNull() - } if value := res.Get("ForceDeploy"); value.Exists() { data.ForceDeploy = types.BoolValue(value.Bool()) } else { @@ -134,11 +125,6 @@ func (data *Deployment) fromBodyPartial(ctx context.Context, res gjson.Result) { } else { data.Version = types.StringNull() } - if value := res.Get("jobid"); value.Exists() && !data.Jobid.IsNull() { - data.Jobid = types.StringValue(value.String()) - } else { - data.Jobid = types.StringNull() - } if value := res.Get("ForceDeploy"); value.Exists() && !data.ForceDeploy.IsNull() { data.ForceDeploy = types.BoolValue(value.Bool()) } else { diff --git a/internal/provider/resource_fmc_deployment.go b/internal/provider/resource_fmc_deployment.go index 2544d647..913951f2 100644 --- a/internal/provider/resource_fmc_deployment.go +++ b/internal/provider/resource_fmc_deployment.go @@ -84,10 +84,6 @@ func (r *DeploymentResource) Schema(ctx context.Context, req resource.SchemaRequ MarkdownDescription: helpers.NewAttributeDescription("Epoch unix time stamp (13 digits).").String, Optional: true, }, - "jobid": schema.StringAttribute{ - MarkdownDescription: helpers.NewAttributeDescription("JobId of deployment.").String, - Optional: true, - }, "force_deploy": schema.BoolAttribute{ MarkdownDescription: helpers.NewAttributeDescription("Force deployment (even if there are no configuration changes).").String, Optional: true, @@ -128,7 +124,12 @@ func (r *DeploymentResource) Create(ctx context.Context, req resource.CreateRequ return } - myInterfaceUUID := strings.ReplaceAll(plan.DeviceList.Elements()[0].String(), `"`, "") + // myInterfaceUUID := strings.ReplaceAll(plan.DeviceList.Elements()[0].String(), `"`, "") + interfaceUUIDList, err := extractDeviceList(plan.DeviceList) + if err != nil { + tflog.Debug(ctx, fmt.Sprintf("%s: Error getting device list", "")) + return + } // Set request domain if provided reqMods := [](func(*fmc.Req)){} @@ -146,7 +147,7 @@ func (r *DeploymentResource) Create(ctx context.Context, req resource.CreateRequ return } - // BoCODE + // ############################################ // As there are no GET api call for /api/fmc_config/v1/domain/{domainUUID}/deployment/deploymentrequests // We need to create abstraction state which reflects deployemnt // We can use /api/fmc_config/v1/domain/{domainUUID}/deployment/jobhistories API @@ -163,7 +164,6 @@ func (r *DeploymentResource) Create(ctx context.Context, req resource.CreateRequ return } deploymentTaskId := resDeployment["metadata"].(map[string]interface{})["task"].(map[string]interface{})["id"].(interface{}).(string) - // deploymentTaskUrl := resDeployment["metdata"].(map[string]interface{})["task"].(map[string]interface{})["links"].(map[string]interface{})["self"].(interface{}).(string) // Get task status for { @@ -182,12 +182,6 @@ func (r *DeploymentResource) Create(ctx context.Context, req resource.CreateRequ break } } - // jsonString := res.String() - // var resMap map[string]interface{} - // err = json.Unmarshal([]byte(jsonString), &resMap) - // if err != nil { - // tflog.Debug(ctx, fmt.Sprintf("%s: Error parsing JSON data", "")) - // } // Read deployment history urlPath := "/api/fmc_config/v1/domain/{DOMAIN_UUID}/deployment/jobhistories?expanded=true" @@ -201,12 +195,6 @@ func (r *DeploymentResource) Create(ctx context.Context, req resource.CreateRequ tflog.Debug(ctx, fmt.Sprintf("%s: Error getting job histories", "")) return } - // jsonString := res.String() - // var resMap map[string]interface{} - // err = json.Unmarshal([]byte(jsonString), &resMap) - // if err != nil { - // tflog.Debug(ctx, fmt.Sprintf("%s: Error parsing JSON data", "")) - // } // Variable to store the matching item var matchingItem map[string]interface{} @@ -219,12 +207,7 @@ func (r *DeploymentResource) Create(ctx context.Context, req resource.CreateRequ tflog.Debug(ctx, fmt.Sprintf("%s: Error: 'items' is not a valid array", "")) } - // Wait until deployment task iis finished - - // urlPath := "/api/fmc_config/v1/domain/{DOMAIN_UUID}/deployment/jobhistories?expanded=true" - // resTask, err = r.client.Get(urlPath, reqMods...) - - // Iterate over items to find the JobID based on devices UUID + // Iterate over items to find latest JobID based on devices UUID JobIdLookup: for _, item := range items { itemMap, ok := item.(map[string]interface{}) @@ -232,12 +215,6 @@ JobIdLookup: continue } - // // Check if the ID matches - // if id, ok := itemMap["id"].(string); ok && id == my_job_id { - // matchingItem = itemMap - // break // Exit the loop as we found the match - // } - if itemMap["jobType"].(interface{}).(string) != "DEPLOYMENT" { continue } @@ -248,25 +225,20 @@ JobIdLookup: if !ok { continue } - println(jobId) - // Iterate over deviceData to check deviceUUID + // Iterate over deviceData to check deviceUUID belongs to deployment for _, device := range deviceList { deviceMap, ok := device.(map[string]interface{}) if !ok { continue } - // deviceDetails, ok := deviceMap["data"].(map[string]interface{}) - // if !ok { - // continue - // } deviceUUID, ok := deviceMap["deviceUUID"].(interface{}).(string) if !ok { continue } - if deviceUUID == myInterfaceUUID { + if contains(interfaceUUIDList, deviceUUID) { plan.Id = types.StringValue(jobId) matchingData = append(matchingData, deviceMap) break JobIdLookup @@ -281,9 +253,7 @@ JobIdLookup: tflog.Debug(ctx, fmt.Sprintf("No matching item found. %+v\n", "")) } - // EoCODE - - // plan.Id = types.StringValue(res.Get("id").String()) + // ############################################ tflog.Debug(ctx, fmt.Sprintf("%s: Create finished successfully", plan.Id.ValueString())) @@ -293,6 +263,23 @@ JobIdLookup: helpers.SetFlagImporting(ctx, false, resp.Private, &resp.Diagnostics) } +// Function to extract device UUIDs from plan +func extractDeviceList(deviceList types.List) ([]string, error) { + var extractedList []string + + // Iterate through each element in the ListValue + for _, elem := range deviceList.Elements() { + // Convert the element to a StringValue and extract its value + if stringValue, ok := elem.(types.String); ok { + extractedList = append(extractedList, stringValue.ValueString()) + } else { + return nil, fmt.Errorf("element is not a StringValue") + } + } + + return extractedList, nil +} + // Function to convert gjson.Result to a map func resJson2Map(res gjson.Result) (map[string]interface{}, error) { // Convert gjson.Result to string @@ -311,6 +298,63 @@ func resJson2Map(res gjson.Result) (map[string]interface{}, error) { return resMap, nil } +// Fubnction to check if item belongs to list +func contains(list []string, item string) bool { + for _, v := range list { + if v == item { + return true + } + } + return false +} + +// Function to heck if all elements of list2 are in list1 (ignoring order) +func containsAllElements(list1, list2 []string) bool { + lookup := make(map[string]bool) + for _, item := range list1 { + lookup[item] = true + } + for _, item := range list2 { + if !lookup[item] { + return false + } + } + return true +} + +func extractDeviceUUIDs(res string) ([]string, error) { + // Parse the JSON into a map + var resMap map[string]interface{} + err := json.Unmarshal([]byte(res), &resMap) + if err != nil { + return nil, fmt.Errorf("failed to parse JSON: %v", err) + } + + // Extract deviceList + deviceList, ok := resMap["deviceList"].([]interface{}) + if !ok { + return nil, fmt.Errorf("deviceList is not an array or is missing") + } + + // Extract deviceUUIDs + var deviceUUIDs []string + for _, item := range deviceList { + device, ok := item.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("deviceList item is not an object") + } + + uuid, ok := device["deviceUUID"].(string) + if !ok { + return nil, fmt.Errorf("deviceUUID is not a string or is missing") + } + + deviceUUIDs = append(deviceUUIDs, uuid) + } + + return deviceUUIDs, nil +} + func (r *DeploymentResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { var state Deployment @@ -328,7 +372,7 @@ func (r *DeploymentResource) Read(ctx context.Context, req resource.ReadRequest, tflog.Debug(ctx, fmt.Sprintf("%s: Beginning Read", state.Id.String())) - // BoCODE + // ############################################ // Read list of deployable devices urlPathDeployable := "/api/fmc_config/v1/domain/{DOMAIN_UUID}/deployment/deployabledevices?expanded=true" @@ -358,7 +402,6 @@ func (r *DeploymentResource) Read(ctx context.Context, req resource.ReadRequest, // Target job ID to match jobId := state.Id.ValueString() - // state.Jobid = types.StringValue(jobId) urlPath := "/api/fmc_config/v1/domain/{DOMAIN_UUID}/deployment/jobhistories/" + jobId res, err := r.client.Get(urlPath, reqMods...) @@ -376,12 +419,32 @@ func (r *DeploymentResource) Read(ctx context.Context, req resource.ReadRequest, return } - // Read device list ---- to be modified to parse list ---- - resDeviceId := res.Get("deviceList.0.deviceUUID").String() + // Read device list + resDeviceIdList, err := extractDeviceUUIDs(res.String()) + if err != nil { + tflog.Debug(ctx, fmt.Sprintf("%s: Error getting device list", "")) + return + } // Check if deployemnt hasn't been rolbacked and device list is not showing up as deployable device // Deployabale device list check - if contains(deviceIdDeployable, resDeviceId) { + if containsAllElements(deviceIdDeployable, resDeviceIdList) { + // if contains(deviceIdDeployable, resDeviceId) { + var resMap map[string]interface{} + resMap, err = resJson2Map(res) + if err != nil { + tflog.Debug(ctx, fmt.Sprintf("%s: Error getting job histories", "")) + return + } + + resMap["deviceList"] = []interface{}{} + + resMapJSON, err := json.Marshal(resMap) + if err != nil { + panic(fmt.Sprintf("Failed to marshal JSON: %v", err)) + } + res = gjson.Parse(string(resMapJSON)) + // After `terraform import` we switch to a full read. if imp { state.fromBody(ctx, res) @@ -390,9 +453,8 @@ func (r *DeploymentResource) Read(ctx context.Context, req resource.ReadRequest, } } else { // Update res with required key (tfsate) becuse we are using different API call to read device state - jsonString := res.String() var resMap map[string]interface{} - err = json.Unmarshal([]byte(jsonString), &resMap) + resMap, err = resJson2Map(res) if err != nil { tflog.Debug(ctx, fmt.Sprintf("%s: Error parsing JSON data (jobhistories)", "")) } @@ -406,15 +468,15 @@ func (r *DeploymentResource) Read(ctx context.Context, req resource.ReadRequest, } res = gjson.Parse(string(resMapJSON)) - // // Read device list - // resDeviceId := res.Get("deviceList.0.deviceUUID").String() - // Define the type of elements in the list elementType := types.StringType // Define the list values - values := []attr.Value{ - types.StringValue(resDeviceId), + var values []attr.Value + + // Populate the values from resDeviceIdList + for _, deviceId := range resDeviceIdList { + values = append(values, types.StringValue(deviceId)) } // Create the ListValue @@ -441,15 +503,6 @@ func (r *DeploymentResource) Read(ctx context.Context, req resource.ReadRequest, helpers.SetFlagImporting(ctx, false, resp.Private, &resp.Diagnostics) } -func contains(slice []string, item string) bool { - for _, v := range slice { - if v == item { - return true - } - } - return false -} - // Section below is generated&owned by "gen/generator.go". //template:begin update func (r *DeploymentResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { From 25e964c19348b9fc23917ce2be17d5b4097f519d Mon Sep 17 00:00:00 2001 From: Wojciech Spyra Date: Fri, 6 Dec 2024 11:51:42 +0100 Subject: [PATCH 07/12] WIP: code for delete resource (rollback) --- gen/definitions/deploy_device.yaml | 5 +- internal/provider/resource_fmc_deployment.go | 155 ++++++++++++++++++- 2 files changed, 150 insertions(+), 10 deletions(-) diff --git a/gen/definitions/deploy_device.yaml b/gen/definitions/deploy_device.yaml index dcbe8ce9..408d201d 100644 --- a/gen/definitions/deploy_device.yaml +++ b/gen/definitions/deploy_device.yaml @@ -1,7 +1,6 @@ --- name: Deployment rest_endpoint: /api/fmc_config/v1/domain/{DOMAIN_UUID}/deployment/deploymentrequests -no_delete: true no_update: true no_data_source: true no_import: true @@ -30,8 +29,8 @@ attributes: description: Ignore warnings during deployment. mandatory: false exclude_example: true - test_value: true - minimum_test_value: true + test_value: "true" + minimum_test_value: "true" - model_name: deviceList tf_name: device_list type: List diff --git a/internal/provider/resource_fmc_deployment.go b/internal/provider/resource_fmc_deployment.go index 913951f2..310c08ac 100644 --- a/internal/provider/resource_fmc_deployment.go +++ b/internal/provider/resource_fmc_deployment.go @@ -22,6 +22,8 @@ import ( "encoding/json" "fmt" + // "net/url" + // "net/url" "strings" @@ -219,6 +221,7 @@ JobIdLookup: continue } + // *** It has to be fixed to add device list rather than single device deviceList, ok := itemMap["deviceList"].([]interface{}) jobId, ok := itemMap["id"].(string) @@ -238,6 +241,7 @@ JobIdLookup: continue } + // *** It has to be fixed to add device list rather than single device if contains(interfaceUUIDList, deviceUUID) { plan.Id = types.StringValue(jobId) matchingData = append(matchingData, deviceMap) @@ -298,7 +302,7 @@ func resJson2Map(res gjson.Result) (map[string]interface{}, error) { return resMap, nil } -// Fubnction to check if item belongs to list +// Function to check if item belongs to list func contains(list []string, item string) bool { for _, v := range list { if v == item { @@ -536,8 +540,6 @@ func (r *DeploymentResource) Update(ctx context.Context, req resource.UpdateRequ // End of section. //template:end update -// Section below is generated&owned by "gen/generator.go". //template:begin delete - func (r *DeploymentResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { var state Deployment @@ -554,13 +556,152 @@ func (r *DeploymentResource) Delete(ctx context.Context, req resource.DeleteRequ } tflog.Debug(ctx, fmt.Sprintf("%s: Beginning Delete", state.Id.ValueString())) + // res, err := r.client.Delete(state.getPath()+"/"+url.QueryEscape(state.Id.ValueString()), reqMods...) + // if err != nil { + // resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to delete object (DELETE), got error: %s, %s", err, res.String())) + // return + // } + + // As there are no DELETE api call for /api/fmc_config/v1/domain/{domainUUID}/deployment/deploymentrequests + // we can only roblback deployemnt to previous state + // we can use /api/fmc_config/v1/domain/{domainUUID}/deployment/rollbackrequests to execute rolback + // first we need to get deployemnt id and device list from state + // next get deployemnt job id to rollback to, it can be retrieved from deployemnt job history + // /api/fmc_config/v1/domain/{domainUUID}/deployment/jobhistories API + // next initiate rollback api and wait until the job is finished + + // Get data from state + stateDeviceList, err := extractDeviceList(state.DeviceList) + if err != nil { + tflog.Debug(ctx, fmt.Sprintf("%s: Error getting device list from state", "")) + return + } + stateDeploymentJobId := state.Id.String() + var rollbackToDeploymentJobId string - tflog.Debug(ctx, fmt.Sprintf("%s: Delete finished successfully", state.Id.ValueString())) + // Read deployment history + urlPath := "/api/fmc_config/v1/domain/{DOMAIN_UUID}/deployment/jobhistories?expanded=true" + res, err := r.client.Get(urlPath, reqMods...) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to read deployment history (POST/PUT), got error: %s, %s", err, res.String())) + return + } + resMap, err := resJson2Map(res) + if err != nil { + tflog.Debug(ctx, fmt.Sprintf("%s: Error getting job histories", "")) + return + } - resp.State.RemoveResource(ctx) -} + // Access "items" + items, ok := resMap["items"].([]interface{}) + if !ok { + tflog.Debug(ctx, fmt.Sprintf("%s: Error: 'items' is not a valid array", "")) + } + + // Iterate over items to find JobID for rollback based on latest deployment job id and devices UUID + // stateDeploymentJobId +JobIdLookup: + for _, item := range items { + itemMap, ok := item.(map[string]interface{}) + if !ok { + continue + } + + if itemMap["jobType"].(interface{}).(string) != "DEPLOYMENT" { + continue + } + + if (itemMap["jobId"].(interface{}).(string) != stateDeploymentJobId) || (rollbackToDeploymentJobId == "") { + continue + } -// End of section. //template:end delete + deviceList, ok := itemMap["deviceList"].([]interface{}) + if !ok { + continue + } + + // Iterate over deviceData to check deviceUUID belongs to deployment + for _, device := range deviceList { + deviceMap, ok := device.(map[string]interface{}) + if !ok { + continue + } + + // *** It has to be fixed to add device list rather than single device + deviceUUID, ok := deviceMap["deviceUUID"].(interface{}).(string) + if !ok { + continue + } + + // *** It has to be fixed to add device list rather than single device + if contains(stateDeviceList, deviceUUID) { + // set rollbackToDeploymentJobId to be equal fo stateDeploymentJobId + // and continuse to iterate to find previous deployemnt + if rollbackToDeploymentJobId == "" { + rollbackToDeploymentJobId = stateDeploymentJobId + continue + } else { + rollbackToDeploymentJobId = itemMap["jobId"].(interface{}).(string) + break JobIdLookup + } + } + } + } + + // Trigger rollback + if rollbackToDeploymentJobId != "" { + + urlPath = "/api/fmc_config/v1/domain/{domainUUID}/deployment/rollbackrequests" + body := `{ ` + "\n" + body += ` "type": "RollbackRequest",` + "\n" + body += ` "rollbackDeviceList": [` + "\n" + body += ` {` + "\n" + body += ` "deploymentJobId": "` + rollbackToDeploymentJobId + `",` + "\n" + // *** It has to be fixed to add device list rather than single device + body += ` "deviceList": [` + "\n" + body += ` "` + stateDeviceList[0] + `"` + "\n" + body += ` ]` + "\n" + body += ` }` + "\n" + body += ` ]` + "\n" + body += `}` + "\n" + + res, err = r.client.Post(urlPath, body, reqMods...) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to configure object (POST/PUT), got error: %s, %s", err, res.String())) + return + } + + resDeployment, err := resJson2Map(res) + if err != nil { + tflog.Debug(ctx, fmt.Sprintf("%s: Error getting task url", "")) + return + } + deploymentTaskId := resDeployment["metadata"].(map[string]interface{})["task"].(map[string]interface{})["id"].(interface{}).(string) + + // Get task status + for { + urlPath := "/api/fmc_config/v1/domain/{DOMAIN_UUID}/job/taskstatuses/" + deploymentTaskId + res, err = r.client.Get(urlPath, reqMods...) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to read job task status (POST/PUT), got error: %s, %s", err, res.String())) + return + } + resTaskstaus, err := resJson2Map(res) + if err != nil { + tflog.Debug(ctx, fmt.Sprintf("%s: Error getting task status", "")) + return + } + if resTaskstaus["status"].(interface{}).(string) == "Deployed" { + break + } + } + + tflog.Debug(ctx, fmt.Sprintf("%s: Delete finished successfully", state.Id.ValueString())) + resp.State.RemoveResource(ctx) + } else { + tflog.Debug(ctx, fmt.Sprintf("%s: Delete failed. Could not trigger rollback", state.Id.ValueString())) + } +} // Section below is generated&owned by "gen/generator.go". //template:begin import // End of section. //template:end import From 2b75412354640583afddcf2b9344c71230eb7338 Mon Sep 17 00:00:00 2001 From: Wojciech Spyra Date: Fri, 6 Dec 2024 15:15:36 +0100 Subject: [PATCH 08/12] WIP: code for delete resource (rollback) --- internal/provider/resource_fmc_deployment.go | 34 ++++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/internal/provider/resource_fmc_deployment.go b/internal/provider/resource_fmc_deployment.go index 310c08ac..8abc3d2f 100644 --- a/internal/provider/resource_fmc_deployment.go +++ b/internal/provider/resource_fmc_deployment.go @@ -576,7 +576,7 @@ func (r *DeploymentResource) Delete(ctx context.Context, req resource.DeleteRequ tflog.Debug(ctx, fmt.Sprintf("%s: Error getting device list from state", "")) return } - stateDeploymentJobId := state.Id.String() + stateDeploymentJobId := state.Id.ValueString() var rollbackToDeploymentJobId string // Read deployment history @@ -611,7 +611,7 @@ JobIdLookup: continue } - if (itemMap["jobId"].(interface{}).(string) != stateDeploymentJobId) || (rollbackToDeploymentJobId == "") { + if (itemMap["id"].(interface{}).(string) != stateDeploymentJobId) && (rollbackToDeploymentJobId == "") { continue } @@ -641,7 +641,7 @@ JobIdLookup: rollbackToDeploymentJobId = stateDeploymentJobId continue } else { - rollbackToDeploymentJobId = itemMap["jobId"].(interface{}).(string) + rollbackToDeploymentJobId = itemMap["id"].(interface{}).(string) break JobIdLookup } } @@ -649,21 +649,21 @@ JobIdLookup: } // Trigger rollback - if rollbackToDeploymentJobId != "" { - - urlPath = "/api/fmc_config/v1/domain/{domainUUID}/deployment/rollbackrequests" - body := `{ ` + "\n" - body += ` "type": "RollbackRequest",` + "\n" - body += ` "rollbackDeviceList": [` + "\n" - body += ` {` + "\n" - body += ` "deploymentJobId": "` + rollbackToDeploymentJobId + `",` + "\n" + if rollbackToDeploymentJobId != "" && rollbackToDeploymentJobId != stateDeploymentJobId { + + urlPath = "/api/fmc_config/v1/domain/{DOMAIN_UUID}/deployment/rollbackrequests" + body := `{ ` + body += ` "type": "RollbackRequest",` + body += ` "rollbackDeviceList": [` + body += ` {` + body += ` "deploymentJobId": "` + rollbackToDeploymentJobId + `",` // *** It has to be fixed to add device list rather than single device - body += ` "deviceList": [` + "\n" - body += ` "` + stateDeviceList[0] + `"` + "\n" - body += ` ]` + "\n" - body += ` }` + "\n" - body += ` ]` + "\n" - body += `}` + "\n" + body += ` "deviceList": [` + body += ` "` + stateDeviceList[0] + `"` + body += ` ]` + body += ` }` + body += ` ]` + body += `}` res, err = r.client.Post(urlPath, body, reqMods...) if err != nil { From 9a4d623b7d92215e6131e4e5aebf1d3678b1e965 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Bernacki?= Date: Wed, 11 Dec 2024 20:29:33 +0100 Subject: [PATCH 09/12] fix CI --- internal/provider/resource_fmc_deployment_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/provider/resource_fmc_deployment_test.go b/internal/provider/resource_fmc_deployment_test.go index 2b26caf1..2d8b5ea0 100644 --- a/internal/provider/resource_fmc_deployment_test.go +++ b/internal/provider/resource_fmc_deployment_test.go @@ -30,8 +30,8 @@ import ( // Section below is generated&owned by "gen/generator.go". //template:begin testAcc func TestAccFmcDeployment(t *testing.T) { - if os.Getenv("TF_VAR_timestamp") == "" && os.Getenv("TF_VAR_device_id_list") == "" { - t.Skip("skipping test, set environment variable TF_VAR_timestamp or TF_VAR_device_id_list") + if os.Getenv("TF_VAR_timestamp") == "" || os.Getenv("TF_VAR_device_id_list") == "" { + t.Skip("skipping test, set environment variable TF_VAR_timestamp and TF_VAR_device_id_list") } var checks []resource.TestCheckFunc From 2f5d933d7cb2a002ee0e9f68850c10956d6fcce2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Bernacki?= Date: Thu, 19 Dec 2024 00:32:57 +0100 Subject: [PATCH 10/12] create and update --- docs/resources/deployment.md | 9 +- examples/resources/fmc_deployment/resource.tf | 5 +- gen/definitions/deploy_device.yaml | 41 +- internal/provider/helpers/utils.go | 8 + internal/provider/model_fmc_deployment.go | 22 +- internal/provider/resource_fmc_deployment.go | 523 ++++-------------- .../provider/resource_fmc_deployment_test.go | 15 +- 7 files changed, 132 insertions(+), 491 deletions(-) diff --git a/docs/resources/deployment.md b/docs/resources/deployment.md index 3cd585bc..ff997c0e 100644 --- a/docs/resources/deployment.md +++ b/docs/resources/deployment.md @@ -14,8 +14,9 @@ This resource can manage a Deployment. ```terraform resource "fmc_deployment" "example" { - version = "1457566762351" - device_list = ["2fe9063e-8bd5-11ef-9475-e4aeac78cf37"] + version = "1457566762351" + device_list = ["2fe9063e-8bd5-11ef-9475-e4aeac78cf37"] + deployment_note = "Deployment note" } ``` @@ -31,8 +32,8 @@ resource "fmc_deployment" "example" { - `deployment_note` (String) User note related to deployment. - `domain` (String) The name of the FMC domain - `force_deploy` (Boolean) Force deployment (even if there are no configuration changes). -- `ignore_warning` (Boolean) Ignore warnings during deployment. -- `version` (String) Epoch unix time stamp (13 digits). +- `save` (Boolean) This attribute is only used internally. +- `version` (String) Version to which the deployment should be done. By default set to current unix timestamp. ### Read-Only diff --git a/examples/resources/fmc_deployment/resource.tf b/examples/resources/fmc_deployment/resource.tf index 74afe93f..3c19d6b3 100644 --- a/examples/resources/fmc_deployment/resource.tf +++ b/examples/resources/fmc_deployment/resource.tf @@ -1,4 +1,5 @@ resource "fmc_deployment" "example" { - version = "1457566762351" - device_list = ["2fe9063e-8bd5-11ef-9475-e4aeac78cf37"] + version = "1457566762351" + device_list = ["2fe9063e-8bd5-11ef-9475-e4aeac78cf37"] + deployment_note = "Deployment note" } diff --git a/gen/definitions/deploy_device.yaml b/gen/definitions/deploy_device.yaml index 408d201d..d221f709 100644 --- a/gen/definitions/deploy_device.yaml +++ b/gen/definitions/deploy_device.yaml @@ -1,38 +1,29 @@ --- name: Deployment rest_endpoint: /api/fmc_config/v1/domain/{DOMAIN_UUID}/deployment/deploymentrequests -no_update: true no_data_source: true no_import: true +no_delete: true doc_category: Deployment -test_tags: [TF_VAR_timestamp,TF_VAR_device_id_list] +test_tags: [TF_VAR_timestamp, TF_VAR_device_id_list] attributes: - model_name: type type: String value: "DeploymentRequest" - model_name: version type: String - description: Epoch unix time stamp (13 digits). - mandatory: false + description: Version to which the deployment should be done. By default set to current unix timestamp. example: "1457566762351" test_value: var.timestamp - - model_name: ForceDeploy - tf_name: force_deploy + - model_name: forceDeploy type: Bool description: Force deployment (even if there are no configuration changes). - mandatory: false exclude_example: true exclude_test: true - model_name: ignoreWarning - tf_name: ignore_warning type: Bool - description: Ignore warnings during deployment. - mandatory: false - exclude_example: true - test_value: "true" - minimum_test_value: "true" + value: "true" - model_name: deviceList - tf_name: device_list type: List description: List of device ids to be deployed. mandatory: true @@ -40,17 +31,17 @@ attributes: example: 2fe9063e-8bd5-11ef-9475-e4aeac78cf37 test_value: var.device_id_list - model_name: deploymentNote - tf_name: deployment_note type: String description: User note related to deployment. - mandatory: false - example: "yournotescomehere" - exclude_example: true + example: "Deployment note" exclude_test: true - -test_prerequisites: |- - variable "timestamp" { default = null } // tests will set $TF_VAR_timestamp - variable "device_id_list" { // tests will set $TF_VAR_device_id_list - type = list(string) - default = null - } \ No newline at end of file + # - model_name: save + # description: "This attribute is only used internally" + # type: Bool + # value: "true" +# test_prerequisites: |- +# variable "timestamp" { default = null } // tests will set $TF_VAR_timestamp +# variable "device_id_list" { // tests will set $TF_VAR_device_id_list +# type = list(string) +# default = null +# } diff --git a/internal/provider/helpers/utils.go b/internal/provider/helpers/utils.go index 1de59406..a50265d9 100644 --- a/internal/provider/helpers/utils.go +++ b/internal/provider/helpers/utils.go @@ -48,6 +48,14 @@ func GetStringList(result []gjson.Result) types.List { return types.ListValueMust(types.StringType, v) } +func GetStringListFromStringSlice(result []string) types.List { + v := make([]attr.Value, len(result)) + for i, e := range result { + v[i] = types.StringValue(e) + } + return types.ListValueMust(types.StringType, v) +} + func GetInt64List(result []gjson.Result) types.List { v := make([]attr.Value, len(result)) for r := range result { diff --git a/internal/provider/model_fmc_deployment.go b/internal/provider/model_fmc_deployment.go index 81e10d15..d7fa116d 100644 --- a/internal/provider/model_fmc_deployment.go +++ b/internal/provider/model_fmc_deployment.go @@ -36,7 +36,6 @@ type Deployment struct { Domain types.String `tfsdk:"domain"` Version types.String `tfsdk:"version"` ForceDeploy types.Bool `tfsdk:"force_deploy"` - IgnoreWarning types.Bool `tfsdk:"ignore_warning"` DeviceList types.List `tfsdk:"device_list"` DeploymentNote types.String `tfsdk:"deployment_note"` } @@ -63,11 +62,9 @@ func (data Deployment) toBody(ctx context.Context, state Deployment) string { body, _ = sjson.Set(body, "version", data.Version.ValueString()) } if !data.ForceDeploy.IsNull() { - body, _ = sjson.Set(body, "ForceDeploy", data.ForceDeploy.ValueBool()) - } - if !data.IgnoreWarning.IsNull() { - body, _ = sjson.Set(body, "ignoreWarning", data.IgnoreWarning.ValueBool()) + body, _ = sjson.Set(body, "forceDeploy", data.ForceDeploy.ValueBool()) } + body, _ = sjson.Set(body, "ignoreWarning", true) if !data.DeviceList.IsNull() { var values []string data.DeviceList.ElementsAs(ctx, &values, false) @@ -76,6 +73,7 @@ func (data Deployment) toBody(ctx context.Context, state Deployment) string { if !data.DeploymentNote.IsNull() { body, _ = sjson.Set(body, "deploymentNote", data.DeploymentNote.ValueString()) } + body, _ = sjson.Set(body, "save", true) return body } @@ -89,16 +87,11 @@ func (data *Deployment) fromBody(ctx context.Context, res gjson.Result) { } else { data.Version = types.StringNull() } - if value := res.Get("ForceDeploy"); value.Exists() { + if value := res.Get("forceDeploy"); value.Exists() { data.ForceDeploy = types.BoolValue(value.Bool()) } else { data.ForceDeploy = types.BoolNull() } - if value := res.Get("ignoreWarning"); value.Exists() { - data.IgnoreWarning = types.BoolValue(value.Bool()) - } else { - data.IgnoreWarning = types.BoolNull() - } if value := res.Get("deviceList"); value.Exists() { data.DeviceList = helpers.GetStringList(value.Array()) } else { @@ -125,16 +118,11 @@ func (data *Deployment) fromBodyPartial(ctx context.Context, res gjson.Result) { } else { data.Version = types.StringNull() } - if value := res.Get("ForceDeploy"); value.Exists() && !data.ForceDeploy.IsNull() { + if value := res.Get("forceDeploy"); value.Exists() && !data.ForceDeploy.IsNull() { data.ForceDeploy = types.BoolValue(value.Bool()) } else { data.ForceDeploy = types.BoolNull() } - if value := res.Get("ignoreWarning"); value.Exists() && !data.IgnoreWarning.IsNull() { - data.IgnoreWarning = types.BoolValue(value.Bool()) - } else { - data.IgnoreWarning = types.BoolNull() - } if value := res.Get("deviceList"); value.Exists() && !data.DeviceList.IsNull() { data.DeviceList = helpers.GetStringList(value.Array()) } else { diff --git a/internal/provider/resource_fmc_deployment.go b/internal/provider/resource_fmc_deployment.go index 8abc3d2f..f08e8818 100644 --- a/internal/provider/resource_fmc_deployment.go +++ b/internal/provider/resource_fmc_deployment.go @@ -21,30 +21,23 @@ import ( "context" "encoding/json" "fmt" + "strconv" + "time" - // "net/url" - - // "net/url" - "strings" - - "github.com/hashicorp/terraform-plugin-framework/attr" + "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/tidwall/gjson" - - // "github.com/hashicorp/terraform-plugin-framework/types/basetypes" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/netascode/go-fmc" "github.com/netascode/terraform-provider-fmc/internal/provider/helpers" + "github.com/tidwall/gjson" ) // 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 = &DeploymentResource{} @@ -83,17 +76,14 @@ func (r *DeploymentResource) Schema(ctx context.Context, req resource.SchemaRequ }, }, "version": schema.StringAttribute{ - MarkdownDescription: helpers.NewAttributeDescription("Epoch unix time stamp (13 digits).").String, + MarkdownDescription: helpers.NewAttributeDescription("Version to which the deployment should be done. By default set to current unix timestamp.").String, Optional: true, + Computed: true, }, "force_deploy": schema.BoolAttribute{ MarkdownDescription: helpers.NewAttributeDescription("Force deployment (even if there are no configuration changes).").String, Optional: true, }, - "ignore_warning": schema.BoolAttribute{ - MarkdownDescription: helpers.NewAttributeDescription("Ignore warnings during deployment.").String, - Optional: true, - }, "device_list": schema.ListAttribute{ MarkdownDescription: helpers.NewAttributeDescription("List of device ids to be deployed.").String, ElementType: types.StringType, @@ -115,8 +105,6 @@ func (r *DeploymentResource) Configure(_ context.Context, req resource.Configure r.client = req.ProviderData.(*FmcProviderData).Client } -// End of section. //template:end model - func (r *DeploymentResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { var plan Deployment @@ -126,141 +114,58 @@ func (r *DeploymentResource) Create(ctx context.Context, req resource.CreateRequ return } - // myInterfaceUUID := strings.ReplaceAll(plan.DeviceList.Elements()[0].String(), `"`, "") - interfaceUUIDList, err := extractDeviceList(plan.DeviceList) - if err != nil { - tflog.Debug(ctx, fmt.Sprintf("%s: Error getting device list", "")) - 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())) - - // Create object - body := plan.toBody(ctx, Deployment{}) - res, err := r.client.Post(plan.getPath(), body, reqMods...) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to configure object (POST/PUT), got error: %s, %s", err, res.String())) - return + // If version not provided, set it to the current unix timestamp + if plan.Version.IsUnknown() { + plan.Version = types.StringValue(strconv.FormatInt(time.Now().UnixMilli(), 10)) } - // ############################################ - // As there are no GET api call for /api/fmc_config/v1/domain/{domainUUID}/deployment/deploymentrequests - // We need to create abstraction state which reflects deployemnt - // We can use /api/fmc_config/v1/domain/{domainUUID}/deployment/jobhistories API - // and /api/fmc_config/v1/domain/{domainUUID}/job/taskstatuses/{objectId} (optionally, no jobID in taskstauses) - // First after POST /api/fmc_config/v1/domain/{domainUUID}/deployment/deploymentrequests - // we have to moinitor task status which available in response body of POST api call - // then, when deployment task is finished we need to store Deployment job into tf state - - // Check deployemnt task status and waint until is finished - // Read task id from deployment response - resDeployment, err := resJson2Map(res) - if err != nil { - tflog.Debug(ctx, fmt.Sprintf("%s: Error getting task url", "")) - return - } - deploymentTaskId := resDeployment["metadata"].(map[string]interface{})["task"].(map[string]interface{})["id"].(interface{}).(string) + origDeviceList := plan.DeviceList - // Get task status - for { - urlPath := "/api/fmc_config/v1/domain/{DOMAIN_UUID}/job/taskstatuses/" + deploymentTaskId - res, err = r.client.Get(urlPath, reqMods...) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to read job task status (POST/PUT), got error: %s, %s", err, res.String())) - return - } - resTaskstaus, err := resJson2Map(res) + if !plan.ForceDeploy.ValueBool() { + // Get list of deployable devices + resDeployable, err := r.client.Get("/api/fmc_config/v1/domain/{DOMAIN_UUID}/deployment/deployabledevices?expanded=true", reqMods...) if err != nil { - tflog.Debug(ctx, fmt.Sprintf("%s: Error getting task status", "")) + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to obtain list of deployable devices object (GET), got error: %s, %s", err, resDeployable.String())) return } - if resTaskstaus["status"].(interface{}).(string) == "Deployed" { - break - } - } + deployableDevices := resDeployable.Get("items.#.device.id") - // Read deployment history - urlPath := "/api/fmc_config/v1/domain/{DOMAIN_UUID}/deployment/jobhistories?expanded=true" - res, err = r.client.Get(urlPath, reqMods...) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to read deployment history (POST/PUT), got error: %s, %s", err, res.String())) - return - } - resMap, err := resJson2Map(res) - if err != nil { - tflog.Debug(ctx, fmt.Sprintf("%s: Error getting job histories", "")) - return - } - - // Variable to store the matching item - var matchingItem map[string]interface{} - // Slice to collect matching data - var matchingData []interface{} - - // Access "items" - items, ok := resMap["items"].([]interface{}) - if !ok { - tflog.Debug(ctx, fmt.Sprintf("%s: Error: 'items' is not a valid array", "")) - } - - // Iterate over items to find latest JobID based on devices UUID -JobIdLookup: - for _, item := range items { - itemMap, ok := item.(map[string]interface{}) - if !ok { - continue - } + var deployableDeviceIds []string + deployableDevices.ForEach(func(_, value gjson.Result) bool { + deployableDeviceIds = append(deployableDeviceIds, value.String()) + return true + }) - if itemMap["jobType"].(interface{}).(string) != "DEPLOYMENT" { - continue - } + var deviceIdsList []string + plan.DeviceList.ElementsAs(ctx, &deviceIdsList, false) - // *** It has to be fixed to add device list rather than single device - deviceList, ok := itemMap["deviceList"].([]interface{}) + devicesToDeploy := filterForDeployableDevices(deviceIdsList, deployableDeviceIds) - jobId, ok := itemMap["id"].(string) - if !ok { - continue - } - - // Iterate over deviceData to check deviceUUID belongs to deployment - for _, device := range deviceList { - deviceMap, ok := device.(map[string]interface{}) - if !ok { - continue - } - - deviceUUID, ok := deviceMap["deviceUUID"].(interface{}).(string) - if !ok { - continue - } - - // *** It has to be fixed to add device list rather than single device - if contains(interfaceUUIDList, deviceUUID) { - plan.Id = types.StringValue(jobId) - matchingData = append(matchingData, deviceMap) - break JobIdLookup - } - } + plan.DeviceList = helpers.GetStringListFromStringSlice(devicesToDeploy) } - // Print the matching item - if matchingItem != nil { - tflog.Debug(ctx, fmt.Sprintf("Matching item: %+v\n", matchingItem)) - } else { - tflog.Debug(ctx, fmt.Sprintf("No matching item found. %+v\n", "")) + tflog.Debug(ctx, fmt.Sprintf("%s: Beginning Create", plan.Id.ValueString())) + + // Create object + body := plan.toBody(ctx, Deployment{}) + res, err := r.client.Post(plan.getPath(), body, reqMods...) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to configure object (POST/PUT), got error: %s, %s", err, res.String())) + return } - // ############################################ + plan.Id = types.StringValue(res.Get("id").String()) + plan.fromBodyUnknowns(ctx, res) tflog.Debug(ctx, fmt.Sprintf("%s: Create finished successfully", plan.Id.ValueString())) + plan.DeviceList = origDeviceList diags = resp.State.Set(ctx, &plan) resp.Diagnostics.Append(diags...) @@ -360,185 +265,77 @@ func extractDeviceUUIDs(res string) ([]string, error) { } func (r *DeploymentResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { - var state Deployment + resp.State.SetAttribute(ctx, path.Root("save"), false) +} - // Read state - diags := req.State.Get(ctx, &state) +func (r *DeploymentResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan Deployment + + // 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 !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())) - - // ############################################ - - // Read list of deployable devices - urlPathDeployable := "/api/fmc_config/v1/domain/{DOMAIN_UUID}/deployment/deployabledevices?expanded=true" - resDeployable, err := r.client.Get(urlPathDeployable, reqMods...) - jsonStringDeployable := resDeployable.String() - var resMapDeployable map[string]interface{} - err = json.Unmarshal([]byte(jsonStringDeployable), &resMapDeployable) - if err != nil { - tflog.Debug(ctx, fmt.Sprintf("%s: Error parsing JSON data (deployabledevices)", "")) - } - - // Access "items" - items, ok := resMapDeployable["items"].([]interface{}) - var deviceIdDeployable []string - if !ok { - tflog.Debug(ctx, fmt.Sprintf("%s: Error: 'items' is not a valid array", "")) - } - for _, item := range items { - itemMap, ok := item.(map[string]interface{}) - if !ok { - continue - } - deviceIdDeployable = append(deviceIdDeployable, itemMap["device"].(map[string]interface{})["id"].(interface{}).(string)) + if !plan.Domain.IsNull() && plan.Domain.ValueString() != "" { + reqMods = append(reqMods, fmc.DomainName(plan.Domain.ValueString())) } - // EO Read List of deployable devices - - // Target job ID to match - jobId := state.Id.ValueString() - - urlPath := "/api/fmc_config/v1/domain/{DOMAIN_UUID}/deployment/jobhistories/" + jobId - res, err := r.client.Get(urlPath, reqMods...) - - if err != nil && strings.Contains(err.Error(), "StatusCode 404") { - resp.State.RemoveResource(ctx) - return - } else if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to retrieve object (GET), got error: %s, %s", err, res.String())) - return + // If version not provided, set it to the current unix timestamp + if plan.Version.IsUnknown() { + plan.Version = types.StringValue(strconv.FormatInt(time.Now().UnixMilli(), 10)) } - imp, diags := helpers.IsFlagImporting(ctx, req) - if resp.Diagnostics.Append(diags...); resp.Diagnostics.HasError() { - return - } + origDeviceList := plan.DeviceList - // Read device list - resDeviceIdList, err := extractDeviceUUIDs(res.String()) - if err != nil { - tflog.Debug(ctx, fmt.Sprintf("%s: Error getting device list", "")) - return - } - - // Check if deployemnt hasn't been rolbacked and device list is not showing up as deployable device - // Deployabale device list check - if containsAllElements(deviceIdDeployable, resDeviceIdList) { - // if contains(deviceIdDeployable, resDeviceId) { - var resMap map[string]interface{} - resMap, err = resJson2Map(res) + if !plan.ForceDeploy.ValueBool() { + // Get list of deployable devices + resDeployable, err := r.client.Get("/api/fmc_config/v1/domain/{DOMAIN_UUID}/deployment/deployabledevices?expanded=true", reqMods...) if err != nil { - tflog.Debug(ctx, fmt.Sprintf("%s: Error getting job histories", "")) + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to obtain list of deployable devices object (GET), got error: %s, %s", err, resDeployable.String())) return } + deployableDevices := resDeployable.Get("items.#.device.id") - resMap["deviceList"] = []interface{}{} - - resMapJSON, err := json.Marshal(resMap) - if err != nil { - panic(fmt.Sprintf("Failed to marshal JSON: %v", err)) - } - res = gjson.Parse(string(resMapJSON)) - - // After `terraform import` we switch to a full read. - if imp { - state.fromBody(ctx, res) - } else { - state.fromBodyPartial(ctx, res) - } - } else { - // Update res with required key (tfsate) becuse we are using different API call to read device state - var resMap map[string]interface{} - resMap, err = resJson2Map(res) - if err != nil { - tflog.Debug(ctx, fmt.Sprintf("%s: Error parsing JSON data (jobhistories)", "")) - } - - // Add hash values to res which are not exists in res but exists in state - resMap["version"] = state.Version.ValueString() - resMap["ignoreWarning"] = state.IgnoreWarning.ValueBool() - resMapJSON, err := json.Marshal(resMap) - if err != nil { - panic(fmt.Sprintf("Failed to marshal JSON: %v", err)) - } - res = gjson.Parse(string(resMapJSON)) - - // Define the type of elements in the list - elementType := types.StringType - - // Define the list values - var values []attr.Value + var deployableDeviceIds []string + deployableDevices.ForEach(func(_, value gjson.Result) bool { + deployableDeviceIds = append(deployableDeviceIds, value.String()) + return true + }) - // Populate the values from resDeviceIdList - for _, deviceId := range resDeviceIdList { - values = append(values, types.StringValue(deviceId)) - } + var deviceIdsList []string + plan.DeviceList.ElementsAs(ctx, &deviceIdsList, false) - // Create the ListValue - deviceList, diags := types.ListValue(elementType, values) - if diags.HasError() { - tflog.Debug(ctx, fmt.Sprintf("%s: Error creating deviceList (jobhistories)", "")) - } + devicesToDeploy := filterForDeployableDevices(deviceIdsList, deployableDeviceIds) - // After `terraform import` we switch to a full read. - if imp { - state.fromBody(ctx, res) - } else { - state.fromBodyPartial(ctx, res) - } - - state.DeviceList = deviceList + plan.DeviceList = helpers.GetStringListFromStringSlice(devicesToDeploy) } - 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) -} - -// Section below is generated&owned by "gen/generator.go". //template:begin update - -func (r *DeploymentResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { - var plan, state Deployment - - // Read plan - diags := req.Plan.Get(ctx, &plan) - if resp.Diagnostics.Append(diags...); resp.Diagnostics.HasError() { - return - } + tflog.Debug(ctx, fmt.Sprintf("%s: Beginning Create", plan.Id.ValueString())) - // Read state - diags = req.State.Get(ctx, &state) - if resp.Diagnostics.Append(diags...); resp.Diagnostics.HasError() { + // Create object + body := plan.toBody(ctx, Deployment{}) + res, err := r.client.Post(plan.getPath(), body, reqMods...) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to configure object (POST/PUT), got error: %s, %s", err, res.String())) 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())) + plan.Id = types.StringValue(res.Get("id").String()) + plan.fromBodyUnknowns(ctx, res) - tflog.Debug(ctx, fmt.Sprintf("%s: Update finished successfully", plan.Id.ValueString())) + tflog.Debug(ctx, fmt.Sprintf("%s: Create finished successfully", plan.Id.ValueString())) + plan.DeviceList = origDeviceList diags = resp.State.Set(ctx, &plan) resp.Diagnostics.Append(diags...) + + helpers.SetFlagImporting(ctx, false, resp.Private, &resp.Diagnostics) } -// End of section. //template:end update +// Section below is generated&owned by "gen/generator.go". //template:begin delete func (r *DeploymentResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { var state Deployment @@ -556,164 +353,30 @@ func (r *DeploymentResource) Delete(ctx context.Context, req resource.DeleteRequ } tflog.Debug(ctx, fmt.Sprintf("%s: Beginning Delete", state.Id.ValueString())) - // res, err := r.client.Delete(state.getPath()+"/"+url.QueryEscape(state.Id.ValueString()), reqMods...) - // if err != nil { - // resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to delete object (DELETE), got error: %s, %s", err, res.String())) - // return - // } - - // As there are no DELETE api call for /api/fmc_config/v1/domain/{domainUUID}/deployment/deploymentrequests - // we can only roblback deployemnt to previous state - // we can use /api/fmc_config/v1/domain/{domainUUID}/deployment/rollbackrequests to execute rolback - // first we need to get deployemnt id and device list from state - // next get deployemnt job id to rollback to, it can be retrieved from deployemnt job history - // /api/fmc_config/v1/domain/{domainUUID}/deployment/jobhistories API - // next initiate rollback api and wait until the job is finished - - // Get data from state - stateDeviceList, err := extractDeviceList(state.DeviceList) - if err != nil { - tflog.Debug(ctx, fmt.Sprintf("%s: Error getting device list from state", "")) - return - } - stateDeploymentJobId := state.Id.ValueString() - var rollbackToDeploymentJobId string - // Read deployment history - urlPath := "/api/fmc_config/v1/domain/{DOMAIN_UUID}/deployment/jobhistories?expanded=true" - res, err := r.client.Get(urlPath, reqMods...) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to read deployment history (POST/PUT), got error: %s, %s", err, res.String())) - return - } - resMap, err := resJson2Map(res) - if err != nil { - tflog.Debug(ctx, fmt.Sprintf("%s: Error getting job histories", "")) - return - } + tflog.Debug(ctx, fmt.Sprintf("%s: Delete finished successfully", state.Id.ValueString())) - // Access "items" - items, ok := resMap["items"].([]interface{}) - if !ok { - tflog.Debug(ctx, fmt.Sprintf("%s: Error: 'items' is not a valid array", "")) - } - - // Iterate over items to find JobID for rollback based on latest deployment job id and devices UUID - // stateDeploymentJobId -JobIdLookup: - for _, item := range items { - itemMap, ok := item.(map[string]interface{}) - if !ok { - continue - } - - if itemMap["jobType"].(interface{}).(string) != "DEPLOYMENT" { - continue - } - - if (itemMap["id"].(interface{}).(string) != stateDeploymentJobId) && (rollbackToDeploymentJobId == "") { - continue - } + resp.State.RemoveResource(ctx) +} - deviceList, ok := itemMap["deviceList"].([]interface{}) - if !ok { - continue - } +// End of section. //template:end delete - // Iterate over deviceData to check deviceUUID belongs to deployment - for _, device := range deviceList { - deviceMap, ok := device.(map[string]interface{}) - if !ok { - continue - } - - // *** It has to be fixed to add device list rather than single device - deviceUUID, ok := deviceMap["deviceUUID"].(interface{}).(string) - if !ok { - continue - } - - // *** It has to be fixed to add device list rather than single device - if contains(stateDeviceList, deviceUUID) { - // set rollbackToDeploymentJobId to be equal fo stateDeploymentJobId - // and continuse to iterate to find previous deployemnt - if rollbackToDeploymentJobId == "" { - rollbackToDeploymentJobId = stateDeploymentJobId - continue - } else { - rollbackToDeploymentJobId = itemMap["id"].(interface{}).(string) - break JobIdLookup - } - } - } +// Checks what devices from those that were requested for deployment are actually deployable +// Function returns list of requested device ids that are deployable +func filterForDeployableDevices(listOfDevices, deployableDevices []string) []string { + devicesMap := make(map[string]bool) + for _, str := range listOfDevices { + devicesMap[str] = true } - // Trigger rollback - if rollbackToDeploymentJobId != "" && rollbackToDeploymentJobId != stateDeploymentJobId { - - urlPath = "/api/fmc_config/v1/domain/{DOMAIN_UUID}/deployment/rollbackrequests" - body := `{ ` - body += ` "type": "RollbackRequest",` - body += ` "rollbackDeviceList": [` - body += ` {` - body += ` "deploymentJobId": "` + rollbackToDeploymentJobId + `",` - // *** It has to be fixed to add device list rather than single device - body += ` "deviceList": [` - body += ` "` + stateDeviceList[0] + `"` - body += ` ]` - body += ` }` - body += ` ]` - body += `}` - - res, err = r.client.Post(urlPath, body, reqMods...) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to configure object (POST/PUT), got error: %s, %s", err, res.String())) - return - } - - resDeployment, err := resJson2Map(res) - if err != nil { - tflog.Debug(ctx, fmt.Sprintf("%s: Error getting task url", "")) - return - } - deploymentTaskId := resDeployment["metadata"].(map[string]interface{})["task"].(map[string]interface{})["id"].(interface{}).(string) - - // Get task status - for { - urlPath := "/api/fmc_config/v1/domain/{DOMAIN_UUID}/job/taskstatuses/" + deploymentTaskId - res, err = r.client.Get(urlPath, reqMods...) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to read job task status (POST/PUT), got error: %s, %s", err, res.String())) - return - } - resTaskstaus, err := resJson2Map(res) - if err != nil { - tflog.Debug(ctx, fmt.Sprintf("%s: Error getting task status", "")) - return - } - if resTaskstaus["status"].(interface{}).(string) == "Deployed" { - break - } + var result []string + for _, str := range deployableDevices { + if devicesMap[str] { + result = append(result, str) + // Remove the element to avoid duplicates in the result + delete(devicesMap, str) } - - tflog.Debug(ctx, fmt.Sprintf("%s: Delete finished successfully", state.Id.ValueString())) - resp.State.RemoveResource(ctx) - } else { - tflog.Debug(ctx, fmt.Sprintf("%s: Delete failed. Could not trigger rollback", state.Id.ValueString())) } -} - -// Section below is generated&owned by "gen/generator.go". //template:begin import -// End of section. //template:end import - -// Section below is generated&owned by "gen/generator.go". //template:begin createSubresources -// End of section. //template:end createSubresources - -// Section below is generated&owned by "gen/generator.go". //template:begin deleteSubresources - -// End of section. //template:end deleteSubresources - -// Section below is generated&owned by "gen/generator.go". //template:begin updateSubresources - -// End of section. //template:end updateSubresources + return result +} diff --git a/internal/provider/resource_fmc_deployment_test.go b/internal/provider/resource_fmc_deployment_test.go index 2d8b5ea0..768d2b80 100644 --- a/internal/provider/resource_fmc_deployment_test.go +++ b/internal/provider/resource_fmc_deployment_test.go @@ -38,11 +38,11 @@ func TestAccFmcDeployment(t *testing.T) { var steps []resource.TestStep if os.Getenv("SKIP_MINIMUM_TEST") == "" { steps = append(steps, resource.TestStep{ - Config: testAccFmcDeploymentPrerequisitesConfig + testAccFmcDeploymentConfig_minimum(), + Config: testAccFmcDeploymentConfig_minimum(), }) } steps = append(steps, resource.TestStep{ - Config: testAccFmcDeploymentPrerequisitesConfig + testAccFmcDeploymentConfig_all(), + Config: testAccFmcDeploymentConfig_all(), Check: resource.ComposeTestCheckFunc(checks...), }) @@ -56,22 +56,12 @@ func TestAccFmcDeployment(t *testing.T) { // End of section. //template:end testAcc // Section below is generated&owned by "gen/generator.go". //template:begin testPrerequisites - -const testAccFmcDeploymentPrerequisitesConfig = ` -variable "timestamp" { default = null } // tests will set $TF_VAR_timestamp -variable "device_id_list" { // tests will set $TF_VAR_device_id_list - type = list(string) - default = null -} -` - // End of section. //template:end testPrerequisites // Section below is generated&owned by "gen/generator.go". //template:begin testAccConfigMinimal func testAccFmcDeploymentConfig_minimum() string { config := `resource "fmc_deployment" "test" {` + "\n" - config += ` ignore_warning = true` + "\n" config += ` device_list = var.device_id_list` + "\n" config += `}` + "\n" return config @@ -84,7 +74,6 @@ func testAccFmcDeploymentConfig_minimum() string { func testAccFmcDeploymentConfig_all() string { config := `resource "fmc_deployment" "test" {` + "\n" config += ` version = var.timestamp` + "\n" - config += ` ignore_warning = true` + "\n" config += ` device_list = var.device_id_list` + "\n" config += `}` + "\n" return config From 65d419eb2e10982b14d8176f9196acfed2536b48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Bernacki?= Date: Thu, 19 Dec 2024 01:19:26 +0100 Subject: [PATCH 11/12] read --- docs/resources/deployment.md | 5 +- examples/resources/fmc_deployment/resource.tf | 1 - gen/definitions/deploy_device.yaml | 15 +- internal/provider/model_fmc_deployment.go | 15 +- internal/provider/resource_fmc_deployment.go | 147 ++++-------------- .../provider/resource_fmc_deployment_test.go | 3 +- 6 files changed, 59 insertions(+), 127 deletions(-) diff --git a/docs/resources/deployment.md b/docs/resources/deployment.md index ff997c0e..eee48786 100644 --- a/docs/resources/deployment.md +++ b/docs/resources/deployment.md @@ -14,7 +14,6 @@ This resource can manage a Deployment. ```terraform resource "fmc_deployment" "example" { - version = "1457566762351" device_list = ["2fe9063e-8bd5-11ef-9475-e4aeac78cf37"] deployment_note = "Deployment note" } @@ -25,14 +24,14 @@ resource "fmc_deployment" "example" { ### Required -- `device_list` (List of String) List of device ids to be deployed. +- `device_list` (List of String) List of device ids to be deployed. If forceDeploy is not set to true, only devices that are deployable will be deployed. ### Optional +- `deploy` (Boolean) This attribute is only used internally. - `deployment_note` (String) User note related to deployment. - `domain` (String) The name of the FMC domain - `force_deploy` (Boolean) Force deployment (even if there are no configuration changes). -- `save` (Boolean) This attribute is only used internally. - `version` (String) Version to which the deployment should be done. By default set to current unix timestamp. ### Read-Only diff --git a/examples/resources/fmc_deployment/resource.tf b/examples/resources/fmc_deployment/resource.tf index 3c19d6b3..fa9b63f8 100644 --- a/examples/resources/fmc_deployment/resource.tf +++ b/examples/resources/fmc_deployment/resource.tf @@ -1,5 +1,4 @@ resource "fmc_deployment" "example" { - version = "1457566762351" device_list = ["2fe9063e-8bd5-11ef-9475-e4aeac78cf37"] deployment_note = "Deployment note" } diff --git a/gen/definitions/deploy_device.yaml b/gen/definitions/deploy_device.yaml index d221f709..ef8f64d8 100644 --- a/gen/definitions/deploy_device.yaml +++ b/gen/definitions/deploy_device.yaml @@ -13,8 +13,7 @@ attributes: - model_name: version type: String description: Version to which the deployment should be done. By default set to current unix timestamp. - example: "1457566762351" - test_value: var.timestamp + exclude_example: true - model_name: forceDeploy type: Bool description: Force deployment (even if there are no configuration changes). @@ -25,7 +24,7 @@ attributes: value: "true" - model_name: deviceList type: List - description: List of device ids to be deployed. + description: List of device ids to be deployed. If forceDeploy is not set to true, only devices that are deployable will be deployed. mandatory: true element_type: String example: 2fe9063e-8bd5-11ef-9475-e4aeac78cf37 @@ -35,12 +34,12 @@ attributes: description: User note related to deployment. example: "Deployment note" exclude_test: true - # - model_name: save - # description: "This attribute is only used internally" - # type: Bool - # value: "true" + - model_name: deploy + description: "This attribute is only used internally" + type: Bool + exclude_test: true + exclude_example: true # test_prerequisites: |- -# variable "timestamp" { default = null } // tests will set $TF_VAR_timestamp # variable "device_id_list" { // tests will set $TF_VAR_device_id_list # type = list(string) # default = null diff --git a/internal/provider/model_fmc_deployment.go b/internal/provider/model_fmc_deployment.go index d7fa116d..461a6445 100644 --- a/internal/provider/model_fmc_deployment.go +++ b/internal/provider/model_fmc_deployment.go @@ -38,6 +38,7 @@ type Deployment struct { ForceDeploy types.Bool `tfsdk:"force_deploy"` DeviceList types.List `tfsdk:"device_list"` DeploymentNote types.String `tfsdk:"deployment_note"` + Deploy types.Bool `tfsdk:"deploy"` } // End of section. //template:end types @@ -73,7 +74,9 @@ func (data Deployment) toBody(ctx context.Context, state Deployment) string { if !data.DeploymentNote.IsNull() { body, _ = sjson.Set(body, "deploymentNote", data.DeploymentNote.ValueString()) } - body, _ = sjson.Set(body, "save", true) + if !data.Deploy.IsNull() { + body, _ = sjson.Set(body, "deploy", data.Deploy.ValueBool()) + } return body } @@ -102,6 +105,11 @@ func (data *Deployment) fromBody(ctx context.Context, res gjson.Result) { } else { data.DeploymentNote = types.StringNull() } + if value := res.Get("deploy"); value.Exists() { + data.Deploy = types.BoolValue(value.Bool()) + } else { + data.Deploy = types.BoolNull() + } } // End of section. //template:end fromBody @@ -133,6 +141,11 @@ func (data *Deployment) fromBodyPartial(ctx context.Context, res gjson.Result) { } else { data.DeploymentNote = types.StringNull() } + if value := res.Get("deploy"); value.Exists() && !data.Deploy.IsNull() { + data.Deploy = types.BoolValue(value.Bool()) + } else { + data.Deploy = types.BoolNull() + } } // End of section. //template:end fromBodyPartial diff --git a/internal/provider/resource_fmc_deployment.go b/internal/provider/resource_fmc_deployment.go index f08e8818..0ddb742e 100644 --- a/internal/provider/resource_fmc_deployment.go +++ b/internal/provider/resource_fmc_deployment.go @@ -19,7 +19,6 @@ package provider import ( "context" - "encoding/json" "fmt" "strconv" "time" @@ -27,6 +26,7 @@ import ( "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/booldefault" "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" @@ -34,6 +34,7 @@ import ( "github.com/netascode/go-fmc" "github.com/netascode/terraform-provider-fmc/internal/provider/helpers" "github.com/tidwall/gjson" + "github.com/tidwall/sjson" ) // End of section. //template:end imports @@ -85,7 +86,7 @@ func (r *DeploymentResource) Schema(ctx context.Context, req resource.SchemaRequ Optional: true, }, "device_list": schema.ListAttribute{ - MarkdownDescription: helpers.NewAttributeDescription("List of device ids to be deployed.").String, + MarkdownDescription: helpers.NewAttributeDescription("List of device ids to be deployed. If forceDeploy is not set to true, only devices that are deployable will be deployed.").String, ElementType: types.StringType, Required: true, }, @@ -93,6 +94,12 @@ func (r *DeploymentResource) Schema(ctx context.Context, req resource.SchemaRequ MarkdownDescription: helpers.NewAttributeDescription("User note related to deployment.").String, Optional: true, }, + "deploy": schema.BoolAttribute{ + MarkdownDescription: "This attribute is only used internally.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(true), + }, }, } } @@ -152,16 +159,19 @@ func (r *DeploymentResource) Create(ctx context.Context, req resource.CreateRequ tflog.Debug(ctx, fmt.Sprintf("%s: Beginning Create", plan.Id.ValueString())) - // Create object - body := plan.toBody(ctx, Deployment{}) - res, err := r.client.Post(plan.getPath(), body, reqMods...) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to configure object (POST/PUT), got error: %s, %s", err, res.String())) - return - } + var deviceIdsSlice []string + plan.DeviceList.ElementsAs(ctx, &deviceIdsSlice, false) - plan.Id = types.StringValue(res.Get("id").String()) - plan.fromBodyUnknowns(ctx, res) + if len(deviceIdsSlice) != 0 { + // Create object + body := plan.toBody(ctx, Deployment{}) + body, _ = sjson.Delete(body, "deploy") + res, err := r.client.Post(plan.getPath(), body, reqMods...) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to configure object (POST/PUT), got error: %s, %s", err, res.String())) + return + } + } tflog.Debug(ctx, fmt.Sprintf("%s: Create finished successfully", plan.Id.ValueString())) @@ -172,100 +182,8 @@ func (r *DeploymentResource) Create(ctx context.Context, req resource.CreateRequ helpers.SetFlagImporting(ctx, false, resp.Private, &resp.Diagnostics) } -// Function to extract device UUIDs from plan -func extractDeviceList(deviceList types.List) ([]string, error) { - var extractedList []string - - // Iterate through each element in the ListValue - for _, elem := range deviceList.Elements() { - // Convert the element to a StringValue and extract its value - if stringValue, ok := elem.(types.String); ok { - extractedList = append(extractedList, stringValue.ValueString()) - } else { - return nil, fmt.Errorf("element is not a StringValue") - } - } - - return extractedList, nil -} - -// Function to convert gjson.Result to a map -func resJson2Map(res gjson.Result) (map[string]interface{}, error) { - // Convert gjson.Result to string - jsonString := res.String() - - // Declare a map to hold the unmarshaled data - var resMap map[string]interface{} - - // Unmarshal the JSON string into the map - err := json.Unmarshal([]byte(jsonString), &resMap) - if err != nil { - return nil, fmt.Errorf("error parsing JSON data: %w", err) - } - - // Return the map and nil error - return resMap, nil -} - -// Function to check if item belongs to list -func contains(list []string, item string) bool { - for _, v := range list { - if v == item { - return true - } - } - return false -} - -// Function to heck if all elements of list2 are in list1 (ignoring order) -func containsAllElements(list1, list2 []string) bool { - lookup := make(map[string]bool) - for _, item := range list1 { - lookup[item] = true - } - for _, item := range list2 { - if !lookup[item] { - return false - } - } - return true -} - -func extractDeviceUUIDs(res string) ([]string, error) { - // Parse the JSON into a map - var resMap map[string]interface{} - err := json.Unmarshal([]byte(res), &resMap) - if err != nil { - return nil, fmt.Errorf("failed to parse JSON: %v", err) - } - - // Extract deviceList - deviceList, ok := resMap["deviceList"].([]interface{}) - if !ok { - return nil, fmt.Errorf("deviceList is not an array or is missing") - } - - // Extract deviceUUIDs - var deviceUUIDs []string - for _, item := range deviceList { - device, ok := item.(map[string]interface{}) - if !ok { - return nil, fmt.Errorf("deviceList item is not an object") - } - - uuid, ok := device["deviceUUID"].(string) - if !ok { - return nil, fmt.Errorf("deviceUUID is not a string or is missing") - } - - deviceUUIDs = append(deviceUUIDs, uuid) - } - - return deviceUUIDs, nil -} - func (r *DeploymentResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { - resp.State.SetAttribute(ctx, path.Root("save"), false) + resp.State.SetAttribute(ctx, path.Root("deploy"), false) } func (r *DeploymentResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { @@ -315,16 +233,19 @@ func (r *DeploymentResource) Update(ctx context.Context, req resource.UpdateRequ tflog.Debug(ctx, fmt.Sprintf("%s: Beginning Create", plan.Id.ValueString())) - // Create object - body := plan.toBody(ctx, Deployment{}) - res, err := r.client.Post(plan.getPath(), body, reqMods...) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to configure object (POST/PUT), got error: %s, %s", err, res.String())) - return - } + var deviceIdsSlice []string + plan.DeviceList.ElementsAs(ctx, &deviceIdsSlice, false) - plan.Id = types.StringValue(res.Get("id").String()) - plan.fromBodyUnknowns(ctx, res) + if len(deviceIdsSlice) != 0 { + // Create object + body := plan.toBody(ctx, Deployment{}) + body, _ = sjson.Delete(body, "deploy") + res, err := r.client.Post(plan.getPath(), body, reqMods...) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to configure object (POST/PUT), got error: %s, %s", err, res.String())) + return + } + } tflog.Debug(ctx, fmt.Sprintf("%s: Create finished successfully", plan.Id.ValueString())) diff --git a/internal/provider/resource_fmc_deployment_test.go b/internal/provider/resource_fmc_deployment_test.go index 768d2b80..65d44090 100644 --- a/internal/provider/resource_fmc_deployment_test.go +++ b/internal/provider/resource_fmc_deployment_test.go @@ -34,6 +34,7 @@ func TestAccFmcDeployment(t *testing.T) { t.Skip("skipping test, set environment variable TF_VAR_timestamp and TF_VAR_device_id_list") } var checks []resource.TestCheckFunc + checks = append(checks, resource.TestCheckResourceAttr("fmc_deployment.test", "version", "")) var steps []resource.TestStep if os.Getenv("SKIP_MINIMUM_TEST") == "" { @@ -73,7 +74,7 @@ func testAccFmcDeploymentConfig_minimum() string { func testAccFmcDeploymentConfig_all() string { config := `resource "fmc_deployment" "test" {` + "\n" - config += ` version = var.timestamp` + "\n" + config += ` version = ""` + "\n" config += ` device_list = var.device_id_list` + "\n" config += `}` + "\n" return config From 828c1a08e0b62f83152f1edc64ba468b2aff28ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Bernacki?= Date: Thu, 19 Dec 2024 11:23:18 +0100 Subject: [PATCH 12/12] add new test --- internal/provider/resource_fmc_deployment_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/provider/resource_fmc_deployment_test.go b/internal/provider/resource_fmc_deployment_test.go index 65d44090..b6cd1778 100644 --- a/internal/provider/resource_fmc_deployment_test.go +++ b/internal/provider/resource_fmc_deployment_test.go @@ -50,6 +50,7 @@ func TestAccFmcDeployment(t *testing.T) { resource.Test(t, resource.TestCase{ PreCheck: func() { testAccPreCheck(t) }, ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + ErrorCheck: func(err error) error { return testAccErrorCheck(t, err) }, Steps: steps, }) }