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: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ cloudflare-operator helps to:
- Keep Cloudflare DNS records up to date
- Update your external IP address on Cloudflare DNS records

> **Heads-up:** every `Account` custom resource must list the zones it is allowed to manage in `spec.managedZones`. Zones and DNS records are matched to accounts automatically using this list.

## What can I do with cloudflare-operator?

cloudflare-operator is based on a set of Kubernetes API extensions ("custom resources"), which control Cloudflare DNS records.
Expand Down
3 changes: 1 addition & 2 deletions api/v1/account_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,8 @@ type AccountSpec struct {
// +optional
Interval metav1.Duration `json:"interval,omitempty"`
// List of zone names that should be managed by cloudflare-operator
// Deprecated and will be removed in a future release
// Used to automatically match zones with the correct account
// +optional
// +deprecated
ManagedZones []string `json:"managedZones,omitempty"`
}

Expand Down
28 changes: 14 additions & 14 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ import (
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
"sigs.k8s.io/controller-runtime/pkg/webhook"

"github.com/cloudflare/cloudflare-go"
cloudflareoperatoriov1 "github.com/containeroo/cloudflare-operator/api/v1"
"github.com/containeroo/cloudflare-operator/internal/controller"
// +kubebuilder:scaffold:imports
Expand Down Expand Up @@ -65,7 +64,6 @@ func main() {
retryInterval time.Duration
ipReconcilerHTTPClientTimeout time.Duration
defaultReconcileInterval time.Duration
cloudflareAPI cloudflare.API
ctx = ctrl.SetupSignalHandler()
)
flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.")
Expand Down Expand Up @@ -128,20 +126,22 @@ func main() {
os.Exit(1)
}

accountManager := controller.NewAccountManager()

if err = (&controller.AccountReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
CloudflareAPI: &cloudflareAPI,
RetryInterval: retryInterval,
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
AccountManager: accountManager,
RetryInterval: retryInterval,
}).SetupWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "Account")
os.Exit(1)
}
if err = (&controller.ZoneReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
CloudflareAPI: &cloudflareAPI,
RetryInterval: retryInterval,
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
AccountManager: accountManager,
RetryInterval: retryInterval,
}).SetupWithManager(ctx, mgr); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "Zone")
os.Exit(1)
Expand All @@ -166,10 +166,10 @@ func main() {
os.Exit(1)
}
if err = (&controller.DNSRecordReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
CloudflareAPI: &cloudflareAPI,
RetryInterval: retryInterval,
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
AccountManager: accountManager,
RetryInterval: retryInterval,
}).SetupWithManager(ctx, mgr); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "DNSRecord")
os.Exit(1)
Expand Down
2 changes: 1 addition & 1 deletion config/crd/bases/cloudflare-operator.io_accounts.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ spec:
managedZones:
description: |-
List of zone names that should be managed by cloudflare-operator
Deprecated and will be removed in a future release
Used to automatically match zones with the correct account
items:
type: string
type: array
Expand Down
2 changes: 2 additions & 0 deletions config/samples/cloudflareoperatorio_v1_account.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,5 @@ spec:
secretRef:
name: api-token-sample
namespace: cloudflare-operator-system
managedZones:
- containeroo-test.org
17 changes: 8 additions & 9 deletions internal/controller/account_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@

RetryInterval time.Duration

CloudflareAPI *cloudflare.API
AccountManager *AccountManager
}

var errWaitForAccount = errors.New("must wait for account")
Expand Down Expand Up @@ -78,7 +78,7 @@
defer func() {
patchOpts := []patch.Option{}

if errors.Is(retErr, reconcile.TerminalError(nil)) || (retErr == nil && (result.IsZero() || !result.Requeue)) {

Check failure on line 81 in internal/controller/account_controller.go

View workflow job for this annotation

GitHub Actions / lint

SA1019: result.Requeue is deprecated: Use `RequeueAfter` instead. (staticcheck)
patchOpts = append(patchOpts, patch.WithStatusObservedGeneration{})
}

Expand Down Expand Up @@ -123,16 +123,14 @@
return ctrl.Result{RequeueAfter: r.RetryInterval}, nil
}

