Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,4 @@ The [LiveDNS documentation](https://api.gandi.net/docs/livedns/) states that rec

On the above example, such a design forces us to perform a `PUT` to add a new `@ 1800 IN MX 10 alt4.aspmx.l.google.com.` record instead of a simple `POST`. Thus, we can not use `POST` to add new records if there is already existing records with the same name and type.

That's why `AppendRecord` has the same behaviour than `SetRecord`. Due to these technical limitations, updating or appending records may affect the TTL of similar records which have the same name and type.
`AppendRecords` appends values to an existing rrset of the same name and type, while `SetRecords` replaces the rrset with the value(s) provided (upsert semantics). Due to these technical limitations, updating or appending records may affect the TTL of similar records which have the same name and type.
27 changes: 22 additions & 5 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import (
"github.com/libdns/libdns"
)

func (p *Provider) setRecord(ctx context.Context, zone string, rr libdns.RR, domain gandiDomain) error {
func (p *Provider) appendRecord(ctx context.Context, zone string, rr libdns.RR, domain gandiDomain) error {
p.mutex.Lock()
defer p.mutex.Unlock()

Expand Down Expand Up @@ -50,21 +50,38 @@ func (p *Provider) setRecord(ctx context.Context, zone string, rr libdns.RR, dom
RRSetValues: recValues,
}

raw, err := json.Marshal(newGandiRecord)
return p.putRecord(ctx, domain, rr.Name, rr.Type, newGandiRecord)
}

// setRecord upserts an rrset to the exact value(s) in rr (replace semantics for a single libdns record).
func (p *Provider) setRecord(ctx context.Context, zone string, rr libdns.RR, domain gandiDomain) error {
p.mutex.Lock()
defer p.mutex.Unlock()

newGandiRecord := gandiRecord{
RRSetTTL: int(rr.TTL.Seconds()),
RRSetType: rr.Type,
RRSetName: rr.Name,
RRSetValues: []string{rr.Data},
}

return p.putRecord(ctx, domain, rr.Name, rr.Type, newGandiRecord)
}

func (p *Provider) putRecord(ctx context.Context, domain gandiDomain, name, recType string, record gandiRecord) error {
raw, err := json.Marshal(record)
if err != nil {
return err
}

// we update existing record or create a new record if it does not exist yet
req, err = http.NewRequestWithContext(ctx, "PUT", fmt.Sprintf("%s/%s/%s", domain.DomainRecordsHref, rr.Name, rr.Type), bytes.NewReader(raw))
req, err := http.NewRequestWithContext(ctx, "PUT", fmt.Sprintf("%s/%s/%s", domain.DomainRecordsHref, name, recType), bytes.NewReader(raw))
if err != nil {
return err
}

req.Header.Set("Content-Type", "application/json")

_, err = p.doRequest(req, nil)

return err
}

Expand Down
275 changes: 275 additions & 0 deletions client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
package gandi

import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/netip"
"slices"
"strings"
"testing"
"time"

"github.com/libdns/libdns"
)

type roundTripFunc func(*http.Request) (*http.Response, error)

func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}

func rrsetKey(name, recType string) string {
return name + "/" + recType
}

// gandiMock simulates LiveDNS domain + per-rrset GET/PUT for client tests.
type gandiMock struct {
zone string
recordsHref string
existing map[string]gandiRecord
puts map[string]gandiRecord
}

func newGandiMock(zone string) *gandiMock {
zone = strings.TrimRight(zone, ".")
href := fmt.Sprintf("https://api.gandi.net/v5/livedns/domains/%s/records", zone)
return &gandiMock{
zone: zone,
recordsHref: href,
existing: make(map[string]gandiRecord),
puts: make(map[string]gandiRecord),
}
}

func (m *gandiMock) setExisting(name, recType string, values ...string) {
m.existing[rrsetKey(name, recType)] = gandiRecord{
RRSetName: name,
RRSetType: recType,
RRSetTTL: 300,
RRSetValues: slices.Clone(values),
}
}

func (m *gandiMock) install(t *testing.T) {
t.Helper()
original := http.DefaultTransport
t.Cleanup(func() { http.DefaultTransport = original })
http.DefaultTransport = roundTripFunc(m.roundTrip)
}

func (m *gandiMock) roundTrip(req *http.Request) (*http.Response, error) {
domainURL := fmt.Sprintf("https://api.gandi.net/v5/livedns/domains/%s", m.zone)

switch req.Method {
case http.MethodGet:
if req.URL.String() == domainURL {
body := fmt.Sprintf(`{"fqdn":%q,"domain_records_href":%q}`, m.zone, m.recordsHref)
return jsonResponse(http.StatusOK, body), nil
}
if strings.HasPrefix(req.URL.String(), m.recordsHref+"/") {
key := strings.TrimPrefix(req.URL.String(), m.recordsHref+"/")
if rec, ok := m.existing[key]; ok {
raw, err := json.Marshal(rec)
if err != nil {
return nil, err
}
return jsonResponse(http.StatusOK, string(raw)), nil
}
return jsonResponse(http.StatusNotFound, `{"code":404,"message":"record not found"}`), nil
}

case http.MethodPut:
if strings.HasPrefix(req.URL.String(), m.recordsHref+"/") {
key := strings.TrimPrefix(req.URL.String(), m.recordsHref+"/")
b, err := io.ReadAll(req.Body)
if err != nil {
return nil, err
}
var rec gandiRecord
if err := json.Unmarshal(b, &rec); err != nil {
return nil, err
}
m.puts[key] = rec
return jsonResponse(http.StatusNoContent, ""), nil
}
}

return nil, fmt.Errorf("unexpected request: %s %s", req.Method, req.URL.String())
}

