diff options
Diffstat (limited to 'provider')
| -rw-r--r-- | provider/config.go | 46 | ||||
| -rw-r--r-- | provider/dns_record.go | 240 | ||||
| -rw-r--r-- | provider/dns_record_test.go | 408 | ||||
| -rw-r--r-- | provider/fake_porkbun_test.go | 182 | ||||
| -rw-r--r-- | provider/names_test.go | 37 | ||||
| -rw-r--r-- | provider/provider.go | 29 | ||||
| -rw-r--r-- | provider/provider_test.go | 77 |
7 files changed, 1019 insertions, 0 deletions
diff --git a/provider/config.go b/provider/config.go new file mode 100644 index 0000000..956cbef --- /dev/null +++ b/provider/config.go @@ -0,0 +1,46 @@ +package provider + +import ( + "context" + "fmt" + + "github.com/pulumi/pulumi-go-provider/infer" + + "git.sr.ht/~rbdr/pulumi-porkbun/internal/porkbun" +) + +type Config struct { + APIKey string `pulumi:"apiKey,optional" provider:"secret"` + SecretAPIKey string `pulumi:"secretApiKey,optional" provider:"secret"` + BaseURL string `pulumi:"baseUrl,optional"` + + client *porkbun.Client +} + +func (c *Config) Annotate(a infer.Annotator) { + a.Describe(&c, "Configuration for the Porkbun provider.") + a.Describe(&c.APIKey, "The Porkbun API key. May also be set via the `PORKBUN_API_KEY` environment variable.") + a.Describe(&c.SecretAPIKey, "The Porkbun secret API key. May also be set via the `PORKBUN_SECRET_API_KEY` environment variable.") + a.Describe(&c.BaseURL, "The base URL of the Porkbun API. Provided for testing purpouses; defaults to the public endpoint.") + a.SetDefault(&c.APIKey, "", "PORKBUN_API_KEY") + a.SetDefault(&c.SecretAPIKey, "", "PORKBUN_SECRET_API_KEY") + a.SetDefault(&c.BaseURL, porkbun.DefaultBaseURL) +} + +func (c *Config) Configure(ctx context.Context) error { + if c.APIKey == "" || c.SecretAPIKey == "" { + return fmt.Errorf("porkbun API credentials are missing: set the porkbun:apiKey and porkbun:secretApiKey " + + "config values (e.g. `pulumi config set porkbun:apiKey --secret`) or the PORKBUN_API_KEY and " + + "PORKBUN_SECRET_API_KEY environment variables") + } + opts := []porkbun.Option{} + if c.BaseURL != "" { + opts = append(opts, porkbun.WithBaseURL(c.BaseURL)) + } + c.client = porkbun.NewClient(c.APIKey, c.SecretAPIKey, opts...) + return nil +} + +func (c Config) Client() *porkbun.Client { + return c.client +} diff --git a/provider/dns_record.go b/provider/dns_record.go new file mode 100644 index 0000000..dc2855e --- /dev/null +++ b/provider/dns_record.go @@ -0,0 +1,240 @@ +package provider + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + + p "github.com/pulumi/pulumi-go-provider" + "github.com/pulumi/pulumi-go-provider/infer" + + "git.sr.ht/~rbdr/pulumi-porkbun/internal/porkbun" +) + +const defaultTTL = 600 + +var recordTypes = map[string]bool{ + "A": true, "AAAA": true, "MX": true, "CNAME": true, "ALIAS": true, + "TXT": true, "NS": true, "SRV": true, "TLSA": true, "CAA": true, + "HTTPS": true, "SVCB": true, "SSHFP": true, +} + +type DnsRecord struct{} + +type DnsRecordArgs struct { + Domain string `pulumi:"domain"` + Name string `pulumi:"name,optional"` + Type string `pulumi:"type"` + Content string `pulumi:"content"` + TTL int `pulumi:"ttl,optional"` + Prio int `pulumi:"prio,optional"` +} + +type DnsRecordState struct { + DnsRecordArgs + Fqdn string `pulumi:"fqdn"` +} + +func (r *DnsRecord) Annotate(a infer.Annotator) { + a.Describe(&r, "A DNS record on a domain using Porkbun's nameservers.\n\n"+ + "To import an existing record, use an ID of the form `<domain>/<recordId>`, "+ + "e.g. `pulumi import porkbun:index:DnsRecord www example.com/123456789`.") +} + +func (d *DnsRecordArgs) Annotate(a infer.Annotator) { + a.Describe(&d.Domain, "The domain the record belongs to, e.g. `example.com`. Changing this forces a new record.") + a.Describe(&d.Name, "The subdomain of the record, e.g. `www`. Leave empty for the root domain. "+ + "Use `*` for a wildcard. Changing this forces a new record.") + a.Describe(&d.Type, "The DNS record type: "+strings.Join(sortedRecordTypes(), ", ")+".") + a.Describe(&d.Content, "The content (answer) of the record, e.g. an IP address for an A record.") + a.Describe(&d.TTL, "The time to live of the record in seconds. The minimum is determined by "+ + "Porkbun account settings (typically 600); values below it are rejected by the API.") + a.Describe(&d.Prio, "The priority of the record, for record types that support it (MX, SRV).") + a.SetDefault(&d.TTL, defaultTTL) + a.SetDefault(&d.Prio, 0) +} + +func (d *DnsRecordState) Annotate(a infer.Annotator) { + a.Describe(&d.Fqdn, "The fully qualified name of the record, e.g. `www.example.com`.") +} + +func (r *DnsRecord) Check(ctx context.Context, req infer.CheckRequest) (infer.CheckResponse[DnsRecordArgs], error) { + args, failures, err := infer.DefaultCheck[DnsRecordArgs](ctx, req.NewInputs) + if err != nil { + return infer.CheckResponse[DnsRecordArgs]{Inputs: args, Failures: failures}, err + } + + // DNS types are case-insensitive; normalize so diffs are stable. + args.Type = strings.ToUpper(strings.TrimSpace(args.Type)) + args.Domain = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(args.Domain)), ".") + args.Name = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(args.Name)), ".") + + if args.Domain == "" { + failures = append(failures, p.CheckFailure{Property: "domain", Reason: "domain must not be empty"}) + } + if !recordTypes[args.Type] { + failures = append(failures, p.CheckFailure{ + Property: "type", + Reason: fmt.Sprintf("%q is not a supported record type (expected one of %s)", args.Type, strings.Join(sortedRecordTypes(), ", ")), + }) + } + + if args.TTL < 0 { + failures = append(failures, p.CheckFailure{Property: "ttl", Reason: "ttl must not be negative"}) + } + if args.Prio < 0 || args.Prio > 65535 { + failures = append(failures, p.CheckFailure{Property: "prio", Reason: "prio must be between 0 and 65535"}) + } + if suffix := "." + args.Domain; strings.HasSuffix(args.Name, suffix) { + failures = append(failures, p.CheckFailure{ + Property: "name", + Reason: fmt.Sprintf("name must be the subdomain only (e.g. %q), not the fully qualified name %q", + strings.TrimSuffix(args.Name, suffix), args.Name), + }) + } + + return infer.CheckResponse[DnsRecordArgs]{Inputs: args, Failures: failures}, nil +} + +func (r *DnsRecord) Diff(ctx context.Context, req infer.DiffRequest[DnsRecordArgs, DnsRecordState]) (infer.DiffResponse, error) { + diff := map[string]p.PropertyDiff{} + if req.Inputs.Domain != req.State.Domain { + diff["domain"] = p.PropertyDiff{Kind: p.UpdateReplace, InputDiff: true} + } + if req.Inputs.Name != req.State.Name { + diff["name"] = p.PropertyDiff{Kind: p.UpdateReplace, InputDiff: true} + } + if req.Inputs.Type != req.State.Type { + diff["type"] = p.PropertyDiff{Kind: p.Update, InputDiff: true} + } + if req.Inputs.Content != req.State.Content { + diff["content"] = p.PropertyDiff{Kind: p.Update, InputDiff: true} + } + if req.Inputs.TTL != req.State.TTL { + diff["ttl"] = p.PropertyDiff{Kind: p.Update, InputDiff: true} + } + if req.Inputs.Prio != req.State.Prio { + diff["prio"] = p.PropertyDiff{Kind: p.Update, InputDiff: true} + } + return p.DiffResponse{HasChanges: len(diff) > 0, DetailedDiff: diff}, nil +} + +func (r *DnsRecord) Create(ctx context.Context, req infer.CreateRequest[DnsRecordArgs]) (infer.CreateResponse[DnsRecordState], error) { + state := newState(req.Inputs) + if req.DryRun { + return infer.CreateResponse[DnsRecordState]{Output: state}, nil + } + + client := infer.GetConfig[Config](ctx).Client() + id, err := client.CreateRecord(ctx, req.Inputs.Domain, recordSpec(req.Inputs)) + if err != nil { + return infer.CreateResponse[DnsRecordState]{}, fmt.Errorf("creating %s record %q on %q: %w", + req.Inputs.Type, req.Inputs.Name, req.Inputs.Domain, err) + } + return infer.CreateResponse[DnsRecordState]{ID: id, Output: state}, nil +} + +func (r *DnsRecord) Update(ctx context.Context, req infer.UpdateRequest[DnsRecordArgs, DnsRecordState]) (infer.UpdateResponse[DnsRecordState], error) { + state := newState(req.Inputs) + if req.DryRun { + return infer.UpdateResponse[DnsRecordState]{Output: state}, nil + } + + client := infer.GetConfig[Config](ctx).Client() + if err := client.EditRecord(ctx, req.Inputs.Domain, req.ID, recordSpec(req.Inputs)); err != nil { + return infer.UpdateResponse[DnsRecordState]{}, fmt.Errorf("updating DNS record %s on %q: %w", + req.ID, req.Inputs.Domain, err) + } + return infer.UpdateResponse[DnsRecordState]{Output: state}, nil +} + +func (r *DnsRecord) Delete(ctx context.Context, req infer.DeleteRequest[DnsRecordState]) (infer.DeleteResponse, error) { + client := infer.GetConfig[Config](ctx).Client() + err := client.DeleteRecord(ctx, req.State.Domain, req.ID) + if err == nil { + return infer.DeleteResponse{}, nil + } + + var apiErr *porkbun.APIError + if errors.As(err, &apiErr) { + if rec, lookupErr := client.GetRecord(ctx, req.State.Domain, req.ID); lookupErr == nil && rec == nil { + return infer.DeleteResponse{}, nil + } + } + return infer.DeleteResponse{}, fmt.Errorf("deleting DNS record %s on %q: %w", req.ID, req.State.Domain, err) +} + +func (r *DnsRecord) Read(ctx context.Context, req infer.ReadRequest[DnsRecordArgs, DnsRecordState]) (infer.ReadResponse[DnsRecordArgs, DnsRecordState], error) { + id := req.ID + domain := req.State.Domain + if before, after, found := strings.Cut(id, "/"); found { + domain, id = strings.ToLower(before), after + } + if domain == "" || id == "" { + return infer.ReadResponse[DnsRecordArgs, DnsRecordState]{}, fmt.Errorf( + "cannot read DNS record %q: unknown domain; to import a record use the ID form \"<domain>/<recordId>\"", req.ID) + } + + client := infer.GetConfig[Config](ctx).Client() + rec, err := client.GetRecord(ctx, domain, id) + if err != nil { + return infer.ReadResponse[DnsRecordArgs, DnsRecordState]{}, fmt.Errorf("reading DNS record %s on %q: %w", id, domain, err) + } + if rec == nil { + return infer.ReadResponse[DnsRecordArgs, DnsRecordState]{}, nil + } + + args := DnsRecordArgs{ + Domain: domain, + Name: subdomain(rec.Name, domain), + Type: rec.Type, + Content: rec.Content, + TTL: rec.TTL, + Prio: rec.Prio, + } + return infer.ReadResponse[DnsRecordArgs, DnsRecordState]{ + ID: id, + Inputs: args, + State: newState(args), + }, nil +} + +func recordSpec(args DnsRecordArgs) porkbun.RecordSpec { + return porkbun.RecordSpec{ + Name: args.Name, + Type: args.Type, + Content: args.Content, + TTL: args.TTL, + Prio: args.Prio, + } +} + +func newState(args DnsRecordArgs) DnsRecordState { + return DnsRecordState{DnsRecordArgs: args, Fqdn: fqdn(args.Name, args.Domain)} +} + +func fqdn(name, domain string) string { + if name == "" { + return domain + } + return name + "." + domain +} + +func subdomain(fullName, domain string) string { + fullName = strings.TrimSuffix(strings.ToLower(fullName), ".") + if fullName == domain { + return "" + } + return strings.TrimSuffix(fullName, "."+domain) +} + +func sortedRecordTypes() []string { + types := make([]string, 0, len(recordTypes)) + for t := range recordTypes { + types = append(types, t) + } + sort.Strings(types) + return types +} diff --git a/provider/dns_record_test.go b/provider/dns_record_test.go new file mode 100644 index 0000000..deaea67 --- /dev/null +++ b/provider/dns_record_test.go @@ -0,0 +1,408 @@ +package provider_test + +import ( + "context" + "net/http/httptest" + "testing" + + "github.com/blang/semver" + p "github.com/pulumi/pulumi-go-provider" + "github.com/pulumi/pulumi-go-provider/integration" + presource "github.com/pulumi/pulumi/sdk/v3/go/common/resource" + "github.com/pulumi/pulumi/sdk/v3/go/property" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "git.sr.ht/~rbdr/pulumi-porkbun/provider" +) + +const dnsRecordToken = "porkbun:index:DnsRecord" + +var testURN = presource.NewURN("test", "provider", "", dnsRecordToken, "test") + +func newTestServer(t *testing.T, fake *fakePorkbun) integration.Server { + t.Helper() + httpSrv := httptest.NewServer(fake) + t.Cleanup(httpSrv.Close) + + server := newIntegrationServer(t) + require.NoError(t, server.Configure(p.ConfigureRequest{ + Args: property.NewMap(map[string]property.Value{ + "apiKey": property.New(testAPIKey), + "secretApiKey": property.New(testSecretKey), + "baseUrl": property.New(httpSrv.URL), + }), + })) + return server +} + +func newIntegrationServer(t *testing.T) integration.Server { + t.Helper() + prov, err := provider.New() + require.NoError(t, err) + server, err := integration.NewServer(context.Background(), provider.Name, + semver.MustParse("1.0.0"), integration.WithProvider(prov)) + require.NoError(t, err) + return server +} + +func recordInputs(overrides map[string]property.Value) property.Map { + inputs := map[string]property.Value{ + "domain": property.New("example.com"), + "name": property.New("www"), + "type": property.New("A"), + "content": property.New("192.0.2.1"), + } + for k, v := range overrides { + inputs[k] = v + } + return property.NewMap(inputs) +} + +func checkedValues(overrides map[string]property.Value, extra map[string]property.Value) property.Map { + values := map[string]property.Value{ + "domain": property.New("example.com"), + "name": property.New("www"), + "type": property.New("A"), + "content": property.New("192.0.2.1"), + "ttl": property.New(600.0), + "prio": property.New(0.0), + } + for k, v := range extra { + values[k] = v + } + for k, v := range overrides { + values[k] = v + } + return property.NewMap(values) +} + +func checkedInputs(overrides map[string]property.Value) property.Map { + return checkedValues(overrides, nil) +} + +func checkedState(overrides map[string]property.Value) property.Map { + return checkedValues(overrides, map[string]property.Value{ + "fqdn": property.New("www.example.com"), + }) +} + +func TestDnsRecordLifecycle(t *testing.T) { + fake := newFakePorkbun(t) + server := newTestServer(t, fake) + + expectCreate := checkedState(nil) + expectContentUpdate := checkedState(map[string]property.Value{ + "content": property.New("192.0.2.2"), + }) + expectRename := checkedState(map[string]property.Value{ + "content": property.New("192.0.2.2"), + "name": property.New("api"), + "fqdn": property.New("api.example.com"), + }) + + integration.LifeCycleTest{ + Resource: dnsRecordToken, + Create: integration.Operation{ + Inputs: recordInputs(nil), + ExpectedOutput: &expectCreate, + Hook: func(inputs, output property.Map) { + assert.Equal(t, 1, fake.recordCount(), "exactly one record after create") + }, + }, + Updates: []integration.Operation{ + { + Inputs: recordInputs(map[string]property.Value{"content": property.New("192.0.2.2")}), + ExpectedOutput: &expectContentUpdate, + }, + { + Inputs: recordInputs(map[string]property.Value{ + "content": property.New("192.0.2.2"), + "name": property.New("api"), + }), + ExpectedOutput: &expectRename, + Hook: func(inputs, output property.Map) { + assert.Equal(t, 2, fake.recordCount(), "replacement creates the new record before deleting the old") + }, + }, + }, + }.Run(t, server) + + assert.Equal(t, 0, fake.recordCount(), "destroy must remove the record") + assert.Equal(t, 2, fake.createCalls, "one create plus one replacement") + assert.Equal(t, 1, fake.editCalls, "content change should edit in place") + assert.Equal(t, 2, fake.deleteCalls, "replacement delete plus final destroy") +} + +func TestCreateStoresPorkbunID(t *testing.T) { + fake := newFakePorkbun(t) + server := newTestServer(t, fake) + + resp, err := server.Create(p.CreateRequest{ + Urn: testURN, + Properties: checkedInputs(nil), + }) + require.NoError(t, err) + assert.NotEmpty(t, resp.ID) + require.NotNil(t, fake.record("example.com", resp.ID), "resource ID must be the Porkbun record ID") + + rec := fake.record("example.com", resp.ID) + assert.Equal(t, "www", rec.name) + assert.Equal(t, "A", rec.typ) + assert.Equal(t, "192.0.2.1", rec.content) + assert.Equal(t, 600, rec.ttl) +} + +func TestCreatePreviewMakesNoAPICalls(t *testing.T) { + fake := newFakePorkbun(t) + server := newTestServer(t, fake) + + resp, err := server.Create(p.CreateRequest{ + Urn: testURN, + Properties: checkedInputs(nil), + DryRun: true, + }) + require.NoError(t, err) + assert.Equal(t, 0, fake.createCalls, "preview must not create records") + assert.Equal(t, property.New("www"), resp.Properties.Get("name"), "inputs pass through the preview") + assert.Equal(t, property.New(property.Computed), resp.Properties.Get("fqdn"), + "computed outputs are unknown during preview") +} + +func TestUpdatePreviewMakesNoAPICalls(t *testing.T) { + fake := newFakePorkbun(t) + server := newTestServer(t, fake) + id := fake.seed("example.com", fakeRecord{name: "www", typ: "A", content: "192.0.2.1", ttl: 600}) + + _, err := server.Update(p.UpdateRequest{ + ID: id, + Urn: testURN, + State: checkedState(nil), + Inputs: checkedInputs(map[string]property.Value{"content": property.New("192.0.2.9")}), + DryRun: true, + }) + require.NoError(t, err) + assert.Equal(t, 0, fake.editCalls, "preview must not edit records") + assert.Equal(t, "192.0.2.1", fake.record("example.com", id).content) +} + +func TestDiff(t *testing.T) { + server := newTestServer(t, newFakePorkbun(t)) + + diff := func(newInputs property.Map) p.DiffResponse { + resp, err := server.Diff(p.DiffRequest{ + ID: "42", + Urn: testURN, + State: checkedState(nil), + Inputs: newInputs, + }) + require.NoError(t, err) + return resp + } + + t.Run("no changes", func(t *testing.T) { + resp := diff(checkedInputs(nil)) + assert.False(t, resp.HasChanges) + }) + + t.Run("content and ttl update in place", func(t *testing.T) { + resp := diff(checkedInputs(map[string]property.Value{ + "content": property.New("192.0.2.9"), + "ttl": property.New(900.0), + })) + assert.True(t, resp.HasChanges) + assert.Equal(t, p.Update, resp.DetailedDiff["content"].Kind) + assert.Equal(t, p.Update, resp.DetailedDiff["ttl"].Kind) + }) + + t.Run("domain change forces replacement", func(t *testing.T) { + resp := diff(checkedInputs(map[string]property.Value{ + "domain": property.New("example.org"), + })) + assert.True(t, resp.HasChanges) + assert.Equal(t, p.UpdateReplace, resp.DetailedDiff["domain"].Kind) + }) + + t.Run("name change forces replacement", func(t *testing.T) { + resp := diff(checkedInputs(map[string]property.Value{ + "name": property.New("api"), + })) + assert.True(t, resp.HasChanges) + assert.Equal(t, p.UpdateReplace, resp.DetailedDiff["name"].Kind) + }) + + t.Run("type change updates in place", func(t *testing.T) { + resp := diff(checkedInputs(map[string]property.Value{ + "type": property.New("CNAME"), + "content": property.New("target.example.net"), + })) + assert.True(t, resp.HasChanges) + assert.Equal(t, p.Update, resp.DetailedDiff["type"].Kind) + }) +} + +func TestCheck(t *testing.T) { + server := newTestServer(t, newFakePorkbun(t)) + + check := func(inputs property.Map) p.CheckResponse { + resp, err := server.Check(p.CheckRequest{Urn: testURN, Inputs: inputs}) + require.NoError(t, err) + return resp + } + + failedProperties := func(resp p.CheckResponse) []string { + props := []string{} + for _, f := range resp.Failures { + props = append(props, f.Property) + } + return props + } + + t.Run("applies defaults", func(t *testing.T) { + resp := check(recordInputs(nil)) + assert.Empty(t, resp.Failures) + assert.Equal(t, property.New(600.0), resp.Inputs.Get("ttl")) + assert.Equal(t, property.New(0.0), resp.Inputs.Get("prio")) + }) + + t.Run("normalizes case", func(t *testing.T) { + resp := check(recordInputs(map[string]property.Value{ + "type": property.New("a"), + "domain": property.New("EXAMPLE.com"), + "name": property.New("WWW"), + })) + assert.Empty(t, resp.Failures) + assert.Equal(t, property.New("A"), resp.Inputs.Get("type")) + assert.Equal(t, property.New("example.com"), resp.Inputs.Get("domain")) + assert.Equal(t, property.New("www"), resp.Inputs.Get("name")) + }) + + t.Run("rejects unknown record type", func(t *testing.T) { + resp := check(recordInputs(map[string]property.Value{"type": property.New("BOGUS")})) + assert.Equal(t, []string{"type"}, failedProperties(resp)) + }) + + t.Run("accepts ttl below the typical minimum", func(t *testing.T) { + resp := check(recordInputs(map[string]property.Value{"ttl": property.New(300.0)})) + assert.Empty(t, resp.Failures) + assert.Equal(t, property.New(300.0), resp.Inputs.Get("ttl")) + }) + + t.Run("rejects negative ttl", func(t *testing.T) { + resp := check(recordInputs(map[string]property.Value{"ttl": property.New(-1.0)})) + assert.Equal(t, []string{"ttl"}, failedProperties(resp)) + }) + + t.Run("rejects out of range prio", func(t *testing.T) { + resp := check(recordInputs(map[string]property.Value{"prio": property.New(70000.0)})) + assert.Equal(t, []string{"prio"}, failedProperties(resp)) + }) + + t.Run("rejects fully qualified name", func(t *testing.T) { + resp := check(recordInputs(map[string]property.Value{"name": property.New("www.example.com")})) + assert.Equal(t, []string{"name"}, failedProperties(resp)) + }) +} + +func TestReadRefreshesDrift(t *testing.T) { + fake := newFakePorkbun(t) + server := newTestServer(t, fake) + id := fake.seed("example.com", fakeRecord{name: "www", typ: "A", content: "192.0.2.99", ttl: 900}) + + resp, err := server.Read(p.ReadRequest{ + ID: id, + Urn: testURN, + Properties: checkedState(nil), + }) + require.NoError(t, err) + assert.Equal(t, id, resp.ID) + assert.Equal(t, property.New("192.0.2.99"), resp.Properties.Get("content")) + assert.Equal(t, property.New(900.0), resp.Properties.Get("ttl")) + assert.Equal(t, property.New("www"), resp.Properties.Get("name")) + assert.Equal(t, property.New("www.example.com"), resp.Properties.Get("fqdn")) +} + +func TestReadRootRecord(t *testing.T) { + fake := newFakePorkbun(t) + server := newTestServer(t, fake) + id := fake.seed("example.com", fakeRecord{name: "", typ: "A", content: "192.0.2.1", ttl: 600}) + + resp, err := server.Read(p.ReadRequest{ + ID: id, + Urn: testURN, + Properties: checkedState(map[string]property.Value{ + "name": property.New(""), + "fqdn": property.New("example.com"), + }), + }) + require.NoError(t, err) + assert.Equal(t, property.New(""), resp.Properties.Get("name")) + assert.Equal(t, property.New("example.com"), resp.Properties.Get("fqdn")) +} + +func TestReadDeletedRecord(t *testing.T) { + fake := newFakePorkbun(t) + server := newTestServer(t, fake) + + resp, err := server.Read(p.ReadRequest{ + ID: "9999", + Urn: testURN, + Properties: checkedState(nil), + }) + require.NoError(t, err) + assert.Empty(t, resp.ID, "an empty ID signals the record no longer exists") +} + +func TestImportWithCompositeID(t *testing.T) { + fake := newFakePorkbun(t) + server := newTestServer(t, fake) + id := fake.seed("example.com", fakeRecord{name: "mail", typ: "MX", content: "mx.example.com", ttl: 600, prio: 10}) + + resp, err := server.Read(p.ReadRequest{ + ID: "example.com/" + id, + Urn: testURN, + }) + require.NoError(t, err) + assert.Equal(t, id, resp.ID, "the canonical ID is the bare record ID") + assert.Equal(t, property.New("example.com"), resp.Properties.Get("domain")) + assert.Equal(t, property.New("mail"), resp.Properties.Get("name")) + assert.Equal(t, property.New("MX"), resp.Properties.Get("type")) + assert.Equal(t, property.New(10.0), resp.Properties.Get("prio")) + assert.Equal(t, property.New("mail.example.com"), resp.Properties.Get("fqdn")) +} + +func TestImportWithoutDomainFails(t *testing.T) { + server := newTestServer(t, newFakePorkbun(t)) + + _, err := server.Read(p.ReadRequest{ID: "12345", Urn: testURN}) + require.Error(t, err) + assert.Contains(t, err.Error(), "<domain>/<recordId>") +} + +func TestDeleteIsIdempotent(t *testing.T) { + fake := newFakePorkbun(t) + server := newTestServer(t, fake) + + err := server.Delete(p.DeleteRequest{ + ID: "4242", + Urn: testURN, + Properties: checkedState(nil), + }) + require.NoError(t, err) + assert.Equal(t, 1, fake.deleteCalls) + assert.Equal(t, 1, fake.retrieveCalls, "a failed delete should be verified with a lookup") +} + +func TestUpdateSurfacesAPIErrors(t *testing.T) { + fake := newFakePorkbun(t) + server := newTestServer(t, fake) + + _, err := server.Update(p.UpdateRequest{ + ID: "4242", + Urn: testURN, + State: checkedState(nil), + Inputs: checkedInputs(map[string]property.Value{"content": property.New("192.0.2.9")}), + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "Invalid record ID.") +} diff --git a/provider/fake_porkbun_test.go b/provider/fake_porkbun_test.go new file mode 100644 index 0000000..a6a602d --- /dev/null +++ b/provider/fake_porkbun_test.go @@ -0,0 +1,182 @@ +package provider_test + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "sync" + "testing" +) + +const ( + testAPIKey = "pk1_test" + testSecretKey = "sk1_test" +) + +type fakeRecord struct { + name string + typ string + content string + ttl int + prio int +} + +type fakePorkbun struct { + t *testing.T + + mu sync.Mutex + nextID int + records map[string]map[string]*fakeRecord + + createCalls, editCalls, deleteCalls, retrieveCalls int +} + +func newFakePorkbun(t *testing.T) *fakePorkbun { + return &fakePorkbun{t: t, nextID: 1000, records: map[string]map[string]*fakeRecord{}} +} + +func (f *fakePorkbun) seed(domain string, rec fakeRecord) string { + f.mu.Lock() + defer f.mu.Unlock() + f.nextID++ + id := strconv.Itoa(f.nextID) + if f.records[domain] == nil { + f.records[domain] = map[string]*fakeRecord{} + } + f.records[domain][id] = &rec + return id +} + +func (f *fakePorkbun) record(domain, id string) *fakeRecord { + f.mu.Lock() + defer f.mu.Unlock() + rec, ok := f.records[domain][id] + if !ok { + return nil + } + cp := *rec + return &cp +} + +func (f *fakePorkbun) recordCount() int { + f.mu.Lock() + defer f.mu.Unlock() + n := 0 + for _, recs := range f.records { + n += len(recs) + } + return n +} + +func (f *fakePorkbun) ServeHTTP(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + f.fail(w, http.StatusBadRequest, "Invalid JSON.") + return + } + if body["apikey"] != testAPIKey || body["secretapikey"] != testSecretKey { + f.fail(w, http.StatusForbidden, "Invalid API key. (002)") + return + } + + parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") + switch { + case len(parts) == 1 && parts[0] == "ping": + f.ok(w, map[string]any{"yourIp": "203.0.113.7"}) + + case len(parts) == 3 && parts[0] == "dns" && parts[1] == "create": + f.createCalls++ + domain := parts[2] + f.nextID++ + id := strconv.Itoa(f.nextID) + if f.records[domain] == nil { + f.records[domain] = map[string]*fakeRecord{} + } + f.records[domain][id] = &fakeRecord{ + name: str(body["name"]), + typ: str(body["type"]), + content: str(body["content"]), + ttl: atoi(f.t, str(body["ttl"])), + prio: atoi(f.t, str(body["prio"])), + } + + f.ok(w, map[string]any{"id": f.nextID}) + + case len(parts) == 4 && parts[0] == "dns" && parts[1] == "edit": + f.editCalls++ + rec, ok := f.records[parts[2]][parts[3]] + if !ok { + f.fail(w, http.StatusBadRequest, "Invalid record ID.") + return + } + rec.typ = str(body["type"]) + rec.content = str(body["content"]) + rec.ttl = atoi(f.t, str(body["ttl"])) + rec.prio = atoi(f.t, str(body["prio"])) + f.ok(w, nil) + + case len(parts) == 4 && parts[0] == "dns" && parts[1] == "delete": + f.deleteCalls++ + if _, ok := f.records[parts[2]][parts[3]]; !ok { + f.fail(w, http.StatusBadRequest, "Invalid record ID.") + return + } + delete(f.records[parts[2]], parts[3]) + f.ok(w, nil) + + case len(parts) == 4 && parts[0] == "dns" && parts[1] == "retrieve": + f.retrieveCalls++ + domain, id := parts[2], parts[3] + records := []map[string]any{} + if rec, ok := f.records[domain][id]; ok { + fullName := domain + if rec.name != "" { + fullName = rec.name + "." + domain + } + + records = append(records, map[string]any{ + "id": id, "name": fullName, "type": rec.typ, "content": rec.content, + "ttl": strconv.Itoa(rec.ttl), "prio": strconv.Itoa(rec.prio), "notes": "", + }) + } + f.ok(w, map[string]any{"records": records}) + + default: + f.fail(w, http.StatusNotFound, fmt.Sprintf("Unknown endpoint %q.", r.URL.Path)) + } +} + +func (f *fakePorkbun) ok(w http.ResponseWriter, extra map[string]any) { + resp := map[string]any{"status": "SUCCESS"} + for k, v := range extra { + resp[k] = v + } + json.NewEncoder(w).Encode(resp) +} + +func (f *fakePorkbun) fail(w http.ResponseWriter, code int, message string) { + w.WriteHeader(code) + json.NewEncoder(w).Encode(map[string]any{"status": "ERROR", "message": message}) +} + +func str(v any) string { + s, _ := v.(string) + return s +} + +func atoi(t *testing.T, s string) int { + t.Helper() + if s == "" { + return 0 + } + n, err := strconv.Atoi(s) + if err != nil { + t.Errorf("fake porkbun: expected numeric string, got %q", s) + } + return n +} diff --git a/provider/names_test.go b/provider/names_test.go new file mode 100644 index 0000000..ee2dd86 --- /dev/null +++ b/provider/names_test.go @@ -0,0 +1,37 @@ +package provider + +import "testing" + +func TestFqdn(t *testing.T) { + cases := []struct { + name, domain, want string + }{ + {"www", "example.com", "www.example.com"}, + {"", "example.com", "example.com"}, + {"*", "example.com", "*.example.com"}, + {"a.b", "example.com", "a.b.example.com"}, + } + for _, c := range cases { + if got := fqdn(c.name, c.domain); got != c.want { + t.Errorf("fqdn(%q, %q) = %q, want %q", c.name, c.domain, got, c.want) + } + } +} + +func TestSubdomain(t *testing.T) { + cases := []struct { + fullName, domain, want string + }{ + {"www.example.com", "example.com", "www"}, + {"example.com", "example.com", ""}, + {"WWW.EXAMPLE.COM", "example.com", "www"}, + {"www.example.com.", "example.com", "www"}, + {"a.b.example.com", "example.com", "a.b"}, + {"*.example.com", "example.com", "*"}, + } + for _, c := range cases { + if got := subdomain(c.fullName, c.domain); got != c.want { + t.Errorf("subdomain(%q, %q) = %q, want %q", c.fullName, c.domain, got, c.want) + } + } +} diff --git a/provider/provider.go b/provider/provider.go new file mode 100644 index 0000000..8545bf8 --- /dev/null +++ b/provider/provider.go @@ -0,0 +1,29 @@ +package provider + +import ( + p "github.com/pulumi/pulumi-go-provider" + "github.com/pulumi/pulumi-go-provider/infer" + "github.com/pulumi/pulumi/sdk/v3/go/common/tokens" +) + +const Name = "porkbun" + +var Version = "1.0.0" + +func New() (p.Provider, error) { + return infer.NewProviderBuilder(). + WithDisplayName("Porkbun"). + WithDescription("A Pulumi provider for managing DNS records on Porkbun."). + WithHomepage("https://git.sr.ht/~rbdr/pulumi-porkbun"). + WithRepository("https://git.sr.ht/~rbdr/pulumi-porkbun"). + WithLicense("Apache-2.0"). + WithKeywords("pulumi", "porkbun", "dns", "category/network"). + WithNamespace("rbdr"). + WithGoImportPath("git.sr.ht/~rbdr/pulumi-porkbun/sdk/go/porkbun"). + WithConfig(infer.Config(&Config{})). + WithResources( + infer.Resource(&DnsRecord{}), + ). + WithModuleMap(map[tokens.ModuleName]tokens.ModuleName{"provider": "index"}). + Build() +} diff --git a/provider/provider_test.go b/provider/provider_test.go new file mode 100644 index 0000000..ade33a6 --- /dev/null +++ b/provider/provider_test.go @@ -0,0 +1,77 @@ +package provider_test + +import ( + "encoding/json" + "net/http/httptest" + "testing" + + p "github.com/pulumi/pulumi-go-provider" + "github.com/pulumi/pulumi/sdk/v3/go/property" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConfigureRequiresCredentials(t *testing.T) { + + t.Setenv("PORKBUN_API_KEY", "") + t.Setenv("PORKBUN_SECRET_API_KEY", "") + + server := newIntegrationServer(t) + err := server.Configure(p.ConfigureRequest{Args: property.Map{}}) + require.Error(t, err) + assert.Contains(t, err.Error(), "PORKBUN_API_KEY") +} + +func TestConfigureReadsCredentialsFromEnvironment(t *testing.T) { + fake := newFakePorkbun(t) + httpSrv := httptest.NewServer(fake) + t.Cleanup(httpSrv.Close) + + t.Setenv("PORKBUN_API_KEY", testAPIKey) + t.Setenv("PORKBUN_SECRET_API_KEY", testSecretKey) + + server := newIntegrationServer(t) + checked, err := server.CheckConfig(p.CheckRequest{ + Inputs: property.NewMap(map[string]property.Value{ + "baseUrl": property.New(httpSrv.URL), + }), + }) + require.NoError(t, err) + require.NoError(t, server.Configure(p.ConfigureRequest{Args: checked.Inputs})) + + resp, err := server.Create(p.CreateRequest{ + Urn: testURN, + Properties: checkedInputs(nil), + }) + require.NoError(t, err) + assert.NotNil(t, fake.record("example.com", resp.ID)) +} + +func TestSchema(t *testing.T) { + server := newIntegrationServer(t) + resp, err := server.GetSchema(p.GetSchemaRequest{}) + require.NoError(t, err) + + var schema struct { + Name string `json:"name"` + Config struct { + Variables map[string]struct { + Secret bool `json:"secret"` + } `json:"variables"` + } `json:"config"` + Resources map[string]struct { + RequiredInputs []string `json:"requiredInputs"` + } `json:"resources"` + } + require.NoError(t, json.Unmarshal([]byte(resp.Schema), &schema)) + + assert.Equal(t, "porkbun", schema.Name) + require.Contains(t, schema.Resources, dnsRecordToken) + assert.ElementsMatch(t, []string{"domain", "type", "content"}, + schema.Resources[dnsRecordToken].RequiredInputs) + + require.Contains(t, schema.Config.Variables, "apiKey") + require.Contains(t, schema.Config.Variables, "secretApiKey") + assert.True(t, schema.Config.Variables["apiKey"].Secret, "apiKey must be marked secret") + assert.True(t, schema.Config.Variables["secretApiKey"].Secret, "secretApiKey must be marked secret") +} |