aboutsummaryrefslogtreecommitdiff
path: root/provider/dns_record.go
diff options
context:
space:
mode:
Diffstat (limited to 'provider/dns_record.go')
-rw-r--r--provider/dns_record.go240
1 files changed, 240 insertions, 0 deletions
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
+}