From de459fb5da06b236401e7e79411fb88873733629 Mon Sep 17 00:00:00 2001 From: Dario Tranchitella Date: Fri, 3 Oct 2025 14:30:58 +0200 Subject: [PATCH] feat!: write permissions (#937) * fix: decoding object only if requested Signed-off-by: Dario Tranchitella * feat(api): limiting write permissions Signed-off-by: Dario Tranchitella * feat: write permissions handlers, routes, and controller Signed-off-by: Dario Tranchitella * docs: write permissions Signed-off-by: Dario Tranchitella --------- Signed-off-by: Dario Tranchitella --- api/v1alpha1/tenantcontrolplane_status.go | 4 +- api/v1alpha1/tenantcontrolplane_types.go | 21 ++ api/v1alpha1/zz_generated.deepcopy.go | 16 ++ ...i.clastix.io_tenantcontrolplanes_spec.yaml | 18 ++ ...kamaji.clastix.io_tenantcontrolplanes.yaml | 18 ++ cmd/manager/cmd.go | 3 + .../soot/controllers/write_permissions.go | 207 ++++++++++++++++++ controllers/soot/manager.go | 14 ++ docs/content/guides/write-permissions.md | 115 ++++++++++ docs/content/reference/api.md | 58 ++++- docs/mkdocs.yml | 1 + internal/resources/k8s_deployment_resource.go | 34 ++- internal/webhook/chainer.go | 25 ++- internal/webhook/handlers/write_permission.go | 32 +++ internal/webhook/routes/tcp_freeze.go | 3 +- .../webhook/routes/tcp_writepermission.go | 18 ++ 16 files changed, 561 insertions(+), 26 deletions(-) create mode 100644 controllers/soot/controllers/write_permissions.go create mode 100644 docs/content/guides/write-permissions.md create mode 100644 internal/webhook/handlers/write_permission.go create mode 100644 internal/webhook/routes/tcp_writepermission.go diff --git a/api/v1alpha1/tenantcontrolplane_status.go b/api/v1alpha1/tenantcontrolplane_status.go index a8c4223..12ea8ec 100644 --- a/api/v1alpha1/tenantcontrolplane_status.go +++ b/api/v1alpha1/tenantcontrolplane_status.go @@ -189,12 +189,14 @@ type KubernetesStatus struct { Ingress *KubernetesIngressStatus `json:"ingress,omitempty"` } -// +kubebuilder:validation:Enum=Provisioning;CertificateAuthorityRotating;Upgrading;Migrating;Ready;NotReady;Sleeping +// +kubebuilder:validation:Enum=Unknown;Provisioning;CertificateAuthorityRotating;Upgrading;Migrating;Ready;NotReady;Sleeping;WriteLimited type KubernetesVersionStatus string var ( + VersionUnknown KubernetesVersionStatus = "Unknown" VersionProvisioning KubernetesVersionStatus = "Provisioning" VersionSleeping KubernetesVersionStatus = "Sleeping" + VersionWriteLimited KubernetesVersionStatus = "WriteLimited" VersionCARotating KubernetesVersionStatus = "CertificateAuthorityRotating" VersionUpgrading KubernetesVersionStatus = "Upgrading" VersionMigrating KubernetesVersionStatus = "Migrating" diff --git a/api/v1alpha1/tenantcontrolplane_types.go b/api/v1alpha1/tenantcontrolplane_types.go index 16c91f0..0a8a89b 100644 --- a/api/v1alpha1/tenantcontrolplane_types.go +++ b/api/v1alpha1/tenantcontrolplane_types.go @@ -297,6 +297,20 @@ type AddonsSpec struct { KubeProxy *AddonSpec `json:"kubeProxy,omitempty"` } +type Permissions struct { + BlockCreate bool `json:"blockCreation,omitempty"` + BlockUpdate bool `json:"blockUpdate,omitempty"` + BlockDelete bool `json:"blockDeletion,omitempty"` +} + +func (p *Permissions) HasAnyLimitation() bool { + if p.BlockCreate || p.BlockUpdate || p.BlockDelete { + return true + } + + return false +} + // TenantControlPlaneSpec defines the desired state of TenantControlPlane. // +kubebuilder:validation:XValidation:rule="!has(oldSelf.dataStore) || has(self.dataStore)", message="unsetting the dataStore is not supported" // +kubebuilder:validation:XValidation:rule="!has(oldSelf.dataStoreSchema) || has(self.dataStoreSchema)", message="unsetting the dataStoreSchema is not supported" @@ -306,6 +320,13 @@ type AddonsSpec struct { // +kubebuilder:validation:XValidation:rule="self.controlPlane.service.serviceType != 'LoadBalancer' || (oldSelf.controlPlane.service.serviceType != 'LoadBalancer' && self.controlPlane.service.serviceType == 'LoadBalancer') || has(self.networkProfile.loadBalancerClass) == has(oldSelf.networkProfile.loadBalancerClass)",message="LoadBalancerClass cannot be set or unset at runtime" type TenantControlPlaneSpec struct { + // WritePermissions allows to select which operations (create, delete, update) must be blocked: + // by default, all actions are allowed, and API Server can write to its Datastore. + // + // By blocking all actions, the Tenant Control Plane can enter in a Read Only mode: + // this phase can be used to prevent Datastore quota exhaustion or for your own business logic + // (e.g.: blocking creation and update, but allowing deletion to "clean up" space). + WritePermissions Permissions `json:"writePermissions,omitempty"` // DataStore specifies the DataStore that should be used to store the Kubernetes data for the given Tenant Control Plane. // When Kamaji runs with the default DataStore flag, all empty values will inherit the default value. // By leaving it empty and running Kamaji with no default DataStore flag, it is possible to achieve automatic assignment to a specific DataStore object. diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index b9efa16..72fd59f 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -1285,6 +1285,21 @@ func (in *NetworkProfileSpec) DeepCopy() *NetworkProfileSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Permissions) DeepCopyInto(out *Permissions) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Permissions. +func (in *Permissions) DeepCopy() *Permissions { + if in == nil { + return nil + } + out := new(Permissions) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PublicKeyPrivateKeyPairStatus) DeepCopyInto(out *PublicKeyPrivateKeyPairStatus) { *out = *in @@ -1449,6 +1464,7 @@ func (in *TenantControlPlaneList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *TenantControlPlaneSpec) DeepCopyInto(out *TenantControlPlaneSpec) { *out = *in + out.WritePermissions = in.WritePermissions in.ControlPlane.DeepCopyInto(&out.ControlPlane) in.Kubernetes.DeepCopyInto(&out.Kubernetes) in.NetworkProfile.DeepCopyInto(&out.NetworkProfile) diff --git a/charts/kamaji-crds/hack/kamaji.clastix.io_tenantcontrolplanes_spec.yaml b/charts/kamaji-crds/hack/kamaji.clastix.io_tenantcontrolplanes_spec.yaml index e74efe9..44a035c 100644 --- a/charts/kamaji-crds/hack/kamaji.clastix.io_tenantcontrolplanes_spec.yaml +++ b/charts/kamaji-crds/hack/kamaji.clastix.io_tenantcontrolplanes_spec.yaml @@ -6955,6 +6955,22 @@ versions: description: 'CIDR for Kubernetes Services: if empty, defaulted to 10.96.0.0/16.' type: string type: object + writePermissions: + description: |- + WritePermissions allows to select which operations (create, delete, update) must be blocked: + by default, all actions are allowed, and API Server can write to its Datastore. + + By blocking all actions, the Tenant Control Plane can enter in a Read Only mode: + this phase can be used to prevent Datastore quota exhaustion or for your own business logic + (e.g.: blocking creation and update, but allowing deletion to "clean up" space). + properties: + blockCreation: + type: boolean + blockDeletion: + type: boolean + blockUpdate: + type: boolean + type: object required: - controlPlane - kubernetes @@ -7703,6 +7719,7 @@ versions: default: Provisioning description: Status returns the current status of the Kubernetes version, such as its provisioning state, or completed upgrade. enum: + - Unknown - Provisioning - CertificateAuthorityRotating - Upgrading @@ -7710,6 +7727,7 @@ versions: - Ready - NotReady - Sleeping + - WriteLimited type: string version: description: Version is the running Kubernetes version of the Tenant Control Plane. diff --git a/charts/kamaji/crds/kamaji.clastix.io_tenantcontrolplanes.yaml b/charts/kamaji/crds/kamaji.clastix.io_tenantcontrolplanes.yaml index 5b60d25..49abfd5 100644 --- a/charts/kamaji/crds/kamaji.clastix.io_tenantcontrolplanes.yaml +++ b/charts/kamaji/crds/kamaji.clastix.io_tenantcontrolplanes.yaml @@ -6963,6 +6963,22 @@ spec: description: 'CIDR for Kubernetes Services: if empty, defaulted to 10.96.0.0/16.' type: string type: object + writePermissions: + description: |- + WritePermissions allows to select which operations (create, delete, update) must be blocked: + by default, all actions are allowed, and API Server can write to its Datastore. + + By blocking all actions, the Tenant Control Plane can enter in a Read Only mode: + this phase can be used to prevent Datastore quota exhaustion or for your own business logic + (e.g.: blocking creation and update, but allowing deletion to "clean up" space). + properties: + blockCreation: + type: boolean + blockDeletion: + type: boolean + blockUpdate: + type: boolean + type: object required: - controlPlane - kubernetes @@ -7711,6 +7727,7 @@ spec: default: Provisioning description: Status returns the current status of the Kubernetes version, such as its provisioning state, or completed upgrade. enum: + - Unknown - Provisioning - CertificateAuthorityRotating - Upgrading @@ -7718,6 +7735,7 @@ spec: - Ready - NotReady - Sleeping + - WriteLimited type: string version: description: Version is the running Kubernetes version of the Tenant Control Plane. diff --git a/cmd/manager/cmd.go b/cmd/manager/cmd.go index 82e17cc..c004a7e 100644 --- a/cmd/manager/cmd.go +++ b/cmd/manager/cmd.go @@ -219,6 +219,9 @@ func NewCmd(scheme *runtime.Scheme) *cobra.Command { routes.TenantControlPlaneMigrate{}: { handlers.Freeze{}, }, + routes.TenantControlPlaneWritePermission{}: { + handlers.WritePermission{}, + }, routes.TenantControlPlaneDefaults{}: { handlers.TenantControlPlaneDefaults{ DefaultDatastore: datastore, diff --git a/controllers/soot/controllers/write_permissions.go b/controllers/soot/controllers/write_permissions.go new file mode 100644 index 0000000..9e4ea44 --- /dev/null +++ b/controllers/soot/controllers/write_permissions.go @@ -0,0 +1,207 @@ +// Copyright 2022 Clastix Labs +// SPDX-License-Identifier: Apache-2.0 + +package controllers + +import ( + "context" + "fmt" + "time" + + "github.com/go-logr/logr" + admissionregistrationv1 "k8s.io/api/admissionregistration/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/kubernetes/cmd/kubeadm/app/util/errors" + "k8s.io/utils/ptr" + controllerruntime "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" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/source" + + kamajiv1alpha1 "github.com/clastix/kamaji/api/v1alpha1" + sooterrors "github.com/clastix/kamaji/controllers/soot/controllers/errors" + "github.com/clastix/kamaji/controllers/utils" + "github.com/clastix/kamaji/internal/utilities" +) + +type WritePermissions struct { + Logger logr.Logger + Client client.Client + GetTenantControlPlaneFunc utils.TenantControlPlaneRetrievalFn + WebhookNamespace string + WebhookServiceName string + WebhookCABundle []byte + TriggerChannel chan event.GenericEvent +} + +func (r *WritePermissions) Reconcile(ctx context.Context, _ reconcile.Request) (reconcile.Result, error) { + tcp, err := r.GetTenantControlPlaneFunc() + if err != nil { + if errors.Is(err, sooterrors.ErrPausedReconciliation) { + r.Logger.Info(err.Error()) + + return reconcile.Result{}, nil + } + + return reconcile.Result{}, err + } + // Cannot detect the status of the TenantControlPlane, enqueuing back + if tcp.Status.Kubernetes.Version.Status == nil { + return reconcile.Result{RequeueAfter: time.Second}, nil + } + + switch { + case ptr.Deref(tcp.Status.Kubernetes.Version.Status, kamajiv1alpha1.VersionUnknown) == kamajiv1alpha1.VersionWriteLimited && + tcp.Spec.WritePermissions.HasAnyLimitation(): + err = r.createOrUpdate(ctx, tcp.Spec.WritePermissions) + default: + err = r.cleanup(ctx) + } + + if err != nil { + r.Logger.Error(err, "reconciliation failed") + + return reconcile.Result{}, err + } + + return reconcile.Result{}, nil +} + +func (r *WritePermissions) createOrUpdate(ctx context.Context, writePermissions kamajiv1alpha1.Permissions) error { + obj := r.object().DeepCopy() + + _, err := utilities.CreateOrUpdateWithConflict(ctx, r.Client, obj, func() error { + obj.Webhooks = []admissionregistrationv1.ValidatingWebhook{ + { + Name: "leases.write-permissions.kamaji.clastix.io", + ClientConfig: admissionregistrationv1.WebhookClientConfig{ + URL: ptr.To(fmt.Sprintf("https://%s.%s.svc:443/write-permission", r.WebhookServiceName, r.WebhookNamespace)), + CABundle: r.WebhookCABundle, + }, + Rules: []admissionregistrationv1.RuleWithOperations{ + { + Operations: []admissionregistrationv1.OperationType{ + admissionregistrationv1.Create, + admissionregistrationv1.Delete, + }, + Rule: admissionregistrationv1.Rule{ + APIGroups: []string{"*"}, + APIVersions: []string{"*"}, + Resources: []string{"*"}, + Scope: ptr.To(admissionregistrationv1.NamespacedScope), + }, + }, + }, + FailurePolicy: ptr.To(admissionregistrationv1.Fail), + MatchPolicy: ptr.To(admissionregistrationv1.Equivalent), + NamespaceSelector: &metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{ + { + Key: "kubernetes.io/metadata.name", + Operator: metav1.LabelSelectorOpIn, + Values: []string{ + "kube-node-lease", + }, + }, + }, + }, + SideEffects: ptr.To(admissionregistrationv1.SideEffectClassNoneOnDryRun), + AdmissionReviewVersions: []string{"v1"}, + }, + { + Name: "catchall.write-permissions.kamaji.clastix.io", + ClientConfig: admissionregistrationv1.WebhookClientConfig{ + URL: ptr.To(fmt.Sprintf("https://%s.%s.svc:443/write-permission", r.WebhookServiceName, r.WebhookNamespace)), + CABundle: r.WebhookCABundle, + }, + Rules: []admissionregistrationv1.RuleWithOperations{ + { + Operations: func() []admissionregistrationv1.OperationType { + var ops []admissionregistrationv1.OperationType + + if writePermissions.BlockCreate { + ops = append(ops, admissionregistrationv1.Create) + } + + if writePermissions.BlockUpdate { + ops = append(ops, admissionregistrationv1.Update) + } + + if writePermissions.BlockDelete { + ops = append(ops, admissionregistrationv1.Delete) + } + + return ops + }(), + Rule: admissionregistrationv1.Rule{ + APIGroups: []string{"*"}, + APIVersions: []string{"*"}, + Resources: []string{"*"}, + Scope: ptr.To(admissionregistrationv1.AllScopes), + }, + }, + }, + FailurePolicy: ptr.To(admissionregistrationv1.Fail), + MatchPolicy: ptr.To(admissionregistrationv1.Equivalent), + NamespaceSelector: &metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{ + { + Key: "kubernetes.io/metadata.name", + Operator: metav1.LabelSelectorOpNotIn, + Values: []string{ + "kube-system", + "kube-node-lease", + }, + }, + }, + }, + SideEffects: ptr.To(admissionregistrationv1.SideEffectClassNoneOnDryRun), + TimeoutSeconds: nil, + AdmissionReviewVersions: []string{"v1"}, + }, + } + + return nil + }) + + return err +} + +func (r *WritePermissions) cleanup(ctx context.Context) error { + if err := r.Client.Delete(ctx, r.object()); err != nil { + if apierrors.IsNotFound(err) { + return nil + } + + return fmt.Errorf("unable to clean-up ValidationWebhook required for write permissions: %w", err) + } + + return nil +} + +func (r *WritePermissions) SetupWithManager(mgr manager.Manager) error { + r.TriggerChannel = make(chan event.GenericEvent) + + return controllerruntime.NewControllerManagedBy(mgr). + WithOptions(controller.TypedOptions[reconcile.Request]{SkipNameValidation: ptr.To(true)}). + For(&admissionregistrationv1.ValidatingWebhookConfiguration{}, builder.WithPredicates(predicate.NewPredicateFuncs(func(object client.Object) bool { + return object.GetName() == r.object().GetName() + }))). + WatchesRawSource(source.Channel(r.TriggerChannel, &handler.EnqueueRequestForObject{})). + Complete(r) +} + +func (r *WritePermissions) object() *admissionregistrationv1.ValidatingWebhookConfiguration { + return &admissionregistrationv1.ValidatingWebhookConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kamaji-write-permissions", + }, + } +} diff --git a/controllers/soot/manager.go b/controllers/soot/manager.go index f0064a8..385c384 100644 --- a/controllers/soot/manager.go +++ b/controllers/soot/manager.go @@ -253,6 +253,19 @@ func (m *Manager) Reconcile(ctx context.Context, request reconcile.Request) (res // // Register all the controllers of the soot here: // + writePermissions := &controllers.WritePermissions{ + Logger: mgr.GetLogger().WithName("writePermissions"), + Client: mgr.GetClient(), + GetTenantControlPlaneFunc: m.retrieveTenantControlPlane(tcpCtx, request), + WebhookNamespace: m.MigrateServiceNamespace, + WebhookServiceName: m.MigrateServiceName, + WebhookCABundle: m.MigrateCABundle, + TriggerChannel: nil, + } + if err = writePermissions.SetupWithManager(mgr); err != nil { + return reconcile.Result{}, err + } + migrate := &controllers.Migrate{ WebhookNamespace: m.MigrateServiceNamespace, WebhookServiceName: m.MigrateServiceName, @@ -370,6 +383,7 @@ func (m *Manager) Reconcile(ctx context.Context, request reconcile.Request) (res m.sootMap[request.NamespacedName.String()] = sootItem{ triggers: []chan event.GenericEvent{ + writePermissions.TriggerChannel, migrate.TriggerChannel, konnectivityAgent.TriggerChannel, kubeProxy.TriggerChannel, diff --git a/docs/content/guides/write-permissions.md b/docs/content/guides/write-permissions.md new file mode 100644 index 0000000..ab51f41 --- /dev/null +++ b/docs/content/guides/write-permissions.md @@ -0,0 +1,115 @@ +# Write Permissions + +Using the _Write Permissions_ section, operators can limit write operations for a given Tenant Control Plane, +where no further write actions can be made by its tenants. + +This feature ensures consistency during maintenance, migrations, incident recovery, quote enforcement, +or when freezing workloads for auditing and compliance purposes. + +Write Operations can limit the following actions: + +- Create +- Update +- Delete + +By default, all write operations are allowed. + +## Enabling a Read-Only mode + +You can enable ReadOnly mode by setting all the boolean fields of `TenantControlPlane.spec.writePermissions` to `true`. + +```yaml +apiVersion: kamaji.clastix.io/v1alpha1 +kind: TenantControlPlane +metadata: + name: my-control-plane +spec: + writePermissions: + blockCreate: true + blockUpdate: true + blockDelete: true +``` + +Once applied, the Tenant Control Plane will switch into `WriteLimited` status. + +## Enforcing a quota mode + +If your Tenant Control Plane has a Datastore quota, this feature allows freezing write and update operations, +but still allowing its tenants to perform a clean-up by deleting exceeding resources. + +```yaml +apiVersion: kamaji.clastix.io/v1alpha1 +kind: TenantControlPlane +metadata: + name: my-control-plane +spec: + writePermissions: + blockCreate: true + blockUpdate: true + blockDelete: false +``` + +!!! note "Datastore quota" + Kamaji does **not** enforce storage quota for a given Tenant Control Plane: + you have to implement it according to your business logic. + +## Monitoring the status + +You can verify the status of your Tenant Control Plane with `kubectl get tcp`: + +```json +$: kubectl get tcp k8s-133 +NAME VERSION INSTALLED VERSION STATUS CONTROL-PLANE ENDPOINT KUBECONFIG DATASTORE AGE +k8s-133 v1.33.0 v1.33.0 WriteLimited 172.18.255.100:6443 k8s-133-admin-kubeconfig default 50d +``` + +The `STATUS` field will display `WriteLimited` when write permissions are limited. + +## How it works + +When a Tenant Control Plane write status is _limited_, Kamaji creates a `ValidatingWebhookConfiguration` in the Tenant Cluster: + +``` +$: kubectl get validatingwebhookconfigurations +NAME WEBHOOKS AGE +kamaji-write-permissions 2 59m +``` + +The webhook intercepts all API requests to the Tenant Control Plane and programmatically denies any attempts to modify resources. + +As a result, all changes initiated by tenants (such as `kubectl apply`, `kubectl delete`, or CRD updates) could be blocked. + +!!! warning "Operators and Controller" + When the write status is limited, all actions are intercepted by the webhook. + If a Pod must be rescheduled, the webhook will deny it. + +## Behaviour with limited write operations + +If a tenant user tries to perform non-allowed write operations, such as: + +- creating resources when `TenantControlPlane.spec.writePermissions.blockCreate` is set to `true` +- updating resources when `TenantControlPlane.spec.writePermissions.blockUpdate` is set to `true` +- deleting resources when `TenantControlPlane.spec.writePermissions.blockDelete` is set to `true` + +the following error is returned: + +``` +Error from server (Forbidden): admission webhook "catchall.write-permissions.kamaji.clastix.io" denied the request: +the current Control Plane has limited write permissions, current changes are blocked: +removing the webhook may lead to an inconsistent state upon its completion +``` + +This guarantees the cluster remains in a frozen, consistent state, preventing partial updates or drift. + +## Use Cases + +Typical scenarios where ReadOnly mode is useful: + +- **Planned Maintenance**: freeze workloads before performing upgrades or infrastructure changes. +- **Disaster Recovery**: lock the Tenant Control Plane to prevent accidental modifications during incident handling. +- **Auditing & Compliance**: ensure workloads cannot be altered during a compliance check or certification process. +- **Quota Enforcement**: preventing Datastore quote over commit in terms of storage size. + +!!! info "Migrating the DataStore" + In a similar manner, when migrating a Tenant Control Plane to a different store, similar enforcement is put in place. + This is managed automatically by Kamaji: there's no need to toggle on and off the ReadOnly mode. diff --git a/docs/content/reference/api.md b/docs/content/reference/api.md index 030f78d..668a2c1 100644 --- a/docs/content/reference/api.md +++ b/docs/content/reference/api.md @@ -28523,6 +28523,18 @@ DataStoreUsername by concatenating the namespace and name of the TenantControlPl NetworkProfile specifies how the network is
false + + writePermissions + object + + WritePermissions allows to select which operations (create, delete, update) must be blocked: +by default, all actions are allowed, and API Server can write to its Datastore. + +By blocking all actions, the Tenant Control Plane can enter in a Read Only mode: +this phase can be used to prevent Datastore quota exhaustion or for your own business logic +(e.g.: blocking creation and update, but allowing deletion to "clean up" space).
+ + false @@ -41987,6 +41999,50 @@ Example: {"192.168.1.0/24", "10.0.0.0/8"}
+`TenantControlPlane.spec.writePermissions` + + +WritePermissions allows to select which operations (create, delete, update) must be blocked: +by default, all actions are allowed, and API Server can write to its Datastore. + +By blocking all actions, the Tenant Control Plane can enter in a Read Only mode: +this phase can be used to prevent Datastore quota exhaustion or for your own business logic +(e.g.: blocking creation and update, but allowing deletion to "clean up" space). + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
blockCreationboolean +
+
false
blockDeletionboolean +
+
false
blockUpdateboolean +
+
false
+ + `TenantControlPlane.status` @@ -44111,7 +44167,7 @@ KubernetesVersion contains the information regarding the running Kubernetes vers Status returns the current status of the Kubernetes version, such as its provisioning state, or completed upgrade.

- Enum: Provisioning, CertificateAuthorityRotating, Upgrading, Migrating, Ready, NotReady, Sleeping
+ Enum: Unknown, Provisioning, CertificateAuthorityRotating, Upgrading, Migrating, Ready, NotReady, Sleeping, WriteLimited
Default: Provisioning
false diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 05dc83c..3c6048a 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -74,6 +74,7 @@ nav: - guides/backup-and-restore.md - guides/certs-lifecycle.md - guides/pausing.md + - guides/write-permissions.md - guides/datastore-migration.md - guides/gitops.md - guides/console.md diff --git a/internal/resources/k8s_deployment_resource.go b/internal/resources/k8s_deployment_resource.go index d8fabb1..ff51508 100644 --- a/internal/resources/k8s_deployment_resource.go +++ b/internal/resources/k8s_deployment_resource.go @@ -36,7 +36,9 @@ func (r *KubernetesDeploymentResource) isStatusEqual(tenantControlPlane *kamajiv } func (r *KubernetesDeploymentResource) ShouldStatusBeUpdated(_ context.Context, tenantControlPlane *kamajiv1alpha1.TenantControlPlane) bool { - return !r.isStatusEqual(tenantControlPlane) || tenantControlPlane.Spec.Kubernetes.Version != tenantControlPlane.Status.Kubernetes.Version.Version + return !r.isStatusEqual(tenantControlPlane) || + tenantControlPlane.Spec.Kubernetes.Version != tenantControlPlane.Status.Kubernetes.Version.Version || + *r.computeStatus(tenantControlPlane) != ptr.Deref(tenantControlPlane.Status.Kubernetes.Version.Status, kamajiv1alpha1.VersionUnknown) } func (r *KubernetesDeploymentResource) ShouldCleanup(*kamajiv1alpha1.TenantControlPlane) bool { @@ -78,19 +80,29 @@ func (r *KubernetesDeploymentResource) GetName() string { return "deployment" } -func (r *KubernetesDeploymentResource) UpdateTenantControlPlaneStatus(_ context.Context, tenantControlPlane *kamajiv1alpha1.TenantControlPlane) error { +func (r *KubernetesDeploymentResource) computeStatus(tenantControlPlane *kamajiv1alpha1.TenantControlPlane) *kamajiv1alpha1.KubernetesVersionStatus { switch { case ptr.Deref(tenantControlPlane.Spec.ControlPlane.Deployment.Replicas, 2) == 0: - tenantControlPlane.Status.Kubernetes.Version.Status = &kamajiv1alpha1.VersionSleeping - case !r.isProgressingUpgrade(): - tenantControlPlane.Status.Kubernetes.Version.Status = &kamajiv1alpha1.VersionReady - tenantControlPlane.Status.Kubernetes.Version.Version = tenantControlPlane.Spec.Kubernetes.Version - case r.isUpgrading(tenantControlPlane): - tenantControlPlane.Status.Kubernetes.Version.Status = &kamajiv1alpha1.VersionUpgrading - case r.isProvisioning(tenantControlPlane): - tenantControlPlane.Status.Kubernetes.Version.Status = &kamajiv1alpha1.VersionProvisioning + return &kamajiv1alpha1.VersionSleeping case r.isNotReady(): - tenantControlPlane.Status.Kubernetes.Version.Status = &kamajiv1alpha1.VersionNotReady + return &kamajiv1alpha1.VersionNotReady + case tenantControlPlane.Spec.WritePermissions.HasAnyLimitation(): + return &kamajiv1alpha1.VersionWriteLimited + case !r.isProgressingUpgrade(): + return &kamajiv1alpha1.VersionReady + case r.isUpgrading(tenantControlPlane): + return &kamajiv1alpha1.VersionUpgrading + case r.isProvisioning(tenantControlPlane): + return &kamajiv1alpha1.VersionProvisioning + default: + return &kamajiv1alpha1.VersionUnknown + } +} + +func (r *KubernetesDeploymentResource) UpdateTenantControlPlaneStatus(_ context.Context, tenantControlPlane *kamajiv1alpha1.TenantControlPlane) error { + tenantControlPlane.Status.Kubernetes.Version.Status = r.computeStatus(tenantControlPlane) + if *tenantControlPlane.Status.Kubernetes.Version.Status == kamajiv1alpha1.VersionReady { + tenantControlPlane.Status.Kubernetes.Version.Version = tenantControlPlane.Spec.Kubernetes.Version } tenantControlPlane.Status.Kubernetes.Deployment = kamajiv1alpha1.KubernetesDeploymentStatus{ diff --git a/internal/webhook/chainer.go b/internal/webhook/chainer.go index a78737c..99e5d6e 100644 --- a/internal/webhook/chainer.go +++ b/internal/webhook/chainer.go @@ -25,18 +25,21 @@ type handlersChainer struct { //nolint:gocognit func (h handlersChainer) Handler(object runtime.Object, routeHandlers ...handlers.Handler) admission.HandlerFunc { return func(ctx context.Context, req admission.Request) admission.Response { - decodedObj, oldDecodedObj := object.DeepCopyObject(), object.DeepCopyObject() + var decodedObj, oldDecodedObj runtime.Object + if object != nil { + decodedObj, oldDecodedObj = object.DeepCopyObject(), object.DeepCopyObject() - switch req.Operation { - case admissionv1.Delete: - // When deleting the OldObject struct field contains the object being deleted: - // https://github.com/kubernetes/kubernetes/pull/76346 - if err := h.decoder.DecodeRaw(req.OldObject, decodedObj); err != nil { - return admission.Errored(http.StatusInternalServerError, errors.Wrap(err, fmt.Sprintf("unable to decode deleted object into %T", object))) - } - default: - if err := h.decoder.Decode(req, decodedObj); err != nil { - return admission.Errored(http.StatusInternalServerError, errors.Wrap(err, fmt.Sprintf("unable to decode into %T", object))) + switch req.Operation { + case admissionv1.Delete: + // When deleting the OldObject struct field contains the object being deleted: + // https://github.com/kubernetes/kubernetes/pull/76346 + if err := h.decoder.DecodeRaw(req.OldObject, decodedObj); err != nil { + return admission.Errored(http.StatusInternalServerError, errors.Wrap(err, fmt.Sprintf("unable to decode deleted object into %T", object))) + } + default: + if err := h.decoder.Decode(req, decodedObj); err != nil { + return admission.Errored(http.StatusInternalServerError, errors.Wrap(err, fmt.Sprintf("unable to decode into %T", object))) + } } } diff --git a/internal/webhook/handlers/write_permission.go b/internal/webhook/handlers/write_permission.go new file mode 100644 index 0000000..1d911d9 --- /dev/null +++ b/internal/webhook/handlers/write_permission.go @@ -0,0 +1,32 @@ +// Copyright 2022 Clastix Labs +// SPDX-License-Identifier: Apache-2.0 + +package handlers + +import ( + "context" + "fmt" + + "gomodules.xyz/jsonpatch/v2" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +type WritePermission struct{} + +func (f WritePermission) OnCreate(runtime.Object) AdmissionResponse { + return f.response +} + +func (f WritePermission) OnDelete(runtime.Object) AdmissionResponse { + return f.response +} + +func (f WritePermission) OnUpdate(runtime.Object, runtime.Object) AdmissionResponse { + return f.response +} + +func (f WritePermission) response(context.Context, admission.Request) ([]jsonpatch.JsonPatchOperation, error) { + return nil, fmt.Errorf("the current Control Plane has limited write permissions, current changes are blocked: " + + "removing the webhook may lead to an inconsistent state upon its completion") +} diff --git a/internal/webhook/routes/tcp_freeze.go b/internal/webhook/routes/tcp_freeze.go index 1803a53..ca0aa91 100644 --- a/internal/webhook/routes/tcp_freeze.go +++ b/internal/webhook/routes/tcp_freeze.go @@ -4,7 +4,6 @@ package routes import ( - corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/runtime" ) @@ -15,5 +14,5 @@ func (t TenantControlPlaneMigrate) GetPath() string { } func (t TenantControlPlaneMigrate) GetObject() runtime.Object { - return &corev1.Namespace{} + return nil } diff --git a/internal/webhook/routes/tcp_writepermission.go b/internal/webhook/routes/tcp_writepermission.go new file mode 100644 index 0000000..ff85139 --- /dev/null +++ b/internal/webhook/routes/tcp_writepermission.go @@ -0,0 +1,18 @@ +// Copyright 2022 Clastix Labs +// SPDX-License-Identifier: Apache-2.0 + +package routes + +import ( + "k8s.io/apimachinery/pkg/runtime" +) + +type TenantControlPlaneWritePermission struct{} + +func (t TenantControlPlaneWritePermission) GetPath() string { + return "/write-permission" +} + +func (t TenantControlPlaneWritePermission) GetObject() runtime.Object { + return nil +}