From de571b69a249fa2e3e7af9e1cd20e08ab742c248 Mon Sep 17 00:00:00 2001 From: danischm Date: Wed, 18 Sep 2024 12:38:17 +0200 Subject: [PATCH] Add device resource and data source --- docs/data-sources/device.md | 40 +++ docs/resources/device.md | 56 ++++ .../data-sources/meraki_device/data-source.tf | 4 + examples/resources/meraki_device/import.sh | 1 + examples/resources/meraki_device/resource.tf | 9 + gen/definition.go | 18 +- gen/definitions/device.yaml | 66 +++++ .../provider/data_source_meraki_device.go | 153 ++++++++++ .../data_source_meraki_device_test.go | 94 ++++++ internal/provider/model_meraki_device.go | 204 +++++++++++++ internal/provider/provider.go | 2 + internal/provider/resource_meraki_device.go | 275 ++++++++++++++++++ .../provider/resource_meraki_device_test.go | 108 +++++++ 13 files changed, 1024 insertions(+), 6 deletions(-) create mode 100644 docs/data-sources/device.md create mode 100644 docs/resources/device.md create mode 100644 examples/data-sources/meraki_device/data-source.tf create mode 100644 examples/resources/meraki_device/import.sh create mode 100644 examples/resources/meraki_device/resource.tf create mode 100644 gen/definitions/device.yaml create mode 100644 internal/provider/data_source_meraki_device.go create mode 100644 internal/provider/data_source_meraki_device_test.go create mode 100644 internal/provider/model_meraki_device.go create mode 100644 internal/provider/resource_meraki_device.go create mode 100644 internal/provider/resource_meraki_device_test.go diff --git a/docs/data-sources/device.md b/docs/data-sources/device.md new file mode 100644 index 0000000..cc95ba2 --- /dev/null +++ b/docs/data-sources/device.md @@ -0,0 +1,40 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "meraki_device Data Source - terraform-provider-meraki" +subcategory: "Devices" +description: |- + This data source can read the Device configuration. +--- + +# meraki_device (Data Source) + +This data source can read the `Device` configuration. + +## Example Usage + +```terraform +data "meraki_device" "example" { + id = "12345678" + serial = "1234-ABCD-1234" +} +``` + + +## Schema + +### Required + +- `serial` (String) Switch serial + +### Read-Only + +- `address` (String) The address of a device +- `floor_plan_id` (String) The floor plan to associate to this device. null disassociates the device from the floorplan. +- `id` (String) The id of the object +- `lat` (Number) The latitude of a device +- `lng` (Number) The longitude of a device +- `move_map_marker` (Boolean) Whether or not to set the latitude and longitude of a device based on the new address. Only applies when lat and lng are not specified. +- `name` (String) The name of a device +- `notes` (String) The notes for the device. String. Limited to 255 characters. +- `switch_profile_id` (String) The ID of a switch template to bind to the device (for available switch templates, see the `Switch Templates` endpoint). Use null to unbind the switch device from the current profile. For a device to be bindable to a switch template, it must (1) be a switch, and (2) belong to a network that is bound to a configuration template. +- `tags` (List of String) The list of tags of a device diff --git a/docs/resources/device.md b/docs/resources/device.md new file mode 100644 index 0000000..a9e441c --- /dev/null +++ b/docs/resources/device.md @@ -0,0 +1,56 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "meraki_device Resource - terraform-provider-meraki" +subcategory: "Devices" +description: |- + This resource can manage the Device configuration. +--- + +# meraki_device (Resource) + +This resource can manage the `Device` configuration. + +## Example Usage + +```terraform +resource "meraki_device" "example" { + serial = "1234-ABCD-1234" + address = "1600 Pennsylvania Ave" + lat = 37.4180951010362 + lng = -122.098531723022 + name = "My AP" + notes = "My AP's note" + tags = ["recently-added"] +} +``` + + +## Schema + +### Required + +- `serial` (String) Switch serial + +### Optional + +- `address` (String) The address of a device +- `floor_plan_id` (String) The floor plan to associate to this device. null disassociates the device from the floorplan. +- `lat` (Number) The latitude of a device +- `lng` (Number) The longitude of a device +- `move_map_marker` (Boolean) Whether or not to set the latitude and longitude of a device based on the new address. Only applies when lat and lng are not specified. +- `name` (String) The name of a device +- `notes` (String) The notes for the device. String. Limited to 255 characters. +- `switch_profile_id` (String) The ID of a switch template to bind to the device (for available switch templates, see the `Switch Templates` endpoint). Use null to unbind the switch device from the current profile. For a device to be bindable to a switch template, it must (1) be a switch, and (2) belong to a network that is bound to a configuration template. +- `tags` (List of String) The list of tags of a device + +### Read-Only + +- `id` (String) The id of the object + +## Import + +Import is supported using the following syntax: + +```shell +terraform import meraki_device.example "," +``` diff --git a/examples/data-sources/meraki_device/data-source.tf b/examples/data-sources/meraki_device/data-source.tf new file mode 100644 index 0000000..3cf7b46 --- /dev/null +++ b/examples/data-sources/meraki_device/data-source.tf @@ -0,0 +1,4 @@ +data "meraki_device" "example" { + id = "12345678" + serial = "1234-ABCD-1234" +} diff --git a/examples/resources/meraki_device/import.sh b/examples/resources/meraki_device/import.sh new file mode 100644 index 0000000..d7d7dfd --- /dev/null +++ b/examples/resources/meraki_device/import.sh @@ -0,0 +1 @@ +terraform import meraki_device.example "," diff --git a/examples/resources/meraki_device/resource.tf b/examples/resources/meraki_device/resource.tf new file mode 100644 index 0000000..cbec192 --- /dev/null +++ b/examples/resources/meraki_device/resource.tf @@ -0,0 +1,9 @@ +resource "meraki_device" "example" { + serial = "1234-ABCD-1234" + address = "1600 Pennsylvania Ave" + lat = 37.4180951010362 + lng = -122.098531723022 + name = "My AP" + notes = "My AP's note" + tags = ["recently-added"] +} diff --git a/gen/definition.go b/gen/definition.go index 4997748..a93565d 100644 --- a/gen/definition.go +++ b/gen/definition.go @@ -77,6 +77,8 @@ func main() { os.Exit(1) } + config := yamlconfig.YamlConfig{} + shortEndpointPath := "" if endpointPath[len(endpointPath)-1] == '}' { parts := strings.Split(endpointPath, "/") @@ -91,10 +93,14 @@ func main() { var schema map[string]interface{} paths := spec.(map[string]interface{})["paths"].(map[string]interface{}) // use POST schema if it exists, otherwise fall back to PUT schema - if endpoint, ok := paths[shortEndpointPath].(map[string]interface{})["post"]; ok { - schema = endpoint.(map[string]interface{})["requestBody"].(map[string]interface{})["content"].(map[string]interface{})["application/json"].(map[string]interface{}) - } else { + if sep, ok := paths[shortEndpointPath]; ok { + if endpoint, ok := sep.(map[string]interface{})["post"]; ok { + schema = endpoint.(map[string]interface{})["requestBody"].(map[string]interface{})["content"].(map[string]interface{})["application/json"].(map[string]interface{}) + } + } + if schema == nil { schema = paths[endpointPath].(map[string]interface{})["put"].(map[string]interface{})["requestBody"].(map[string]interface{})["content"].(map[string]interface{})["application/json"].(map[string]interface{}) + config.PutCreate = true } example := schema["schema"].(map[string]interface{})["example"].(map[string]interface{}) exampleStr, err := json.Marshal(&example) @@ -102,8 +108,6 @@ func main() { panic(err) } - config := yamlconfig.YamlConfig{} - urlResult := parseUrl(endpointPath) if urlResult.resultPath[len(urlResult.resultPath)-1] == '/' { urlResult.resultPath = urlResult.resultPath[:len(urlResult.resultPath)-1] @@ -139,7 +143,7 @@ func main() { dataSourceNameQuery := false for _, a := range config.Attributes { - if a.ModelName == "name" && len(a.DataPath) == 0 { + if a.ModelName == "name" && len(a.DataPath) == 0 && !config.PutCreate { dataSourceNameQuery = true break } @@ -222,6 +226,8 @@ func parseUrl(url string) parseUrlResult { ret.category = "Organizations" } else if strings.Contains(parts[0], "/networks") { ret.category = "Networks" + } else if strings.Contains(parts[0], "/devices") { + ret.category = "Devices" } if len(parts) > 0 { if strings.Contains(parts[1], "/switch") { diff --git a/gen/definitions/device.yaml b/gen/definitions/device.yaml new file mode 100644 index 0000000..a29f81b --- /dev/null +++ b/gen/definitions/device.yaml @@ -0,0 +1,66 @@ +name: Device +rest_endpoint: /devices/%v +put_create: true +no_delete: true +doc_category: Devices +attributes: + - tf_name: serial + type: String + reference: true + description: Switch serial + example: 1234-ABCD-1234 + test_value: tolist(meraki_network_device_claim.test.serials)[0] + - model_name: address + type: String + description: The address of a device + example: 1600 Pennsylvania Ave + - model_name: floorPlanId + type: String + exclude_test: true + description: The floor plan to associate to this device. null disassociates the device from the floorplan. + example: g_2176982374 + - model_name: lat + type: Float64 + description: The latitude of a device + example: "37.4180951010362" + - model_name: lng + type: Float64 + description: The longitude of a device + example: "-122.098531723022" + - model_name: moveMapMarker + type: Bool + exclude_test: true + description: Whether or not to set the latitude and longitude of a device based on the new address. Only applies when lat and lng are not specified. + example: "true" + - model_name: name + type: String + description: The name of a device + example: My AP + minimum_test_value: '"My AP1"' + - model_name: notes + type: String + description: The notes for the device. String. Limited to 255 characters. + example: My AP's note + - model_name: switchProfileId + type: String + exclude_test: true + description: The ID of a switch template to bind to the device (for available switch templates, see the `Switch Templates` endpoint). Use null to unbind the switch device from the current profile. For a device to be bindable to a switch template, it must (1) be a switch, and (2) belong to a network that is bound to a configuration template. + example: "1234" + - model_name: tags + type: List + element_type: String + description: The list of tags of a device + example: recently-added +test_prerequisites: | + data "meraki_organization" "test" { + name = "Dev" + } + resource "meraki_network" "test" { + organization_id = data.meraki_organization.test.id + name = "Network1" + product_types = ["switch", "wireless"] + } + resource "meraki_network_device_claim" "test" { + network_id = meraki_network.test.id + serials = ["Q5KD-PCG4-HB8R"] + } diff --git a/internal/provider/data_source_meraki_device.go b/internal/provider/data_source_meraki_device.go new file mode 100644 index 0000000..604d60b --- /dev/null +++ b/internal/provider/data_source_meraki_device.go @@ -0,0 +1,153 @@ +// Copyright © 2024 Cisco Systems, Inc. and its affiliates. +// All rights reserved. +// +// Licensed under the Mozilla Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://mozilla.org/MPL/2.0/ +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: MPL-2.0 + +package provider + +// Section below is generated&owned by "gen/generator.go". //template:begin imports +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/netascode/go-meraki" + "github.com/tidwall/gjson" +) + +// 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 = &DeviceDataSource{} + _ datasource.DataSourceWithConfigure = &DeviceDataSource{} +) + +func NewDeviceDataSource() datasource.DataSource { + return &DeviceDataSource{} +} + +type DeviceDataSource struct { + client *meraki.Client +} + +func (d *DeviceDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_device" +} + +func (d *DeviceDataSource) 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 `Device` configuration.", + + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + MarkdownDescription: "The id of the object", + Computed: true, + }, + "serial": schema.StringAttribute{ + MarkdownDescription: "Switch serial", + Required: true, + }, + "address": schema.StringAttribute{ + MarkdownDescription: "The address of a device", + Computed: true, + }, + "floor_plan_id": schema.StringAttribute{ + MarkdownDescription: "The floor plan to associate to this device. null disassociates the device from the floorplan.", + Computed: true, + }, + "lat": schema.Float64Attribute{ + MarkdownDescription: "The latitude of a device", + Computed: true, + }, + "lng": schema.Float64Attribute{ + MarkdownDescription: "The longitude of a device", + Computed: true, + }, + "move_map_marker": schema.BoolAttribute{ + MarkdownDescription: "Whether or not to set the latitude and longitude of a device based on the new address. Only applies when lat and lng are not specified.", + Computed: true, + }, + "name": schema.StringAttribute{ + MarkdownDescription: "The name of a device", + Computed: true, + }, + "notes": schema.StringAttribute{ + MarkdownDescription: "The notes for the device. String. Limited to 255 characters.", + Computed: true, + }, + "switch_profile_id": schema.StringAttribute{ + MarkdownDescription: "The ID of a switch template to bind to the device (for available switch templates, see the `Switch Templates` endpoint). Use null to unbind the switch device from the current profile. For a device to be bindable to a switch template, it must (1) be a switch, and (2) belong to a network that is bound to a configuration template.", + Computed: true, + }, + "tags": schema.ListAttribute{ + MarkdownDescription: "The list of tags of a device", + ElementType: types.StringType, + Computed: true, + }, + }, + } +} + +func (d *DeviceDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, _ *datasource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + d.client = req.ProviderData.(*MerakiProviderData).Client +} + +// End of section. //template:end model + +// Section below is generated&owned by "gen/generator.go". //template:begin read + +func (d *DeviceDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var config Device + + // Read config + diags := req.Config.Get(ctx, &config) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Debug(ctx, fmt.Sprintf("%s: Beginning Read", config.Id.String())) + + var res gjson.Result + var err error + + if !res.Exists() { + res, err = d.client.Get(config.getPath()) + 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_meraki_device_test.go b/internal/provider/data_source_meraki_device_test.go new file mode 100644 index 0000000..8037b0d --- /dev/null +++ b/internal/provider/data_source_meraki_device_test.go @@ -0,0 +1,94 @@ +// Copyright © 2024 Cisco Systems, Inc. and its affiliates. +// All rights reserved. +// +// Licensed under the Mozilla Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://mozilla.org/MPL/2.0/ +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: MPL-2.0 + +package provider + +// Section below is generated&owned by "gen/generator.go". //template:begin imports +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +// End of section. //template:end imports + +// Section below is generated&owned by "gen/generator.go". //template:begin testAccDataSource + +func TestAccDataSourceMerakiDevice(t *testing.T) { + var checks []resource.TestCheckFunc + checks = append(checks, resource.TestCheckResourceAttr("data.meraki_device.test", "address", "1600 Pennsylvania Ave")) + checks = append(checks, resource.TestCheckResourceAttr("data.meraki_device.test", "lat", "37.4180951010362")) + checks = append(checks, resource.TestCheckResourceAttr("data.meraki_device.test", "lng", "-122.098531723022")) + checks = append(checks, resource.TestCheckResourceAttr("data.meraki_device.test", "name", "My AP")) + checks = append(checks, resource.TestCheckResourceAttr("data.meraki_device.test", "notes", "My AP's note")) + checks = append(checks, resource.TestCheckResourceAttr("data.meraki_device.test", "tags.0", "recently-added")) + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccDataSourceMerakiDevicePrerequisitesConfig + testAccDataSourceMerakiDeviceConfig(), + Check: resource.ComposeTestCheckFunc(checks...), + }, + }, + }) +} + +// End of section. //template:end testAccDataSource + +// Section below is generated&owned by "gen/generator.go". //template:begin testPrerequisites + +const testAccDataSourceMerakiDevicePrerequisitesConfig = ` +data "meraki_organization" "test" { + name = "Dev" +} +resource "meraki_network" "test" { + organization_id = data.meraki_organization.test.id + name = "Network1" + product_types = ["switch", "wireless"] +} +resource "meraki_network_device_claim" "test" { + network_id = meraki_network.test.id + serials = ["Q5KD-PCG4-HB8R"] +} + +` + +// End of section. //template:end testPrerequisites + +// Section below is generated&owned by "gen/generator.go". //template:begin testAccDataSourceConfig + +func testAccDataSourceMerakiDeviceConfig() string { + config := `resource "meraki_device" "test" {` + "\n" + config += ` serial = tolist(meraki_network_device_claim.test.serials)[0]` + "\n" + config += ` address = "1600 Pennsylvania Ave"` + "\n" + config += ` lat = 37.4180951010362` + "\n" + config += ` lng = -122.098531723022` + "\n" + config += ` name = "My AP"` + "\n" + config += ` notes = "My AP's note"` + "\n" + config += ` tags = ["recently-added"]` + "\n" + config += `}` + "\n" + + config += ` + data "meraki_device" "test" { + serial = tolist(meraki_network_device_claim.test.serials)[0] + } + ` + return config +} + +// End of section. //template:end testAccDataSourceConfig diff --git a/internal/provider/model_meraki_device.go b/internal/provider/model_meraki_device.go new file mode 100644 index 0000000..005ba32 --- /dev/null +++ b/internal/provider/model_meraki_device.go @@ -0,0 +1,204 @@ +// Copyright © 2024 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/CiscoDevNet/terraform-provider-meraki/internal/provider/helpers" + "github.com/hashicorp/terraform-plugin-framework/types" + "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 Device struct { + Id types.String `tfsdk:"id"` + Serial types.String `tfsdk:"serial"` + Address types.String `tfsdk:"address"` + FloorPlanId types.String `tfsdk:"floor_plan_id"` + Lat types.Float64 `tfsdk:"lat"` + Lng types.Float64 `tfsdk:"lng"` + MoveMapMarker types.Bool `tfsdk:"move_map_marker"` + Name types.String `tfsdk:"name"` + Notes types.String `tfsdk:"notes"` + SwitchProfileId types.String `tfsdk:"switch_profile_id"` + Tags types.List `tfsdk:"tags"` +} + +// End of section. //template:end types + +// Section below is generated&owned by "gen/generator.go". //template:begin getPath + +func (data Device) getPath() string { + return fmt.Sprintf("/devices/%v", url.QueryEscape(data.Serial.ValueString())) +} + +// End of section. //template:end getPath + +// Section below is generated&owned by "gen/generator.go". //template:begin toBody + +func (data Device) toBody(ctx context.Context, state Device) string { + body := "" + if !data.Address.IsNull() { + body, _ = sjson.Set(body, "address", data.Address.ValueString()) + } + if !data.FloorPlanId.IsNull() { + body, _ = sjson.Set(body, "floorPlanId", data.FloorPlanId.ValueString()) + } + if !data.Lat.IsNull() { + body, _ = sjson.Set(body, "lat", data.Lat.ValueFloat64()) + } + if !data.Lng.IsNull() { + body, _ = sjson.Set(body, "lng", data.Lng.ValueFloat64()) + } + if !data.MoveMapMarker.IsNull() { + body, _ = sjson.Set(body, "moveMapMarker", data.MoveMapMarker.ValueBool()) + } + if !data.Name.IsNull() { + body, _ = sjson.Set(body, "name", data.Name.ValueString()) + } + if !data.Notes.IsNull() { + body, _ = sjson.Set(body, "notes", data.Notes.ValueString()) + } + if !data.SwitchProfileId.IsNull() { + body, _ = sjson.Set(body, "switchProfileId", data.SwitchProfileId.ValueString()) + } + if !data.Tags.IsNull() { + var values []string + data.Tags.ElementsAs(ctx, &values, false) + body, _ = sjson.Set(body, "tags", values) + } + return body +} + +// End of section. //template:end toBody + +// Section below is generated&owned by "gen/generator.go". //template:begin fromBody + +func (data *Device) fromBody(ctx context.Context, res gjson.Result) { + if value := res.Get("address"); value.Exists() { + data.Address = types.StringValue(value.String()) + } else { + data.Address = types.StringNull() + } + if value := res.Get("floorPlanId"); value.Exists() { + data.FloorPlanId = types.StringValue(value.String()) + } else { + data.FloorPlanId = types.StringNull() + } + if value := res.Get("lat"); value.Exists() { + data.Lat = types.Float64Value(value.Float()) + } else { + data.Lat = types.Float64Null() + } + if value := res.Get("lng"); value.Exists() { + data.Lng = types.Float64Value(value.Float()) + } else { + data.Lng = types.Float64Null() + } + if value := res.Get("moveMapMarker"); value.Exists() { + data.MoveMapMarker = types.BoolValue(value.Bool()) + } else { + data.MoveMapMarker = types.BoolNull() + } + if value := res.Get("name"); value.Exists() { + data.Name = types.StringValue(value.String()) + } else { + data.Name = types.StringNull() + } + if value := res.Get("notes"); value.Exists() { + data.Notes = types.StringValue(value.String()) + } else { + data.Notes = types.StringNull() + } + if value := res.Get("switchProfileId"); value.Exists() { + data.SwitchProfileId = types.StringValue(value.String()) + } else { + data.SwitchProfileId = types.StringNull() + } + if value := res.Get("tags"); value.Exists() { + data.Tags = helpers.GetStringList(value.Array()) + } else { + data.Tags = types.ListNull(types.StringType) + } +} + +// 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 *Device) fromBodyPartial(ctx context.Context, res gjson.Result) { + if value := res.Get("address"); value.Exists() && !data.Address.IsNull() { + data.Address = types.StringValue(value.String()) + } else { + data.Address = types.StringNull() + } + if value := res.Get("floorPlanId"); value.Exists() && !data.FloorPlanId.IsNull() { + data.FloorPlanId = types.StringValue(value.String()) + } else { + data.FloorPlanId = types.StringNull() + } + if value := res.Get("lat"); value.Exists() && !data.Lat.IsNull() { + data.Lat = types.Float64Value(value.Float()) + } else { + data.Lat = types.Float64Null() + } + if value := res.Get("lng"); value.Exists() && !data.Lng.IsNull() { + data.Lng = types.Float64Value(value.Float()) + } else { + data.Lng = types.Float64Null() + } + if value := res.Get("moveMapMarker"); value.Exists() && !data.MoveMapMarker.IsNull() { + data.MoveMapMarker = types.BoolValue(value.Bool()) + } else { + data.MoveMapMarker = types.BoolNull() + } + if value := res.Get("name"); value.Exists() && !data.Name.IsNull() { + data.Name = types.StringValue(value.String()) + } else { + data.Name = types.StringNull() + } + if value := res.Get("notes"); value.Exists() && !data.Notes.IsNull() { + data.Notes = types.StringValue(value.String()) + } else { + data.Notes = types.StringNull() + } + if value := res.Get("switchProfileId"); value.Exists() && !data.SwitchProfileId.IsNull() { + data.SwitchProfileId = types.StringValue(value.String()) + } else { + data.SwitchProfileId = types.StringNull() + } + if value := res.Get("tags"); value.Exists() && !data.Tags.IsNull() { + data.Tags = helpers.GetStringList(value.Array()) + } else { + data.Tags = types.ListNull(types.StringType) + } +} + +// End of section. //template:end fromBodyPartial diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 2fbd8fa..9f1b01b 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -217,6 +217,7 @@ func (p *MerakiProvider) Configure(ctx context.Context, req provider.ConfigureRe func (p *MerakiProvider) Resources(ctx context.Context) []func() resource.Resource { return []func() resource.Resource{ + NewDeviceResource, NewNetworkResource, NewNetworkClientPolicyResource, NewNetworkDeviceClaimResource, @@ -266,6 +267,7 @@ func (p *MerakiProvider) Resources(ctx context.Context) []func() resource.Resour func (p *MerakiProvider) DataSources(ctx context.Context) []func() datasource.DataSource { return []func() datasource.DataSource{ + NewDeviceDataSource, NewNetworkDataSource, NewNetworkClientPolicyDataSource, NewNetworkGroupPolicyDataSource, diff --git a/internal/provider/resource_meraki_device.go b/internal/provider/resource_meraki_device.go new file mode 100644 index 0000000..9a4d542 --- /dev/null +++ b/internal/provider/resource_meraki_device.go @@ -0,0 +1,275 @@ +// Copyright © 2024 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" + "strings" + + "github.com/CiscoDevNet/terraform-provider-meraki/internal/provider/helpers" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/netascode/go-meraki" +) + +// 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 = &DeviceResource{} + _ resource.ResourceWithImportState = &DeviceResource{} +) + +func NewDeviceResource() resource.Resource { + return &DeviceResource{} +} + +type DeviceResource struct { + client *meraki.Client +} + +func (r *DeviceResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_device" +} + +func (r *DeviceResource) 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 the `Device` configuration.").String, + + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + MarkdownDescription: "The id of the object", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "serial": schema.StringAttribute{ + MarkdownDescription: helpers.NewAttributeDescription("Switch serial").String, + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "address": schema.StringAttribute{ + MarkdownDescription: helpers.NewAttributeDescription("The address of a device").String, + Optional: true, + }, + "floor_plan_id": schema.StringAttribute{ + MarkdownDescription: helpers.NewAttributeDescription("The floor plan to associate to this device. null disassociates the device from the floorplan.").String, + Optional: true, + }, + "lat": schema.Float64Attribute{ + MarkdownDescription: helpers.NewAttributeDescription("The latitude of a device").String, + Optional: true, + }, + "lng": schema.Float64Attribute{ + MarkdownDescription: helpers.NewAttributeDescription("The longitude of a device").String, + Optional: true, + }, + "move_map_marker": schema.BoolAttribute{ + MarkdownDescription: helpers.NewAttributeDescription("Whether or not to set the latitude and longitude of a device based on the new address. Only applies when lat and lng are not specified.").String, + Optional: true, + }, + "name": schema.StringAttribute{ + MarkdownDescription: helpers.NewAttributeDescription("The name of a device").String, + Optional: true, + }, + "notes": schema.StringAttribute{ + MarkdownDescription: helpers.NewAttributeDescription("The notes for the device. String. Limited to 255 characters.").String, + Optional: true, + }, + "switch_profile_id": schema.StringAttribute{ + MarkdownDescription: helpers.NewAttributeDescription("The ID of a switch template to bind to the device (for available switch templates, see the `Switch Templates` endpoint). Use null to unbind the switch device from the current profile. For a device to be bindable to a switch template, it must (1) be a switch, and (2) belong to a network that is bound to a configuration template.").String, + Optional: true, + }, + "tags": schema.ListAttribute{ + MarkdownDescription: helpers.NewAttributeDescription("The list of tags of a device").String, + ElementType: types.StringType, + Optional: true, + }, + }, + } +} + +func (r *DeviceResource) Configure(_ context.Context, req resource.ConfigureRequest, _ *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + r.client = req.ProviderData.(*MerakiProviderData).Client +} + +// End of section. //template:end model + +// Section below is generated&owned by "gen/generator.go". //template:begin create + +func (r *DeviceResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan Device + + // 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())) + + // Create object + body := plan.toBody(ctx, Device{}) + res, err := r.client.Put(plan.getPath(), body) + 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 *DeviceResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state Device + + // Read state + diags := req.State.Get(ctx, &state) + if resp.Diagnostics.Append(diags...); resp.Diagnostics.HasError() { + return + } + + tflog.Debug(ctx, fmt.Sprintf("%s: Beginning Read", state.Id.String())) + res, err := r.client.Get(state.getPath()) + 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 *DeviceResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state Device + + // 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 + } + + tflog.Debug(ctx, fmt.Sprintf("%s: Beginning Update", plan.Id.ValueString())) + + body := plan.toBody(ctx, state) + res, err := r.client.Put(plan.getPath(), body) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to configure object (PUT), got error: %s, %s", err, res.String())) + return + } + + 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 *DeviceResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state Device + + // Read state + diags := req.State.Get(ctx, &state) + if resp.Diagnostics.Append(diags...); resp.Diagnostics.HasError() { + return + } + + 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 +func (r *DeviceResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + idParts := strings.Split(req.ID, ",") + + if len(idParts) != 1 || idParts[0] == "" { + resp.Diagnostics.AddError( + "Unexpected Import Identifier", + fmt.Sprintf("Expected import identifier with format: . Got: %q", req.ID), + ) + return + } + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("serial"), idParts[0])...) + + helpers.SetFlagImporting(ctx, true, resp.Private, &resp.Diagnostics) +} + +// End of section. //template:end import diff --git a/internal/provider/resource_meraki_device_test.go b/internal/provider/resource_meraki_device_test.go new file mode 100644 index 0000000..973a8d6 --- /dev/null +++ b/internal/provider/resource_meraki_device_test.go @@ -0,0 +1,108 @@ +// Copyright © 2024 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 TestAccMerakiDevice(t *testing.T) { + var checks []resource.TestCheckFunc + checks = append(checks, resource.TestCheckResourceAttr("meraki_device.test", "address", "1600 Pennsylvania Ave")) + checks = append(checks, resource.TestCheckResourceAttr("meraki_device.test", "lat", "37.4180951010362")) + checks = append(checks, resource.TestCheckResourceAttr("meraki_device.test", "lng", "-122.098531723022")) + checks = append(checks, resource.TestCheckResourceAttr("meraki_device.test", "name", "My AP")) + checks = append(checks, resource.TestCheckResourceAttr("meraki_device.test", "notes", "My AP's note")) + checks = append(checks, resource.TestCheckResourceAttr("meraki_device.test", "tags.0", "recently-added")) + + var steps []resource.TestStep + if os.Getenv("SKIP_MINIMUM_TEST") == "" { + steps = append(steps, resource.TestStep{ + Config: testAccMerakiDevicePrerequisitesConfig + testAccMerakiDeviceConfig_minimum(), + }) + } + steps = append(steps, resource.TestStep{ + Config: testAccMerakiDevicePrerequisitesConfig + testAccMerakiDeviceConfig_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 + +const testAccMerakiDevicePrerequisitesConfig = ` +data "meraki_organization" "test" { + name = "Dev" +} +resource "meraki_network" "test" { + organization_id = data.meraki_organization.test.id + name = "Network1" + product_types = ["switch", "wireless"] +} +resource "meraki_network_device_claim" "test" { + network_id = meraki_network.test.id + serials = ["Q5KD-PCG4-HB8R"] +} + +` + +// End of section. //template:end testPrerequisites + +// Section below is generated&owned by "gen/generator.go". //template:begin testAccConfigMinimal + +func testAccMerakiDeviceConfig_minimum() string { + config := `resource "meraki_device" "test" {` + "\n" + config += ` serial = tolist(meraki_network_device_claim.test.serials)[0]` + "\n" + config += ` name = "My AP1"` + "\n" + config += `}` + "\n" + return config +} + +// End of section. //template:end testAccConfigMinimal + +// Section below is generated&owned by "gen/generator.go". //template:begin testAccConfigAll + +func testAccMerakiDeviceConfig_all() string { + config := `resource "meraki_device" "test" {` + "\n" + config += ` serial = tolist(meraki_network_device_claim.test.serials)[0]` + "\n" + config += ` address = "1600 Pennsylvania Ave"` + "\n" + config += ` lat = 37.4180951010362` + "\n" + config += ` lng = -122.098531723022` + "\n" + config += ` name = "My AP"` + "\n" + config += ` notes = "My AP's note"` + "\n" + config += ` tags = ["recently-added"]` + "\n" + config += `}` + "\n" + return config +} + +// End of section. //template:end testAccConfigAll