if r.CloudflareAPI.APIToken != cloudflareAPIToken {
cloudflareAPI, err := cloudflare.NewWithAPIToken(cloudflareAPIToken)
if err != nil {
intconditions.MarkFalse(account, err)
return ctrl.Result{}, err
}

*r.CloudflareAPI = *cloudflareAPI
cloudflareAPI, err := cloudflare.NewWithAPIToken(cloudflareAPIToken)
if err != nil {
intconditions.MarkFalse(account, err)
return ctrl.Result{}, err
}

r.AccountManager.UpsertAccount(account.Name, cloudflareAPI, cloudflareAPIToken, account.Spec.ManagedZones)

intconditions.MarkTrue(account, "Account is ready")

return ctrl.Result{RequeueAfter: account.Spec.Interval.Duration}, nil
Expand All @@ -142,4 +140,5 @@
func (r *AccountReconciler) reconcileDelete(account *cloudflareoperatoriov1.Account) {
metrics.AccountFailureCounter.DeleteLabelValues(account.Name)
controllerutil.RemoveFinalizer(account, cloudflareoperatoriov1.CloudflareOperatorFinalizer)
r.AccountManager.RemoveAccount(account.Name)
}
20 changes: 13 additions & 7 deletions internal/controller/account_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ import (

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

"github.com/cloudflare/cloudflare-go"
cloudflareoperatoriov1 "github.com/containeroo/cloudflare-operator/api/v1"
networkingv1 "k8s.io/api/networking/v1"
)
Expand All @@ -44,8 +43,6 @@ func NewTestScheme() *runtime.Scheme {
return s
}

var cloudflareAPI cloudflare.API

func TestAccountReconciler_reconcileAccount(t *testing.T) {
t.Run("reconcile account", func(t *testing.T) {
g := NewWithT(t)
Expand Down Expand Up @@ -74,12 +71,14 @@ func TestAccountReconciler_reconcileAccount(t *testing.T) {
},
}

accountManager := NewAccountManager()

r := &AccountReconciler{
Client: fake.NewClientBuilder().
WithScheme(NewTestScheme()).
WithObjects(secret, account).
Build(),
CloudflareAPI: &cloudflareAPI,
AccountManager: accountManager,
}

_, err := r.reconcileAccount(context.TODO(), account)
Expand All @@ -89,7 +88,10 @@ func TestAccountReconciler_reconcileAccount(t *testing.T) {
*conditions.TrueCondition(cloudflareoperatoriov1.ConditionTypeReady, cloudflareoperatoriov1.ConditionReasonReady, "Account is ready"),
}))

g.Expect(cloudflareAPI.APIToken).To(Equal(string(secret.Data["apiToken"])))
api, token, ok := accountManager.GetAccount(account.Name)
g.Expect(ok).To(BeTrue())
g.Expect(token).To(Equal(string(secret.Data["apiToken"])))
g.Expect(api).ToNot(BeNil())
})

t.Run("econcile account error secret not found", func(t *testing.T) {
Expand All @@ -109,12 +111,14 @@ func TestAccountReconciler_reconcileAccount(t *testing.T) {
},
}

accountManager := NewAccountManager()

r := &AccountReconciler{
Client: fake.NewClientBuilder().
WithScheme(NewTestScheme()).
WithObjects(account).
Build(),
CloudflareAPI: &cloudflareAPI,
AccountManager: accountManager,
}

_, err := r.reconcileAccount(context.TODO(), account)
Expand Down Expand Up @@ -152,12 +156,14 @@ func TestAccountReconciler_reconcileAccount(t *testing.T) {
},
}

accountManager := NewAccountManager()

r := &AccountReconciler{
Client: fake.NewClientBuilder().
WithScheme(NewTestScheme()).
WithObjects(secret, account).
Build(),
CloudflareAPI: &cloudflareAPI,
AccountManager: accountManager,
}

_, err := r.reconcileAccount(context.TODO(), account)
Expand Down
173 changes: 173 additions & 0 deletions internal/controller/account_manager.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
package controller

import (
"errors"
"sort"
"strings"
"sync"

"github.com/cloudflare/cloudflare-go"
)

