From 6de356e1763a7fc9eca26506362fdc5330ad7aaf Mon Sep 17 00:00:00 2001 From: Brad Sickles Date: Mon, 8 Mar 2021 16:21:21 -0500 Subject: [PATCH] 221 Autogen subdomain (#6) --- internal/provider/autogen_subdomain_test.go | 114 ++++++++++++++ internal/provider/config_utils.go | 21 +++ internal/provider/data_autogen_subdomain.go | 92 +++++++++++ .../provider/data_autogen_subdomain_test.go | 79 ++++++++++ internal/provider/data_connection.go | 37 ++--- internal/provider/data_workspace.go | 33 ---- internal/provider/provider.go | 2 + .../resource_autogen_subdomain_delegation.go | 146 ++++++++++++++++++ ...ource_autogen_subdomain_delegation_test.go | 127 +++++++++++++++ internal/server/README.md | 2 + ns/autogen_subdomain.go | 23 +++ ns/ns_client.go | 137 ++++++++++++++++ ns/ns_config.go | 29 +++- website/docs/d/autogen_subdomain.markdown | 35 +++++ .../r/autogen_subdomain_delegation.markdown | 41 +++++ 15 files changed, 855 insertions(+), 63 deletions(-) create mode 100644 internal/provider/autogen_subdomain_test.go create mode 100644 internal/provider/data_autogen_subdomain.go create mode 100644 internal/provider/data_autogen_subdomain_test.go create mode 100644 internal/provider/resource_autogen_subdomain_delegation.go create mode 100644 internal/provider/resource_autogen_subdomain_delegation_test.go create mode 100644 internal/server/README.md create mode 100644 ns/autogen_subdomain.go create mode 100644 website/docs/d/autogen_subdomain.markdown create mode 100644 website/docs/r/autogen_subdomain_delegation.markdown diff --git a/internal/provider/autogen_subdomain_test.go b/internal/provider/autogen_subdomain_test.go new file mode 100644 index 0000000..9baff92 --- /dev/null +++ b/internal/provider/autogen_subdomain_test.go @@ -0,0 +1,114 @@ +package provider + +import ( + "encoding/json" + "fmt" + "github.com/gorilla/mux" + "github.com/nullstone-io/terraform-provider-ns/ns" + "net/http" +) + +func mockNsServerWithAutogenSubdomains(subdomains map[string]map[string]*ns.AutogenSubdomain, delegations map[string]map[string]*ns.AutogenSubdomainDelegation) http.Handler { + findSubdomain := func(orgName, subdomainName string) *ns.AutogenSubdomain { + orgSubdomains, ok := subdomains[orgName] + if !ok { + return nil + } + subdomain, ok := orgSubdomains[subdomainName] + if !ok { + return nil + } + return subdomain + } + findDelegation := func(orgName, subdomainName string) *ns.AutogenSubdomainDelegation { + orgDelegations, ok := delegations[orgName] + if !ok { + return nil + } + delegation, ok := orgDelegations[subdomainName] + if !ok { + return nil + } + return delegation + } + + router := mux.NewRouter() + router. + Methods(http.MethodGet). + Path("/orgs/{orgName}/autogen_subdomains/{subdomainName}"). + HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + orgName, subdomainName := vars["orgName"], vars["subdomainName"] + subdomain := findSubdomain(orgName, subdomainName) + if subdomain != nil { + raw, _ := json.Marshal(subdomain) + w.Write(raw) + } else { + http.NotFound(w, r) + } + }) + router. + Methods(http.MethodGet). + Path("/orgs/{orgName}/autogen_subdomains/{subdomainName}/delegation"). + HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + orgName, subdomainName := vars["orgName"], vars["subdomainName"] + delegation := findDelegation(orgName, subdomainName) + if delegation != nil { + raw, _ := json.Marshal(delegation) + w.Write(raw) + return + } else { + http.NotFound(w, r) + } + }) + router. + Methods(http.MethodPut). + Path("/orgs/{orgName}/autogen_subdomains/{subdomainName}/delegation"). + Headers("Content-Type", "application/json"). + HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + orgName, subdomainName := vars["orgName"], vars["subdomainName"] + if subdomain := findSubdomain(orgName, subdomainName); subdomain == nil { + http.NotFound(w, r) + return + } + if _, ok := delegations[orgName]; !ok { + delegations[orgName] = map[string]*ns.AutogenSubdomainDelegation{} + } + + if r.Body == nil { + http.Error(w, "invalid body", http.StatusInternalServerError) + return + } + defer r.Body.Close() + decoder := json.NewDecoder(r.Body) + var delegation ns.AutogenSubdomainDelegation + if err := decoder.Decode(&delegation); err != nil { + http.Error(w, fmt.Sprintf("invalid body: %s", err), http.StatusInternalServerError) + return + } + + delegations[orgName][subdomainName] = &delegation + raw, _ := json.Marshal(delegation) + w.Write(raw) + }) + router. + Methods(http.MethodDelete). + Path("/orgs/{orgName}/autogen_subdomains/{subdomainName}/delegation"). + HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + orgName, subdomainName := vars["orgName"], vars["subdomainName"] + if subdomain := findSubdomain(orgName, subdomainName); subdomain == nil { + http.NotFound(w, r) + return + } + if _, ok := delegations[orgName]; !ok { + delegations[orgName] = map[string]*ns.AutogenSubdomainDelegation{} + } + + delegations[orgName][subdomainName] = &ns.AutogenSubdomainDelegation{Nameservers: []string{}} + w.WriteHeader(http.StatusNoContent) + }) + return router +} diff --git a/internal/provider/config_utils.go b/internal/provider/config_utils.go index ec30cc0..dcb5072 100644 --- a/internal/provider/config_utils.go +++ b/internal/provider/config_utils.go @@ -19,3 +19,24 @@ func boolFromConfig(config map[string]tftypes.Value, key string) bool { config[key].As(&val) return val } + +func stringSliceFromConfig(config map[string]tftypes.Value, key string) ([]string, error) { + if config[key].IsNull() { + return make([]string, 0), nil + } + + tfslice := make([]tftypes.Value, 0) + if err := config[key].As(&tfslice); err != nil { + return nil, err + } + + slice := make([]string, 0) + for _, tfitem := range tfslice { + var item string + if err := tfitem.As(&item); err != nil { + return nil, err + } + slice = append(slice, item) + } + return slice, nil +} diff --git a/internal/provider/data_autogen_subdomain.go b/internal/provider/data_autogen_subdomain.go new file mode 100644 index 0000000..26f2e97 --- /dev/null +++ b/internal/provider/data_autogen_subdomain.go @@ -0,0 +1,92 @@ +package provider + +import ( + "context" + "fmt" + "github.com/hashicorp/terraform-plugin-go/tfprotov5" + "github.com/hashicorp/terraform-plugin-go/tfprotov5/tftypes" + "github.com/nullstone-io/terraform-provider-ns/internal/server" + "strings" +) + +type dataAutogenSubdomain struct { + p *provider +} + +func newDataAutogenSubdomain(p *provider) (*dataAutogenSubdomain, error) { + if p == nil { + return nil, fmt.Errorf("a provider is required") + } + return &dataAutogenSubdomain{p: p}, nil +} + +var ( + _ server.DataSource = (*dataAutogenSubdomain)(nil) +) + +func (*dataAutogenSubdomain) Schema(ctx context.Context) *tfprotov5.Schema { + return &tfprotov5.Schema{ + Version: 1, + Block: &tfprotov5.SchemaBlock{ + Description: "Data source to configure a connection to another nullstone workspace.", + DescriptionKind: tfprotov5.StringKindMarkdown, + Attributes: []*tfprotov5.SchemaAttribute{ + deprecatedIDAttribute(), + { + Name: "name", + Type: tftypes.String, + Required: true, + Description: "The name of the autogenerated subdomain.", + DescriptionKind: tfprotov5.StringKindMarkdown, + }, + { + Name: "domain_name", + Type: tftypes.String, + Computed: true, + Description: "The domain name that nullstone manages for this autogenerated subdomain. It is usually `nullstone.app`.", + DescriptionKind: tfprotov5.StringKindMarkdown, + }, + { + Name: "fqdn", + Type: tftypes.String, + Computed: true, + Description: "The fully-qualified domain name (FQDN) that nullstone manages for this autogenerated subdomain. It is composed as `{name}.{domain_name}.`.", + DescriptionKind: tfprotov5.StringKindMarkdown, + }, + }, + }, + } +} + +func (d *dataAutogenSubdomain) Validate(ctx context.Context, config map[string]tftypes.Value) ([]*tfprotov5.Diagnostic, error) { + return nil, nil +} + +func (d *dataAutogenSubdomain) Read(ctx context.Context, config map[string]tftypes.Value) (map[string]tftypes.Value, []*tfprotov5.Diagnostic, error) { + name := stringFromConfig(config, "name") + + state := map[string]tftypes.Value{ + "id": tftypes.NewValue(tftypes.String, name), + "name": tftypes.NewValue(tftypes.String, name), + } + diags := make([]*tfprotov5.Diagnostic, 0) + + subdomain, err := d.p.NsClient.GetAutogenSubdomain(name) + if err != nil { + diags = append(diags, &tfprotov5.Diagnostic{ + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "error retrieving autogen subdomain", + Detail: err.Error(), + }) + } else if subdomain == nil { + diags = append(diags, &tfprotov5.Diagnostic{ + Severity: tfprotov5.DiagnosticSeverityError, + Summary: fmt.Sprintf("The autogen_subdomain %q is missing.", name), + }) + } else { + state["domain_name"] = tftypes.NewValue(tftypes.String, subdomain.DomainName) + state["fqdn"] = tftypes.NewValue(tftypes.String, fmt.Sprintf("%s.%s.", subdomain.Name, strings.TrimSuffix(subdomain.DomainName, "."))) + } + + return state, diags, nil +} diff --git a/internal/provider/data_autogen_subdomain_test.go b/internal/provider/data_autogen_subdomain_test.go new file mode 100644 index 0000000..0411573 --- /dev/null +++ b/internal/provider/data_autogen_subdomain_test.go @@ -0,0 +1,79 @@ +package provider + +import ( + "fmt" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/nullstone-io/terraform-provider-ns/ns" + "regexp" + "testing" +) + +func TestDataAutogenSubdomain(t *testing.T) { + subdomains := map[string]map[string]*ns.AutogenSubdomain{ + "org0": { + "api": { + Id: 1, + Name: "api", + DomainName: "nullstone.app", + }, + }, + } + delegations := map[string]map[string]*ns.AutogenSubdomainDelegation{} + + t.Run("fails to find non-existent autogen_subdomain", func(t *testing.T) { + tfconfig := fmt.Sprintf(` +provider "ns" { + organization = "org0" +} +data "ns_autogen_subdomain" "subdomain" { + name = "docs" +} +`) + + getNsConfig, closeNsFn := mockNs(mockNsServerWithAutogenSubdomains(subdomains, delegations)) + defer closeNsFn() + getTfeConfig, _ := mockTfe(nil) + + checks := resource.ComposeTestCheckFunc() + resource.UnitTest(t, resource.TestCase{ + ProtoV5ProviderFactories: protoV5ProviderFactories(getNsConfig, getTfeConfig), + Steps: []resource.TestStep{ + { + Config: tfconfig, + Check: checks, + ExpectError: regexp.MustCompile(`The autogen_subdomain "docs" is missing.`), + }, + }, + }) + }) + + t.Run("sets up attributes properly", func(t *testing.T) { + tfconfig := fmt.Sprintf(` +provider "ns" { + organization = "org0" +} +data "ns_autogen_subdomain" "subdomain" { + name = "api" +} +`) + checks := resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("data.ns_autogen_subdomain.subdomain", `name`, "api"), + resource.TestCheckResourceAttr("data.ns_autogen_subdomain.subdomain", `domain_name`, "nullstone.app"), + resource.TestCheckResourceAttr("data.ns_autogen_subdomain.subdomain", `fqdn`, "api.nullstone.app."), + ) + + getNsConfig, closeNsFn := mockNs(mockNsServerWithAutogenSubdomains(subdomains, delegations)) + defer closeNsFn() + getTfeConfig, _ := mockTfe(nil) + + resource.UnitTest(t, resource.TestCase{ + ProtoV5ProviderFactories: protoV5ProviderFactories(getNsConfig, getTfeConfig), + Steps: []resource.TestStep{ + { + Config: tfconfig, + Check: checks, + }, + }, + }) + }) +} diff --git a/internal/provider/data_connection.go b/internal/provider/data_connection.go index f165b9b..98711fb 100644 --- a/internal/provider/data_connection.go +++ b/internal/provider/data_connection.go @@ -81,33 +81,6 @@ Typically, this is set to data.ns_connection.other.name`, } func (d *dataConnection) Validate(ctx context.Context, config map[string]tftypes.Value) ([]*tfprotov5.Diagnostic, error) { - diags := make([]*tfprotov5.Diagnostic, 0) - - var name string - if err := config["name"].As(&name); err != nil { - diags = append(diags, &tfprotov5.Diagnostic{ - Severity: tfprotov5.DiagnosticSeverityError, - Summary: "ns_connection.name must be a string", - }) - } else if !validConnectionName.Match([]byte(name)) { - diags = append(diags, &tfprotov5.Diagnostic{ - Severity: tfprotov5.DiagnosticSeverityError, - Summary: "ns_connection.name can only contain the characters 'a'-'z', '0'-'9', '-', '_'", - }) - } - - var optional bool - if err := config["optional"].As(&optional); err != nil { - diags = append(diags, &tfprotov5.Diagnostic{ - Severity: tfprotov5.DiagnosticSeverityError, - Summary: err.Error(), - }) - } - - if len(diags) > 0 { - return diags, nil - } - return nil, nil } @@ -119,6 +92,16 @@ func (d *dataConnection) Read(ctx context.Context, config map[string]tftypes.Val workspaceId := "" diags := make([]*tfprotov5.Diagnostic, 0) + if !validConnectionName.Match([]byte(name)) { + diags = append(diags, &tfprotov5.Diagnostic{ + Severity: tfprotov5.DiagnosticSeverityError, + Summary: fmt.Sprintf("name (%s) can only contain the characters 'a'-'z', '0'-'9', '-', '_'", name), + }) + } + if len(diags) > 0 { + return nil, diags, nil + } + outputsValue := tftypes.NewValue(tftypes.Map{AttributeType: tftypes.String}, map[string]tftypes.Value{}) workspace, err := d.getConnectionWorkspace(name, type_, via) diff --git a/internal/provider/data_workspace.go b/internal/provider/data_workspace.go index e2554b3..8534e53 100644 --- a/internal/provider/data_workspace.go +++ b/internal/provider/data_workspace.go @@ -80,39 +80,6 @@ func (*dataWorkspace) Schema(ctx context.Context) *tfprotov5.Schema { } func (d *dataWorkspace) Validate(ctx context.Context, config map[string]tftypes.Value) ([]*tfprotov5.Diagnostic, error) { - diags := make([]*tfprotov5.Diagnostic, 0) - - if !config["stack"].IsNull() { - var stack string - if err := config["stack"].As(&stack); err != nil { - diags = append(diags, &tfprotov5.Diagnostic{ - Severity: tfprotov5.DiagnosticSeverityError, - Summary: err.Error(), - }) - } - } - if !config["env"].IsNull() { - var env string - if err := config["env"].As(&env); err != nil { - diags = append(diags, &tfprotov5.Diagnostic{ - Severity: tfprotov5.DiagnosticSeverityError, - Summary: err.Error(), - }) - } - } - if !config["block"].IsNull() { - var block string - if err := config["block"].As(&block); err != nil { - diags = append(diags, &tfprotov5.Diagnostic{ - Severity: tfprotov5.DiagnosticSeverityError, - Summary: err.Error(), - }) - } - } - - if len(diags) > 0 { - return diags, nil - } return nil, nil } diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 9961ea5..794f6a4 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -33,6 +33,8 @@ func New(version string, getNsConfig func() ns.Config, getTfeConfig func() *tfe. // data sources s.MustRegisterDataSource("ns_workspace", newDataWorkspace) s.MustRegisterDataSource("ns_connection", newDataConnection) + s.MustRegisterDataSource("ns_autogen_subdomain", newDataAutogenSubdomain) + s.MustRegisterResource("ns_autogen_subdomain_delegation", newResourceSubdomainDelegation) return s } diff --git a/internal/provider/resource_autogen_subdomain_delegation.go b/internal/provider/resource_autogen_subdomain_delegation.go new file mode 100644 index 0000000..1550524 --- /dev/null +++ b/internal/provider/resource_autogen_subdomain_delegation.go @@ -0,0 +1,146 @@ +package provider + +import ( + "context" + "fmt" + "github.com/hashicorp/terraform-plugin-go/tfprotov5" + "github.com/hashicorp/terraform-plugin-go/tfprotov5/tftypes" + "github.com/nullstone-io/terraform-provider-ns/internal/server" + "github.com/nullstone-io/terraform-provider-ns/ns" +) + +type resourceSubdomainDelegation struct { + p *provider +} + +func newResourceSubdomainDelegation(p *provider) (*resourceSubdomainDelegation, error) { + if p == nil { + return nil, fmt.Errorf("a provider is required") + } + return &resourceSubdomainDelegation{p: p}, nil +} + +var ( + _ server.Resource = (*resourceSubdomainDelegation)(nil) + _ server.ResourceUpdater = (*resourceSubdomainDelegation)(nil) +) + +func (r *resourceSubdomainDelegation) Schema(ctx context.Context) *tfprotov5.Schema { + return &tfprotov5.Schema{ + Block: &tfprotov5.SchemaBlock{ + Attributes: []*tfprotov5.SchemaAttribute{ + deprecatedIDAttribute(), + { + Name: "subdomain", + Required: true, + Type: tftypes.String, + Description: "Name of auto-generated subdomain that already exists in Nullstone system. This should not include `nullstone.app`.", + DescriptionKind: tfprotov5.StringKindMarkdown, + }, + { + Name: "nameservers", + Required: true, + Type: tftypes.List{ElementType: tftypes.String}, + Description: "A list of nameservers that refer to a DNS zone where this subdomain can delegate.", + DescriptionKind: tfprotov5.StringKindMarkdown, + }, + }, + }, + } +} + +func (r *resourceSubdomainDelegation) Validate(ctx context.Context, config map[string]tftypes.Value) ([]*tfprotov5.Diagnostic, error) { + return nil, nil +} + +func (r *resourceSubdomainDelegation) PlanCreate(ctx context.Context, proposed map[string]tftypes.Value, config map[string]tftypes.Value) (map[string]tftypes.Value, []*tfprotov5.Diagnostic, error) { + return r.plan(ctx, proposed) +} + +func (r *resourceSubdomainDelegation) PlanUpdate(ctx context.Context, proposed map[string]tftypes.Value, config map[string]tftypes.Value, prior map[string]tftypes.Value) (map[string]tftypes.Value, []*tfprotov5.Diagnostic, error) { + return r.plan(ctx, proposed) +} + +func (r *resourceSubdomainDelegation) plan(ctx context.Context, proposed map[string]tftypes.Value) (map[string]tftypes.Value, []*tfprotov5.Diagnostic, error) { + subdomainName := stringFromConfig(proposed, "subdomain") + + return map[string]tftypes.Value{ + "id": tftypes.NewValue(tftypes.String, subdomainName), + "subdomain": proposed["subdomain"], + "nameservers": proposed["nameservers"], + }, nil, nil +} + +func (r *resourceSubdomainDelegation) Read(ctx context.Context, config map[string]tftypes.Value) (map[string]tftypes.Value, []*tfprotov5.Diagnostic, error) { + state := map[string]tftypes.Value{} + diags := make([]*tfprotov5.Diagnostic, 0) + + subdomainName := stringFromConfig(config, "subdomain") + + delegation, err := r.p.NsClient.GetAutogenSubdomainDelegation(subdomainName) + if err != nil { + diags = append(diags, &tfprotov5.Diagnostic{ + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "error retrieving autogen subdomain delegation", + Detail: err.Error(), + }) + } else { + state["id"] = config["subdomain"] + state["subdomain"] = config["subdomain"] + state["nameservers"] = delegation.Nameservers.ToProtov5() + } + + return state, diags, nil +} + +func (r *resourceSubdomainDelegation) Create(ctx context.Context, planned map[string]tftypes.Value, config map[string]tftypes.Value, prior map[string]tftypes.Value) (state map[string]tftypes.Value, diags []*tfprotov5.Diagnostic, err error) { + return r.Update(ctx, planned, config, prior) +} + +func (r *resourceSubdomainDelegation) Update(ctx context.Context, planned map[string]tftypes.Value, config map[string]tftypes.Value, prior map[string]tftypes.Value) (map[string]tftypes.Value, []*tfprotov5.Diagnostic, error) { + state := map[string]tftypes.Value{} + diags := make([]*tfprotov5.Diagnostic, 0) + + subdomain := stringFromConfig(planned, "subdomain") + nameservers, _ := stringSliceFromConfig(planned, "nameservers") + delegation := &ns.AutogenSubdomainDelegation{Nameservers: ns.Nameservers(nameservers)} + + if result, err := r.p.NsClient.UpdateAutogenSubdomainDelegation(subdomain, delegation); err != nil { + diags = append(diags, &tfprotov5.Diagnostic{ + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "error updating autogen subdomain delegation", + Detail: err.Error(), + }) + } else if result == nil { + diags = append(diags, &tfprotov5.Diagnostic{ + Severity: tfprotov5.DiagnosticSeverityError, + Summary: fmt.Sprintf("The autogen_subdomain_delegation %q is missing.", subdomain), + }) + } else { + state["id"] = tftypes.NewValue(tftypes.String, subdomain) + state["subdomain"] = tftypes.NewValue(tftypes.String, subdomain) + state["nameservers"] = result.Nameservers.ToProtov5() + } + + return state, diags, nil +} + +func (r *resourceSubdomainDelegation) Destroy(ctx context.Context, prior map[string]tftypes.Value) ([]*tfprotov5.Diagnostic, error) { + diags := make([]*tfprotov5.Diagnostic, 0) + + subdomain := stringFromConfig(prior, "subdomain") + if found, err := r.p.NsClient.DestroyAutogenSubdomainDelegation(subdomain); err != nil { + diags = append(diags, &tfprotov5.Diagnostic{ + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "error destroying autogen subdomain delegation", + Detail: err.Error(), + }) + } else if !found { + diags = append(diags, &tfprotov5.Diagnostic{ + Severity: tfprotov5.DiagnosticSeverityError, + Summary: fmt.Sprintf("The autogen_subdomain_delegation %q is missing.", subdomain), + }) + } + + return diags, nil +} diff --git a/internal/provider/resource_autogen_subdomain_delegation_test.go b/internal/provider/resource_autogen_subdomain_delegation_test.go new file mode 100644 index 0000000..62477fe --- /dev/null +++ b/internal/provider/resource_autogen_subdomain_delegation_test.go @@ -0,0 +1,127 @@ +package provider + +import ( + "fmt" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/nullstone-io/terraform-provider-ns/ns" + "regexp" + "testing" +) + +func TestResourceSubdomainDelegation(t *testing.T) { + subdomains := map[string]map[string]*ns.AutogenSubdomain{ + "org0": { + "api": { + Id: 1, + Name: "api", + DomainName: "nullstone.app", + }, + "docs": { + Id: 2, + Name: "docs", + DomainName: "nullstone.app", + }, + }, + } + delegations := map[string]map[string]*ns.AutogenSubdomainDelegation{ + "org0": { + "docs": { + Nameservers: []string{"2.2.2.2", "3.3.3.3", "4.4.4.4"}, + }, + }, + } + + t.Run("fails to update non-existent delegation", func(t *testing.T) { + tfconfig := fmt.Sprintf(` +provider "ns" { + organization = "org0" +} +resource "ns_autogen_subdomain_delegation" "to_fake" { + subdomain = "missing" + nameservers = ["1.1.1.1","2.2.2.2","3.3.3.3"] +} +`) + + getNsConfig, closeNsFn := mockNs(mockNsServerWithAutogenSubdomains(subdomains, delegations)) + defer closeNsFn() + getTfeConfig, _ := mockTfe(nil) + + checks := resource.ComposeTestCheckFunc() + resource.UnitTest(t, resource.TestCase{ + ProtoV5ProviderFactories: protoV5ProviderFactories(getNsConfig, getTfeConfig), + Steps: []resource.TestStep{ + { + Config: tfconfig, + Check: checks, + ExpectError: regexp.MustCompile(`The autogen_subdomain_delegation "missing" is missing.`), + }, + }, + }) + }) + + t.Run("correctly updates new delegation", func(t *testing.T) { + tfconfig := fmt.Sprintf(` +provider "ns" { + organization = "org0" +} +resource "ns_autogen_subdomain_delegation" "to_fake" { + subdomain = "api" + nameservers = ["1.1.1.1","2.2.2.2","3.3.3.3"] +} +`) + + getNsConfig, closeNsFn := mockNs(mockNsServerWithAutogenSubdomains(subdomains, delegations)) + defer closeNsFn() + getTfeConfig, _ := mockTfe(nil) + + checks := resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("ns_autogen_subdomain_delegation.to_fake", `subdomain`, "api"), + resource.TestCheckResourceAttr("ns_autogen_subdomain_delegation.to_fake", `nameservers.#`, "3"), + resource.TestCheckResourceAttr("ns_autogen_subdomain_delegation.to_fake", `nameservers.0`, "1.1.1.1"), + resource.TestCheckResourceAttr("ns_autogen_subdomain_delegation.to_fake", `nameservers.1`, "2.2.2.2"), + resource.TestCheckResourceAttr("ns_autogen_subdomain_delegation.to_fake", `nameservers.2`, "3.3.3.3"), + ) + resource.UnitTest(t, resource.TestCase{ + ProtoV5ProviderFactories: protoV5ProviderFactories(getNsConfig, getTfeConfig), + Steps: []resource.TestStep{ + { + Config: tfconfig, + Check: checks, + }, + }, + }) + }) + + t.Run("correctly updates existing delegation", func(t *testing.T) { + tfconfig := fmt.Sprintf(` +provider "ns" { + organization = "org0" +} +resource "ns_autogen_subdomain_delegation" "to_fake" { + subdomain = "docs" + nameservers = ["5.5.5.5", "6.6.6.6", "7.7.7.7"] +} +`) + + getNsConfig, closeNsFn := mockNs(mockNsServerWithAutogenSubdomains(subdomains, delegations)) + defer closeNsFn() + getTfeConfig, _ := mockTfe(nil) + + checks := resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("ns_autogen_subdomain_delegation.to_fake", `subdomain`, "docs"), + resource.TestCheckResourceAttr("ns_autogen_subdomain_delegation.to_fake", `nameservers.#`, "3"), + resource.TestCheckResourceAttr("ns_autogen_subdomain_delegation.to_fake", `nameservers.0`, "5.5.5.5"), + resource.TestCheckResourceAttr("ns_autogen_subdomain_delegation.to_fake", `nameservers.1`, "6.6.6.6"), + resource.TestCheckResourceAttr("ns_autogen_subdomain_delegation.to_fake", `nameservers.2`, "7.7.7.7"), + ) + resource.UnitTest(t, resource.TestCase{ + ProtoV5ProviderFactories: protoV5ProviderFactories(getNsConfig, getTfeConfig), + Steps: []resource.TestStep{ + { + Config: tfconfig, + Check: checks, + }, + }, + }) + }) +} diff --git a/internal/server/README.md b/internal/server/README.md new file mode 100644 index 0000000..dc06a27 --- /dev/null +++ b/internal/server/README.md @@ -0,0 +1,2 @@ +This server was pulled from https://github.com/paultyng/terraform-provider-sql. +It was necessary to use this instead of the official SDK because it was impossible to create data source attribute with dynamic schema. diff --git a/ns/autogen_subdomain.go b/ns/autogen_subdomain.go new file mode 100644 index 0000000..7e465cc --- /dev/null +++ b/ns/autogen_subdomain.go @@ -0,0 +1,23 @@ +package ns + +import "github.com/hashicorp/terraform-plugin-go/tfprotov5/tftypes" + +type AutogenSubdomain struct { + Id int `json:"id"` + Name string `json:"name"` + DomainName string `json:"domainName"` +} + +type AutogenSubdomainDelegation struct { + Nameservers Nameservers `json:"nameservers"` +} + +type Nameservers []string + +func (s Nameservers) ToProtov5() tftypes.Value { + nameservers := make([]tftypes.Value, 0) + for _, nameserver := range s { + nameservers = append(nameservers, tftypes.NewValue(tftypes.String, nameserver)) + } + return tftypes.NewValue(tftypes.List{ElementType: tftypes.String}, nameservers) +} diff --git a/ns/ns_client.go b/ns/ns_client.go index 023d659..cf290b8 100644 --- a/ns/ns_client.go +++ b/ns/ns_client.go @@ -1,6 +1,7 @@ package ns import ( + "bytes" "encoding/json" "fmt" "io/ioutil" @@ -123,3 +124,139 @@ func (c *Client) GetLatestConfig(stackName string, workspaceUid uuid.UUID) (*Run } return &runConfig, nil } + +// GET /orgs/autogen_subdomains/:subdomainName +func (c *Client) GetAutogenSubdomain(subdomainName string) (*AutogenSubdomain, error) { + client := &http.Client{ + Transport: c.Config.CreateTransport(http.DefaultTransport), + } + + u, err := c.Config.ConstructUrl(path.Join("orgs", c.Org, "autogen_subdomains", subdomainName)) + if err != nil { + return nil, err + } + + res, err := client.Get(u.String()) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode == http.StatusNotFound { + return nil, nil + } + raw, _ := ioutil.ReadAll(res.Body) + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("error getting autogen subdomain (%d): %s", res.StatusCode, string(raw)) + } + + var subdomain AutogenSubdomain + if err := json.Unmarshal(raw, &subdomain); err != nil { + return nil, fmt.Errorf("invalid response getting autogen subdomain: %w", err) + } + return &subdomain, nil +} + +// GET /orgs/autogen_subdomains/:subdomainName/delegation +func (c *Client) GetAutogenSubdomainDelegation(subdomainName string) (*AutogenSubdomainDelegation, error) { + client := &http.Client{ + Transport: c.Config.CreateTransport(http.DefaultTransport), + } + + u, err := c.Config.ConstructUrl(path.Join("orgs", c.Org, "autogen_subdomains", subdomainName, "delegation")) + if err != nil { + return nil, err + } + + res, err := client.Get(u.String()) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode == http.StatusNotFound { + return nil, nil + } + raw, _ := ioutil.ReadAll(res.Body) + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("error getting autogen subdomain delegation (%d): %s", res.StatusCode, string(raw)) + } + + var delegation AutogenSubdomainDelegation + if err := json.Unmarshal(raw, &delegation); err != nil { + return nil, fmt.Errorf("invalid response getting autogen subdomain delegation: %w", err) + } + return &delegation, nil +} + +// PUT /orgs/autogen_subdomains/:subdomainId/delegation ... +func (c *Client) UpdateAutogenSubdomainDelegation(subdomainName string, delegation *AutogenSubdomainDelegation) (*AutogenSubdomainDelegation, error) { + client := &http.Client{ + Transport: c.Config.CreateTransport(http.DefaultTransport), + } + + u, err := c.Config.ConstructUrl(path.Join("orgs", c.Org, "autogen_subdomains", subdomainName, "delegation")) + if err != nil { + return nil, err + } + + rawPayload, _ := json.Marshal(delegation) + req, err := http.NewRequest(http.MethodPut, u.String(), bytes.NewReader(rawPayload)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + + res, err := client.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode == http.StatusNotFound { + return nil, nil + } + raw, _ := ioutil.ReadAll(res.Body) + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("error updating autogen subdomain delegation (%d): %s", res.StatusCode, string(raw)) + } + + var updatedDelegation AutogenSubdomainDelegation + if err := json.Unmarshal(raw, &updatedDelegation); err != nil { + return nil, fmt.Errorf("invalid response updating autogen subdomain delegation: %w", err) + } + return &updatedDelegation, nil +} + +// DELETE /orgs/autogen_subdomains/:subdomainId/delegation ... +func (c *Client) DestroyAutogenSubdomainDelegation(subdomainName string) (found bool, err error) { + client := &http.Client{ + Transport: c.Config.CreateTransport(http.DefaultTransport), + } + + u, err := c.Config.ConstructUrl(path.Join("orgs", c.Org, "autogen_subdomains", subdomainName, "delegation")) + if err != nil { + return false, err + } + + req, err := http.NewRequest(http.MethodDelete, u.String(), nil) + if err != nil { + return false, err + } + + res, err := client.Do(req) + if err != nil { + return false, err + } + defer res.Body.Close() + + if res.StatusCode == http.StatusNotFound { + return false, err + } + raw, _ := ioutil.ReadAll(res.Body) + if res.StatusCode != http.StatusOK && res.StatusCode != http.StatusNoContent { + return false, fmt.Errorf("error destroying autogen subdomain delegation (%d): %s", res.StatusCode, string(raw)) + } + + return true, nil +} diff --git a/ns/ns_config.go b/ns/ns_config.go index 6e1563d..b7cc99e 100644 --- a/ns/ns_config.go +++ b/ns/ns_config.go @@ -2,7 +2,9 @@ package ns import ( "fmt" + "log" "net/http" + "net/http/httputil" "net/url" "os" "path" @@ -12,6 +14,7 @@ var ( ApiKeyEnvVar = "NULLSTONE_API_KEY" AddressEnvVar = "NULLSTONE_ADDR" DefaultAddress = "https://api.nullstone.io" + TraceEnvVar = "NULLSTONE_TRACE" ) func NewConfig() Config { @@ -22,12 +25,16 @@ func NewConfig() Config { if val := os.Getenv(AddressEnvVar); val != "" { cfg.BaseAddress = val } + if val := os.Getenv(TraceEnvVar); val != "" { + cfg.IsTraceEnabled = true + } return cfg } type Config struct { - BaseAddress string - ApiKey string + BaseAddress string + ApiKey string + IsTraceEnabled bool } func (c *Config) ConstructUrl(reqPath string) (*url.URL, error) { @@ -40,7 +47,23 @@ func (c *Config) ConstructUrl(reqPath string) (*url.URL, error) { } func (c *Config) CreateTransport(baseTransport http.RoundTripper) http.RoundTripper { - return &apiKeyTransport{BaseTransport: baseTransport, ApiKey: c.ApiKey} + bt := baseTransport + if c.IsTraceEnabled { + bt = &tracingTransport{BaseTransport: bt} + } + return &apiKeyTransport{BaseTransport: bt, ApiKey: c.ApiKey} +} + +var _ http.RoundTripper = &tracingTransport{} + +type tracingTransport struct { + BaseTransport http.RoundTripper +} + +func (t *tracingTransport) RoundTrip(r *http.Request) (*http.Response, error) { + raw, _ := httputil.DumpRequestOut(r, true) + log.Printf("[DEBUG] %s", string(raw)) + return t.BaseTransport.RoundTrip(r) } var _ http.RoundTripper = &apiKeyTransport{} diff --git a/website/docs/d/autogen_subdomain.markdown b/website/docs/d/autogen_subdomain.markdown new file mode 100644 index 0000000..ae40ebf --- /dev/null +++ b/website/docs/d/autogen_subdomain.markdown @@ -0,0 +1,35 @@ +--- +layout: "ns" +page_title: "Nullstone: ns_autogen_subdomain" +sidebar_current: "docs-ns-autogen-subdomain" +description: |- + Data source to read a nullstone autogenerated subdomain. +--- + +# ns_autogen_subdomain + +Nullstone can generate autogen subdomains for users that look like `random-subdomain.nullstone.app`. +This data source allows users to read information about that subdomain. + +## Example Usage + +#### Example + +```hcl +data "ns_autogen_subdomain" "subdomain" { + name = var.subdomain +} + +output "subdomain_fqdn" { + value = data.ns_autogen_subdomain.fqdn +} +``` + +## Argument Reference + +- `name` - (Required) Name of auto-generated subdomain that already exists in Nullstone system. This should not include `nullstone.app`. + +## Attributes Reference + +* `domain_name` - The name of the domain that Nullstone administers for this auto-generated subdomain. +* `fqdn` - The fully-qualified domain name (FQDN) for this auto-generated subdomain. It is composed as `{name}.{domain_name}`. diff --git a/website/docs/r/autogen_subdomain_delegation.markdown b/website/docs/r/autogen_subdomain_delegation.markdown new file mode 100644 index 0000000..c6f9ec1 --- /dev/null +++ b/website/docs/r/autogen_subdomain_delegation.markdown @@ -0,0 +1,41 @@ +--- +layout: "ns" +page_title: "Nullstone: ns_autogen_subdomain_delegation" +sidebar_current: "docs-ns-autogen-subdomain-delegation" +description: |- + Resource to configure a delegation set for a nullstone autogenerated subdomain. +--- + +# ns_autogen_subdomain_delegation + +Nullstone can generate autogen subdomains for users that look like `random-subdomain.nullstone.app`. +This resource allows users to delegate that subdomain to their own DNS zone. + +The subdomain must be auto-generated through the Nullstone UI/API before delegating the subdomain. + +## Example Usage + +#### AWS Example + +```hcl +data "ns_autogen_subdomain" "subdomain" { + name = var.subdomain +} + +resource "aws_route53_zone" "this" { + name = data.ns_autogen_subdomain.subdomain.fqdn +} + +resource "ns_autogen_subdomain_delegation" "to_aws" { + subdomain = var.subdomain + nameservers = aws_route53_zone.this.name_servers +} +``` + +## Argument Reference + +- `subdomain` - (Required) Name of auto-generated subdomain that already exists in Nullstone system. This should not include `nullstone.app`. +- `nameservers` - (Required) A list of nameservers that refer to a DNS zone where this subdomain can delegate. + +## Attributes Reference +