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/data-sources/site.md b/docs/data-sources/site.md new file mode 100644 index 0000000..7be455f --- /dev/null +++ b/docs/data-sources/site.md @@ -0,0 +1,47 @@ +--- +subcategory: "Sites" +layout: "nd" +page_title: "ND: nd_site" +sidebar_current: "docs-nd-data-source-nd_site" +description: |- + Data source for Nexus Dashboard Sites +--- + +# nd_site # + +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` + +## Example Usage ## + +```hcl +data "nd_site" "example" { + name = "example" +} +``` + +## Schema ## + +### Required ### + +* `name` (name) - (String) The name of the site. + +### Read-Only ### +* `id` (id) - (String) The ID 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. +* `use_proxy` (useProxy) - (Bool) The use proxy of the site, used to route network traffic through a proxy server. diff --git a/docs/data-sources/version.md b/docs/data-sources/version.md new file mode 100644 index 0000000..3cf7130 --- /dev/null +++ b/docs/data-sources/version.md @@ -0,0 +1,44 @@ +--- +subcategory: "Version" +layout: "nd" +page_title: "ND: nd_version" +sidebar_current: "docs-nd-data-source-nd_version" +description: |- + Data source for Nexus Dashboard Version +--- + +# nd_site # + +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: `version.json` + +## 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..d741d0f --- /dev/null +++ b/docs/index.md @@ -0,0 +1,89 @@ +--- +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. +--- + +# Nexus Dashboard (ND) + +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 + +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 username and password. + +Example: + +```hcl +provider "nd" { + username = "admin" + password = "password" + url = "https://my-cisco-nd.com" + login_domain = "DefaultAuth" +} +``` + +## Example Usage + +```hcl +terraform { + required_providers { + nd = { + source = "ciscodevnet/nd" + } + } +} + +provider "nd" { + username = "admin" + password = "password" + url = "https://my-cisco-nd.com" + insecure = false +} + +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 + +## 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: `false` + - 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 new file mode 100644 index 0000000..f121270 --- /dev/null +++ b/docs/resources/site.md @@ -0,0 +1,92 @@ +--- +subcategory: "Sites" +layout: "nd" +page_title: "ND: nd_site" +sidebar_current: "docs-nd-resource-nd_site" +description: |- + Manages Sites for Nexus Dashboard +--- + +# nd_site # + +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` +* [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 ## + +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" { + 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" + use_proxy = false +} +``` + +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 ### + +* `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 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. +* `use_proxy` (useProxy) - (Bool) The use proxy of the site, used to route network traffic through a proxy server. + * Default: false + +### 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 (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: + +``` +import { + name = "{name}" + 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 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" +``` diff --git a/examples/data-sources/nd_site/main.tf b/examples/data-sources/nd_site/main.tf new file mode 100644 index 0000000..e63fedc --- /dev/null +++ b/examples/data-sources/nd_site/main.tf @@ -0,0 +1,3 @@ +data "nd_site" "example" { + 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..36faa6f --- /dev/null +++ b/examples/resources/nd_site/main.tf @@ -0,0 +1,12 @@ +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" + use_proxy = true +} 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/go.mod b/go.mod index 3ffd811..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 @@ -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/client/auth.go b/internal/client/auth.go new file mode 100644 index 0000000..7d9c7ac --- /dev/null +++ b/internal/client/auth.go @@ -0,0 +1,45 @@ +package client + +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)) + // 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/client/client.go b/internal/client/client.go new file mode 100644 index 0000000..1b89864 --- /dev/null +++ b/internal/client/client.go @@ -0,0 +1,455 @@ +package client + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/base64" + "errors" + "fmt" + "io" + "log" + "math" + "math/rand" + "time" + + "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" + "golang.org/x/net/html" +) + +const ndAuthPayload = `{ + "userName": "%s", + "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 + httpClient *http.Client + authToken *Auth + mutex sync.Mutex + username string + password string + insecure bool + proxyUrl string + 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, maxRetries int64) *Client { + + 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, + maxRetries: maxRetries, + } + + 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, + }, + } + + if client.proxyUrl != "" { + transport = client.configProxy(transport) + } + + client.httpClient = &http.Client{ + Transport: transport, + } + + return client +} + +// GetClient returns a singleton +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, maxRetries) + } + 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) 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 +} + +func (c *Client) Authenticate() error { + body, err := gabs.ParseJSON([]byte(fmt.Sprintf(ndAuthPayload, c.username, c.password))) + if err != nil { + return err + } + + if c.domain != "" { + body.Set(c.domain, "domain") + } + + 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, c.skipLoggingPayload) + + if err != nil { + return err + } + + if obj == nil { + return errors.New("Empty response") + } + + token := obj.S("token").String() + + if token == "" || token == "{}" { + return errors.New("Invalid Username or Password") + } + + if c.authToken == nil { + c.authToken = &Auth{} + } + + c.authToken.Token = token + c.authToken.CalculateExpiry(1200) //refreshTime=1200 Sec + + return nil +} + +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()) + + var body []byte + if req.Body != nil && c.maxRetries != 0 { + body, _ = io.ReadAll(req.Body) + } + + for attempts := int64(0); ; attempts++ { + if c.maxRetries != 0 { + req.Body = io.NopCloser(bytes.NewBuffer(body)) + } + + if !skipLoggingPayload { + log.Printf("[TRACE] HTTP Request Body: %v", req.Body) + } + + resp, err := c.httpClient.Do(req) + + 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("[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 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 + } + } + } +} + +// 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 = c.MakeRestRequest(method, path, payload, true, c.skipLoggingPayload) + 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 := 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 + if restResponse.StatusCode == 404 { + return nil + } + + if restResponse != nil && cont.Data() != nil && (restResponse.StatusCode != 200 && restResponse.StatusCode != 201) { + diags.AddError( + 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 %s rest request failed.", method, path), + fmt.Sprintf("Err: %s. Please report this issue to the provider developers.", err), + ) + return nil + } + + 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/data_source_nd_site.go b/internal/provider/data_source_nd_site.go new file mode 100644 index 0000000..ed63d58 --- /dev/null +++ b/internal/provider/data_source_nd_site.go @@ -0,0 +1,134 @@ +package provider + +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" +) + +// 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.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: "Data source for Nexus Dashboard Sites", + + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The ID of the site.", + }, + "name": schema.StringAttribute{ + Required: true, + MarkdownDescription: "The name of the site.", + }, + "url": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The URL of the site.", + }, + "type": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The type of the site.", + }, + "username": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The username of the site.", + }, + "password": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The password of the site.", + }, + "login_domain": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The login domain of the site.", + }, + "inband_epg": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The In-Band Endpoint Group (EPG) used to connect ND to the site.", + }, + "latitude": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The latitude location of the site.", + }, + "longitude": schema.StringAttribute{ + 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") +} + +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.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/data_source_nd_site_test.go b/internal/provider/data_source_nd_site_test.go new file mode 100644 index 0000000..3578c5d --- /dev/null +++ b/internal/provider/data_source_nd_site_test.go @@ -0,0 +1,50 @@ +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", "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"), + resource.TestCheckResourceAttr("nd_site.example_0", "use_proxy", "false"), + ), + }, + { + Config: testConfigNdSiteNonExisting, + ExpectError: regexp.MustCompile("Failed to read nd_site data source"), + }, + }, + }) +} + +const testConfigNdSite = testConfigNdSiteMinDependencyForDataSource + ` +data "nd_site" "example_0" { + name = "example_0" + depends_on = [nd_site.example_0] +} +` + +const testConfigNdSiteNonExisting = ` +data "nd_site" "test" { + name = "non_existing" +} +` diff --git a/internal/provider/data_source_nd_version.go b/internal/provider/data_source_nd_version.go new file mode 100644 index 0000000..f09860f --- /dev/null +++ b/internal/provider/data_source_nd_version.go @@ -0,0 +1,207 @@ +package provider + +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" + "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.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: "Data source for Nexus Dashboard Version", + + 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.Client) + + if !ok { + resp.Diagnostics.AddError( + "Unexpected Data Source Configure Type", + fmt.Sprintf("Expected *client.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 + } + + 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 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 + } + 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/data_source_nd_version_test.go b/internal/provider/data_source_nd_version_test.go new file mode 100644 index 0000000..8332b26 --- /dev/null +++ b/internal/provider/data_source_nd_version_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..8c56471 --- /dev/null +++ b/internal/provider/provider.go @@ -0,0 +1,226 @@ +package provider + +import ( + "context" + "fmt" + "os" + "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" + "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" +) + +// 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 run 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"` + 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. +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 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 `false`.", + 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, + 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), + }, + }, + }, + } +} + +// 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", false) + proxyUrl := getStringAttribute(data.ProxyUrl, "ND_PROXY_URL") + url := getStringAttribute(data.URL, "ND_URL") + loginDomain := getStringAttribute(data.LoginDomain, "ND_LOGIN_DOMAIN") + proxyCreds := getStringAttribute(data.ProxyCreds, "ND_PROXY_CREDS") + maxRetries := int64(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 == "" { + resp.Diagnostics.AddError( + "Authentication details not provided", + "Password must be provided for the ND provider", + ) + } + + if loginDomain == "" { + loginDomain = "DefaultAuth" + } + + ndClient := client.GetClient(url, username, password, proxyUrl, proxyCreds, loginDomain, isInsecure, maxRetries) + + 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") + } + } +} diff --git a/internal/provider/resource_nd_site.go b/internal/provider/resource_nd_site.go new file mode 100644 index 0000000..d34af7e --- /dev/null +++ b/internal/provider/resource_nd_site.go @@ -0,0 +1,515 @@ +package provider + +import ( + "context" + "encoding/json" + "fmt" + + "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" + "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.Client +} + +// SiteResourceModel describes the resource data model. +type SiteResourceModel struct { + Id types.String `tfsdk:"id"` + 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:"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(), + 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)...) + } +} + +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: "Manages Sites for 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(), + }, + }, + "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 of the site.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + stringplanmodifier.UseStateForUnknown(), + }, + }, + "type": schema.StringAttribute{ + Required: true, + MarkdownDescription: "The type of the site.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + stringvalidator.OneOf("aci", "dcnm", "third_party", "cloud_aci", "dcnm_ng", "ndfc"), + }, + }, + "username": schema.StringAttribute{ + Required: true, + MarkdownDescription: "The username of the site.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + stringplanmodifier.UseStateForUnknown(), + }, + }, + "password": schema.StringAttribute{ + Required: true, + MarkdownDescription: "The password of the site.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + stringplanmodifier.UseStateForUnknown(), + }, + }, + "login_domain": schema.StringAttribute{ + Optional: true, + Computed: true, + MarkdownDescription: "The login domain of the site.", + 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 ND to the site.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + stringplanmodifier.UseStateForUnknown(), + }, + }, + "latitude": schema.StringAttribute{ + Optional: true, + Computed: true, + MarkdownDescription: "The latitude location of the site.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "longitude": schema.StringAttribute{ + Optional: true, + Computed: true, + MarkdownDescription: "The longitude location of the site.", + PlanModifiers: []planmodifier.String{ + 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") +} + +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.Client) + + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *client.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 + } + + r.client.DoRestRequest(ctx, &resp.Diagnostics, sitePath, "POST", jsonPayload) + + if resp.Diagnostics.HasError() { + return + } + setSiteId(ctx, data) + + 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 + } + + r.client.DoRestRequest(ctx, &resp.Diagnostics, fmt.Sprintf("%s/%s", sitePath, data.Id.ValueString()), "PUT", jsonPayload) + + if resp.Diagnostics.HasError() { + return + } + setSiteId(ctx, data) + + 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 + } + r.client.DoRestRequest(ctx, &resp.Diagnostics, 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)...) + + 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{}{} + + 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() + } + + inbandEpg := "" + if !data.InbandEpg.IsNull() && !data.InbandEpg.IsUnknown() { + inbandEpg = data.InbandEpg.ValueString() + payloadMap["inband_epg"] = inbandEpg + } + + 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.Latitude.IsNull() && !data.Latitude.IsUnknown() { + payloadMap["latitude"] = data.Latitude.ValueString() + } + + if !data.Longitude.IsNull() && !data.Longitude.IsUnknown() { + payloadMap["longitude"] = data.Longitude.ValueString() + } + + if !data.UseProxy.IsNull() && !data.UseProxy.IsUnknown() { + payloadMap["useProxy"] = data.UseProxy.ValueBool() + } + + siteConfiguration := map[string]interface{}{} + siteType := "" + + 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", + } + } + } + + 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.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()) + + 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.SiteName = basetypes.NewStringValue(attributeValue.(string)) + data.Id = 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 attributeName == "useProxy" { + data.UseProxy = basetypes.NewBoolValue(attributeValue.(bool)) + } + } + } else { + data.Id = basetypes.NewStringNull() + data.SiteName = basetypes.NewStringNull() + } +} diff --git a/internal/provider/resource_nd_site_test.go b/internal/provider/resource_nd_site_test.go new file mode 100644 index 0000000..54fe089 --- /dev/null +++ b/internal/provider/resource_nd_site_test.go @@ -0,0 +1,146 @@ +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", "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"), + resource.TestCheckResourceAttr("nd_site.example_1", "use_proxy", "false"), + ), + }, + }, + }) +} + +// ND Site full configuration with import test +func TestAccResourceNdSiteWithImportTest(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: 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", "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"), + resource.TestCheckResourceAttr("nd_site.example_2", "use_proxy", "true"), + ), + }, + // Import and verify values + { + ResourceName: "nd_site.example_2", + ImportState: 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 + { + 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", "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"), + resource.TestCheckResourceAttr("nd_site.example_2", "use_proxy", "false"), + ), + }, + }, + }) +} + +const testConfigNdSiteMinDependencyForDataSource = ` +resource "nd_site" "example_0" { + name = "example_0" + username = "admin" + password = "password" + url = "10.195.219.154" + type = "aci" +} +` + +const testConfigNdSiteMinDependency = ` +resource "nd_site" "example_1" { + name = "example_1" + username = "admin" + password = "password" + 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" + use_proxy = true +} +` + +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" + use_proxy = false +} +` diff --git a/main.go b/main.go index 64ab2fb..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" ) @@ -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. diff --git a/scripts/gofmtcheck.sh b/scripts/gofmtcheck.sh old mode 100644 new mode 100755 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