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 `/`, "+ "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 \"/\"", 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 }