diff options
| author | Ruben Beltran del Rio <jj@r.bdr.sh> | 2026-07-03 15:21:56 +0200 |
|---|---|---|
| committer | Ruben Beltran del Rio <jj@r.bdr.sh> | 2026-07-03 15:21:58 +0200 |
| commit | 95dc66cb108a311065917362a00b86c08767303d (patch) | |
| tree | 46ca65be6548224be0dd5a42bb86f2ecede14da8 /provider/dns_record.go | |
Diffstat (limited to 'provider/dns_record.go')
| -rw-r--r-- | provider/dns_record.go | 240 |
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 +} |