Skip to content

Commit 3b5e630

Browse files
authored
fix: verify deletion before returning to prevent race conditions (#26)
* fix: verify deletion before returning to prevent race conditions Pi-hole's API can return 204 (success) before a delete is fully processed internally. When Terraform's ForceNew triggers a delete+create sequence, this race condition causes "item already present" errors. Changes: - Add verification loop in Delete() to poll until record is confirmed gone - Log warning when delete is called for non-existent record (stateless) - Promote terraform-plugin-log from indirect to direct dependency for tflog The verification loop polls up to 10 times (100ms intervals) to confirm the record is no longer visible via the API before returning. Fixes CI failures in TestAccLocalDNS on Pi-hole 2025.03.0. * chore: fix lint and add local tooling - Add make tools target to install golangci-lint locally to ./bin - Update lint target to auto-install tool if missing - Pin golangci-lint to v1.64.0 (matches CI) - Suppress deprecated StopContext warning with nolint directive - Regenerate docs * chore: add success message to lint target
1 parent c449620 commit 3b5e630

6 files changed

Lines changed: 77 additions & 12 deletions

File tree

Makefile

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
.PHONY: help test testall lint docs docker-run release-snapshot
1+
.PHONY: help test testall lint docs docker-run release-snapshot tools
2+
3+
# Tool versions
4+
GOLANGCI_LINT_VERSION ?= v1.64.0
5+
6+
# Local bin directory for tools
7+
BIN_DIR := $(CURDIR)/bin
8+
export PATH := $(BIN_DIR):$(PATH)
29

310
help: ## Show this help
411
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
@@ -11,8 +18,15 @@ test: ## Run unit tests
1118
testall: ## Run all tests including acceptance tests (requires PIHOLE_URL and PIHOLE_PASSWORD)
1219
TF_ACC=1 go test ./... -v $(TESTARGS) -timeout 120m
1320

14-
lint: ## Run linter
15-
golangci-lint run ./...
21+
tools: ## Install development tools locally to ./bin
22+
@mkdir -p $(BIN_DIR)
23+
GOBIN=$(BIN_DIR) go install github.com/golangci/golangci-lint/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION)
24+
25+
lint: $(BIN_DIR)/golangci-lint ## Run linter
26+
$(BIN_DIR)/golangci-lint run ./... && echo "✓ Lint passed"
27+
28+
$(BIN_DIR)/golangci-lint:
29+
@$(MAKE) tools
1630

1731
docs: ## Generate Terraform documentation
1832
go run github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs@v0.19.4

