From 65626ea4e05f6873c5d4d464493c921bcf93f1ac Mon Sep 17 00:00:00 2001 From: Sabari Jaganathan <93724860+sajagana@users.noreply.github.com> Date: Thu, 9 May 2024 11:12:26 +0530 Subject: [PATCH 01/13] [minor_change] Added nd_site and nd_version resource and data-sources --- docs/data-sources/site.md | 46 ++ docs/data-sources/version.md | 39 ++ docs/index.md | 46 ++ docs/resources/site.md | 85 ++++ examples/README.md | 9 + examples/data-sources/nd_site/main.tf | 3 + examples/data-sources/nd_site/provider.tf | 14 + examples/data-sources/nd_version/main.tf | 2 + examples/data-sources/nd_version/provider.tf | 14 + examples/provider/provider.tf | 14 + examples/resources/nd_site/main.tf | 11 + examples/resources/nd_site/provider.tf | 14 + internal/.DS_Store | Bin 0 -> 6148 bytes internal/provider/auth.go | 44 ++ internal/provider/client.go | 368 ++++++++++++++ internal/provider/nd_site_data_source.go | 129 +++++ internal/provider/nd_site_data_source_test.go | 49 ++ internal/provider/nd_site_resource.go | 477 ++++++++++++++++++ internal/provider/nd_site_resource_test.go | 143 ++++++ internal/provider/nd_version_data_source.go | 212 ++++++++ .../provider/nd_version_data_source_test.go | 38 ++ internal/provider/provider.go | 233 +++++++++ internal/provider/provider_test.go | 37 ++ 23 files changed, 2027 insertions(+) create mode 100644 docs/data-sources/site.md create mode 100644 docs/data-sources/version.md create mode 100644 docs/index.md create mode 100644 docs/resources/site.md create mode 100644 examples/README.md create mode 100644 examples/data-sources/nd_site/main.tf create mode 100644 examples/data-sources/nd_site/provider.tf create mode 100644 examples/data-sources/nd_version/main.tf create mode 100644 examples/data-sources/nd_version/provider.tf create mode 100644 examples/provider/provider.tf create mode 100644 examples/resources/nd_site/main.tf create mode 100644 examples/resources/nd_site/provider.tf create mode 100644 internal/.DS_Store create mode 100644 internal/provider/auth.go create mode 100644 internal/provider/client.go create mode 100644 internal/provider/nd_site_data_source.go create mode 100644 internal/provider/nd_site_data_source_test.go create mode 100644 internal/provider/nd_site_resource.go create mode 100644 internal/provider/nd_site_resource_test.go create mode 100644 internal/provider/nd_version_data_source.go create mode 100644 internal/provider/nd_version_data_source_test.go create mode 100644 internal/provider/provider.go create mode 100644 internal/provider/provider_test.go diff --git a/docs/data-sources/site.md b/docs/data-sources/site.md new file mode 100644 index 0000000..5aeb93f --- /dev/null +++ b/docs/data-sources/site.md @@ -0,0 +1,46 @@ +--- +subcategory: "Sites" +layout: "nd" +page_title: "ND: nd_site" +sidebar_current: "docs-nd-data-source-nd_site" +description: |- + Data source for the Nexus Dashboard Sites +--- + +# nd_site # + +Data source for the Nexus Dashboard Sites + +## API Information ## + +* Site Management [API Information](https://developer.cisco.com/docs/nexus-dashboard/3-1-1/api-reference/) + +## GUI Information ## + +* Location: `Admin Console -> Manage -> Sites` +* GUI Configuration [Steps](https://www.cisco.com/c/en/us/td/docs/dcn/nd/3x/articles-311/nexus-dashboard-sites-311.html#_adding_aci_sites) + +## Example Usage ## + +```hcl +data "nd_site" "example" { + site_name = "example" +} +``` + +## Schema ## + +### Required ### + +* `site_name` (name) - (String) The name of the site. + +### Read-Only ### +* `id` (id) - (String) The ID of the site. +* `url` (host) - (String) The URL to reference the APICs. +* `site_username` (userName) - (String) The username for the APIC. +* `site_password` (password) - (String) The password for the APIC. +* `site_type` (siteType) - (String) The site type of the APICs. +* `login_domain` (loginDomain) - (String) The AAA login domain for the username of the APIC. +* `inband_epg` (inband_epg) - (String) The In-Band Endpoint Group (EPG) used to connect Nexus Dashboard to the fabric. +* `latitude` (latitude) - (String) The latitude of the location of the site. +* `longitude` (longitude) - (String) The longitude of the location of the site. diff --git a/docs/data-sources/version.md b/docs/data-sources/version.md new file mode 100644 index 0000000..4e74ffe --- /dev/null +++ b/docs/data-sources/version.md @@ -0,0 +1,39 @@ +--- +subcategory: "Version" +layout: "nd" +page_title: "ND: nd_version" +sidebar_current: "docs-nd-data-source-nd_version" +description: |- + Data source for the Nexus Dashboard Version +--- + +# nd_site # + +Data source for the Nexus Dashboard Version + +## GUI Information ## + +* Location: `Help -> Welcome Screen` + +## Example Usage ## + +```hcl +data "nd_version" "example" { +} +``` + +## Schema ## + +### Read-Only ### + +* `build_host` (build_host) - (String) The build host of the ND Platform Version. +* `build_time` (build_time) - (String) The build time of the ND Platform Version. +* `commit_id` (commit_id) - (String) The commit id of the ND Platform Version. +* `maintenance` (maintenance) - (Number) The maintenance version number of the ND Platform Version. +* `major` (major) - (Number) The major version number of the ND Platform Version. +* `minor` (minor) - (Number) The minor version number of the ND Platform Version. +* `patch` (patch) - (String) The patch version letter of the ND Platform Version. +* `product_id` (product_id) - (String) The product id of the ND Platform Version. +* `product_name` (product_name) - (String) The product name of the ND Platform Version. +* `release` (release) - (Boolean) The release status of the ND Platform Version. +* `user` (user) - (String) The build user name of the ND Platform Version. \ No newline at end of file diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..9840c43 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,46 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "nd Provider" +subcategory: "" +description: |- + +--- + +# nd Provider + + + +## Example Usage + +```terraform +terraform { + required_providers { + nd = { + source = "ciscodevnet/nd" + } + } +} + +provider "nd" { + username = "" + password = "" + url = "" + insecure = true +} +``` + + +## Schema + +### Optional + +- `cert_name` (String) Certificate name for the User in Cisco ND. This can also be set as the ND_CERT_NAME environment variable. +- `domain` (String) URL of the Cisco ND web interface. This can also be set as the ND_DOMAIN environment variable. +- `insecure` (String) Allow insecure HTTPS client. This can also be set as the ND_INSECURE environment variable. Defaults to `true`. +- `password` (String) Password for the ND Account. This can also be set as the ND_PASSWORD environment variable. +- `private_key` (String) Private key path for signature calculation. This can also be set as the ND_PRIVATE_KEY environment variable. +- `proxy_creds` (String) Proxy server credentials in the form of username:password. This can also be set as the ND_PROXY_CREDS environment variable. +- `proxy_url` (String) Proxy Server URL with port number. This can also be set as the ND_PROXY_URL environment variable. +- `retries` (String) Number of retries for REST API calls. This can also be set as the ND_RETRIES environment variable. Defaults to `2`. +- `url` (String) URL of the Cisco ND web interface. This can also be set as the ND_URL environment variable. +- `username` (String) Username for the ND Account. This can also be set as the ND_USERNAME environment variable. diff --git a/docs/resources/site.md b/docs/resources/site.md new file mode 100644 index 0000000..7cda543 --- /dev/null +++ b/docs/resources/site.md @@ -0,0 +1,85 @@ +--- +subcategory: "Sites" +layout: "nd" +page_title: "ND: nd_site" +sidebar_current: "docs-nd-resource-nd_site" +description: |- + Manages Sites for the Nexus Dashboard +--- + +# nd_site # + +Manages Sites for the Nexus Dashboard + +## API Information ## + +* Site Management [API Information](https://developer.cisco.com/docs/nexus-dashboard/3-1-1/api-reference/) + +## GUI Information ## + +* Location: `Admin Console -> Manage -> Sites` +* GUI Configuration [Steps](https://www.cisco.com/c/en/us/td/docs/dcn/nd/3x/articles-311/nexus-dashboard-sites-311.html#_adding_aci_sites) + +## Example Usage ## + +The configuration snippet below shows all possible attributes of the ND Site. + +!> This example might not be valid configuration and is only used to show all possible attributes. + +```hcl +resource "nd_site" "example" { + site_name = "example" + url = "10.195.219.154" + site_username = "admin" + site_password = "password" + site_type = "aci" + inband_epg = "example_epg" + login_domain = "local" + latitude = "19.36475238603211" + longitude = "-155.28865502961474" +} +``` + +All examples for the Site resource can be found in the [examples](https://github.com/CiscoDevNet/terraform-provider-nd/tree/master/examples/resources/nd_site) folder. + +## Schema ## + +### Required ### + +* `site_name` (name) - (String) The name of the site. +* `url` (host) - (String) The URL to reference the APICs. +* `site_username` (userName) - (String) The username for the APIC. +* `site_password` (password) - (String) The password for the APIC. +* `site_type` (siteType) - (String) The site type of the APICs. + * Valid Values: `aci`, `dcnm`, `third_party`, `cloud_aci`, `dcnm_ng`, `ndfc`. + +### Optional ### + +* `login_domain` (loginDomain) - (String) The AAA login domain for the username of the APIC. +* `inband_epg` (inband_epg) - (String) The In-Band Endpoint Group (EPG) used to connect Nexus Dashboard to the fabric. +* `latitude` (latitude) - (String) The latitude of the location of the site. +* `longitude` (longitude) - (String) The longitude of the location of the site. + +### Read-Only ### + +* `id` (id) - (String) The ID of the site. + +## Importing + +An existing Site can be [imported](https://www.terraform.io/docs/import/index.html) into this resource with its name (site_name), via the following command: + +``` +terraform import nd_site.example {site_name} +``` + +Starting in Terraform version 1.5, an existing Site can be imported +using [import blocks](https://developer.hashicorp.com/terraform/language/import) via the following configuration: + +``` +import { + site_name = "{site_name}" + to = nd_site.example +} +``` + +Note: `ND_SITE_USERNAME`, `ND_SITE_PASSWORD` and `ND_LOGIN_DOMAIN` must be set in order to import. \ No newline at end of file diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..026c42c --- /dev/null +++ b/examples/README.md @@ -0,0 +1,9 @@ +# Examples + +This directory contains examples that are mostly used for documentation, but can also be run/tested manually via the Terraform CLI. + +The document generation tool looks for files in the following locations by default. All other *.tf files besides the ones mentioned below are ignored by the documentation tool. This is useful for creating examples that can run and/or ar testable even if some parts are not relevant for the documentation. + +* **provider/provider.tf** example file for the provider index page +* **data-sources/`full data source name`/data-source.tf** example file for the named data source page +* **resources/`full resource name`/resource.tf** example file for the named data source page diff --git a/examples/data-sources/nd_site/main.tf b/examples/data-sources/nd_site/main.tf new file mode 100644 index 0000000..ea60a4e --- /dev/null +++ b/examples/data-sources/nd_site/main.tf @@ -0,0 +1,3 @@ +data "nd_site" "example" { + site_name = "example" +} diff --git a/examples/data-sources/nd_site/provider.tf b/examples/data-sources/nd_site/provider.tf new file mode 100644 index 0000000..364be4d --- /dev/null +++ b/examples/data-sources/nd_site/provider.tf @@ -0,0 +1,14 @@ +terraform { + required_providers { + nd = { + source = "ciscodevnet/nd" + } + } +} + +provider "nd" { + username = "" + password = "" + url = "" + insecure = true +} diff --git a/examples/data-sources/nd_version/main.tf b/examples/data-sources/nd_version/main.tf new file mode 100644 index 0000000..b51e7e8 --- /dev/null +++ b/examples/data-sources/nd_version/main.tf @@ -0,0 +1,2 @@ +data "nd_version" "example" { +} diff --git a/examples/data-sources/nd_version/provider.tf b/examples/data-sources/nd_version/provider.tf new file mode 100644 index 0000000..364be4d --- /dev/null +++ b/examples/data-sources/nd_version/provider.tf @@ -0,0 +1,14 @@ +terraform { + required_providers { + nd = { + source = "ciscodevnet/nd" + } + } +} + +provider "nd" { + username = "" + password = "" + url = "" + insecure = true +} diff --git a/examples/provider/provider.tf b/examples/provider/provider.tf new file mode 100644 index 0000000..364be4d --- /dev/null +++ b/examples/provider/provider.tf @@ -0,0 +1,14 @@ +terraform { + required_providers { + nd = { + source = "ciscodevnet/nd" + } + } +} + +provider "nd" { + username = "" + password = "" + url = "" + insecure = true +} diff --git a/examples/resources/nd_site/main.tf b/examples/resources/nd_site/main.tf new file mode 100644 index 0000000..ce3124f --- /dev/null +++ b/examples/resources/nd_site/main.tf @@ -0,0 +1,11 @@ +resource "nd_site" "example" { + site_name = "example" + site_username = "admin" + site_password = "password" + url = "10.195.219.154" + site_type = "aci" + inband_epg = "test_epg" + latitude = "19.36475238603211" + longitude = "-155.28865502961474" + login_domain = "local" +} diff --git a/examples/resources/nd_site/provider.tf b/examples/resources/nd_site/provider.tf new file mode 100644 index 0000000..364be4d --- /dev/null +++ b/examples/resources/nd_site/provider.tf @@ -0,0 +1,14 @@ +terraform { + required_providers { + nd = { + source = "ciscodevnet/nd" + } + } +} + +provider "nd" { + username = "" + password = "" + url = "" + insecure = true +} diff --git a/internal/.DS_Store b/internal/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..8e19978aa52b50b975ccfd7a98970b8fcbe71fef GIT binary patch literal 6148 zcmeHK%}T>S5T0$TZYV+z3VK`cTChK&2QQ)47cim+mD-S^!I&*+YY(N6yS|Vw;`2DO zyAewjJc-yDnEhtwC(C{bJ6Qk_ok`RLr~!aPB`kS3d?6GkU6PXX5DN2*7$O)z7EVXQ zrEGTmMF!~INf<%~8lK?O`wJ5tV(c{>Ch=(8XuOG1xw5vtA*HO!o8VqggK>X68Fl*E z70u4IPQt?OhZpf++N*7y=ycqV(?Mnm;%I;=*Oze`>1jt#(r9FA0~?T@=k;p4vstro zP*;cTd0ow#?W0y*H5={LeD2BZz5U~}-b3=3>SxQQzz@&Jro#eW&{*2oQ;?;JPVX>Q z)D`)R%m6dM46G0X?g*6C71}dzl^I|Le#QWu4+@pgcbHo=M+Xk{`$+K$Aqm=ammpLQ zeTTV4jGzcxifBuPyJ84ij(+9xe22M3TMojkjL&f^3wJ{iW_9!{lMceS$Rjhr3@kEG zw$mE*|I_d9|BFRDV+NRkf5m{Pbc1dOx8!>3(&nhwD%4w463WXhew3hLS~2EQE8amh Zf__B?qVF)bh#nOF5im6HzzqB<1Mk(WO;Z2> literal 0 HcmV?d00001 diff --git a/internal/provider/auth.go b/internal/provider/auth.go new file mode 100644 index 0000000..2ee3ea4 --- /dev/null +++ b/internal/provider/auth.go @@ -0,0 +1,44 @@ +package provider + +import ( + "fmt" + "log" + "net/http" + "time" +) + +type Auth struct { + Token string + Expiry time.Time +} + +func (au *Auth) IsValid() bool { + if au.Token != "" && au.Expiry.Unix() > au.estimateExpireTime() { + return true + } + return false +} + +func (t *Auth) CalculateExpiry(willExpire int64) { + t.Expiry = time.Unix((time.Now().Unix() + willExpire), 0) +} + +func (t *Auth) estimateExpireTime() int64 { + return time.Now().Unix() + 3 +} + +func (client *Client) InjectAuthenticationHeader(req *http.Request, path string) (*http.Request, error) { + log.Printf("[DEBUG] Begin Injection") + if client.AuthToken == nil || !client.AuthToken.IsValid() { + err := client.Authenticate() + if err != nil { + return nil, err + } + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", client.AuthToken.Token)) + req.Header.Set("Cookie", fmt.Sprintf("AuthCookie=%s", client.AuthToken.Token)) + + return req, nil +} diff --git a/internal/provider/client.go b/internal/provider/client.go new file mode 100644 index 0000000..f87b5fc --- /dev/null +++ b/internal/provider/client.go @@ -0,0 +1,368 @@ +package provider + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/base64" + "errors" + "fmt" + "io" + "log" + + "net/http" + "net/url" + "strings" + "sync" + + "github.com/Jeffail/gabs/v2" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +const ndAuthPayload = `{ + "userName": "%s", + "userPasswd": "%s" +}` + +// Client is the main entry point +type Client struct { + BaseURL *url.URL + httpClient *http.Client + AuthToken *Auth + Mutex sync.Mutex + username string + password string + insecure bool + proxyUrl string + proxyCreds string + domain string + version string + skipLoggingPayload bool +} + +// singleton implementation of a client +var clientImpl *Client + +type Option func(*Client) + +func Insecure(insecure bool) Option { + return func(client *Client) { + client.insecure = insecure + } +} + +func Password(password string) Option { + return func(client *Client) { + client.password = password + } +} + +func ProxyUrl(pUrl string) Option { + return func(client *Client) { + client.proxyUrl = pUrl + } +} + +func ProxyCreds(pcreds string) Option { + return func(client *Client) { + client.proxyCreds = pcreds + } +} + +func Domain(domain string) Option { + return func(client *Client) { + client.domain = domain + } +} + +func Version(version string) Option { + return func(client *Client) { + client.version = version + } +} + +func SkipLoggingPayload(skipLoggingPayload bool) Option { + return func(client *Client) { + client.skipLoggingPayload = skipLoggingPayload + } +} + +func initClient(clientUrl, username string, options ...Option) *Client { + var transport *http.Transport + bUrl, err := url.Parse(clientUrl) + if err != nil { + // cannot move forward if url is undefined + log.Fatal(err) + } + client := &Client{ + BaseURL: bUrl, + username: username, + httpClient: http.DefaultClient, + } + + for _, option := range options { + option(client) + } + + transport = client.useInsecureHTTPClient(client.insecure) + if client.proxyUrl != "" { + transport = client.configProxy(transport) + } + + client.httpClient = &http.Client{ + Transport: transport, + } + + return client +} + +// GetClient returns a singleton +func GetClient(clientUrl, username string, options ...Option) *Client { + if clientImpl == nil { + clientImpl = initClient(clientUrl, username, options...) + } + return clientImpl +} + +func (c *Client) configProxy(transport *http.Transport) *http.Transport { + log.Printf("[DEBUG]: Using Proxy Server: %s ", c.proxyUrl) + pUrl, err := url.Parse(c.proxyUrl) + if err != nil { + log.Fatal(err) + } + transport.Proxy = http.ProxyURL(pUrl) + + if c.proxyCreds != "" { + basicAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte(c.proxyCreds)) + transport.ProxyConnectHeader = http.Header{} + transport.ProxyConnectHeader.Add("Proxy-Authorization", basicAuth) + } + return transport +} + +func (c *Client) useInsecureHTTPClient(insecure bool) *http.Transport { + transport := &http.Transport{ + TLSClientConfig: &tls.Config{ + CipherSuites: []uint16{ + tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA, + tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, + tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256, + tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + }, + PreferServerCipherSuites: true, + InsecureSkipVerify: insecure, + MinVersion: tls.VersionTLS11, + MaxVersion: tls.VersionTLS13, + }, + } + + return transport +} + +func (c *Client) MakeRestRequest(method string, path string, body *gabs.Container, authenticated bool) (*http.Request, error) { + return c.makeRestRequest(method, path, body, authenticated, c.skipLoggingPayload) +} + +func (c *Client) makeRestRequest(method string, path string, body *gabs.Container, authenticated bool, skipLoggingPayload bool) (*http.Request, error) { + if path != "/login" { + if strings.HasPrefix(path, "/") { + path = path[1:] + } + path = fmt.Sprintf("/%v", path) + } + url, err := url.Parse(path) + + if err != nil { + return nil, err + } + if method == "PATCH" { + validateString := url.Query() + validateString.Set("validate", "false") + url.RawQuery = validateString.Encode() + } + fURL := c.BaseURL.ResolveReference(url) + + var req *http.Request + if method == "GET" || method == "DELETE" { + req, err = http.NewRequest(method, fURL.String(), nil) + } else { + req, err = http.NewRequest(method, fURL.String(), bytes.NewBuffer((body.Bytes()))) + } + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + log.Printf("[DEBUG] HTTP request %s %s", method, path) + + if skipLoggingPayload { + log.Printf("HTTP request %s %s", method, path) + } else { + log.Printf("HTTP request %s %s %v", method, path, req) + } + + if authenticated { + req, err = c.InjectAuthenticationHeader(req, path) + if err != nil { + return req, err + } + } + + if !skipLoggingPayload { + log.Printf("HTTP request after injection %s %s %v", method, path, req) + } + + return req, nil +} + +// Authenticate is used to +func (c *Client) Authenticate() error { + method := "POST" + path := "/api/v1/auth/login" + + authPayload := ndAuthPayload + if c.domain == "" { + c.domain = "DefaultAuth" + } + path = "/login" + body, err := gabs.ParseJSON([]byte(fmt.Sprintf(authPayload, c.username, c.password))) + if err != nil { + return err + } + + if c.domain != "" { + body.Set(c.domain, "domain") + } + + req, err := c.MakeRestRequest(method, path, body, false) + if err != nil { + return err + } + + obj, _, err := c.Do(req) + + if err != nil { + return err + } + + if obj == nil { + return errors.New("Empty response") + } + req.Header.Set("Content-Type", "application/json") + + token := StripQuotes(obj.S("token").String()) + + if token == "" || token == "{}" { + return errors.New("Invalid Username or Password") + } + + if c.AuthToken == nil { + c.AuthToken = &Auth{} + } + + c.AuthToken.Token = StripQuotes(token) + c.AuthToken.CalculateExpiry(1200) //refreshTime=1200 Sec + + return nil +} + +func StripQuotes(word string) string { + if strings.HasPrefix(word, "\"") && strings.HasSuffix(word, "\"") { + return strings.TrimSuffix(strings.TrimPrefix(word, "\""), "\"") + } + return word +} + +func (c *Client) Do(req *http.Request) (*gabs.Container, *http.Response, error) { + return c.do(req, c.skipLoggingPayload) +} + +func (c *Client) do(req *http.Request, skipLoggingPayload bool) (*gabs.Container, *http.Response, error) { + + log.Printf("[DEBUG] Begining DO method %s", req.URL.String()) + log.Printf("[TRACE] HTTP Request Method and URL: %s %s", req.Method, req.URL.String()) + if !skipLoggingPayload { + log.Printf("[TRACE] HTTP Request Body: %v", req.Body) + } + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, nil, err + } + + if !skipLoggingPayload { + log.Printf("[TRACE] HTTP Response: %d %s %v", resp.StatusCode, resp.Status, resp) + } else { + log.Printf("[TRACE] HTTP Response: %d %s", resp.StatusCode, resp.Status) + } + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, err + } + bodyStr := string(bodyBytes) + err = resp.Body.Close() + if err != nil { + return nil, nil, err + } + if !skipLoggingPayload { + log.Printf("[DEBUG] HTTP response unique string %s %s %s", req.Method, req.URL.String(), bodyStr) + } + if req.Method != "DELETE" && resp.StatusCode != 204 { + obj, err := gabs.ParseJSON(bodyBytes) + + if err != nil { + log.Printf("Error occured while json parsing %+v", err) + return nil, resp, err + } + log.Printf("[DEBUG] Exit from do method") + return obj, resp, err + } else if req.Method == "DELETE" && resp.StatusCode == 204 { + return nil, resp, nil + } else if resp.StatusCode == 204 { + return nil, nil, nil + } else { + return nil, resp, err + } +} + +func DoRestRequest(ctx context.Context, diags *diag.Diagnostics, client *Client, path, method string, payload *gabs.Container) *gabs.Container { + if !strings.HasPrefix("/", path) { + path = fmt.Sprintf("/%s", path) + } + var restRequest *http.Request + var err error + + restRequest, err = client.MakeRestRequest(method, path, payload, true) + if err != nil { + diags.AddError( + "Creation of rest request failed", + fmt.Sprintf("err: %s. Please report this issue to the provider developers.", err), + ) + return nil + } + + cont, restResponse, err := client.Do(restRequest) + + // Return nil when the object is not found and ignore 404 not found error + if restResponse.StatusCode == 404 { + return nil + } + + if restResponse != nil && cont.Data() != nil && (restResponse.StatusCode != 200 && restResponse.StatusCode != 201) { + diags.AddError( + fmt.Sprintf("The %s rest request failed inside status code check", strings.ToLower(method)), + fmt.Sprintf("Code: %d Response: %s, err: %s. Please report this issue to the provider developers.", restResponse.StatusCode, cont.Data().(map[string]interface{})["errors"], err), + ) + tflog.Debug(ctx, fmt.Sprintf("%v", cont.Search("errors"))) + return nil + } else if err != nil { + diags.AddError( + fmt.Sprintf("The %s rest request failed else part of the != 200", strings.ToLower(method)), + fmt.Sprintf("Err: %s. Please report this issue to the provider developers.", err), + ) + return nil + } + + return cont +} diff --git a/internal/provider/nd_site_data_source.go b/internal/provider/nd_site_data_source.go new file mode 100644 index 0000000..1f6492a --- /dev/null +++ b/internal/provider/nd_site_data_source.go @@ -0,0 +1,129 @@ +package provider + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +// Ensure provider defined types fully satisfy framework interfaces. +var _ datasource.DataSource = &SiteDataSource{} + +func NewSiteDataSource() datasource.DataSource { + return &SiteDataSource{} +} + +// SiteDataSource defines the data source implementation. +type SiteDataSource struct { + client *Client +} + +func (d *SiteDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + tflog.Debug(ctx, "Start metadata of datasource: nd_site") + resp.TypeName = req.ProviderTypeName + "_site" + tflog.Debug(ctx, "End metadata of datasource: nd_site") +} + +func (d *SiteDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { + tflog.Debug(ctx, "Start schema of datasource: nd_site") + resp.Schema = schema.Schema{ + // This description is used by the documentation generator and the language server. + MarkdownDescription: "The site datasource for the 'ND Platform Site' information", + + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The id of the site.", + }, + "site_name": schema.StringAttribute{ + Required: true, + MarkdownDescription: "The name of the site.", + }, + "url": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The URL to reference the APICs.", + }, + "site_type": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The site type of the APICs.", + }, + "site_username": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The username for the APIC.", + }, + "site_password": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The password for the APIC.", + }, + "login_domain": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The AAA login domain for the username of the APIC.", + }, + "inband_epg": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The In-Band Endpoint Group (EPG) used to connect Nexus Dashboard to the fabric.", + }, + "latitude": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The latitude of the location of the site.", + }, + "longitude": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The longitude of the location of the site.", + }, + }, + } + tflog.Debug(ctx, "End schema of datasource: nd_site") +} + +func (d *SiteDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + tflog.Debug(ctx, "Start configure of datasource: nd_site") + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*Client) + + if !ok { + resp.Diagnostics.AddError( + "Unexpected Data Source Configure Type", + fmt.Sprintf("Expected *Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + + return + } + + d.client = client + tflog.Debug(ctx, "End configure of datasource: nd_site") +} + +func (d *SiteDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + tflog.Debug(ctx, "Start read of datasource: nd_site") + var data *SiteResourceModel + + // Read Terraform configuration data into the model + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + setSiteId(ctx, data) + + tflog.Debug(ctx, fmt.Sprintf("Read of datasource nd_site with id '%s'", data.Id.ValueString())) + + getAndSetSiteAttributes(ctx, &resp.Diagnostics, d.client, data) + + if data.Id.IsNull() { + resp.Diagnostics.AddError("Failed to read nd_site data source", "") + return + } + + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) + tflog.Debug(ctx, fmt.Sprintf("End read of datasource nd_site with id '%s'", data.Id.ValueString())) +} diff --git a/internal/provider/nd_site_data_source_test.go b/internal/provider/nd_site_data_source_test.go new file mode 100644 index 0000000..5e7b952 --- /dev/null +++ b/internal/provider/nd_site_data_source_test.go @@ -0,0 +1,49 @@ +package provider + +import ( + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func TestAccDataSourceNdSite(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testConfigNdSite, + ExpectNonEmptyPlan: false, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("nd_site.example_0", "inband_epg", ""), + resource.TestCheckResourceAttr("nd_site.example_0", "latitude", ""), + resource.TestCheckResourceAttr("nd_site.example_0", "login_domain", ""), + resource.TestCheckResourceAttr("nd_site.example_0", "longitude", ""), + resource.TestCheckResourceAttr("nd_site.example_0", "site_name", "example_0"), + resource.TestCheckResourceAttr("nd_site.example_0", "site_password", "password"), + resource.TestCheckResourceAttr("nd_site.example_0", "site_type", "aci"), + resource.TestCheckResourceAttr("nd_site.example_0", "site_username", "admin"), + resource.TestCheckResourceAttr("nd_site.example_0", "url", "10.195.219.154"), + ), + }, + { + Config: testConfigNdSiteNonExisting, + ExpectError: regexp.MustCompile("Failed to read nd_site data source"), + }, + }, + }) +} + +const testConfigNdSite = testConfigNdSiteMinDependencyForDataSource + ` +data "nd_site" "example_0" { + site_name = "example_0" + depends_on = [nd_site.example_0] +} +` + +const testConfigNdSiteNonExisting = ` +data "nd_site" "test" { + site_name = "ansible_test_non_existing" +} +` diff --git a/internal/provider/nd_site_resource.go b/internal/provider/nd_site_resource.go new file mode 100644 index 0000000..680cea9 --- /dev/null +++ b/internal/provider/nd_site_resource.go @@ -0,0 +1,477 @@ +package provider + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "github.com/Jeffail/gabs/v2" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +// Ensure provider defined types fully satisfy framework interfaces. +var _ resource.Resource = &SiteResource{} +var _ resource.ResourceWithImportState = &SiteResource{} + +var sitePath = "nexus/api/sitemanagement/v4/sites" +var siteTypeMap = map[string]string{ + "ACI": "aci", + "DCNM": "dcnm", + "ThirdParty": "third_party", + "CloudACI": "cloud_aci", + "DCNMNG": "dcnm_ng", + "NDFC": "ndfc", + "aci": "ACI", + "dcnm": "DCNM", + "third_party": "ThirdParty", + "cloud_aci": "CloudACI", + "dcnm_ng": "DCNMNG", + "ndfc": "NDFC", +} + +func NewSiteResource() resource.Resource { + return &SiteResource{} +} + +// SiteResource defines the resource implementation. +type SiteResource struct { + client *Client +} + +// SiteResourceModel describes the resource data model. +type SiteResourceModel struct { + Id types.String `tfsdk:"id"` + SiteName types.String `tfsdk:"site_name"` + SitePassword types.String `tfsdk:"site_password"` + SiteUsername types.String `tfsdk:"site_username"` + LoginDomain types.String `tfsdk:"login_domain"` + InbandEpg types.String `tfsdk:"inband_epg"` + Url types.String `tfsdk:"url"` + SiteType types.String `tfsdk:"site_type"` + Latitude types.String `tfsdk:"latitude"` + Longitude types.String `tfsdk:"longitude"` +} + +func (r *SiteResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + tflog.Debug(ctx, "Start metadata of resource: nd_site") + resp.TypeName = req.ProviderTypeName + "_site" + tflog.Debug(ctx, "End metadata of resource: nd_site") +} + +func (r *SiteResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + tflog.Debug(ctx, "Start schema of resource: nd_site") + resp.Schema = schema.Schema{ + // This description is used by the documentation generator and the language server. + MarkdownDescription: "The site resource for the Nexus Dashboard", + + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The id of the site.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + stringplanmodifier.UseStateForUnknown(), + }, + }, + "site_name": schema.StringAttribute{ + Required: true, + MarkdownDescription: "The name of the site.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + stringplanmodifier.UseStateForUnknown(), + }, + }, + "url": schema.StringAttribute{ + Required: true, + MarkdownDescription: "The URL to reference the APICs.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + stringplanmodifier.UseStateForUnknown(), + }, + }, + "site_type": schema.StringAttribute{ + Required: true, + MarkdownDescription: "The site type of the APICs.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + stringvalidator.OneOf("aci", "dcnm", "third_party", "cloud_aci", "dcnm_ng", "ndfc"), + }, + }, + "site_username": schema.StringAttribute{ + Required: true, + MarkdownDescription: "The username for the APIC.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + stringplanmodifier.UseStateForUnknown(), + }, + }, + "site_password": schema.StringAttribute{ + Required: true, + MarkdownDescription: "The password for the APIC.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + stringplanmodifier.UseStateForUnknown(), + }, + }, + "login_domain": schema.StringAttribute{ + Optional: true, + Computed: true, + MarkdownDescription: "The AAA login domain for the username of the APIC.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + stringplanmodifier.UseStateForUnknown(), + }, + }, + "inband_epg": schema.StringAttribute{ + Optional: true, + Computed: true, + MarkdownDescription: "The In-Band Endpoint Group (EPG) used to connect Nexus Dashboard to the fabric.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + stringplanmodifier.UseStateForUnknown(), + }, + }, + "latitude": schema.StringAttribute{ + Optional: true, + Computed: true, + MarkdownDescription: "The latitude of the location of the site.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "longitude": schema.StringAttribute{ + Optional: true, + Computed: true, + MarkdownDescription: "The longitude of the location of the site.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + }, + } + tflog.Debug(ctx, "End schema of resource: nd_site") +} + +func (r *SiteResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + tflog.Debug(ctx, "Start configure of resource: nd_site") + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*Client) + + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + + return + } + + r.client = client + tflog.Debug(ctx, "End configure of resource: nd_site") +} + +func (r *SiteResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + tflog.Debug(ctx, "Start create of resource: nd_site") + + var stateData *SiteResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &stateData)...) + + var data *SiteResourceModel + + // Read Terraform plan data into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + jsonPayload := getSiteCreateJsonPayload(ctx, &resp.Diagnostics, data) + + if resp.Diagnostics.HasError() { + return + } + + DoRestRequest(ctx, &resp.Diagnostics, r.client, sitePath, "POST", jsonPayload) + + setSiteId(ctx, data) + + if resp.Diagnostics.HasError() { + return + } + + getAndSetSiteAttributes(ctx, &resp.Diagnostics, r.client, data) + + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) + tflog.Debug(ctx, fmt.Sprintf("End create of resource nd_site with id '%s'", data.Id.ValueString())) +} + +func (r *SiteResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + tflog.Debug(ctx, "Start read of resource: nd_site") + var data *SiteResourceModel + + // Read Terraform prior state data into the model + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + tflog.Debug(ctx, fmt.Sprintf("Read of resource nd_site with id '%s'", data.Id.ValueString())) + + getAndSetSiteAttributes(ctx, &resp.Diagnostics, r.client, data) + + // Save updated data into Terraform state + if data.Id.IsNull() { + var emptyData *SiteResourceModel + resp.Diagnostics.Append(resp.State.Set(ctx, &emptyData)...) + } else { + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) + } + + tflog.Debug(ctx, fmt.Sprintf("End read of resource nd_site with id '%s'", data.Id.ValueString())) +} + +func (r *SiteResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + tflog.Debug(ctx, "Start update of resource: nd_site") + + var stateData *SiteResourceModel + var data *SiteResourceModel + + // Read Terraform plan data into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + resp.Diagnostics.Append(req.State.Get(ctx, &stateData)...) + + if resp.Diagnostics.HasError() { + return + } + + tflog.Debug(ctx, fmt.Sprintf("Update of resource nd_site with id '%s'", data.Id.ValueString())) + + jsonPayload := getSiteCreateJsonPayload(ctx, &resp.Diagnostics, data) + + if resp.Diagnostics.HasError() { + return + } + + DoRestRequest(ctx, &resp.Diagnostics, r.client, fmt.Sprintf("%s/%s", sitePath, data.Id.ValueString()), "PUT", jsonPayload) + + if resp.Diagnostics.HasError() { + return + } + + getAndSetSiteAttributes(ctx, &resp.Diagnostics, r.client, data) + + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) + tflog.Debug(ctx, "End update of resource nd_site") +} + +func (r *SiteResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + tflog.Debug(ctx, "Start delete of resource: nd_site") + var data *SiteResourceModel + + // Read Terraform prior state data into the model + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + tflog.Debug(ctx, fmt.Sprintf("Delete of resource nd_site with id '%s'", data.Id.ValueString())) + if resp.Diagnostics.HasError() { + return + } + DoRestRequest(ctx, &resp.Diagnostics, r.client, fmt.Sprintf("%s/%s", sitePath, data.Id.ValueString()), "DELETE", nil) + if resp.Diagnostics.HasError() { + return + } + tflog.Debug(ctx, fmt.Sprintf("End delete of resource nd_site with id '%s'", data.Id.ValueString())) +} + +func (r *SiteResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + tflog.Debug(ctx, "Start import state of resource: nd_site") + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) + + var stateData *SiteResourceModel + resp.Diagnostics.Append(resp.State.Get(ctx, &stateData)...) + + username := os.Getenv("ND_SITE_USERNAME") + if username == "" { + resp.Diagnostics.AddError("Missing input", "A username must be provided during import, please set the ND_SITE_USERNAME environment variable") + } + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("site_username"), username)...) + + password := os.Getenv("ND_SITE_PASSWORD") + if password == "" { + resp.Diagnostics.AddError("Missing input", "A password must be provided during import, please set the ND_SITE_PASSWORD environment variable") + } + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("site_password"), password)...) + + loginDomain := os.Getenv("ND_LOGIN_DOMAIN") + if loginDomain == "" { + resp.Diagnostics.AddError("Missing input", "A login_domain must be provided during import, please set the ND_LOGIN_DOMAIN environment variable") + } + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("login_domain"), loginDomain)...) + + tflog.Debug(ctx, fmt.Sprintf("Import state of resource nd_site with id '%s'", stateData.Id.ValueString())) + tflog.Debug(ctx, "End import of state resource: nd_site") +} + +func getSiteCreateJsonPayload(ctx context.Context, diags *diag.Diagnostics, data *SiteResourceModel) *gabs.Container { + payloadMap := map[string]interface{}{} + siteType := "" + + if !data.SitePassword.IsNull() && !data.SitePassword.IsUnknown() { + payloadMap["password"] = data.SitePassword.ValueString() + } + + if !data.SiteUsername.IsNull() && !data.SiteUsername.IsUnknown() { + payloadMap["userName"] = data.SiteUsername.ValueString() + } + + if !data.LoginDomain.IsNull() && !data.LoginDomain.IsUnknown() { + payloadMap["loginDomain"] = data.LoginDomain.ValueString() + } + + if !data.InbandEpg.IsNull() && !data.InbandEpg.IsUnknown() { + payloadMap["inband_epg"] = data.InbandEpg.ValueString() + } + + if !data.SiteName.IsNull() && !data.SiteName.IsUnknown() { + payloadMap["name"] = data.SiteName.ValueString() + } + + if !data.Url.IsNull() && !data.Url.IsUnknown() { + payloadMap["host"] = data.Url.ValueString() + } + + if !data.SiteType.IsNull() && !data.SiteType.IsUnknown() { + payloadMap["siteType"] = data.SiteType.ValueString() + siteType = data.SiteType.ValueString() + } + + if !data.Latitude.IsNull() && !data.Latitude.IsUnknown() { + payloadMap["latitude"] = data.Latitude.ValueString() + } + + if !data.Longitude.IsNull() && !data.Longitude.IsUnknown() { + payloadMap["longitude"] = data.Longitude.ValueString() + } + + siteConfiguration := map[string]interface{}{} + if siteType == "aci" || siteType == "cloud_aci" { + siteTypeParam := siteType + if siteType == "cloud_aci" { + siteTypeParam = siteTypeMap[siteType] + } + + inbandEpg := "" + if payloadMap["inband_epg"] != nil { + inbandEpg = payloadMap["inband_epg"].(string) + } + siteConfiguration[siteTypeParam] = map[string]interface{}{ + "InbandEPGDN": inbandEpg, + } + } else if siteType == "ndfc" || siteType == "dcnm" { + siteConfiguration[siteType] = map[string]string{ + "fabricName": payloadMap["name"].(string), + "fabricTechnology": "External", + "fabricType": "External", + } + } + + payloadMap["siteConfig"] = siteConfiguration + payloadMap["siteType"] = siteTypeMap[siteType] + + payload, err := json.Marshal(map[string]interface{}{"spec": payloadMap}) + if err != nil { + diags.AddError( + "Marshalling of json payload failed", + fmt.Sprintf("Err: %s. Please report this issue to the provider developers.", err), + ) + return nil + } + + jsonPayload, err := gabs.ParseJSON(payload) + + if err != nil { + diags.AddError( + "Construction of json payload failed", + fmt.Sprintf("Err: %s. Please report this issue to the provider developers.", err), + ) + return nil + } + return jsonPayload +} + +func setSiteId(ctx context.Context, data *SiteResourceModel) { + data.Id = types.StringValue(data.SiteName.ValueString()) +} + +func getAndSetSiteAttributes(ctx context.Context, diags *diag.Diagnostics, client *Client, data *SiteResourceModel) { + + responseData := DoRestRequest(ctx, diags, client, fmt.Sprintf("%s/%s", sitePath, data.Id.ValueString()), "GET", nil) + + if diags.HasError() { + return + } + + if responseData.Data() != nil { + responseReadInfo := responseData.Data().(map[string]interface{}) + specReadInfo := responseReadInfo["spec"].(map[string]interface{}) + for attributeName, attributeValue := range specReadInfo { + if attributeName == "name" { + data.Id = basetypes.NewStringValue(attributeValue.(string)) + data.SiteName = basetypes.NewStringValue(attributeValue.(string)) + } + + if attributeName == "siteConfig" { + data.InbandEpg = basetypes.NewStringValue(attributeValue.(map[string]interface{})[siteTypeMap[specReadInfo["siteType"].(string)]].(map[string]interface{})["InbandEPGDN"].(string)) + } + + if attributeName == "host" { + data.Url = basetypes.NewStringValue(attributeValue.(string)) + } + + if attributeName == "siteType" { + data.SiteType = basetypes.NewStringValue(siteTypeMap[attributeValue.(string)]) + } + + if attributeName == "latitude" { + data.Latitude = basetypes.NewStringValue(attributeValue.(string)) + } + + if attributeName == "longitude" { + data.Longitude = basetypes.NewStringValue(attributeValue.(string)) + } + + if os.Getenv("ND_LOGIN_DOMAIN") != "" { + data.LoginDomain = basetypes.NewStringValue(os.Getenv("ND_LOGIN_DOMAIN")) + } else if attributeName == "loginDomain" && data.LoginDomain.IsUnknown() { + data.LoginDomain = basetypes.NewStringValue(attributeValue.(string)) + } + } + } else { + data.Id = basetypes.NewStringNull() + data.SiteName = basetypes.NewStringNull() + } +} diff --git a/internal/provider/nd_site_resource_test.go b/internal/provider/nd_site_resource_test.go new file mode 100644 index 0000000..a3cc5fa --- /dev/null +++ b/internal/provider/nd_site_resource_test.go @@ -0,0 +1,143 @@ +package provider + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +// ND Site min configuration without import test +func TestAccResourceNdSiteTest(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Create with minimum config and verify default ND values + { + Config: testConfigNdSiteMinDependency, + ExpectNonEmptyPlan: false, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("nd_site.example_1", "inband_epg", ""), + resource.TestCheckResourceAttr("nd_site.example_1", "latitude", ""), + resource.TestCheckResourceAttr("nd_site.example_1", "login_domain", ""), + resource.TestCheckResourceAttr("nd_site.example_1", "longitude", ""), + resource.TestCheckResourceAttr("nd_site.example_1", "site_name", "example_1"), + resource.TestCheckResourceAttr("nd_site.example_1", "site_password", "password"), + resource.TestCheckResourceAttr("nd_site.example_1", "site_type", "aci"), + resource.TestCheckResourceAttr("nd_site.example_1", "site_username", "admin"), + resource.TestCheckResourceAttr("nd_site.example_1", "url", "10.195.219.154"), + ), + }, + }, + }) +} + +// ND Site full configuration with import test +func TestAccResourceNdSiteWithImportTest(t *testing.T) { + t.Setenv("ND_SITE_USERNAME", "admin") + t.Setenv("ND_SITE_PASSWORD", "password") + t.Setenv("ND_LOGIN_DOMAIN", "local") + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Create with minimum config and verify default ND values + { + Config: testConfigNdSiteMinDependencyCreate, + ExpectNonEmptyPlan: false, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("nd_site.example_2", "inband_epg", "test_epg"), + resource.TestCheckResourceAttr("nd_site.example_2", "latitude", ""), + resource.TestCheckResourceAttr("nd_site.example_2", "login_domain", "local"), + resource.TestCheckResourceAttr("nd_site.example_2", "longitude", ""), + resource.TestCheckResourceAttr("nd_site.example_2", "site_name", "example_2"), + resource.TestCheckResourceAttr("nd_site.example_2", "site_password", "password"), + resource.TestCheckResourceAttr("nd_site.example_2", "site_type", "aci"), + resource.TestCheckResourceAttr("nd_site.example_2", "site_username", "admin"), + resource.TestCheckResourceAttr("nd_site.example_2", "url", "10.195.219.155"), + ), + }, + // Import and verify values + { + ResourceName: "nd_site.example_2", + ImportState: true, + ImportStateVerify: true, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("nd_site.example_2", "inband_epg", "test_epg"), + resource.TestCheckResourceAttr("nd_site.example_2", "latitude", ""), + resource.TestCheckResourceAttr("nd_site.example_2", "login_domain", "local"), + resource.TestCheckResourceAttr("nd_site.example_2", "longitude", ""), + resource.TestCheckResourceAttr("nd_site.example_2", "site_name", "example_2"), + resource.TestCheckResourceAttr("nd_site.example_2", "site_password", "password"), + resource.TestCheckResourceAttr("nd_site.example_2", "site_type", "aci"), + resource.TestCheckResourceAttr("nd_site.example_2", "site_username", "admin"), + resource.TestCheckResourceAttr("nd_site.example_2", "url", "10.195.219.155"), + ), + }, + // Update with full config and verify default ND values + { + Config: testConfigNdSiteAllDependencyUpdate, + ExpectNonEmptyPlan: false, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("nd_site.example_2", "inband_epg", "test_epg"), + resource.TestCheckResourceAttr("nd_site.example_2", "latitude", "19.36475238603211"), + resource.TestCheckResourceAttr("nd_site.example_2", "login_domain", "local"), + resource.TestCheckResourceAttr("nd_site.example_2", "longitude", "-155.28865502961474"), + resource.TestCheckResourceAttr("nd_site.example_2", "site_name", "example_2"), + resource.TestCheckResourceAttr("nd_site.example_2", "site_password", "password"), + resource.TestCheckResourceAttr("nd_site.example_2", "site_type", "aci"), + resource.TestCheckResourceAttr("nd_site.example_2", "site_username", "admin"), + resource.TestCheckResourceAttr("nd_site.example_2", "url", "10.195.219.155"), + ), + }, + }, + }) +} + +const testConfigNdSiteMinDependencyForDataSource = ` +resource "nd_site" "example_0" { + site_name = "example_0" + site_username = "admin" + site_password = "password" + url = "10.195.219.154" + site_type = "aci" +} +` + +const testConfigNdSiteMinDependency = ` +resource "nd_site" "example_1" { + site_name = "example_1" + site_username = "admin" + site_password = "password" + url = "10.195.219.154" + site_type = "aci" +} +` + +const testConfigNdSiteMinDependencyCreate = ` +resource "nd_site" "example_2" { + site_name = "example_2" + site_username = "admin" + site_password = "password" + url = "10.195.219.155" + site_type = "aci" + inband_epg = "test_epg" + latitude = "" + longitude = "" + login_domain = "local" +} +` + +const testConfigNdSiteAllDependencyUpdate = ` +resource "nd_site" "example_2" { + site_name = "example_2" + site_username = "admin" + site_password = "password" + url = "10.195.219.155" + site_type = "aci" + inband_epg = "test_epg" + latitude = "19.36475238603211" + longitude = "-155.28865502961474" + login_domain = "local" +} +` diff --git a/internal/provider/nd_version_data_source.go b/internal/provider/nd_version_data_source.go new file mode 100644 index 0000000..5ce6efe --- /dev/null +++ b/internal/provider/nd_version_data_source.go @@ -0,0 +1,212 @@ +package provider + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +// Ensure provider defined types fully satisfy framework interfaces. +var _ datasource.DataSource = &VersionDataSource{} + +func NewVersionDataSource() datasource.DataSource { + return &VersionDataSource{} +} + +// VersionDataSource defines the data source implementation. +type VersionDataSource struct { + client *Client +} + +func (d *VersionDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + tflog.Debug(ctx, "Start metadata of datasource: nd_version") + resp.TypeName = req.ProviderTypeName + "_version" + tflog.Debug(ctx, "End metadata of datasource: nd_version") +} + +type VersionResourceModel struct { + Id types.String `tfsdk:"commit_id"` + BuildTime types.String `tfsdk:"build_time"` + BuildHost types.String `tfsdk:"build_host"` + User types.String `tfsdk:"user"` + ProductId types.String `tfsdk:"product_id"` + ProductName types.String `tfsdk:"product_name"` + Release types.Bool `tfsdk:"release"` + Major types.Float64 `tfsdk:"major"` + Minor types.Float64 `tfsdk:"minor"` + Maintenance types.Float64 `tfsdk:"maintenance"` + Patch types.String `tfsdk:"patch"` +} + +func (d *VersionDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { + tflog.Debug(ctx, "Start schema of datasource: nd_version") + resp.Schema = schema.Schema{ + // This description is used by the documentation generator and the language server. + MarkdownDescription: "The version datasource for the 'ND Platform Version' information", + + Attributes: map[string]schema.Attribute{ + "commit_id": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The commit id of the ND Platform Version.", + }, + "build_time": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The build time of the ND Platform Version.", + }, + "build_host": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The build host of the ND Platform Version.", + }, + "user": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The build user name of the ND Platform Version.", + }, + "product_id": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The product id of the ND Platform Version.", + }, + "product_name": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The product name of the ND Platform Version.", + }, + "release": schema.BoolAttribute{ + Computed: true, + MarkdownDescription: "The release status of the ND Platform Version.", + }, + "major": schema.Float64Attribute{ + Computed: true, + MarkdownDescription: "The major version number of the ND Platform Version.", + }, + "minor": schema.Float64Attribute{ + Computed: true, + MarkdownDescription: "The minor version number of the ND Platform Version.", + }, + "maintenance": schema.Float64Attribute{ + Computed: true, + MarkdownDescription: "The maintenance version number of the ND Platform Version.", + }, + "patch": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The patch version letter of the ND Platform Version.", + }, + }, + } + tflog.Debug(ctx, "End schema of datasource: nd_version") +} + +func (d *VersionDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + tflog.Debug(ctx, "Start configure of datasource: nd_version") + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*Client) + + if !ok { + resp.Diagnostics.AddError( + "Unexpected Data Source Configure Type", + fmt.Sprintf("Expected *Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + + return + } + + d.client = client + tflog.Debug(ctx, "End configure of datasource: nd_version") +} + +func (d *VersionDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + tflog.Debug(ctx, "Start read of datasource: nd_version") + var data *VersionResourceModel + + // Read Terraform configuration data into the model + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + setVersionId(ctx, data) + + tflog.Debug(ctx, fmt.Sprintf("Read of datasource nd_version with id '%s'", data.Id.ValueString())) + + getAndSetVersionAttributes(ctx, &resp.Diagnostics, d.client, data) + + if data.Id.IsNull() { + resp.Diagnostics.AddError("Failed to read nd_version data source", "") + return + } + + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) + tflog.Debug(ctx, fmt.Sprintf("End read of datasource nd_version with id '%s'", data.Id.ValueString())) +} + +func setVersionId(ctx context.Context, data *VersionResourceModel) { + data.Id = types.StringValue(data.Id.ValueString()) +} + +func getAndSetVersionAttributes(ctx context.Context, diags *diag.Diagnostics, client *Client, data *VersionResourceModel) { + requestData := DoRestRequest(ctx, diags, client, "version.json", "GET", nil) + if diags.HasError() { + return + } + if requestData.Data() != nil { + classReadInfo := requestData.Data().(map[string]interface{}) + for attributeName, attributeValue := range classReadInfo { + + if attributeName == "commit_id" { + data.Id = basetypes.NewStringValue(attributeValue.(string)) + } + + if attributeName == "build_time" { + data.BuildTime = basetypes.NewStringValue(attributeValue.(string)) + } + + if attributeName == "build_host" { + data.BuildHost = basetypes.NewStringValue(attributeValue.(string)) + } + + if attributeName == "user" { + data.User = basetypes.NewStringValue(attributeValue.(string)) + } + + if attributeName == "product_id" { + data.ProductId = basetypes.NewStringValue(attributeValue.(string)) + } + + if attributeName == "product_name" { + data.ProductName = basetypes.NewStringValue(attributeValue.(string)) + } + + if attributeName == "release" { + data.Release = basetypes.NewBoolValue(attributeValue.(bool)) + } + + if attributeName == "major" { + data.Major = basetypes.NewFloat64Value(attributeValue.(float64)) + } + + if attributeName == "minor" { + data.Minor = basetypes.NewFloat64Value(attributeValue.(float64)) + } + + if attributeName == "maintenance" { + data.Maintenance = basetypes.NewFloat64Value(attributeValue.(float64)) + } + + if attributeName == "patch" { + data.Patch = basetypes.NewStringValue(attributeValue.(string)) + } + } + } else { + data.Id = basetypes.NewStringNull() + } +} diff --git a/internal/provider/nd_version_data_source_test.go b/internal/provider/nd_version_data_source_test.go new file mode 100644 index 0000000..8332b26 --- /dev/null +++ b/internal/provider/nd_version_data_source_test.go @@ -0,0 +1,38 @@ +package provider + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func TestAccDataSourceNdVersion(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testConfigNdVersion, + ExpectNonEmptyPlan: false, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("data.nd_version.test", "user"), + resource.TestCheckResourceAttr("data.nd_version.test", "product_name", "Nexus Dashboard"), + resource.TestCheckResourceAttr("data.nd_version.test", "product_id", "nd"), + resource.TestCheckResourceAttrSet("data.nd_version.test", "build_host"), + resource.TestCheckResourceAttrSet("data.nd_version.test", "build_time"), + resource.TestCheckResourceAttrSet("data.nd_version.test", "commit_id"), + resource.TestCheckResourceAttrSet("data.nd_version.test", "maintenance"), + resource.TestCheckResourceAttrSet("data.nd_version.test", "major"), + resource.TestCheckResourceAttrSet("data.nd_version.test", "minor"), + resource.TestCheckResourceAttrSet("data.nd_version.test", "patch"), + resource.TestCheckResourceAttrSet("data.nd_version.test", "release"), + ), + }, + }, + }) +} + +const testConfigNdVersion = ` +data "nd_version" "test" { +} +` diff --git a/internal/provider/provider.go b/internal/provider/provider.go new file mode 100644 index 0000000..1326609 --- /dev/null +++ b/internal/provider/provider.go @@ -0,0 +1,233 @@ +package provider + +import ( + "context" + "fmt" + "os" + "strconv" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/provider/schema" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ provider.Provider = &ndProvider{} +) + +// New is a helper function to simplify provider server and testing implementation. +func New(version string) func() provider.Provider { + return func() provider.Provider { + return &ndProvider{ + version: version, + } + } +} + +// ndProvider is the provider implementation. +type ndProvider struct { + // version is set to the provider version on release, "dev" when the + // provider is built and ran locally, and "test" when running acceptance + // testing. + version string +} + +// ndProviderModel describes the provider data model. +type ndProviderModel struct { + Username types.String `tfsdk:"username"` + Password types.String `tfsdk:"password"` + IsInsecure types.Bool `tfsdk:"insecure"` + ProxyUrl types.String `tfsdk:"proxy_url"` + URL types.String `tfsdk:"url"` + Domain types.String `tfsdk:"domain"` + PrivateKey types.String `tfsdk:"private_key"` + Certname types.String `tfsdk:"cert_name"` + ProxyCreds types.String `tfsdk:"proxy_creds"` + MaxRetries types.Int64 `tfsdk:"retries"` +} + +// Metadata returns the provider type name. +func (p *ndProvider) Metadata(_ context.Context, _ provider.MetadataRequest, resp *provider.MetadataResponse) { + resp.TypeName = "nd" + resp.Version = p.version +} + +// Schema defines the provider-level schema for configuration data. +func (p *ndProvider) Schema(ctx context.Context, req provider.SchemaRequest, resp *provider.SchemaResponse) { + resp.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "username": schema.StringAttribute{ + Description: "Username for the ND Account. This can also be set as the ND_USERNAME environment variable.", + Optional: true, + }, + "password": schema.StringAttribute{ + Description: "Password for the ND Account. This can also be set as the ND_PASSWORD environment variable.", + Optional: true, + }, + "insecure": schema.BoolAttribute{ + Description: "Allow insecure HTTPS client. This can also be set as the ND_INSECURE environment variable. Defaults to `true`.", + Optional: true, + }, + "proxy_url": schema.StringAttribute{ + Description: "Proxy Server URL with port number. This can also be set as the ND_PROXY_URL environment variable.", + Optional: true, + }, + "url": schema.StringAttribute{ + Description: "URL of the Cisco ND web interface. This can also be set as the ND_URL environment variable.", + Optional: true, + }, + "private_key": schema.StringAttribute{ + Description: "Private key path for signature calculation. This can also be set as the ND_PRIVATE_KEY environment variable.", + Optional: true, + }, + "domain": schema.StringAttribute{ + Description: "URL of the Cisco ND web interface. This can also be set as the ND_DOMAIN environment variable.", + Optional: true, + }, + "cert_name": schema.StringAttribute{ + Description: "Certificate name for the User in Cisco ND. This can also be set as the ND_CERT_NAME environment variable.", + Optional: true, + }, + "proxy_creds": schema.StringAttribute{ + Description: "Proxy server credentials in the form of username:password. This can also be set as the ND_PROXY_CREDS environment variable.", + Optional: true, + }, + "retries": schema.Int64Attribute{ + Description: "Number of retries for REST API calls. This can also be set as the ND_RETRIES environment variable. Defaults to `2`.", + Optional: true, + }, + }, + } +} + +// Configure prepares a ND API client for data sources and resources. +func (p *ndProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) { + + var data ndProviderModel + + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + username := getStringAttribute(data.Username, "ND_USERNAME") + password := getStringAttribute(data.Password, "ND_PASSWORD") + isInsecure := getBoolAttribute(resp, data.IsInsecure, "ND_INSECURE", true) + proxyUrl := getStringAttribute(data.ProxyUrl, "ND_PROXY_URL") + url := getStringAttribute(data.URL, "ND_URL") + domain := getStringAttribute(data.Domain, "ND_DOMAIN") + privateKey := getStringAttribute(data.PrivateKey, "ND_PRIVATE_KEY") + certName := getStringAttribute(data.Certname, "ND_CERT_NAME") + proxyCreds := getStringAttribute(data.ProxyCreds, "ND_PROXY_CREDS") + maxRetries := getIntAttribute(resp, data.MaxRetries, "ND_RETRIES", 2) + + if username == "" { + resp.Diagnostics.AddError( + "Username not provided", + "Username must be provided for the ND provider", + ) + } + + if password == "" && (privateKey == "" || certName == "") { + resp.Diagnostics.AddError( + "Authentication details not provided", + "Either 'password' OR 'private_key' and 'cert_name' must be provided for the ND provider", + ) + } + + if url == "" { + resp.Diagnostics.AddError( + "Url not provided", + "Url must be provided for the ND provider", + ) + } else if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { + resp.Diagnostics.AddError( + "Incorrect url prefix", + fmt.Sprintf("Url '%s' must start with 'http://' or 'https://'", url), + ) + } + + if maxRetries < 0 || maxRetries > 9 { + resp.Diagnostics.AddError( + "Incorrect retry amount", + fmt.Sprintf("Retries must be between 0 and 9 inclusive, got: %d", maxRetries), + ) + } + + var ndClient *Client + if password != "" { + ndClient = GetClient(url, username, Password(password), Insecure(isInsecure), ProxyUrl(proxyUrl), ProxyCreds(proxyCreds), Domain(domain)) + } else { + ndClient = nil + } + + resp.DataSourceData = ndClient + resp.ResourceData = ndClient +} + +func (p *ndProvider) DataSources(ctx context.Context) []func() datasource.DataSource { + return []func() datasource.DataSource{ + NewVersionDataSource, + NewSiteDataSource, + } +} + +func (p *ndProvider) Resources(ctx context.Context) []func() resource.Resource { + return []func() resource.Resource{ + NewSiteResource, + } +} + +func getStringAttribute(attribute basetypes.StringValue, envKey string) string { + + if attribute.IsNull() { + return os.Getenv(envKey) + } + return attribute.ValueString() + +} + +func getBoolAttribute(resp *provider.ConfigureResponse, attribute basetypes.BoolValue, envKey string, defaultValue bool) bool { + + if attribute.IsNull() { + envValue := os.Getenv(envKey) + if envValue == "" { + return defaultValue + } + boolValue, err := strconv.ParseBool(envValue) + if err != nil { + resp.Diagnostics.AddError( + fmt.Sprintf("Invalid input '%s'", envValue), + fmt.Sprintf("A boolean value must be provided for %s", envKey), + ) + } + return boolValue + } + return attribute.ValueBool() + +} + +func getIntAttribute(resp *provider.ConfigureResponse, attribute basetypes.Int64Value, envKey string, defaultValue int) int { + + if attribute.IsNull() { + envValue := os.Getenv(envKey) + if envValue == "" { + return defaultValue + } + intValue, err := strconv.Atoi(envValue) + if err != nil { + resp.Diagnostics.AddError( + fmt.Sprintf("Invalid input '%s'", envValue), + fmt.Sprintf("A integer value must be provided for %s", envKey), + ) + } + return intValue + } + return int(attribute.ValueInt64()) +} diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go new file mode 100644 index 0000000..2937463 --- /dev/null +++ b/internal/provider/provider_test.go @@ -0,0 +1,37 @@ +package provider + +import ( + "os" + "strconv" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/providerserver" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" +) + +// testAccProtoV6ProviderFactories are used to instantiate a provider during +// acceptance testing. The factory function will be invoked for every Terraform +// CLI command executed to create a provider server to which the CLI can +// reattach. +var testAccProtoV6ProviderFactories = map[string]func() (tfprotov6.ProviderServer, error){ + "nd": providerserver.NewProtocol6WithError(New("test")()), +} + +func testAccPreCheck(t *testing.T) { + if v := os.Getenv("ND_USERNAME"); v == "" { + t.Fatal("ND_USERNAME must be set for acceptance tests") + } + if v := os.Getenv("ND_PASSWORD"); v == "" { + t.Fatal("ND_PASSWORD must be set for acceptance tests") + } + if v := os.Getenv("ND_URL"); v == "" { + t.Fatal("ND_URL must be set for acceptance tests") + } + if v := os.Getenv("ND_VAL_REL_DN"); v == "" { + t.Fatal("ND_VAL_REL_DN must be set for acceptance tests") + boolValue, err := strconv.ParseBool(v) + if err != nil || boolValue == true { + t.Fatal("ND_VAL_REL_DN must be a 'false' boolean value") + } + } +} From 5134c7ddbab0872ec86ed276e4aa49cd766734f5 Mon Sep 17 00:00:00 2001 From: Sabari Jaganathan <93724860+sajagana@users.noreply.github.com> Date: Fri, 19 Jul 2024 22:45:01 +0530 Subject: [PATCH 02/13] [ignore] Renamed the resource files and attribute names in the nd_site resource Removed unwanted code from client.go file --- docs/data-sources/site.md | 26 +++--- docs/data-sources/version.md | 9 +- docs/index.md | 89 +++++++++++++----- docs/resources/site.md | 60 ++++++------ examples/data-sources/nd_site/main.tf | 2 +- examples/resources/nd_site/main.tf | 18 ++-- internal/provider/client.go | 61 +++++-------- ..._data_source.go => data_source_nd_site.go} | 28 +++--- ...ce_test.go => data_source_nd_site_test.go} | 12 +-- ...ta_source.go => data_source_nd_version.go} | 8 +- ...test.go => data_source_nd_version_test.go} | 0 internal/provider/provider.go | 64 +++++-------- ...d_site_resource.go => resource_nd_site.go} | 91 +++++++++---------- ...ource_test.go => resource_nd_site_test.go} | 64 ++++++------- 14 files changed, 269 insertions(+), 263 deletions(-) rename internal/provider/{nd_site_data_source.go => data_source_nd_site.go} (81%) rename internal/provider/{nd_site_data_source_test.go => data_source_nd_site_test.go} (75%) rename internal/provider/{nd_version_data_source.go => data_source_nd_version.go} (96%) rename internal/provider/{nd_version_data_source_test.go => data_source_nd_version_test.go} (100%) rename internal/provider/{nd_site_resource.go => resource_nd_site.go} (88%) rename internal/provider/{nd_site_resource_test.go => resource_nd_site_test.go} (69%) diff --git a/docs/data-sources/site.md b/docs/data-sources/site.md index 5aeb93f..ac34e02 100644 --- a/docs/data-sources/site.md +++ b/docs/data-sources/site.md @@ -4,27 +4,27 @@ layout: "nd" page_title: "ND: nd_site" sidebar_current: "docs-nd-data-source-nd_site" description: |- - Data source for the Nexus Dashboard Sites + Data source for Nexus Dashboard Sites --- # nd_site # -Data source for the Nexus Dashboard Sites +Data source for Nexus Dashboard Sites ## API Information ## * Site Management [API Information](https://developer.cisco.com/docs/nexus-dashboard/3-1-1/api-reference/) +* API Endpoint: `nexus/api/sitemanagement/v4/sites` ## GUI Information ## * Location: `Admin Console -> Manage -> Sites` -* GUI Configuration [Steps](https://www.cisco.com/c/en/us/td/docs/dcn/nd/3x/articles-311/nexus-dashboard-sites-311.html#_adding_aci_sites) ## Example Usage ## ```hcl data "nd_site" "example" { - site_name = "example" + name = "example" } ``` @@ -32,15 +32,15 @@ data "nd_site" "example" { ### Required ### -* `site_name` (name) - (String) The name of the site. +* `name` (name) - (String) The name of the site. ### Read-Only ### * `id` (id) - (String) The ID of the site. -* `url` (host) - (String) The URL to reference the APICs. -* `site_username` (userName) - (String) The username for the APIC. -* `site_password` (password) - (String) The password for the APIC. -* `site_type` (siteType) - (String) The site type of the APICs. -* `login_domain` (loginDomain) - (String) The AAA login domain for the username of the APIC. -* `inband_epg` (inband_epg) - (String) The In-Band Endpoint Group (EPG) used to connect Nexus Dashboard to the fabric. -* `latitude` (latitude) - (String) The latitude of the location of the site. -* `longitude` (longitude) - (String) The longitude of the location of the site. +* `url` (host) - (String) The URL of the site. +* `username` (userName) - (String) The username of the site. +* `password` (password) - (String) The password of the site. +* `type` (siteType) - (String) The type of the site. +* `login_domain` (loginDomain) - (String) The login domain of the site. +* `inband_epg` (inband_epg) - (String) The In-Band Endpoint Group (EPG) used to connect ND to the site. +* `latitude` (latitude) - (String) The latitude location of the site. +* `longitude` (longitude) - (String) The longitude location of the site. diff --git a/docs/data-sources/version.md b/docs/data-sources/version.md index 4e74ffe..7ec6660 100644 --- a/docs/data-sources/version.md +++ b/docs/data-sources/version.md @@ -4,12 +4,17 @@ layout: "nd" page_title: "ND: nd_version" sidebar_current: "docs-nd-data-source-nd_version" description: |- - Data source for the Nexus Dashboard Version + Data source for Nexus Dashboard Version --- # nd_site # -Data source for the Nexus Dashboard Version +Data source for Nexus Dashboard Version + +## API Information ## + +* Site Management [API Information](https://developer.cisco.com/docs/nexus-dashboard/3-1-1/api-reference/) +* API Endpoint: `nexus/api/sitemanagement/v4/sites` ## GUI Information ## diff --git a/docs/index.md b/docs/index.md index 9840c43..d9fa4d7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,18 +1,40 @@ --- -# generated by https://github.com/hashicorp/terraform-plugin-docs -page_title: "nd Provider" -subcategory: "" +layout: "nd" +page_title: "Provider: ND" +sidebar_current: "docs-nd-index" description: |- - + The Cisco ND provider is used to interact with the resources provided by Cisco Nexus Dashboard. + The provider needs to be configured with the proper credentials before it can be used. --- -# nd Provider +# Nexus Dashboard (ND) +Cisco Nexus Dashboard is a central management console for multiple data center sites and a common platform for hosting Cisco data center operation services, such as Nexus Dashboard Insights and Nexus Dashboard Orchestrator. These services are available for all the data center sites and provide real time analytics, visibility, assurance for network policies and operations, as well as policy orchestration for the data center fabrics, such as Cisco ACI or Cisco NDFC. +# Cisco ND Provider + +The Cisco ND terraform provider is used to interact with resources provided by Cisco Nexus Dashboard. The provider needs to be configured with proper credentials to authenticate with Cisco Nexus Dashboard. + +## Authentication + +Authentication with user-id and password. + +Example: + +```hcl +provider "nd" { + username = "admin" + password = "password" + url = "https://my-cisco-nd.com" + login_domain = "DefaultAuth" +} +``` + +In this method, it will obtain an authentication token from Cisco Nexus Dashboard and will use that token to authenticate. A limitation with this approach is Nexus Dashboard counts the request to authenticate and threshold it to avoid DOS attack. After too many attempts this authentication method may fail as the threshold will be exceeded. To avoid the above-mentioned problem Cisco Nexus Dashboard supports signature-based authentication. ## Example Usage -```terraform +```hcl terraform { required_providers { nd = { @@ -22,25 +44,48 @@ terraform { } provider "nd" { - username = "" - password = "" - url = "" + username = "admin" + password = "password" + url = "https://my-cisco-nd.com" insecure = true } + +resource "nd_site" "example" { + name = "example" + username = "admin" + password = "password" + url = "10.195.219.154" + type = "aci" + inband_epg = "test_epg" + latitude = "19.36475238603211" + longitude = "-155.28865502961474" + login_domain = "local" +} ``` - ## Schema -### Optional - -- `cert_name` (String) Certificate name for the User in Cisco ND. This can also be set as the ND_CERT_NAME environment variable. -- `domain` (String) URL of the Cisco ND web interface. This can also be set as the ND_DOMAIN environment variable. -- `insecure` (String) Allow insecure HTTPS client. This can also be set as the ND_INSECURE environment variable. Defaults to `true`. -- `password` (String) Password for the ND Account. This can also be set as the ND_PASSWORD environment variable. -- `private_key` (String) Private key path for signature calculation. This can also be set as the ND_PRIVATE_KEY environment variable. -- `proxy_creds` (String) Proxy server credentials in the form of username:password. This can also be set as the ND_PROXY_CREDS environment variable. -- `proxy_url` (String) Proxy Server URL with port number. This can also be set as the ND_PROXY_URL environment variable. -- `retries` (String) Number of retries for REST API calls. This can also be set as the ND_RETRIES environment variable. Defaults to `2`. -- `url` (String) URL of the Cisco ND web interface. This can also be set as the ND_URL environment variable. -- `username` (String) Username for the ND Account. This can also be set as the ND_USERNAME environment variable. +## Required + +- `username` (String) Username for the Nexus Dashboard Account. + - Environment variable: `ND_USERNAME` +- `password` (String) Password for the Nexus Dashboard Account. + - Environment variable: `ND_PASSWORD` +- `url` (String) URL of the Cisco Nexus Dashboard web interface. + - Environment variable: `ND_URL` + +## Optional + +- `login_domain` (String) Login domain for the Nexus Dashboard Account. + - Default: `DefaultAuth` + - Environment variable: `ND_LOGIN_DOMAIN` +- `insecure` (Boolean) Allow insecure HTTPS client. + - Default: `true` + - Environment variable: `ND_INSECURE` +- `proxy_creds` (String) Proxy server credentials in the form of username:password. + - Environment variable: `ND_PROXY_CREDS` +- `proxy_url` (String) Proxy Server URL with port number. + - Environment variable: `ND_PROXY_URL` +- `retries` (Number) Number of retries for REST API calls. + - Default: `2` + - Environment variable: `ND_RETRIES` diff --git a/docs/resources/site.md b/docs/resources/site.md index 7cda543..434196f 100644 --- a/docs/resources/site.md +++ b/docs/resources/site.md @@ -4,21 +4,22 @@ layout: "nd" page_title: "ND: nd_site" sidebar_current: "docs-nd-resource-nd_site" description: |- - Manages Sites for the Nexus Dashboard + Manages Sites for Nexus Dashboard --- # nd_site # -Manages Sites for the Nexus Dashboard +Manages Sites for Nexus Dashboard ## API Information ## * Site Management [API Information](https://developer.cisco.com/docs/nexus-dashboard/3-1-1/api-reference/) +* API Endpoint: `nexus/api/sitemanagement/v4/sites` ## GUI Information ## * Location: `Admin Console -> Manage -> Sites` -* GUI Configuration [Steps](https://www.cisco.com/c/en/us/td/docs/dcn/nd/3x/articles-311/nexus-dashboard-sites-311.html#_adding_aci_sites) +* [Guide](https://www.cisco.com/c/en/us/td/docs/dcn/nd/3x/articles-311/nexus-dashboard-sites-311.html#_adding_aci_sites) ## Example Usage ## @@ -28,15 +29,15 @@ The configuration snippet below shows all possible attributes of the ND Site. ```hcl resource "nd_site" "example" { - site_name = "example" - url = "10.195.219.154" - site_username = "admin" - site_password = "password" - site_type = "aci" - inband_epg = "example_epg" - login_domain = "local" - latitude = "19.36475238603211" - longitude = "-155.28865502961474" + name = "example" + url = "10.195.219.154" + username = "admin" + password = "password" + type = "aci" + inband_epg = "epg" + login_domain = "local" + latitude = "19.36475238603211" + longitude = "-155.28865502961474" } ``` @@ -46,19 +47,19 @@ All examples for the Site resource can be found in the [examples](https://github ### Required ### -* `site_name` (name) - (String) The name of the site. -* `url` (host) - (String) The URL to reference the APICs. -* `site_username` (userName) - (String) The username for the APIC. -* `site_password` (password) - (String) The password for the APIC. -* `site_type` (siteType) - (String) The site type of the APICs. +* `name` (name) - (String) The name of the site. +* `url` (host) - (String) The URL of the site. +* `username` (userName) - (String) The username of the site. +* `password` (password) - (String) The password of the site. +* `type` (siteType) - (String) The type of the site. * Valid Values: `aci`, `dcnm`, `third_party`, `cloud_aci`, `dcnm_ng`, `ndfc`. ### Optional ### -* `login_domain` (loginDomain) - (String) The AAA login domain for the username of the APIC. -* `inband_epg` (inband_epg) - (String) The In-Band Endpoint Group (EPG) used to connect Nexus Dashboard to the fabric. -* `latitude` (latitude) - (String) The latitude of the location of the site. -* `longitude` (longitude) - (String) The longitude of the location of the site. +* `login_domain` (loginDomain) - (String) The login domain of the site. +* `inband_epg` (inband_epg) - (String) The In-Band Endpoint Group (EPG) used to connect ND to the site. +* `latitude` (latitude) - (String) The latitude location of the site. +* `longitude` (longitude) - (String) The longitude location of the site. ### Read-Only ### @@ -66,20 +67,17 @@ All examples for the Site resource can be found in the [examples](https://github ## Importing -An existing Site can be [imported](https://www.terraform.io/docs/import/index.html) into this resource with its name (site_name), via the following command: +~> `ND_SITE_USERNAME`, `ND_SITE_PASSWORD` and `ND_LOGIN_DOMAIN` must be set in order to import. -``` -terraform import nd_site.example {site_name} -``` +An existing Site can be [imported](https://www.terraform.io/docs/import/index.html) into this resource with its name (name), via the following command: -Starting in Terraform version 1.5, an existing Site can be imported -using [import blocks](https://developer.hashicorp.com/terraform/language/import) via the following configuration: +terraform import nd_site.example {name} + +Starting in Terraform version 1.5, an existing Site can be imported using [import blocks](https://developer.hashicorp.com/terraform/language/import) via the following configuration: ``` import { - site_name = "{site_name}" - to = nd_site.example + name = "{name}" + to = nd_site.example } ``` - -Note: `ND_SITE_USERNAME`, `ND_SITE_PASSWORD` and `ND_LOGIN_DOMAIN` must be set in order to import. \ No newline at end of file diff --git a/examples/data-sources/nd_site/main.tf b/examples/data-sources/nd_site/main.tf index ea60a4e..e63fedc 100644 --- a/examples/data-sources/nd_site/main.tf +++ b/examples/data-sources/nd_site/main.tf @@ -1,3 +1,3 @@ data "nd_site" "example" { - site_name = "example" + name = "example" } diff --git a/examples/resources/nd_site/main.tf b/examples/resources/nd_site/main.tf index ce3124f..a5ccbe6 100644 --- a/examples/resources/nd_site/main.tf +++ b/examples/resources/nd_site/main.tf @@ -1,11 +1,11 @@ resource "nd_site" "example" { - site_name = "example" - site_username = "admin" - site_password = "password" - url = "10.195.219.154" - site_type = "aci" - inband_epg = "test_epg" - latitude = "19.36475238603211" - longitude = "-155.28865502961474" - login_domain = "local" + name = "example" + username = "admin" + password = "password" + url = "10.195.219.154" + type = "aci" + inband_epg = "test_epg" + latitude = "19.36475238603211" + longitude = "-155.28865502961474" + login_domain = "local" } diff --git a/internal/provider/client.go b/internal/provider/client.go index f87b5fc..ab2c47a 100644 --- a/internal/provider/client.go +++ b/internal/provider/client.go @@ -120,7 +120,7 @@ func initClient(clientUrl, username string, options ...Option) *Client { // GetClient returns a singleton func GetClient(clientUrl, username string, options ...Option) *Client { if clientImpl == nil { - clientImpl = initClient(clientUrl, username, options...) + return initClient(clientUrl, username, options...) } return clientImpl } @@ -161,11 +161,7 @@ func (c *Client) useInsecureHTTPClient(insecure bool) *http.Transport { return transport } -func (c *Client) MakeRestRequest(method string, path string, body *gabs.Container, authenticated bool) (*http.Request, error) { - return c.makeRestRequest(method, path, body, authenticated, c.skipLoggingPayload) -} - -func (c *Client) makeRestRequest(method string, path string, body *gabs.Container, authenticated bool, skipLoggingPayload bool) (*http.Request, error) { +func (c *Client) MakeRestRequest(method string, path string, body *gabs.Container, authenticated bool, skipLoggingPayload bool) (*http.Request, error) { if path != "/login" { if strings.HasPrefix(path, "/") { path = path[1:] @@ -218,15 +214,7 @@ func (c *Client) makeRestRequest(method string, path string, body *gabs.Containe // Authenticate is used to func (c *Client) Authenticate() error { - method := "POST" - path := "/api/v1/auth/login" - - authPayload := ndAuthPayload - if c.domain == "" { - c.domain = "DefaultAuth" - } - path = "/login" - body, err := gabs.ParseJSON([]byte(fmt.Sprintf(authPayload, c.username, c.password))) + body, err := gabs.ParseJSON([]byte(fmt.Sprintf(ndAuthPayload, c.username, c.password))) if err != nil { return err } @@ -235,12 +223,13 @@ func (c *Client) Authenticate() error { body.Set(c.domain, "domain") } - req, err := c.MakeRestRequest(method, path, body, false) + req, err := c.MakeRestRequest("POST", "/login", body, false, c.skipLoggingPayload) if err != nil { return err } + req.Header.Set("Content-Type", "application/json") - obj, _, err := c.Do(req) + obj, _, err := c.Do(req, c.skipLoggingPayload) if err != nil { return err @@ -249,9 +238,8 @@ func (c *Client) Authenticate() error { if obj == nil { return errors.New("Empty response") } - req.Header.Set("Content-Type", "application/json") - token := StripQuotes(obj.S("token").String()) + token := obj.S("token").String() if token == "" || token == "{}" { return errors.New("Invalid Username or Password") @@ -261,30 +249,20 @@ func (c *Client) Authenticate() error { c.AuthToken = &Auth{} } - c.AuthToken.Token = StripQuotes(token) + c.AuthToken.Token = token c.AuthToken.CalculateExpiry(1200) //refreshTime=1200 Sec return nil } -func StripQuotes(word string) string { - if strings.HasPrefix(word, "\"") && strings.HasSuffix(word, "\"") { - return strings.TrimSuffix(strings.TrimPrefix(word, "\""), "\"") - } - return word -} - -func (c *Client) Do(req *http.Request) (*gabs.Container, *http.Response, error) { - return c.do(req, c.skipLoggingPayload) -} - -func (c *Client) do(req *http.Request, skipLoggingPayload bool) (*gabs.Container, *http.Response, error) { - - log.Printf("[DEBUG] Begining DO method %s", req.URL.String()) +func (c *Client) Do(req *http.Request, skipLoggingPayload bool) (*gabs.Container, *http.Response, error) { + log.Printf("[DEBUG] Beginning DO method %s", req.URL.String()) log.Printf("[TRACE] HTTP Request Method and URL: %s %s", req.Method, req.URL.String()) + if !skipLoggingPayload { log.Printf("[TRACE] HTTP Request Body: %v", req.Body) } + resp, err := c.httpClient.Do(req) if err != nil { return nil, nil, err @@ -300,19 +278,21 @@ func (c *Client) do(req *http.Request, skipLoggingPayload bool) (*gabs.Container if err != nil { return nil, nil, err } + bodyStr := string(bodyBytes) err = resp.Body.Close() if err != nil { return nil, nil, err } + if !skipLoggingPayload { log.Printf("[DEBUG] HTTP response unique string %s %s %s", req.Method, req.URL.String(), bodyStr) } + if req.Method != "DELETE" && resp.StatusCode != 204 { obj, err := gabs.ParseJSON(bodyBytes) - if err != nil { - log.Printf("Error occured while json parsing %+v", err) + log.Printf("Error occurred while json parsing %+v", err) return nil, resp, err } log.Printf("[DEBUG] Exit from do method") @@ -333,7 +313,7 @@ func DoRestRequest(ctx context.Context, diags *diag.Diagnostics, client *Client, var restRequest *http.Request var err error - restRequest, err = client.MakeRestRequest(method, path, payload, true) + restRequest, err = client.MakeRestRequest(method, path, payload, true, client.skipLoggingPayload) if err != nil { diags.AddError( "Creation of rest request failed", @@ -342,23 +322,24 @@ func DoRestRequest(ctx context.Context, diags *diag.Diagnostics, client *Client, return nil } - cont, restResponse, err := client.Do(restRequest) + cont, restResponse, err := client.Do(restRequest, client.skipLoggingPayload) // Return nil when the object is not found and ignore 404 not found error + // The resource ID will be set it to nil and the state file content will be deleted when the object is not found if restResponse.StatusCode == 404 { return nil } if restResponse != nil && cont.Data() != nil && (restResponse.StatusCode != 200 && restResponse.StatusCode != 201) { diags.AddError( - fmt.Sprintf("The %s rest request failed inside status code check", strings.ToLower(method)), + fmt.Sprintf("The %s %s rest request failed.", method, path), fmt.Sprintf("Code: %d Response: %s, err: %s. Please report this issue to the provider developers.", restResponse.StatusCode, cont.Data().(map[string]interface{})["errors"], err), ) tflog.Debug(ctx, fmt.Sprintf("%v", cont.Search("errors"))) return nil } else if err != nil { diags.AddError( - fmt.Sprintf("The %s rest request failed else part of the != 200", strings.ToLower(method)), + fmt.Sprintf("The %s %s rest request failed.", method, path), fmt.Sprintf("Err: %s. Please report this issue to the provider developers.", err), ) return nil diff --git a/internal/provider/nd_site_data_source.go b/internal/provider/data_source_nd_site.go similarity index 81% rename from internal/provider/nd_site_data_source.go rename to internal/provider/data_source_nd_site.go index 1f6492a..89ff555 100644 --- a/internal/provider/nd_site_data_source.go +++ b/internal/provider/data_source_nd_site.go @@ -31,48 +31,48 @@ func (d *SiteDataSource) Schema(ctx context.Context, req datasource.SchemaReques tflog.Debug(ctx, "Start schema of datasource: nd_site") resp.Schema = schema.Schema{ // This description is used by the documentation generator and the language server. - MarkdownDescription: "The site datasource for the 'ND Platform Site' information", + MarkdownDescription: "Data source for Nexus Dashboard Sites", Attributes: map[string]schema.Attribute{ "id": schema.StringAttribute{ Computed: true, - MarkdownDescription: "The id of the site.", + MarkdownDescription: "The ID of the site.", }, - "site_name": schema.StringAttribute{ + "name": schema.StringAttribute{ Required: true, MarkdownDescription: "The name of the site.", }, "url": schema.StringAttribute{ Computed: true, - MarkdownDescription: "The URL to reference the APICs.", + MarkdownDescription: "The URL of the site.", }, - "site_type": schema.StringAttribute{ + "type": schema.StringAttribute{ Computed: true, - MarkdownDescription: "The site type of the APICs.", + MarkdownDescription: "The type of the site.", }, - "site_username": schema.StringAttribute{ + "username": schema.StringAttribute{ Computed: true, - MarkdownDescription: "The username for the APIC.", + MarkdownDescription: "The username of the site.", }, - "site_password": schema.StringAttribute{ + "password": schema.StringAttribute{ Computed: true, - MarkdownDescription: "The password for the APIC.", + MarkdownDescription: "The password of the site.", }, "login_domain": schema.StringAttribute{ Computed: true, - MarkdownDescription: "The AAA login domain for the username of the APIC.", + MarkdownDescription: "The login domain of the site.", }, "inband_epg": schema.StringAttribute{ Computed: true, - MarkdownDescription: "The In-Band Endpoint Group (EPG) used to connect Nexus Dashboard to the fabric.", + MarkdownDescription: "The In-Band Endpoint Group (EPG) used to connect ND to the site.", }, "latitude": schema.StringAttribute{ Computed: true, - MarkdownDescription: "The latitude of the location of the site.", + MarkdownDescription: "The latitude location of the site.", }, "longitude": schema.StringAttribute{ Computed: true, - MarkdownDescription: "The longitude of the location of the site.", + MarkdownDescription: "The longitude location of the site.", }, }, } diff --git a/internal/provider/nd_site_data_source_test.go b/internal/provider/data_source_nd_site_test.go similarity index 75% rename from internal/provider/nd_site_data_source_test.go rename to internal/provider/data_source_nd_site_test.go index 5e7b952..e7621ac 100644 --- a/internal/provider/nd_site_data_source_test.go +++ b/internal/provider/data_source_nd_site_test.go @@ -20,10 +20,10 @@ func TestAccDataSourceNdSite(t *testing.T) { resource.TestCheckResourceAttr("nd_site.example_0", "latitude", ""), resource.TestCheckResourceAttr("nd_site.example_0", "login_domain", ""), resource.TestCheckResourceAttr("nd_site.example_0", "longitude", ""), - resource.TestCheckResourceAttr("nd_site.example_0", "site_name", "example_0"), - resource.TestCheckResourceAttr("nd_site.example_0", "site_password", "password"), - resource.TestCheckResourceAttr("nd_site.example_0", "site_type", "aci"), - resource.TestCheckResourceAttr("nd_site.example_0", "site_username", "admin"), + resource.TestCheckResourceAttr("nd_site.example_0", "name", "example_0"), + resource.TestCheckResourceAttr("nd_site.example_0", "password", "password"), + resource.TestCheckResourceAttr("nd_site.example_0", "type", "aci"), + resource.TestCheckResourceAttr("nd_site.example_0", "username", "admin"), resource.TestCheckResourceAttr("nd_site.example_0", "url", "10.195.219.154"), ), }, @@ -37,13 +37,13 @@ func TestAccDataSourceNdSite(t *testing.T) { const testConfigNdSite = testConfigNdSiteMinDependencyForDataSource + ` data "nd_site" "example_0" { - site_name = "example_0" + name = "example_0" depends_on = [nd_site.example_0] } ` const testConfigNdSiteNonExisting = ` data "nd_site" "test" { - site_name = "ansible_test_non_existing" + name = "non_existing" } ` diff --git a/internal/provider/nd_version_data_source.go b/internal/provider/data_source_nd_version.go similarity index 96% rename from internal/provider/nd_version_data_source.go rename to internal/provider/data_source_nd_version.go index 5ce6efe..783886a 100644 --- a/internal/provider/nd_version_data_source.go +++ b/internal/provider/data_source_nd_version.go @@ -48,7 +48,7 @@ func (d *VersionDataSource) Schema(ctx context.Context, req datasource.SchemaReq tflog.Debug(ctx, "Start schema of datasource: nd_version") resp.Schema = schema.Schema{ // This description is used by the documentation generator and the language server. - MarkdownDescription: "The version datasource for the 'ND Platform Version' information", + MarkdownDescription: "Data source for Nexus Dashboard Version", Attributes: map[string]schema.Attribute{ "commit_id": schema.StringAttribute{ @@ -133,8 +133,6 @@ func (d *VersionDataSource) Read(ctx context.Context, req datasource.ReadRequest return } - setVersionId(ctx, data) - tflog.Debug(ctx, fmt.Sprintf("Read of datasource nd_version with id '%s'", data.Id.ValueString())) getAndSetVersionAttributes(ctx, &resp.Diagnostics, d.client, data) @@ -149,10 +147,6 @@ func (d *VersionDataSource) Read(ctx context.Context, req datasource.ReadRequest tflog.Debug(ctx, fmt.Sprintf("End read of datasource nd_version with id '%s'", data.Id.ValueString())) } -func setVersionId(ctx context.Context, data *VersionResourceModel) { - data.Id = types.StringValue(data.Id.ValueString()) -} - func getAndSetVersionAttributes(ctx context.Context, diags *diag.Diagnostics, client *Client, data *VersionResourceModel) { requestData := DoRestRequest(ctx, diags, client, "version.json", "GET", nil) if diags.HasError() { diff --git a/internal/provider/nd_version_data_source_test.go b/internal/provider/data_source_nd_version_test.go similarity index 100% rename from internal/provider/nd_version_data_source_test.go rename to internal/provider/data_source_nd_version_test.go diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 1326609..2ec0f93 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -39,16 +39,14 @@ type ndProvider struct { // ndProviderModel describes the provider data model. type ndProviderModel struct { - Username types.String `tfsdk:"username"` - Password types.String `tfsdk:"password"` - IsInsecure types.Bool `tfsdk:"insecure"` - ProxyUrl types.String `tfsdk:"proxy_url"` - URL types.String `tfsdk:"url"` - Domain types.String `tfsdk:"domain"` - PrivateKey types.String `tfsdk:"private_key"` - Certname types.String `tfsdk:"cert_name"` - ProxyCreds types.String `tfsdk:"proxy_creds"` - MaxRetries types.Int64 `tfsdk:"retries"` + Username types.String `tfsdk:"username"` + Password types.String `tfsdk:"password"` + URL types.String `tfsdk:"url"` + LoginDomain types.String `tfsdk:"login_domain"` + IsInsecure types.Bool `tfsdk:"insecure"` + ProxyUrl types.String `tfsdk:"proxy_url"` + ProxyCreds types.String `tfsdk:"proxy_creds"` + MaxRetries types.Int64 `tfsdk:"retries"` } // Metadata returns the provider type name. @@ -62,35 +60,27 @@ func (p *ndProvider) Schema(ctx context.Context, req provider.SchemaRequest, res resp.Schema = schema.Schema{ Attributes: map[string]schema.Attribute{ "username": schema.StringAttribute{ - Description: "Username for the ND Account. This can also be set as the ND_USERNAME environment variable.", + Description: "Username for the Nexus Dashboard Account. This can also be set as the ND_USERNAME environment variable.", Optional: true, }, "password": schema.StringAttribute{ - Description: "Password for the ND Account. This can also be set as the ND_PASSWORD environment variable.", - Optional: true, - }, - "insecure": schema.BoolAttribute{ - Description: "Allow insecure HTTPS client. This can also be set as the ND_INSECURE environment variable. Defaults to `true`.", - Optional: true, - }, - "proxy_url": schema.StringAttribute{ - Description: "Proxy Server URL with port number. This can also be set as the ND_PROXY_URL environment variable.", + Description: "Password for the Nexus Dashboard Account. This can also be set as the ND_PASSWORD environment variable.", Optional: true, }, "url": schema.StringAttribute{ - Description: "URL of the Cisco ND web interface. This can also be set as the ND_URL environment variable.", + Description: "URL of the Cisco Nexus Dashboard web interface. This can also be set as the ND_URL environment variable.", Optional: true, }, - "private_key": schema.StringAttribute{ - Description: "Private key path for signature calculation. This can also be set as the ND_PRIVATE_KEY environment variable.", + "login_domain": schema.StringAttribute{ + Description: "Login domain for the Nexus Dashboard Account. This can also be set as the ND_LOGIN_DOMAIN environment variable. Defaults to `DefaultAuth`.", Optional: true, }, - "domain": schema.StringAttribute{ - Description: "URL of the Cisco ND web interface. This can also be set as the ND_DOMAIN environment variable.", + "insecure": schema.BoolAttribute{ + Description: "Allow insecure HTTPS client. This can also be set as the ND_INSECURE environment variable. Defaults to `true`.", Optional: true, }, - "cert_name": schema.StringAttribute{ - Description: "Certificate name for the User in Cisco ND. This can also be set as the ND_CERT_NAME environment variable.", + "proxy_url": schema.StringAttribute{ + Description: "Proxy Server URL with port number. This can also be set as the ND_PROXY_URL environment variable.", Optional: true, }, "proxy_creds": schema.StringAttribute{ @@ -107,7 +97,6 @@ func (p *ndProvider) Schema(ctx context.Context, req provider.SchemaRequest, res // Configure prepares a ND API client for data sources and resources. func (p *ndProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) { - var data ndProviderModel resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) @@ -121,9 +110,7 @@ func (p *ndProvider) Configure(ctx context.Context, req provider.ConfigureReques isInsecure := getBoolAttribute(resp, data.IsInsecure, "ND_INSECURE", true) proxyUrl := getStringAttribute(data.ProxyUrl, "ND_PROXY_URL") url := getStringAttribute(data.URL, "ND_URL") - domain := getStringAttribute(data.Domain, "ND_DOMAIN") - privateKey := getStringAttribute(data.PrivateKey, "ND_PRIVATE_KEY") - certName := getStringAttribute(data.Certname, "ND_CERT_NAME") + loginDomain := getStringAttribute(data.LoginDomain, "ND_LOGIN_DOMAIN") proxyCreds := getStringAttribute(data.ProxyCreds, "ND_PROXY_CREDS") maxRetries := getIntAttribute(resp, data.MaxRetries, "ND_RETRIES", 2) @@ -134,13 +121,17 @@ func (p *ndProvider) Configure(ctx context.Context, req provider.ConfigureReques ) } - if password == "" && (privateKey == "" || certName == "") { + if password == "" { resp.Diagnostics.AddError( "Authentication details not provided", - "Either 'password' OR 'private_key' and 'cert_name' must be provided for the ND provider", + "Password must be provided for the ND provider", ) } + if loginDomain == "" { + loginDomain = "DefaultAuth" + } + if url == "" { resp.Diagnostics.AddError( "Url not provided", @@ -162,7 +153,7 @@ func (p *ndProvider) Configure(ctx context.Context, req provider.ConfigureReques var ndClient *Client if password != "" { - ndClient = GetClient(url, username, Password(password), Insecure(isInsecure), ProxyUrl(proxyUrl), ProxyCreds(proxyCreds), Domain(domain)) + ndClient = GetClient(url, username, Password(password), Insecure(isInsecure), ProxyUrl(proxyUrl), ProxyCreds(proxyCreds), Domain(loginDomain)) } else { ndClient = nil } @@ -185,16 +176,13 @@ func (p *ndProvider) Resources(ctx context.Context) []func() resource.Resource { } func getStringAttribute(attribute basetypes.StringValue, envKey string) string { - if attribute.IsNull() { return os.Getenv(envKey) } return attribute.ValueString() - } func getBoolAttribute(resp *provider.ConfigureResponse, attribute basetypes.BoolValue, envKey string, defaultValue bool) bool { - if attribute.IsNull() { envValue := os.Getenv(envKey) if envValue == "" { @@ -210,11 +198,9 @@ func getBoolAttribute(resp *provider.ConfigureResponse, attribute basetypes.Bool return boolValue } return attribute.ValueBool() - } func getIntAttribute(resp *provider.ConfigureResponse, attribute basetypes.Int64Value, envKey string, defaultValue int) int { - if attribute.IsNull() { envValue := os.Getenv(envKey) if envValue == "" { diff --git a/internal/provider/nd_site_resource.go b/internal/provider/resource_nd_site.go similarity index 88% rename from internal/provider/nd_site_resource.go rename to internal/provider/resource_nd_site.go index 680cea9..1a6f730 100644 --- a/internal/provider/nd_site_resource.go +++ b/internal/provider/resource_nd_site.go @@ -52,13 +52,13 @@ type SiteResource struct { // SiteResourceModel describes the resource data model. type SiteResourceModel struct { Id types.String `tfsdk:"id"` - SiteName types.String `tfsdk:"site_name"` - SitePassword types.String `tfsdk:"site_password"` - SiteUsername types.String `tfsdk:"site_username"` + SiteName types.String `tfsdk:"name"` + SitePassword types.String `tfsdk:"password"` + SiteUsername types.String `tfsdk:"username"` LoginDomain types.String `tfsdk:"login_domain"` InbandEpg types.String `tfsdk:"inband_epg"` Url types.String `tfsdk:"url"` - SiteType types.String `tfsdk:"site_type"` + SiteType types.String `tfsdk:"type"` Latitude types.String `tfsdk:"latitude"` Longitude types.String `tfsdk:"longitude"` } @@ -73,18 +73,18 @@ func (r *SiteResource) Schema(ctx context.Context, req resource.SchemaRequest, r tflog.Debug(ctx, "Start schema of resource: nd_site") resp.Schema = schema.Schema{ // This description is used by the documentation generator and the language server. - MarkdownDescription: "The site resource for the Nexus Dashboard", + MarkdownDescription: "Manages Sites for Nexus Dashboard", Attributes: map[string]schema.Attribute{ "id": schema.StringAttribute{ Computed: true, - MarkdownDescription: "The id of the site.", + MarkdownDescription: "The ID of the site.", PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), stringplanmodifier.UseStateForUnknown(), }, }, - "site_name": schema.StringAttribute{ + "name": schema.StringAttribute{ Required: true, MarkdownDescription: "The name of the site.", PlanModifiers: []planmodifier.String{ @@ -94,15 +94,15 @@ func (r *SiteResource) Schema(ctx context.Context, req resource.SchemaRequest, r }, "url": schema.StringAttribute{ Required: true, - MarkdownDescription: "The URL to reference the APICs.", + MarkdownDescription: "The URL of the site.", PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), stringplanmodifier.UseStateForUnknown(), }, }, - "site_type": schema.StringAttribute{ + "type": schema.StringAttribute{ Required: true, - MarkdownDescription: "The site type of the APICs.", + MarkdownDescription: "The type of the site.", PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), stringplanmodifier.UseStateForUnknown(), @@ -111,17 +111,17 @@ func (r *SiteResource) Schema(ctx context.Context, req resource.SchemaRequest, r stringvalidator.OneOf("aci", "dcnm", "third_party", "cloud_aci", "dcnm_ng", "ndfc"), }, }, - "site_username": schema.StringAttribute{ + "username": schema.StringAttribute{ Required: true, - MarkdownDescription: "The username for the APIC.", + MarkdownDescription: "The username of the site.", PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), stringplanmodifier.UseStateForUnknown(), }, }, - "site_password": schema.StringAttribute{ + "password": schema.StringAttribute{ Required: true, - MarkdownDescription: "The password for the APIC.", + MarkdownDescription: "The password of the site.", PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), stringplanmodifier.UseStateForUnknown(), @@ -130,7 +130,7 @@ func (r *SiteResource) Schema(ctx context.Context, req resource.SchemaRequest, r "login_domain": schema.StringAttribute{ Optional: true, Computed: true, - MarkdownDescription: "The AAA login domain for the username of the APIC.", + MarkdownDescription: "The login domain of the site.", PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), stringplanmodifier.UseStateForUnknown(), @@ -139,7 +139,7 @@ func (r *SiteResource) Schema(ctx context.Context, req resource.SchemaRequest, r "inband_epg": schema.StringAttribute{ Optional: true, Computed: true, - MarkdownDescription: "The In-Band Endpoint Group (EPG) used to connect Nexus Dashboard to the fabric.", + MarkdownDescription: "The In-Band Endpoint Group (EPG) used to connect ND to the site.", PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), stringplanmodifier.UseStateForUnknown(), @@ -148,7 +148,7 @@ func (r *SiteResource) Schema(ctx context.Context, req resource.SchemaRequest, r "latitude": schema.StringAttribute{ Optional: true, Computed: true, - MarkdownDescription: "The latitude of the location of the site.", + MarkdownDescription: "The latitude location of the site.", PlanModifiers: []planmodifier.String{ stringplanmodifier.UseStateForUnknown(), }, @@ -156,7 +156,7 @@ func (r *SiteResource) Schema(ctx context.Context, req resource.SchemaRequest, r "longitude": schema.StringAttribute{ Optional: true, Computed: true, - MarkdownDescription: "The longitude of the location of the site.", + MarkdownDescription: "The longitude location of the site.", PlanModifiers: []planmodifier.String{ stringplanmodifier.UseStateForUnknown(), }, @@ -211,11 +211,10 @@ func (r *SiteResource) Create(ctx context.Context, req resource.CreateRequest, r DoRestRequest(ctx, &resp.Diagnostics, r.client, sitePath, "POST", jsonPayload) - setSiteId(ctx, data) - if resp.Diagnostics.HasError() { return } + setSiteId(ctx, data) getAndSetSiteAttributes(ctx, &resp.Diagnostics, r.client, data) @@ -277,6 +276,7 @@ func (r *SiteResource) Update(ctx context.Context, req resource.UpdateRequest, r if resp.Diagnostics.HasError() { return } + setSiteId(ctx, data) getAndSetSiteAttributes(ctx, &resp.Diagnostics, r.client, data) @@ -318,13 +318,13 @@ func (r *SiteResource) ImportState(ctx context.Context, req resource.ImportState if username == "" { resp.Diagnostics.AddError("Missing input", "A username must be provided during import, please set the ND_SITE_USERNAME environment variable") } - resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("site_username"), username)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("username"), username)...) password := os.Getenv("ND_SITE_PASSWORD") if password == "" { resp.Diagnostics.AddError("Missing input", "A password must be provided during import, please set the ND_SITE_PASSWORD environment variable") } - resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("site_password"), password)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("password"), password)...) loginDomain := os.Getenv("ND_LOGIN_DOMAIN") if loginDomain == "" { @@ -338,7 +338,6 @@ func (r *SiteResource) ImportState(ctx context.Context, req resource.ImportState func getSiteCreateJsonPayload(ctx context.Context, diags *diag.Diagnostics, data *SiteResourceModel) *gabs.Container { payloadMap := map[string]interface{}{} - siteType := "" if !data.SitePassword.IsNull() && !data.SitePassword.IsUnknown() { payloadMap["password"] = data.SitePassword.ValueString() @@ -352,8 +351,10 @@ func getSiteCreateJsonPayload(ctx context.Context, diags *diag.Diagnostics, data payloadMap["loginDomain"] = data.LoginDomain.ValueString() } + inbandEpg := "" if !data.InbandEpg.IsNull() && !data.InbandEpg.IsUnknown() { - payloadMap["inband_epg"] = data.InbandEpg.ValueString() + inbandEpg = data.InbandEpg.ValueString() + payloadMap["inband_epg"] = inbandEpg } if !data.SiteName.IsNull() && !data.SiteName.IsUnknown() { @@ -364,11 +365,6 @@ func getSiteCreateJsonPayload(ctx context.Context, diags *diag.Diagnostics, data payloadMap["host"] = data.Url.ValueString() } - if !data.SiteType.IsNull() && !data.SiteType.IsUnknown() { - payloadMap["siteType"] = data.SiteType.ValueString() - siteType = data.SiteType.ValueString() - } - if !data.Latitude.IsNull() && !data.Latitude.IsUnknown() { payloadMap["latitude"] = data.Latitude.ValueString() } @@ -378,24 +374,26 @@ func getSiteCreateJsonPayload(ctx context.Context, diags *diag.Diagnostics, data } siteConfiguration := map[string]interface{}{} - if siteType == "aci" || siteType == "cloud_aci" { - siteTypeParam := siteType - if siteType == "cloud_aci" { - siteTypeParam = siteTypeMap[siteType] - } + siteType := "" - inbandEpg := "" - if payloadMap["inband_epg"] != nil { - inbandEpg = payloadMap["inband_epg"].(string) - } - siteConfiguration[siteTypeParam] = map[string]interface{}{ - "InbandEPGDN": inbandEpg, - } - } else if siteType == "ndfc" || siteType == "dcnm" { - siteConfiguration[siteType] = map[string]string{ - "fabricName": payloadMap["name"].(string), - "fabricTechnology": "External", - "fabricType": "External", + if !data.SiteType.IsNull() && !data.SiteType.IsUnknown() { + siteType = data.SiteType.ValueString() + + if siteType == "aci" || siteType == "cloud_aci" { + siteTypeParam := siteType + if siteType == "cloud_aci" { + siteTypeParam = siteTypeMap[siteType] + } + + siteConfiguration[siteTypeParam] = map[string]interface{}{ + "InbandEPGDN": inbandEpg, + } + } else if siteType == "ndfc" || siteType == "dcnm" { + siteConfiguration[siteType] = map[string]string{ + "fabricName": payloadMap["name"].(string), + "fabricTechnology": "External", + "fabricType": "External", + } } } @@ -440,7 +438,6 @@ func getAndSetSiteAttributes(ctx context.Context, diags *diag.Diagnostics, clien specReadInfo := responseReadInfo["spec"].(map[string]interface{}) for attributeName, attributeValue := range specReadInfo { if attributeName == "name" { - data.Id = basetypes.NewStringValue(attributeValue.(string)) data.SiteName = basetypes.NewStringValue(attributeValue.(string)) } diff --git a/internal/provider/nd_site_resource_test.go b/internal/provider/resource_nd_site_test.go similarity index 69% rename from internal/provider/nd_site_resource_test.go rename to internal/provider/resource_nd_site_test.go index a3cc5fa..54b26c9 100644 --- a/internal/provider/nd_site_resource_test.go +++ b/internal/provider/resource_nd_site_test.go @@ -21,10 +21,10 @@ func TestAccResourceNdSiteTest(t *testing.T) { resource.TestCheckResourceAttr("nd_site.example_1", "latitude", ""), resource.TestCheckResourceAttr("nd_site.example_1", "login_domain", ""), resource.TestCheckResourceAttr("nd_site.example_1", "longitude", ""), - resource.TestCheckResourceAttr("nd_site.example_1", "site_name", "example_1"), - resource.TestCheckResourceAttr("nd_site.example_1", "site_password", "password"), - resource.TestCheckResourceAttr("nd_site.example_1", "site_type", "aci"), - resource.TestCheckResourceAttr("nd_site.example_1", "site_username", "admin"), + resource.TestCheckResourceAttr("nd_site.example_1", "name", "example_1"), + resource.TestCheckResourceAttr("nd_site.example_1", "password", "password"), + resource.TestCheckResourceAttr("nd_site.example_1", "type", "aci"), + resource.TestCheckResourceAttr("nd_site.example_1", "username", "admin"), resource.TestCheckResourceAttr("nd_site.example_1", "url", "10.195.219.154"), ), }, @@ -50,10 +50,10 @@ func TestAccResourceNdSiteWithImportTest(t *testing.T) { resource.TestCheckResourceAttr("nd_site.example_2", "latitude", ""), resource.TestCheckResourceAttr("nd_site.example_2", "login_domain", "local"), resource.TestCheckResourceAttr("nd_site.example_2", "longitude", ""), - resource.TestCheckResourceAttr("nd_site.example_2", "site_name", "example_2"), - resource.TestCheckResourceAttr("nd_site.example_2", "site_password", "password"), - resource.TestCheckResourceAttr("nd_site.example_2", "site_type", "aci"), - resource.TestCheckResourceAttr("nd_site.example_2", "site_username", "admin"), + resource.TestCheckResourceAttr("nd_site.example_2", "name", "example_2"), + resource.TestCheckResourceAttr("nd_site.example_2", "password", "password"), + resource.TestCheckResourceAttr("nd_site.example_2", "type", "aci"), + resource.TestCheckResourceAttr("nd_site.example_2", "username", "admin"), resource.TestCheckResourceAttr("nd_site.example_2", "url", "10.195.219.155"), ), }, @@ -67,10 +67,10 @@ func TestAccResourceNdSiteWithImportTest(t *testing.T) { resource.TestCheckResourceAttr("nd_site.example_2", "latitude", ""), resource.TestCheckResourceAttr("nd_site.example_2", "login_domain", "local"), resource.TestCheckResourceAttr("nd_site.example_2", "longitude", ""), - resource.TestCheckResourceAttr("nd_site.example_2", "site_name", "example_2"), - resource.TestCheckResourceAttr("nd_site.example_2", "site_password", "password"), - resource.TestCheckResourceAttr("nd_site.example_2", "site_type", "aci"), - resource.TestCheckResourceAttr("nd_site.example_2", "site_username", "admin"), + resource.TestCheckResourceAttr("nd_site.example_2", "name", "example_2"), + resource.TestCheckResourceAttr("nd_site.example_2", "password", "password"), + resource.TestCheckResourceAttr("nd_site.example_2", "type", "aci"), + resource.TestCheckResourceAttr("nd_site.example_2", "username", "admin"), resource.TestCheckResourceAttr("nd_site.example_2", "url", "10.195.219.155"), ), }, @@ -83,10 +83,10 @@ func TestAccResourceNdSiteWithImportTest(t *testing.T) { resource.TestCheckResourceAttr("nd_site.example_2", "latitude", "19.36475238603211"), resource.TestCheckResourceAttr("nd_site.example_2", "login_domain", "local"), resource.TestCheckResourceAttr("nd_site.example_2", "longitude", "-155.28865502961474"), - resource.TestCheckResourceAttr("nd_site.example_2", "site_name", "example_2"), - resource.TestCheckResourceAttr("nd_site.example_2", "site_password", "password"), - resource.TestCheckResourceAttr("nd_site.example_2", "site_type", "aci"), - resource.TestCheckResourceAttr("nd_site.example_2", "site_username", "admin"), + resource.TestCheckResourceAttr("nd_site.example_2", "name", "example_2"), + resource.TestCheckResourceAttr("nd_site.example_2", "password", "password"), + resource.TestCheckResourceAttr("nd_site.example_2", "type", "aci"), + resource.TestCheckResourceAttr("nd_site.example_2", "username", "admin"), resource.TestCheckResourceAttr("nd_site.example_2", "url", "10.195.219.155"), ), }, @@ -96,31 +96,31 @@ func TestAccResourceNdSiteWithImportTest(t *testing.T) { const testConfigNdSiteMinDependencyForDataSource = ` resource "nd_site" "example_0" { - site_name = "example_0" - site_username = "admin" - site_password = "password" + name = "example_0" + username = "admin" + password = "password" url = "10.195.219.154" - site_type = "aci" + type = "aci" } ` const testConfigNdSiteMinDependency = ` resource "nd_site" "example_1" { - site_name = "example_1" - site_username = "admin" - site_password = "password" + name = "example_1" + username = "admin" + password = "password" url = "10.195.219.154" - site_type = "aci" + type = "aci" } ` const testConfigNdSiteMinDependencyCreate = ` resource "nd_site" "example_2" { - site_name = "example_2" - site_username = "admin" - site_password = "password" + name = "example_2" + username = "admin" + password = "password" url = "10.195.219.155" - site_type = "aci" + type = "aci" inband_epg = "test_epg" latitude = "" longitude = "" @@ -130,11 +130,11 @@ resource "nd_site" "example_2" { const testConfigNdSiteAllDependencyUpdate = ` resource "nd_site" "example_2" { - site_name = "example_2" - site_username = "admin" - site_password = "password" + name = "example_2" + username = "admin" + password = "password" url = "10.195.219.155" - site_type = "aci" + type = "aci" inband_epg = "test_epg" latitude = "19.36475238603211" longitude = "-155.28865502961474" From 95e45fafab082748672fa58138ce6656216e35e8 Mon Sep 17 00:00:00 2001 From: Sabari Jaganathan <93724860+sajagana@users.noreply.github.com> Date: Mon, 29 Jul 2024 21:50:41 +0530 Subject: [PATCH 03/13] [ignore] Added schema validation to the provider.go and updated client.go file --- docs/data-sources/version.md | 2 +- docs/index.md | 2 +- docs/resources/site.md | 2 +- examples/README.md | 9 ---- internal/provider/auth.go | 1 + internal/provider/client.go | 95 +++++++++-------------------------- internal/provider/provider.go | 65 +++++++++++++++++------- 7 files changed, 75 insertions(+), 101 deletions(-) delete mode 100644 examples/README.md diff --git a/docs/data-sources/version.md b/docs/data-sources/version.md index 7ec6660..3cf7130 100644 --- a/docs/data-sources/version.md +++ b/docs/data-sources/version.md @@ -14,7 +14,7 @@ Data source for Nexus Dashboard Version ## API Information ## * Site Management [API Information](https://developer.cisco.com/docs/nexus-dashboard/3-1-1/api-reference/) -* API Endpoint: `nexus/api/sitemanagement/v4/sites` +* API Endpoint: `version.json` ## GUI Information ## diff --git a/docs/index.md b/docs/index.md index d9fa4d7..02a44ec 100644 --- a/docs/index.md +++ b/docs/index.md @@ -82,7 +82,7 @@ resource "nd_site" "example" { - `insecure` (Boolean) Allow insecure HTTPS client. - Default: `true` - Environment variable: `ND_INSECURE` -- `proxy_creds` (String) Proxy server credentials in the form of username:password. +- `proxy_creds` (String) Proxy server credentials in the form of `username:password`. - Environment variable: `ND_PROXY_CREDS` - `proxy_url` (String) Proxy Server URL with port number. - Environment variable: `ND_PROXY_URL` diff --git a/docs/resources/site.md b/docs/resources/site.md index 434196f..7634165 100644 --- a/docs/resources/site.md +++ b/docs/resources/site.md @@ -67,7 +67,7 @@ All examples for the Site resource can be found in the [examples](https://github ## Importing -~> `ND_SITE_USERNAME`, `ND_SITE_PASSWORD` and `ND_LOGIN_DOMAIN` must be set in order to import. +~> The environment variables `ND_SITE_USERNAME`, `ND_SITE_PASSWORD` and `ND_LOGIN_DOMAIN` must be set in order to import. An existing Site can be [imported](https://www.terraform.io/docs/import/index.html) into this resource with its name (name), via the following command: diff --git a/examples/README.md b/examples/README.md deleted file mode 100644 index 026c42c..0000000 --- a/examples/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# Examples - -This directory contains examples that are mostly used for documentation, but can also be run/tested manually via the Terraform CLI. - -The document generation tool looks for files in the following locations by default. All other *.tf files besides the ones mentioned below are ignored by the documentation tool. This is useful for creating examples that can run and/or ar testable even if some parts are not relevant for the documentation. - -* **provider/provider.tf** example file for the provider index page -* **data-sources/`full data source name`/data-source.tf** example file for the named data source page -* **resources/`full resource name`/resource.tf** example file for the named data source page diff --git a/internal/provider/auth.go b/internal/provider/auth.go index 2ee3ea4..ecc984e 100644 --- a/internal/provider/auth.go +++ b/internal/provider/auth.go @@ -38,6 +38,7 @@ func (client *Client) InjectAuthenticationHeader(req *http.Request, path string) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", client.AuthToken.Token)) + // The header "Cookie" must be set for the Nexus Dashboard 2.3 and later versions. req.Header.Set("Cookie", fmt.Sprintf("AuthCookie=%s", client.AuthToken.Token)) return req, nil diff --git a/internal/provider/client.go b/internal/provider/client.go index ab2c47a..98c905c 100644 --- a/internal/provider/client.go +++ b/internal/provider/client.go @@ -37,75 +37,47 @@ type Client struct { proxyUrl string proxyCreds string domain string - version string skipLoggingPayload bool } // singleton implementation of a client var clientImpl *Client -type Option func(*Client) +func initClient(clientUrl, username, password, proxyUrl, proxyCreds, loginDomain string, isInsecure bool) *Client { -func Insecure(insecure bool) Option { - return func(client *Client) { - client.insecure = insecure - } -} - -func Password(password string) Option { - return func(client *Client) { - client.password = password - } -} - -func ProxyUrl(pUrl string) Option { - return func(client *Client) { - client.proxyUrl = pUrl - } -} - -func ProxyCreds(pcreds string) Option { - return func(client *Client) { - client.proxyCreds = pcreds - } -} - -func Domain(domain string) Option { - return func(client *Client) { - client.domain = domain - } -} - -func Version(version string) Option { - return func(client *Client) { - client.version = version - } -} - -func SkipLoggingPayload(skipLoggingPayload bool) Option { - return func(client *Client) { - client.skipLoggingPayload = skipLoggingPayload - } -} - -func initClient(clientUrl, username string, options ...Option) *Client { - var transport *http.Transport bUrl, err := url.Parse(clientUrl) if err != nil { // cannot move forward if url is undefined log.Fatal(err) } + client := &Client{ BaseURL: bUrl, username: username, httpClient: http.DefaultClient, + password: password, + insecure: isInsecure, + proxyUrl: proxyUrl, + proxyCreds: proxyCreds, + domain: loginDomain, } - for _, option := range options { - option(client) + transport := &http.Transport{ + TLSClientConfig: &tls.Config{ + CipherSuites: []uint16{ + tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA, + tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, + tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256, + tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + }, + PreferServerCipherSuites: true, + InsecureSkipVerify: client.insecure, + MinVersion: tls.VersionTLS11, + MaxVersion: tls.VersionTLS13, + }, } - transport = client.useInsecureHTTPClient(client.insecure) if client.proxyUrl != "" { transport = client.configProxy(transport) } @@ -118,9 +90,9 @@ func initClient(clientUrl, username string, options ...Option) *Client { } // GetClient returns a singleton -func GetClient(clientUrl, username string, options ...Option) *Client { +func GetClient(clientUrl, username, password, proxyUrl, proxyCreds, loginDomain string, isInsecure bool) *Client { if clientImpl == nil { - return initClient(clientUrl, username, options...) + return initClient(clientUrl, username, password, proxyUrl, proxyCreds, loginDomain, isInsecure) } return clientImpl } @@ -141,26 +113,6 @@ func (c *Client) configProxy(transport *http.Transport) *http.Transport { return transport } -func (c *Client) useInsecureHTTPClient(insecure bool) *http.Transport { - transport := &http.Transport{ - TLSClientConfig: &tls.Config{ - CipherSuites: []uint16{ - tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA, - tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, - tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256, - tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, - tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, - }, - PreferServerCipherSuites: true, - InsecureSkipVerify: insecure, - MinVersion: tls.VersionTLS11, - MaxVersion: tls.VersionTLS13, - }, - } - - return transport -} - func (c *Client) MakeRestRequest(method string, path string, body *gabs.Container, authenticated bool, skipLoggingPayload bool) (*http.Request, error) { if path != "/login" { if strings.HasPrefix(path, "/") { @@ -212,7 +164,6 @@ func (c *Client) MakeRestRequest(method string, path string, body *gabs.Containe return req, nil } -// Authenticate is used to func (c *Client) Authenticate() error { body, err := gabs.ParseJSON([]byte(fmt.Sprintf(ndAuthPayload, c.username, c.password))) if err != nil { diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 2ec0f93..618a339 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -4,13 +4,16 @@ import ( "context" "fmt" "os" + "regexp" "strconv" - "strings" + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/provider/schema" "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" ) @@ -62,18 +65,33 @@ func (p *ndProvider) Schema(ctx context.Context, req provider.SchemaRequest, res "username": schema.StringAttribute{ Description: "Username for the Nexus Dashboard Account. This can also be set as the ND_USERNAME environment variable.", Optional: true, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, }, "password": schema.StringAttribute{ Description: "Password for the Nexus Dashboard Account. This can also be set as the ND_PASSWORD environment variable.", Optional: true, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, }, "url": schema.StringAttribute{ Description: "URL of the Cisco Nexus Dashboard web interface. This can also be set as the ND_URL environment variable.", Optional: true, + Validators: []validator.String{ + stringvalidator.RegexMatches( + regexp.MustCompile(`^(https?)://[^\s/$.?#].[^\s]*$`), + "The url must contain only alphanumeric characters", + ), + }, }, "login_domain": schema.StringAttribute{ Description: "Login domain for the Nexus Dashboard Account. This can also be set as the ND_LOGIN_DOMAIN environment variable. Defaults to `DefaultAuth`.", Optional: true, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, }, "insecure": schema.BoolAttribute{ Description: "Allow insecure HTTPS client. This can also be set as the ND_INSECURE environment variable. Defaults to `true`.", @@ -82,14 +100,26 @@ func (p *ndProvider) Schema(ctx context.Context, req provider.SchemaRequest, res "proxy_url": schema.StringAttribute{ Description: "Proxy Server URL with port number. This can also be set as the ND_PROXY_URL environment variable.", Optional: true, + Validators: []validator.String{ + stringvalidator.RegexMatches( + regexp.MustCompile(`^(https?)://[^\s/$.?#].[^\s]*$`), + "The proxy_url must contain only alphanumeric characters", + ), + }, }, "proxy_creds": schema.StringAttribute{ Description: "Proxy server credentials in the form of username:password. This can also be set as the ND_PROXY_CREDS environment variable.", Optional: true, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, }, "retries": schema.Int64Attribute{ Description: "Number of retries for REST API calls. This can also be set as the ND_RETRIES environment variable. Defaults to `2`.", Optional: true, + Validators: []validator.Int64{ + int64validator.Between(0, 10), + }, }, }, } @@ -132,31 +162,32 @@ func (p *ndProvider) Configure(ctx context.Context, req provider.ConfigureReques loginDomain = "DefaultAuth" } - if url == "" { - resp.Diagnostics.AddError( - "Url not provided", - "Url must be provided for the ND provider", - ) - } else if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { + var urlRegex = regexp.MustCompile(`^(https?)://[^\s/$.?#].[^\s]*$`) + + if !urlRegex.MatchString(url) { resp.Diagnostics.AddError( - "Incorrect url prefix", - fmt.Sprintf("Url '%s' must start with 'http://' or 'https://'", url), + "Incorrect url format", + fmt.Sprintf("The url '%s' must contain only alphanumeric characters", url), ) } - if maxRetries < 0 || maxRetries > 9 { + if proxyUrl != "" { + if !urlRegex.MatchString(proxyUrl) { + resp.Diagnostics.AddError( + "Incorrect proxy url format", + fmt.Sprintf("The proxy_url '%s' must contain only alphanumeric characters", proxyUrl), + ) + } + } + + if maxRetries < 0 || maxRetries > 10 { resp.Diagnostics.AddError( "Incorrect retry amount", - fmt.Sprintf("Retries must be between 0 and 9 inclusive, got: %d", maxRetries), + fmt.Sprintf("The retries must be between 0 and 10 inclusive, got: %d", maxRetries), ) } - var ndClient *Client - if password != "" { - ndClient = GetClient(url, username, Password(password), Insecure(isInsecure), ProxyUrl(proxyUrl), ProxyCreds(proxyCreds), Domain(loginDomain)) - } else { - ndClient = nil - } + ndClient := GetClient(url, username, password, proxyUrl, proxyCreds, loginDomain, isInsecure) resp.DataSourceData = ndClient resp.ResourceData = ndClient From 64d5bf2e44ac9ae4a9b33462a0547965a66af5e4 Mon Sep 17 00:00:00 2001 From: Sabari Jaganathan <93724860+sajagana@users.noreply.github.com> Date: Tue, 6 Aug 2024 20:00:32 +0530 Subject: [PATCH 04/13] [ignore] Added retries support to the client --- go.mod | 2 +- internal/provider/client.go | 230 +++++++++++++++++---- internal/provider/provider.go | 29 +-- internal/provider/resource_nd_site_test.go | 40 ++-- main.go | 2 +- 5 files changed, 216 insertions(+), 87 deletions(-) diff --git a/go.mod b/go.mod index 3ffd811..ed7e890 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/hashicorp/terraform-plugin-go v0.23.0 github.com/hashicorp/terraform-plugin-log v0.9.0 github.com/hashicorp/terraform-plugin-testing v1.8.0 + golang.org/x/net v0.23.0 ) require ( @@ -74,7 +75,6 @@ require ( golang.org/x/crypto v0.23.0 // indirect golang.org/x/exp v0.0.0-20230809150735-7b3493d9a819 // indirect golang.org/x/mod v0.16.0 // indirect - golang.org/x/net v0.23.0 // indirect golang.org/x/sys v0.20.0 // indirect golang.org/x/text v0.15.0 // indirect golang.org/x/tools v0.19.0 // indirect diff --git a/internal/provider/client.go b/internal/provider/client.go index 98c905c..bc71976 100644 --- a/internal/provider/client.go +++ b/internal/provider/client.go @@ -9,6 +9,9 @@ import ( "fmt" "io" "log" + "math" + "math/rand" + "time" "net/http" "net/url" @@ -18,6 +21,7 @@ import ( "github.com/Jeffail/gabs/v2" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-log/tflog" + "golang.org/x/net/html" ) const ndAuthPayload = `{ @@ -25,6 +29,14 @@ const ndAuthPayload = `{ "userPasswd": "%s" }` +// Default timeout for NGINX in ND is 90 Seconds. +// Allow the client to set a shorter or longer time depending on their +// environment +const DefaultReqTimeoutVal int = 100 +const DefaultBackoffMinDelay int = 4 +const DefaultBackoffMaxDelay int = 60 +const DefaultBackoffDelayFactor float64 = 3 + // Client is the main entry point type Client struct { BaseURL *url.URL @@ -38,12 +50,16 @@ type Client struct { proxyCreds string domain string skipLoggingPayload bool + maxRetries int64 + backoffMinDelay int64 + backoffMaxDelay int64 + backoffDelayFactor float64 } // singleton implementation of a client var clientImpl *Client -func initClient(clientUrl, username, password, proxyUrl, proxyCreds, loginDomain string, isInsecure bool) *Client { +func initClient(clientUrl, username, password, proxyUrl, proxyCreds, loginDomain string, isInsecure bool, maxRetries int64) *Client { bUrl, err := url.Parse(clientUrl) if err != nil { @@ -60,6 +76,7 @@ func initClient(clientUrl, username, password, proxyUrl, proxyCreds, loginDomain proxyUrl: proxyUrl, proxyCreds: proxyCreds, domain: loginDomain, + maxRetries: maxRetries, } transport := &http.Transport{ @@ -90,9 +107,9 @@ func initClient(clientUrl, username, password, proxyUrl, proxyCreds, loginDomain } // GetClient returns a singleton -func GetClient(clientUrl, username, password, proxyUrl, proxyCreds, loginDomain string, isInsecure bool) *Client { +func GetClient(clientUrl, username, password, proxyUrl, proxyCreds, loginDomain string, isInsecure bool, maxRetries int64) *Client { if clientImpl == nil { - return initClient(clientUrl, username, password, proxyUrl, proxyCreds, loginDomain, isInsecure) + return initClient(clientUrl, username, password, proxyUrl, proxyCreds, loginDomain, isInsecure, maxRetries) } return clientImpl } @@ -210,50 +227,88 @@ func (c *Client) Do(req *http.Request, skipLoggingPayload bool) (*gabs.Container log.Printf("[DEBUG] Beginning DO method %s", req.URL.String()) log.Printf("[TRACE] HTTP Request Method and URL: %s %s", req.Method, req.URL.String()) - if !skipLoggingPayload { - log.Printf("[TRACE] HTTP Request Body: %v", req.Body) + var body []byte + if req.Body != nil && c.maxRetries != 0 { + body, _ = io.ReadAll(req.Body) } - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, nil, err - } + for attempts := int64(0); ; attempts++ { + if c.maxRetries != 0 { + req.Body = io.NopCloser(bytes.NewBuffer(body)) + } - if !skipLoggingPayload { - log.Printf("[TRACE] HTTP Response: %d %s %v", resp.StatusCode, resp.Status, resp) - } else { - log.Printf("[TRACE] HTTP Response: %d %s", resp.StatusCode, resp.Status) - } + if !skipLoggingPayload { + log.Printf("[TRACE] HTTP Request Body: %v", req.Body) + } - bodyBytes, err := io.ReadAll(resp.Body) - if err != nil { - return nil, nil, err - } + resp, err := c.httpClient.Do(req) - bodyStr := string(bodyBytes) - err = resp.Body.Close() - if err != nil { - return nil, nil, err - } + if err != nil { + if ok := c.backoff(attempts); !ok { + log.Printf("[ERROR] HTTP Connection error occured: %+v", err) + log.Printf("[DEBUG] Exit from Do method") + return nil, nil, errors.New(fmt.Sprintf("Failed to connect to ND. Verify that you are connecting to an ND.\nError message: %+v", err)) + } else { + log.Printf("[ERROR] HTTP Connection failed: %s, retries: %v", err, attempts) + continue + } + } - if !skipLoggingPayload { - log.Printf("[DEBUG] HTTP response unique string %s %s %s", req.Method, req.URL.String(), bodyStr) - } + if !skipLoggingPayload { + log.Printf("[TRACE] HTTP Response: %d %s %v", resp.StatusCode, resp.Status, resp) + } else { + log.Printf("[TRACE] HTTP Response: %d %s", resp.StatusCode, resp.Status) + } - if req.Method != "DELETE" && resp.StatusCode != 204 { - obj, err := gabs.ParseJSON(bodyBytes) + bodyBytes, err := io.ReadAll(resp.Body) if err != nil { - log.Printf("Error occurred while json parsing %+v", err) - return nil, resp, err + return nil, nil, err + } + + bodyStr := string(bodyBytes) + err = resp.Body.Close() + if err != nil { + return nil, nil, err + } + + if !skipLoggingPayload { + log.Printf("[DEBUG] HTTP response unique string %s %s %s", req.Method, req.URL.String(), bodyStr) + } + + if req.Method != "DELETE" && resp.StatusCode != 204 { + obj, err := gabs.ParseJSON(bodyBytes) + if err != nil { + log.Printf("Error occurred while json parsing %+v", err) + return nil, resp, err + } + log.Printf("[DEBUG] Exit from do method") + return obj, resp, err + } else if req.Method == "DELETE" && resp.StatusCode == 204 { + log.Printf("[DEBUG] Exit from do method") + return nil, resp, nil + } else if resp.StatusCode == 204 { + log.Printf("[DEBUG] Exit from do method") + return nil, nil, nil + } else { + if ok := c.backoff(attempts); !ok { + obj, err := gabs.ParseJSON(bodyBytes) + if err != nil { + log.Printf("[ERROR] Error occured while json parsing: %+v with HTTP StatusCode 405, 500-504", err) + + // If nginx is too busy or the page is not found, ND's nginx will response with an HTML doc instead of a JSON Response. + // In those cases, parse the HTML response for the message and return that to the user + htmlErr := c.checkHtmlResp(bodyStr) + log.Printf("[ERROR] Error occured while json parsing: %s", htmlErr.Error()) + log.Printf("[DEBUG] Exit from Do method") + return nil, resp, errors.New(fmt.Sprintf("Failed to parse JSON response from: %s. Verify that you are connecting to an ND.\nHTTP response status: %s\nMessage: %s", req.URL.String(), resp.Status, htmlErr)) + } + log.Printf("[DEBUG] Exit from Do method") + return obj, resp, nil + } else { + log.Printf("[ERROR] HTTP Request failed: StatusCode %v, Retries: %v", resp.StatusCode, attempts) + continue + } } - log.Printf("[DEBUG] Exit from do method") - return obj, resp, err - } else if req.Method == "DELETE" && resp.StatusCode == 204 { - return nil, resp, nil - } else if resp.StatusCode == 204 { - return nil, nil, nil - } else { - return nil, resp, err } } @@ -298,3 +353,102 @@ func DoRestRequest(ctx context.Context, diags *diag.Diagnostics, client *Client, return cont } + +func (c *Client) backoff(attempts int64) bool { + log.Printf("[DEBUG] Begining backoff method: attempts %v on %v", attempts, c.maxRetries) + if attempts >= c.maxRetries { + log.Printf("[DEBUG] Exit from backoff method with return value false") + return false + } + + minDelay := time.Duration(DefaultBackoffMinDelay) * time.Second + if c.backoffMinDelay != 0 { + minDelay = time.Duration(c.backoffMinDelay) * time.Second + } + + maxDelay := time.Duration(DefaultBackoffMaxDelay) * time.Second + if c.backoffMaxDelay != 0 { + maxDelay = time.Duration(c.backoffMaxDelay) * time.Second + } + + factor := DefaultBackoffDelayFactor + if c.backoffDelayFactor != 0 { + factor = c.backoffDelayFactor + } + + min := float64(minDelay) + backoff := min * math.Pow(factor, float64(attempts)) + if backoff > float64(maxDelay) { + backoff = float64(maxDelay) + } + backoff = (rand.Float64()/2+0.5)*(backoff-min) + min + backoffDuration := time.Duration(backoff) + log.Printf("[TRACE] Starting sleeping for %v", backoffDuration.Round(time.Second)) + time.Sleep(backoffDuration) + log.Printf("[DEBUG] Exit from backoff method with return value true") + return true +} + +// If nginx is too busy or the page is not found, ND's nginx will response with an HTML doc instead of a JSON Response. +// In those cases, parse the HTML response for the message and return that to the user +// +// Sample Response Body: https://github.com/nginx/nginx-releases/blob/master/html/50x.html +// +// +//
+//Sorry, the page you are looking for is currently unavailable.
+// Please try again later.
If you are the system administrator of this resource then you should check +// the error log for details.
+//Faithfully yours, nginx.
+// +// +// +// Sample return error: +// An error occurred. Sorry, the page you are looking for is currently unavailable. If you are the system administrator of this +// resource then you should check the error log for details. Faithfully yours, nginx. +func (c *Client) checkHtmlResp(body string) error { + reader := strings.NewReader(body) + tokenizer := html.NewTokenizer(reader) + errStr := "" + prevTag := "" + for { + tt := tokenizer.Next() + if tt == html.ErrorToken { + break + } + tag, _ := tokenizer.TagName() + token := tokenizer.Token() + + if prevTag == "a" || prevTag == "p" || prevTag == "body" { + data := strings.TrimSpace(token.Data) + if data == "" { + continue + } + if errStr == "" { + errStr = data + } else { + errStr = errStr + " " + data + } + } + prevTag = string(tag) + } + if errStr == "" { + errStr = "Empty ND HTML Response" + } + log.Printf("[DEBUG] HTML Error Parsing Result: %s", errStr) + return fmt.Errorf(errStr) +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 618a339..83d7914 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -142,7 +142,7 @@ func (p *ndProvider) Configure(ctx context.Context, req provider.ConfigureReques url := getStringAttribute(data.URL, "ND_URL") loginDomain := getStringAttribute(data.LoginDomain, "ND_LOGIN_DOMAIN") proxyCreds := getStringAttribute(data.ProxyCreds, "ND_PROXY_CREDS") - maxRetries := getIntAttribute(resp, data.MaxRetries, "ND_RETRIES", 2) + maxRetries := int64(getIntAttribute(resp, data.MaxRetries, "ND_RETRIES", 2)) if username == "" { resp.Diagnostics.AddError( @@ -162,32 +162,7 @@ func (p *ndProvider) Configure(ctx context.Context, req provider.ConfigureReques loginDomain = "DefaultAuth" } - var urlRegex = regexp.MustCompile(`^(https?)://[^\s/$.?#].[^\s]*$`) - - if !urlRegex.MatchString(url) { - resp.Diagnostics.AddError( - "Incorrect url format", - fmt.Sprintf("The url '%s' must contain only alphanumeric characters", url), - ) - } - - if proxyUrl != "" { - if !urlRegex.MatchString(proxyUrl) { - resp.Diagnostics.AddError( - "Incorrect proxy url format", - fmt.Sprintf("The proxy_url '%s' must contain only alphanumeric characters", proxyUrl), - ) - } - } - - if maxRetries < 0 || maxRetries > 10 { - resp.Diagnostics.AddError( - "Incorrect retry amount", - fmt.Sprintf("The retries must be between 0 and 10 inclusive, got: %d", maxRetries), - ) - } - - ndClient := GetClient(url, username, password, proxyUrl, proxyCreds, loginDomain, isInsecure) + ndClient := GetClient(url, username, password, proxyUrl, proxyCreds, loginDomain, isInsecure, maxRetries) resp.DataSourceData = ndClient resp.ResourceData = ndClient diff --git a/internal/provider/resource_nd_site_test.go b/internal/provider/resource_nd_site_test.go index 54b26c9..1e68184 100644 --- a/internal/provider/resource_nd_site_test.go +++ b/internal/provider/resource_nd_site_test.go @@ -99,7 +99,7 @@ resource "nd_site" "example_0" { name = "example_0" username = "admin" password = "password" - url = "10.195.219.154" + url = "10.195.219.154" type = "aci" } ` @@ -109,35 +109,35 @@ resource "nd_site" "example_1" { name = "example_1" username = "admin" password = "password" - url = "10.195.219.154" + url = "10.195.219.154" type = "aci" } ` const testConfigNdSiteMinDependencyCreate = ` resource "nd_site" "example_2" { - name = "example_2" - username = "admin" - password = "password" - url = "10.195.219.155" - type = "aci" - inband_epg = "test_epg" - latitude = "" - longitude = "" - login_domain = "local" + name = "example_2" + username = "admin" + password = "password" + url = "10.195.219.155" + type = "aci" + inband_epg = "test_epg" + latitude = "" + longitude = "" + login_domain = "local" } ` const testConfigNdSiteAllDependencyUpdate = ` resource "nd_site" "example_2" { - name = "example_2" - username = "admin" - password = "password" - url = "10.195.219.155" - type = "aci" - inband_epg = "test_epg" - latitude = "19.36475238603211" - longitude = "-155.28865502961474" - login_domain = "local" + name = "example_2" + username = "admin" + password = "password" + url = "10.195.219.155" + type = "aci" + inband_epg = "test_epg" + latitude = "19.36475238603211" + longitude = "-155.28865502961474" + login_domain = "local" } ` diff --git a/main.go b/main.go index 64ab2fb..7d094c3 100644 --- a/main.go +++ b/main.go @@ -40,7 +40,7 @@ func main() { opts := providerserver.ServeOpts{ // NOTE: This is not a typical Terraform Registry provider address, - // such as registry.terraform.io/hashicorp/nd. This specific + // such as https://registry.terraform.io/providers/CiscoDevNet/nd/latest. This specific // provider address is used in these tutorials in conjunction with a // specific Terraform CLI configuration for manual development testing // of this provider. From 2fc451b77a3e6dc3bd6c4549c4ef597dd10f007a Mon Sep 17 00:00:00 2001 From: Sabari Jaganathan <93724860+sajagana@users.noreply.github.com> Date: Tue, 20 Aug 2024 07:55:11 +0530 Subject: [PATCH 05/13] [ignore] Removed .DS_Store file and updated tools.go file --- internal/.DS_Store | Bin 6148 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 internal/.DS_Store diff --git a/internal/.DS_Store b/internal/.DS_Store deleted file mode 100644 index 8e19978aa52b50b975ccfd7a98970b8fcbe71fef..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHK%}T>S5T0$TZYV+z3VK`cTChK&2QQ)47cim+mD-S^!I&*+YY(N6yS|Vw;`2DO zyAewjJc-yDnEhtwC(C{bJ6Qk_ok`RLr~!aPB`kS3d?6GkU6PXX5DN2*7$O)z7EVXQ zrEGTmMF!~INf<%~8lK?O`wJ5tV(c{>Ch=(8XuOG1xw5vtA*HO!o8VqggK>X68Fl*E z70u4IPQt?OhZpf++N*7y=ycqV(?Mnm;%I;=*Oze`>1jt#(r9FA0~?T@=k;p4vstro zP*;cTd0ow#?W0y*H5={LeD2BZz5U~}-b3=3>SxQQzz@&Jro#eW&{*2oQ;?;JPVX>Q z)D`)R%m6dM46G0X?g*6C71}dzl^I|Le#QWu4+@pgcbHo=M+Xk{`$+K$Aqm=ammpLQ zeTTV4jGzcxifBuPyJ84ij(+9xe22M3TMojkjL&f^3wJ{iW_9!{lMceS$Rjhr3@kEG zw$mE*|I_d9|BFRDV+NRkf5m{Pbc1dOx8!>3(&nhwD%4w463WXhew3hLS~2EQE8amh Zf__B?qVF)bh#nOF5im6HzzqB<1Mk(WO;Z2> From c82eb2413d630586a3fd2ce448c576c15af3a410 Mon Sep 17 00:00:00 2001 From: Sabari Jaganathan <93724860+sajagana@users.noreply.github.com> Date: Mon, 26 Aug 2024 17:36:50 +0530 Subject: [PATCH 06/13] [ignore] Added getBaseSiteResourceModel function to the nd_site resource --- internal/provider/provider.go | 2 +- internal/provider/resource_nd_site.go | 19 ++++++++++++++++++- internal/provider/resource_nd_site_test.go | 11 ----------- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 83d7914..a098209 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -35,7 +35,7 @@ func New(version string) func() provider.Provider { // ndProvider is the provider implementation. type ndProvider struct { // version is set to the provider version on release, "dev" when the - // provider is built and ran locally, and "test" when running acceptance + // provider is built and run locally, and "test" when running acceptance // testing. version string } diff --git a/internal/provider/resource_nd_site.go b/internal/provider/resource_nd_site.go index 1a6f730..951d3b3 100644 --- a/internal/provider/resource_nd_site.go +++ b/internal/provider/resource_nd_site.go @@ -63,6 +63,21 @@ type SiteResourceModel struct { Longitude types.String `tfsdk:"longitude"` } +func getBaseSiteResourceModel(username, password, login_domain string) *SiteResourceModel { + return &SiteResourceModel{ + Id: basetypes.NewStringNull(), + SiteName: basetypes.NewStringNull(), + SitePassword: basetypes.NewStringValue(password), + SiteUsername: basetypes.NewStringValue(username), + LoginDomain: basetypes.NewStringValue(login_domain), + InbandEpg: basetypes.NewStringNull(), + Url: basetypes.NewStringNull(), + SiteType: basetypes.NewStringNull(), + Latitude: basetypes.NewStringNull(), + Longitude: basetypes.NewStringNull(), + } +} + func (r *SiteResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { tflog.Debug(ctx, "Start metadata of resource: nd_site") resp.TypeName = req.ProviderTypeName + "_site" @@ -428,6 +443,7 @@ func setSiteId(ctx context.Context, data *SiteResourceModel) { func getAndSetSiteAttributes(ctx context.Context, diags *diag.Diagnostics, client *Client, data *SiteResourceModel) { responseData := DoRestRequest(ctx, diags, client, fmt.Sprintf("%s/%s", sitePath, data.Id.ValueString()), "GET", nil) + *data = *getBaseSiteResourceModel(data.SiteUsername.ValueString(), data.SitePassword.ValueString(), data.LoginDomain.ValueString()) if diags.HasError() { return @@ -439,6 +455,7 @@ func getAndSetSiteAttributes(ctx context.Context, diags *diag.Diagnostics, clien for attributeName, attributeValue := range specReadInfo { if attributeName == "name" { data.SiteName = basetypes.NewStringValue(attributeValue.(string)) + data.Id = basetypes.NewStringValue(attributeValue.(string)) } if attributeName == "siteConfig" { @@ -463,7 +480,7 @@ func getAndSetSiteAttributes(ctx context.Context, diags *diag.Diagnostics, clien if os.Getenv("ND_LOGIN_DOMAIN") != "" { data.LoginDomain = basetypes.NewStringValue(os.Getenv("ND_LOGIN_DOMAIN")) - } else if attributeName == "loginDomain" && data.LoginDomain.IsUnknown() { + } else if attributeName == "loginDomain" { data.LoginDomain = basetypes.NewStringValue(attributeValue.(string)) } } diff --git a/internal/provider/resource_nd_site_test.go b/internal/provider/resource_nd_site_test.go index 1e68184..03c178e 100644 --- a/internal/provider/resource_nd_site_test.go +++ b/internal/provider/resource_nd_site_test.go @@ -62,17 +62,6 @@ func TestAccResourceNdSiteWithImportTest(t *testing.T) { ResourceName: "nd_site.example_2", ImportState: true, ImportStateVerify: true, - Check: resource.ComposeAggregateTestCheckFunc( - resource.TestCheckResourceAttr("nd_site.example_2", "inband_epg", "test_epg"), - resource.TestCheckResourceAttr("nd_site.example_2", "latitude", ""), - resource.TestCheckResourceAttr("nd_site.example_2", "login_domain", "local"), - resource.TestCheckResourceAttr("nd_site.example_2", "longitude", ""), - resource.TestCheckResourceAttr("nd_site.example_2", "name", "example_2"), - resource.TestCheckResourceAttr("nd_site.example_2", "password", "password"), - resource.TestCheckResourceAttr("nd_site.example_2", "type", "aci"), - resource.TestCheckResourceAttr("nd_site.example_2", "username", "admin"), - resource.TestCheckResourceAttr("nd_site.example_2", "url", "10.195.219.155"), - ), }, // Update with full config and verify default ND values { From 05ece24864edcfcb0872deb2564a666eb24a01c3 Mon Sep 17 00:00:00 2001 From: Sabari Jaganathan <93724860+sajagana@users.noreply.github.com> Date: Tue, 27 Aug 2024 12:48:35 +0530 Subject: [PATCH 07/13] [ignore] Changed the gofmtcheck.sh script file permission --- go.mod | 2 +- main.go | 2 +- scripts/gofmtcheck.sh | 0 3 files changed, 2 insertions(+), 2 deletions(-) mode change 100644 => 100755 scripts/gofmtcheck.sh diff --git a/go.mod b/go.mod index ed7e890..f4127f8 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module terraform-provider-nd +module github.com/CiscoDevNet/terraform-provider-nd go 1.21 diff --git a/main.go b/main.go index 7d094c3..631591d 100644 --- a/main.go +++ b/main.go @@ -8,7 +8,7 @@ import ( "flag" "log" - "terraform-provider-nd/internal/provider" + "github.com/CiscoDevNet/terraform-provider-nd/internal/provider" "github.com/hashicorp/terraform-plugin-framework/providerserver" ) diff --git a/scripts/gofmtcheck.sh b/scripts/gofmtcheck.sh old mode 100644 new mode 100755 From 85ee6c56df89eb5e6f021dd0bb1b92504620a807 Mon Sep 17 00:00:00 2001 From: Sabari Jaganathan <93724860+sajagana@users.noreply.github.com> Date: Thu, 29 Aug 2024 12:16:10 +0530 Subject: [PATCH 08/13] [ignore] Removed unwanted code --- internal/provider/resource_nd_site.go | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/internal/provider/resource_nd_site.go b/internal/provider/resource_nd_site.go index 951d3b3..fc48209 100644 --- a/internal/provider/resource_nd_site.go +++ b/internal/provider/resource_nd_site.go @@ -331,19 +331,20 @@ func (r *SiteResource) ImportState(ctx context.Context, req resource.ImportState username := os.Getenv("ND_SITE_USERNAME") if username == "" { - resp.Diagnostics.AddError("Missing input", "A username must be provided during import, please set the ND_SITE_USERNAME environment variable") + resp.Diagnostics.AddError("Missing input", "The username of the ND site must be provided during import, please set the ND_SITE_USERNAME environment variable") } resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("username"), username)...) password := os.Getenv("ND_SITE_PASSWORD") if password == "" { - resp.Diagnostics.AddError("Missing input", "A password must be provided during import, please set the ND_SITE_PASSWORD environment variable") + resp.Diagnostics.AddError("Missing input", "The password of the ND site must be provided during import, please set the ND_SITE_PASSWORD environment variable") } resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("password"), password)...) loginDomain := os.Getenv("ND_LOGIN_DOMAIN") if loginDomain == "" { - resp.Diagnostics.AddError("Missing input", "A login_domain must be provided during import, please set the ND_LOGIN_DOMAIN environment variable") + resp.Diagnostics.AddError("Missing input", "The login_domain of the ND site must be provided during import, please set the ND_LOGIN_DOMAIN environment variable") + } resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("login_domain"), loginDomain)...) @@ -480,8 +481,6 @@ func getAndSetSiteAttributes(ctx context.Context, diags *diag.Diagnostics, clien if os.Getenv("ND_LOGIN_DOMAIN") != "" { data.LoginDomain = basetypes.NewStringValue(os.Getenv("ND_LOGIN_DOMAIN")) - } else if attributeName == "loginDomain" { - data.LoginDomain = basetypes.NewStringValue(attributeValue.(string)) } } } else { From d465c4ede980fcac863f09202429d4dfde767d6e Mon Sep 17 00:00:00 2001 From: Sabari Jaganathan <93724860+sajagana@users.noreply.github.com> Date: Thu, 29 Aug 2024 23:02:17 +0530 Subject: [PATCH 09/13] [ignore] Change the ND_LOGIN_DOMAIN to ND_SITE_LOGIN_DOMAIN in the resource_nd_site.go and resource_nd_site_test.go file --- internal/provider/resource_nd_site.go | 8 ++++---- internal/provider/resource_nd_site_test.go | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/provider/resource_nd_site.go b/internal/provider/resource_nd_site.go index fc48209..632facf 100644 --- a/internal/provider/resource_nd_site.go +++ b/internal/provider/resource_nd_site.go @@ -341,9 +341,9 @@ func (r *SiteResource) ImportState(ctx context.Context, req resource.ImportState } resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("password"), password)...) - loginDomain := os.Getenv("ND_LOGIN_DOMAIN") + loginDomain := os.Getenv("ND_SITE_LOGIN_DOMAIN") if loginDomain == "" { - resp.Diagnostics.AddError("Missing input", "The login_domain of the ND site must be provided during import, please set the ND_LOGIN_DOMAIN environment variable") + resp.Diagnostics.AddError("Missing input", "The login_domain of the ND site must be provided during import, please set the ND_SITE_LOGIN_DOMAIN environment variable") } resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("login_domain"), loginDomain)...) @@ -479,8 +479,8 @@ func getAndSetSiteAttributes(ctx context.Context, diags *diag.Diagnostics, clien data.Longitude = basetypes.NewStringValue(attributeValue.(string)) } - if os.Getenv("ND_LOGIN_DOMAIN") != "" { - data.LoginDomain = basetypes.NewStringValue(os.Getenv("ND_LOGIN_DOMAIN")) + if os.Getenv("ND_SITE_LOGIN_DOMAIN") != "" { + data.LoginDomain = basetypes.NewStringValue(os.Getenv("ND_SITE_LOGIN_DOMAIN")) } } } else { diff --git a/internal/provider/resource_nd_site_test.go b/internal/provider/resource_nd_site_test.go index 03c178e..9f3c173 100644 --- a/internal/provider/resource_nd_site_test.go +++ b/internal/provider/resource_nd_site_test.go @@ -36,7 +36,7 @@ func TestAccResourceNdSiteTest(t *testing.T) { func TestAccResourceNdSiteWithImportTest(t *testing.T) { t.Setenv("ND_SITE_USERNAME", "admin") t.Setenv("ND_SITE_PASSWORD", "password") - t.Setenv("ND_LOGIN_DOMAIN", "local") + t.Setenv("ND_SITE_LOGIN_DOMAIN", "local") resource.Test(t, resource.TestCase{ PreCheck: func() { testAccPreCheck(t) }, ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, From 7541283a81bb09d4d04b7a3510976028ba6b3e24 Mon Sep 17 00:00:00 2001 From: Sabari Jaganathan <93724860+sajagana@users.noreply.github.com> Date: Fri, 30 Aug 2024 19:49:38 +0530 Subject: [PATCH 10/13] [ignore] Removed unwanted code from nd_site resource --- internal/provider/resource_nd_site.go | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/internal/provider/resource_nd_site.go b/internal/provider/resource_nd_site.go index 632facf..664bbe4 100644 --- a/internal/provider/resource_nd_site.go +++ b/internal/provider/resource_nd_site.go @@ -329,6 +329,8 @@ func (r *SiteResource) ImportState(ctx context.Context, req resource.ImportState var stateData *SiteResourceModel resp.Diagnostics.Append(resp.State.Get(ctx, &stateData)...) + // The API does not return the username, password, and login_domain attributes. + // Therefore, these attributes will be assigned based on the values of environment variables. username := os.Getenv("ND_SITE_USERNAME") if username == "" { resp.Diagnostics.AddError("Missing input", "The username of the ND site must be provided during import, please set the ND_SITE_USERNAME environment variable") @@ -344,7 +346,6 @@ func (r *SiteResource) ImportState(ctx context.Context, req resource.ImportState loginDomain := os.Getenv("ND_SITE_LOGIN_DOMAIN") if loginDomain == "" { resp.Diagnostics.AddError("Missing input", "The login_domain of the ND site must be provided during import, please set the ND_SITE_LOGIN_DOMAIN environment variable") - } resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("login_domain"), loginDomain)...) @@ -444,6 +445,8 @@ func setSiteId(ctx context.Context, data *SiteResourceModel) { func getAndSetSiteAttributes(ctx context.Context, diags *diag.Diagnostics, client *Client, data *SiteResourceModel) { responseData := DoRestRequest(ctx, diags, client, fmt.Sprintf("%s/%s", sitePath, data.Id.ValueString()), "GET", nil) + // The API does not return the username, password, and login_domain attributes. + // Therefore, these attributes will be assigned based on the user's configuration settings or the values of environment variables. *data = *getBaseSiteResourceModel(data.SiteUsername.ValueString(), data.SitePassword.ValueString(), data.LoginDomain.ValueString()) if diags.HasError() { @@ -478,10 +481,6 @@ func getAndSetSiteAttributes(ctx context.Context, diags *diag.Diagnostics, clien if attributeName == "longitude" { data.Longitude = basetypes.NewStringValue(attributeValue.(string)) } - - if os.Getenv("ND_SITE_LOGIN_DOMAIN") != "" { - data.LoginDomain = basetypes.NewStringValue(os.Getenv("ND_SITE_LOGIN_DOMAIN")) - } } } else { data.Id = basetypes.NewStringNull() From f9ec75d3eff011a6e9d55e670b2d7375a4e5a527 Mon Sep 17 00:00:00 2001 From: Sabari Jaganathan <93724860+sajagana@users.noreply.github.com> Date: Thu, 10 Oct 2024 19:13:41 +0530 Subject: [PATCH 11/13] [ignore] Moved client files to the client folder and changed the nd_site import logic --- docs/data-sources/site.md | 1 + docs/index.md | 10 +-- docs/resources/site.md | 5 +- examples/resources/nd_site/main.tf | 1 + internal/{provider => client}/auth.go | 8 +- internal/{provider => client}/client.go | 27 +++--- internal/provider/data_source_nd_site.go | 9 +- internal/provider/data_source_nd_site_test.go | 1 + internal/provider/data_source_nd_version.go | 11 +-- internal/provider/provider.go | 7 +- internal/provider/resource_nd_site.go | 88 ++++++++++++------- internal/provider/resource_nd_site_test.go | 22 ++++- .../resource/schema/boolplanmodifier/doc.go | 5 ++ .../boolplanmodifier/requires_replace.go | 30 +++++++ .../boolplanmodifier/requires_replace_if.go | 73 +++++++++++++++ .../requires_replace_if_configured.go | 34 +++++++ .../requires_replace_if_func.go | 25 ++++++ .../boolplanmodifier/use_state_for_unknown.go | 55 ++++++++++++ vendor/modules.txt | 1 + 19 files changed, 344 insertions(+), 69 deletions(-) rename internal/{provider => client}/auth.go (84%) rename internal/{provider => client}/client.go (94%) create mode 100644 vendor/github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier/doc.go create mode 100644 vendor/github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier/requires_replace.go create mode 100644 vendor/github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier/requires_replace_if.go create mode 100644 vendor/github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier/requires_replace_if_configured.go create mode 100644 vendor/github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier/requires_replace_if_func.go create mode 100644 vendor/github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier/use_state_for_unknown.go diff --git a/docs/data-sources/site.md b/docs/data-sources/site.md index ac34e02..7be455f 100644 --- a/docs/data-sources/site.md +++ b/docs/data-sources/site.md @@ -44,3 +44,4 @@ data "nd_site" "example" { * `inband_epg` (inband_epg) - (String) The In-Band Endpoint Group (EPG) used to connect ND to the site. * `latitude` (latitude) - (String) The latitude location of the site. * `longitude` (longitude) - (String) The longitude location of the site. +* `use_proxy` (useProxy) - (Bool) The use proxy of the site, used to route network traffic through a proxy server. diff --git a/docs/index.md b/docs/index.md index 02a44ec..d741d0f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -9,7 +9,7 @@ description: |- # Nexus Dashboard (ND) -Cisco Nexus Dashboard is a central management console for multiple data center sites and a common platform for hosting Cisco data center operation services, such as Nexus Dashboard Insights and Nexus Dashboard Orchestrator. These services are available for all the data center sites and provide real time analytics, visibility, assurance for network policies and operations, as well as policy orchestration for the data center fabrics, such as Cisco ACI or Cisco NDFC. +Cisco Nexus Dashboard is a central management console for multiple data center sites and a common analytics solution for Cisco data center operations. Nexus Dashboard allows users to manage multiple data center sites and provide real time analytics, visibility, assurance for network policies and operations, as well as policy orchestration for the data center fabrics, such as Cisco ACI or Cisco NDFC or standalone Nexus 9000 switches. # Cisco ND Provider @@ -17,7 +17,7 @@ The Cisco ND terraform provider is used to interact with resources provided by C ## Authentication -Authentication with user-id and password. +Authentication with username and password. Example: @@ -30,8 +30,6 @@ provider "nd" { } ``` -In this method, it will obtain an authentication token from Cisco Nexus Dashboard and will use that token to authenticate. A limitation with this approach is Nexus Dashboard counts the request to authenticate and threshold it to avoid DOS attack. After too many attempts this authentication method may fail as the threshold will be exceeded. To avoid the above-mentioned problem Cisco Nexus Dashboard supports signature-based authentication. - ## Example Usage ```hcl @@ -47,7 +45,7 @@ provider "nd" { username = "admin" password = "password" url = "https://my-cisco-nd.com" - insecure = true + insecure = false } resource "nd_site" "example" { @@ -80,7 +78,7 @@ resource "nd_site" "example" { - Default: `DefaultAuth` - Environment variable: `ND_LOGIN_DOMAIN` - `insecure` (Boolean) Allow insecure HTTPS client. - - Default: `true` + - Default: `false` - Environment variable: `ND_INSECURE` - `proxy_creds` (String) Proxy server credentials in the form of `username:password`. - Environment variable: `ND_PROXY_CREDS` diff --git a/docs/resources/site.md b/docs/resources/site.md index 7634165..7f4cd43 100644 --- a/docs/resources/site.md +++ b/docs/resources/site.md @@ -38,6 +38,7 @@ resource "nd_site" "example" { login_domain = "local" latitude = "19.36475238603211" longitude = "-155.28865502961474" + use_proxy = false } ``` @@ -60,6 +61,8 @@ All examples for the Site resource can be found in the [examples](https://github * `inband_epg` (inband_epg) - (String) The In-Band Endpoint Group (EPG) used to connect ND to the site. * `latitude` (latitude) - (String) The latitude location of the site. * `longitude` (longitude) - (String) The longitude location of the site. +* `use_proxy` (useProxy) - (Bool) The use proxy of the site, used to route network traffic through a proxy server. + * Default: false ### Read-Only ### @@ -67,7 +70,7 @@ All examples for the Site resource can be found in the [examples](https://github ## Importing -~> The environment variables `ND_SITE_USERNAME`, `ND_SITE_PASSWORD` and `ND_LOGIN_DOMAIN` must be set in order to import. +~> The details for `username`, `password`, and `login_domain` will be set to `null` when the `nd_site` resource imports an already registered site from the Nexus Dashboard. Modifying the `username`, `password`, and `login_domain` will not update the imported site configuration on the Nexus Dashboard. An existing Site can be [imported](https://www.terraform.io/docs/import/index.html) into this resource with its name (name), via the following command: diff --git a/examples/resources/nd_site/main.tf b/examples/resources/nd_site/main.tf index a5ccbe6..36faa6f 100644 --- a/examples/resources/nd_site/main.tf +++ b/examples/resources/nd_site/main.tf @@ -8,4 +8,5 @@ resource "nd_site" "example" { latitude = "19.36475238603211" longitude = "-155.28865502961474" login_domain = "local" + use_proxy = true } diff --git a/internal/provider/auth.go b/internal/client/auth.go similarity index 84% rename from internal/provider/auth.go rename to internal/client/auth.go index ecc984e..7d9c7ac 100644 --- a/internal/provider/auth.go +++ b/internal/client/auth.go @@ -1,4 +1,4 @@ -package provider +package client import ( "fmt" @@ -29,7 +29,7 @@ func (t *Auth) estimateExpireTime() int64 { func (client *Client) InjectAuthenticationHeader(req *http.Request, path string) (*http.Request, error) { log.Printf("[DEBUG] Begin Injection") - if client.AuthToken == nil || !client.AuthToken.IsValid() { + if client.authToken == nil || !client.authToken.IsValid() { err := client.Authenticate() if err != nil { return nil, err @@ -37,9 +37,9 @@ func (client *Client) InjectAuthenticationHeader(req *http.Request, path string) } req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", client.AuthToken.Token)) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", client.authToken.Token)) // The header "Cookie" must be set for the Nexus Dashboard 2.3 and later versions. - req.Header.Set("Cookie", fmt.Sprintf("AuthCookie=%s", client.AuthToken.Token)) + req.Header.Set("Cookie", fmt.Sprintf("AuthCookie=%s", client.authToken.Token)) return req, nil } diff --git a/internal/provider/client.go b/internal/client/client.go similarity index 94% rename from internal/provider/client.go rename to internal/client/client.go index bc71976..1b89864 100644 --- a/internal/provider/client.go +++ b/internal/client/client.go @@ -1,4 +1,4 @@ -package provider +package client import ( "bytes" @@ -39,10 +39,10 @@ const DefaultBackoffDelayFactor float64 = 3 // Client is the main entry point type Client struct { - BaseURL *url.URL + baseURL *url.URL httpClient *http.Client - AuthToken *Auth - Mutex sync.Mutex + authToken *Auth + mutex sync.Mutex username string password string insecure bool @@ -68,7 +68,7 @@ func initClient(clientUrl, username, password, proxyUrl, proxyCreds, loginDomain } client := &Client{ - BaseURL: bUrl, + baseURL: bUrl, username: username, httpClient: http.DefaultClient, password: password, @@ -147,7 +147,7 @@ func (c *Client) MakeRestRequest(method string, path string, body *gabs.Containe validateString.Set("validate", "false") url.RawQuery = validateString.Encode() } - fURL := c.BaseURL.ResolveReference(url) + fURL := c.baseURL.ResolveReference(url) var req *http.Request if method == "GET" || method == "DELETE" { @@ -213,12 +213,12 @@ func (c *Client) Authenticate() error { return errors.New("Invalid Username or Password") } - if c.AuthToken == nil { - c.AuthToken = &Auth{} + if c.authToken == nil { + c.authToken = &Auth{} } - c.AuthToken.Token = token - c.AuthToken.CalculateExpiry(1200) //refreshTime=1200 Sec + c.authToken.Token = token + c.authToken.CalculateExpiry(1200) //refreshTime=1200 Sec return nil } @@ -312,14 +312,15 @@ func (c *Client) Do(req *http.Request, skipLoggingPayload bool) (*gabs.Container } } -func DoRestRequest(ctx context.Context, diags *diag.Diagnostics, client *Client, path, method string, payload *gabs.Container) *gabs.Container { +// func (c *Client) DoRestRequest(ctx context.Context, diags *diag.Diagnostics, client *Client, path, method string, payload *gabs.Container) *gabs.Container { +func (c *Client) DoRestRequest(ctx context.Context, diags *diag.Diagnostics, path, method string, payload *gabs.Container) *gabs.Container { if !strings.HasPrefix("/", path) { path = fmt.Sprintf("/%s", path) } var restRequest *http.Request var err error - restRequest, err = client.MakeRestRequest(method, path, payload, true, client.skipLoggingPayload) + restRequest, err = c.MakeRestRequest(method, path, payload, true, c.skipLoggingPayload) if err != nil { diags.AddError( "Creation of rest request failed", @@ -328,7 +329,7 @@ func DoRestRequest(ctx context.Context, diags *diag.Diagnostics, client *Client, return nil } - cont, restResponse, err := client.Do(restRequest, client.skipLoggingPayload) + cont, restResponse, err := c.Do(restRequest, c.skipLoggingPayload) // Return nil when the object is not found and ignore 404 not found error // The resource ID will be set it to nil and the state file content will be deleted when the object is not found diff --git a/internal/provider/data_source_nd_site.go b/internal/provider/data_source_nd_site.go index 89ff555..ed63d58 100644 --- a/internal/provider/data_source_nd_site.go +++ b/internal/provider/data_source_nd_site.go @@ -4,6 +4,7 @@ import ( "context" "fmt" + "github.com/CiscoDevNet/terraform-provider-nd/internal/client" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/datasource/schema" "github.com/hashicorp/terraform-plugin-log/tflog" @@ -18,7 +19,7 @@ func NewSiteDataSource() datasource.DataSource { // SiteDataSource defines the data source implementation. type SiteDataSource struct { - client *Client + client *client.Client } func (d *SiteDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { @@ -74,6 +75,10 @@ func (d *SiteDataSource) Schema(ctx context.Context, req datasource.SchemaReques Computed: true, MarkdownDescription: "The longitude location of the site.", }, + "use_proxy": schema.BoolAttribute{ + Computed: true, + MarkdownDescription: "The use proxy of the site.", + }, }, } tflog.Debug(ctx, "End schema of datasource: nd_site") @@ -86,7 +91,7 @@ func (d *SiteDataSource) Configure(ctx context.Context, req datasource.Configure return } - client, ok := req.ProviderData.(*Client) + client, ok := req.ProviderData.(*client.Client) if !ok { resp.Diagnostics.AddError( diff --git a/internal/provider/data_source_nd_site_test.go b/internal/provider/data_source_nd_site_test.go index e7621ac..3578c5d 100644 --- a/internal/provider/data_source_nd_site_test.go +++ b/internal/provider/data_source_nd_site_test.go @@ -25,6 +25,7 @@ func TestAccDataSourceNdSite(t *testing.T) { resource.TestCheckResourceAttr("nd_site.example_0", "type", "aci"), resource.TestCheckResourceAttr("nd_site.example_0", "username", "admin"), resource.TestCheckResourceAttr("nd_site.example_0", "url", "10.195.219.154"), + resource.TestCheckResourceAttr("nd_site.example_0", "use_proxy", "false"), ), }, { diff --git a/internal/provider/data_source_nd_version.go b/internal/provider/data_source_nd_version.go index 783886a..f09860f 100644 --- a/internal/provider/data_source_nd_version.go +++ b/internal/provider/data_source_nd_version.go @@ -4,6 +4,7 @@ import ( "context" "fmt" + "github.com/CiscoDevNet/terraform-provider-nd/internal/client" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/datasource/schema" "github.com/hashicorp/terraform-plugin-framework/diag" @@ -21,7 +22,7 @@ func NewVersionDataSource() datasource.DataSource { // VersionDataSource defines the data source implementation. type VersionDataSource struct { - client *Client + client *client.Client } func (d *VersionDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { @@ -107,12 +108,12 @@ func (d *VersionDataSource) Configure(ctx context.Context, req datasource.Config return } - client, ok := req.ProviderData.(*Client) + client, ok := req.ProviderData.(*client.Client) if !ok { resp.Diagnostics.AddError( "Unexpected Data Source Configure Type", - fmt.Sprintf("Expected *Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + fmt.Sprintf("Expected *client.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), ) return @@ -147,8 +148,8 @@ func (d *VersionDataSource) Read(ctx context.Context, req datasource.ReadRequest tflog.Debug(ctx, fmt.Sprintf("End read of datasource nd_version with id '%s'", data.Id.ValueString())) } -func getAndSetVersionAttributes(ctx context.Context, diags *diag.Diagnostics, client *Client, data *VersionResourceModel) { - requestData := DoRestRequest(ctx, diags, client, "version.json", "GET", nil) +func getAndSetVersionAttributes(ctx context.Context, diags *diag.Diagnostics, client *client.Client, data *VersionResourceModel) { + requestData := client.DoRestRequest(ctx, diags, "version.json", "GET", nil) if diags.HasError() { return } diff --git a/internal/provider/provider.go b/internal/provider/provider.go index a098209..8c56471 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -7,6 +7,7 @@ import ( "regexp" "strconv" + "github.com/CiscoDevNet/terraform-provider-nd/internal/client" "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/datasource" @@ -94,7 +95,7 @@ func (p *ndProvider) Schema(ctx context.Context, req provider.SchemaRequest, res }, }, "insecure": schema.BoolAttribute{ - Description: "Allow insecure HTTPS client. This can also be set as the ND_INSECURE environment variable. Defaults to `true`.", + Description: "Allow insecure HTTPS client. This can also be set as the ND_INSECURE environment variable. Defaults to `false`.", Optional: true, }, "proxy_url": schema.StringAttribute{ @@ -137,7 +138,7 @@ func (p *ndProvider) Configure(ctx context.Context, req provider.ConfigureReques username := getStringAttribute(data.Username, "ND_USERNAME") password := getStringAttribute(data.Password, "ND_PASSWORD") - isInsecure := getBoolAttribute(resp, data.IsInsecure, "ND_INSECURE", true) + isInsecure := getBoolAttribute(resp, data.IsInsecure, "ND_INSECURE", false) proxyUrl := getStringAttribute(data.ProxyUrl, "ND_PROXY_URL") url := getStringAttribute(data.URL, "ND_URL") loginDomain := getStringAttribute(data.LoginDomain, "ND_LOGIN_DOMAIN") @@ -162,7 +163,7 @@ func (p *ndProvider) Configure(ctx context.Context, req provider.ConfigureReques loginDomain = "DefaultAuth" } - ndClient := GetClient(url, username, password, proxyUrl, proxyCreds, loginDomain, isInsecure, maxRetries) + ndClient := client.GetClient(url, username, password, proxyUrl, proxyCreds, loginDomain, isInsecure, maxRetries) resp.DataSourceData = ndClient resp.ResourceData = ndClient diff --git a/internal/provider/resource_nd_site.go b/internal/provider/resource_nd_site.go index 664bbe4..d34af7e 100644 --- a/internal/provider/resource_nd_site.go +++ b/internal/provider/resource_nd_site.go @@ -4,14 +4,15 @@ import ( "context" "encoding/json" "fmt" - "os" + "github.com/CiscoDevNet/terraform-provider-nd/internal/client" "github.com/Jeffail/gabs/v2" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" "github.com/hashicorp/terraform-plugin-framework/schema/validator" @@ -46,7 +47,7 @@ func NewSiteResource() resource.Resource { // SiteResource defines the resource implementation. type SiteResource struct { - client *Client + client *client.Client } // SiteResourceModel describes the resource data model. @@ -61,20 +62,50 @@ type SiteResourceModel struct { SiteType types.String `tfsdk:"type"` Latitude types.String `tfsdk:"latitude"` Longitude types.String `tfsdk:"longitude"` + UseProxy types.Bool `tfsdk:"use_proxy"` } func getBaseSiteResourceModel(username, password, login_domain string) *SiteResourceModel { return &SiteResourceModel{ Id: basetypes.NewStringNull(), SiteName: basetypes.NewStringNull(), - SitePassword: basetypes.NewStringValue(password), SiteUsername: basetypes.NewStringValue(username), + SitePassword: basetypes.NewStringValue(password), LoginDomain: basetypes.NewStringValue(login_domain), InbandEpg: basetypes.NewStringNull(), Url: basetypes.NewStringNull(), SiteType: basetypes.NewStringNull(), Latitude: basetypes.NewStringNull(), Longitude: basetypes.NewStringNull(), + UseProxy: basetypes.NewBoolNull(), + } +} + +func (r *SiteResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { + if !req.Plan.Raw.IsNull() { + var planData, stateData, configData *SiteResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &planData)...) + resp.Diagnostics.Append(req.State.Get(ctx, &stateData)...) + resp.Diagnostics.Append(req.Config.Get(ctx, &configData)...) + + if stateData != nil { + if resp.Diagnostics.HasError() { + return + } + + if !configData.SiteUsername.IsNull() && stateData.SiteUsername.ValueString() == "" { + planData.SiteUsername = basetypes.NewStringValue("") + } + + if !configData.SitePassword.IsNull() && stateData.SitePassword.ValueString() == "" { + planData.SitePassword = basetypes.NewStringValue("") + } + + if !configData.LoginDomain.IsNull() && stateData.LoginDomain.ValueString() == "" { + planData.LoginDomain = basetypes.NewStringValue("") + } + } + resp.Diagnostics.Append(resp.Plan.Set(ctx, &planData)...) } } @@ -176,6 +207,14 @@ func (r *SiteResource) Schema(ctx context.Context, req resource.SchemaRequest, r stringplanmodifier.UseStateForUnknown(), }, }, + "use_proxy": schema.BoolAttribute{ + Optional: true, + Computed: true, + MarkdownDescription: "The use proxy of the site, used to route network traffic through a proxy server.", + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.RequiresReplace(), + }, + }, }, } tflog.Debug(ctx, "End schema of resource: nd_site") @@ -188,12 +227,12 @@ func (r *SiteResource) Configure(ctx context.Context, req resource.ConfigureRequ return } - client, ok := req.ProviderData.(*Client) + client, ok := req.ProviderData.(*client.Client) if !ok { resp.Diagnostics.AddError( "Unexpected Resource Configure Type", - fmt.Sprintf("Expected *Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + fmt.Sprintf("Expected *client.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), ) return @@ -224,7 +263,7 @@ func (r *SiteResource) Create(ctx context.Context, req resource.CreateRequest, r return } - DoRestRequest(ctx, &resp.Diagnostics, r.client, sitePath, "POST", jsonPayload) + r.client.DoRestRequest(ctx, &resp.Diagnostics, sitePath, "POST", jsonPayload) if resp.Diagnostics.HasError() { return @@ -286,7 +325,7 @@ func (r *SiteResource) Update(ctx context.Context, req resource.UpdateRequest, r return } - DoRestRequest(ctx, &resp.Diagnostics, r.client, fmt.Sprintf("%s/%s", sitePath, data.Id.ValueString()), "PUT", jsonPayload) + r.client.DoRestRequest(ctx, &resp.Diagnostics, fmt.Sprintf("%s/%s", sitePath, data.Id.ValueString()), "PUT", jsonPayload) if resp.Diagnostics.HasError() { return @@ -315,7 +354,7 @@ func (r *SiteResource) Delete(ctx context.Context, req resource.DeleteRequest, r if resp.Diagnostics.HasError() { return } - DoRestRequest(ctx, &resp.Diagnostics, r.client, fmt.Sprintf("%s/%s", sitePath, data.Id.ValueString()), "DELETE", nil) + r.client.DoRestRequest(ctx, &resp.Diagnostics, fmt.Sprintf("%s/%s", sitePath, data.Id.ValueString()), "DELETE", nil) if resp.Diagnostics.HasError() { return } @@ -329,26 +368,6 @@ func (r *SiteResource) ImportState(ctx context.Context, req resource.ImportState var stateData *SiteResourceModel resp.Diagnostics.Append(resp.State.Get(ctx, &stateData)...) - // The API does not return the username, password, and login_domain attributes. - // Therefore, these attributes will be assigned based on the values of environment variables. - username := os.Getenv("ND_SITE_USERNAME") - if username == "" { - resp.Diagnostics.AddError("Missing input", "The username of the ND site must be provided during import, please set the ND_SITE_USERNAME environment variable") - } - resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("username"), username)...) - - password := os.Getenv("ND_SITE_PASSWORD") - if password == "" { - resp.Diagnostics.AddError("Missing input", "The password of the ND site must be provided during import, please set the ND_SITE_PASSWORD environment variable") - } - resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("password"), password)...) - - loginDomain := os.Getenv("ND_SITE_LOGIN_DOMAIN") - if loginDomain == "" { - resp.Diagnostics.AddError("Missing input", "The login_domain of the ND site must be provided during import, please set the ND_SITE_LOGIN_DOMAIN environment variable") - } - resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("login_domain"), loginDomain)...) - tflog.Debug(ctx, fmt.Sprintf("Import state of resource nd_site with id '%s'", stateData.Id.ValueString())) tflog.Debug(ctx, "End import of state resource: nd_site") } @@ -390,6 +409,10 @@ func getSiteCreateJsonPayload(ctx context.Context, diags *diag.Diagnostics, data payloadMap["longitude"] = data.Longitude.ValueString() } + if !data.UseProxy.IsNull() && !data.UseProxy.IsUnknown() { + payloadMap["useProxy"] = data.UseProxy.ValueBool() + } + siteConfiguration := map[string]interface{}{} siteType := "" @@ -442,9 +465,8 @@ func setSiteId(ctx context.Context, data *SiteResourceModel) { data.Id = types.StringValue(data.SiteName.ValueString()) } -func getAndSetSiteAttributes(ctx context.Context, diags *diag.Diagnostics, client *Client, data *SiteResourceModel) { - - responseData := DoRestRequest(ctx, diags, client, fmt.Sprintf("%s/%s", sitePath, data.Id.ValueString()), "GET", nil) +func getAndSetSiteAttributes(ctx context.Context, diags *diag.Diagnostics, client *client.Client, data *SiteResourceModel) { + responseData := client.DoRestRequest(ctx, diags, fmt.Sprintf("%s/%s", sitePath, data.Id.ValueString()), "GET", nil) // The API does not return the username, password, and login_domain attributes. // Therefore, these attributes will be assigned based on the user's configuration settings or the values of environment variables. *data = *getBaseSiteResourceModel(data.SiteUsername.ValueString(), data.SitePassword.ValueString(), data.LoginDomain.ValueString()) @@ -481,6 +503,10 @@ func getAndSetSiteAttributes(ctx context.Context, diags *diag.Diagnostics, clien if attributeName == "longitude" { data.Longitude = basetypes.NewStringValue(attributeValue.(string)) } + + if attributeName == "useProxy" { + data.UseProxy = basetypes.NewBoolValue(attributeValue.(bool)) + } } } else { data.Id = basetypes.NewStringNull() diff --git a/internal/provider/resource_nd_site_test.go b/internal/provider/resource_nd_site_test.go index 9f3c173..54fe089 100644 --- a/internal/provider/resource_nd_site_test.go +++ b/internal/provider/resource_nd_site_test.go @@ -26,6 +26,7 @@ func TestAccResourceNdSiteTest(t *testing.T) { resource.TestCheckResourceAttr("nd_site.example_1", "type", "aci"), resource.TestCheckResourceAttr("nd_site.example_1", "username", "admin"), resource.TestCheckResourceAttr("nd_site.example_1", "url", "10.195.219.154"), + resource.TestCheckResourceAttr("nd_site.example_1", "use_proxy", "false"), ), }, }, @@ -34,9 +35,6 @@ func TestAccResourceNdSiteTest(t *testing.T) { // ND Site full configuration with import test func TestAccResourceNdSiteWithImportTest(t *testing.T) { - t.Setenv("ND_SITE_USERNAME", "admin") - t.Setenv("ND_SITE_PASSWORD", "password") - t.Setenv("ND_SITE_LOGIN_DOMAIN", "local") resource.Test(t, resource.TestCase{ PreCheck: func() { testAccPreCheck(t) }, ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, @@ -55,13 +53,26 @@ func TestAccResourceNdSiteWithImportTest(t *testing.T) { resource.TestCheckResourceAttr("nd_site.example_2", "type", "aci"), resource.TestCheckResourceAttr("nd_site.example_2", "username", "admin"), resource.TestCheckResourceAttr("nd_site.example_2", "url", "10.195.219.155"), + resource.TestCheckResourceAttr("nd_site.example_2", "use_proxy", "true"), ), }, // Import and verify values { ResourceName: "nd_site.example_2", ImportState: true, - ImportStateVerify: true, + ImportStateVerify: false, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("nd_site.example_2", "inband_epg", "test_epg"), + resource.TestCheckResourceAttr("nd_site.example_2", "latitude", ""), + resource.TestCheckResourceAttr("nd_site.example_2", "login_domain", ""), + resource.TestCheckResourceAttr("nd_site.example_2", "longitude", ""), + resource.TestCheckResourceAttr("nd_site.example_2", "name", "example_2"), + resource.TestCheckResourceAttr("nd_site.example_2", "password", ""), + resource.TestCheckResourceAttr("nd_site.example_2", "type", "aci"), + resource.TestCheckResourceAttr("nd_site.example_2", "username", ""), + resource.TestCheckResourceAttr("nd_site.example_2", "url", "10.195.219.155"), + resource.TestCheckResourceAttr("nd_site.example_2", "use_proxy", "true"), + ), }, // Update with full config and verify default ND values { @@ -77,6 +88,7 @@ func TestAccResourceNdSiteWithImportTest(t *testing.T) { resource.TestCheckResourceAttr("nd_site.example_2", "type", "aci"), resource.TestCheckResourceAttr("nd_site.example_2", "username", "admin"), resource.TestCheckResourceAttr("nd_site.example_2", "url", "10.195.219.155"), + resource.TestCheckResourceAttr("nd_site.example_2", "use_proxy", "false"), ), }, }, @@ -114,6 +126,7 @@ resource "nd_site" "example_2" { latitude = "" longitude = "" login_domain = "local" + use_proxy = true } ` @@ -128,5 +141,6 @@ resource "nd_site" "example_2" { latitude = "19.36475238603211" longitude = "-155.28865502961474" login_domain = "local" + use_proxy = false } ` diff --git a/vendor/github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier/doc.go b/vendor/github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier/doc.go new file mode 100644 index 0000000..115960d --- /dev/null +++ b/vendor/github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier/doc.go @@ -0,0 +1,5 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +// Package boolplanmodifier provides plan modifiers for types.Bool attributes. +package boolplanmodifier diff --git a/vendor/github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier/requires_replace.go b/vendor/github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier/requires_replace.go new file mode 100644 index 0000000..10e84a3 --- /dev/null +++ b/vendor/github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier/requires_replace.go @@ -0,0 +1,30 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package boolplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// RequiresReplace returns a plan modifier that conditionally requires +// resource replacement if: +// +// - The resource is planned for update. +// - The plan and state values are not equal. +// +// Use RequiresReplaceIfConfigured if the resource replacement should +// only occur if there is a configuration value (ignore unconfigured drift +// detection changes). Use RequiresReplaceIf if the resource replacement +// should check provider-defined conditional logic. +func RequiresReplace() planmodifier.Bool { + return RequiresReplaceIf( + func(_ context.Context, _ planmodifier.BoolRequest, resp *RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true + }, + "If the value of this attribute changes, Terraform will destroy and recreate the resource.", + "If the value of this attribute changes, Terraform will destroy and recreate the resource.", + ) +} diff --git a/vendor/github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier/requires_replace_if.go b/vendor/github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier/requires_replace_if.go new file mode 100644 index 0000000..389ddb9 --- /dev/null +++ b/vendor/github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier/requires_replace_if.go @@ -0,0 +1,73 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package boolplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// RequiresReplaceIf returns a plan modifier that conditionally requires +// resource replacement if: +// +// - The resource is planned for update. +// - The plan and state values are not equal. +// - The given function returns true. Returning false will not unset any +// prior resource replacement. +// +// Use RequiresReplace if the resource replacement should always occur on value +// changes. Use RequiresReplaceIfConfigured if the resource replacement should +// occur on value changes, but only if there is a configuration value (ignore +// unconfigured drift detection changes). +func RequiresReplaceIf(f RequiresReplaceIfFunc, description, markdownDescription string) planmodifier.Bool { + return requiresReplaceIfModifier{ + ifFunc: f, + description: description, + markdownDescription: markdownDescription, + } +} + +// requiresReplaceIfModifier is an plan modifier that sets RequiresReplace +// on the attribute if a given function is true. +type requiresReplaceIfModifier struct { + ifFunc RequiresReplaceIfFunc + description string + markdownDescription string +} + +// Description returns a human-readable description of the plan modifier. +func (m requiresReplaceIfModifier) Description(_ context.Context) string { + return m.description +} + +// MarkdownDescription returns a markdown description of the plan modifier. +func (m requiresReplaceIfModifier) MarkdownDescription(_ context.Context) string { + return m.markdownDescription +} + +// PlanModifyBool implements the plan modification logic. +func (m requiresReplaceIfModifier) PlanModifyBool(ctx context.Context, req planmodifier.BoolRequest, resp *planmodifier.BoolResponse) { + // Do not replace on resource creation. + if req.State.Raw.IsNull() { + return + } + + // Do not replace on resource destroy. + if req.Plan.Raw.IsNull() { + return + } + + // Do not replace if the plan and state values are equal. + if req.PlanValue.Equal(req.StateValue) { + return + } + + ifFuncResp := &RequiresReplaceIfFuncResponse{} + + m.ifFunc(ctx, req, ifFuncResp) + + resp.Diagnostics.Append(ifFuncResp.Diagnostics...) + resp.RequiresReplace = ifFuncResp.RequiresReplace +} diff --git a/vendor/github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier/requires_replace_if_configured.go b/vendor/github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier/requires_replace_if_configured.go new file mode 100644 index 0000000..ca6c434 --- /dev/null +++ b/vendor/github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier/requires_replace_if_configured.go @@ -0,0 +1,34 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package boolplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// RequiresReplaceIfConfigured returns a plan modifier that conditionally requires +// resource replacement if: +// +// - The resource is planned for update. +// - The plan and state values are not equal. +// - The configuration value is not null. +// +// Use RequiresReplace if the resource replacement should occur regardless of +// the presence of a configuration value. Use RequiresReplaceIf if the resource +// replacement should check provider-defined conditional logic. +func RequiresReplaceIfConfigured() planmodifier.Bool { + return RequiresReplaceIf( + func(_ context.Context, req planmodifier.BoolRequest, resp *RequiresReplaceIfFuncResponse) { + if req.ConfigValue.IsNull() { + return + } + + resp.RequiresReplace = true + }, + "If the value of this attribute is configured and changes, Terraform will destroy and recreate the resource.", + "If the value of this attribute is configured and changes, Terraform will destroy and recreate the resource.", + ) +} diff --git a/vendor/github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier/requires_replace_if_func.go b/vendor/github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier/requires_replace_if_func.go new file mode 100644 index 0000000..d38abd6 --- /dev/null +++ b/vendor/github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier/requires_replace_if_func.go @@ -0,0 +1,25 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package boolplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// RequiresReplaceIfFunc is a conditional function used in the RequiresReplaceIf +// plan modifier to determine whether the attribute requires replacement. +type RequiresReplaceIfFunc func(context.Context, planmodifier.BoolRequest, *RequiresReplaceIfFuncResponse) + +// RequiresReplaceIfFuncResponse is the response type for a RequiresReplaceIfFunc. +type RequiresReplaceIfFuncResponse struct { + // Diagnostics report errors or warnings related to this logic. An empty + // or unset slice indicates success, with no warnings or errors generated. + Diagnostics diag.Diagnostics + + // RequiresReplace should be enabled if the resource should be replaced. + RequiresReplace bool +} diff --git a/vendor/github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier/use_state_for_unknown.go b/vendor/github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier/use_state_for_unknown.go new file mode 100644 index 0000000..efab196 --- /dev/null +++ b/vendor/github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier/use_state_for_unknown.go @@ -0,0 +1,55 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package boolplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// UseStateForUnknown returns a plan modifier that copies a known prior state +// value into the planned value. Use this when it is known that an unconfigured +// value will remain the same after a resource update. +// +// To prevent Terraform errors, the framework automatically sets unconfigured +// and Computed attributes to an unknown value "(known after apply)" on update. +// Using this plan modifier will instead display the prior state value in the +// plan, unless a prior plan modifier adjusts the value. +func UseStateForUnknown() planmodifier.Bool { + return useStateForUnknownModifier{} +} + +// useStateForUnknownModifier implements the plan modifier. +type useStateForUnknownModifier struct{} + +// Description returns a human-readable description of the plan modifier. +func (m useStateForUnknownModifier) Description(_ context.Context) string { + return "Once set, the value of this attribute in state will not change." +} + +// MarkdownDescription returns a markdown description of the plan modifier. +func (m useStateForUnknownModifier) MarkdownDescription(_ context.Context) string { + return "Once set, the value of this attribute in state will not change." +} + +// PlanModifyBool implements the plan modification logic. +func (m useStateForUnknownModifier) PlanModifyBool(_ context.Context, req planmodifier.BoolRequest, resp *planmodifier.BoolResponse) { + // Do nothing if there is no state value. + if req.StateValue.IsNull() { + return + } + + // Do nothing if there is a known planned value. + if !req.PlanValue.IsUnknown() { + return + } + + // Do nothing if there is an unknown configuration value, otherwise interpolation gets messed up. + if req.ConfigValue.IsUnknown() { + return + } + + resp.PlanValue = req.StateValue +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 498eb08..53714d2 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -200,6 +200,7 @@ github.com/hashicorp/terraform-plugin-framework/provider/schema github.com/hashicorp/terraform-plugin-framework/providerserver github.com/hashicorp/terraform-plugin-framework/resource github.com/hashicorp/terraform-plugin-framework/resource/schema +github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier github.com/hashicorp/terraform-plugin-framework/resource/schema/defaults github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier From 7becfe87ea6eb15a281d593e849b5345ec0c68a9 Mon Sep 17 00:00:00 2001 From: Sabari Jaganathan <93724860+sajagana@users.noreply.github.com> Date: Tue, 29 Oct 2024 15:35:48 +0530 Subject: [PATCH 12/13] [minor_change] Added insecure flag on the GitHub workflows and updated nd_site resource doc --- .github/workflows/checks.yml | 2 ++ docs/resources/site.md | 10 ++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 1ad3b68..222e769 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -62,6 +62,7 @@ jobs: nd_host: - name: v3.1 url: "https://173.36.219.35/" + insecure: true steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 @@ -78,6 +79,7 @@ jobs: TF_ACC_STATE_LINEAGE: "1" ND_VAL_REL_DN: false ND_URL: ${{ matrix.nd_host.url }} + ND_INSECURE: ${{ matrix.nd_host.insecure }} - name: Upload coverage to Codecov # Upload Coverage on latest only if: ${{ matrix.nd_host.name == 'v3.1'}} diff --git a/docs/resources/site.md b/docs/resources/site.md index 7f4cd43..8dc27fb 100644 --- a/docs/resources/site.md +++ b/docs/resources/site.md @@ -70,11 +70,11 @@ All examples for the Site resource can be found in the [examples](https://github ## Importing -~> The details for `username`, `password`, and `login_domain` will be set to `null` when the `nd_site` resource imports an already registered site from the Nexus Dashboard. Modifying the `username`, `password`, and `login_domain` will not update the imported site configuration on the Nexus Dashboard. - An existing Site can be [imported](https://www.terraform.io/docs/import/index.html) into this resource with its name (name), via the following command: +``` terraform import nd_site.example {name} +``` Starting in Terraform version 1.5, an existing Site can be imported using [import blocks](https://developer.hashicorp.com/terraform/language/import) via the following configuration: @@ -84,3 +84,9 @@ import { to = nd_site.example } ``` + +~> The values for `username`, `password`, and `login_domain` attributes will not be imported when the nd_site resource imports an already registered site from the Nexus Dashboard. Use the `-replace` option to change the `username`, `password`, and `login_domain` attributes for the imported site. + +``` +terraform apply -replace="nd_site.example" +``` From 58121da1a416f8043914946fc282bf3b43a2c1d9 Mon Sep 17 00:00:00 2001 From: Sabari Jaganathan <93724860+sajagana@users.noreply.github.com> Date: Tue, 29 Oct 2024 16:06:00 +0530 Subject: [PATCH 13/13] [minor_change] Changed the import note on nd_side resource doc --- docs/resources/site.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/resources/site.md b/docs/resources/site.md index 8dc27fb..f121270 100644 --- a/docs/resources/site.md +++ b/docs/resources/site.md @@ -85,7 +85,7 @@ import { } ``` -~> The values for `username`, `password`, and `login_domain` attributes will not be imported when the nd_site resource imports an already registered site from the Nexus Dashboard. Use the `-replace` option to change the `username`, `password`, and `login_domain` attributes for the imported site. +~> The values for `username`, `password`, and `login_domain` attributes will not be imported when the `nd_site` resource imports an already registered site from Nexus Dashboard. Modifying the `username`, `password`, and `login_domain` will not update the imported site configuration on Nexus Dashboard. Use the `-replace` option to force the site recreation and use the new provided `username`, `password`, and `login_domain` attributes for the imported site. ``` terraform apply -replace="nd_site.example"