From 6c7012aec6160c4f78872277c73e3d84ea67ac9b Mon Sep 17 00:00:00 2001 From: jwierzbo Date: Thu, 1 Oct 2020 15:28:52 +0200 Subject: [PATCH] Provider code --- .gitignore | 29 ++ LICENSE | 201 ++++++++++++++ Makefile | 35 +++ README.md | 42 +++ cmd/terraform-provider-grafanads/grafanads.go | 13 + pkg/api/client.go | 66 +++++ pkg/api/datasource_generic.go | 130 +++++++++ pkg/provider/provider.go | 49 ++++ pkg/provider/provider_test.go | 54 ++++ pkg/provider/resource_data_source_generic.go | 256 ++++++++++++++++++ .../resource_data_source_generic_test.go | 120 ++++++++ 11 files changed, 995 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 cmd/terraform-provider-grafanads/grafanads.go create mode 100644 pkg/api/client.go create mode 100644 pkg/api/datasource_generic.go create mode 100644 pkg/provider/provider.go create mode 100644 pkg/provider/provider_test.go create mode 100644 pkg/provider/resource_data_source_generic.go create mode 100644 pkg/provider/resource_data_source_generic_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a3e2fd --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Local .terraform directories +**/.terraform/* + +# .tfstate files +*.tfstate +*.tfstate.* + +# Crash log files +crash.log + +# Ignore any .tfvars files that are generated automatically for each Terraform run. Most +# .tfvars files are managed as part of configuration and so should be included in +# version control. +# +# example.tfvars + +# Ignore override files as they are usually used to override resources locally and so +# are not checked in +override.tf +override.tf.json +*_override.tf +*_override.tf.json + +# Include override files you do wish to add to version control using negated pattern +# +# !example_override.tf + +# Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan +# example: *tfplan* diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..40dd755 --- /dev/null +++ b/Makefile @@ -0,0 +1,35 @@ +NAME="terraform-provider-grafanads" +VERSION="1.0.0" +BINARY=${NAME} + +.PHONY: terraform-provider-grafanads +terraform-provider-grafanads: + go install github.com/jwierzbo/terraform-provider-grafanads/cmd/${NAME} + +.PHONY: update-libs +update-libs: + GOSUMDB=off GOPROXY=direct go mod tidy -v + go mod vendor -v + +test: + go test ./... + +testacc: + TF_ACC=1 go test ./... -v $(TESTARGS) -timeout 120m + +.PHONY: create-release +create-release: + mkdir -p build/${NAME}/linux-amd64 + mkdir -p build/${NAME}/darwin-amd64 + + GOOS=linux GOARCH=amd64 go build -o build/${NAME}/linux-amd64/${NAME} \ + github.com/jwierzbo/terraform-provider-grafanads/cmd/${NAME} + + GOOS=darwin GOARCH=amd64 go build -o build/${NAME}/darwin-amd64/${NAME} \ + github.com/jwierzbo/terraform-provider-grafanads/cmd/${NAME} + +.PHONY: install-local +install-local: + go build -o ${BINARY} github.com/jwierzbo/terraform-provider-grafanads/cmd/${NAME} + mkdir -p ~/.terraform.d/plugins/ + mv ${BINARY} ~/.terraform.d/plugins/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..8e71da9 --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# terraform-provider-grafanads + + + +The terraform provider to manage every type of Grafana Datasource + +Based on: https://github.com/grafana/grafana-api-golang-client + + +## Requirements + +Tested with: +- [Terraform](https://www.terraform.io/downloads.html) 0.12.x +- [Go](https://golang.org/doc/install) 1.13 (to build the provider plugin) + + +## Usage + +Example code is located under [sample](sample) directory ([sample/main.tf](sample/main.tf)) + +```shell script +make install-local +cd sample +terraform init +terraform plan +terraform apply +``` + + +## Tests + +In order to test the provider, you can simply run `make test`. + +```sh +$ make test +``` + +In order to run the full suite of Acceptance tests, run `make testacc`. + +``` +GRAFANA_URL=https://grafana.company.com GRAFANA_AUTH=XYZ make testacc +``` diff --git a/cmd/terraform-provider-grafanads/grafanads.go b/cmd/terraform-provider-grafanads/grafanads.go new file mode 100644 index 0000000..2b78326 --- /dev/null +++ b/cmd/terraform-provider-grafanads/grafanads.go @@ -0,0 +1,13 @@ +package main + +import ( + "github.com/hashicorp/terraform/plugin" + + gprovider "github.com/jwierzbo/terraform-provider-grafana-datasource/pkg/provider" +) + +func main() { + plugin.Serve(&plugin.ServeOpts{ + ProviderFunc: gprovider.Provider, + }) +} diff --git a/pkg/api/client.go b/pkg/api/client.go new file mode 100644 index 0000000..85197c9 --- /dev/null +++ b/pkg/api/client.go @@ -0,0 +1,66 @@ +package gapi + +import ( + "bytes" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "path" + "strings" + + "github.com/hashicorp/go-cleanhttp" +) + +type Client struct { + key string + baseURL url.URL + *http.Client +} + +//New creates a new grafana client +//auth can be in user:pass format, or it can be an api key +func New(auth, baseURL string) (*Client, error) { + u, err := url.Parse(baseURL) + if err != nil { + return nil, err + } + key := "" + if strings.Contains(auth, ":") { + split := strings.SplitN(auth, ":", 2) + u.User = url.UserPassword(split[0], split[1]) + } else { + key = fmt.Sprintf("Bearer %s", auth) + } + return &Client{ + key, + *u, + cleanhttp.DefaultClient(), + }, nil +} + +func (c *Client) newRequest(method, requestPath string, query url.Values, body io.Reader) (*http.Request, error) { + url := c.baseURL + url.Path = path.Join(url.Path, requestPath) + url.RawQuery = query.Encode() + req, err := http.NewRequest(method, url.String(), body) + if err != nil { + return req, err + } + if c.key != "" { + req.Header.Add("Authorization", c.key) + } + + if os.Getenv("GF_LOG") != "" { + if body == nil { + log.Printf("request (%s) to %s with no body data", method, url.String()) + } else { + log.Printf("request (%s) to %s with body data: %s", method, url.String(), body.(*bytes.Buffer).String()) + } + } + + req.Header.Add("Content-Type", "application/json") + return req, err +} diff --git a/pkg/api/datasource_generic.go b/pkg/api/datasource_generic.go new file mode 100644 index 0000000..70206fc --- /dev/null +++ b/pkg/api/datasource_generic.go @@ -0,0 +1,130 @@ +package gapi + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io/ioutil" +) + +type DataSourceGeneric struct { + Id int64 `json:"id,omitempty"` + Name string `json:"name"` + Type string `json:"type"` + URL string `json:"url"` + Access string `json:"access"` + + Database string `json:"database,omitempty"` + User string `json:"user,omitempty"` + // Deprecated in favor of secureJsonData.password + Password string `json:"password,omitempty"` + + OrgId int64 `json:"orgId,omitempty"` + IsDefault bool `json:"isDefault"` + + BasicAuth bool `json:"basicAuth"` + BasicAuthUser string `json:"basicAuthUser,omitempty"` + // Deprecated in favor of secureJsonData.basicAuthPassword + BasicAuthPassword string `json:"basicAuthPassword,omitempty"` + + JSONData JsonData `json:"jsonData,omitempty"` + SecureJSONData JsonData `json:"secureJsonData,omitempty"` +} + +type JsonData map[string]interface{} + +func (c *Client) NewDataSource(s *DataSourceGeneric) (int64, error) { + data, err := json.Marshal(s) + if err != nil { + return 0, err + } + req, err := c.newRequest("POST", "/api/datasources", nil, bytes.NewBuffer(data)) + if err != nil { + return 0, err + } + + resp, err := c.Do(req) + if err != nil { + return 0, err + } + if resp.StatusCode != 200 { + return 0, errors.New(resp.Status) + } + + data, err = ioutil.ReadAll(resp.Body) + if err != nil { + return 0, err + } + + result := struct { + Id int64 `json:"id"` + }{} + err = json.Unmarshal(data, &result) + return result.Id, err +} + +func (c *Client) UpdateDataSource(s *DataSourceGeneric) error { + path := fmt.Sprintf("/api/datasources/%d", s.Id) + data, err := json.Marshal(s) + if err != nil { + return err + } + req, err := c.newRequest("PUT", path, nil, bytes.NewBuffer(data)) + if err != nil { + return err + } + + resp, err := c.Do(req) + if err != nil { + return err + } + if resp.StatusCode != 200 { + return errors.New(resp.Status) + } + + return nil +} + +func (c *Client) DataSource(id int64) (*DataSourceGeneric, error) { + path := fmt.Sprintf("/api/datasources/%d", id) + req, err := c.newRequest("GET", path, nil, nil) + if err != nil { + return nil, err + } + + resp, err := c.Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode != 200 { + return nil, errors.New(resp.Status) + } + + data, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + result := &DataSourceGeneric{} + err = json.Unmarshal(data, &result) + return result, err +} + +func (c *Client) DeleteDataSource(id int64) error { + path := fmt.Sprintf("/api/datasources/%d", id) + req, err := c.newRequest("DELETE", path, nil, nil) + if err != nil { + return err + } + + resp, err := c.Do(req) + if err != nil { + return err + } + if resp.StatusCode != 200 { + return errors.New(resp.Status) + } + + return nil +} diff --git a/pkg/provider/provider.go b/pkg/provider/provider.go new file mode 100644 index 0000000..8161aa1 --- /dev/null +++ b/pkg/provider/provider.go @@ -0,0 +1,49 @@ +package grafana + +import ( + "github.com/hashicorp/terraform/helper/logging" + "github.com/hashicorp/terraform/helper/schema" + "github.com/hashicorp/terraform/terraform" + + gapi "github.com/jwierzbo/terraform-provider-grafana-datasource/pkg/api" +) + +func Provider() terraform.ResourceProvider { + return &schema.Provider{ + Schema: map[string]*schema.Schema{ + "url": { + Type: schema.TypeString, + Required: true, + DefaultFunc: schema.EnvDefaultFunc("GRAFANA_URL", nil), + Description: "URL of the root of the target Grafana server.", + }, + "auth": { + Type: schema.TypeString, + Required: true, + Sensitive: true, + DefaultFunc: schema.EnvDefaultFunc("GRAFANA_AUTH", nil), + Description: "Credentials for accessing the Grafana API.", + }, + }, + + ResourcesMap: map[string]*schema.Resource{ + "grafanads_data_source_generic": ResourceDataSourceGeneric(), + }, + + ConfigureFunc: providerConfigure, + } +} + +func providerConfigure(d *schema.ResourceData) (interface{}, error) { + client, err := gapi.New( + d.Get("auth").(string), + d.Get("url").(string), + ) + if err != nil { + return nil, err + } + + client.Transport = logging.NewTransport("Grafana", client.Transport) + + return client, nil +} diff --git a/pkg/provider/provider_test.go b/pkg/provider/provider_test.go new file mode 100644 index 0000000..646e620 --- /dev/null +++ b/pkg/provider/provider_test.go @@ -0,0 +1,54 @@ +package grafana + +import ( + "os" + "testing" + + "github.com/hashicorp/terraform/helper/schema" + "github.com/hashicorp/terraform/terraform" +) + +// To run these acceptance tests, you will need a Grafana server. +// Grafana can be downloaded here: http://grafana.org/download/ +// +// The tests will need an API key to authenticate with the server. To create +// one, use the menu for one of your installation's organizations (The +// "Main Org." is fine if you've just done a fresh installation to run these +// tests) to reach the "API Keys" admin page. +// +// Giving the API key the Admin role is the easiest way to ensure enough +// access is granted to run all of the tests. +// +// Once you've created the API key, set the GRAFANA_URL and GRAFANA_AUTH +// environment variables to the Grafana base URL and the API key respectively, +// and then run: +// make testacc TEST=./builtin/providers/grafana + +var testAccProviders map[string]terraform.ResourceProvider +var testAccProvider *schema.Provider + +func init() { + testAccProvider = Provider().(*schema.Provider) + testAccProviders = map[string]terraform.ResourceProvider{ + "grafanads": testAccProvider, + } +} + +func TestProvider(t *testing.T) { + if err := Provider().(*schema.Provider).InternalValidate(); err != nil { + t.Fatalf("err: %s", err) + } +} + +func TestProvider_impl(t *testing.T) { + var _ terraform.ResourceProvider = Provider() +} + +func testAccPreCheck(t *testing.T) { + if v := os.Getenv("GRAFANA_URL"); v == "" { + t.Fatal("GRAFANA_URL must be set for acceptance tests") + } + if v := os.Getenv("GRAFANA_AUTH"); v == "" { + t.Fatal("GRAFANA_AUTH must be set for acceptance tests") + } +} diff --git a/pkg/provider/resource_data_source_generic.go b/pkg/provider/resource_data_source_generic.go new file mode 100644 index 0000000..4863293 --- /dev/null +++ b/pkg/provider/resource_data_source_generic.go @@ -0,0 +1,256 @@ +package grafana + +import ( + "fmt" + "log" + "strconv" + + "github.com/hashicorp/terraform/helper/schema" + + gapi "github.com/jwierzbo/terraform-provider-grafana-datasource/pkg/api" +) + +func ResourceDataSourceGeneric() *schema.Resource { + return &schema.Resource{ + Create: CreateDataSourceGeneric, + Update: UpdateDataSourceGeneric, + Delete: DeleteDataSourceGeneric, + Read: ReadDataSourceGeneric, + + Schema: map[string]*schema.Schema{ + "access_mode": { + Type: schema.TypeString, + Optional: true, + Default: "proxy", + }, + "org_id": { + Type: schema.TypeInt, + Optional: true, + }, + "basic_auth_enabled": { + Type: schema.TypeBool, + Optional: true, + Default: false, + }, + "basic_auth_password": { + Type: schema.TypeString, + Optional: true, + Default: "", + Sensitive: true, + }, + "basic_auth_username": { + Type: schema.TypeString, + Optional: true, + Default: "", + }, + "database_name": { + Type: schema.TypeString, + Optional: true, + Default: "", + }, + "is_default": { + Type: schema.TypeBool, + Optional: true, + Default: false, + }, + "name": { + Type: schema.TypeString, + Required: true, + }, + "password": { + Type: schema.TypeString, + Optional: true, + Default: "", + Sensitive: true, + }, + "type": { + Type: schema.TypeString, + Required: true, + }, + "url": { + Type: schema.TypeString, + Optional: true, + }, + "username": { + Type: schema.TypeString, + Optional: true, + Default: "", + }, + + "json_data_string": { + Type: schema.TypeMap, + Elem: &schema.Schema{Type: schema.TypeString}, + Optional: true, + }, + "json_data_bool": { + Type: schema.TypeMap, + Elem: &schema.Schema{Type: schema.TypeBool}, + Optional: true, + }, + "json_data_int": { + Type: schema.TypeMap, + Elem: &schema.Schema{Type: schema.TypeInt}, + Optional: true, + }, + + "secure_json_string": { + Type: schema.TypeMap, + Elem: &schema.Schema{Type: schema.TypeString}, + Optional: true, + Sensitive: true, + }, + "secure_json_bool": { + Type: schema.TypeMap, + Elem: &schema.Schema{Type: schema.TypeBool}, + Optional: true, + Sensitive: true, + }, + "secure_json_int": { + Type: schema.TypeMap, + Elem: &schema.Schema{Type: schema.TypeInt}, + Optional: true, + Sensitive: true, + }, + }, + } +} + +// CreateDataSourceGeneric creates a Grafana datasource +func CreateDataSourceGeneric(d *schema.ResourceData, meta interface{}) error { + client := meta.(*gapi.Client) + + dataSource, err := makeDataSource(d) + if err != nil { + return err + } + + id, err := client.NewDataSource(dataSource) + if err != nil { + return err + } + + d.SetId(strconv.FormatInt(id, 10)) + + return ReadDataSourceGeneric(d, meta) +} + +// UpdateDataSourceGeneric updates a Grafana datasource +func UpdateDataSourceGeneric(d *schema.ResourceData, meta interface{}) error { + client := meta.(*gapi.Client) + + dataSource, err := makeDataSource(d) + if err != nil { + return err + } + + return client.UpdateDataSource(dataSource) +} + +// ReadDataSourceGeneric reads a Grafana datasource +func ReadDataSourceGeneric(d *schema.ResourceData, meta interface{}) error { + client := meta.(*gapi.Client) + + idStr := d.Id() + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + return fmt.Errorf("Invalid id: %#v", idStr) + } + + dataSource, err := client.DataSource(id) + if err != nil { + if err.Error() == "404 Not Found" { + log.Printf("[WARN] removing datasource %s from state because it no longer exists in grafana", d.Get("name").(string)) + d.SetId("") + return nil + } + return err + } + + d.Set("id", dataSource.Id) + d.Set("access_mode", dataSource.Access) + d.Set("basic_auth_enabled", dataSource.BasicAuth) + d.Set("basic_auth_username", dataSource.BasicAuthUser) + d.Set("basic_auth_password", dataSource.BasicAuthPassword) + d.Set("database_name", dataSource.Database) + d.Set("is_default", dataSource.IsDefault) + d.Set("name", dataSource.Name) + d.Set("password", dataSource.Password) + d.Set("type", dataSource.Type) + d.Set("url", dataSource.URL) + d.Set("org_id", dataSource.OrgId) + d.Set("username", dataSource.User) + + return nil +} + +// DeleteDataSourceGeneric deletes a Grafana datasource +func DeleteDataSourceGeneric(d *schema.ResourceData, meta interface{}) error { + client := meta.(*gapi.Client) + + idStr := d.Id() + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + return fmt.Errorf("Invalid id: %#v", idStr) + } + + return client.DeleteDataSource(id) +} + +func makeDataSource(d *schema.ResourceData) (*gapi.DataSourceGeneric, error) { + idStr := d.Id() + var id int64 + var err error + if idStr != "" { + id, err = strconv.ParseInt(idStr, 10, 64) + } + + return &gapi.DataSourceGeneric{ + Id: id, + OrgId: int64(d.Get("org_id").(int)), + Name: d.Get("name").(string), + Type: d.Get("type").(string), + URL: d.Get("url").(string), + Access: d.Get("access_mode").(string), + Database: d.Get("database_name").(string), + User: d.Get("username").(string), + Password: d.Get("password").(string), + IsDefault: d.Get("is_default").(bool), + BasicAuth: d.Get("basic_auth_enabled").(bool), + BasicAuthUser: d.Get("basic_auth_username").(string), + BasicAuthPassword: d.Get("basic_auth_password").(string), + JSONData: makeJSONData(d), + SecureJSONData: makeSecureJSONData(d), + }, err +} + +func mergeMaps(input map[string]interface{}, output gapi.JsonData) { + for k, v := range input { + output[k] = v + } +} + +func makeJSONData(d *schema.ResourceData) gapi.JsonData { + result := gapi.JsonData{} + + strings := d.Get("json_data_string").(map[string]interface{}) + bools := d.Get("json_data_bool").(map[string]interface{}) + ints := d.Get("json_data_int").(map[string]interface{}) + + mergeMaps(strings, result) + mergeMaps(bools, result) + mergeMaps(ints, result) + return result +} + +func makeSecureJSONData(d *schema.ResourceData) gapi.JsonData { + result := gapi.JsonData{} + + strings := d.Get("secure_json_string").(map[string]interface{}) + bools := d.Get("secure_json_bool").(map[string]interface{}) + ints := d.Get("secure_json_int").(map[string]interface{}) + + mergeMaps(strings, result) + mergeMaps(bools, result) + mergeMaps(ints, result) + return result +} diff --git a/pkg/provider/resource_data_source_generic_test.go b/pkg/provider/resource_data_source_generic_test.go new file mode 100644 index 0000000..1951af3 --- /dev/null +++ b/pkg/provider/resource_data_source_generic_test.go @@ -0,0 +1,120 @@ +package grafana + +import ( + "fmt" + "regexp" + "strconv" + "testing" + + gapi "github.com/jwierzbo/terraform-provider-grafana-datasource/pkg/api" + + "github.com/hashicorp/terraform/helper/resource" + "github.com/hashicorp/terraform/terraform" +) + +var resourceTests = []struct { + resource string + config string + attrChecks map[string]string +}{ + { + "grafanads_data_source_generic.mongoatlas", + ` + resource "grafanads_data_source_generic" "mongoatlas" { + type = "grafana-mongodb-atlas-datasource" + name = "mongoatlas-provider-test" + org_id = 9 + json_data_string = { + atlasPublicKey: "xxx", + } + secure_json_string = { + atlasPrivateKey: "yyy", + } + } + `, + map[string]string{ + "type": "grafana-mongodb-atlas-datasource", + "name": "mongoatlas-provider-test", + }, + }, +} + +func TestAccDataSource_basic(t *testing.T) { + var dataSource gapi.DataSourceGeneric + + // Iterate over the provided configurations for datasources + for _, test := range resourceTests { + + // Always check that the resource was created and that `id` is a number + checks := []resource.TestCheckFunc{ + testAccDataSourceCheckExists(test.resource, &dataSource), + resource.TestMatchResourceAttr( + test.resource, + "id", + regexp.MustCompile(`\d+`), + ), + } + + // Add custom checks for specified attribute values + for attr, value := range test.attrChecks { + checks = append(checks, resource.TestCheckResourceAttr( + test.resource, + attr, + value, + )) + } + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccDataSourceCheckDestroy(&dataSource), + Steps: []resource.TestStep{ + { + Config: test.config, + Check: resource.ComposeAggregateTestCheckFunc( + checks..., + ), + }, + }, + }) + } +} + +func testAccDataSourceCheckExists(rn string, dataSource *gapi.DataSourceGeneric) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[rn] + if !ok { + return fmt.Errorf("resource not found: %s", rn) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("resource id not set") + } + + id, err := strconv.ParseInt(rs.Primary.ID, 10, 64) + if err != nil { + return fmt.Errorf("resource id is malformed") + } + + client := testAccProvider.Meta().(*gapi.Client) + gotDataSource, err := client.DataSource(id) + if err != nil { + return fmt.Errorf("error getting data source: %s", err) + } + + *dataSource = *gotDataSource + + return nil + } +} + +func testAccDataSourceCheckDestroy(dataSource *gapi.DataSourceGeneric) resource.TestCheckFunc { + return func(s *terraform.State) error { + client := testAccProvider.Meta().(*gapi.Client) + _, err := client.DataSource(dataSource.Id) + if err == nil { + return fmt.Errorf("data source still exists") + } + return nil + } +}