diff --git a/cmd/controller/main.go b/cmd/controller/main.go index 7029e3d..e458434 100644 --- a/cmd/controller/main.go +++ b/cmd/controller/main.go @@ -98,13 +98,31 @@ func main() { os.Exit(1) } + err = builder. + ControllerManagedBy(mgr). + For(&v1.ClusterOriginIssuer{}). + Complete(reconcile.AsReconciler(mgr.GetClient(), &controllers.ClusterOriginIssuerController{ + Client: mgr.GetClient(), + Reader: mgr.GetAPIReader(), + ClusterResourceNamespace: o.ClusterResourceNamespace, + Clock: clock.RealClock{}, + Factory: f, + Log: log.WithName("controllers").WithName("ClusterOriginIssuer"), + })) + + if err != nil { + log.Error(err, "could not create cluster origin issuer controller") + os.Exit(1) + } + err = builder. ControllerManagedBy(mgr). For(&certmanager.CertificateRequest{}). Complete(reconcile.AsReconciler(mgr.GetClient(), &controllers.CertificateRequestController{ - Client: mgr.GetClient(), - Reader: mgr.GetAPIReader(), - Log: log.WithName("controllers").WithName("CertificateRequest"), + Client: mgr.GetClient(), + Reader: mgr.GetAPIReader(), + ClusterResourceNamespace: o.ClusterResourceNamespace, + Log: log.WithName("controllers").WithName("CertificateRequest"), Clock: clock.RealClock{}, CheckApprovedCondition: !o.DisableApprovedCheck, diff --git a/cmd/controller/options/options.go b/cmd/controller/options/options.go index 008fb76..4fab115 100644 --- a/cmd/controller/options/options.go +++ b/cmd/controller/options/options.go @@ -7,8 +7,9 @@ import ( ) type ControllerOptions struct { - KubernetesAPIQPS float32 - KubernetesAPIBurst int + KubernetesAPIQPS float32 + KubernetesAPIBurst int + ClusterResourceNamespace string DisableApprovedCheck bool } @@ -29,6 +30,7 @@ func (o *ControllerOptions) AddFlags(fs *pflag.FlagSet) { fs.Float32Var(&o.KubernetesAPIQPS, "kube-api-qps", defaultKubernetesAPIQPS, "Maximium queries-per-second of requests to the Kubernetes apiserver.") fs.IntVar(&o.KubernetesAPIBurst, "kube-api-burst", defaultKubernetesAPIBurst, "Maximium queries-per-second burst of request send to the Kubernetes apiserver.") fs.BoolVar(&o.DisableApprovedCheck, "disable-approved-check", o.DisableApprovedCheck, "Disables waiting for CertificateRequests to have an approved condition before signing.") + fs.StringVar(&o.ClusterResourceNamespace, "cluster-resource-namespace", o.ClusterResourceNamespace, "Namespace used for cluster-scoped resources, such as secrets used by ClusterOriginIssuer") } func (o *ControllerOptions) Validate() error { @@ -40,5 +42,9 @@ func (o *ControllerOptions) Validate() error { return fmt.Errorf("invalid value for kube-api-qps: %v must be higher than 0", o.KubernetesAPIQPS) } + if o.ClusterResourceNamespace == "" { + return fmt.Errorf("invalid value for cluster-resource-namespace: must be set") + } + return nil } diff --git a/deploy/crds/cert-manager.k8s.cloudflare.com_clusteroriginissuers.yaml b/deploy/crds/cert-manager.k8s.cloudflare.com_clusteroriginissuers.yaml new file mode 100644 index 0000000..61d9af6 --- /dev/null +++ b/deploy/crds/cert-manager.k8s.cloudflare.com_clusteroriginissuers.yaml @@ -0,0 +1,119 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.13.0 + name: clusteroriginissuers.cert-manager.k8s.cloudflare.com +spec: + group: cert-manager.k8s.cloudflare.com + names: + kind: ClusterOriginIssuer + listKind: ClusterOriginIssuerList + plural: clusteroriginissuers + singular: clusteroriginissuer + scope: Cluster + versions: + - name: v1 + schema: + openAPIV3Schema: + description: A ClusterOriginIssuer represents the Cloudflare Origin CA as + an external cert-manager issuer. It is scoped to a single namespace, so + it can be used only by resources in the same namespace. + 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: Spec is the desired state of the ClusterOriginIssuer resource. + properties: + auth: + description: Auth configures how to authenticate with the Cloudflare + API. + properties: + serviceKeyRef: + description: ServiceKeyRef authenticates with an API Service Key. + properties: + key: + description: Key of the secret to select from. Must be a valid + secret key. + type: string + name: + description: Name of the secret in the issuer's namespace + to select. If a cluster-scoped issuer, the secret is selected + from the "cluster resource namespace" configured on the + controller. + type: string + required: + - key + - name + type: object + type: object + requestType: + description: RequestType is the signature algorithm Cloudflare should + use to sign the certificate. + enum: + - OriginRSA + - OriginECC + type: string + required: + - auth + - requestType + type: object + status: + description: Status of the ClusterOriginIssuer. This is set and managed + automatically. + properties: + conditions: + description: List of status conditions to indicate the status of an + OriginIssuer Known condition types are `Ready`. + items: + description: OriginIssuerCondition contains condition information + for the OriginIssuer. + properties: + lastTransitionTime: + description: LastTransitionTime is the timestamp corresponding + to the last status change of this condition. + format: date-time + type: string + message: + description: Message is a human readable description of the + details of the last transition1, complementing reason. + type: string + reason: + description: Reason is a brief machine readable explanation + for the condition's last transition. + type: string + status: + description: Status of the condition, one of ('True', 'False', + 'Unknown') + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: Type of the condition, known values are ('Ready') + enum: + - Ready + type: string + required: + - status + - type + type: object + type: array + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/deploy/crds/cert-manager.k8s.cloudflare.com_originissuers.yaml b/deploy/crds/cert-manager.k8s.cloudflare.com_originissuers.yaml index d68b373..6f6eab1 100644 --- a/deploy/crds/cert-manager.k8s.cloudflare.com_originissuers.yaml +++ b/deploy/crds/cert-manager.k8s.cloudflare.com_originissuers.yaml @@ -48,8 +48,10 @@ spec: secret key. type: string name: - description: Name of the secret in the OriginIssuer's namespace - to select from. + description: Name of the secret in the issuer's namespace + to select. If a cluster-scoped issuer, the secret is selected + from the "cluster resource namespace" configured on the + controller. type: string required: - key diff --git a/deploy/manifests/deployment.yaml b/deploy/manifests/deployment.yaml index ff5f439..9ad0e43 100644 --- a/deploy/manifests/deployment.yaml +++ b/deploy/manifests/deployment.yaml @@ -15,8 +15,10 @@ spec: spec: serviceAccountName: originissuer-control containers: - - image: cloudflare/origin-ca-issuer:v0.8.0 - name: origin-ca-controller + - name: origin-ca-controller + image: cloudflare/origin-ca-issuer:v0.8.0 + args: + - --cluster-resource-namespace=origin-ca-issuer resources: limits: cpu: "1" diff --git a/pkgs/apis/v1/doc.go b/pkgs/apis/v1/doc.go index ea0b3e3..086d6f8 100644 --- a/pkgs/apis/v1/doc.go +++ b/pkgs/apis/v1/doc.go @@ -23,5 +23,5 @@ var ( ) func init() { - SchemeBuilder.Register(&OriginIssuer{}, &OriginIssuerList{}) + SchemeBuilder.Register(&OriginIssuer{}, &OriginIssuerList{}, &ClusterOriginIssuer{}, &ClusterOriginIssuerList{}) } diff --git a/pkgs/apis/v1/types_originissuer.go b/pkgs/apis/v1/types_originissuer.go index 0e57afa..7e48798 100644 --- a/pkgs/apis/v1/types_originissuer.go +++ b/pkgs/apis/v1/types_originissuer.go @@ -32,6 +32,35 @@ type OriginIssuerList struct { Items []OriginIssuer `json:"items"` } +// +kubebuilder:object:root=true +// +kubebuilder:resource:scope=Cluster +// +kubebuilder:subresource:status + +// A ClusterOriginIssuer represents the Cloudflare Origin CA as an external cert-manager issuer. +// It is scoped to a single namespace, so it can be used only by resources in the same +// namespace. +type ClusterOriginIssuer struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + // Spec is the desired state of the ClusterOriginIssuer resource. + Spec OriginIssuerSpec `json:"spec,omitempty"` + + // Status of the ClusterOriginIssuer. This is set and managed automatically. + // +optional + Status OriginIssuerStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// ClusterOriginIssuerList is a list of OriginIssuers. +type ClusterOriginIssuerList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata.omitempty"` + + Items []ClusterOriginIssuer `json:"items"` +} + // OriginIssuerSpec is the specification of an OriginIssuer. This includes any // configuration required for the issuer. type OriginIssuerSpec struct { @@ -60,7 +89,9 @@ type OriginIssuerAuthentication struct { // SecretKeySelector contains a reference to a secret. type SecretKeySelector struct { - // Name of the secret in the OriginIssuer's namespace to select from. + // Name of the secret in the issuer's namespace to select. If a cluster-scoped + // issuer, the secret is selected from the "cluster resource namespace" configured + // on the controller. Name string `json:"name"` // Key of the secret to select from. Must be a valid secret key. Key string `json:"key"` diff --git a/pkgs/apis/v1/zz_generated.deepcopy.go b/pkgs/apis/v1/zz_generated.deepcopy.go index 8184e07..477e1c5 100644 --- a/pkgs/apis/v1/zz_generated.deepcopy.go +++ b/pkgs/apis/v1/zz_generated.deepcopy.go @@ -8,6 +8,65 @@ import ( runtime "k8s.io/apimachinery/pkg/runtime" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClusterOriginIssuer) DeepCopyInto(out *ClusterOriginIssuer) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterOriginIssuer. +func (in *ClusterOriginIssuer) DeepCopy() *ClusterOriginIssuer { + if in == nil { + return nil + } + out := new(ClusterOriginIssuer) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ClusterOriginIssuer) 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 *ClusterOriginIssuerList) DeepCopyInto(out *ClusterOriginIssuerList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]ClusterOriginIssuer, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterOriginIssuerList. +func (in *ClusterOriginIssuerList) DeepCopy() *ClusterOriginIssuerList { + if in == nil { + return nil + } + out := new(ClusterOriginIssuerList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ClusterOriginIssuerList) 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 *OriginIssuer) DeepCopyInto(out *OriginIssuer) { *out = *in diff --git a/pkgs/controllers/certificaterequest.go b/pkgs/controllers/certificaterequest.go index b6a8032..01c010f 100644 --- a/pkgs/controllers/certificaterequest.go +++ b/pkgs/controllers/certificaterequest.go @@ -27,9 +27,10 @@ const originDBWriteErrorCode = 1100 // that references this controller. type CertificateRequestController struct { client.Client - Reader client.Reader - Log logr.Logger - Factory cfapi.Factory + Reader client.Reader + ClusterResourceNamespace string + Log logr.Logger + Factory cfapi.Factory Clock clock.Clock CheckApprovedCondition bool @@ -110,32 +111,74 @@ func (r *CertificateRequestController) Reconcile(ctx context.Context, cr *certma return reconcile.Result{}, nil } - iss := v1.OriginIssuer{} - issNamespaceName := types.NamespacedName{ - Namespace: cr.Namespace, - Name: cr.Spec.IssuerRef.Name, - } + var ( + secretNamespaceName types.NamespacedName + issuerspec v1.OriginIssuerSpec + ) - if err := r.Client.Get(ctx, issNamespaceName, &iss); err != nil { - log.Error(err, "failed to retrieve OriginIssuer resource", "namespace", issNamespaceName.Namespace, "name", issNamespaceName.Name) - _ = r.setStatus(ctx, cr, cmmeta.ConditionFalse, certmanager.CertificateRequestReasonPending, fmt.Sprintf("Failed to retrieve OriginIssuer resource %s: %v", issNamespaceName, err)) + switch cr.Spec.IssuerRef.Kind { + case "OriginIssuer": + iss := v1.OriginIssuer{} + issNamespaceName := types.NamespacedName{ + Namespace: cr.Namespace, + Name: cr.Spec.IssuerRef.Name, + } - return reconcile.Result{}, err - } + if err := r.Client.Get(ctx, issNamespaceName, &iss); err != nil { + log.Error(err, "failed to retrieve OriginIssuer resource", "namespace", issNamespaceName.Namespace, "name", issNamespaceName.Name) + _ = r.setStatus(ctx, cr, cmmeta.ConditionFalse, certmanager.CertificateRequestReasonPending, fmt.Sprintf("Failed to retrieve OriginIssuer resource %s: %v", issNamespaceName, err)) + + return reconcile.Result{}, err + } + + if !IssuerStatusHasCondition(iss.Status, v1.OriginIssuerCondition{Type: v1.ConditionReady, Status: v1.ConditionTrue}) { + err := fmt.Errorf("resource %s is not ready", issNamespaceName) + log.Error(err, "issuer failed readiness checks", "namespace", issNamespaceName.Namespace, "name", issNamespaceName.Name) + _ = r.setStatus(ctx, cr, cmmeta.ConditionFalse, certmanager.CertificateRequestReasonPending, fmt.Sprintf("OriginIssuer %s is not Ready", issNamespaceName)) + + return reconcile.Result{}, err + } + + secretNamespaceName = types.NamespacedName{ + Namespace: iss.Namespace, + Name: iss.Spec.Auth.ServiceKeyRef.Name, + } + issuerspec = iss.Spec + case "ClusterOriginIssuer": + iss := v1.ClusterOriginIssuer{} + issNamespaceName := types.NamespacedName{ + Name: cr.Spec.IssuerRef.Name, + } + + if err := r.Client.Get(ctx, issNamespaceName, &iss); err != nil { + log.Error(err, "failed to retrieve OriginIssuer resource", "namespace", issNamespaceName.Namespace, "name", issNamespaceName.Name) + _ = r.setStatus(ctx, cr, cmmeta.ConditionFalse, certmanager.CertificateRequestReasonPending, fmt.Sprintf("Failed to retrieve OriginIssuer resource %s: %v", issNamespaceName, err)) + + return reconcile.Result{}, err + } - if !IssuerHasCondition(iss, v1.OriginIssuerCondition{Type: v1.ConditionReady, Status: v1.ConditionTrue}) { - err := fmt.Errorf("resource %s is not ready", issNamespaceName) - log.Error(err, "issuer failed readiness checks", "namespace", issNamespaceName.Namespace, "name", issNamespaceName.Name) - _ = r.setStatus(ctx, cr, cmmeta.ConditionFalse, certmanager.CertificateRequestReasonPending, fmt.Sprintf("OriginIssuer %s is not Ready", issNamespaceName)) + if !IssuerStatusHasCondition(iss.Status, v1.OriginIssuerCondition{Type: v1.ConditionReady, Status: v1.ConditionTrue}) { + err := fmt.Errorf("resource %s is not ready", issNamespaceName) + log.Error(err, "issuer failed readiness checks", "namespace", issNamespaceName.Namespace, "name", issNamespaceName.Name) + _ = r.setStatus(ctx, cr, cmmeta.ConditionFalse, certmanager.CertificateRequestReasonPending, fmt.Sprintf("OriginIssuer %s is not Ready", issNamespaceName)) + + return reconcile.Result{}, err + } + + secretNamespaceName = types.NamespacedName{ + Namespace: r.ClusterResourceNamespace, + Name: iss.Spec.Auth.ServiceKeyRef.Name, + } + issuerspec = iss.Spec + default: + err := fmt.Errorf("unknown issuer kind: %s", cr.Spec.IssuerRef.Kind) + log.Error(err, "certificate request references unknown issuer kind", "namespace", cr.Namespace, "name", cr.Name) + _ = r.setStatus(ctx, cr, cmmeta.ConditionFalse, certmanager.CertificateRequestReasonFailed, fmt.Sprintf("Unknown issuer kind: %s", cr.Spec.IssuerRef.Kind)) return reconcile.Result{}, err } - secret := core.Secret{} - secretNamespaceName := types.NamespacedName{ - Namespace: iss.Namespace, - Name: iss.Spec.Auth.ServiceKeyRef.Name, - } + var secret core.Secret if err := r.Reader.Get(ctx, secretNamespaceName, &secret); err != nil { log.Error(err, "failed to retieve OriginIssuer auth secret", "namespace", secretNamespaceName.Namespace, "name", secretNamespaceName.Name) if apierrors.IsNotFound(err) { @@ -147,9 +190,9 @@ func (r *CertificateRequestController) Reconcile(ctx context.Context, cr *certma return reconcile.Result{}, err } - serviceKey, ok := secret.Data[iss.Spec.Auth.ServiceKeyRef.Key] + serviceKey, ok := secret.Data[issuerspec.Auth.ServiceKeyRef.Key] if !ok { - err := fmt.Errorf("secret %s does not contain key %q", secret.Name, iss.Spec.Auth.ServiceKeyRef.Key) + err := fmt.Errorf("secret %s does not contain key %q", secret.Name, issuerspec.Auth.ServiceKeyRef.Key) log.Error(err, "failed to retrieve OriginIssuer auth secret") _ = r.setStatus(ctx, cr, cmmeta.ConditionFalse, "NotFound", fmt.Sprintf("Failed to retrieve auth secret: %v", err)) @@ -163,7 +206,7 @@ func (r *CertificateRequestController) Reconcile(ctx context.Context, cr *certma return reconcile.Result{}, err } - p, err := provisioners.New(c, iss.Spec.RequestType, log) + p, err := provisioners.New(c, issuerspec.RequestType, log) if err != nil { log.Error(err, "failed to create provisioner") diff --git a/pkgs/controllers/certificaterequest_test.go b/pkgs/controllers/certificaterequest_test.go index 5753426..c7fc31c 100644 --- a/pkgs/controllers/certificaterequest_test.go +++ b/pkgs/controllers/certificaterequest_test.go @@ -47,7 +47,7 @@ func TestCertificateRequestReconcile(t *testing.T) { namespaceName types.NamespacedName }{ { - name: "working", + name: "working OriginIssuer", objects: []runtime.Object{ cmgen.CertificateRequest("foobar", cmgen.SetCertificateRequestNamespace("default"), @@ -126,6 +126,85 @@ func TestCertificateRequestReconcile(t *testing.T) { Name: "foobar", }, }, + { + name: "working ClusterOriginIssuer", + objects: []runtime.Object{ + cmgen.CertificateRequest("foobar", + cmgen.SetCertificateRequestNamespace("default"), + cmgen.SetCertificateRequestDuration(&metav1.Duration{Duration: 7 * 24 * time.Hour}), + cmgen.SetCertificateRequestCSR((func() []byte { + csr, _, err := cmgen.CSR(x509.ECDSA) + if err != nil { + t.Fatalf("creating CSR: %s", err) + } + + return csr + })()), + cmgen.SetCertificateRequestIssuer(cmmeta.ObjectReference{ + Name: "foobar", + Kind: "ClusterOriginIssuer", + Group: "cert-manager.k8s.cloudflare.com", + }), + ), + &v1.ClusterOriginIssuer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foobar", + }, + Spec: v1.OriginIssuerSpec{ + Auth: v1.OriginIssuerAuthentication{ + ServiceKeyRef: v1.SecretKeySelector{ + Name: "service-key-issuer", + Key: "key", + }, + }, + }, + Status: v1.OriginIssuerStatus{ + Conditions: []v1.OriginIssuerCondition{ + { + Type: v1.ConditionReady, + Status: v1.ConditionTrue, + }, + }, + }, + }, + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "service-key-issuer", + Namespace: "super-secret", + }, + Data: map[string][]byte{ + "key": []byte("djEuMC0weDAwQkFCMTBD"), + }, + }, + }, + signer: SignerFunc(func(ctx context.Context, sr *cfapi.SignRequest) (*cfapi.SignResponse, error) { + return &cfapi.SignResponse{ + Id: "1", + Certificate: "bogus", + Hostnames: []string{"example.com"}, + Expiration: time.Time{}, + Type: "colemak", + Validity: 0, + CSR: "foobar", + }, nil + }), + expected: cmapi.CertificateRequestStatus{ + Conditions: []cmapi.CertificateRequestCondition{ + { + Type: cmapi.CertificateRequestConditionReady, + Status: cmmeta.ConditionTrue, + LastTransitionTime: &now, + Reason: "Issued", + Message: "Certificate issued", + }, + }, + Certificate: []byte("bogus"), + }, + namespaceName: types.NamespacedName{ + Namespace: "default", + Name: "foobar", + }, + }, { name: "requeue after API error", objects: []runtime.Object{ @@ -203,9 +282,10 @@ func TestCertificateRequestReconcile(t *testing.T) { Build() controller := &CertificateRequestController{ - Client: client, - Reader: client, - Log: logf.Log, + Client: client, + Reader: client, + ClusterResourceNamespace: "super-secret", + Log: logf.Log, Factory: cfapi.FactoryFunc(func(serviceKey []byte) (cfapi.Interface, error) { return tt.signer, nil }), diff --git a/pkgs/controllers/clusteroriginissuer.go b/pkgs/controllers/clusteroriginissuer.go new file mode 100644 index 0000000..11c0a7f --- /dev/null +++ b/pkgs/controllers/clusteroriginissuer.go @@ -0,0 +1,74 @@ +package controllers + +import ( + "context" + "fmt" + + "github.com/cloudflare/origin-ca-issuer/internal/cfapi" + v1 "github.com/cloudflare/origin-ca-issuer/pkgs/apis/v1" + "github.com/go-logr/logr" + core "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/clock" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +// ClusterOriginIssuerController implements a controller that watches for changes +// to OriginIssuer resources. +type ClusterOriginIssuerController struct { + client.Client + Reader client.Reader + ClusterResourceNamespace string + Log logr.Logger + Clock clock.Clock + Factory cfapi.Factory +} + +// Reconcile reconciles ClusterOriginIssuer resources by managing Cloudflare API provisioners. +func (r *ClusterOriginIssuerController) Reconcile(ctx context.Context, iss *v1.ClusterOriginIssuer) (reconcile.Result, error) { + log := r.Log.WithValues("namespace", iss.Namespace, "clusteroriginissuer", iss.Name) + + if err := validateOriginIssuer(iss.Spec); err != nil { + log.Error(err, "failed to validate ClusterOriginIssuer resource") + + return reconcile.Result{}, err + } + + secret := core.Secret{} + secretNamespaceName := types.NamespacedName{ + Namespace: r.ClusterResourceNamespace, + Name: iss.Spec.Auth.ServiceKeyRef.Name, + } + + if err := r.Reader.Get(ctx, secretNamespaceName, &secret); err != nil { + log.Error(err, "failed to retieve ClusterOriginIssuer auth secret", "namespace", secretNamespaceName.Namespace, "name", secretNamespaceName.Name) + + if apierrors.IsNotFound(err) { + _ = r.setStatus(ctx, iss, v1.ConditionFalse, "NotFound", fmt.Sprintf("Failed to retrieve auth secret: %v", err)) + } else { + _ = r.setStatus(ctx, iss, v1.ConditionFalse, "Error", fmt.Sprintf("Failed to retrieve auth secret: %v", err)) + } + + return reconcile.Result{}, err + } + + _, ok := secret.Data[iss.Spec.Auth.ServiceKeyRef.Key] + if !ok { + err := fmt.Errorf("secret %s does not contain key %q", secret.Name, iss.Spec.Auth.ServiceKeyRef.Key) + log.Error(err, "failed to retrieve ClusterOriginIssuer auth secret") + _ = r.setStatus(ctx, iss, v1.ConditionFalse, "NotFound", fmt.Sprintf("Failed to retrieve auth secret: %v", err)) + + return reconcile.Result{}, err + } + + return reconcile.Result{}, r.setStatus(ctx, iss, v1.ConditionTrue, "Verified", "ClusterOriginIssuer verified and ready to sign certificates") +} + +// setStatus is a helper function to set the Issuer status condition with reason and message, and update the API. +func (r *ClusterOriginIssuerController) setStatus(ctx context.Context, iss *v1.ClusterOriginIssuer, status v1.ConditionStatus, reason, message string) error { + SetIssuerStatusCondition(&iss.Status, v1.ConditionReady, status, r.Log, r.Clock, reason, message) + + return r.Client.Status().Update(ctx, iss) +} diff --git a/pkgs/controllers/clusteroriginissuer_test.go b/pkgs/controllers/clusteroriginissuer_test.go new file mode 100644 index 0000000..3761853 --- /dev/null +++ b/pkgs/controllers/clusteroriginissuer_test.go @@ -0,0 +1,200 @@ +package controllers + +import ( + "context" + "testing" + "time" + + cmapi "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" + "github.com/cloudflare/origin-ca-issuer/internal/cfapi" + v1 "github.com/cloudflare/origin-ca-issuer/pkgs/apis/v1" + "github.com/google/go-cmp/cmp" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes/scheme" + fakeClock "k8s.io/utils/clock/testing" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +func TestClusterOriginIssuerReconcile(t *testing.T) { + if err := cmapi.AddToScheme(scheme.Scheme); err != nil { + t.Fatal(err) + } + + if err := v1.AddToScheme(scheme.Scheme); err != nil { + t.Fatal(err) + } + + clock := fakeClock.NewFakeClock(time.Now().Truncate(time.Second)) + now := metav1.NewTime(clock.Now()) + + tests := []struct { + name string + objects []runtime.Object + expected v1.OriginIssuerStatus + error string + namespaceName types.NamespacedName + }{ + { + name: "working with secrets", + objects: []runtime.Object{ + &v1.ClusterOriginIssuer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + Spec: v1.OriginIssuerSpec{ + RequestType: v1.RequestTypeOriginRSA, + Auth: v1.OriginIssuerAuthentication{ + ServiceKeyRef: v1.SecretKeySelector{ + Name: "issuer-service-key", + Key: "key", + }, + }, + }, + }, + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "issuer-service-key", + Namespace: "super-secret", + }, + Data: map[string][]byte{ + "key": []byte("djEuMC0weDAwQkFCMTBD"), + }, + }, + }, + expected: v1.OriginIssuerStatus{ + Conditions: []v1.OriginIssuerCondition{ + { + Type: v1.ConditionReady, + Status: v1.ConditionTrue, + LastTransitionTime: &now, + Reason: "Verified", + Message: "ClusterOriginIssuer verified and ready to sign certificates", + }, + }, + }, + namespaceName: types.NamespacedName{ + Name: "foo", + }, + }, + { + name: "missing secret", + objects: []runtime.Object{ + &v1.ClusterOriginIssuer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + Spec: v1.OriginIssuerSpec{ + RequestType: v1.RequestTypeOriginRSA, + Auth: v1.OriginIssuerAuthentication{ + ServiceKeyRef: v1.SecretKeySelector{ + Name: "issuer-service-key", + Key: "key", + }, + }, + }, + }, + }, + expected: v1.OriginIssuerStatus{ + Conditions: []v1.OriginIssuerCondition{ + { + Type: v1.ConditionReady, + Status: v1.ConditionFalse, + LastTransitionTime: &now, + Reason: "NotFound", + Message: `Failed to retrieve auth secret: secrets "issuer-service-key" not found`, + }, + }, + }, + error: `secrets "issuer-service-key" not found`, + namespaceName: types.NamespacedName{ + Name: "foo", + }, + }, + { + name: "secret missing key", + objects: []runtime.Object{ + &v1.ClusterOriginIssuer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + Spec: v1.OriginIssuerSpec{ + RequestType: v1.RequestTypeOriginRSA, + Auth: v1.OriginIssuerAuthentication{ + ServiceKeyRef: v1.SecretKeySelector{ + Name: "issuer-service-key", + Key: "key", + }, + }, + }, + }, + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "issuer-service-key", + Namespace: "super-secret", + }, + Data: map[string][]byte{}, + }, + }, + expected: v1.OriginIssuerStatus{ + Conditions: []v1.OriginIssuerCondition{ + { + Type: v1.ConditionReady, + Status: v1.ConditionFalse, + LastTransitionTime: &now, + Reason: "NotFound", + Message: `Failed to retrieve auth secret: secret issuer-service-key does not contain key "key"`, + }, + }, + }, + error: `secret issuer-service-key does not contain key "key"`, + namespaceName: types.NamespacedName{ + Name: "foo", + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + client := fake.NewClientBuilder(). + WithScheme(scheme.Scheme). + WithRuntimeObjects(tt.objects...). + WithStatusSubresource(&v1.ClusterOriginIssuer{}). + Build() + + controller := &ClusterOriginIssuerController{ + Client: client, + Reader: client, + ClusterResourceNamespace: "super-secret", + Factory: cfapi.FactoryFunc(func(serviceKey []byte) (cfapi.Interface, error) { + return nil, nil + }), + Clock: clock, + Log: logf.Log, + } + + _, err := reconcile.AsReconciler(client, controller).Reconcile(context.Background(), reconcile.Request{ + NamespacedName: tt.namespaceName, + }) + + if err != nil { + if diff := cmp.Diff(err.Error(), tt.error); diff != "" { + t.Fatalf("diff: (-wanted +got)\n%s", diff) + } + } + + got := &v1.ClusterOriginIssuer{} + if err := client.Get(context.TODO(), tt.namespaceName, got); err != nil { + t.Fatalf("expected to retrieve cluster issuer from client: %s", err) + } + if diff := cmp.Diff(got.Status, tt.expected); diff != "" { + t.Fatalf("diff: (-want +got)\n%s", diff) + } + }) + } +} diff --git a/pkgs/controllers/originissuer.go b/pkgs/controllers/originissuer.go index c1dae32..1747a25 100644 --- a/pkgs/controllers/originissuer.go +++ b/pkgs/controllers/originissuer.go @@ -74,7 +74,7 @@ func (r *OriginIssuerController) Reconcile(ctx context.Context, iss *v1.OriginIs // setStatus is a helper function to set the Issuer status condition with reason and message, and update the API. func (r *OriginIssuerController) setStatus(ctx context.Context, iss *v1.OriginIssuer, status v1.ConditionStatus, reason, message string) error { - SetIssuerCondition(iss, v1.ConditionReady, status, r.Log, r.Clock, reason, message) + SetIssuerStatusCondition(&iss.Status, v1.ConditionReady, status, r.Log, r.Clock, reason, message) return r.Client.Status().Update(ctx, iss) } diff --git a/pkgs/controllers/originissuer_suite_test.go b/pkgs/controllers/originissuer_suite_test.go index e0dfb52..d1839dd 100644 --- a/pkgs/controllers/originissuer_suite_test.go +++ b/pkgs/controllers/originissuer_suite_test.go @@ -134,7 +134,7 @@ func TestOriginIssuerReconcileSuite(t *testing.T) { return false } - return IssuerHasCondition(iss, v1.OriginIssuerCondition{Type: v1.ConditionReady, Status: v1.ConditionTrue}) + return IssuerStatusHasCondition(iss.Status, v1.OriginIssuerCondition{Type: v1.ConditionReady, Status: v1.ConditionTrue}) }, 5*time.Second, 10*time.Millisecond, "OriginIssuer reconciler") } diff --git a/pkgs/controllers/util.go b/pkgs/controllers/util.go index 11b7561..588e173 100644 --- a/pkgs/controllers/util.go +++ b/pkgs/controllers/util.go @@ -7,12 +7,13 @@ import ( "k8s.io/utils/clock" ) -// IssuerHasCondition will return true if the given OriginIssuer has a condition -// matching the provided OriginIssuerCondtion. Only the Type and Status fields -// are used in the comparison, meaning this function will return `true` even if -// the Reason, Message, and LastTransitionTime fields do not match. -func IssuerHasCondition(iss v1.OriginIssuer, c v1.OriginIssuerCondition) bool { - for _, cond := range iss.Status.Conditions { +// IssuerStatusHasCondition will return true if the given OriginIssuerStatus has +// a condition matching the provided OriginIssuerCondtion. Only the Type and +// Status fields are used in the comparison, meaning this function will return +// `true` even if the Reason, Message, and LastTransitionTime fields do not +// match. +func IssuerStatusHasCondition(status v1.OriginIssuerStatus, c v1.OriginIssuerCondition) bool { + for _, cond := range status.Conditions { if c.Type == cond.Type && c.Status == cond.Status { return true } @@ -21,7 +22,7 @@ func IssuerHasCondition(iss v1.OriginIssuer, c v1.OriginIssuerCondition) bool { return false } -// SetIssuerCondition will set a condition on the given OriginIssuer. +// SetIssuerStatusCondition will set a condition on the given OriginIssuerStatus. // // If no condition of the same type exists, the condition will be inserted with // the LastTransitionTime set to the current time. @@ -32,7 +33,7 @@ func IssuerHasCondition(iss v1.OriginIssuer, c v1.OriginIssuerCondition) bool { // If a condition of the same type and different state already exists, the // condition will be updated and the LastTransitionTime set to the current // time. -func SetIssuerCondition(iss *v1.OriginIssuer, conditionType v1.ConditionType, status v1.ConditionStatus, log logr.Logger, cl clock.Clock, reason, message string) { +func SetIssuerStatusCondition(ois *v1.OriginIssuerStatus, conditionType v1.ConditionType, status v1.ConditionStatus, log logr.Logger, cl clock.Clock, reason, message string) { now := metav1.NewTime(cl.Now()) c := v1.OriginIssuerCondition{ Type: conditionType, @@ -42,7 +43,7 @@ func SetIssuerCondition(iss *v1.OriginIssuer, conditionType v1.ConditionType, st LastTransitionTime: &now, } - for i, condition := range iss.Status.Conditions { + for i, condition := range ois.Conditions { if condition.Type != conditionType { continue } @@ -57,10 +58,10 @@ func SetIssuerCondition(iss *v1.OriginIssuer, conditionType v1.ConditionType, st ) } - iss.Status.Conditions[i] = c + ois.Conditions[i] = c return } - iss.Status.Conditions = append(iss.Status.Conditions, c) + ois.Conditions = append(ois.Conditions, c) }