diff --git a/api/v1/account_types.go b/api/v1/account_types.go index 54815947..8a0eb36d 100644 --- a/api/v1/account_types.go +++ b/api/v1/account_types.go @@ -45,7 +45,7 @@ type AccountSpec struct { type AccountStatus struct { // Conditions contains the different condition statuses for the Account object. // +optional - Conditions []metav1.Condition `json:"conditions"` + Conditions []metav1.Condition `json:"conditions,omitempty"` } // +kubebuilder:object:root=true @@ -72,6 +72,16 @@ func (in *Account) SetConditions(conditions []metav1.Condition) { in.Status.Conditions = conditions } +// GetApiTokenSecretRef returns the secret reference that contains the Cloudflare API token. +func (in *Account) GetApiTokenSecretRef() corev1.SecretReference { + return in.Spec.ApiToken.SecretRef +} + +// GetInterval returns the reconcile interval defined on the account. +func (in *Account) GetInterval() metav1.Duration { + return in.Spec.Interval +} + // +kubebuilder:object:root=true // AccountList contains a list of Account diff --git a/api/v2/account_types.go b/api/v2/account_types.go new file mode 100644 index 00000000..23f91c0e --- /dev/null +++ b/api/v2/account_types.go @@ -0,0 +1,97 @@ +/* +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 v2 + +import ( + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type AccountSpecApiToken struct { + // Secret containing the API token (key must be named "apiToken") + SecretRef corev1.SecretReference `json:"secretRef"` +} + +// AccountSpec defines the desired state of Account +type AccountSpec struct { + // Cloudflare API token + ApiToken AccountSpecApiToken `json:"apiToken"` + // Interval to check account status + // +kubebuilder:default="5m" + // +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 + // +optional + // +deprecated + ManagedZones []string `json:"managedZones,omitempty"` +} + +// AccountStatus defines the observed state of Account +type AccountStatus struct { + // Conditions contains the different condition statuses for the Account object. + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Namespaced +// +kubebuilder:storageversion + +// Account is the Schema for the accounts API +// +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=`.status.conditions[?(@.type == "Ready")].status` +type Account struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec AccountSpec `json:"spec,omitempty"` + Status AccountStatus `json:"status,omitempty"` +} + +// GetConditions returns the status conditions of the object. +func (in *Account) GetConditions() []metav1.Condition { + return in.Status.Conditions +} + +// SetConditions sets the status conditions on the object. +func (in *Account) SetConditions(conditions []metav1.Condition) { + in.Status.Conditions = conditions +} + +// GetApiTokenSecretRef returns the secret reference that contains the Cloudflare API token. +func (in *Account) GetApiTokenSecretRef() corev1.SecretReference { + return in.Spec.ApiToken.SecretRef +} + +// GetInterval returns the reconcile interval defined on the account. +func (in *Account) GetInterval() metav1.Duration { + return in.Spec.Interval +} + +// +kubebuilder:object:root=true + +// AccountList contains a list of Account +type AccountList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []Account `json:"items"` +} + +func init() { + SchemeBuilder.Register(&Account{}, &AccountList{}) +} diff --git a/api/v2/common.go b/api/v2/common.go new file mode 100644 index 00000000..0f7ff5cf --- /dev/null +++ b/api/v2/common.go @@ -0,0 +1,19 @@ +/* +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 v2 + +const CloudflareOperatorFinalizer = "cloudflare-operator.io/finalizer" diff --git a/api/v2/condition_types.go b/api/v2/condition_types.go new file mode 100644 index 00000000..87a53ce5 --- /dev/null +++ b/api/v2/condition_types.go @@ -0,0 +1,31 @@ +/* +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 v2 + +const ( + // ConditionTypeReady represents the fact that the object is ready. + ConditionTypeReady string = "Ready" + + // ConditionReasonReady represents the fact that the object is ready. + ConditionReasonReady string = "Ready" + + // ConditionReasonNotReady represents the fact that the object is not ready. + ConditionReasonNotReady string = "NotReady" + + // ConditionReasonFailed represents the fact that the object has failed. + ConditionReasonFailed string = "Failed" +) diff --git a/api/v2/doc.go b/api/v2/doc.go new file mode 100644 index 00000000..6c6ce8d4 --- /dev/null +++ b/api/v2/doc.go @@ -0,0 +1,20 @@ +/* +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 v2 contains API Schema definitions for the cloudflare-operator.io v2 API group +// +kubebuilder:object:generate=true +// +groupName=cloudflare-operator.io +package v2 diff --git a/api/v2/groupversion_info.go b/api/v2/groupversion_info.go new file mode 100644 index 00000000..0055f57e --- /dev/null +++ b/api/v2/groupversion_info.go @@ -0,0 +1,36 @@ +/* +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 v2 contains API Schema definitions for the cloudflare-operator.io v2 API group +// +kubebuilder:object:generate=true +// +groupName=cloudflare-operator.io +package v2 + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/scheme" +) + +var ( + // GroupVersion is group version used to register these objects + GroupVersion = schema.GroupVersion{Group: "cloudflare-operator.io", Version: "v2"} + + // SchemeBuilder is used to add go types to the GroupVersionKind scheme + SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} + + // AddToScheme adds the types in this group-version to the given scheme. + AddToScheme = SchemeBuilder.AddToScheme +) diff --git a/api/v2/zz_generated.deepcopy.go b/api/v2/zz_generated.deepcopy.go new file mode 100644 index 00000000..63ad921f --- /dev/null +++ b/api/v2/zz_generated.deepcopy.go @@ -0,0 +1,145 @@ +//go:build !ignore_autogenerated + +/* +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. +*/ + +// Code generated by controller-gen. DO NOT EDIT. + +package v2 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Account) DeepCopyInto(out *Account) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Account. +func (in *Account) DeepCopy() *Account { + if in == nil { + return nil + } + out := new(Account) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Account) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AccountList) DeepCopyInto(out *AccountList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Account, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AccountList. +func (in *AccountList) DeepCopy() *AccountList { + if in == nil { + return nil + } + out := new(AccountList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *AccountList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AccountSpec) DeepCopyInto(out *AccountSpec) { + *out = *in + out.ApiToken = in.ApiToken + out.Interval = in.Interval + if in.ManagedZones != nil { + in, out := &in.ManagedZones, &out.ManagedZones + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AccountSpec. +func (in *AccountSpec) DeepCopy() *AccountSpec { + if in == nil { + return nil + } + out := new(AccountSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AccountSpecApiToken) DeepCopyInto(out *AccountSpecApiToken) { + *out = *in + out.SecretRef = in.SecretRef +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AccountSpecApiToken. +func (in *AccountSpecApiToken) DeepCopy() *AccountSpecApiToken { + if in == nil { + return nil + } + out := new(AccountSpecApiToken) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AccountStatus) DeepCopyInto(out *AccountStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]metav1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AccountStatus. +func (in *AccountStatus) DeepCopy() *AccountStatus { + if in == nil { + return nil + } + out := new(AccountStatus) + in.DeepCopyInto(out) + return out +} diff --git a/cmd/main.go b/cmd/main.go index 7b14dab4..8da01626 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -37,6 +37,7 @@ import ( "github.com/cloudflare/cloudflare-go" cloudflareoperatoriov1 "github.com/containeroo/cloudflare-operator/api/v1" + cloudflareoperatoriov2 "github.com/containeroo/cloudflare-operator/api/v2" "github.com/containeroo/cloudflare-operator/internal/controller" // +kubebuilder:scaffold:imports ) @@ -52,6 +53,7 @@ func init() { utilruntime.Must(clientgoscheme.AddToScheme(scheme)) utilruntime.Must(cloudflareoperatoriov1.AddToScheme(scheme)) + utilruntime.Must(cloudflareoperatoriov2.AddToScheme(scheme)) // +kubebuilder:scaffold:scheme } @@ -128,13 +130,30 @@ func main() { os.Exit(1) } - if err = (&controller.AccountReconciler{ + if err = (&controller.AccountReconciler[*cloudflareoperatoriov1.Account]{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), CloudflareAPI: &cloudflareAPI, RetryInterval: retryInterval, + Finalizer: cloudflareoperatoriov1.CloudflareOperatorFinalizer, + NewAccount: func() *cloudflareoperatoriov1.Account { + return &cloudflareoperatoriov1.Account{} + }, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "Account (v1)") + os.Exit(1) + } + if err = (&controller.AccountReconciler[*cloudflareoperatoriov2.Account]{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + CloudflareAPI: &cloudflareAPI, + RetryInterval: retryInterval, + Finalizer: cloudflareoperatoriov2.CloudflareOperatorFinalizer, + NewAccount: func() *cloudflareoperatoriov2.Account { + return &cloudflareoperatoriov2.Account{} + }, }).SetupWithManager(mgr); err != nil { - setupLog.Error(err, "unable to create controller", "controller", "Account") + setupLog.Error(err, "unable to create controller", "controller", "Account (v2)") os.Exit(1) } if err = (&controller.ZoneReconciler{ diff --git a/config/crd/bases/cloudflare-operator.io_accounts.yaml b/config/crd/bases/cloudflare-operator.io_accounts.yaml index a2c6f9a7..1393cdd6 100644 --- a/config/crd/bases/cloudflare-operator.io_accounts.yaml +++ b/config/crd/bases/cloudflare-operator.io_accounts.yaml @@ -12,7 +12,7 @@ spec: listKind: AccountList plural: accounts singular: account - scope: Cluster + scope: Namespaced versions: - additionalPrinterColumns: - jsonPath: .status.conditions[?(@.type == "Ready")].status @@ -141,6 +141,131 @@ spec: type: object type: object served: true + storage: false + subresources: + status: {} + - additionalPrinterColumns: + - jsonPath: .status.conditions[?(@.type == "Ready")].status + name: Ready + type: string + name: v2 + schema: + openAPIV3Schema: + description: Account is the Schema for the accounts API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: AccountSpec defines the desired state of Account + properties: + apiToken: + description: Cloudflare API token + properties: + secretRef: + description: Secret containing the API token (key must be named "apiToken") + properties: + name: + description: name is unique within a namespace to reference a secret resource. + type: string + namespace: + description: namespace defines the space within which the secret name must be unique. + type: string + type: object + x-kubernetes-map-type: atomic + required: + - secretRef + type: object + interval: + default: 5m + description: Interval to check account status + type: string + managedZones: + description: |- + List of zone names that should be managed by cloudflare-operator + Deprecated and will be removed in a future release + items: + type: string + type: array + required: + - apiToken + type: object + status: + description: AccountStatus defines the observed state of Account + properties: + conditions: + description: Conditions contains the different condition statuses for the Account object. + items: + description: Condition contains details for one aspect of the current state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + type: object + type: object + served: true storage: true subresources: status: {} diff --git a/config/manifests/grafana/dashboards/overview.json b/config/manifests/grafana/dashboards/overview.json index b92c4ca7..fd429445 100644 --- a/config/manifests/grafana/dashboards/overview.json +++ b/config/manifests/grafana/dashboards/overview.json @@ -130,7 +130,7 @@ "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, - "expr": "cloudflare_operator_account_status", + "expr": "sum(cloudflare_operator_account_status)", "interval": "", "legendFormat": "", "refId": "A" @@ -207,7 +207,7 @@ "uid": "${DS_PROMETHEUS}" }, "exemplar": true, - "expr": "cloudflare_operator_account_status + cloudflare_operator_dns_record_status + cloudflare_operator_ip_status + cloudflare_operator_zone_status", + "expr": "sum(cloudflare_operator_account_status) + sum(cloudflare_operator_dns_record_status) + sum(cloudflare_operator_ip_status) + sum(cloudflare_operator_zone_status)", "interval": "", "legendFormat": "", "refId": "A" diff --git a/config/samples/cloudflareoperatorio_v1_account.yaml b/config/samples/cloudflareoperatorio_v1_account.yaml index c0c570ec..007aed1d 100644 --- a/config/samples/cloudflareoperatorio_v1_account.yaml +++ b/config/samples/cloudflareoperatorio_v1_account.yaml @@ -11,6 +11,7 @@ stringData: apiVersion: cloudflare-operator.io/v1 kind: Account metadata: + namespace: cloudflare-operator-system name: account-sample spec: apiToken: diff --git a/config/samples/cloudflareoperatorio_v2_account.yaml b/config/samples/cloudflareoperatorio_v2_account.yaml new file mode 100644 index 00000000..cf1232d8 --- /dev/null +++ b/config/samples/cloudflareoperatorio_v2_account.yaml @@ -0,0 +1,20 @@ +--- +apiVersion: v1 +kind: Secret +metadata: + name: api-token-sample + namespace: cloudflare-operator-system +type: Opaque +stringData: + apiToken: ${CF_API_TOKEN} +--- +apiVersion: cloudflare-operator.io/v2 +kind: Account +metadata: + name: account-sample + namespace: cloudflare-operator-system +spec: + apiToken: + secretRef: + name: api-token-sample + namespace: cloudflare-operator-system diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index 00ed8cc3..92aaace9 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -3,5 +3,6 @@ resources: - cloudflareoperatorio_v1_dnsrecord.yaml - cloudflareoperatorio_v1_ip.yaml - cloudflareoperatorio_v1_account.yaml +- cloudflareoperatorio_v2_account.yaml - cloudflareoperatorio_v1_zone.yaml #+kubebuilder:scaffold:manifestskustomizesamples diff --git a/internal/conditions/conditions.go b/internal/conditions/conditions.go index 260e7558..69e8c76d 100644 --- a/internal/conditions/conditions.go +++ b/internal/conditions/conditions.go @@ -18,6 +18,7 @@ package conditions import ( cloudflareoperatoriov1 "github.com/containeroo/cloudflare-operator/api/v1" + cloudflareoperatoriov2 "github.com/containeroo/cloudflare-operator/api/v2" "github.com/containeroo/cloudflare-operator/internal/metrics" "github.com/fluxcd/pkg/runtime/conditions" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -25,8 +26,9 @@ import ( // SetCondition updates the Kubernetes condition status dynamically func SetCondition(to conditions.Setter, status metav1.ConditionStatus, reason, msg string) { + condType, _, _, _ := conditionConstants(to) conditions.Set(to, &metav1.Condition{ - Type: cloudflareoperatoriov1.ConditionTypeReady, + Type: condType, Status: status, Reason: reason, Message: msg, @@ -44,7 +46,10 @@ func updateMetrics(to conditions.Setter, status metav1.ConditionStatus) { switch o := to.(type) { case *cloudflareoperatoriov1.Account: - metrics.AccountFailureCounter.WithLabelValues(o.Name).Set(value) + metrics.AccountFailureCounter.WithLabelValues(o.Namespace, o.Name).Set(value) + + case *cloudflareoperatoriov2.Account: + metrics.AccountFailureCounter.WithLabelValues(o.Namespace, o.Name).Set(value) case *cloudflareoperatoriov1.Zone: metrics.ZoneFailureCounter.WithLabelValues(o.Name, o.Spec.Name).Set(value) @@ -59,13 +64,31 @@ func updateMetrics(to conditions.Setter, status metav1.ConditionStatus) { // Convenience wrappers func MarkFalse(to conditions.Setter, err error) { - SetCondition(to, metav1.ConditionFalse, cloudflareoperatoriov1.ConditionReasonFailed, err.Error()) + _, _, _, reasonFailed := conditionConstants(to) + SetCondition(to, metav1.ConditionFalse, reasonFailed, err.Error()) } func MarkTrue(to conditions.Setter, msg string) { - SetCondition(to, metav1.ConditionTrue, cloudflareoperatoriov1.ConditionReasonReady, msg) + _, reasonReady, _, _ := conditionConstants(to) + SetCondition(to, metav1.ConditionTrue, reasonReady, msg) } func MarkUnknown(to conditions.Setter, msg string) { - SetCondition(to, metav1.ConditionUnknown, cloudflareoperatoriov1.ConditionReasonNotReady, msg) + _, _, reasonNotReady, _ := conditionConstants(to) + SetCondition(to, metav1.ConditionUnknown, reasonNotReady, msg) +} + +func conditionConstants(to conditions.Setter) (condType, reasonReady, reasonNotReady, reasonFailed string) { + switch to.(type) { + case *cloudflareoperatoriov2.Account: + return cloudflareoperatoriov2.ConditionTypeReady, + cloudflareoperatoriov2.ConditionReasonReady, + cloudflareoperatoriov2.ConditionReasonNotReady, + cloudflareoperatoriov2.ConditionReasonFailed + default: + return cloudflareoperatoriov1.ConditionTypeReady, + cloudflareoperatoriov1.ConditionReasonReady, + cloudflareoperatoriov1.ConditionReasonNotReady, + cloudflareoperatoriov1.ConditionReasonFailed + } } diff --git a/internal/conditions/conditions_test.go b/internal/conditions/conditions_test.go index b6d81f4e..53e15c6c 100644 --- a/internal/conditions/conditions_test.go +++ b/internal/conditions/conditions_test.go @@ -21,6 +21,7 @@ import ( "testing" cloudflareoperatoriov1 "github.com/containeroo/cloudflare-operator/api/v1" + cloudflareoperatoriov2 "github.com/containeroo/cloudflare-operator/api/v2" "github.com/fluxcd/pkg/runtime/conditions" . "github.com/onsi/gomega" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -37,6 +38,14 @@ func TestPredicate(t *testing.T) { g.Expect(testAccount.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{ *conditions.TrueCondition(cloudflareoperatoriov1.ConditionTypeReady, cloudflareoperatoriov1.ConditionReasonReady, "test"), })) + + v2Account := &cloudflareoperatoriov2.Account{} + + MarkTrue(v2Account, "test") + + g.Expect(v2Account.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{ + *conditions.TrueCondition(cloudflareoperatoriov2.ConditionTypeReady, cloudflareoperatoriov2.ConditionReasonReady, "test"), + })) }) t.Run("set false condition", func(t *testing.T) { @@ -49,6 +58,14 @@ func TestPredicate(t *testing.T) { g.Expect(testAccount.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{ *conditions.FalseCondition(cloudflareoperatoriov1.ConditionTypeReady, cloudflareoperatoriov1.ConditionReasonFailed, "test"), })) + + v2Account := &cloudflareoperatoriov2.Account{} + + MarkFalse(v2Account, errors.New("test")) + + g.Expect(v2Account.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{ + *conditions.FalseCondition(cloudflareoperatoriov2.ConditionTypeReady, cloudflareoperatoriov2.ConditionReasonFailed, "test"), + })) }) t.Run("set unknown condition", func(t *testing.T) { @@ -61,5 +78,13 @@ func TestPredicate(t *testing.T) { g.Expect(testAccount.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{ *conditions.UnknownCondition(cloudflareoperatoriov1.ConditionTypeReady, cloudflareoperatoriov1.ConditionReasonNotReady, "test"), })) + + v2Account := &cloudflareoperatoriov2.Account{} + + MarkUnknown(v2Account, "test") + + g.Expect(v2Account.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{ + *conditions.UnknownCondition(cloudflareoperatoriov2.ConditionTypeReady, cloudflareoperatoriov2.ConditionReasonNotReady, "test"), + })) }) } diff --git a/internal/controller/account_controller.go b/internal/controller/account_controller.go index dc73f9f1..e784d2ad 100644 --- a/internal/controller/account_controller.go +++ b/internal/controller/account_controller.go @@ -26,37 +26,53 @@ import ( "sigs.k8s.io/controller-runtime/pkg/predicate" "github.com/cloudflare/cloudflare-go" + fluxconditions "github.com/fluxcd/pkg/runtime/conditions" "github.com/fluxcd/pkg/runtime/patch" corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/reconcile" "k8s.io/apimachinery/pkg/runtime" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" - cloudflareoperatoriov1 "github.com/containeroo/cloudflare-operator/api/v1" intconditions "github.com/containeroo/cloudflare-operator/internal/conditions" "github.com/containeroo/cloudflare-operator/internal/metrics" apierrors "k8s.io/apimachinery/pkg/api/errors" apierrutil "k8s.io/apimachinery/pkg/util/errors" ) +// AccountObject describes the subset of account fields required by the reconciler. +type AccountObject interface { + client.Object + fluxconditions.Setter + GetApiTokenSecretRef() corev1.SecretReference + GetInterval() metav1.Duration +} + // AccountReconciler reconciles an Account object -type AccountReconciler struct { +type AccountReconciler[T AccountObject] struct { client.Client Scheme *runtime.Scheme RetryInterval time.Duration CloudflareAPI *cloudflare.API + + Finalizer string + NewAccount func() T } var errWaitForAccount = errors.New("must wait for account") // SetupWithManager sets up the controller with the Manager. -func (r *AccountReconciler) SetupWithManager(mgr ctrl.Manager) error { +func (r *AccountReconciler[T]) SetupWithManager(mgr ctrl.Manager) error { + if r.NewAccount == nil { + return errors.New("account reconciler requires a constructor") + } + return ctrl.NewControllerManagedBy(mgr). - For(&cloudflareoperatoriov1.Account{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). + For(r.NewAccount(), builder.WithPredicates(predicate.GenerationChangedPredicate{})). Complete(r) } @@ -67,8 +83,8 @@ func (r *AccountReconciler) SetupWithManager(mgr ctrl.Manager) error { // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. -func (r *AccountReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, retErr error) { - account := &cloudflareoperatoriov1.Account{} +func (r *AccountReconciler[T]) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, retErr error) { + account := r.NewAccount() if err := r.Get(ctx, req.NamespacedName, account); err != nil { return ctrl.Result{}, client.IgnoreNotFound(err) } @@ -83,20 +99,20 @@ func (r *AccountReconciler) Reconcile(ctx context.Context, req ctrl.Request) (re } if err := patchHelper.Patch(ctx, account, patchOpts...); err != nil { - if !account.DeletionTimestamp.IsZero() { + if !account.GetDeletionTimestamp().IsZero() { err = apierrutil.FilterOut(err, func(e error) bool { return apierrors.IsNotFound(e) }) } retErr = apierrutil.Reduce(apierrutil.NewAggregate([]error{retErr, err})) } }() - if !account.DeletionTimestamp.IsZero() { + if !account.GetDeletionTimestamp().IsZero() { r.reconcileDelete(account) return ctrl.Result{}, nil } - if !controllerutil.ContainsFinalizer(account, cloudflareoperatoriov1.CloudflareOperatorFinalizer) { - controllerutil.AddFinalizer(account, cloudflareoperatoriov1.CloudflareOperatorFinalizer) + if r.Finalizer != "" && !controllerutil.ContainsFinalizer(account, r.Finalizer) { + controllerutil.AddFinalizer(account, r.Finalizer) return ctrl.Result{Requeue: true}, nil } @@ -104,11 +120,17 @@ func (r *AccountReconciler) Reconcile(ctx context.Context, req ctrl.Request) (re } // reconcileAccount reconciles the account -func (r *AccountReconciler) reconcileAccount(ctx context.Context, account *cloudflareoperatoriov1.Account) (ctrl.Result, error) { +func (r *AccountReconciler[T]) reconcileAccount(ctx context.Context, account T) (ctrl.Result, error) { + secretRef := account.GetApiTokenSecretRef() + secretNamespace := secretRef.Namespace + if secretNamespace == "" { + secretNamespace = account.GetNamespace() + } + secret := &corev1.Secret{} if err := r.Get(ctx, client.ObjectKey{ - Namespace: account.Spec.ApiToken.SecretRef.Namespace, - Name: account.Spec.ApiToken.SecretRef.Name, + Namespace: secretNamespace, + Name: secretRef.Name, }, secret); err != nil { intconditions.MarkFalse(account, err) if apierrors.IsNotFound(err) { @@ -135,11 +157,18 @@ func (r *AccountReconciler) reconcileAccount(ctx context.Context, account *cloud intconditions.MarkTrue(account, "Account is ready") - return ctrl.Result{RequeueAfter: account.Spec.Interval.Duration}, nil + interval := account.GetInterval().Duration + if interval == 0 { + interval = r.RetryInterval + } + + return ctrl.Result{RequeueAfter: interval}, nil } // reconcileDelete reconciles the deletion of the account -func (r *AccountReconciler) reconcileDelete(account *cloudflareoperatoriov1.Account) { - metrics.AccountFailureCounter.DeleteLabelValues(account.Name) - controllerutil.RemoveFinalizer(account, cloudflareoperatoriov1.CloudflareOperatorFinalizer) +func (r *AccountReconciler[T]) reconcileDelete(account T) { + metrics.AccountFailureCounter.DeleteLabelValues(account.GetNamespace(), account.GetName()) + if r.Finalizer != "" { + controllerutil.RemoveFinalizer(account, r.Finalizer) + } } diff --git a/internal/controller/account_controller_test.go b/internal/controller/account_controller_test.go index f59324be..a6a9b810 100644 --- a/internal/controller/account_controller_test.go +++ b/internal/controller/account_controller_test.go @@ -18,8 +18,10 @@ package controller import ( "context" + "fmt" "os" "testing" + "time" "github.com/fluxcd/pkg/runtime/conditions" . "github.com/onsi/gomega" @@ -33,6 +35,7 @@ import ( "github.com/cloudflare/cloudflare-go" cloudflareoperatoriov1 "github.com/containeroo/cloudflare-operator/api/v1" + cloudflareoperatoriov2 "github.com/containeroo/cloudflare-operator/api/v2" networkingv1 "k8s.io/api/networking/v1" ) @@ -40,6 +43,7 @@ func NewTestScheme() *runtime.Scheme { s := runtime.NewScheme() utilruntime.Must(corev1.AddToScheme(s)) utilruntime.Must(cloudflareoperatoriov1.AddToScheme(s)) + utilruntime.Must(cloudflareoperatoriov2.AddToScheme(s)) utilruntime.Must(networkingv1.AddToScheme(s)) return s } @@ -47,124 +51,166 @@ func NewTestScheme() *runtime.Scheme { var cloudflareAPI cloudflare.API func TestAccountReconciler_reconcileAccount(t *testing.T) { - t.Run("reconcile account", func(t *testing.T) { - g := NewWithT(t) + runAccountReconcilerTests[*cloudflareoperatoriov1.Account](t, "v1", func() *cloudflareoperatoriov1.Account { + return &cloudflareoperatoriov1.Account{} + }) + runAccountReconcilerTests[*cloudflareoperatoriov2.Account](t, "v2", func() *cloudflareoperatoriov2.Account { + return &cloudflareoperatoriov2.Account{} + }) +} - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "secret", - Namespace: "default", - }, - Data: map[string][]byte{ - "apiToken": []byte(os.Getenv("CF_API_TOKEN")), - }, - } +func runAccountReconcilerTests[T AccountObject](t *testing.T, version string, newAccount func() T) { + t.Helper() - account := &cloudflareoperatoriov1.Account{ - ObjectMeta: metav1.ObjectMeta{ - Name: "account", - }, - Spec: cloudflareoperatoriov1.AccountSpec{ - ApiToken: cloudflareoperatoriov1.AccountSpecApiToken{ - SecretRef: corev1.SecretReference{ - Name: "secret", - Namespace: "default", - }, - }, - }, - } + t.Run(fmt.Sprintf("%s reconcile account", version), func(t *testing.T) { + g := NewWithT(t) + cloudflareAPI = cloudflare.API{} + + account := newAccount() + secret := configureAccountAndSecret(account, map[string][]byte{ + "apiToken": []byte(os.Getenv("CF_API_TOKEN")), + }) - r := &AccountReconciler{ + r := &AccountReconciler[T]{ Client: fake.NewClientBuilder(). WithScheme(NewTestScheme()). WithObjects(secret, account). Build(), CloudflareAPI: &cloudflareAPI, + RetryInterval: time.Second, } _, err := r.reconcileAccount(context.TODO(), account) g.Expect(err).ToNot(HaveOccurred()) - g.Expect(account.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{ - *conditions.TrueCondition(cloudflareoperatoriov1.ConditionTypeReady, cloudflareoperatoriov1.ConditionReasonReady, "Account is ready"), + condType, reasonReady, _, _ := accountConditionConstantsFromAccount(account) + g.Expect(account.GetConditions()).To(conditions.MatchConditions([]metav1.Condition{ + *conditions.TrueCondition(condType, reasonReady, "Account is ready"), })) g.Expect(cloudflareAPI.APIToken).To(Equal(string(secret.Data["apiToken"]))) }) - t.Run("econcile account error secret not found", func(t *testing.T) { + t.Run(fmt.Sprintf("%s reconcile account error secret not found", version), func(t *testing.T) { g := NewWithT(t) + cloudflareAPI = cloudflare.API{} - account := &cloudflareoperatoriov1.Account{ - ObjectMeta: metav1.ObjectMeta{ - Name: "account", - }, - Spec: cloudflareoperatoriov1.AccountSpec{ - ApiToken: cloudflareoperatoriov1.AccountSpecApiToken{ - SecretRef: corev1.SecretReference{ - Name: "secret", - Namespace: "default", - }, - }, - }, - } + account := newAccount() + _ = configureAccountAndSecret(account, map[string][]byte{ + "apiToken": []byte(os.Getenv("CF_API_TOKEN")), + }) - r := &AccountReconciler{ + r := &AccountReconciler[T]{ Client: fake.NewClientBuilder(). WithScheme(NewTestScheme()). WithObjects(account). Build(), CloudflareAPI: &cloudflareAPI, + RetryInterval: time.Second, } _, err := r.reconcileAccount(context.TODO(), account) g.Expect(err).ToNot(HaveOccurred()) - g.Expect(account.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{ - *conditions.FalseCondition(cloudflareoperatoriov1.ConditionTypeReady, cloudflareoperatoriov1.ConditionReasonFailed, "secrets \"secret\" not found"), - })) + condType, _, _, reasonFailed := accountConditionConstantsFromAccount(account) + g.Expect(account.GetConditions()).To(HaveLen(1)) + condition := account.GetConditions()[0] + g.Expect(condition.Type).To(Equal(condType)) + g.Expect(condition.Status).To(Equal(metav1.ConditionFalse)) + g.Expect(condition.Reason).To(Equal(reasonFailed)) + g.Expect(condition.Message).To(ContainSubstring("secret")) }) - t.Run("reconcile account error key not found in secret", func(t *testing.T) { + t.Run(fmt.Sprintf("%s reconcile account error key not found in secret", version), func(t *testing.T) { g := NewWithT(t) + cloudflareAPI = cloudflare.API{} - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "secret", - Namespace: "default", - }, - Data: map[string][]byte{ - "invalid": []byte("invalid"), - }, - } - - account := &cloudflareoperatoriov1.Account{ - ObjectMeta: metav1.ObjectMeta{ - Name: "account", - }, - Spec: cloudflareoperatoriov1.AccountSpec{ - ApiToken: cloudflareoperatoriov1.AccountSpecApiToken{ - SecretRef: corev1.SecretReference{ - Name: "secret", - Namespace: "default", - }, - }, - }, - } + account := newAccount() + secret := configureAccountAndSecret(account, map[string][]byte{ + "invalid": []byte("invalid"), + }) - r := &AccountReconciler{ + r := &AccountReconciler[T]{ Client: fake.NewClientBuilder(). WithScheme(NewTestScheme()). WithObjects(secret, account). Build(), CloudflareAPI: &cloudflareAPI, + RetryInterval: time.Second, } _, err := r.reconcileAccount(context.TODO(), account) g.Expect(err).ToNot(HaveOccurred()) - g.Expect(account.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{ - *conditions.FalseCondition(cloudflareoperatoriov1.ConditionTypeReady, cloudflareoperatoriov1.ConditionReasonFailed, "secret has no key named \"apiToken\""), - })) + condType, _, _, reasonFailed := accountConditionConstantsFromAccount(account) + g.Expect(account.GetConditions()).To(HaveLen(1)) + condition := account.GetConditions()[0] + g.Expect(condition.Type).To(Equal(condType)) + g.Expect(condition.Status).To(Equal(metav1.ConditionFalse)) + g.Expect(condition.Reason).To(Equal(reasonFailed)) + g.Expect(condition.Message).To(Equal("secret has no key named \"apiToken\"")) }) } + +func configureAccountAndSecret[T AccountObject](account T, data map[string][]byte) *corev1.Secret { + secretData := data + if secretData == nil { + secretData = map[string][]byte{} + } + + switch a := any(account).(type) { + case *cloudflareoperatoriov1.Account: + a.ObjectMeta = metav1.ObjectMeta{ + Name: "account", + Namespace: "default", + } + a.Spec = cloudflareoperatoriov1.AccountSpec{ + ApiToken: cloudflareoperatoriov1.AccountSpecApiToken{ + SecretRef: corev1.SecretReference{ + Name: "secret", + Namespace: "default", + }, + }, + } + case *cloudflareoperatoriov2.Account: + a.ObjectMeta = metav1.ObjectMeta{ + Name: "account", + Namespace: "default", + } + a.Spec = cloudflareoperatoriov2.AccountSpec{ + ApiToken: cloudflareoperatoriov2.AccountSpecApiToken{ + SecretRef: corev1.SecretReference{ + Name: "secret", + Namespace: "default", + }, + }, + } + default: + panic("unsupported account type") + } + + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "secret", + Namespace: "default", + }, + Data: secretData, + } +} + +func accountConditionConstantsFromAccount[T AccountObject](account T) (condType, reasonReady, reasonNotReady, reasonFailed string) { + switch any(account).(type) { + case *cloudflareoperatoriov2.Account: + return cloudflareoperatoriov2.ConditionTypeReady, + cloudflareoperatoriov2.ConditionReasonReady, + cloudflareoperatoriov2.ConditionReasonNotReady, + cloudflareoperatoriov2.ConditionReasonFailed + case *cloudflareoperatoriov1.Account: + return cloudflareoperatoriov1.ConditionTypeReady, + cloudflareoperatoriov1.ConditionReasonReady, + cloudflareoperatoriov1.ConditionReasonNotReady, + cloudflareoperatoriov1.ConditionReasonFailed + default: + panic("unsupported account type") + } +} diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index e3e5ea06..b4e49f36 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -27,7 +27,7 @@ var ( Name: "cloudflare_operator_account_status", Help: "Cloudflare account status", }, - []string{"name"}, + []string{"namespace", "name"}, ) DnsRecordFailureCounter = prometheus.NewGaugeVec( prometheus.GaugeOpts{