Files
capsule/internal/controllers/tls/manager.go
T
Oliver Bähler 65a4e19e02 feat: requests and limit policies (#2095)
* chore: save progress

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat: requests and limit policies

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat: requests and limit policies

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat: requests and limit policies

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat: requests and limit policies

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat: requests and limit policies

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat: requests and limit policies

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat: requests and limit policies

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat: requests and limit policies

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat: requests and limit policies

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

---------

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>
2026-08-21 14:31:06 +02:00

710 lines
20 KiB
Go

// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
//nolint:nestif
package tls
import (
"bytes"
"context"
"crypto/x509"
"fmt"
"time"
"github.com/go-logr/logr"
"golang.org/x/sync/errgroup"
admissionregistrationv1 "k8s.io/api/admissionregistration/v1"
corev1 "k8s.io/api/core/v1"
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/util/retry"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/builder"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
"sigs.k8s.io/controller-runtime/pkg/handler"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
"github.com/projectcapsule/capsule/internal/controllers/utils"
"github.com/projectcapsule/capsule/pkg/runtime/cert"
"github.com/projectcapsule/capsule/pkg/runtime/configuration"
"github.com/projectcapsule/capsule/pkg/runtime/predicates"
)
const (
certificateExpirationThreshold = 3 * 24 * time.Hour
certificateValidity = 6 * 30 * 24 * time.Hour
)
type Reconciler struct {
client.Client
Log logr.Logger
Scheme *runtime.Scheme
Namespace string
Configuration configuration.Configuration
}
func (r *Reconciler) SetupWithManager(mgr ctrl.Manager, ctrlConfig utils.ControllerOptions) error {
enqueueFn := handler.EnqueueRequestsFromMapFunc(func(context.Context, client.Object) []reconcile.Request {
return []reconcile.Request{
{
NamespacedName: types.NamespacedName{
Namespace: configuration.ControllerNamespace(),
Name: r.Configuration.TLSSecretName(),
},
},
}
})
return ctrl.NewControllerManagedBy(mgr).
For(
&corev1.Secret{},
builder.WithPredicates(
predicates.NamesMatchingPredicate{
Names: []string{r.Configuration.TLSSecretName()},
},
),
).
Named("capsule/tls").
WatchesMetadata(
&admissionregistrationv1.ValidatingWebhookConfiguration{},
enqueueFn,
builder.WithPredicates(
predicates.NamesMatchingPredicate{
Names: []string{string(r.Configuration.Admission().Validating.Name)},
},
),
).
WatchesMetadata(
&admissionregistrationv1.MutatingWebhookConfiguration{},
enqueueFn,
builder.WithPredicates(
predicates.NamesMatchingPredicate{
Names: []string{string(r.Configuration.Admission().Mutating.Name)},
},
),
).
WatchesMetadata(
&apiextensionsv1.CustomResourceDefinition{},
enqueueFn,
builder.WithPredicates(
predicates.NamesMatchingPredicate{
Names: r.managedCRDNames(),
},
),
).
WithOptions(ctrlConfig.Runtime.ToControllerOptions()).
Complete(r)
}
func (r *Reconciler) Reconcile(ctx context.Context, request ctrl.Request) (ctrl.Result, error) {
log := r.Log.WithValues(
"Request.Namespace", request.Namespace,
"Request.Name", request.Name,
)
if request.Namespace == "" {
request.Namespace = r.Namespace
}
if request.Name == "" {
request.Name = r.Configuration.TLSSecretName()
}
log.V(4).Info("TLS reconciliation started")
certSecret := &corev1.Secret{}
if err := r.Get(ctx, request.NamespacedName, certSecret); err != nil {
if !apierrors.IsNotFound(err) {
return ctrl.Result{}, err
}
certSecret = &corev1.Secret{}
certSecret.Name = request.Name
certSecret.Namespace = request.Namespace
certSecret.Data = map[string][]byte{}
}
if err := r.ReconcileCertificates(ctx, log, certSecret); err != nil {
return ctrl.Result{}, err
}
servingCert, err := cert.GetCertificateFromBytes(certSecret.Data[corev1.TLSCertKey])
if err != nil {
return ctrl.Result{}, err
}
requeueTime := servingCert.NotAfter.Add(-(certificateExpirationThreshold - time.Second))
requeueAfter := max(time.Until(requeueTime), 0)
log.V(4).Info("TLS reconciliation completed", "requeueAfter", requeueAfter.String())
return ctrl.Result{
Requeue: true,
RequeueAfter: requeueAfter,
}, nil
}
func (r *Reconciler) ReconcileCertificates(
ctx context.Context,
log logr.Logger,
certSecret *corev1.Secret,
) error {
sans, err := r.desiredWebhookSANs(ctx)
if err != nil {
return err
}
log.V(4).Info(
"Resolved desired webhook certificate SANs",
"dnsNames", sans.DNSNames,
"ipAddresses", cert.IPsToStrings(sans.IPAddrs),
)
if err := r.reconcileTLSSecret(ctx, log, certSecret, sans); err != nil {
return err
}
caBundle := certSecret.Data[corev1.ServiceAccountRootCAKey]
if len(caBundle) == 0 {
return fmt.Errorf("missing %q field in %q secret", corev1.ServiceAccountRootCAKey, r.Configuration.TLSSecretName())
}
log.V(5).Info("Patching caBundle in admission webhooks and managed CRD conversions")
patchGroup, groupCtx := errgroup.WithContext(ctx)
patchGroup.Go(func() error {
return r.patchMutatingWebhookConfigurationCABundle(groupCtx, caBundle)
})
patchGroup.Go(func() error {
return r.patchValidatingWebhookConfigurationCABundle(groupCtx, caBundle)
})
for key, managed := range r.conversionManagedCRDs() {
patchGroup.Go(func() error {
if err := r.updateManagedCustomResourceDefinition(groupCtx, managed, caBundle); err != nil {
return fmt.Errorf("cannot update managed CRD %q (%s): %w", key, managed.Name, err)
}
return nil
})
}
return patchGroup.Wait()
}
// reconcileTLSSecret calculates certificate material from the latest persisted
// Secret inside an optimistic-concurrency retry. This is deliberately not based
// on the object supplied by the caller: every controller replica performs the
// startup reconciliation before leader election, so that object may already be
// stale by the time certificate generation finishes.
func (r *Reconciler) reconcileTLSSecret(
ctx context.Context,
log logr.Logger,
certSecret *corev1.Secret,
sans cert.CertificateSANs,
) error {
key := client.ObjectKeyFromObject(certSecret)
if key.Name == "" {
key.Name = r.Configuration.TLSSecretName()
}
if key.Namespace == "" {
key.Namespace = r.Namespace
}
err := retry.OnError(retry.DefaultBackoff, func(err error) bool {
return apierrors.IsAlreadyExists(err) || apierrors.IsConflict(err)
}, func() error {
// Use the TLS type when Capsule creates the Secret itself. If the Secret
// already exists, CreateOrUpdate loads its persisted type into desired;
// preserve it because Secret type is immutable. In particular, the Helm
// chart pre-creates an empty Opaque Secret so the controller Pod can mount
// it before this reconciliation supplies the certificate data.
desired := &corev1.Secret{
ObjectMeta: *certSecret.ObjectMeta.DeepCopy(),
Type: corev1.SecretTypeTLS,
}
desired.Name = key.Name
desired.Namespace = key.Namespace
_, err := controllerutil.CreateOrUpdate(ctx, r.Client, desired, func() error {
if desired.Labels == nil {
desired.Labels = map[string]string{}
}
if desired.Annotations == nil {
desired.Annotations = map[string]string{}
}
ca, _, rotateServingCert, err := r.ensureCertificateMaterial(log, desired, sans)
if err != nil {
return err
}
log.V(4).Info(
"certificate requires rotation",
"rotation", rotateServingCert,
)
if rotateServingCert {
if ca == nil {
return fmt.Errorf("cannot rotate serving certificate without CA private key")
}
crt, key, err := ca.GenerateCertificate(cert.NewCertOpts(
time.Now().Add(certificateValidity),
sans,
))
if err != nil {
return fmt.Errorf("generate serving TLS certificate: %w", err)
}
desired.Data[corev1.TLSCertKey] = crt.Bytes()
desired.Data[corev1.TLSPrivateKeyKey] = key.Bytes()
}
return r.validateSecretCertificate(desired, sans)
})
if err != nil {
return err
}
certSecret.ObjectMeta = desired.ObjectMeta
certSecret.Type = desired.Type
certSecret.Immutable = desired.Immutable
certSecret.Data = copySecretData(desired.Data)
return nil
})
if err != nil {
r.Log.Error(err, "cannot reconcile Capsule TLS Secret", "secret", key.String())
}
return err
}
// ensureCertificateMaterial ensures that the Secret contains a stable CA
// certificate/key pair and decides whether the serving certificate must be
// regenerated.
//
// Important behavior:
// - Only a new, empty Secret bootstraps a CA.
// - Existing valid CA is reused.
// - Serving certificate renewal never rotates the CA.
// - Established or externally managed CA material is never replaced
// automatically, because doing so would immediately invalidate every
// published caBundle.
func (r *Reconciler) ensureCertificateMaterial(
log logr.Logger,
certSecret *corev1.Secret,
sans cert.CertificateSANs,
) (*cert.CapsuleCA, []byte, bool, error) {
sans = sans.Normalize()
if sans.Empty() {
return nil, nil, false, fmt.Errorf("cannot ensure TLS material without SANs")
}
if certSecret.Data == nil {
certSecret.Data = map[string][]byte{}
}
caBundle := certSecret.Data[corev1.ServiceAccountRootCAKey]
caKey := certSecret.Data["ca.key"]
hasCABundle := len(caBundle) > 0
hasCAKey := len(caKey) > 0
var ca *cert.CapsuleCA
rotateServingCert := false
switch {
case hasCABundle && hasCAKey:
loadedCA, err := cert.NewCertificateAuthorityFromBytes(caBundle, caKey)
if err != nil {
return nil, nil, false, fmt.Errorf(
"TLS Secret %s contains invalid CA certificate/key material; refusing automatic CA replacement: %w",
client.ObjectKeyFromObject(certSecret).String(),
err,
)
}
ca = loadedCA
case hasCABundle && !hasCAKey:
// This is an externally managed or legacy Secret. It is safe to keep
// serving while its certificate remains valid, but Capsule cannot renew
// it without the CA key. Replacing that CA in-place would make the API
// server distrust one or more running webhook replicas during rollout.
if err := r.validateSecretCertificate(certSecret, sans); err != nil {
return nil, nil, false, fmt.Errorf(
"TLS Secret %s has no CA private key and its serving certificate needs renewal; refusing automatic CA replacement: %w",
client.ObjectKeyFromObject(certSecret).String(),
err,
)
}
log.V(3).Info(
"Keeping externally managed TLS material without a CA private key",
"secret", client.ObjectKeyFromObject(certSecret).String(),
)
return nil, caBundle, false, nil
case !hasCABundle && hasCAKey:
return nil, nil, false, fmt.Errorf(
"TLS Secret %s contains a CA private key but no CA certificate; refusing automatic CA replacement",
client.ObjectKeyFromObject(certSecret).String(),
)
default:
if len(certSecret.Data[corev1.TLSCertKey]) > 0 || len(certSecret.Data[corev1.TLSPrivateKeyKey]) > 0 {
return nil, nil, false, fmt.Errorf(
"TLS Secret %s contains serving certificate material but no CA certificate; refusing automatic CA replacement",
client.ObjectKeyFromObject(certSecret).String(),
)
}
log.V(10).Info(
"TLS Secret is empty, generating initial CA",
"secret", client.ObjectKeyFromObject(certSecret).String(),
)
generatedCA, generatedCABundle, generatedCAKey, err := generateCertificateAuthorityMaterial()
if err != nil {
return nil, nil, false, err
}
ca = generatedCA
caBundle = generatedCABundle
certSecret.Data[corev1.ServiceAccountRootCAKey] = generatedCABundle
certSecret.Data["ca.key"] = generatedCAKey
rotateServingCert = true
}
servingCertPEM := certSecret.Data[corev1.TLSCertKey]
servingKeyPEM := certSecret.Data[corev1.TLSPrivateKeyKey]
if len(servingCertPEM) == 0 || len(servingKeyPEM) == 0 {
log.V(10).Info(
"TLS Secret is missing serving certificate material, rotating serving certificate",
"secret", client.ObjectKeyFromObject(certSecret).String(),
)
rotateServingCert = true
} else {
servingCert, err := cert.GetCertificateFromBytes(servingCertPEM)
if err != nil {
log.V(10).Info(
"Failed to parse serving certificate, rotating serving certificate",
"secret", client.ObjectKeyFromObject(certSecret).String(),
"error", err.Error(),
)
rotateServingCert = true
} else {
if time.Until(servingCert.NotAfter) <= certificateExpirationThreshold {
log.V(10).Info(
"Serving certificate is close to expiry, rotating serving certificate",
"secret", client.ObjectKeyFromObject(certSecret).String(),
"notAfter", servingCert.NotAfter,
)
rotateServingCert = true
}
if !sans.MatchesCertificate(servingCert) {
log.V(3).Info(
"Serving certificate SANs differ from desired SANs, rotating serving certificate",
"secret", client.ObjectKeyFromObject(certSecret).String(),
"desiredDNSNames", sans.DNSNames,
"desiredIPAddresses", cert.IPsToStrings(sans.IPAddrs),
"currentDNSNames", servingCert.DNSNames,
"currentIPAddresses", cert.IPsToStrings(servingCert.IPAddresses),
)
rotateServingCert = true
}
if err := r.validateSecretCertificate(certSecret, sans); err != nil {
log.V(10).Info(
"Serving certificate failed validation, rotating serving certificate",
"secret", client.ObjectKeyFromObject(certSecret).String(),
"error", err.Error(),
)
rotateServingCert = true
}
}
}
if rotateServingCert && ca == nil {
return nil, nil, false, fmt.Errorf(
"cannot rotate serving certificate for TLS Secret %s without CA private key",
client.ObjectKeyFromObject(certSecret).String(),
)
}
return ca, caBundle, rotateServingCert, nil
}
func generateCertificateAuthorityMaterial() (*cert.CapsuleCA, []byte, []byte, error) {
ca, err := cert.GenerateCertificateAuthority()
if err != nil {
return nil, nil, nil, err
}
caCrt, err := ca.CACertificatePem()
if err != nil {
return nil, nil, nil, err
}
caKey, err := ca.CAPrivateKeyPem()
if err != nil {
return nil, nil, nil, err
}
return ca, caCrt.Bytes(), caKey.Bytes(), nil
}
func (r *Reconciler) patchMutatingWebhookConfigurationCABundle(ctx context.Context, caBundle []byte) error {
return r.patchAdmissionConfigurationCABundle(
ctx,
r.Configuration.MutatingWebhookConfigurationName(),
caBundle,
func() client.Object { return &admissionregistrationv1.MutatingWebhookConfiguration{} },
updateMutatingWebhookCABundles,
)
}
func (r *Reconciler) patchValidatingWebhookConfigurationCABundle(ctx context.Context, caBundle []byte) error {
return r.patchAdmissionConfigurationCABundle(
ctx,
r.Configuration.ValidatingWebhookConfigurationName(),
caBundle,
func() client.Object { return &admissionregistrationv1.ValidatingWebhookConfiguration{} },
updateValidatingWebhookCABundles,
)
}
func (r *Reconciler) patchAdmissionConfigurationCABundle(
ctx context.Context,
name string,
caBundle []byte,
newConfiguration func() client.Object,
updateCABundles func(client.Object, []byte) (bool, error),
) error {
if name == "" {
return nil
}
return retry.RetryOnConflict(retry.DefaultBackoff, func() error {
configuration := newConfiguration()
if err := r.Get(ctx, types.NamespacedName{Name: name}, configuration); err != nil {
if apierrors.IsNotFound(err) {
return nil
}
return err
}
before, ok := configuration.DeepCopyObject().(client.Object)
if !ok {
return fmt.Errorf("admission configuration %q cannot be deep-copied as a client object", name)
}
changed, err := updateCABundles(configuration, caBundle)
if err != nil {
return err
}
if !changed {
return nil
}
return r.Patch(ctx, configuration, client.MergeFrom(before))
})
}
func updateMutatingWebhookCABundles(object client.Object, caBundle []byte) (bool, error) {
configuration, ok := object.(*admissionregistrationv1.MutatingWebhookConfiguration)
if !ok {
return false, fmt.Errorf("expected MutatingWebhookConfiguration, got %T", object)
}
changed := false
for index := range configuration.Webhooks {
if bytes.Equal(configuration.Webhooks[index].ClientConfig.CABundle, caBundle) {
continue
}
configuration.Webhooks[index].ClientConfig.CABundle = append([]byte(nil), caBundle...)
changed = true
}
return changed, nil
}
func updateValidatingWebhookCABundles(object client.Object, caBundle []byte) (bool, error) {
configuration, ok := object.(*admissionregistrationv1.ValidatingWebhookConfiguration)
if !ok {
return false, fmt.Errorf("expected ValidatingWebhookConfiguration, got %T", object)
}
changed := false
for index := range configuration.Webhooks {
if bytes.Equal(configuration.Webhooks[index].ClientConfig.CABundle, caBundle) {
continue
}
configuration.Webhooks[index].ClientConfig.CABundle = append([]byte(nil), caBundle...)
changed = true
}
return changed, nil
}
func (r *Reconciler) validateSecretCertificate(
certSecret *corev1.Secret,
sans cert.CertificateSANs,
) error {
caPEM := certSecret.Data[corev1.ServiceAccountRootCAKey]
if len(caPEM) == 0 {
return fmt.Errorf("missing %q in TLS Secret %s/%s",
corev1.ServiceAccountRootCAKey,
certSecret.Namespace,
certSecret.Name,
)
}
roots := x509.NewCertPool()
if ok := roots.AppendCertsFromPEM(caPEM); !ok {
return fmt.Errorf("failed to parse %q in TLS Secret %s/%s",
corev1.ServiceAccountRootCAKey,
certSecret.Namespace,
certSecret.Name,
)
}
leaf, err := cert.GetCertificateFromBytes(certSecret.Data[corev1.TLSCertKey])
if err != nil {
return fmt.Errorf("parse serving certificate from TLS Secret %s/%s: %w",
certSecret.Namespace,
certSecret.Name,
err,
)
}
keyPEM := certSecret.Data[corev1.TLSPrivateKeyKey]
if len(keyPEM) == 0 {
return fmt.Errorf("missing %q in TLS Secret %s/%s",
corev1.TLSPrivateKeyKey,
certSecret.Namespace,
certSecret.Name,
)
}
key, err := cert.GetPrivateKeyFromBytes(keyPEM)
if err != nil {
return fmt.Errorf("parse serving private key from TLS Secret %s/%s: %w",
certSecret.Namespace,
certSecret.Name,
err,
)
}
if err := cert.ValidateCertificate(leaf, key, certificateExpirationThreshold); err != nil {
return fmt.Errorf("serving certificate/key pair is invalid or expiring: %w", err)
}
normalized := sans.Normalize()
if normalized.Empty() {
return fmt.Errorf("cannot validate serving certificate without desired SANs")
}
for _, dnsName := range normalized.DNSNames {
if _, err := leaf.Verify(x509.VerifyOptions{
DNSName: dnsName,
Roots: roots,
KeyUsages: []x509.ExtKeyUsage{
x509.ExtKeyUsageServerAuth,
},
}); err != nil {
return fmt.Errorf("serving certificate is not valid for DNS SAN %q: %w", dnsName, err)
}
}
for _, ip := range normalized.IPAddrs {
if _, err := leaf.Verify(x509.VerifyOptions{
DNSName: ip.String(),
Roots: roots,
KeyUsages: []x509.ExtKeyUsage{
x509.ExtKeyUsageServerAuth,
},
}); err != nil {
return fmt.Errorf("serving certificate is not valid for IP SAN %q: %w", ip.String(), err)
}
}
return nil
}
func (r *Reconciler) updateManagedCustomResourceDefinition(
ctx context.Context,
managed ManagedCRD,
caBundle []byte,
) error {
if !managed.ManageConversion {
return nil
}
return retry.RetryOnConflict(retry.DefaultBackoff, func() error {
crd := &apiextensionsv1.CustomResourceDefinition{}
if err := r.Get(ctx, types.NamespacedName{Name: managed.Name}, crd); err != nil {
if apierrors.IsNotFound(err) {
return nil
}
return err
}
// Only patch CRDs that already use webhook conversion.
if crd.Spec.Conversion == nil ||
crd.Spec.Conversion.Webhook == nil ||
crd.Spec.Conversion.Webhook.ClientConfig == nil {
return nil
}
current := crd.Spec.Conversion.Webhook.ClientConfig.CABundle
if bytes.Equal(current, caBundle) {
return nil
}
before := crd.DeepCopy()
crd.Spec.Conversion.Webhook.ClientConfig.CABundle = append([]byte(nil), caBundle...)
return r.Patch(ctx, crd, client.MergeFrom(before))
})
}
func copySecretData(in map[string][]byte) map[string][]byte {
out := make(map[string][]byte, len(in))
for key, value := range in {
out[key] = append([]byte(nil), value...)
}
return out
}