docs/resources/dns_record.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ resource "pihole_dns_record" "record" {
2929

3030
### Optional
3131

32-
- `force` (Boolean) Attempt to force record creation. Note: Pi-hole v6 API currently does not implement this for DNS endpoints, but it is included for forward compatibility with future Pi-hole versions.
32+
- `force` (Boolean) If true and the record already exists, delete it before creating the new record. Enables upsert/overwrite behavior.
3333

3434
### Read-Only
3535

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ go 1.22
44

55
require (
66
github.com/hashicorp/go-retryablehttp v0.7.7
7+
github.com/hashicorp/terraform-plugin-log v0.9.0
78
github.com/hashicorp/terraform-plugin-sdk/v2 v2.34.0
89
)
910

@@ -30,7 +31,6 @@ require (
3031
github.com/hashicorp/terraform-exec v0.21.0 // indirect
3132
github.com/hashicorp/terraform-json v0.22.1 // indirect
3233
github.com/hashicorp/terraform-plugin-go v0.23.0 // indirect
33-
github.com/hashicorp/terraform-plugin-log v0.9.0 // indirect
3434
github.com/hashicorp/terraform-registry-address v0.2.3 // indirect
3535
github.com/hashicorp/terraform-svchost v0.1.1 // indirect
3636
github.com/hashicorp/yamux v0.1.1 // indirect

internal/pihole/v6/cname.go

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"strings"
1111
"time"
1212

13+
"github.com/hashicorp/terraform-plugin-log/tflog"
1314
"github.com/poindexter12/terraform-provider-pihole/internal/pihole"
1415
)
1516

@@ -116,13 +117,17 @@ func (s *cnameService) Create(ctx context.Context, domain, target string, opts *
116117
}
117118

118119
// Delete removes a CNAME record.
119-
// Returns nil if the record doesn't exist (idempotent delete).
120+
// Returns nil if the record doesn't exist (idempotent delete, logs warning).
121+
// Verifies the record is fully deleted before returning to prevent race conditions.
120122
func (s *cnameService) Delete(ctx context.Context, domain string) error {
121123
// First get the record to find its target
122124
record, err := s.Get(ctx, domain)
123125
if err != nil {
124-
// If record not found, delete is already done
126+
// If record not found, delete is already done - warn but don't error
125127
if err == pihole.ErrCNAMENotFound {
128+
tflog.Warn(ctx, "CNAME record does not exist, nothing to delete", map[string]interface{}{
129+
"domain": domain,
130+
})
126131
return nil
127132
}
128133
return err
@@ -141,7 +146,26 @@ func (s *cnameService) Delete(ctx context.Context, domain string) error {
141146
return fmt.Errorf("unexpected status code: %d (expected 204)", resp.StatusCode)
142147
}
143148

144-
return nil
149+
// Verify the record is actually gone before returning.
150+
// Pi-hole's API can return 204 before the delete is fully processed internally,
151+
// causing race conditions when Create() is called immediately after.
152+
const maxVerifyAttempts = 10
153+
const verifyDelay = 100 * time.Millisecond
154+
for attempt := 0; attempt < maxVerifyAttempts; attempt++ {
155+
_, err := s.Get(ctx, domain)
156+
if err == pihole.ErrCNAMENotFound {
157+
// Record is confirmed deleted
158+
return nil
159+
}
160+
// Record still visible, wait and retry
161+
select {
162+
case <-ctx.Done():
163+
return ctx.Err()
164+
case <-time.After(verifyDelay):
165+
}
166+
}
167+
168+
return fmt.Errorf("delete API succeeded but CNAME record %q still visible after %d verification attempts", domain, maxVerifyAttempts)
145169
}
146170

147171
// parseCNAMEs converts "domain,target" strings to CNAMERecord structs

internal/pihole/v6/dns.go

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"strings"
1111
"time"
1212

13+
"github.com/hashicorp/terraform-plugin-log/tflog"
1314
"github.com/poindexter12/terraform-provider-pihole/internal/pihole"
1415
)
1516

@@ -126,13 +127,17 @@ func (s *dnsService) Create(ctx context.Context, domain, ip string, opts *pihole
126127
}
127128

128129
// Delete removes a DNS record.
129-
// Returns nil if the record doesn't exist (idempotent delete).
130+
// Returns nil if the record doesn't exist (idempotent delete, logs warning).
131+
// Verifies the record is fully deleted before returning to prevent race conditions.
130132
func (s *dnsService) Delete(ctx context.Context, domain string) error {
131133
// First get the record to find its IP
132134
record, err := s.Get(ctx, domain)
133135
if err != nil {
134-
// If record not found, delete is already done
136+
// If record not found, delete is already done - warn but don't error
135137
if err == pihole.ErrDNSNotFound {
138+
tflog.Warn(ctx, "DNS record does not exist, nothing to delete", map[string]interface{}{
139+
"domain": domain,
140+
})
136141
return nil
137142
}
138143
return err
@@ -151,7 +156,26 @@ func (s *dnsService) Delete(ctx context.Context, domain string) error {
151156
return fmt.Errorf("unexpected status code: %d (expected 204)", resp.StatusCode)
152157
}
153158

154-
return nil
159+
// Verify the record is actually gone before returning.
160+
// Pi-hole's API can return 204 before the delete is fully processed internally,
161+
// causing race conditions when Create() is called immediately after.
162+
const maxVerifyAttempts = 10
163+
const verifyDelay = 100 * time.Millisecond
164+
for attempt := 0; attempt < maxVerifyAttempts; attempt++ {
165+
_, err := s.Get(ctx, domain)
166+
if err == pihole.ErrDNSNotFound {
167+
// Record is confirmed deleted
168+
return nil
169+
}
170+
// Record still visible, wait and retry
171+
select {
172+
case <-ctx.Done():
173+
return ctx.Err()
174+
case <-time.After(verifyDelay):
175+
}
176+
}
177+
178+
return fmt.Errorf("delete API succeeded but record %q still visible after %d verification attempts", domain, maxVerifyAttempts)
155179
}
156180

157181
// parseDNSHosts converts "IP domain" strings to DNSRecord structs

internal/provider/provider.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,10 @@ func configure(version string, provider *schema.Provider) func(ctx context.Conte
8282
// Don't logout sessions passed in via __PIHOLE_SESSION_ID as those
8383
// are managed externally (e.g., for testing or session pooling).
8484
if externalSessionID == "" {
85-
if stopCtx, ok := schema.StopContext(ctx); ok {
85+
// StopContext is deprecated but still functional in SDK v2.
86+
// The replacement (context-aware CRUD) doesn't provide stop signals.
87+
// TODO: Remove when migrating to terraform-plugin-framework.
88+
if stopCtx, ok := schema.StopContext(ctx); ok { //nolint:staticcheck
8689
go cleanupOnStop(stopCtx, piholeClient)
8790
}
8891
}

0 commit comments

Comments
 (0)