diff --git a/api/v1alpha2/accesstag_types.go b/api/v1alpha2/accesstag_types.go new file mode 100644 index 0000000..1bef115 --- /dev/null +++ b/api/v1alpha2/accesstag_types.go @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025-2026 The Cloudflare Operator Authors + +package v1alpha2 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// AccessTagSpec defines the desired state of AccessTag. +type AccessTagSpec struct { + // Name is the Cloudflare Access tag. Cloudflare Access tags are account-level + // objects that must exist before an AccessApplication can reference them via + // spec.tags. This value must match the tag string used in those references. + // The tag name is the resource's identity and cannot be changed once created; + // rename by creating a new AccessTag. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` + + // Cloudflare contains the Cloudflare API credentials and account information. + // +kubebuilder:validation:Required + Cloudflare CloudflareDetails `json:"cloudflare"` +} + +// AccessTagStatus defines the observed state of AccessTag. +type AccessTagStatus struct { + // AccountID is the Cloudflare Account ID the tag was ensured in. + // +optional + AccountID string `json:"accountId,omitempty"` + + // TagName is the Cloudflare-side tag name once it has been ensured to exist. + // +optional + TagName string `json:"tagName,omitempty"` + + // AppCount is the number of Access applications currently using this tag, + // as last observed from Cloudflare. + // +optional + AppCount int `json:"appCount,omitempty"` + + // State indicates the current state of the tag. + // +optional + State string `json:"state,omitempty"` + + // Conditions represent the latest available observations of the tag's state. + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` + + // ObservedGeneration is the most recent generation observed for this AccessTag. + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Namespaced,shortName=accesstag +// +kubebuilder:printcolumn:name="Tag",type=string,JSONPath=`.spec.name` +// +kubebuilder:printcolumn:name="Apps",type=integer,JSONPath=`.status.appCount` +// +kubebuilder:printcolumn:name="State",type=string,JSONPath=`.status.state` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` + +// AccessTag is the Schema for the accesstags API. +// An AccessTag ensures a Cloudflare Zero Trust Access tag exists in the account, +// so that AccessApplications can reference it via spec.tags. +type AccessTag struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec AccessTagSpec `json:"spec,omitempty"` + Status AccessTagStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// AccessTagList contains a list of AccessTag. +type AccessTagList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []AccessTag `json:"items"` +} + +func init() { + SchemeBuilder.Register(&AccessTag{}, &AccessTagList{}) +} diff --git a/api/v1alpha2/zz_generated.deepcopy.go b/api/v1alpha2/zz_generated.deepcopy.go index cc35dd4..d08f853 100644 --- a/api/v1alpha2/zz_generated.deepcopy.go +++ b/api/v1alpha2/zz_generated.deepcopy.go @@ -1425,6 +1425,103 @@ func (in *AccessServiceTokenStatus) DeepCopy() *AccessServiceTokenStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AccessTag) DeepCopyInto(out *AccessTag) { + *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 AccessTag. +func (in *AccessTag) DeepCopy() *AccessTag { + if in == nil { + return nil + } + out := new(AccessTag) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *AccessTag) 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 *AccessTagList) DeepCopyInto(out *AccessTagList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]AccessTag, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AccessTagList. +func (in *AccessTagList) DeepCopy() *AccessTagList { + if in == nil { + return nil + } + out := new(AccessTagList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *AccessTagList) 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 *AccessTagSpec) DeepCopyInto(out *AccessTagSpec) { + *out = *in + in.Cloudflare.DeepCopyInto(&out.Cloudflare) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AccessTagSpec. +func (in *AccessTagSpec) DeepCopy() *AccessTagSpec { + if in == nil { + return nil + } + out := new(AccessTagSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AccessTagStatus) DeepCopyInto(out *AccessTagStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AccessTagStatus. +func (in *AccessTagStatus) DeepCopy() *AccessTagStatus { + if in == nil { + return nil + } + out := new(AccessTagStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ActivityLogSettings) DeepCopyInto(out *ActivityLogSettings) { *out = *in diff --git a/cmd/main.go b/cmd/main.go index 5a2ac1a..e227de3 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -15,6 +15,7 @@ import ( "github.com/StringKe/cloudflare-operator/internal/controller/accessidentityprovider" "github.com/StringKe/cloudflare-operator/internal/controller/accesspolicy" "github.com/StringKe/cloudflare-operator/internal/controller/accessservicetoken" + "github.com/StringKe/cloudflare-operator/internal/controller/accesstag" "github.com/StringKe/cloudflare-operator/internal/controller/accesstunnel" "github.com/StringKe/cloudflare-operator/internal/controller/cloudflarecredentials" "github.com/StringKe/cloudflare-operator/internal/controller/cloudflaredomain" @@ -320,6 +321,13 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "AccessApplication") os.Exit(1) } + if err = (&accesstag.Reconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "AccessTag") + os.Exit(1) + } if err = (&accessgroup.Reconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), diff --git a/config/crd/bases/networking.cloudflare-operator.io_accesstags.yaml b/config/crd/bases/networking.cloudflare-operator.io_accesstags.yaml new file mode 100644 index 0000000..16da7a9 --- /dev/null +++ b/config/crd/bases/networking.cloudflare-operator.io_accesstags.yaml @@ -0,0 +1,229 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.2 + name: accesstags.networking.cloudflare-operator.io +spec: + group: networking.cloudflare-operator.io + names: + kind: AccessTag + listKind: AccessTagList + plural: accesstags + shortNames: + - accesstag + singular: accesstag + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.name + name: Tag + type: string + - jsonPath: .status.appCount + name: Apps + type: integer + - jsonPath: .status.state + name: State + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha2 + schema: + openAPIV3Schema: + description: |- + AccessTag is the Schema for the accesstags API. + An AccessTag ensures a Cloudflare Zero Trust Access tag exists in the account, + so that AccessApplications can reference it via spec.tags. + 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: AccessTagSpec defines the desired state of AccessTag. + properties: + cloudflare: + description: Cloudflare contains the Cloudflare API credentials and + account information. + properties: + CLOUDFLARE_API_KEY: + description: |- + Key in the secret to use for Cloudflare API Key. + If not specified, defaults to "CLOUDFLARE_API_KEY" at runtime. + Needs Email also to be provided. + For Delete operations for new tunnels only, or as an alternate to API Token. + type: string + CLOUDFLARE_API_TOKEN: + description: |- + Key in the secret to use for Cloudflare API token. + If not specified, defaults to "CLOUDFLARE_API_TOKEN" at runtime. + type: string + CLOUDFLARE_TUNNEL_CREDENTIAL_FILE: + description: |- + Key in the secret to use as credentials.json for an existing tunnel. + If not specified, defaults to "CLOUDFLARE_TUNNEL_CREDENTIAL_FILE" at runtime. + type: string + CLOUDFLARE_TUNNEL_CREDENTIAL_SECRET: + description: |- + Key in the secret to use as tunnel secret for an existing tunnel. + If not specified, defaults to "CLOUDFLARE_TUNNEL_CREDENTIAL_SECRET" at runtime. + type: string + accountId: + description: Account ID in Cloudflare. AccountId and AccountName + cannot be both empty. If both are provided, Account ID is used + if valid, else falls back to Account Name. + type: string + accountName: + description: Account Name in Cloudflare. AccountName and AccountId + cannot be both empty. If both are provided, Account ID is used + if valid, else falls back to Account Name. + type: string + credentialsRef: + description: |- + CredentialsRef references a CloudflareCredentials resource for API authentication. + When specified, this takes precedence over inline credential fields. + This is the recommended way to configure credentials. + properties: + name: + description: Name of the CloudflareCredentials resource to + use + type: string + required: + - name + type: object + domain: + description: |- + Cloudflare Domain to which this tunnel belongs to. + Required if not using credentialsRef with a defaultDomain. + type: string + email: + description: Email to use along with API Key for Delete operations + for new tunnels only, or as an alternate to API Token + type: string + secret: + description: Secret containing Cloudflare API key/token (legacy, + use credentialsRef instead) + type: string + zoneId: + description: |- + ZoneId is the Cloudflare Zone ID for DNS operations. + If not specified, it will be looked up via CloudflareDomain or the domain field. + Specifying this directly is useful for multi-zone scenarios. + type: string + type: object + name: + description: |- + Name is the Cloudflare Access tag. Cloudflare Access tags are account-level + objects that must exist before an AccessApplication can reference them via + spec.tags. This value must match the tag string used in those references. + The tag name is the resource's identity and cannot be changed once created; + rename by creating a new AccessTag. + minLength: 1 + type: string + required: + - cloudflare + - name + type: object + status: + description: AccessTagStatus defines the observed state of AccessTag. + properties: + accountId: + description: AccountID is the Cloudflare Account ID the tag was ensured + in. + type: string + appCount: + description: |- + AppCount is the number of Access applications currently using this tag, + as last observed from Cloudflare. + type: integer + conditions: + description: Conditions represent the latest available observations + of the tag's state. + 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 + observedGeneration: + description: ObservedGeneration is the most recent generation observed + for this AccessTag. + format: int64 + type: integer + state: + description: State indicates the current state of the tag. + type: string + tagName: + description: TagName is the Cloudflare-side tag name once it has been + ensured to exist. + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 32e20d9..90aece9 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -19,6 +19,7 @@ resources: # Access Control CRDs - bases/networking.cloudflare-operator.io_accessapplications.yaml - bases/networking.cloudflare-operator.io_accessgroups.yaml +- bases/networking.cloudflare-operator.io_accesstags.yaml - bases/networking.cloudflare-operator.io_accessidentityproviders.yaml - bases/networking.cloudflare-operator.io_accesspolicies.yaml - bases/networking.cloudflare-operator.io_accessservicetokens.yaml diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 1877cc2..7a62311 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -99,6 +99,7 @@ rules: - accessidentityproviders - accesspolicies - accessservicetokens + - accesstags - accesstunnels - cloudflarecredentials - cloudflaredomains @@ -144,6 +145,7 @@ rules: - accessidentityproviders/finalizers - accesspolicies/finalizers - accessservicetokens/finalizers + - accesstags/finalizers - cloudflarecredentials/finalizers - cloudflaredomains/finalizers - cloudflaresyncstates/finalizers @@ -182,6 +184,7 @@ rules: - accessidentityproviders/status - accesspolicies/status - accessservicetokens/status + - accesstags/status - accesstunnels/status - cloudflarecredentials/status - cloudflaredomains/status diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index 65b32da..c4b2fbd 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -21,6 +21,7 @@ resources: # v1alpha2 Access Control CRDs - networking_v1alpha2_accessapplication.yaml - networking_v1alpha2_accessgroup.yaml +- networking_v1alpha2_accesstag.yaml - networking_v1alpha2_accessidentityprovider.yaml - networking_v1alpha2_accessservicetoken.yaml # v1alpha2 Gateway CRDs diff --git a/config/samples/networking_v1alpha2_accesstag.yaml b/config/samples/networking_v1alpha2_accesstag.yaml new file mode 100644 index 0000000..2d193cf --- /dev/null +++ b/config/samples/networking_v1alpha2_accesstag.yaml @@ -0,0 +1,14 @@ +apiVersion: networking.cloudflare-operator.io/v1alpha2 +kind: AccessTag +metadata: + name: monitoring + namespace: default +spec: + # The Cloudflare Access tag string. Must match the value referenced by + # AccessApplication.spec.tags entries. + name: monitoring + cloudflare: + accountId: "your-cloudflare-account-id" + # Secret (or credentialsRef) holding the Cloudflare API token, resolved in + # this resource's namespace - same convention as AccessApplication. + secret: cloudflare-credentials diff --git a/internal/clients/cf/access_tag.go b/internal/clients/cf/access_tag.go new file mode 100644 index 0000000..f7eba4a --- /dev/null +++ b/internal/clients/cf/access_tag.go @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025-2026 The Cloudflare Operator Authors + +package cf + +import ( + "context" + + "github.com/cloudflare/cloudflare-go" +) + +// AccessTagResult is the operator-facing view of a Cloudflare Access tag. +type AccessTagResult struct { + Name string + AppCount int +} + +// findAccessTag returns the converted tag matching name, or nil if none match. +// Separated from the API call so the match/convert logic is unit-testable. +func findAccessTag(tags []cloudflare.AccessTag, name string) *AccessTagResult { + for _, t := range tags { + if t.Name == name { + return &AccessTagResult{Name: t.Name, AppCount: t.AppCount} + } + } + return nil +} + +// GetAccessTag finds an Access tag by name. It returns nil (without error) when +// no tag matches, mirroring the other "find existing" helpers in this package. +func (c *API) GetAccessTag(ctx context.Context, name string) (*AccessTagResult, error) { + if _, err := c.GetAccountId(ctx); err != nil { + c.Log.Error(err, "error getting account ID") + return nil, err + } + + rc := cloudflare.AccountIdentifier(c.ValidAccountId) + + tags, err := c.CloudflareClient.ListAccessTags(ctx, rc, cloudflare.ListAccessTagsParams{}) + if err != nil { + c.Log.Error(err, "error listing access tags") + return nil, err + } + + return findAccessTag(tags, name), nil +} + +// CreateAccessTag creates an account-level Access tag with the given name. +func (c *API) CreateAccessTag(ctx context.Context, name string) (*AccessTagResult, error) { + if _, err := c.GetAccountId(ctx); err != nil { + c.Log.Error(err, "error getting account ID") + return nil, err + } + + rc := cloudflare.AccountIdentifier(c.ValidAccountId) + + tag, err := c.CloudflareClient.CreateAccessTag(ctx, rc, cloudflare.CreateAccessTagParams{Name: name}) + if err != nil { + c.Log.Error(err, "error creating access tag", "name", name) + return nil, err + } + + return &AccessTagResult{Name: tag.Name, AppCount: tag.AppCount}, nil +} + +// DeleteAccessTag deletes an account-level Access tag by name. +func (c *API) DeleteAccessTag(ctx context.Context, name string) error { + if _, err := c.GetAccountId(ctx); err != nil { + c.Log.Error(err, "error getting account ID") + return err + } + + rc := cloudflare.AccountIdentifier(c.ValidAccountId) + + if err := c.CloudflareClient.DeleteAccessTag(ctx, rc, name); err != nil { + c.Log.Error(err, "error deleting access tag", "name", name) + return err + } + + return nil +} diff --git a/internal/clients/cf/access_tag_test.go b/internal/clients/cf/access_tag_test.go new file mode 100644 index 0000000..c3dbc3e --- /dev/null +++ b/internal/clients/cf/access_tag_test.go @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025-2026 The Cloudflare Operator Authors + +package cf + +import ( + "reflect" + "testing" + + "github.com/cloudflare/cloudflare-go" +) + +func TestFindAccessTag(t *testing.T) { + tags := []cloudflare.AccessTag{ + {Name: "monitoring", AppCount: 3}, + {Name: "backups", AppCount: 0}, + } + + tests := []struct { + name string + tags []cloudflare.AccessTag + query string + want *AccessTagResult + }{ + {name: "found returns converted result", tags: tags, query: "monitoring", want: &AccessTagResult{Name: "monitoring", AppCount: 3}}, + {name: "found with zero app count", tags: tags, query: "backups", want: &AccessTagResult{Name: "backups", AppCount: 0}}, + {name: "not found returns nil", tags: tags, query: "nope", want: nil}, + {name: "empty list returns nil", tags: nil, query: "monitoring", want: nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := findAccessTag(tt.tags, tt.query) + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("findAccessTag(%q) = %+v, want %+v", tt.query, got, tt.want) + } + }) + } +} diff --git a/internal/clients/cf/interface.go b/internal/clients/cf/interface.go index 1a940f9..2866900 100644 --- a/internal/clients/cf/interface.go +++ b/internal/clients/cf/interface.go @@ -61,6 +61,11 @@ type CloudflareClient interface { DeleteAccessApplication(ctx context.Context, applicationID string) error ListAccessApplicationsByName(ctx context.Context, name string) (*AccessApplicationResult, error) + // Access Tag operations + CreateAccessTag(ctx context.Context, name string) (*AccessTagResult, error) + GetAccessTag(ctx context.Context, name string) (*AccessTagResult, error) + DeleteAccessTag(ctx context.Context, name string) error + // Access Policy operations CreateAccessPolicy(ctx context.Context, params AccessPolicyParams) (*AccessPolicyResult, error) GetAccessPolicy(ctx context.Context, applicationID, policyID string) (*AccessPolicyResult, error) diff --git a/internal/clients/cf/mock/mock_client.go b/internal/clients/cf/mock/mock_client.go index 0f8c8a3..4300a1c 100644 --- a/internal/clients/cf/mock/mock_client.go +++ b/internal/clients/cf/mock/mock_client.go @@ -131,6 +131,21 @@ func (mr *MockCloudflareClientMockRecorder) CreateAccessServiceToken(ctx, name, return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateAccessServiceToken", reflect.TypeOf((*MockCloudflareClient)(nil).CreateAccessServiceToken), ctx, name, duration) } +// CreateAccessTag mocks base method. +func (m *MockCloudflareClient) CreateAccessTag(ctx context.Context, name string) (*cf.AccessTagResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateAccessTag", ctx, name) + ret0, _ := ret[0].(*cf.AccessTagResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateAccessTag indicates an expected call of CreateAccessTag. +func (mr *MockCloudflareClientMockRecorder) CreateAccessTag(ctx, name any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateAccessTag", reflect.TypeOf((*MockCloudflareClient)(nil).CreateAccessTag), ctx, name) +} + // CreateDNSRecord mocks base method. func (m *MockCloudflareClient) CreateDNSRecord(ctx context.Context, params cf.DNSRecordParams) (*cf.DNSRecordResult, error) { m.ctrl.T.Helper() @@ -352,6 +367,20 @@ func (mr *MockCloudflareClientMockRecorder) DeleteAccessServiceToken(ctx, tokenI return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAccessServiceToken", reflect.TypeOf((*MockCloudflareClient)(nil).DeleteAccessServiceToken), ctx, tokenID) } +// DeleteAccessTag mocks base method. +func (m *MockCloudflareClient) DeleteAccessTag(ctx context.Context, name string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteAccessTag", ctx, name) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteAccessTag indicates an expected call of DeleteAccessTag. +func (mr *MockCloudflareClientMockRecorder) DeleteAccessTag(ctx, name any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAccessTag", reflect.TypeOf((*MockCloudflareClient)(nil).DeleteAccessTag), ctx, name) +} + // DeleteDNSId mocks base method. func (m *MockCloudflareClient) DeleteDNSId(ctx context.Context, fqdn, dnsID string, created bool) error { m.ctrl.T.Helper() @@ -549,6 +578,21 @@ func (mr *MockCloudflareClientMockRecorder) EnableWebAnalytics(ctx, hostname any return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EnableWebAnalytics", reflect.TypeOf((*MockCloudflareClient)(nil).EnableWebAnalytics), ctx, hostname) } +// FindPagesDeploymentByCommitHash mocks base method. +func (m *MockCloudflareClient) FindPagesDeploymentByCommitHash(ctx context.Context, projectName, commitHash string) (*cf.PagesDeploymentResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindPagesDeploymentByCommitHash", ctx, projectName, commitHash) + ret0, _ := ret[0].(*cf.PagesDeploymentResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindPagesDeploymentByCommitHash indicates an expected call of FindPagesDeploymentByCommitHash. +func (mr *MockCloudflareClientMockRecorder) FindPagesDeploymentByCommitHash(ctx, projectName, commitHash any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindPagesDeploymentByCommitHash", reflect.TypeOf((*MockCloudflareClient)(nil).FindPagesDeploymentByCommitHash), ctx, projectName, commitHash) +} + // GetAccessApplication mocks base method. func (m *MockCloudflareClient) GetAccessApplication(ctx context.Context, applicationID string) (*cf.AccessApplicationResult, error) { m.ctrl.T.Helper() @@ -624,6 +668,21 @@ func (mr *MockCloudflareClientMockRecorder) GetAccessServiceTokenByName(ctx, nam return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccessServiceTokenByName", reflect.TypeOf((*MockCloudflareClient)(nil).GetAccessServiceTokenByName), ctx, name) } +// GetAccessTag mocks base method. +func (m *MockCloudflareClient) GetAccessTag(ctx context.Context, name string) (*cf.AccessTagResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccessTag", ctx, name) + ret0, _ := ret[0].(*cf.AccessTagResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccessTag indicates an expected call of GetAccessTag. +func (mr *MockCloudflareClientMockRecorder) GetAccessTag(ctx, name any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccessTag", reflect.TypeOf((*MockCloudflareClient)(nil).GetAccessTag), ctx, name) +} + // GetAccountId mocks base method. func (m *MockCloudflareClient) GetAccountId(ctx context.Context) (string, error) { m.ctrl.T.Helper() @@ -1135,21 +1194,6 @@ func (mr *MockCloudflareClientMockRecorder) ListPagesDeployments(ctx, projectNam return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListPagesDeployments", reflect.TypeOf((*MockCloudflareClient)(nil).ListPagesDeployments), ctx, projectName) } -// FindPagesDeploymentByCommitHash mocks base method. -func (m *MockCloudflareClient) FindPagesDeploymentByCommitHash(ctx context.Context, projectName, commitHash string) (*cf.PagesDeploymentResult, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "FindPagesDeploymentByCommitHash", ctx, projectName, commitHash) - ret0, _ := ret[0].(*cf.PagesDeploymentResult) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// FindPagesDeploymentByCommitHash indicates an expected call of FindPagesDeploymentByCommitHash. -func (mr *MockCloudflareClientMockRecorder) FindPagesDeploymentByCommitHash(ctx, projectName, commitHash any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindPagesDeploymentByCommitHash", reflect.TypeOf((*MockCloudflareClient)(nil).FindPagesDeploymentByCommitHash), ctx, projectName, commitHash) -} - // ListPagesDomains mocks base method. func (m *MockCloudflareClient) ListPagesDomains(ctx context.Context, projectName string) ([]cf.PagesDomainResult, error) { m.ctrl.T.Helper() diff --git a/internal/controller/accesstag/controller.go b/internal/controller/accesstag/controller.go new file mode 100644 index 0000000..c9a063a --- /dev/null +++ b/internal/controller/accesstag/controller.go @@ -0,0 +1,257 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025-2026 The Cloudflare Operator Authors + +// Package accesstag provides the controller for the AccessTag CRD. +// It ensures account-level Cloudflare Zero Trust Access tags exist so that +// AccessApplications can reference them via spec.tags. +package accesstag + +import ( + "context" + "fmt" + + "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + ctrllog "sigs.k8s.io/controller-runtime/pkg/log" + + networkingv1alpha2 "github.com/StringKe/cloudflare-operator/api/v1alpha2" + "github.com/StringKe/cloudflare-operator/internal/clients/cf" + "github.com/StringKe/cloudflare-operator/internal/controller" + "github.com/StringKe/cloudflare-operator/internal/controller/common" +) + +const ( + // FinalizerName guards the AccessTag so the controller can run its + // app_count-checked teardown before the object is removed. + FinalizerName = "cloudflare.com/accesstag-finalizer" + // StateActive indicates the tag is ensured to exist in Cloudflare. + StateActive = "active" +) + +// Reconciler reconciles an AccessTag object. It ensures the named Cloudflare +// Access tag exists (adopt-or-create) and writes status back to the CRD. +type Reconciler struct { + client.Client + Scheme *runtime.Scheme + Recorder record.EventRecorder + APIFactory *common.APIClientFactory +} + +// +kubebuilder:rbac:groups=networking.cloudflare-operator.io,resources=accesstags,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=networking.cloudflare-operator.io,resources=accesstags/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=networking.cloudflare-operator.io,resources=accesstags/finalizers,verbs=update +// +kubebuilder:rbac:groups=core,resources=secrets,verbs=get;list;watch +// +kubebuilder:rbac:groups="",resources=events,verbs=create;patch + +func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := ctrllog.FromContext(ctx) + + tag := &networkingv1alpha2.AccessTag{} + if err := r.Get(ctx, req.NamespacedName, tag); err != nil { + if apierrors.IsNotFound(err) { + return common.NoRequeue(), nil + } + return common.NoRequeue(), err + } + + // Handle deletion + if !tag.DeletionTimestamp.IsZero() { + return r.handleDeletion(ctx, logger, tag) + } + + // Ensure finalizer + if added, err := controller.EnsureFinalizer(ctx, r.Client, tag, FinalizerName); err != nil { + return common.NoRequeue(), err + } else if added { + return ctrl.Result{Requeue: true}, nil + } + + // Get API client - use resource namespace for credentials resolution + apiResult, err := r.APIFactory.GetClient(ctx, common.APIClientOptions{ + CloudflareDetails: &tag.Spec.Cloudflare, + Namespace: tag.Namespace, + StatusAccountID: tag.Status.AccountID, + }) + if err != nil { + logger.Error(err, "Failed to get API client") + return r.setErrorStatus(ctx, tag, err) + } + + return r.reconcileTag(ctx, logger, tag, apiResult) +} + +// reconcileTag ensures the Access tag exists in Cloudflare (adopt-or-create). +func (r *Reconciler) reconcileTag( + ctx context.Context, + logger logr.Logger, + tag *networkingv1alpha2.AccessTag, + apiResult *common.APIClientResult, +) (ctrl.Result, error) { + // GetAccessTag returns (nil, nil) when the tag does not exist yet. + result, err := apiResult.API.GetAccessTag(ctx, tag.Spec.Name) + if err != nil { + logger.Error(err, "Failed to get AccessTag from Cloudflare") + return r.setErrorStatus(ctx, tag, err) + } + + if result == nil { + result, err = apiResult.API.CreateAccessTag(ctx, tag.Spec.Name) + if err != nil { + logger.Error(err, "Failed to create AccessTag") + return r.setErrorStatus(ctx, tag, err) + } + r.Recorder.Event(tag, corev1.EventTypeNormal, "Created", + fmt.Sprintf("AccessTag '%s' created in Cloudflare", tag.Spec.Name)) + } else { + logger.V(1).Info("AccessTag already exists in Cloudflare, adopting", "name", tag.Spec.Name) + } + + return r.setSuccessStatus(ctx, tag, apiResult.AccountID, result) +} + +// handleDeletion runs the app_count-guarded teardown, then removes the finalizer. +// It never blocks deletion: on any read/delete failure the finalizer is removed +// anyway, so a transient Cloudflare error cannot wedge the object. +func (r *Reconciler) handleDeletion( + ctx context.Context, + logger logr.Logger, + tag *networkingv1alpha2.AccessTag, +) (ctrl.Result, error) { + if !controllerutil.ContainsFinalizer(tag, FinalizerName) { + return common.NoRequeue(), nil + } + + apiResult, err := r.APIFactory.GetClient(ctx, common.APIClientOptions{ + CloudflareDetails: &tag.Spec.Cloudflare, + Namespace: tag.Namespace, + StatusAccountID: tag.Status.AccountID, + }) + if err != nil { + logger.Error(err, "Failed to get API client for deletion") + // Continue with finalizer removal - resource may need manual cleanup. + } else { + r.tryDeleteTag(ctx, logger, tag, apiResult.API) + } + + // Remove finalizer + if err := controller.UpdateWithConflictRetry(ctx, r.Client, tag, func() { + controllerutil.RemoveFinalizer(tag, FinalizerName) + }); err != nil { + logger.Error(err, "Failed to remove finalizer") + return common.NoRequeue(), err + } + r.Recorder.Event(tag, corev1.EventTypeNormal, controller.EventReasonFinalizerRemoved, "Finalizer removed") + + return common.NoRequeue(), nil +} + +// tryDeleteTag best-effort deletes the Cloudflare tag, but only when nothing +// references it (app_count == 0). Tags are account-global while AccessTag CRs are +// namespaced, so a tag still in use - by an application or a sibling CR in another +// namespace - must not be removed. Never returns an error: teardown is best-effort. +func (r *Reconciler) tryDeleteTag( + ctx context.Context, + logger logr.Logger, + tag *networkingv1alpha2.AccessTag, + api *cf.API, +) { + existing, err := api.GetAccessTag(ctx, tag.Spec.Name) + switch { + case err != nil: + logger.Error(err, "Failed to read AccessTag during deletion, removing finalizer anyway", "name", tag.Spec.Name) + case existing == nil: + logger.Info("AccessTag not found in Cloudflare, may have been already deleted", "name", tag.Spec.Name) + case shouldDeleteTag(existing.AppCount): + if delErr := api.DeleteAccessTag(ctx, tag.Spec.Name); delErr != nil { + logger.Error(delErr, "Failed to delete AccessTag from Cloudflare, removing finalizer anyway", "name", tag.Spec.Name) + r.Recorder.Event(tag, corev1.EventTypeWarning, "DeleteFailed", + fmt.Sprintf("Failed to delete from Cloudflare (will remove finalizer anyway): %s", cf.SanitizeErrorMessage(delErr))) + } else { + r.Recorder.Event(tag, corev1.EventTypeNormal, "Deleted", "AccessTag deleted from Cloudflare") + } + default: + logger.Info("AccessTag still in use, leaving it in Cloudflare", + "name", tag.Spec.Name, "appCount", existing.AppCount) + r.Recorder.Event(tag, corev1.EventTypeNormal, "Retained", + fmt.Sprintf("AccessTag '%s' still used by %d application(s); not deleted", tag.Spec.Name, existing.AppCount)) + } +} + +// shouldDeleteTag reports whether a Cloudflare Access tag is safe to delete: only +// when no Access applications reference it. Pure helper, separated for unit testing. +func shouldDeleteTag(appCount int) bool { + return appCount == 0 +} + +// setSuccessStatus updates the AccessTag status after a successful sync. +func (r *Reconciler) setSuccessStatus( + ctx context.Context, + tag *networkingv1alpha2.AccessTag, + accountID string, + result *cf.AccessTagResult, +) (ctrl.Result, error) { + err := controller.UpdateStatusWithConflictRetry(ctx, r.Client, tag, func() { + tag.Status.AccountID = accountID + tag.Status.TagName = result.Name + tag.Status.AppCount = result.AppCount + tag.Status.State = StateActive + tag.Status.ObservedGeneration = tag.Generation + + meta.SetStatusCondition(&tag.Status.Conditions, metav1.Condition{ + Type: "Ready", + Status: metav1.ConditionTrue, + ObservedGeneration: tag.Generation, + Reason: "Synced", + Message: "AccessTag synced to Cloudflare", + LastTransitionTime: metav1.Now(), + }) + }) + if err != nil { + return common.NoRequeue(), fmt.Errorf("failed to update status: %w", err) + } + + return common.NoRequeue(), nil +} + +// setErrorStatus updates the AccessTag status with an error and requeues. +func (r *Reconciler) setErrorStatus( + ctx context.Context, + tag *networkingv1alpha2.AccessTag, + cause error, +) (ctrl.Result, error) { + updateErr := controller.UpdateStatusWithConflictRetry(ctx, r.Client, tag, func() { + tag.Status.State = "error" + tag.Status.ObservedGeneration = tag.Generation + meta.SetStatusCondition(&tag.Status.Conditions, metav1.Condition{ + Type: "Ready", + Status: metav1.ConditionFalse, + ObservedGeneration: tag.Generation, + Reason: "Error", + Message: cf.SanitizeErrorMessage(cause), + LastTransitionTime: metav1.Now(), + }) + }) + if updateErr != nil { + return common.NoRequeue(), fmt.Errorf("failed to update status: %w", updateErr) + } + + return common.RequeueShort(), nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error { + r.Recorder = mgr.GetEventRecorderFor("accesstag-controller") + r.APIFactory = common.NewAPIClientFactory(mgr.GetClient(), ctrl.Log.WithName("accesstag")) + + return ctrl.NewControllerManagedBy(mgr). + For(&networkingv1alpha2.AccessTag{}). + Complete(r) +} diff --git a/internal/controller/accesstag/controller_test.go b/internal/controller/accesstag/controller_test.go new file mode 100644 index 0000000..7ad2efa --- /dev/null +++ b/internal/controller/accesstag/controller_test.go @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025-2026 The Cloudflare Operator Authors + +package accesstag + +import "testing" + +func TestShouldDeleteTag(t *testing.T) { + tests := []struct { + name string + appCount int + want bool + }{ + {name: "unused tag is deletable", appCount: 0, want: true}, + {name: "tag used by one app is retained", appCount: 1, want: false}, + {name: "tag used by many apps is retained", appCount: 25, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := shouldDeleteTag(tt.appCount); got != tt.want { + t.Fatalf("shouldDeleteTag(%d) = %v, want %v", tt.appCount, got, tt.want) + } + }) + } +}