1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
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
}
|