aboutsummaryrefslogtreecommitdiff
path: root/provider
diff options
context:
space:
mode:
Diffstat (limited to 'provider')
-rw-r--r--provider/config.go46
-rw-r--r--provider/dns_record.go240
-rw-r--r--provider/dns_record_test.go408
-rw-r--r--provider/fake_porkbun_test.go182
-rw-r--r--provider/names_test.go37
-rw-r--r--provider/provider.go29
-rw-r--r--provider/provider_test.go77
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")
+}