Skip to content
Draft
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
3 changes: 1 addition & 2 deletions internal/controller/account_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ import (

metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"

cloudflare "github.com/cloudflare/cloudflare-go/v7"
cloudflareoperatoriov1 "github.com/containeroo/cloudflare-operator/api/v1"
networkingv1 "k8s.io/api/networking/v1"
)
Expand All @@ -47,7 +46,7 @@ func NewTestScheme() *runtime.Scheme {
}

var (
cloudflareAPI *cloudflare.Client
cloudflareAPI *cloudflareClient
cloudflareAPIToken string
)

Expand Down
7 changes: 3 additions & 4 deletions internal/controller/cloudflare_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,18 @@ import (
"errors"
"fmt"

cloudflare "github.com/cloudflare/cloudflare-go/v7"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"sigs.k8s.io/controller-runtime/pkg/client"

cloudflareoperatoriov1 "github.com/containeroo/cloudflare-operator/api/v1"
)

func cloudflareAPIFromZone(ctx context.Context, kubeClient client.Client, zone *cloudflareoperatoriov1.Zone) (*cloudflare.Client, error) {
func cloudflareAPIFromZone(ctx context.Context, kubeClient client.Client, zone *cloudflareoperatoriov1.Zone) (*cloudflareClient, error) {
return cloudflareAPIForAccountName(ctx, kubeClient, zone.Spec.AccountRef.Name)
}

func cloudflareAPIFromDNSRecord(ctx context.Context, kubeClient client.Client, dnsRecord *cloudflareoperatoriov1.DNSRecord, zone *cloudflareoperatoriov1.Zone) (*cloudflare.Client, error) {
func cloudflareAPIFromDNSRecord(ctx context.Context, kubeClient client.Client, dnsRecord *cloudflareoperatoriov1.DNSRecord, zone *cloudflareoperatoriov1.Zone) (*cloudflareClient, error) {
accountName := dnsRecord.Spec.AccountRef.Name
if zone != nil && zone.Spec.AccountRef.Name != "" {
if accountName != "" && accountName != zone.Spec.AccountRef.Name {
Expand All @@ -45,7 +44,7 @@ func cloudflareAPIFromDNSRecord(ctx context.Context, kubeClient client.Client, d
return cloudflareAPIForAccountName(ctx, kubeClient, accountName)
}

func cloudflareAPIForAccountName(ctx context.Context, kubeClient client.Client, accountName string) (*cloudflare.Client, error) {
func cloudflareAPIForAccountName(ctx context.Context, kubeClient client.Client, accountName string) (*cloudflareClient, error) {
account, err := accountForName(ctx, kubeClient, accountName)
if err != nil {
return nil, err
Expand Down
113 changes: 64 additions & 49 deletions internal/controller/cloudflare_dns.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,23 +23,36 @@ import (
"fmt"
"net/http"

cloudflare "github.com/cloudflare/cloudflare-go/v7"
"github.com/cloudflare/cloudflare-go/v7/dns"
"github.com/cloudflare/cloudflare-go/v7/option"
"github.com/cloudflare/cloudflare-go/v7/zones"

cloudflareoperatoriov1 "github.com/containeroo/cloudflare-operator/api/v1"
)

func newCloudflareClient(token string) *cloudflare.Client {
return cloudflare.NewClient(option.WithAPIToken(token))
type cloudflareClient struct {
DNS *dns.DNSService
Zones *zones.ZoneService
}

func cloudflareZoneIDByName(ctx context.Context, cloudflareAPI *cloudflare.Client, zoneName string) (string, error) {
pager := cloudflareAPI.Zones.ListAutoPaging(ctx, zones.ZoneListParams{
Name: cloudflare.String(zoneName),
PerPage: cloudflare.Float(50),
})
func newCloudflareClient(token string, opts ...option.RequestOption) *cloudflareClient {
opts = append([]option.RequestOption{
option.WithEnvironmentProduction(),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The scoped constructor no longer applies cloudflare.DefaultClientOptions(), which previously supplied the SDK's HTTP client with a 10-minute ResponseHeaderTimeout. These generated services otherwise fall back to http.DefaultClient, which has no response/header timeout, while the reconciler contexts have no deadline. A Cloudflare connection that accepts but never returns headers can therefore pin a reconcile worker indefinitely. Please preserve a bounded timeout here—via option.WithRequestTimeout(...) or a bounded HTTP client—and add a timeout-focused test. Please also confirm whether dropping environment behavior such as CLOUDFLARE_BASE_URL is intentional.

option.WithAPIToken(token),
}, opts...)
return &cloudflareClient{
DNS: dns.NewDNSService(opts...),
Zones: zones.NewZoneService(opts...),
}
}

func cloudflareZoneIDByName(ctx context.Context, cloudflareAPI *cloudflareClient, zoneName string) (string, error) {
params := zones.ZoneListParams{}
params.Name.Value = zoneName
params.Name.Present = true
params.PerPage.Value = 50
params.PerPage.Present = true
pager := cloudflareAPI.Zones.ListAutoPaging(ctx, params)
for pager.Next() {
zone := pager.Current()
if zone.Name == zoneName {
Expand All @@ -53,20 +66,23 @@ func cloudflareZoneIDByName(ctx context.Context, cloudflareAPI *cloudflare.Clien
return "", errors.New("zone could not be found")
}

func getCloudflareDNSRecord(ctx context.Context, cloudflareAPI *cloudflare.Client, zoneID, recordID string) (dns.RecordResponse, error) {
record, err := cloudflareAPI.DNS.Records.Get(ctx, recordID, dns.RecordGetParams{
ZoneID: cloudflare.String(zoneID),
})
func getCloudflareDNSRecord(ctx context.Context, cloudflareAPI *cloudflareClient, zoneID, recordID string) (dns.RecordResponse, error) {
params := dns.RecordGetParams{}
params.ZoneID.Value = zoneID
params.ZoneID.Present = true
record, err := cloudflareAPI.DNS.Records.Get(ctx, recordID, params)
if err != nil {
return dns.RecordResponse{}, err
}
return *record, nil
}

func listCloudflareDNSRecords(ctx context.Context, cloudflareAPI *cloudflare.Client, zoneID string, params dns.RecordListParams) ([]dns.RecordResponse, error) {
params.ZoneID = cloudflare.String(zoneID)
func listCloudflareDNSRecords(ctx context.Context, cloudflareAPI *cloudflareClient, zoneID string, params dns.RecordListParams) ([]dns.RecordResponse, error) {
params.ZoneID.Value = zoneID
params.ZoneID.Present = true
if !params.PerPage.Present {
params.PerPage = cloudflare.Float(1000)
params.PerPage.Value = 1000
params.PerPage.Present = true
}

var records []dns.RecordResponse
Expand All @@ -80,42 +96,43 @@ func listCloudflareDNSRecords(ctx context.Context, cloudflareAPI *cloudflare.Cli
return records, nil
}

func createCloudflareDNSRecord(ctx context.Context, cloudflareAPI *cloudflare.Client, zoneID string, desiredRecord cloudflareoperatoriov1.DNSRecordSpec) (dns.RecordResponse, error) {
func createCloudflareDNSRecord(ctx context.Context, cloudflareAPI *cloudflareClient, zoneID string, desiredRecord cloudflareoperatoriov1.DNSRecordSpec) (dns.RecordResponse, error) {
body, err := newCloudflareDNSRecordBody(desiredRecord)
if err != nil {
return dns.RecordResponse{}, err
}

record, err := cloudflareAPI.DNS.Records.New(ctx, dns.RecordNewParams{
ZoneID: cloudflare.String(zoneID),
Body: body,
})
params := dns.RecordNewParams{Body: body}
params.ZoneID.Value = zoneID
params.ZoneID.Present = true
record, err := cloudflareAPI.DNS.Records.New(ctx, params)
if err != nil {
return dns.RecordResponse{}, err
}
return *record, nil
}

func editCloudflareDNSRecord(ctx context.Context, cloudflareAPI *cloudflare.Client, zoneID, recordID string, desiredRecord cloudflareoperatoriov1.DNSRecordSpec) error {
func editCloudflareDNSRecord(ctx context.Context, cloudflareAPI *cloudflareClient, zoneID, recordID string, desiredRecord cloudflareoperatoriov1.DNSRecordSpec) error {
body, err := editCloudflareDNSRecordBody(desiredRecord)
if err != nil {
return err
}

_, err = cloudflareAPI.DNS.Records.Edit(ctx, recordID, dns.RecordEditParams{
ZoneID: cloudflare.String(zoneID),
Body: body,
})
params := dns.RecordEditParams{Body: body}
params.ZoneID.Value = zoneID
params.ZoneID.Present = true
_, err = cloudflareAPI.DNS.Records.Edit(ctx, recordID, params)
return err
}

func deleteCloudflareDNSRecord(ctx context.Context, cloudflareAPI *cloudflare.Client, zoneID, recordID string) error {
func deleteCloudflareDNSRecord(ctx context.Context, cloudflareAPI *cloudflareClient, zoneID, recordID string) error {
if recordID == "" {
return nil
}
_, err := cloudflareAPI.DNS.Records.Delete(ctx, recordID, dns.RecordDeleteParams{
ZoneID: cloudflare.String(zoneID),
})
params := dns.RecordDeleteParams{}
params.ZoneID.Value = zoneID
params.ZoneID.Present = true
_, err := cloudflareAPI.DNS.Records.Delete(ctx, recordID, params)
return err
}

Expand All @@ -125,21 +142,20 @@ func newCloudflareDNSRecordBody(desiredRecord cloudflareoperatoriov1.DNSRecordSp
return dns.RecordNewParamsBody{}, err
}

body := dns.RecordNewParamsBody{
Name: cloudflare.String(desiredRecord.Name),
TTL: cloudflare.F(dns.TTL(normalizedTTL(desiredRecord.TTL))),
Type: cloudflare.F(dns.RecordNewParamsBodyType(desiredRecord.Type)),
Proxied: cloudflare.Bool(proxiedEnabled(desiredRecord.Proxied)),
Comment: cloudflare.String(desiredRecord.Comment),
}
body := dns.RecordNewParamsBody{}
body.Name.Value, body.Name.Present = desiredRecord.Name, true
body.TTL.Value, body.TTL.Present = dns.TTL(normalizedTTL(desiredRecord.TTL)), true
body.Type.Value, body.Type.Present = dns.RecordNewParamsBodyType(desiredRecord.Type), true
body.Proxied.Value, body.Proxied.Present = proxiedEnabled(desiredRecord.Proxied), true
body.Comment.Value, body.Comment.Present = desiredRecord.Comment, true
if desiredRecord.Content != "" || data == nil {
body.Content = cloudflare.String(desiredRecord.Content)
body.Content.Value, body.Content.Present = desiredRecord.Content, true
}
if desiredRecord.Priority != nil {
body.Priority = cloudflare.Float(float64(*desiredRecord.Priority))
body.Priority.Value, body.Priority.Present = float64(*desiredRecord.Priority), true
}
if data != nil {
body.Data = cloudflare.F[interface{}](data)
body.Data.Value, body.Data.Present = data, true
}
return body, nil
}
Expand All @@ -150,21 +166,20 @@ func editCloudflareDNSRecordBody(desiredRecord cloudflareoperatoriov1.DNSRecordS
return dns.RecordEditParamsBody{}, err
}

body := dns.RecordEditParamsBody{
Name: cloudflare.String(desiredRecord.Name),
TTL: cloudflare.F(dns.TTL(normalizedTTL(desiredRecord.TTL))),
Type: cloudflare.F(dns.RecordEditParamsBodyType(desiredRecord.Type)),
Proxied: cloudflare.Bool(proxiedEnabled(desiredRecord.Proxied)),
Comment: cloudflare.String(desiredRecord.Comment),
}
body := dns.RecordEditParamsBody{}
body.Name.Value, body.Name.Present = desiredRecord.Name, true
body.TTL.Value, body.TTL.Present = dns.TTL(normalizedTTL(desiredRecord.TTL)), true
body.Type.Value, body.Type.Present = dns.RecordEditParamsBodyType(desiredRecord.Type), true
body.Proxied.Value, body.Proxied.Present = proxiedEnabled(desiredRecord.Proxied), true
body.Comment.Value, body.Comment.Present = desiredRecord.Comment, true
if desiredRecord.Content != "" || data == nil {
body.Content = cloudflare.String(desiredRecord.Content)
body.Content.Value, body.Content.Present = desiredRecord.Content, true
}
if desiredRecord.Priority != nil {
body.Priority = cloudflare.Float(float64(*desiredRecord.Priority))
body.Priority.Value, body.Priority.Present = float64(*desiredRecord.Priority), true
}
if data != nil {
body.Data = cloudflare.F[interface{}](data)
body.Data.Value, body.Data.Present = data, true
}
return body, nil
}
Expand Down
64 changes: 64 additions & 0 deletions internal/controller/cloudflare_dns_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/*
Copyright 2025 containeroo

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package controller

import (
"context"
"io"
"net/http"
"strings"
"testing"

"github.com/cloudflare/cloudflare-go/v7/option"
. "github.com/onsi/gomega"
)

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

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

func TestNewCloudflareClientUsesProductionEnvironment(t *testing.T) {
g := NewWithT(t)

httpClient := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
g.Expect(req.URL.Scheme).To(Equal("https"))
g.Expect(req.URL.Host).To(Equal("api.cloudflare.com"))
g.Expect(req.URL.Path).To(Equal("/client/v4/zones"))
g.Expect(req.Header.Get("Authorization")).To(Equal("Bearer token"))

return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{
"result": [{"id": "zone-id", "name": "example.com"}],
"result_info": {"page": 1, "per_page": 50, "count": 1, "total_count": 1, "total_pages": 1},
"success": true,
"errors": [],
"messages": []
}`)),
Request: req,
}, nil
})}

client := newCloudflareClient("token", option.WithHTTPClient(httpClient))
zoneID, err := cloudflareZoneIDByName(context.Background(), client, "example.com")

g.Expect(err).NotTo(HaveOccurred())
g.Expect(zoneID).To(Equal("zone-id"))
}
14 changes: 7 additions & 7 deletions internal/controller/dnsrecord_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ import (
"sigs.k8s.io/controller-runtime/pkg/predicate"
"sigs.k8s.io/controller-runtime/pkg/reconcile"

cloudflare "github.com/cloudflare/cloudflare-go/v7"
"github.com/cloudflare/cloudflare-go/v7/dns"
cloudflareoperatoriov1 "github.com/containeroo/cloudflare-operator/api/v1"
intconditions "github.com/containeroo/cloudflare-operator/internal/conditions"
Expand Down Expand Up @@ -203,12 +202,13 @@ func (r *DNSRecordReconciler) reconcileDNSRecord(ctx context.Context, dnsrecord
return ctrl.Result{RequeueAfter: r.RetryInterval}, nil
}
} else {
cloudflareExistingRecord, err := listCloudflareDNSRecords(ctx, cloudflareAPI, zone.Status.ID, dns.RecordListParams{
Type: cloudflare.F(dns.RecordListParamsType(dnsrecord.Spec.Type)),
Name: cloudflare.F(dns.RecordListParamsName{
Exact: cloudflare.String(dnsrecord.Spec.Name),
}),
})
params := dns.RecordListParams{}
params.Type.Value = dns.RecordListParamsType(dnsrecord.Spec.Type)
params.Type.Present = true
params.Name.Value.Exact.Value = dnsrecord.Spec.Name
params.Name.Value.Exact.Present = true
params.Name.Present = true
cloudflareExistingRecord, err := listCloudflareDNSRecords(ctx, cloudflareAPI, zone.Status.ID, params)
if err != nil {
intconditions.MarkFalse(dnsrecord, err)
return ctrl.Result{RequeueAfter: r.RetryInterval}, nil
Expand Down
3 changes: 1 addition & 2 deletions internal/controller/zone_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ import (
"strings"
"time"

cloudflare "github.com/cloudflare/cloudflare-go/v7"
"github.com/cloudflare/cloudflare-go/v7/dns"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
Expand Down Expand Up @@ -165,7 +164,7 @@ func (r *ZoneReconciler) reconcileZone(ctx context.Context, zone *cloudflareoper
}

// handlePrune deletes DNS records that are not managed by the operator if enabled
func (r *ZoneReconciler) handlePrune(ctx context.Context, cloudflareAPI *cloudflare.Client, zone *cloudflareoperatoriov1.Zone) error {
func (r *ZoneReconciler) handlePrune(ctx context.Context, cloudflareAPI *cloudflareClient, zone *cloudflareoperatoriov1.Zone) error {
log := ctrl.LoggerFrom(ctx)

zones := &cloudflareoperatoriov1.ZoneList{}
Expand Down
Loading