Compare commits

...

6 Commits

Author SHA1 Message Date
dependabot[bot]
3d2444e646 feat(deps): bump k8s.io/klog/v2 from 2.130.1 to 2.140.0 in the k8s group (#1100)
Bumps the k8s group with 1 update: [k8s.io/klog/v2](https://github.com/kubernetes/klog).


Updates `k8s.io/klog/v2` from 2.130.1 to 2.140.0
- [Release notes](https://github.com/kubernetes/klog/releases)
- [Changelog](https://github.com/kubernetes/klog/blob/main/RELEASE.md)
- [Commits](https://github.com/kubernetes/klog/compare/v2.130.1...2.140.0)

---
updated-dependencies:
- dependency-name: k8s.io/klog/v2
  dependency-version: 2.140.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: k8s
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-09 14:18:32 +01:00
Dario Tranchitella
cedd0f642c fix(datastore): consistent password update if user exists (#1097)
Signed-off-by: Dario Tranchitella <dario@tranchitella.eu>
2026-03-09 14:14:27 +01:00
Dario Tranchitella
e4da581e69 fix: reinit kubelet configuration upon patch for op remove (#1099)
* fix: reinit kubelet configuration upon patch for op remove

Signed-off-by: Dario Tranchitella <dario@tranchitella.eu>

* fix(docs): updating cluster api

Signed-off-by: Dario Tranchitella <dario@tranchitella.eu>

---------

Signed-off-by: Dario Tranchitella <dario@tranchitella.eu>
2026-03-09 14:13:48 +01:00
dependabot[bot]
6ed71b1e3e feat(deps): bump k8s.io/kubernetes in the k8s group (#1092)
Bumps the k8s group with 1 update: [k8s.io/kubernetes](https://github.com/kubernetes/kubernetes).


Updates `k8s.io/kubernetes` from 1.35.1 to 1.35.2
- [Release notes](https://github.com/kubernetes/kubernetes/releases)
- [Commits](https://github.com/kubernetes/kubernetes/compare/v1.35.1...v1.35.2)

---
updated-dependencies:
- dependency-name: k8s.io/kubernetes
  dependency-version: 1.35.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: k8s
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-03 10:57:19 +01:00
Dario Tranchitella
a5bfbaaf72 feat!: cidr validation via cel (#1095)
* fix(docs): container probes

Signed-off-by: Dario Tranchitella <dario@tranchitella.eu>

* feat(api): cidr validation using cel

Signed-off-by: Dario Tranchitella <dario@tranchitella.eu>

* refactor: cidr validation is offloaded to cel

Signed-off-by: Dario Tranchitella <dario@tranchitella.eu>

* feat(test): integration test for cidr and ip cel functions

Signed-off-by: Dario Tranchitella <dario@tranchitella.eu>

* docs: bumping up minimum management cluster version

Signed-off-by: Dario Tranchitella <dario@tranchitella.eu>

---------

Signed-off-by: Dario Tranchitella <dario@tranchitella.eu>
2026-03-02 14:30:10 +01:00
Dario Tranchitella
adaaef0857 chore(goreleaser): prerelease must be false (#1096)
Signed-off-by: Dario Tranchitella <dario@tranchitella.eu>
2026-03-02 09:48:58 +01:00
25 changed files with 2918 additions and 241 deletions

View File

@@ -39,7 +39,6 @@ kos:
- linux/arm
release:
prerelease: auto
footer: |
**Container Images**
```

View File

@@ -0,0 +1,260 @@
// Copyright 2022 Clastix Labs
// SPDX-License-Identifier: Apache-2.0
package v1alpha1
import (
"context"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
var _ = Describe("NetworkProfile validation", func() {
var (
ctx context.Context
tcp *TenantControlPlane
)
const (
ipv6CIDRBlock = "fd00::/108"
)
BeforeEach(func() {
ctx = context.Background()
tcp = &TenantControlPlane{
ObjectMeta: metav1.ObjectMeta{
GenerateName: "tcp-network-",
Namespace: "default",
},
Spec: TenantControlPlaneSpec{
ControlPlane: ControlPlane{
Service: ServiceSpec{
ServiceType: ServiceTypeClusterIP,
},
},
},
}
})
AfterEach(func() {
// When creation is denied by validation, GenerateName is never resolved
// and tcp.Name remains empty, so there is nothing to delete.
if tcp.Name == "" {
return
}
if err := k8sClient.Delete(ctx, tcp); err != nil && !apierrors.IsNotFound(err) {
Expect(err).NotTo(HaveOccurred())
}
})
Context("serviceCidr", func() {
It("allows creation with the default IPv4 CIDR", func() {
tcp.Spec.NetworkProfile.ServiceCIDR = "10.96.0.0/16"
Expect(k8sClient.Create(ctx, tcp)).To(Succeed())
})
It("allows creation with a non-default valid IPv4 CIDR", func() {
tcp.Spec.NetworkProfile.ServiceCIDR = "172.16.0.0/12"
Expect(k8sClient.Create(ctx, tcp)).To(Succeed())
})
It("allows creation with a valid IPv6 CIDR", func() {
tcp.Spec.NetworkProfile.ServiceCIDR = ipv6CIDRBlock
Expect(k8sClient.Create(ctx, tcp)).To(Succeed())
})
It("allows creation when serviceCidr is empty", func() {
tcp.Spec.NetworkProfile.ServiceCIDR = ""
Expect(k8sClient.Create(ctx, tcp)).To(Succeed())
})
It("denies creation with a plain IP address instead of a CIDR", func() {
tcp.Spec.NetworkProfile.ServiceCIDR = "10.96.0.1"
err := k8sClient.Create(ctx, tcp)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("serviceCidr must be empty or a valid CIDR"))
})
It("denies creation with an arbitrary non-CIDR string", func() {
tcp.Spec.NetworkProfile.ServiceCIDR = "not-a-cidr"
err := k8sClient.Create(ctx, tcp)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("serviceCidr must be empty or a valid CIDR"))
})
})
Context("podCidr", func() {
It("allows creation with the default IPv4 CIDR", func() {
tcp.Spec.NetworkProfile.PodCIDR = "10.244.0.0/16"
Expect(k8sClient.Create(ctx, tcp)).To(Succeed())
})
It("allows creation with a non-default valid IPv4 CIDR", func() {
tcp.Spec.NetworkProfile.PodCIDR = "192.168.128.0/17"
Expect(k8sClient.Create(ctx, tcp)).To(Succeed())
})
It("allows creation with a valid IPv6 CIDR", func() {
tcp.Spec.NetworkProfile.PodCIDR = "2001:db8::/48"
Expect(k8sClient.Create(ctx, tcp)).To(Succeed())
})
It("allows creation when podCidr is empty", func() {
tcp.Spec.NetworkProfile.PodCIDR = ""
Expect(k8sClient.Create(ctx, tcp)).To(Succeed())
})
It("denies creation with a plain IP address instead of a CIDR", func() {
tcp.Spec.NetworkProfile.PodCIDR = "10.244.0.1"
err := k8sClient.Create(ctx, tcp)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("podCidr must be empty or a valid CIDR"))
})
It("denies creation with an arbitrary non-CIDR string", func() {
tcp.Spec.NetworkProfile.PodCIDR = "not-a-cidr"
err := k8sClient.Create(ctx, tcp)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("podCidr must be empty or a valid CIDR"))
})
})
Context("loadBalancerSourceRanges CIDR format", func() {
BeforeEach(func() {
tcp.Spec.ControlPlane.Service.ServiceType = ServiceTypeLoadBalancer
})
It("allows creation with a single valid CIDR", func() {
tcp.Spec.NetworkProfile.LoadBalancerSourceRanges = []string{"10.0.0.0/8"}
Expect(k8sClient.Create(ctx, tcp)).To(Succeed())
})
It("allows creation with multiple valid CIDRs", func() {
tcp.Spec.NetworkProfile.LoadBalancerSourceRanges = []string{
"10.0.0.0/8",
"192.168.0.0/24",
"172.16.0.0/12",
}
Expect(k8sClient.Create(ctx, tcp)).To(Succeed())
})
It("allows creation with valid IPv6 CIDRs", func() {
tcp.Spec.NetworkProfile.LoadBalancerSourceRanges = []string{
"2001:db8::/32",
"fd00::/8",
}
Expect(k8sClient.Create(ctx, tcp)).To(Succeed())
})
It("denies creation when an entry is a plain IP address", func() {
tcp.Spec.NetworkProfile.LoadBalancerSourceRanges = []string{"192.168.1.1"}
err := k8sClient.Create(ctx, tcp)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("all LoadBalancer source range entries must be valid CIDR"))
})
It("denies creation when an entry is an arbitrary string", func() {
tcp.Spec.NetworkProfile.LoadBalancerSourceRanges = []string{"not-a-cidr"}
err := k8sClient.Create(ctx, tcp)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("all LoadBalancer source range entries must be valid CIDR"))
})
It("denies creation when at least one entry in a mixed list is invalid", func() {
tcp.Spec.NetworkProfile.LoadBalancerSourceRanges = []string{
"10.0.0.0/8",
"not-a-cidr",
}
err := k8sClient.Create(ctx, tcp)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("all LoadBalancer source range entries must be valid CIDR"))
})
})
Context("dnsServiceIPs", func() {
BeforeEach(func() {
tcp.Spec.NetworkProfile.ServiceCIDR = "10.96.0.0/16"
})
It("allows creation when dnsServiceIPs is not set", func() {
Expect(k8sClient.Create(ctx, tcp)).To(Succeed())
})
It("allows creation with an explicitly empty dnsServiceIPs list", func() {
tcp.Spec.NetworkProfile.DNSServiceIPs = []string{}
Expect(k8sClient.Create(ctx, tcp)).To(Succeed())
})
It("allows creation when all IPs are within the service CIDR", func() {
tcp.Spec.NetworkProfile.DNSServiceIPs = []string{"10.96.0.10"}
Expect(k8sClient.Create(ctx, tcp)).To(Succeed())
})
It("allows creation with multiple IPs all within the service CIDR", func() {
tcp.Spec.NetworkProfile.DNSServiceIPs = []string{
"10.96.0.10",
"10.96.0.11",
}
Expect(k8sClient.Create(ctx, tcp)).To(Succeed())
})
It("denies creation when a DNS service IP is outside the service CIDR", func() {
tcp.Spec.NetworkProfile.DNSServiceIPs = []string{"192.168.1.10"}
err := k8sClient.Create(ctx, tcp)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("all DNS service IPs must be part of the Service CIDR"))
})
It("denies creation when at least one IP in a mixed list is outside the service CIDR", func() {
tcp.Spec.NetworkProfile.DNSServiceIPs = []string{
"10.96.0.10",
"192.168.1.10",
}
err := k8sClient.Create(ctx, tcp)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("all DNS service IPs must be part of the Service CIDR"))
})
It("allows creation with an IPv6 DNS service IP within an IPv6 service CIDR", func() {
tcp.Spec.NetworkProfile.ServiceCIDR = ipv6CIDRBlock
tcp.Spec.NetworkProfile.DNSServiceIPs = []string{"fd00::10"}
Expect(k8sClient.Create(ctx, tcp)).To(Succeed())
})
It("denies creation when an IPv6 DNS service IP is outside the IPv6 service CIDR", func() {
tcp.Spec.NetworkProfile.ServiceCIDR = ipv6CIDRBlock
tcp.Spec.NetworkProfile.DNSServiceIPs = []string{"2001:db8::10"}
err := k8sClient.Create(ctx, tcp)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("all DNS service IPs must be part of the Service CIDR"))
})
})
})

View File

@@ -12,6 +12,7 @@ import (
)
// NetworkProfileSpec defines the desired state of NetworkProfile.
// +kubebuilder:validation:XValidation:rule="!has(self.dnsServiceIPs) || self.dnsServiceIPs.all(r, cidr(self.serviceCidr).containsIP(r))",message="all DNS service IPs must be part of the Service CIDR"
type NetworkProfileSpec struct {
// LoadBalancerSourceRanges restricts the IP ranges that can access
// the LoadBalancer type Service. This field defines a list of IP
@@ -20,14 +21,16 @@ type NetworkProfileSpec struct {
// This feature is useful for restricting access to API servers or services
// to specific networks for security purposes.
// Example: {"192.168.1.0/24", "10.0.0.0/8"}
//+kubebuilder:validation:MaxItems=16
//+kubebuilder:validation:XValidation:rule="self.all(r, isCIDR(r))",message="all LoadBalancer source range entries must be valid CIDR"
LoadBalancerSourceRanges []string `json:"loadBalancerSourceRanges,omitempty"`
// Specify the LoadBalancer class in case of multiple load balancer implementations.
// Field supported only for Tenant Control Plane instances exposed using a LoadBalancer Service.
// +kubebuilder:validation:MinLength=1
// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="LoadBalancerClass is immutable"
LoadBalancerClass *string `json:"loadBalancerClass,omitempty"`
// Address where API server of will be exposed.
// In case of LoadBalancer Service, this can be empty in order to use the exposed IP provided by the cloud controller manager.
// Address where API server will be exposed.
// In the case of LoadBalancer Service, this can be empty in order to use the exposed IP provided by the cloud controller manager.
Address string `json:"address,omitempty"`
// The default domain name used for DNS resolution within the cluster.
//+kubebuilder:default="cluster.local"
@@ -37,7 +40,7 @@ type NetworkProfileSpec struct {
// AllowAddressAsExternalIP will include tenantControlPlane.Spec.NetworkProfile.Address in the section of
// ExternalIPs of the Kubernetes Service (only ClusterIP or NodePort)
AllowAddressAsExternalIP bool `json:"allowAddressAsExternalIP,omitempty"`
// Port where API server of will be exposed
// Port where API server will be exposed
//+kubebuilder:default=6443
Port int32 `json:"port,omitempty"`
// CertSANs sets extra Subject Alternative Names (SANs) for the API Server signing certificate.
@@ -45,14 +48,20 @@ type NetworkProfileSpec struct {
CertSANs []string `json:"certSANs,omitempty"`
// CIDR for Kubernetes Services: if empty, defaulted to 10.96.0.0/16.
//+kubebuilder:default="10.96.0.0/16"
//+kubebuilder:validation:Optional
//+kubebuilder:validation:XValidation:rule="self == '' || isCIDR(self)",message="serviceCidr must be empty or a valid CIDR"
ServiceCIDR string `json:"serviceCidr,omitempty"`
// CIDR for Kubernetes Pods: if empty, defaulted to 10.244.0.0/16.
//+kubebuilder:default="10.244.0.0/16"
//+kubebuilder:validation:Optional
//+kubebuilder:validation:XValidation:rule="self == '' || isCIDR(self)",message="podCidr must be empty or a valid CIDR"
PodCIDR string `json:"podCidr,omitempty"`
// The DNS Service for internal resolution, it must match the Service CIDR.
// In case of an empty value, it is automatically computed according to the Service CIDR, e.g.:
// Service CIDR 10.96.0.0/16, the resulting DNS Service IP will be 10.96.0.10 for IPv4,
// for IPv6 from the CIDR 2001:db8:abcd::/64 the resulting DNS Service IP will be 2001:db8:abcd::10.
//+kubebuilder:validation:MaxItems=8
//+kubebuilder:validation:Optional
DNSServiceIPs []string `json:"dnsServiceIPs,omitempty"`
}

View File

@@ -9,7 +9,8 @@ package v1alpha1
import (
corev1 "k8s.io/api/core/v1"
"k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1"
runtime "k8s.io/apimachinery/pkg/runtime"
apisv1 "sigs.k8s.io/gateway-api/apis/v1"
)
@@ -653,6 +654,13 @@ func (in *DataStoreStatus) DeepCopyInto(out *DataStoreStatus) {
*out = make([]string, len(*in))
copy(*out, *in)
}
if in.Conditions != nil {
in, out := &in.Conditions, &out.Conditions
*out = make([]v1.Condition, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DataStoreStatus.
@@ -957,7 +965,7 @@ func (in *JSONPatch) DeepCopyInto(out *JSONPatch) {
*out = *in
if in.Value != nil {
in, out := &in.Value, &out.Value
*out = new(v1.JSON)
*out = new(apiextensionsv1.JSON)
(*in).DeepCopyInto(*out)
}
}

View File

@@ -7576,8 +7576,8 @@ versions:
properties:
address:
description: |-
Address where API server of will be exposed.
In case of LoadBalancer Service, this can be empty in order to use the exposed IP provided by the cloud controller manager.
Address where API server will be exposed.
In the case of LoadBalancer Service, this can be empty in order to use the exposed IP provided by the cloud controller manager.
type: string
allowAddressAsExternalIP:
description: |-
@@ -7607,6 +7607,7 @@ versions:
for IPv6 from the CIDR 2001:db8:abcd::/64 the resulting DNS Service IP will be 2001:db8:abcd::10.
items:
type: string
maxItems: 8
type: array
loadBalancerClass:
description: |-
@@ -7628,21 +7629,34 @@ versions:
Example: {"192.168.1.0/24", "10.0.0.0/8"}
items:
type: string
maxItems: 16
type: array
x-kubernetes-validations:
- message: all LoadBalancer source range entries must be valid CIDR
rule: self.all(r, isCIDR(r))
podCidr:
default: 10.244.0.0/16
description: 'CIDR for Kubernetes Pods: if empty, defaulted to 10.244.0.0/16.'
type: string
x-kubernetes-validations:
- message: podCidr must be empty or a valid CIDR
rule: self == '' || isCIDR(self)
port:
default: 6443
description: Port where API server of will be exposed
description: Port where API server will be exposed
format: int32
type: integer
serviceCidr:
default: 10.96.0.0/16
description: 'CIDR for Kubernetes Services: if empty, defaulted to 10.96.0.0/16.'
type: string
x-kubernetes-validations:
- message: serviceCidr must be empty or a valid CIDR
rule: self == '' || isCIDR(self)
type: object
x-kubernetes-validations:
- message: all DNS service IPs must be part of the Service CIDR
rule: '!has(self.dnsServiceIPs) || self.dnsServiceIPs.all(r, cidr(self.serviceCidr).containsIP(r))'
writePermissions:
description: |-
WritePermissions allows to select which operations (create, delete, update) must be blocked:

View File

@@ -7584,8 +7584,8 @@ spec:
properties:
address:
description: |-
Address where API server of will be exposed.
In case of LoadBalancer Service, this can be empty in order to use the exposed IP provided by the cloud controller manager.
Address where API server will be exposed.
In the case of LoadBalancer Service, this can be empty in order to use the exposed IP provided by the cloud controller manager.
type: string
allowAddressAsExternalIP:
description: |-
@@ -7615,6 +7615,7 @@ spec:
for IPv6 from the CIDR 2001:db8:abcd::/64 the resulting DNS Service IP will be 2001:db8:abcd::10.
items:
type: string
maxItems: 8
type: array
loadBalancerClass:
description: |-
@@ -7636,21 +7637,34 @@ spec:
Example: {"192.168.1.0/24", "10.0.0.0/8"}
items:
type: string
maxItems: 16
type: array
x-kubernetes-validations:
- message: all LoadBalancer source range entries must be valid CIDR
rule: self.all(r, isCIDR(r))
podCidr:
default: 10.244.0.0/16
description: 'CIDR for Kubernetes Pods: if empty, defaulted to 10.244.0.0/16.'
type: string
x-kubernetes-validations:
- message: podCidr must be empty or a valid CIDR
rule: self == '' || isCIDR(self)
port:
default: 6443
description: Port where API server of will be exposed
description: Port where API server will be exposed
format: int32
type: integer
serviceCidr:
default: 10.96.0.0/16
description: 'CIDR for Kubernetes Services: if empty, defaulted to 10.96.0.0/16.'
type: string
x-kubernetes-validations:
- message: serviceCidr must be empty or a valid CIDR
rule: self == '' || isCIDR(self)
type: object
x-kubernetes-validations:
- message: all DNS service IPs must be part of the Service CIDR
rule: '!has(self.dnsServiceIPs) || self.dnsServiceIPs.all(r, cidr(self.serviceCidr).containsIP(r))'
writePermissions:
description: |-
WritePermissions allows to select which operations (create, delete, update) must be blocked:

View File

@@ -255,8 +255,6 @@ func NewCmd(scheme *runtime.Scheme) *cobra.Command {
Scheme: *mgr.GetScheme(),
},
},
handlers.TenantControlPlaneServiceCIDR{},
handlers.TenantControlPlaneLoadBalancerSourceRanges{},
handlers.TenantControlPlaneGatewayValidation{
Client: mgr.GetClient(),
DiscoveryClient: discoveryClient,

View File

@@ -267,25 +267,24 @@ func getKubernetesStorageResources(c client.Client, dbConnection datastore.Conne
func getKubernetesAdditionalStorageResources(c client.Client, dbConnections map[string]datastore.Connection, dataStoreOverrides []builder.DataStoreOverrides, threshold time.Duration) []resources.Resource {
res := make([]resources.Resource, 0, len(dataStoreOverrides))
for _, dso := range dataStoreOverrides {
datastore := dso.DataStore
res = append(res,
&ds.MultiTenancy{
DataStore: datastore,
DataStore: dso.DataStore,
},
&ds.Config{
Client: c,
ConnString: dbConnections[dso.Resource].GetConnectionString(),
DataStore: datastore,
DataStore: dso.DataStore,
IsOverride: true,
},
&ds.Setup{
Client: c,
Connection: dbConnections[dso.Resource],
DataStore: datastore,
DataStore: dso.DataStore,
},
&ds.Certificate{
Client: c,
DataStore: datastore,
DataStore: dso.DataStore,
CertExpirationThreshold: threshold,
})
}

View File

@@ -40,7 +40,7 @@ Throughout the following instructions, shell variables are used to indicate valu
source kamaji.env
```
Any regular and conformant Kubernetes v1.22+ cluster can be turned into a Kamaji setup. To work properly, the Management Cluster should provide:
Any regular and conformant Kubernetes v1.33+ cluster can be turned into a Kamaji setup. To work properly, the Management Cluster should provide:
- CNI module installed, eg. [Calico](https://github.com/projectcalico/calico), [Cilium](https://github.com/cilium/cilium).
- CSI module installed with a Storage Class for the Tenant datastores. The [Local Path Provisioner](https://github.com/rancher/local-path-provisioner) is a suggested choice, even for production environments.

File diff suppressed because it is too large Load Diff

View File

@@ -34,7 +34,7 @@ Edge Releases are generally considered production ready and the project will mar
| Kamaji | Management Cluster | Tenant Cluster |
|-------------|--------------------|----------------------|
| edge-25.4.1 | v1.22+ | [v1.30.0 .. v1.33.0] |
| 26.3.2-edge | v1.33+ | [v1.30.0 .. v1.35.0] |
Using Edge Release artifacts and reporting bugs helps us ensure a rapid pace of development and is a great way to help maintainers.

View File

@@ -0,0 +1,126 @@
// Copyright 2022 Clastix Labs
// SPDX-License-Identifier: Apache-2.0
package e2e
import (
"context"
"fmt"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/sets"
"k8s.io/client-go/util/retry"
pointer "k8s.io/utils/ptr"
"sigs.k8s.io/controller-runtime/pkg/client"
kamajiv1alpha1 "github.com/clastix/kamaji/api/v1alpha1"
)
var _ = Describe("When the datastore-config Secret is corrupted for a PostgreSQL-backed TenantControlPlane", func() {
tcp := &kamajiv1alpha1.TenantControlPlane{
ObjectMeta: metav1.ObjectMeta{
Name: "postgresql-secret-regeneration",
Namespace: "default",
},
Spec: kamajiv1alpha1.TenantControlPlaneSpec{
DataStore: "postgresql-bronze",
ControlPlane: kamajiv1alpha1.ControlPlane{
Deployment: kamajiv1alpha1.DeploymentSpec{
Replicas: pointer.To(int32(1)),
},
Service: kamajiv1alpha1.ServiceSpec{
ServiceType: "ClusterIP",
},
},
Kubernetes: kamajiv1alpha1.KubernetesSpec{
Version: "v1.23.6",
},
},
}
JustBeforeEach(func() {
Expect(k8sClient.Create(context.Background(), tcp)).NotTo(HaveOccurred())
StatusMustEqualTo(tcp, kamajiv1alpha1.VersionReady)
})
JustAfterEach(func() {
Expect(k8sClient.Delete(context.Background(), tcp)).Should(Succeed())
})
It("Should regenerate the Secret and restart the TCP pods successfully", func() {
By("recording the UIDs of the currently running TenantControlPlane pods")
initialPodUIDs := sets.New[types.UID]()
Eventually(func() int {
podList := &corev1.PodList{}
if err := k8sClient.List(context.Background(), podList,
client.InNamespace(tcp.GetNamespace()),
client.MatchingLabels{"kamaji.clastix.io/name": tcp.GetName()},
); err != nil {
return 0
}
initialPodUIDs.Clear()
for _, pod := range podList.Items {
initialPodUIDs.Insert(pod.GetUID())
}
return initialPodUIDs.Len()
}, time.Minute, time.Second).Should(Not(BeZero()))
By("retrieving the current datastore-config Secret and its checksum")
secretName := fmt.Sprintf("%s-datastore-config", tcp.GetName())
var secret corev1.Secret
Expect(k8sClient.Get(context.Background(), types.NamespacedName{Name: secretName, Namespace: tcp.GetNamespace()}, &secret)).To(Succeed())
originalChecksum := secret.GetAnnotations()["kamaji.clastix.io/checksum"]
Expect(originalChecksum).NotTo(BeEmpty(), "expected datastore-config Secret to carry a checksum annotation")
By("corrupting the DB_PASSWORD in the datastore-config Secret")
err := retry.RetryOnConflict(retry.DefaultRetry, func() error {
if err := k8sClient.Get(context.Background(), client.ObjectKeyFromObject(&secret), &secret); err != nil {
return err
}
secret.Data["DB_PASSWORD"] = []byte("corrupted-password")
return k8sClient.Update(context.Background(), &secret)
})
Expect(err).ToNot(HaveOccurred())
By("waiting for the controller to detect the corruption and regenerate the Secret with a new checksum")
Eventually(func() string {
if err := k8sClient.Get(context.Background(), client.ObjectKeyFromObject(&secret), &secret); err != nil {
return ""
}
return secret.GetAnnotations()["kamaji.clastix.io/checksum"]
}, 5*time.Minute, time.Second).ShouldNot(Equal(originalChecksum))
By("waiting for at least one new TenantControlPlane pod to replace the pre-existing ones")
Eventually(func() bool {
var podList corev1.PodList
if err := k8sClient.List(context.Background(), &podList,
client.InNamespace(tcp.GetNamespace()),
client.MatchingLabels{"kamaji.clastix.io/name": tcp.GetName()},
); err != nil {
return false
}
for _, pod := range podList.Items {
if !initialPodUIDs.Has(pod.GetUID()) {
return true
}
}
return false
}, 5*time.Minute, time.Second).Should(BeTrue())
By("verifying the TenantControlPlane is Ready after the restart with the regenerated Secret")
StatusMustEqualTo(tcp, kamajiv1alpha1.VersionReady)
})
})

4
go.mod
View File

@@ -33,9 +33,9 @@ require (
k8s.io/apimachinery v0.35.0
k8s.io/client-go v0.35.0
k8s.io/cluster-bootstrap v0.0.0
k8s.io/klog/v2 v2.130.1
k8s.io/klog/v2 v2.140.0
k8s.io/kubelet v0.0.0
k8s.io/kubernetes v1.35.1
k8s.io/kubernetes v1.35.2
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4
sigs.k8s.io/controller-runtime v0.22.4
sigs.k8s.io/gateway-api v1.4.1

8
go.sum
View File

@@ -539,8 +539,8 @@ k8s.io/cri-api v0.35.0 h1:fxLSKyJHqbyCSUsg1rW4DRpmjSEM/elZ1GXzYTSLoDQ=
k8s.io/cri-api v0.35.0/go.mod h1:Cnt29u/tYl1Se1cBRL30uSZ/oJ5TaIp4sZm1xDLvcMc=
k8s.io/cri-client v0.35.0 h1:U1K4bteO93yioUS38804ybN+kWaon9zrzVtB37I3fCs=
k8s.io/cri-client v0.35.0/go.mod h1:XG5GkuuSpxvungsJVzW58NyWBoGSQhMMJmE5c66m9N8=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
k8s.io/kms v0.35.0 h1:/x87FED2kDSo66csKtcYCEHsxF/DBlNl7LfJ1fVQs1o=
k8s.io/kms v0.35.0/go.mod h1:VT+4ekZAdrZDMgShK37vvlyHUVhwI9t/9tvh0AyCWmQ=
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE=
@@ -549,8 +549,8 @@ k8s.io/kube-proxy v0.35.0 h1:erv2wYmGZ6nyu/FtmaIb+ORD3q2rfZ4Fhn7VXs/8cPQ=
k8s.io/kube-proxy v0.35.0/go.mod h1:bd9lpN3uLLOOWc/CFZbkPEi9DTkzQQymbE8FqSU4bWk=
k8s.io/kubelet v0.35.0 h1:8cgJHCBCKLYuuQ7/Pxb/qWbJfX1LXIw7790ce9xHq7c=
k8s.io/kubelet v0.35.0/go.mod h1:ciRzAXn7C4z5iB7FhG1L2CGPPXLTVCABDlbXt/Zz8YA=
k8s.io/kubernetes v1.35.1 h1:qmjXSCDPnOuXPuJb5pv+eLzpXhhlD09Jid1pG/OvFU8=
k8s.io/kubernetes v1.35.1/go.mod h1:AaPpCpiS8oAqRbEwpY5r3RitLpwpVp5lVXKFkJril58=
k8s.io/kubernetes v1.35.2 h1:2HthVDfK3YJYv624imuKXPzUJ17xQop9OT5dgT+IMKE=
k8s.io/kubernetes v1.35.2/go.mod h1:AaPpCpiS8oAqRbEwpY5r3RitLpwpVp5lVXKFkJril58=
k8s.io/system-validators v1.12.1 h1:AY1+COTLJN/Sj0w9QzH1H0yvyF3Kl6CguMnh32WlcUU=
k8s.io/system-validators v1.12.1/go.mod h1:awfSS706v9R12VC7u7K89FKfqVy44G+E0L1A0FX9Wmw=
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck=

View File

@@ -47,6 +47,7 @@ func NewStorageConnection(ctx context.Context, client client.Client, ds kamajiv1
type Connection interface {
CreateUser(ctx context.Context, user, password string) error
UpdateUser(ctx context.Context, user, password string) error
CreateDB(ctx context.Context, dbName string) error
GrantPrivileges(ctx context.Context, user, dbName string) error
UserExists(ctx context.Context, user string) (bool, error)

View File

@@ -5,6 +5,10 @@ package errors
import "fmt"
func NewUpdateUserError(err error) error {
return fmt.Errorf("cannot update user: %w", err)
}
func NewCreateUserError(err error) error {
return fmt.Errorf("cannot create user: %w", err)
}

View File

@@ -49,6 +49,10 @@ func (e *EtcdClient) CreateUser(ctx context.Context, user, password string) erro
return nil
}
func (e *EtcdClient) UpdateUser(ctx context.Context, user, password string) error {
return nil
}
func (e *EtcdClient) CreateDB(context.Context, string) error {
return nil
}

View File

@@ -29,6 +29,7 @@ const (
mysqlShowGrantsStatement = "SHOW GRANTS FOR `%s`@`%%`"
mysqlCreateDBStatement = "CREATE DATABASE IF NOT EXISTS %s"
mysqlCreateUserStatement = "CREATE USER `%s`@`%%` IDENTIFIED BY '%s'"
mysqlUpdateUserStatement = "ALTER USER `%s`@`%%` IDENTIFIED BY '%s'"
mysqlGrantPrivilegesStatement = "GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX ON `%s`.* TO `%s`@`%%`"
mysqlDropDBStatement = "DROP DATABASE IF EXISTS `%s`"
mysqlDropUserStatement = "DROP USER IF EXISTS `%s`"
@@ -158,6 +159,14 @@ func (c *MySQLConnection) CreateUser(ctx context.Context, user, password string)
return nil
}
func (c *MySQLConnection) UpdateUser(ctx context.Context, user, password string) error {
if err := c.mutate(ctx, mysqlUpdateUserStatement, user, password); err != nil {
return errors.NewUpdateUserError(err)
}
return nil
}
func (c *MySQLConnection) CreateDB(ctx context.Context, dbName string) error {
if err := c.mutate(ctx, mysqlCreateDBStatement, dbName); err != nil {
return errors.NewCreateDBError(err)

View File

@@ -70,6 +70,10 @@ func (nc *NATSConnection) CreateUser(_ context.Context, _, _ string) error {
return nil
}
func (nc *NATSConnection) UpdateUser(_ context.Context, _, _ string) error {
return nil
}
func (nc *NATSConnection) CreateDB(_ context.Context, dbName string) error {
_, err := nc.js.CreateKeyValue(&nats.KeyValueConfig{Bucket: dbName})
if err != nil {

View File

@@ -20,6 +20,7 @@ const (
postgresqlCreateDBStatement = `CREATE DATABASE "%s"`
postgresqlUserExists = "SELECT 1 FROM pg_roles WHERE rolname = ?"
postgresqlCreateUserStatement = `CREATE ROLE "%s" LOGIN PASSWORD ?`
postgresqlUpdateUserStatement = `ALTER ROLE "%s" WITH PASSWORD ?`
postgresqlShowGrantsStatement = "SELECT has_database_privilege(rolname, ?, 'create') from pg_roles where rolcanlogin and rolname = ?"
postgresqlShowOwnershipStatement = "SELECT 't' FROM pg_catalog.pg_database AS d WHERE d.datname = ? AND pg_catalog.pg_get_userbyid(d.datdba) = ?"
postgresqlShowTableOwnershipStatement = "SELECT 't' from pg_tables where tableowner = ? AND tablename = ?"
@@ -142,6 +143,15 @@ func (r *PostgreSQLConnection) CreateUser(ctx context.Context, user, password st
return nil
}
func (r *PostgreSQLConnection) UpdateUser(ctx context.Context, user, password string) error {
_, err := r.db.ExecContext(ctx, fmt.Sprintf(postgresqlUpdateUserStatement, user), password)
if err != nil {
return errors.NewUpdateUserError(err)
}
return nil
}
func (r *PostgreSQLConnection) DBExists(ctx context.Context, dbName string) (bool, error) {
rows, err := r.db.ExecContext(ctx, postgresqlFetchDBStatement, dbName)
if err != nil {

View File

@@ -104,6 +104,7 @@ func getKubeletConfigmapContent(kubeletConfiguration KubeletConfiguration, patch
return nil, fmt.Errorf("unable to apply JSON patching to KubeletConfiguration: %w", patchErr)
}
kc = kubelettypes.KubeletConfiguration{}
if patchErr = utilities.DecodeFromJSON(string(kubeletConfig), &kc); patchErr != nil {
return nil, fmt.Errorf("unable to decode JSON to KubeletConfiguration: %w", patchErr)
}

View File

@@ -230,6 +230,10 @@ func (r *Setup) createUser(ctx context.Context, _ *kamajiv1alpha1.TenantControlP
}
if exists {
if updateErr := r.Connection.UpdateUser(ctx, r.resource.user, r.resource.password); updateErr != nil {
return controllerutil.OperationResultNone, fmt.Errorf("unable to update the user to : %w", updateErr)
}
return controllerutil.OperationResultNone, nil
}

View File

@@ -1,58 +0,0 @@
// Copyright 2022 Clastix Labs
// SPDX-License-Identifier: Apache-2.0
package handlers
import (
"context"
"fmt"
"net"
"gomodules.xyz/jsonpatch/v2"
"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
kamajiv1alpha1 "github.com/clastix/kamaji/api/v1alpha1"
"github.com/clastix/kamaji/internal/webhook/utils"
)
type TenantControlPlaneLoadBalancerSourceRanges struct{}
func (t TenantControlPlaneLoadBalancerSourceRanges) handle(tcp *kamajiv1alpha1.TenantControlPlane) error {
for _, sourceCIDR := range tcp.Spec.NetworkProfile.LoadBalancerSourceRanges {
_, _, err := net.ParseCIDR(sourceCIDR)
if err != nil {
return fmt.Errorf("invalid LoadBalancer source CIDR %s, %s", sourceCIDR, err.Error())
}
}
return nil
}
func (t TenantControlPlaneLoadBalancerSourceRanges) OnCreate(object runtime.Object) AdmissionResponse {
return func(context.Context, admission.Request) ([]jsonpatch.JsonPatchOperation, error) {
tcp := object.(*kamajiv1alpha1.TenantControlPlane) //nolint:forcetypeassert
if err := t.handle(tcp); err != nil {
return nil, err
}
return nil, nil
}
}
func (t TenantControlPlaneLoadBalancerSourceRanges) OnDelete(runtime.Object) AdmissionResponse {
return utils.NilOp()
}
func (t TenantControlPlaneLoadBalancerSourceRanges) OnUpdate(object runtime.Object, _ runtime.Object) AdmissionResponse {
return func(context.Context, admission.Request) ([]jsonpatch.JsonPatchOperation, error) {
tcp := object.(*kamajiv1alpha1.TenantControlPlane) //nolint:forcetypeassert
if err := t.handle(tcp); err != nil {
return nil, err
}
return nil, nil
}
}

View File

@@ -1,64 +0,0 @@
// Copyright 2022 Clastix Labs
// SPDX-License-Identifier: Apache-2.0
package handlers_test
import (
"context"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
kamajiv1alpha1 "github.com/clastix/kamaji/api/v1alpha1"
"github.com/clastix/kamaji/internal/webhook/handlers"
)
var _ = Describe("TCP LoadBalancer Source Ranges Webhook", func() {
var (
ctx context.Context
t handlers.TenantControlPlaneLoadBalancerSourceRanges
tcp *kamajiv1alpha1.TenantControlPlane
)
BeforeEach(func() {
t = handlers.TenantControlPlaneLoadBalancerSourceRanges{}
tcp = &kamajiv1alpha1.TenantControlPlane{
ObjectMeta: metav1.ObjectMeta{
Name: "tcp",
Namespace: "default",
},
Spec: kamajiv1alpha1.TenantControlPlaneSpec{},
}
ctx = context.Background()
})
It("allows creation when valid CIDR ranges are provided", func() {
tcp.Spec.ControlPlane.Service.ServiceType = kamajiv1alpha1.ServiceTypeLoadBalancer
tcp.Spec.NetworkProfile.LoadBalancerSourceRanges = []string{"192.168.0.0/24"}
_, err := t.OnCreate(tcp)(ctx, admission.Request{})
Expect(err).ToNot(HaveOccurred())
})
It("allows creation when LoadBalancer service has no CIDR field", func() {
tcp.Spec.ControlPlane.Service.ServiceType = kamajiv1alpha1.ServiceTypeLoadBalancer
_, err := t.OnCreate(tcp)(ctx, admission.Request{})
Expect(err).ToNot(HaveOccurred())
})
It("allows creation when LoadBalancer service has an empty CIDR list", func() {
tcp.Spec.ControlPlane.Service.ServiceType = kamajiv1alpha1.ServiceTypeLoadBalancer
tcp.Spec.NetworkProfile.LoadBalancerSourceRanges = []string{}
_, err := t.OnCreate(tcp)(ctx, admission.Request{})
Expect(err).ToNot(HaveOccurred())
})
It("denies creation when source ranges contain invalid CIDRs", func() {
tcp.Spec.ControlPlane.Service.ServiceType = kamajiv1alpha1.ServiceTypeLoadBalancer
tcp.Spec.NetworkProfile.LoadBalancerSourceRanges = []string{"192.168.0.0/33"}
_, err := t.OnCreate(tcp)(ctx, admission.Request{})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("invalid LoadBalancer source CIDR 192.168.0.0/33"))
})
})

View File

@@ -1,71 +0,0 @@
// Copyright 2022 Clastix Labs
// SPDX-License-Identifier: Apache-2.0
package handlers
import (
"context"
"fmt"
"net"
"gomodules.xyz/jsonpatch/v2"
"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
kamajiv1alpha1 "github.com/clastix/kamaji/api/v1alpha1"
"github.com/clastix/kamaji/internal/webhook/utils"
)
type TenantControlPlaneServiceCIDR struct{}
func (t TenantControlPlaneServiceCIDR) handle(tcp *kamajiv1alpha1.TenantControlPlane) error {
if tcp.Spec.Addons.CoreDNS == nil {
return nil
}
_, cidr, err := net.ParseCIDR(tcp.Spec.NetworkProfile.ServiceCIDR)
if err != nil {
return fmt.Errorf("unable to parse Service CIDR, %s", err.Error())
}
for _, serviceIP := range tcp.Spec.NetworkProfile.DNSServiceIPs {
ip := net.ParseIP(serviceIP)
if ip == nil {
return fmt.Errorf("unable to parse IP address %s", serviceIP)
}
if !cidr.Contains(ip) {
return fmt.Errorf("the Service CIDR does not contain the DNS Service IP %s", serviceIP)
}
}
return nil
}
func (t TenantControlPlaneServiceCIDR) OnCreate(object runtime.Object) AdmissionResponse {
return func(context.Context, admission.Request) ([]jsonpatch.JsonPatchOperation, error) {
tcp := object.(*kamajiv1alpha1.TenantControlPlane) //nolint:forcetypeassert
if err := t.handle(tcp); err != nil {
return nil, err
}
return nil, nil
}
}
func (t TenantControlPlaneServiceCIDR) OnDelete(runtime.Object) AdmissionResponse {
return utils.NilOp()
}
func (t TenantControlPlaneServiceCIDR) OnUpdate(object runtime.Object, _ runtime.Object) AdmissionResponse {
return func(context.Context, admission.Request) ([]jsonpatch.JsonPatchOperation, error) {
tcp := object.(*kamajiv1alpha1.TenantControlPlane) //nolint:forcetypeassert
if err := t.handle(tcp); err != nil {
return nil, err
}
return nil, nil
}
}