diff --git a/PROJECT b/PROJECT index 5bf9bc0..d45a60a 100644 --- a/PROJECT +++ b/PROJECT @@ -1,25 +1,33 @@ domain: cfargotunnel.com layout: - - go.kubebuilder.io/v3 +- go.kubebuilder.io/v3 plugins: manifests.sdk.operatorframework.io/v2: {} scorecard.sdk.operatorframework.io/v2: {} projectName: cloudflare-operator repo: github.com/adyanth/cloudflare-operator resources: - - controller: true - domain: k8s.io - group: networking - kind: Service - path: k8s.io/api/core/v1 - version: v1 - - api: - crdVersion: v1 - namespaced: true - controller: true - domain: cfargotunnel.com - group: networking - kind: Tunnel - path: github.com/adyanth/cloudflare-operator/api/v1alpha1 - version: v1alpha1 +- controller: true + domain: k8s.io + group: networking + kind: Service + path: k8s.io/api/core/v1 + version: v1 +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: cfargotunnel.com + group: networking + kind: Tunnel + path: github.com/adyanth/cloudflare-operator/api/v1alpha1 + version: v1alpha1 +- api: + crdVersion: v1 + controller: true + domain: cfargotunnel.com + group: networking + kind: ClusterTunnel + path: github.com/adyanth/cloudflare-operator/api/v1alpha1 + version: v1alpha1 version: "3" diff --git a/README.md b/README.md index 9603d3b..1b14c3d 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,6 @@ [![GitHub issues](https://img.shields.io/github/issues/adyanth/cloudflare-operator)](https://github.com/adyanth/cloudflare-operator/issues) [![Go Report Card](https://goreportcard.com/badge/github.com/adyanth/cloudflare-operator)](https://goreportcard.com/report/github.com/adyanth/cloudflare-operator) -> This is **NOT** an official operator provided/backed by Cloudflare Inc. - > **_NOTE_**: This project is currently in Alpha > UDP*: UDP support for Cloudflare Tunnels is in [Early Access](https://blog.cloudflare.com/extending-cloudflares-zero-trust-platform-to-support-udp-and-internal-dns/) @@ -45,6 +43,7 @@ The Cloudflare Operator aims to provide a new way of dynamically deploying the [ * Accept a Secret for Cloudflare API Tokens and Keys * Run a scaled (configurable) Deployment of `cloudflared` * Manage a ConfigMap for the above Deployment + * Have Cluster and Namespace scoped Tunnels * A Service controller which monitors Service Resources for Annotations and do the following: * Update the `cloudflared` ConfigMap to include the new Service to be served * Restart the `cloudflared` Deployment to make the configuration change take effect @@ -64,3 +63,5 @@ There is more detailed information on this architecture and thought process behi Go through the dedicated documentation on [Getting Started](docs/getting-started.md) to learn how to deploy this operator and a sample tunnel along with a service to expose. Look into the [Configuration](docs/configuration.md) documentation to understand various configurable parameters of this operator. + +> **_NOTE_**: This is **NOT** an official operator provided/backed by Cloudflare Inc. It utilizes their [v4 API](https://api.cloudflare.com/) and their [`cloudflared`](https://github.com/cloudflare/cloudflared) to automate setting up of tunnels on Kubernetes. diff --git a/api/v1alpha1/clustertunnel_types.go b/api/v1alpha1/clustertunnel_types.go new file mode 100644 index 0000000..460c7b3 --- /dev/null +++ b/api/v1alpha1/clustertunnel_types.go @@ -0,0 +1,46 @@ +/* +Copyright 2022. + +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 v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! +// NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. + +//+kubebuilder:object:root=true +//+kubebuilder:subresource:status +//+kubebuilder:resource:scope=Cluster + +// ClusterTunnel is the Schema for the clustertunnels API +type ClusterTunnel struct { + Tunnel `json:",inline"` +} + +//+kubebuilder:object:root=true + +// ClusterTunnelList contains a list of ClusterTunnel +type ClusterTunnelList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []ClusterTunnel `json:"items"` +} + +func init() { + SchemeBuilder.Register(&ClusterTunnel{}, &ClusterTunnelList{}) +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 5cba663..8f386e2 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -40,6 +40,62 @@ func (in *CloudflareDetails) DeepCopy() *CloudflareDetails { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClusterTunnel) DeepCopyInto(out *ClusterTunnel) { + *out = *in + in.Tunnel.DeepCopyInto(&out.Tunnel) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterTunnel. +func (in *ClusterTunnel) DeepCopy() *ClusterTunnel { + if in == nil { + return nil + } + out := new(ClusterTunnel) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ClusterTunnel) 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 *ClusterTunnelList) DeepCopyInto(out *ClusterTunnelList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]ClusterTunnel, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterTunnelList. +func (in *ClusterTunnelList) DeepCopy() *ClusterTunnelList { + if in == nil { + return nil + } + out := new(ClusterTunnelList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ClusterTunnelList) 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 *ExistingTunnel) DeepCopyInto(out *ExistingTunnel) { *out = *in diff --git a/config/crd/bases/networking.cfargotunnel.com_clustertunnels.yaml b/config/crd/bases/networking.cfargotunnel.com_clustertunnels.yaml new file mode 100644 index 0000000..71d9a05 --- /dev/null +++ b/config/crd/bases/networking.cfargotunnel.com_clustertunnels.yaml @@ -0,0 +1,151 @@ + +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.7.0 + creationTimestamp: null + name: clustertunnels.networking.cfargotunnel.com +spec: + group: networking.cfargotunnel.com + names: + kind: ClusterTunnel + listKind: ClusterTunnelList + plural: clustertunnels + singular: clustertunnel + scope: Cluster + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: ClusterTunnel is the Schema for the clustertunnels 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: TunnelSpec defines the desired state of Tunnel + properties: + cloudflare: + description: Cloudflare Credentials + properties: + CLOUDFLARE_API_KEY: + default: CLOUDFLARE_API_KEY + description: Key in the secret to use for Cloudflare API Key, + defaults to CLOUDFLARE_API_KEY. 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: + default: CLOUDFLARE_API_TOKEN + description: Key in the secret to use for Cloudflare API token, + defaults to CLOUDFLARE_API_TOKEN + type: string + CLOUDFLARE_TUNNEL_CREDENTIAL_FILE: + default: CLOUDFLARE_TUNNEL_CREDENTIAL_FILE + description: Key in the secret to use as credentials.json for + the tunnel, defaults to CLOUDFLARE_TUNNEL_CREDENTIAL_FILE + type: string + CLOUDFLARE_TUNNEL_CREDENTIAL_SECRET: + default: CLOUDFLARE_TUNNEL_CREDENTIAL_SECRET + description: Key in the secret to use as credentials.json for + the tunnel, defaults to CLOUDFLARE_TUNNEL_CREDENTIAL_SECRET + 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 + domain: + description: Cloudflare Domain to which this tunnel belongs to + 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 + type: string + type: object + existingTunnel: + description: Existing tunnel object. ExistingTunnel and NewTunnel + cannot be both empty and are mutually exclusive. + properties: + id: + description: Existing Tunnel ID to run on. Tunnel ID and Tunnel + Name cannot be both empty. If both are provided, ID is used + if valid, else falls back to Name. + type: string + name: + description: Existing Tunnel name to run on. Tunnel Name and Tunnel + ID cannot be both empty. If both are provided, ID is used if + valid, else falls back to Name. + type: string + type: object + image: + default: cloudflare/cloudflared:2022.1.3 + description: Image sets the Cloudflared Image to use. Defaults to + the image set during the release of the operator. + type: string + newTunnel: + description: New tunnel object. NewTunnel and ExistingTunnel cannot + be both empty and are mutually exclusive. + properties: + name: + description: Tunnel name to create on Cloudflare. + type: string + type: object + size: + default: 1 + description: Size defines the number of Daemon pods to run for this + tunnel + format: int32 + minimum: 0 + type: integer + type: object + status: + description: TunnelStatus defines the observed state of Tunnel + properties: + accountId: + type: string + tunnelId: + description: 'INSERT ADDITIONAL STATUS FIELD - define observed state + of cluster Important: Run "make" to regenerate code after modifying + this file' + type: string + tunnelName: + type: string + zoneId: + type: string + required: + - accountId + - tunnelId + - tunnelName + - zoneId + type: object + type: object + served: true + storage: true + subresources: + status: {} +status: + acceptedNames: + kind: "" + plural: "" + conditions: [] + storedVersions: [] diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index c2c510c..9fe4bea 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -3,17 +3,20 @@ # It should be run by config/default resources: - bases/networking.cfargotunnel.com_tunnels.yaml +- bases/networking.cfargotunnel.com_clustertunnels.yaml #+kubebuilder:scaffold:crdkustomizeresource patchesStrategicMerge: # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix. # patches here are for enabling the conversion webhook for each CRD #- patches/webhook_in_tunnels.yaml +#- patches/webhook_in_clustertunnels.yaml #+kubebuilder:scaffold:crdkustomizewebhookpatch # [CERTMANAGER] To enable cert-manager, uncomment all the sections with [CERTMANAGER] prefix. # patches here are for enabling the CA injection for each CRD #- patches/cainjection_in_tunnels.yaml +#- patches/cainjection_in_clustertunnels.yaml #+kubebuilder:scaffold:crdkustomizecainjectionpatch # the following config is for teaching kustomize how to do kustomization for CRDs. diff --git a/config/crd/patches/cainjection_in_clustertunnels.yaml b/config/crd/patches/cainjection_in_clustertunnels.yaml new file mode 100644 index 0000000..d8a7012 --- /dev/null +++ b/config/crd/patches/cainjection_in_clustertunnels.yaml @@ -0,0 +1,7 @@ +# The following patch adds a directive for certmanager to inject CA into the CRD +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + cert-manager.io/inject-ca-from: $(CERTIFICATE_NAMESPACE)/$(CERTIFICATE_NAME) + name: clustertunnels.networking.cfargotunnel.com diff --git a/config/crd/patches/webhook_in_clustertunnels.yaml b/config/crd/patches/webhook_in_clustertunnels.yaml new file mode 100644 index 0000000..47fb964 --- /dev/null +++ b/config/crd/patches/webhook_in_clustertunnels.yaml @@ -0,0 +1,16 @@ +# The following patch enables a conversion webhook for the CRD +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: clustertunnels.networking.cfargotunnel.com +spec: + conversion: + strategy: Webhook + webhook: + clientConfig: + service: + namespace: system + name: webhook-service + path: /convert + conversionReviewVersions: + - v1 diff --git a/config/rbac/clustertunnel_editor_role.yaml b/config/rbac/clustertunnel_editor_role.yaml new file mode 100644 index 0000000..32e2826 --- /dev/null +++ b/config/rbac/clustertunnel_editor_role.yaml @@ -0,0 +1,24 @@ +# permissions for end users to edit clustertunnels. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: clustertunnel-editor-role +rules: +- apiGroups: + - networking.cfargotunnel.com + resources: + - clustertunnels + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - networking.cfargotunnel.com + resources: + - clustertunnels/status + verbs: + - get diff --git a/config/rbac/clustertunnel_viewer_role.yaml b/config/rbac/clustertunnel_viewer_role.yaml new file mode 100644 index 0000000..5d4da3f --- /dev/null +++ b/config/rbac/clustertunnel_viewer_role.yaml @@ -0,0 +1,20 @@ +# permissions for end users to view clustertunnels. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: clustertunnel-viewer-role +rules: +- apiGroups: + - networking.cfargotunnel.com + resources: + - clustertunnels + verbs: + - get + - list + - watch +- apiGroups: + - networking.cfargotunnel.com + resources: + - clustertunnels/status + verbs: + - get diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 3ad2917..28e794c 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -64,6 +64,32 @@ rules: - services/finalizers verbs: - update +- apiGroups: + - networking.cfargotunnel.com + resources: + - clustertunnels + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - networking.cfargotunnel.com + resources: + - clustertunnels/finalizers + verbs: + - update +- apiGroups: + - networking.cfargotunnel.com + resources: + - clustertunnels/status + verbs: + - get + - patch + - update - apiGroups: - networking.cfargotunnel.com resources: diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index 27ef74b..51192d9 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -5,4 +5,5 @@ resources: - networking_v1alpha1_tunnel.yaml - apps_v1_deployment.yaml - v1_service.yaml +- networking_v1alpha1_clustertunnel.yaml #+kubebuilder:scaffold:manifestskustomizesamples diff --git a/config/samples/networking_v1alpha1_clustertunnel.yaml b/config/samples/networking_v1alpha1_clustertunnel.yaml new file mode 100644 index 0000000..9ac5d51 --- /dev/null +++ b/config/samples/networking_v1alpha1_clustertunnel.yaml @@ -0,0 +1,15 @@ +apiVersion: networking.cfargotunnel.com/v1alpha1 +kind: ClusterTunnel +metadata: + name: clustertunnel-sample +spec: + newTunnel: + name: new-k8s-cluster-tunnel + size: 2 + cloudflare: + domain: example.com + secret: cloudflare-secrets # present in the cluster-resource-namespace (defaults to cloudflare-operator-system) + # accountId and accountName cannot be both empty. If both are provided, Account ID is used if valid, else falls back to Account Name. + email: email@domain.com + accountName: + accountId: diff --git a/controllers/clustertunnel_controller.go b/controllers/clustertunnel_controller.go new file mode 100644 index 0000000..d3eadf1 --- /dev/null +++ b/controllers/clustertunnel_controller.go @@ -0,0 +1,559 @@ +/* +Copyright 2022. + +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 controllers + +import ( + "context" + "fmt" + "time" + + yaml "gopkg.in/yaml.v3" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + apitypes "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/intstr" + 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" + + networkingv1alpha1 "github.com/adyanth/cloudflare-operator/api/v1alpha1" + "github.com/go-logr/logr" + "k8s.io/client-go/tools/record" +) + +// ClusterTunnelReconciler reconciles a ClusterTunnel object +type ClusterTunnelReconciler struct { + client.Client + Scheme *runtime.Scheme + Namespace string + Recorder record.EventRecorder + + // Custom data for ease of (re)use + + ctx context.Context + log logr.Logger + tunnel *networkingv1alpha1.ClusterTunnel + cfAPI *CloudflareAPI + cfSecret *corev1.Secret + tunnelCreds string +} + +// labelsForClusterTunnel returns the labels for selecting the resources +// belonging to the given Tunnel. +func labelsForClusterTunnel(cf networkingv1alpha1.ClusterTunnel) map[string]string { + return map[string]string{ + clusterTunnelAnnotation: cf.Name, + tunnelAppAnnotation: "cloudflared", + tunnelIdAnnotation: cf.Status.TunnelId, + tunnelNameAnnotation: cf.Status.TunnelName, + tunnelDomainAnnotation: cf.Spec.Cloudflare.Domain, + isClusterTunnelAnnotation: "true", + } +} + +func (r *ClusterTunnelReconciler) initStruct(ctx context.Context, tunnel *networkingv1alpha1.ClusterTunnel) error { + r.ctx = ctx + r.tunnel = tunnel + + var err error + + if r.cfAPI, r.cfSecret, err = getAPIDetails(r.ctx, r.Client, r.log, r.tunnel.Spec, r.tunnel.Status, r.Namespace); err != nil { + r.log.Error(err, "unable to get API details") + r.Recorder.Event(tunnel, corev1.EventTypeWarning, "ErrSpecSecret", "Error reading Secret to configure API") + return err + } + + return nil +} + +//+kubebuilder:rbac:groups=networking.cfargotunnel.com,resources=clustertunnels,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=networking.cfargotunnel.com,resources=clustertunnels/status,verbs=get;update;patch +//+kubebuilder:rbac:groups=networking.cfargotunnel.com,resources=clustertunnels/finalizers,verbs=update +//+kubebuilder:rbac:groups=core,resources=configmaps,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=core,resources=secrets,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups="",resources=events,verbs=create;patch + +// Reconcile is part of the main kubernetes reconciliation loop which aims to +// move the current state of the cluster closer to the desired state. +// TODO(user): Modify the Reconcile function to compare the state specified by +// the ClusterTunnel object against the actual cluster state, and then +// perform operations to make the cluster state reflect the state specified by +// the user. +// +// For more details, check Reconcile and its Result here: +// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.10.0/pkg/reconcile +func (r *ClusterTunnelReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + r.log = ctrllog.FromContext(ctx) + + // Lookup the Tunnel resource + tunnel := &networkingv1alpha1.ClusterTunnel{} + if err := r.Get(ctx, req.NamespacedName, tunnel); err != nil { + if apierrors.IsNotFound(err) { + // Tunnel object not found, could have been deleted after reconcile request. + // Owned objects are automatically garbage collected. For additional cleanup logic use finalizers. + // Return and don't requeue + r.log.Info("ClusterTunnel deleted, nothing to do") + return ctrl.Result{}, nil + } + r.log.Error(err, "unable to fetch ClusterTunnel") + return ctrl.Result{}, err + } + + if err := r.initStruct(ctx, tunnel); err != nil { + return ctrl.Result{}, err + } + + if res, ok, err := r.setupTunnel(); !ok { + return res, err + } + + // Update status + if err := r.updateTunnelStatus(); err != nil { + return ctrl.Result{}, err + } + + // Create necessary resources + if res, ok, err := r.createManagedResources(); !ok { + return res, err + } + + return ctrl.Result{}, nil +} + +func (r *ClusterTunnelReconciler) setupTunnel() (ctrl.Result, bool, error) { + okNewTunnel := r.tunnel.Spec.NewTunnel != networkingv1alpha1.NewTunnel{} + okExistingTunnel := r.tunnel.Spec.ExistingTunnel != networkingv1alpha1.ExistingTunnel{} + + // If both are set (or neither are), we have a problem + if okNewTunnel == okExistingTunnel { + err := fmt.Errorf("spec ExistingTunnel and NewTunnel cannot be both empty and are mutually exclusive") + r.log.Error(err, "spec ExistingTunnel and NewTunnel cannot be both empty and are mutually exclusive") + r.Recorder.Event(r.tunnel, corev1.EventTypeWarning, "ErrSpecTunnel", "ExistingTunnel and NewTunnel cannot be both empty and are mutually exclusive") + return ctrl.Result{}, false, err + } + + if okExistingTunnel { + // Existing Tunnel, Set tunnelId in status and get creds file + if err := r.setupExistingTunnel(); err != nil { + return ctrl.Result{}, false, err + } + } else { + // New tunnel, finalizer/cleanup logic + creation + if r.tunnel.GetDeletionTimestamp() != nil { + if res, ok, err := r.cleanupTunnel(); !ok { + return res, false, err + } + } else { + if err := r.setupNewTunnel(); err != nil { + return ctrl.Result{}, false, err + } + } + } + + return ctrl.Result{}, true, nil +} + +func (r *ClusterTunnelReconciler) setupExistingTunnel() error { + r.cfAPI.TunnelName = r.tunnel.Spec.ExistingTunnel.Name + r.cfAPI.TunnelId = r.tunnel.Spec.ExistingTunnel.Id + + // Read secret for credentials file + cfCredFileB64, okCredFile := r.cfSecret.Data[r.tunnel.Spec.Cloudflare.CLOUDFLARE_TUNNEL_CREDENTIAL_FILE] + cfSecretB64, okSecret := r.cfSecret.Data[r.tunnel.Spec.Cloudflare.CLOUDFLARE_TUNNEL_CREDENTIAL_SECRET] + + if !okCredFile && !okSecret { + err := fmt.Errorf("neither key not found in secret") + r.log.Error(err, "neither key not found in secret", "secret", r.tunnel.Spec.Cloudflare.Secret, "key1", r.tunnel.Spec.Cloudflare.CLOUDFLARE_TUNNEL_CREDENTIAL_FILE, "key2", r.tunnel.Spec.Cloudflare.CLOUDFLARE_TUNNEL_CREDENTIAL_SECRET) + r.Recorder.Event(r.tunnel, corev1.EventTypeWarning, "ErrSpecSecret", "Neither Key found in Secret") + return err + } + + if okCredFile { + r.tunnelCreds = string(cfCredFileB64) + } else { + creds, err := r.cfAPI.GetTunnelCreds(string(cfSecretB64)) + if err != nil { + r.log.Error(err, "error getting tunnel credentials from secret") + r.Recorder.Event(r.tunnel, corev1.EventTypeWarning, "ErrSpecApi", "Error in getting Tunnel Credentials from Secret") + return err + } + r.tunnelCreds = creds + } + + return nil +} + +func (r *ClusterTunnelReconciler) setupNewTunnel() error { + // New tunnel, not yet setup, create on Cloudflare + if r.tunnel.Status.TunnelId == "" { + r.Recorder.Event(r.tunnel, corev1.EventTypeNormal, "Creating", "Tunnel is being created") + r.cfAPI.TunnelName = r.tunnel.Spec.NewTunnel.Name + _, creds, err := r.cfAPI.CreateCloudflareTunnel() + if err != nil { + r.log.Error(err, "unable to create Tunnel") + r.Recorder.Event(r.tunnel, corev1.EventTypeWarning, "FailedCreate", "Unable to create Tunnel on Cloudflare") + return err + } + r.log.Info("Tunnel created on Cloudflare") + r.Recorder.Event(r.tunnel, corev1.EventTypeNormal, "Created", "Tunnel created successfully on Cloudflare") + r.tunnelCreds = creds + } + + // Add finalizer for tunnel + if !controllerutil.ContainsFinalizer(r.tunnel, tunnelFinalizerAnnotation) { + controllerutil.AddFinalizer(r.tunnel, tunnelFinalizerAnnotation) + if err := r.Update(r.ctx, r.tunnel); err != nil { + r.Recorder.Event(r.tunnel, corev1.EventTypeNormal, "FailedFinalizerSet", "Failed to add Tunnel Finalizer") + return err + } + r.Recorder.Event(r.tunnel, corev1.EventTypeNormal, "FinalizerSet", "Tunnel Finalizer added") + } + return nil +} + +func (r *ClusterTunnelReconciler) cleanupTunnel() (ctrl.Result, bool, error) { + if controllerutil.ContainsFinalizer(r.tunnel, tunnelFinalizerAnnotation) { + // Run finalization logic. If the finalization logic fails, + // don't remove the finalizer so that we can retry during the next reconciliation. + + r.log.Info("starting deletion cycle", "size", r.tunnel.Spec.Size) + r.Recorder.Event(r.tunnel, corev1.EventTypeNormal, "Deleting", "Starting Tunnel Deletion") + cfDeployment := &appsv1.Deployment{} + var bypass bool + if err := r.Get(r.ctx, apitypes.NamespacedName{Name: r.tunnel.Name, Namespace: r.Namespace}, cfDeployment); err != nil { + r.log.Error(err, "Error in getting deployments, might already be deleted?") + bypass = true + } + if !bypass && *cfDeployment.Spec.Replicas != 0 { + r.log.Info("Scaling down cloudflared") + r.Recorder.Event(r.tunnel, corev1.EventTypeNormal, "Scaling", "Scaling down cloudflared") + var size int32 = 0 + cfDeployment.Spec.Replicas = &size + if err := r.Update(r.ctx, cfDeployment); err != nil { + r.log.Error(err, "Failed to update Deployment", "Deployment.Namespace", cfDeployment.Namespace, "Deployment.Name", cfDeployment.Name) + r.Recorder.Event(r.tunnel, corev1.EventTypeWarning, "FailedScaling", "Failed to scale down cloudflared") + return ctrl.Result{}, false, err + } + r.log.Info("Scaling down successful", "size", r.tunnel.Spec.Size) + r.Recorder.Event(r.tunnel, corev1.EventTypeNormal, "Scaled", "Scaling down cloudflared successful") + return ctrl.Result{RequeueAfter: 5 * time.Second}, false, nil + } + if bypass || *cfDeployment.Spec.Replicas == 0 { + if err := r.cfAPI.DeleteCloudflareTunnel(); err != nil { + r.Recorder.Event(r.tunnel, corev1.EventTypeWarning, "FailedDeleting", "Tunnel deletion failed") + return ctrl.Result{}, false, err + } + r.log.Info("Tunnel deleted", "tunnelID", r.tunnel.Status.TunnelId) + r.Recorder.Event(r.tunnel, corev1.EventTypeNormal, "Deleted", "Tunnel deletion successful") + + // Remove tunnelFinalizer. Once all finalizers have been + // removed, the object will be deleted. + controllerutil.RemoveFinalizer(r.tunnel, tunnelFinalizerAnnotation) + err := r.Update(r.ctx, r.tunnel) + if err != nil { + r.log.Error(err, "unable to continue with tunnel deletion") + r.Recorder.Event(r.tunnel, corev1.EventTypeWarning, "FailedFinalizerUnset", "Unable to remove Tunnel Finalizer") + return ctrl.Result{}, false, err + } + r.Recorder.Event(r.tunnel, corev1.EventTypeNormal, "FinalizerUnset", "Tunnel Finalizer removed") + return ctrl.Result{}, true, nil + } + } + return ctrl.Result{}, true, nil +} + +func (r *ClusterTunnelReconciler) updateTunnelStatus() error { + r.tunnel.Labels = labelsForClusterTunnel(*r.tunnel) + if err := r.Update(r.ctx, r.tunnel); err != nil { + return err + } + + if err := r.cfAPI.ValidateAll(); err != nil { + r.log.Error(err, "Failed to validate API credentials") + r.Recorder.Event(r.tunnel, corev1.EventTypeWarning, "ErrSpecApi", "Error validating Cloudflare API credentials") + return err + } + r.tunnel.Status.AccountId = r.cfAPI.ValidAccountId + r.tunnel.Status.TunnelId = r.cfAPI.ValidTunnelId + r.tunnel.Status.TunnelName = r.cfAPI.ValidTunnelName + r.tunnel.Status.ZoneId = r.cfAPI.ValidZoneId + if err := r.Status().Update(r.ctx, r.tunnel); err != nil { + r.log.Error(err, "Failed to update Tunnel status", "Tunnel.Namespace", r.Namespace, "Tunnel.Name", r.tunnel.Name) + r.Recorder.Event(r.tunnel, corev1.EventTypeWarning, "FailedStatusSet", "Failed to set Tunnel status required for operation") + return err + } + r.log.Info("Tunnel status is set", "status", r.tunnel.Status) + return nil +} + +func (r *ClusterTunnelReconciler) createManagedSecret() error { + managedSecret := &corev1.Secret{} + if err := r.Get(r.ctx, apitypes.NamespacedName{Name: r.tunnel.Name, Namespace: r.Namespace}, managedSecret); err != nil && apierrors.IsNotFound(err) { + // Define a new Secret + sec := r.secretForTunnel(r.tunnel, r.tunnelCreds) + r.log.Info("Creating a new Secret", "Secret.Namespace", sec.Namespace, "Secret.Name", sec.Name) + r.Recorder.Event(r.tunnel, corev1.EventTypeNormal, "CreatingSecret", "Creating Tunnel Secret") + err = r.Create(r.ctx, sec) + if err != nil { + r.log.Error(err, "Failed to create new Secret", "Deployment.Namespace", sec.Namespace, "Deployment.Name", sec.Name) + r.Recorder.Event(r.tunnel, corev1.EventTypeWarning, "FailedCreatingSecret", "Creating Tunnel Secret failed") + return err + } + r.log.Info("Secret created", "Secret.Namespace", sec.Namespace, "Secret.Name", sec.Name) + r.Recorder.Event(r.tunnel, corev1.EventTypeNormal, "CreatedSecret", "Created Tunnel Secret") + } else if err != nil { + r.log.Error(err, "Failed to get Secret") + r.Recorder.Event(r.tunnel, corev1.EventTypeWarning, "FailedCreatedSecret", "Reading Tunnel Secret failed") + return err + } + return nil +} + +func (r *ClusterTunnelReconciler) createManagedConfigMap() error { + cfConfigMap := &corev1.ConfigMap{} + if err := r.Get(r.ctx, apitypes.NamespacedName{Name: r.tunnel.Name, Namespace: r.Namespace}, cfConfigMap); err != nil && apierrors.IsNotFound(err) { + // Define a new ConfigMap + cm := r.configMapForTunnel(r.tunnel) + r.log.Info("Creating a new ConfigMap", "ConfigMap.Namespace", cm.Namespace, "ConfigMap.Name", cm.Name) + r.Recorder.Event(r.tunnel, corev1.EventTypeNormal, "Configuring", "Creating Tunnel ConfigMap") + err = r.Create(r.ctx, cm) + if err != nil { + r.log.Error(err, "Failed to create new ConfigMap", "Deployment.Namespace", cm.Namespace, "Deployment.Name", cm.Name) + r.Recorder.Event(r.tunnel, corev1.EventTypeWarning, "FailedConfiguring", "Creating Tunnel ConfigMap failed") + return err + } + r.log.Info("ConfigMap created", "ConfigMap.Namespace", cm.Namespace, "ConfigMap.Name", cm.Name) + r.Recorder.Event(r.tunnel, corev1.EventTypeNormal, "Configured", "Created Tunnel ConfigMap") + } else if err != nil { + r.log.Error(err, "Failed to get ConfigMap") + r.Recorder.Event(r.tunnel, corev1.EventTypeWarning, "FailedConfigured", "Reading Tunnel ConfigMap failed") + return err + } + return nil +} + +func (r *ClusterTunnelReconciler) createOrScaleManagedDeployment() (ctrl.Result, bool, error) { + // Check if Deployment already exists, else create it + cfDeployment := &appsv1.Deployment{} + if res, err := r.createManagedDeployment(cfDeployment); err != nil || (res != ctrl.Result{}) { + return res, false, err + } + + // Ensure the Deployment size is the same as the spec + if res, err := r.scaleManagedDeployment(cfDeployment); err != nil || (res != ctrl.Result{}) { + return res, false, err + } + + return ctrl.Result{}, true, nil +} + +func (r *ClusterTunnelReconciler) createManagedDeployment(cfDeployment *appsv1.Deployment) (ctrl.Result, error) { + if err := r.Get(r.ctx, apitypes.NamespacedName{Name: r.tunnel.Name, Namespace: r.Namespace}, cfDeployment); err != nil && apierrors.IsNotFound(err) { + // Define a new deployment + dep := r.deploymentForTunnel(r.tunnel) + r.log.Info("Creating a new Deployment", "Deployment.Namespace", dep.Namespace, "Deployment.Name", dep.Name) + r.Recorder.Event(r.tunnel, corev1.EventTypeNormal, "Deploying", "Creating Tunnel Deployment") + err = r.Create(r.ctx, dep) + if err != nil { + r.log.Error(err, "Failed to create new Deployment", "Deployment.Namespace", dep.Namespace, "Deployment.Name", dep.Name) + r.Recorder.Event(r.tunnel, corev1.EventTypeWarning, "FailedDeploying", "Creating Tunnel Deployment failed") + return ctrl.Result{}, err + } + r.log.Info("Deployment created", "Deployment.Namespace", dep.Namespace, "Deployment.Name", dep.Name) + r.Recorder.Event(r.tunnel, corev1.EventTypeNormal, "Deployed", "Created Tunnel Deployment") + return ctrl.Result{Requeue: true}, nil + } else if err != nil { + r.log.Error(err, "Failed to get Deployment") + r.Recorder.Event(r.tunnel, corev1.EventTypeWarning, "FailedDeployed", "Reading Tunnel Deployment failed") + return ctrl.Result{}, err + } + return ctrl.Result{}, nil +} + +func (r *ClusterTunnelReconciler) scaleManagedDeployment(cfDeployment *appsv1.Deployment) (ctrl.Result, error) { + size := r.tunnel.Spec.Size + if *cfDeployment.Spec.Replicas != size { + r.log.Info("Updating deployment", "currentReplica", *cfDeployment.Spec.Replicas, "desiredSize", size) + r.Recorder.Event(r.tunnel, corev1.EventTypeNormal, "Scaling", "Scaling Tunnel Deployment") + cfDeployment.Spec.Replicas = &size + if err := r.Update(r.ctx, cfDeployment); err != nil { + r.log.Error(err, "Failed to update Deployment", "Deployment.Namespace", cfDeployment.Namespace, "Deployment.Name", cfDeployment.Name) + r.Recorder.Event(r.tunnel, corev1.EventTypeWarning, "FailedScaling", "Failed to scale Tunnel Deployment") + return ctrl.Result{}, err + } + r.log.Info("Deployment updated") + r.Recorder.Event(r.tunnel, corev1.EventTypeNormal, "Scaled", "Scaled Tunnel Deployment") + // Ask to requeue after 1 minute in order to give enough time for the + // pods be created on the cluster side and the operand be able + // to do the next update step accurately. + return ctrl.Result{RequeueAfter: time.Minute}, nil + } + return ctrl.Result{}, nil +} + +func (r *ClusterTunnelReconciler) createManagedResources() (ctrl.Result, bool, error) { + // Check if Secret already exists, else create it + if err := r.createManagedSecret(); err != nil { + return ctrl.Result{}, false, err + } + + // Check if ConfigMap already exists, else create it + if err := r.createManagedConfigMap(); err != nil { + return ctrl.Result{}, false, err + } + + // Create Deployment if does not exist and scale it + if res, ok, err := r.createOrScaleManagedDeployment(); !ok { + return res, false, err + } + + return ctrl.Result{}, true, nil +} + +// configMapForTunnel returns a tunnel ConfigMap object +func (r *ClusterTunnelReconciler) configMapForTunnel(cfTunnel *networkingv1alpha1.ClusterTunnel) *corev1.ConfigMap { + ls := labelsForClusterTunnel(*cfTunnel) + initialConfigBytes, _ := yaml.Marshal(Configuration{ + TunnelId: cfTunnel.Status.TunnelId, + SourceFile: "/etc/cloudflared/creds/credentials.json", + Metrics: "0.0.0.0:2000", + NoAutoUpdate: true, + Ingress: []UnvalidatedIngressRule{{ + Service: "http_status:404", + }}, + }) + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: cfTunnel.Name, + Namespace: r.Namespace, + Labels: ls, + }, + Data: map[string]string{"config.yaml": string(initialConfigBytes)}, + } + // Set Tunnel instance as the owner and controller + ctrl.SetControllerReference(cfTunnel, cm, r.Scheme) + return cm +} + +// secretForTunnel returns a tunnel Secret object +func (r *ClusterTunnelReconciler) secretForTunnel(cfTunnel *networkingv1alpha1.ClusterTunnel, tunnelCreds string) *corev1.Secret { + ls := labelsForClusterTunnel(*cfTunnel) + sec := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: cfTunnel.Name, + Namespace: r.Namespace, + Labels: ls, + }, + StringData: map[string]string{"credentials.json": tunnelCreds}, + } + // Set Tunnel instance as the owner and controller + ctrl.SetControllerReference(cfTunnel, sec, r.Scheme) + return sec +} + +// deploymentForTunnel returns a tunnel Deployment object +func (r *ClusterTunnelReconciler) deploymentForTunnel(cfTunnel *networkingv1alpha1.ClusterTunnel) *appsv1.Deployment { + ls := labelsForClusterTunnel(*cfTunnel) + replicas := cfTunnel.Spec.Size + + dep := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: cfTunnel.Name, + Namespace: r.Namespace, + Labels: ls, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: &replicas, + Selector: &metav1.LabelSelector{ + MatchLabels: ls, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: ls, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{ + Image: r.tunnel.Spec.Image, + Name: "cloudflared", + Args: []string{"tunnel", "--config", "/etc/cloudflared/config/config.yaml", "run"}, + LivenessProbe: &corev1.Probe{ + Handler: corev1.Handler{ + HTTPGet: &corev1.HTTPGetAction{ + Path: "/ready", + Port: intstr.IntOrString{IntVal: 2000}, + }, + }, + FailureThreshold: 1, + InitialDelaySeconds: 10, + PeriodSeconds: 10, + }, + VolumeMounts: []corev1.VolumeMount{{ + Name: "config", + MountPath: "/etc/cloudflared/config", + ReadOnly: true, + }, { + Name: "creds", + MountPath: "/etc/cloudflared/creds", + ReadOnly: true, + }}, + Resources: corev1.ResourceRequirements{ + Limits: corev1.ResourceList{"memory": resource.MustParse("128Mi"), "cpu": resource.MustParse("500m")}, + }, + }}, + Volumes: []corev1.Volume{{ + Name: "creds", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{SecretName: cfTunnel.Name}, + }, + }, { + Name: "config", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: cfTunnel.Name}, + Items: []corev1.KeyToPath{{ + Key: "config.yaml", + Path: "config.yaml", + }}, + }, + }, + }}, + }, + }, + }, + } + // Set Tunnel instance as the owner and controller + ctrl.SetControllerReference(cfTunnel, dep, r.Scheme) + return dep +} + +// SetupWithManager sets up the controller with the Manager. +func (r *ClusterTunnelReconciler) SetupWithManager(mgr ctrl.Manager) error { + r.Recorder = mgr.GetEventRecorderFor("cloudflare-operator") + return ctrl.NewControllerManagedBy(mgr). + For(&networkingv1alpha1.ClusterTunnel{}). + Owns(&corev1.ConfigMap{}). + Owns(&corev1.Secret{}). + Owns(&appsv1.Deployment{}). + Complete(r) +} diff --git a/controllers/service_controller.go b/controllers/service_controller.go index 08581d4..ea09fea 100644 --- a/controllers/service_controller.go +++ b/controllers/service_controller.go @@ -39,75 +39,43 @@ import ( "k8s.io/client-go/tools/record" ) -const ( - // One of the Tunne CRD, ID, Name is mandatory - // Tunnel CR Name - tunnelCRAnnotation = "tunnels.networking.cfargotunnel.com/cr" - // Tunnel ID matching Tunnel Resource - tunnelIdAnnotation = "tunnels.networking.cfargotunnel.com/id" - // Tunnel Name matching Tunnel Resource Spec - tunnelNameAnnotation = "tunnels.networking.cfargotunnel.com/name" - // FQDN to create a DNS entry for and route traffic from internet on, defaults to Service name + cloudflare domain - fqdnAnnotation = "tunnels.networking.cfargotunnel.com/fqdn" - // If this annotation is set to false, do not limit searching Tunnel to Service namespace, and pick the 1st one found (Might be random?) - // If set to anything other than false, use it as a namspace where Tunnel exists - tunnelNSAnnotation = "tunnels.networking.cfargotunnel.com/ns" - - // Protocol to use between cloudflared and the Service. - // Defaults to http if protocol is tcp and port is 80, https if protocol is tcp and port is 443 - // Else, defaults to tcp if Service Proto is tcp and udp if Service Proto is udp. - // Allowed values are in tunnelValidProtoMap (http, https, tcp, udp) - tunnelProtoAnnotation = "tunnels.networking.cfargotunnel.com/proto" - tunnelProtoHTTP = "http" - tunnelProtoHTTPS = "https" - tunnelProtoTCP = "tcp" - tunnelProtoUDP = "udp" - - // Checksum of the config, used to restart pods in the deployment - tunnelConfigChecksum = "tunnels.networking.cfargotunnel.com/checksum" - - tunnelFinalizerAnnotation = "tunnels.networking.cfargotunnel.com/finalizer" - tunnelDomainLabel = "tunnels.networking.cfargotunnel.com/domain" - configHostnameLabel = "tunnels.networking.cfargotunnel.com/hostname" - configServiceLabel = "tunnels.networking.cfargotunnel.com/service" - configServiceLabelSplit = "." - configmapKey = "config.yaml" -) - -var tunnelValidProtoMap map[string]bool = map[string]bool{ - tunnelProtoHTTP: true, - tunnelProtoHTTPS: true, - tunnelProtoTCP: true, - tunnelProtoUDP: true, -} - // ServiceReconciler reconciles a Service object type ServiceReconciler struct { client.Client - Scheme *runtime.Scheme - Recorder record.EventRecorder + Scheme *runtime.Scheme + Recorder record.EventRecorder + Namespace string // Custom data for ease of (re)use - ctx context.Context - log logr.Logger - config *UnvalidatedIngressRule - tunnel *networkingv1alpha1.Tunnel - service *corev1.Service - configmap *corev1.ConfigMap - listOpts []client.ListOption - cfAPI *CloudflareAPI + ctx context.Context + log logr.Logger + config *UnvalidatedIngressRule + tunnel *networkingv1alpha1.Tunnel + clusterTunnel *networkingv1alpha1.ClusterTunnel + service *corev1.Service + configmap *corev1.ConfigMap + namespacedName apitypes.NamespacedName + cfAPI *CloudflareAPI + isClusterTunnel bool } // labelsForService returns the labels for selecting the resources served by a Tunnel. func (r ServiceReconciler) labelsForService() map[string]string { - return map[string]string{ - tunnelDomainLabel: r.tunnel.Spec.Cloudflare.Domain, + labels := map[string]string{ configHostnameLabel: r.config.Hostname, configServiceLabel: encodeCfService(r.config.Service), - tunnelNSAnnotation: r.tunnel.Namespace, - tunnelCRAnnotation: r.tunnel.Name, } + + if r.isClusterTunnel { + labels[clusterTunnelAnnotation] = r.clusterTunnel.Name + labels[tunnelDomainLabel] = r.clusterTunnel.Spec.Cloudflare.Domain + } else { + labels[tunnelAnnotation] = r.tunnel.Name + labels[tunnelDomainLabel] = r.tunnel.Spec.Cloudflare.Domain + } + + return labels } func decodeLabel(label string, service corev1.Service) string { @@ -121,62 +89,63 @@ func encodeCfService(cfService string) string { return fmt.Sprintf("%s%s%s", protoSplit[0], configServiceLabelSplit, domainSplit[1]) } -func (r ServiceReconciler) getListOpts() []client.ListOption { - // Read Service annotations. If both annotations are not set, return without doing anything - tunnelName, okName := r.service.Annotations[tunnelNameAnnotation] - tunnelId, okId := r.service.Annotations[tunnelIdAnnotation] - tunnelNS, okNS := r.service.Annotations[tunnelNSAnnotation] - tunnelCRD, okCRD := r.service.Annotations[tunnelCRAnnotation] - - // listOpts to search for ConfigMap. Set labels, and namespace restriction if - listOpts := []client.ListOption{} - labels := map[string]string{} - if okId { - labels[tunnelIdAnnotation] = tunnelId - } - if okName { - labels[tunnelNameAnnotation] = tunnelName - } - if okCRD { - labels[tunnelCRAnnotation] = tunnelCRD - } - - if tunnelNS == "true" || !okNS { // Either set to "true" or not specified - labels[tunnelNSAnnotation] = r.service.Namespace - listOpts = append(listOpts, client.InNamespace(r.service.Namespace)) - } else if okNS && tunnelNS != "false" { // Set to something that is not "false" - labels[tunnelNSAnnotation] = tunnelNS - listOpts = append(listOpts, client.InNamespace(tunnelNS)) - } // else set to "false", thus no filter on namespace, pick the 1st one - - listOpts = append(listOpts, client.MatchingLabels(labels)) - return listOpts -} - func (r *ServiceReconciler) initStruct(ctx context.Context, service *corev1.Service) error { r.ctx = ctx r.service = service - r.listOpts = r.getListOpts() - r.log.Info("setting listOpts", "listOpts", r.listOpts) + // Read Service annotations. If both annotations are not set, return without doing anything + tunnelName, okTunnel := r.service.Annotations[tunnelAnnotation] + clusterTunnelName, okClusterTunnel := r.service.Annotations[clusterTunnelAnnotation] + + if okTunnel == okClusterTunnel { + err := fmt.Errorf("cannot have both tunnel and cluster tunnel annotations") + r.log.Error(err, "error reading annotations") + r.Recorder.Event(service, corev1.EventTypeWarning, "ErrAnno", "Conflicting annotations found") + return err + } var err error - var tunnel *networkingv1alpha1.Tunnel - if tunnel, err = r.getTunnel(); err != nil { - r.log.Error(err, "unable to get tunnel for configuration") - r.Recorder.Event(service, corev1.EventTypeWarning, "ErrTunnel", "Error finding Tunnel referenced by Service") - return err + if okClusterTunnel { + r.isClusterTunnel = true + + r.namespacedName = apitypes.NamespacedName{Name: clusterTunnelName, Namespace: r.Namespace} + r.clusterTunnel = &networkingv1alpha1.ClusterTunnel{} + if err := r.Get(r.ctx, r.namespacedName, r.clusterTunnel); err != nil { + r.log.Error(err, "Failed to get ClusterTunnel", "namespacedName", r.namespacedName) + r.Recorder.Event(service, corev1.EventTypeWarning, "ErrTunnel", "Error getting ClusterTunnel") + return err + } + + if r.cfAPI, _, err = getAPIDetails(r.ctx, r.Client, r.log, r.clusterTunnel.Spec, r.clusterTunnel.Status, r.Namespace); err != nil { + r.log.Error(err, "unable to get API details") + r.Recorder.Event(service, corev1.EventTypeWarning, "ErrApiConfig", "Error getting API details") + return err + } + } else { + r.isClusterTunnel = false + + namespacedName := apitypes.NamespacedName{Name: tunnelName, Namespace: r.service.Namespace} + r.tunnel = &networkingv1alpha1.Tunnel{} + if err := r.Get(r.ctx, namespacedName, r.tunnel); err != nil { + r.log.Error(err, "Failed to get Tunnel", "namespacedName", namespacedName) + r.Recorder.Event(service, corev1.EventTypeWarning, "ErrTunnel", "Error getting Tunnel") + return err + } + + if r.cfAPI, _, err = getAPIDetails(r.ctx, r.Client, r.log, r.tunnel.Spec, r.tunnel.Status, r.Namespace); err != nil { + r.log.Error(err, "unable to get API details") + r.Recorder.Event(service, corev1.EventTypeWarning, "ErrApiConfig", "Error getting API details") + return err + } } - r.tunnel = tunnel - var configmap *corev1.ConfigMap - if configmap, err = r.getConfigMap(); err != nil { + r.configmap = &corev1.ConfigMap{} + if err := r.Get(r.ctx, r.namespacedName, r.configmap); err != nil { r.log.Error(err, "unable to get configmap for configuration") r.Recorder.Event(service, corev1.EventTypeWarning, "ErrConfigMap", "Error finding ConfigMap for Tunnel referenced by Service") return err } - r.configmap = configmap var config UnvalidatedIngressRule if config, err = r.getConfigForService("", nil); err != nil { @@ -186,19 +155,15 @@ func (r *ServiceReconciler) initStruct(ctx context.Context, service *corev1.Serv } r.config = &config - var cfAPI *CloudflareAPI - if cfAPI, _, err = getAPIDetails(r.ctx, r.Client, r.log, *r.tunnel); err != nil { - r.log.Error(err, "unable to get API details") - r.Recorder.Event(service, corev1.EventTypeWarning, "ErrApiConfig", "Error getting API details") - return err - } - r.cfAPI = cfAPI - return nil } //+kubebuilder:rbac:groups=core,resources=services,verbs=get;list;watch;update //+kubebuilder:rbac:groups=core,resources=services/finalizers,verbs=update +//+kubebuilder:rbac:groups=networking.cfargotunnel.com,resources=tunnels,verbs=get +//+kubebuilder:rbac:groups=networking.cfargotunnel.com,resources=tunnels/status,verbs=get +//+kubebuilder:rbac:groups=networking.cfargotunnel.com,resources=clustertunnels,verbs=get +//+kubebuilder:rbac:groups=networking.cfargotunnel.com,resources=clustertunnels/status,verbs=get //+kubebuilder:rbac:groups=core,resources=configmaps,verbs=get;list;watch;update;patch //+kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;update;patch //+kubebuilder:rbac:groups="",resources=events,verbs=create;patch @@ -225,11 +190,10 @@ func (r *ServiceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct return ctrl.Result{}, err } - _, okName := service.Annotations[tunnelNameAnnotation] - _, okId := service.Annotations[tunnelIdAnnotation] - _, okCRD := service.Annotations[tunnelCRAnnotation] + _, okTunnel := service.Annotations[tunnelAnnotation] + _, okClusterTunnel := service.Annotations[clusterTunnelAnnotation] - if !(okCRD || okName || okId) { + if !(okTunnel || okClusterTunnel) { // If a service with annotation is edited to remove just annotations, cleanup wont happen. // Not an issue as such, since it will be overwritten the next time it is used. return ctrl.Result{}, r.unManagedService(ctx, service) @@ -331,46 +295,18 @@ func (r *ServiceReconciler) creationLogic() error { return nil } -func (r *ServiceReconciler) getTunnel() (*networkingv1alpha1.Tunnel, error) { - // Fetch Tunnel from API - tunnelList := &networkingv1alpha1.TunnelList{} - if err := r.List(r.ctx, tunnelList, r.listOpts...); err != nil { - r.log.Error(err, "Failed to list Tunnels", "listOpts", r.listOpts) - return &networkingv1alpha1.Tunnel{}, err - } - if len(tunnelList.Items) == 0 { - err := fmt.Errorf("no tunnels found") - r.log.Error(err, "Failed to list Tunnels", "listOpts", r.listOpts) - return &networkingv1alpha1.Tunnel{}, err - } - tunnel := tunnelList.Items[0] - - return &tunnel, nil -} - -func (r ServiceReconciler) getConfigMap() (*corev1.ConfigMap, error) { - // Fetch ConfigMap from API - configMapList := &corev1.ConfigMapList{} - if err := r.List(r.ctx, configMapList, r.listOpts...); err != nil { - r.log.Error(err, "Failed to list ConfigMaps", "listOpts", r.listOpts) - return &corev1.ConfigMap{}, err - } - if len(configMapList.Items) == 0 { - err := fmt.Errorf("no configmaps found") - r.log.Error(err, "Failed to list ConfigMaps", "listOpts", r.listOpts) - return &corev1.ConfigMap{}, err - } - configmap := configMapList.Items[0] - return &configmap, nil -} - func (r *ServiceReconciler) getRelevantServices() ([]corev1.Service, error) { // Fetch Services from API - labels := map[string]string{ - tunnelNSAnnotation: r.tunnel.Namespace, - tunnelCRAnnotation: r.tunnel.Name, + var listOpts []client.ListOption + if r.isClusterTunnel { + listOpts = []client.ListOption{client.MatchingLabels(map[string]string{ + clusterTunnelAnnotation: r.clusterTunnel.Name, + })} + } else { + listOpts = []client.ListOption{client.InNamespace(r.service.Namespace), client.MatchingLabels(map[string]string{ + tunnelAnnotation: r.tunnel.Name, + })} } - listOpts := []client.ListOption{client.MatchingLabels(labels)} serviceList := &corev1.ServiceList{} if err := r.List(r.ctx, serviceList, listOpts...); err != nil { r.log.Error(err, "failed to list Services", "listOpts", listOpts) @@ -435,11 +371,11 @@ func (r ServiceReconciler) getConfigForService(tunnelDomain string, service *cor cfHostname := service.Annotations[fqdnAnnotation] - // Generate cfHostname string from Ingress Spec if not provided + // Generate cfHostname string from Service Spec if not provided if cfHostname == "" { if tunnelDomain == "" { r.log.Info("Using current tunnel's domain for generating config") - tunnelDomain = r.tunnel.Spec.Cloudflare.Domain + tunnelDomain = r.cfAPI.Domain } cfHostname = fmt.Sprintf("%s.%s", service.Name, tunnelDomain) r.log.Info("using default domain value", "domain", tunnelDomain) diff --git a/controllers/tunnel_controller.go b/controllers/tunnel_controller.go index 6363dac..b16250a 100644 --- a/controllers/tunnel_controller.go +++ b/controllers/tunnel_controller.go @@ -60,67 +60,26 @@ type TunnelReconciler struct { // belonging to the given Tunnel CR name. func labelsForTunnel(cf networkingv1alpha1.Tunnel) map[string]string { return map[string]string{ - "tunnels.networking.cfargotunnel.com/cr": cf.Name, - "tunnels.networking.cfargotunnel.com/app": "cloudflared", - "tunnels.networking.cfargotunnel.com/id": cf.Status.TunnelId, - "tunnels.networking.cfargotunnel.com/ns": cf.Namespace, - "tunnels.networking.cfargotunnel.com/name": cf.Status.TunnelName, - "tunnels.networking.cfargotunnel.com/domain": cf.Spec.Cloudflare.Domain, + tunnelAnnotation: cf.Name, + tunnelAppAnnotation: "cloudflared", + tunnelIdAnnotation: cf.Status.TunnelId, + tunnelNameAnnotation: cf.Status.TunnelName, + tunnelDomainAnnotation: cf.Spec.Cloudflare.Domain, + isClusterTunnelAnnotation: "false", } } -func getAPIDetails(ctx context.Context, c client.Client, log logr.Logger, tunnel networkingv1alpha1.Tunnel) (*CloudflareAPI, *corev1.Secret, error) { - - // Get secret containing API token - cfSecret := &corev1.Secret{} - if err := c.Get(ctx, apitypes.NamespacedName{Name: tunnel.Spec.Cloudflare.Secret, Namespace: tunnel.Namespace}, cfSecret); err != nil { - log.Error(err, "secret not found", "secret", tunnel.Spec.Cloudflare.Secret) - return &CloudflareAPI{}, &corev1.Secret{}, err - } - - // Read secret for API Token - cfAPITokenB64, ok := cfSecret.Data[tunnel.Spec.Cloudflare.CLOUDFLARE_API_TOKEN] - if !ok { - log.Info("key not found in secret", "secret", tunnel.Spec.Cloudflare.Secret, "key", tunnel.Spec.Cloudflare.CLOUDFLARE_API_TOKEN) - } - - // Read secret for API Key - cfAPIKeyB64, ok := cfSecret.Data[tunnel.Spec.Cloudflare.CLOUDFLARE_API_KEY] - if !ok { - log.Info("key not found in secret", "secret", tunnel.Spec.Cloudflare.Secret, "key", tunnel.Spec.Cloudflare.CLOUDFLARE_API_KEY) - } - - cfAPI := &CloudflareAPI{ - Log: log, - AccountName: tunnel.Spec.Cloudflare.AccountName, - AccountId: tunnel.Spec.Cloudflare.AccountId, - Domain: tunnel.Spec.Cloudflare.Domain, - APIToken: string(cfAPITokenB64), - APIKey: string(cfAPIKeyB64), - APIEmail: tunnel.Spec.Cloudflare.Email, - ValidAccountId: tunnel.Status.AccountId, - ValidTunnelId: tunnel.Status.TunnelId, - ValidTunnelName: tunnel.Status.TunnelName, - ValidZoneId: tunnel.Status.ZoneId, - } - return cfAPI, cfSecret, nil -} - func (r *TunnelReconciler) initStruct(ctx context.Context, tunnel *networkingv1alpha1.Tunnel) error { r.ctx = ctx r.tunnel = tunnel var err error - var cfAPI *CloudflareAPI - var cfSecret *corev1.Secret - if cfAPI, cfSecret, err = getAPIDetails(r.ctx, r.Client, r.log, *r.tunnel); err != nil { + if r.cfAPI, r.cfSecret, err = getAPIDetails(r.ctx, r.Client, r.log, r.tunnel.Spec, r.tunnel.Status, r.tunnel.Namespace); err != nil { r.log.Error(err, "unable to get API details") r.Recorder.Event(tunnel, corev1.EventTypeWarning, "ErrSpecSecret", "Error reading Secret to configure API") return err } - r.cfAPI = cfAPI - r.cfSecret = cfSecret return nil } @@ -280,7 +239,7 @@ func (r *TunnelReconciler) cleanupTunnel() (ctrl.Result, bool, error) { r.log.Error(err, "Error in getting deployments, might already be deleted?") bypass = true } - if *cfDeployment.Spec.Replicas != 0 { + if !bypass && *cfDeployment.Spec.Replicas != 0 { r.log.Info("Scaling down cloudflared") r.Recorder.Event(r.tunnel, corev1.EventTypeNormal, "Scaling", "Scaling down cloudflared") var size int32 = 0 @@ -352,7 +311,7 @@ func (r *TunnelReconciler) createManagedSecret() error { err = r.Create(r.ctx, sec) if err != nil { r.log.Error(err, "Failed to create new Secret", "Deployment.Namespace", sec.Namespace, "Deployment.Name", sec.Name) - r.Recorder.Event(r.tunnel, corev1.EventTypeWarning, "FailedCreateingSecret", "Creating Tunnel Secret failed") + r.Recorder.Event(r.tunnel, corev1.EventTypeWarning, "FailedCreatingSecret", "Creating Tunnel Secret failed") return err } r.log.Info("Secret created", "Secret.Namespace", sec.Namespace, "Secret.Name", sec.Name) diff --git a/controllers/utils.go b/controllers/utils.go new file mode 100644 index 0000000..b1d706f --- /dev/null +++ b/controllers/utils.go @@ -0,0 +1,97 @@ +package controllers + +import ( + "context" + + corev1 "k8s.io/api/core/v1" + apitypes "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + networkingv1alpha1 "github.com/adyanth/cloudflare-operator/api/v1alpha1" + "github.com/go-logr/logr" +) + +const ( + // Either tunnel or clustertunnel is mandatory + // Tunnel CR Name + tunnelAnnotation = "cfargotunnel.com/tunnel" + // ClusterTunnel CR Name + clusterTunnelAnnotation = "cfargotunnel.com/cluster-tunnel" + // FQDN to create a DNS entry for and route traffic from internet on, defaults to Service name + cloudflare domain + fqdnAnnotation = "cfargotunnel.com/fqdn" + + // Tunnel properties + isClusterTunnelAnnotation = "cfargotunnel.com/is-cluster-tunnel" + tunnelIdAnnotation = "cfargotunnel.com/id" + tunnelNameAnnotation = "cfargotunnel.com/name" + tunnelAppAnnotation = "cfargotunnel.com/app" + tunnelDomainAnnotation = "cfargotunnel.com/domain" + + // Protocol to use between cloudflared and the Service. + // Defaults to http if protocol is tcp and port is 80, https if protocol is tcp and port is 443 + // Else, defaults to tcp if Service Proto is tcp and udp if Service Proto is udp. + // Allowed values are in tunnelValidProtoMap (http, https, tcp, udp) + tunnelProtoAnnotation = "cfargotunnel.com/proto" + tunnelProtoHTTP = "http" + tunnelProtoHTTPS = "https" + tunnelProtoTCP = "tcp" + tunnelProtoUDP = "udp" + tunnelProtoSSH = "ssh" + tunnelProtoRDP = "rdp" + + // Checksum of the config, used to restart pods in the deployment + tunnelConfigChecksum = "cfargotunnel.com/checksum" + + tunnelFinalizerAnnotation = "cfargotunnel.com/finalizer" + tunnelDomainLabel = "cfargotunnel.com/domain" + configHostnameLabel = "cfargotunnel.com/hostname" + configServiceLabel = "cfargotunnel.com/service" + configServiceLabelSplit = "." + configmapKey = "config.yaml" +) + +var tunnelValidProtoMap map[string]bool = map[string]bool{ + tunnelProtoHTTP: true, + tunnelProtoHTTPS: true, + tunnelProtoTCP: true, + tunnelProtoUDP: true, + tunnelProtoSSH: true, + tunnelProtoRDP: true, +} + +func getAPIDetails(ctx context.Context, c client.Client, log logr.Logger, tunnelSpec networkingv1alpha1.TunnelSpec, tunnelStatus networkingv1alpha1.TunnelStatus, namespace string) (*CloudflareAPI, *corev1.Secret, error) { + + // Get secret containing API token + cfSecret := &corev1.Secret{} + if err := c.Get(ctx, apitypes.NamespacedName{Name: tunnelSpec.Cloudflare.Secret, Namespace: namespace}, cfSecret); err != nil { + log.Error(err, "secret not found", "secret", tunnelSpec.Cloudflare.Secret) + return &CloudflareAPI{}, &corev1.Secret{}, err + } + + // Read secret for API Token + cfAPITokenB64, ok := cfSecret.Data[tunnelSpec.Cloudflare.CLOUDFLARE_API_TOKEN] + if !ok { + log.Info("key not found in secret", "secret", tunnelSpec.Cloudflare.Secret, "key", tunnelSpec.Cloudflare.CLOUDFLARE_API_TOKEN) + } + + // Read secret for API Key + cfAPIKeyB64, ok := cfSecret.Data[tunnelSpec.Cloudflare.CLOUDFLARE_API_KEY] + if !ok { + log.Info("key not found in secret", "secret", tunnelSpec.Cloudflare.Secret, "key", tunnelSpec.Cloudflare.CLOUDFLARE_API_KEY) + } + + cfAPI := &CloudflareAPI{ + Log: log, + AccountName: tunnelSpec.Cloudflare.AccountName, + AccountId: tunnelSpec.Cloudflare.AccountId, + Domain: tunnelSpec.Cloudflare.Domain, + APIToken: string(cfAPITokenB64), + APIKey: string(cfAPIKeyB64), + APIEmail: tunnelSpec.Cloudflare.Email, + ValidAccountId: tunnelStatus.AccountId, + ValidTunnelId: tunnelStatus.TunnelId, + ValidTunnelName: tunnelStatus.TunnelName, + ValidZoneId: tunnelStatus.ZoneId, + } + return cfAPI, cfSecret, nil +} diff --git a/docs/getting-started.md b/docs/getting-started.md index 42234c4..dd7b15f 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -116,7 +116,7 @@ metadata: name: whoami annotations: # Specifies the name of the Tunnel resource created before - tunnels.networking.cfargotunnel.com/cr: new-tunnel + cfargotunnel.com/tunnel: new-tunnel spec: selector: app: whoami diff --git a/main.go b/main.go index efe3f2b..2957620 100644 --- a/main.go +++ b/main.go @@ -52,8 +52,10 @@ func main() { var metricsAddr string var enableLeaderElection bool var probeAddr string + var clusterResourceNamespace string flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.") flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") + flag.StringVar(&clusterResourceNamespace, "cluster-resource-namespace", "cloudflare-operator-system", "The default namespace for cluster scoped resources.") flag.BoolVar(&enableLeaderElection, "leader-elect", false, "Enable leader election for controller manager. "+ "Enabling this will ensure there is only one active controller manager.") @@ -79,8 +81,9 @@ func main() { } if err = (&controllers.ServiceReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Namespace: clusterResourceNamespace, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "Service") os.Exit(1) @@ -92,6 +95,14 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "Tunnel") os.Exit(1) } + if err = (&controllers.ClusterTunnelReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Namespace: clusterResourceNamespace, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "ClusterTunnel") + os.Exit(1) + } //+kubebuilder:scaffold:builder if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { diff --git a/samples/cluster-tunnel/crd.yaml b/samples/cluster-tunnel/crd.yaml new file mode 100644 index 0000000..ad6e364 --- /dev/null +++ b/samples/cluster-tunnel/crd.yaml @@ -0,0 +1,39 @@ +# New tunnel +apiVersion: networking.cfargotunnel.com/v1alpha1 +kind: ClusterTunnel +metadata: + name: new-tunnel +spec: + newTunnel: + name: new-k8s-cluster-tunnel + size: 2 + cloudflare: + domain: example.com + secret: cloudflare-secrets + # accountId and accountName cannot be both empty. If both are provided, Account ID is used if valid, else falls back to Account Name. + email: email@domain.com + accountName: + accountId: + # CLOUDFLARE_API_TOKEN: + # CLOUDFLARE_API_KEY: +# --- +# # Existing tunnel +# apiVersion: networking.cfargotunnel.com/v1alpha1 +# kind: ClusterTunnel +# metadata: +# name: existing-tunnel +# spec: +# existingTunnel: +# # id and name cannot be both empty. If both are provided, Tunnel ID is used if valid, else falls back to Tunnel Name. +# id: +# name: +# size: 2 +# cloudflare: +# domain: example.com +# secret: cloudflare-secrets +# # accountId and accountName cannot be both empty. If both are provided, Account ID is used if valid, else falls back to Account Name. +# accountName: +# accountId: +# # CLOUDFLARE_API_KEY: +# # CLOUDFLARE_TUNNEL_CREDENTIAL_FILE: +# # CLOUDFLARE_TUNNEL_CREDENTIAL_SECRET: diff --git a/samples/cluster-tunnel/kustomization.yaml b/samples/cluster-tunnel/kustomization.yaml new file mode 100644 index 0000000..84a593c --- /dev/null +++ b/samples/cluster-tunnel/kustomization.yaml @@ -0,0 +1,8 @@ +namespace: test-cloudflare-operator-cluster-tunnel +namePrefix: test-cloudflare-operator-cluster-tunnel- + +resources: + - "./namespace.yaml" + - "./secrets.yaml" + - "./crd.yaml" + - "./service.yaml" diff --git a/samples/cluster-tunnel/namespace.yaml b/samples/cluster-tunnel/namespace.yaml new file mode 100644 index 0000000..26f659d --- /dev/null +++ b/samples/cluster-tunnel/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: app diff --git a/samples/cluster-tunnel/secrets.yaml b/samples/cluster-tunnel/secrets.yaml new file mode 100644 index 0000000..6ebca11 --- /dev/null +++ b/samples/cluster-tunnel/secrets.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Secret +metadata: + name: cloudflare-secrets + namespace: cloudflare-operator-system # For a default installation, this will be the namespace for cluster resources +type: Opaque +data: + CLOUDFLARE_API_TOKEN: + CLOUDFLARE_API_KEY: + # CREDENTIAL_FILE is used if found, else CREDENTIAL_SECRET is used to build the file. + # Either of them is needed when using an existing tunnel + CLOUDFLARE_TUNNEL_CREDENTIAL_FILE: <~/.cloudflared/tunnelID.json, base64 encoded> + CLOUDFLARE_TUNNEL_CREDENTIAL_SECRET: diff --git a/samples/service.yaml b/samples/cluster-tunnel/service.yaml similarity index 71% rename from samples/service.yaml rename to samples/cluster-tunnel/service.yaml index 5e1a379..eb901b8 100644 --- a/samples/service.yaml +++ b/samples/cluster-tunnel/service.yaml @@ -1,13 +1,7 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: testing-crd ---- apiVersion: apps/v1 kind: Deployment metadata: name: whoami - namespace: testing-crd spec: selector: matchLabels: @@ -31,10 +25,8 @@ apiVersion: v1 kind: Service metadata: name: whoami-test - namespace: testing-crd annotations: - tunnels.networking.cfargotunnel.com/cr: new-tunnel - tunnels.networking.cfargotunnel.com/ns: default + cfargotunnel.com/cluster-tunnel: new-tunnel spec: selector: app: whoami diff --git a/samples/crd.yaml b/samples/tunnel/crd.yaml similarity index 100% rename from samples/crd.yaml rename to samples/tunnel/crd.yaml diff --git a/samples/tunnel/kustomization.yaml b/samples/tunnel/kustomization.yaml new file mode 100644 index 0000000..1ff265d --- /dev/null +++ b/samples/tunnel/kustomization.yaml @@ -0,0 +1,8 @@ +namespace: test-cloudflare-operator-tunnel-app +namePrefix: test-cloudflare-operator-tunnel- + +resources: + - "./namespace.yaml" + - "./secrets.yaml" + - "./crd.yaml" + - "./service.yaml" diff --git a/samples/tunnel/namespace.yaml b/samples/tunnel/namespace.yaml new file mode 100644 index 0000000..26f659d --- /dev/null +++ b/samples/tunnel/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: app diff --git a/samples/secrets.yaml b/samples/tunnel/secrets.yaml similarity index 100% rename from samples/secrets.yaml rename to samples/tunnel/secrets.yaml diff --git a/samples/tunnel/service.yaml b/samples/tunnel/service.yaml new file mode 100644 index 0000000..d63f311 --- /dev/null +++ b/samples/tunnel/service.yaml @@ -0,0 +1,35 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: whoami +spec: + selector: + matchLabels: + app: whoami + template: + metadata: + labels: + app: whoami + spec: + containers: + - name: whoami + image: traefik/whoami + resources: + limits: + memory: "128Mi" + cpu: "500m" + ports: + - containerPort: 80 +--- +apiVersion: v1 +kind: Service +metadata: + name: whoami-test + annotations: + cfargotunnel.com/tunnel: new-tunnel +spec: + selector: + app: whoami + ports: + - port: 80 + targetPort: 80