Skip to content

Commit 763d7e7

Browse files
authored
Merge pull request #14 from veksh/sync-ops
make operations sequential to prevent races and inconsistencies
2 parents 3649cd1 + 37ec017 commit 763d7e7

File tree

6 files changed

+202
-139
lines changed

6 files changed

+202
-139
lines changed

.github/workflows/test.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,5 +87,5 @@ jobs:
8787
- run: go mod download
8888
- env:
8989
TF_ACC: "1"
90-
run: go test -v -cover ./internal/provider/
91-
timeout-minutes: 10
90+
run: go test -v -cover -timeout 30s ./internal/provider/
91+
timeout-minutes: 5

examples/multi-a/main.tf

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
terraform {
2+
required_providers {
3+
godaddy-dns = {
4+
source = "registry.terraform.io/veksh/godaddy-dns"
5+
}
6+
}
7+
}
8+
9+
provider "godaddy-dns" {}
10+
11+
locals {
12+
domain = "veksh.in"
13+
subDomain = "man-test"
14+
recName = "multi-a"
15+
dataValues = ["3.3.3.3", "4.4.4.4"]
16+
}
17+
18+
# terraform import 'godaddy-dns_record.as[0]' veksh.in:A:multi-a.man-test:1.1.1.1
19+
resource "godaddy-dns_record" "as" {
20+
domain = local.domain
21+
type = "A"
22+
name = "${local.recName}.${local.subDomain}"
23+
24+
# add is always ok, mod/del could be problematic
25+
# conservative: terraform plan -destroy -parallelism=1
26+
count = length(local.dataValues)
27+
data = local.dataValues[count.index]
28+
ttl = 600
29+
}