var (
errNoAccountForZone = errors.New("no account manages the requested zone")
errMultipleAccounts = errors.New("multiple accounts manage the requested zone")
)

// accountInfo holds the data we need to interact with Cloudflare for a single account.
type accountInfo struct {
api *cloudflare.API
managedZones map[string]struct{}
token string
}

// AccountManager keeps track of available Cloudflare accounts and the zones they manage.
type AccountManager struct {
mu sync.RWMutex
accounts map[string]*accountInfo
zoneToAccounts map[string]map[string]struct{}
}

// NewAccountManager returns an initialized AccountManager instance.
func NewAccountManager() *AccountManager {
return &AccountManager{
accounts: make(map[string]*accountInfo),
zoneToAccounts: make(map[string]map[string]struct{}),
}
}

// UpsertAccount registers or updates an account and its managed zones.
func (m *AccountManager) UpsertAccount(name string, api *cloudflare.API, token string, managedZones []string) {
canonicalZones := normalizeZones(managedZones)

m.mu.Lock()
defer m.mu.Unlock()

// remove old zone membership for this account
if existing, ok := m.accounts[name]; ok {
for zone := range existing.managedZones {
m.removeZoneMembership(zone, name)
}
}

zoneSet := make(map[string]struct{}, len(canonicalZones))
for _, zone := range canonicalZones {
zoneSet[zone] = struct{}{}
if _, ok := m.zoneToAccounts[zone]; !ok {
m.zoneToAccounts[zone] = make(map[string]struct{})
}
m.zoneToAccounts[zone][name] = struct{}{}
}

m.accounts[name] = &accountInfo{
api: api,
managedZones: zoneSet,
token: token,
}
}

// RemoveAccount unregisters an account and clears any zone mappings.
func (m *AccountManager) RemoveAccount(name string) {
m.mu.Lock()
defer m.mu.Unlock()

entry, ok := m.accounts[name]
if !ok {
return
}

for zone := range entry.managedZones {
m.removeZoneMembership(zone, name)
}

delete(m.accounts, name)
}

// GetAccount returns the stored account info by name.
func (m *AccountManager) GetAccount(name string) (*cloudflare.API, string, bool) {
m.mu.RLock()
defer m.mu.RUnlock()

entry, ok := m.accounts[name]
if !ok {
return nil, "", false
}

return entry.api, entry.token, true
}

// AccountForZone returns the Cloudflare client for the account managing the provided zone.
// If no account manages the zone, errNoAccountForZone is returned.
// If multiple accounts manage the same zone, errMultipleAccounts is returned along with the list of candidates.
func (m *AccountManager) AccountForZone(zone string) (*cloudflare.API, string, error) {
canonical := canonicalZone(zone)

m.mu.RLock()
defer m.mu.RUnlock()

accounts, ok := m.zoneToAccounts[canonical]
if !ok || len(accounts) == 0 {
return nil, "", errNoAccountForZone
}

if len(accounts) > 1 {
names := make([]string, 0, len(accounts))
for name := range accounts {
names = append(names, name)
}
sort.Strings(names)
return nil, strings.Join(names, ", "), errMultipleAccounts
}

var accountName string
for name := range accounts {
accountName = name
}

entry, ok := m.accounts[accountName]
if !ok {
return nil, accountName, errNoAccountForZone
}

return entry.api, accountName, nil
}

func (m *AccountManager) removeZoneMembership(zone, accountName string) {
canonical := canonicalZone(zone)
members, ok := m.zoneToAccounts[canonical]
if !ok {
return
}

delete(members, accountName)
if len(members) == 0 {
delete(m.zoneToAccounts, canonical)
}
}

func normalizeZones(zones []string) []string {
result := make([]string, 0, len(zones))
seen := make(map[string]struct{}, len(zones))
for _, zone := range zones {
canonical := canonicalZone(zone)
if canonical == "" {
continue
}
if _, ok := seen[canonical]; ok {
continue
}
seen[canonical] = struct{}{}
result = append(result, canonical)
}

sort.Strings(result)
return result
}

func canonicalZone(zone string) string {
zone = strings.TrimSpace(zone)
if zone == "" {
return ""
}
return strings.ToLower(zone)
}
Loading
Loading