func (m *gandiMock) lastPut(name, recType string) (gandiRecord, bool) {
rec, ok := m.puts[rrsetKey(name, recType)]
return rec, ok
}

func addressRecord(name string, ttl time.Duration, ip string) libdns.Address {
return libdns.Address{
Name: name,
TTL: ttl,
IP: netip.MustParseAddr(ip),
}
}

func TestSetRecords(t *testing.T) {
const ttl = time.Hour

tests := []struct {
name string
zone string
recordName string
existingValues []string
newIP string
}{
{
name: "subdomain replaces existing value",
zone: "example.com",
recordName: "www",
existingValues: []string{"203.0.113.1"},
newIP: "203.0.113.42",
},
{
name: "apex @ replaces existing value",
zone: "example.com",
recordName: "@",
existingValues: []string{"198.51.100.10"},
newIP: "198.51.100.20",
},
{
name: "no existing rrset creates single value",
zone: "example.com",
recordName: "api",
existingValues: nil,
newIP: "203.0.113.5",
},
{
name: "replaces multiple existing values with one",
zone: "example.net",
recordName: "dyn",
existingValues: []string{"203.0.113.1", "203.0.113.2"},
newIP: "203.0.113.99",
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
mock := newGandiMock(tc.zone)
if tc.existingValues != nil {
mock.setExisting(tc.recordName, "A", tc.existingValues...)
}
mock.install(t)

p := &Provider{BearerToken: "test-token"}
_, err := p.SetRecords(context.Background(), tc.zone, []libdns.Record{
addressRecord(tc.recordName, ttl, tc.newIP),
})
if err != nil {
t.Fatalf("SetRecords: %v", err)
}

sent, ok := mock.lastPut(tc.recordName, "A")
if !ok {
t.Fatal("expected PUT to LiveDNS")
}
if sent.RRSetName != tc.recordName {
t.Fatalf("rrset_name: got %q want %q", sent.RRSetName, tc.recordName)
}
if len(sent.RRSetValues) != 1 || sent.RRSetValues[0] != tc.newIP {
t.Fatalf("expected rrset %q only, got %#v", tc.newIP, sent.RRSetValues)
}
if sent.RRSetTTL != int(ttl.Seconds()) {
t.Fatalf("ttl: got %d want %d", sent.RRSetTTL, int(ttl.Seconds()))
}
})
}
}

func TestAppendRecords(t *testing.T) {
const ttl = time.Hour

tests := []struct {
name string
zone string
recordName string
existingValues []string
newIP string
wantValues []string
}{
{
name: "no existing rrset creates single value",
zone: "example.com",
recordName: "api",
existingValues: nil,
newIP: "203.0.113.5",
wantValues: []string{"203.0.113.5"},
},
{
name: "subdomain merges with existing value",
zone: "example.com",
recordName: "www",
existingValues: []string{"203.0.113.1"},
newIP: "203.0.113.42",
wantValues: []string{"203.0.113.1", "203.0.113.42"},
},
{
name: "apex @ merges with existing value",
zone: "example.com",
recordName: "@",
existingValues: []string{"198.51.100.10"},
newIP: "198.51.100.20",
wantValues: []string{"198.51.100.10", "198.51.100.20"},
},
{
name: "duplicate value is not added twice",
zone: "example.com",
recordName: "mail",
existingValues: []string{"203.0.113.10"},
newIP: "203.0.113.10",
wantValues: []string{"203.0.113.10"},
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
mock := newGandiMock(tc.zone)
if tc.existingValues != nil {
mock.setExisting(tc.recordName, "A", tc.existingValues...)
}
mock.install(t)

p := &Provider{BearerToken: "test-token"}
_, err := p.AppendRecords(context.Background(), tc.zone, []libdns.Record{
addressRecord(tc.recordName, ttl, tc.newIP),
})
if err != nil {
t.Fatalf("AppendRecords: %v", err)
}

sent, ok := mock.lastPut(tc.recordName, "A")
if !ok {
t.Fatal("expected PUT to LiveDNS")
}
if sent.RRSetName != tc.recordName {
t.Fatalf("rrset_name: got %q want %q", sent.RRSetName, tc.recordName)
}
if len(sent.RRSetValues) != len(tc.wantValues) {
t.Fatalf("values: got %#v want %#v", sent.RRSetValues, tc.wantValues)
}
for _, want := range tc.wantValues {
if !slices.Contains(sent.RRSetValues, want) {
t.Fatalf("missing %q in %#v", want, sent.RRSetValues)
}
}
})
}
}

func jsonResponse(status int, body string) *http.Response {
return &http.Response{
StatusCode: status,
Body: io.NopCloser(strings.NewReader(body)),
Header: make(http.Header),
}
}
2 changes: 1 addition & 1 deletion provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ func (p *Provider) AppendRecords(ctx context.Context, zone string, records []lib
}

for _, rec := range records {
err := p.setRecord(ctx, zone, rec.RR(), domain)
err := p.appendRecord(ctx, zone, rec.RR(), domain)
if err != nil {
return nil, err
}
Expand Down