internal/provider/provider.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package provider
33
import (
44
"context"
55
"os"
6+
"sync"
67

78
"github.com/hashicorp/terraform-plugin-framework/datasource"
89
"github.com/hashicorp/terraform-plugin-framework/path"
@@ -25,6 +26,7 @@ type GoDaddyDNSProvider struct {
2526
// "dev" for local testing, "test" for acceptance tests, "v1.2.3" for prod
2627
version string
2728
clientFactory APIClientFactory
29+
reqMutex sync.Mutex
2830
}
2931

3032
func (p *GoDaddyDNSProvider) Metadata(ctx context.Context, req provider.MetadataRequest, resp *provider.MetadataResponse) {
@@ -108,7 +110,7 @@ func (p *GoDaddyDNSProvider) Configure(ctx context.Context, req provider.Configu
108110

109111
func (p *GoDaddyDNSProvider) Resources(ctx context.Context) []func() resource.Resource {
110112
return []func() resource.Resource{
111-
NewRecordResource,
113+
RecordResourceFactory(&p.reqMutex),
112114
}
113115
}
114116

@@ -124,6 +126,7 @@ func New(version string, clientFactory APIClientFactory) func() provider.Provide
124126
return &GoDaddyDNSProvider{
125127
version: version,
126128
clientFactory: clientFactory,
129+
reqMutex: sync.Mutex{},
127130
}
128131
}
129132
}

internal/provider/record.go

Lines changed: 66 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"slices"
77
"strings"
8+
"sync"
89

910
"github.com/hashicorp/terraform-plugin-framework-validators/int64validator"
1011
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
@@ -64,11 +65,14 @@ func tf2model(tfData tfDNSRecord) (model.DNSDomain, model.DNSRecord) {
6465

6566
// RecordResource defines the implementation of GoDaddy DNS RR
6667
type RecordResource struct {
67-
client model.DNSApiClient
68+
client model.DNSApiClient
69+
reqMutex *sync.Mutex
6870
}
6971

70-
func NewRecordResource() resource.Resource {
71-
return &RecordResource{}
72+
func RecordResourceFactory(m *sync.Mutex) func() resource.Resource {
73+
return func() resource.Resource {
74+
return &RecordResource{reqMutex: m}
75+
}
7276
}
7377

7478
func (r *RecordResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
@@ -159,6 +163,10 @@ func (r *RecordResource) Configure(ctx context.Context, req resource.ConfigureRe
159163
r.client = client
160164
}
161165

166+
// create will complain (and fail with client error) if same record is already present
167+
// (mb as a result of calling "apply" with updated config with old record already gone)
168+
// so state must be manually imported to continue (could step around this, but this will
169+
// contradict terraform ideology -- see below)
162170
func (r *RecordResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
163171
var planData tfDNSRecord
164172
resp.Diagnostics.Append(req.Plan.Get(ctx, &planData)...)
@@ -169,11 +177,17 @@ func (r *RecordResource) Create(ctx context.Context, req resource.CreateRequest,
169177
ctx = setLogCtx(ctx, planData, "create")
170178
tflog.Info(ctx, "create: start")
171179
defer tflog.Info(ctx, "create: end")
180+
r.reqMutex.Lock()
181+
defer r.reqMutex.Unlock()
172182

173183
apiDomain, apiRecPlan := tf2model(planData)
174-
// add: does not check (read) if creating w/o prior state
175-
// and so will fail on uniqueness violation (e.g. if CNAME already
176-
// exists, even with the same name); ok for us -- let API do checking
184+
// "put"/"add" does not check prior state (terraform does not provide one for Create)
185+
// and so will fail on uniqueness violation (e.g. if record already exists
186+
// after external modification, or if it is the second CNAME RR etc)
187+
// - lets think it is ok for now -- let API do checking + run "import" if required
188+
// - alt/TODO: read records and do noop if target record is already there
189+
// like `apiAllRecs, err := r.client.GetRecords(ctx, apiDomain, apiRecPlan.Type, apiRecPlan.Name)`
190+
// but lets not be silent about that
177191
err := r.client.AddRecords(ctx, apiDomain, []model.DNSRecord{apiRecPlan})
178192

179193
if err != nil {
@@ -195,6 +209,8 @@ func (r *RecordResource) Read(ctx context.Context, req resource.ReadRequest, res
195209
ctx = setLogCtx(ctx, stateData, "read")
196210
tflog.Info(ctx, "read: start")
197211
defer tflog.Info(ctx, "read: end")
212+
r.reqMutex.Lock()
213+
defer r.reqMutex.Unlock()
198214

199215
apiDomain, apiRecState := tf2model(stateData)
200216

@@ -204,11 +220,9 @@ func (r *RecordResource) Read(ctx context.Context, req resource.ReadRequest, res
204220
fmt.Sprintf("Reading DNS records: query failed: %s", err))
205221
return
206222
}
223+
numFound := 0
207224
if numRecs := len(apiAllRecs); numRecs == 0 {
208225
tflog.Debug(ctx, "Reading DNS record: currently absent")
209-
// no resource found: mb ok or need to re-create
210-
resp.State.RemoveResource(ctx)
211-
return
212226
} else {
213227
tflog.Info(ctx, fmt.Sprintf(
214228
"Reading DNS record: got %d answers", numRecs))
@@ -221,7 +235,6 @@ func (r *RecordResource) Read(ctx context.Context, req resource.ReadRequest, res
221235
// preversion and replace it with one :)
222236
// - TXT and NS for same name could differ only in TTL
223237
// - for SRV PK is proto+service+port+data, value is weight+prio+ttl
224-
numFound := 0
225238
for _, rec := range apiAllRecs {
226239
tflog.Debug(ctx, fmt.Sprintf("Got DNS record: %v", rec))
227240
if rec.SameKey(apiRecState) {
@@ -238,18 +251,31 @@ func (r *RecordResource) Read(ctx context.Context, req resource.ReadRequest, res
238251
numFound += 1
239252
}
240253
}
241-
if numFound == 0 {
242-
tflog.Info(ctx, "no matching record found")
243-
} else {
244-
if numFound > 1 {
245-
tflog.Warn(ctx, "more than one matching record found, using last")
246-
}
247-
}
248254
}
249255

250-
resp.Diagnostics.Append(resp.State.Set(ctx, &stateData)...)
256+
if numFound == 0 {
257+
// mb quite ok, e.g. on creation
258+
tflog.Info(ctx, "Resource is currently absent")
259+
resp.State.RemoveResource(ctx)
260+
} else {
261+
if numFound > 1 {
262+
// unlikely to happen (mb several MXes with the same target?)
263+
tflog.Warn(ctx, "More than one instance of a resource present")
264+
resp.Diagnostics.AddWarning(
265+
"Duplicate resource instances present",
266+
"Will use the last one")
267+
}
268+
resp.Diagnostics.Append(resp.State.Set(ctx, &stateData)...)
269+
}
251270
}
252271

272+
// updating will fail if resource is already changed externally: old record will be "gone"
273+
// after refresh, so actually "create" will be called for new one (and see above): i.e.
274+
// changing "A -> 1.1.1.1" to "A -> 2.2.2.2" first in domain and then in main.tf will
275+
// result in an error (refresh will mark it as gone and will try to create new)
276+
// so, do not do that :)
277+
// the way to settle things down in this case is "refresh" (will mark old as gone)
278+
// + "import" to new (so state will be ok)
253279
func (r *RecordResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
254280
var planData tfDNSRecord
255281
resp.Diagnostics.Append(req.Plan.Get(ctx, &planData)...)
@@ -260,6 +286,8 @@ func (r *RecordResource) Update(ctx context.Context, req resource.UpdateRequest,
260286
ctx = setLogCtx(ctx, planData, "update")
261287
tflog.Info(ctx, "update: start")
262288
defer tflog.Info(ctx, "update: end")
289+
r.reqMutex.Lock()
290+
defer r.reqMutex.Unlock()
263291

264292
apiDomain, apiRecPlan := tf2model(planData)
265293

@@ -286,20 +314,33 @@ func (r *RecordResource) Update(ctx context.Context, req resource.UpdateRequest,
286314
fmt.Sprintf("Getting DNS records to keep failed: %s", err))
287315
return
288316
}
317+
// lets try to detect the situation when old record is gone and new is present
318+
// actually this should not happen (implicit "refresh" before "apply" will remove
319+
// old record from the state), but who knows :)
320+
oldGone := false
321+
if err == errRecordGone {
322+
tflog.Info(ctx, "Current record is already gone")
323+
oldGone = true
324+
}
289325
tflog.Info(ctx, fmt.Sprintf("Got %d records to keep", len(apiUpdateRecs)))
290-
// and finally, our record (TODO: SRV has more fields)
326+
// and finally, add our record (TODO: SRV has more fields)
291327
ourRec := model.DNSUpdateRecord{
292328
Data: apiRecPlan.Data,
293329
TTL: apiRecPlan.TTL,
294330
Priority: apiRecPlan.Priority,
295331
}
296-
// TODO: probably cannot be by construction :)
297-
// and comparing them this way is wrong anyway
332+
newPresent := false
298333
if slices.Index(apiUpdateRecs, ourRec) >= 0 {
299-
tflog.Info(ctx, "Record is already present, nothing to do: done")
300-
err = nil
334+
// still need to delete old value if not gone
335+
tflog.Info(ctx, "Updated record is already present")
336+
newPresent = true
301337
} else {
302338
apiUpdateRecs = append(apiUpdateRecs, ourRec)
339+
}
340+
if oldGone && newPresent {
341+
tflog.Info(ctx, "Nothing left to do")
342+
err = nil
343+
} else {
303344
err = r.client.SetRecords(ctx, apiDomain, apiRecPlan.Type, apiRecPlan.Name, apiUpdateRecs)
304345
}
305346
}
@@ -324,6 +365,8 @@ func (r *RecordResource) Delete(ctx context.Context, req resource.DeleteRequest,
324365
ctx = setLogCtx(ctx, stateData, "delete")
325366
tflog.Info(ctx, "delete: start")
326367
defer tflog.Info(ctx, "delete: end")
368+
r.reqMutex.Lock()
369+
defer r.reqMutex.Unlock()
327370

328371
apiDomain, apiRecState := tf2model(stateData)
329372

0 commit comments

Comments
 (0)