Skip to content

Commit 5a5e957

Browse files
Add classNamespace to topology (#11352)
- Add documentation on securing cross-namespace access for CC - Add ByClusterClassRef index - Support cross-ns CC rebase Signed-off-by: Danil-Grigorev <[email protected]>
1 parent 664518a commit 5a5e957

File tree

17 files changed

+340
-50
lines changed

17 files changed

+340
-50
lines changed

api/v1beta1/cluster_types.go

+13-1
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ limitations under the License.
1717
package v1beta1
1818

1919
import (
20+
"cmp"
2021
"fmt"
2122
"net"
2223
"strings"
@@ -517,6 +518,15 @@ type Topology struct {
517518
// The name of the ClusterClass object to create the topology.
518519
Class string `json:"class"`
519520

521+
// classNamespace is the namespace of the ClusterClass object to create the topology.
522+
// If the namespace is empty or not set, it is defaulted to the namespace of the cluster object.
523+
// Value must follow the DNS1123Subdomain syntax.
524+
// +optional
525+
// +kubebuilder:validation:MinLength=1
526+
// +kubebuilder:validation:MaxLength=253
527+
// +kubebuilder:validation:Pattern=`^[a-z0-9](?:[-a-z0-9]*[a-z0-9])?(?:\.[a-z0-9](?:[-a-z0-9]*[a-z0-9])?)*$`
528+
ClassNamespace string `json:"classNamespace,omitempty"`
529+
520530
// The Kubernetes version of the cluster.
521531
Version string `json:"version"`
522532

@@ -1045,7 +1055,9 @@ func (c *Cluster) GetClassKey() types.NamespacedName {
10451055
if c.Spec.Topology == nil {
10461056
return types.NamespacedName{}
10471057
}
1048-
return types.NamespacedName{Namespace: c.GetNamespace(), Name: c.Spec.Topology.Class}
1058+
1059+
namespace := cmp.Or(c.Spec.Topology.ClassNamespace, c.Namespace)
1060+
return types.NamespacedName{Namespace: namespace, Name: c.Spec.Topology.Class}
10491061
}
10501062

10511063
// GetConditions returns the set of conditions for this object.

api/v1beta1/index/cluster.go

+36
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,44 @@ import (
3030
const (
3131
// ClusterClassNameField is used by the Cluster controller to index Clusters by ClusterClass name.
3232
ClusterClassNameField = "spec.topology.class"
33+
34+
// ClusterClassRefPath is used by the Cluster controller to index Clusters by ClusterClass name and namespace.
35+
ClusterClassRefPath = "spec.topology.classRef"
36+
37+
// clusterClassRefFmt is used to correctly format class ref index key.
38+
clusterClassRefFmt = "%s/%s"
3339
)
3440

41+
// ByClusterClassRef adds the cluster class name index to the
42+
// managers cache.
43+
func ByClusterClassRef(ctx context.Context, mgr ctrl.Manager) error {
44+
if err := mgr.GetCache().IndexField(ctx, &clusterv1.Cluster{},
45+
ClusterClassRefPath,
46+
ClusterByClusterClassRef,
47+
); err != nil {
48+
return errors.Wrap(err, "error setting index field")
49+
}
50+
return nil
51+
}
52+
53+
// ClusterByClusterClassRef contains the logic to index Clusters by ClusterClass name and namespace.
54+
func ClusterByClusterClassRef(o client.Object) []string {
55+
cluster, ok := o.(*clusterv1.Cluster)
56+
if !ok {
57+
panic(fmt.Sprintf("Expected Cluster but got a %T", o))
58+
}
59+
if cluster.Spec.Topology != nil {
60+
key := cluster.GetClassKey()
61+
return []string{fmt.Sprintf(clusterClassRefFmt, key.Namespace, key.Name)}
62+
}
63+
return nil
64+
}
65+
66+
// ClusterClassRef returns ClusterClass index key to be used for search.
67+
func ClusterClassRef(cc *clusterv1.ClusterClass) string {
68+
return fmt.Sprintf(clusterClassRefFmt, cc.GetNamespace(), cc.GetName())
69+
}
70+
3571
// ByClusterClassName adds the cluster class name index to the
3672
// managers cache.
3773
func ByClusterClassName(ctx context.Context, mgr ctrl.Manager) error {

api/v1beta1/index/cluster_test.go

+24-3
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,13 @@ import (
2020
"testing"
2121

2222
. "github.com/onsi/gomega"
23+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2324
"sigs.k8s.io/controller-runtime/pkg/client"
2425

2526
clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1"
2627
)
2728

28-
func TestClusterByClassName(t *testing.T) {
29+
func TestClusterByClusterClassRef(t *testing.T) {
2930
testCases := []struct {
3031
name string
3132
object client.Object
@@ -39,20 +40,40 @@ func TestClusterByClassName(t *testing.T) {
3940
{
4041
name: "when cluster has a valid Topology",
4142
object: &clusterv1.Cluster{
43+
ObjectMeta: metav1.ObjectMeta{
44+
Name: "cluster",
45+
Namespace: "default",
46+
},
4247
Spec: clusterv1.ClusterSpec{
4348
Topology: &clusterv1.Topology{
4449
Class: "class1",
4550
},
4651
},
4752
},
48-
expected: []string{"class1"},
53+
expected: []string{"default/class1"},
54+
},
55+
{
56+
name: "when cluster has a valid Topology with namespace specified",
57+
object: &clusterv1.Cluster{
58+
ObjectMeta: metav1.ObjectMeta{
59+
Name: "cluster",
60+
Namespace: "default",
61+
},
62+
Spec: clusterv1.ClusterSpec{
63+
Topology: &clusterv1.Topology{
64+
Class: "class1",
65+
ClassNamespace: "other",
66+
},
67+
},
68+
},
69+
expected: []string{"other/class1"},
4970
},
5071
}
5172

5273
for _, test := range testCases {
5374
t.Run(test.name, func(t *testing.T) {
5475
g := NewWithT(t)
55-
got := ClusterByClusterClassClassName(test.object)
76+
got := ClusterByClusterClassRef(test.object)
5677
g.Expect(got).To(Equal(test.expected))
5778
})
5879
}

api/v1beta1/index/index.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ func AddDefaultIndexes(ctx context.Context, mgr ctrl.Manager) error {
3636
}
3737

3838
if feature.Gates.Enabled(feature.ClusterTopology) {
39-
if err := ByClusterClassName(ctx, mgr); err != nil {
39+
if err := ByClusterClassRef(ctx, mgr); err != nil {
4040
return err
4141
}
4242
}

api/v1beta1/zz_generated.openapi.go

+7
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

config/crd/bases/cluster.x-k8s.io_clusters.yaml

+9
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/book/src/tasks/experimental-features/cluster-class/write-clusterclass.md

+78-2
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ flexible enough to be used in as many Clusters as possible by supporting variant
1515
* [Defining a custom naming strategy for MachineDeployment objects](#defining-a-custom-naming-strategy-for-machinedeployment-objects)
1616
* [Defining a custom naming strategy for MachinePool objects](#defining-a-custom-naming-strategy-for-machinepool-objects)
1717
* [Advanced features of ClusterClass with patches](#advanced-features-of-clusterclass-with-patches)
18-
* [MachineDeployment variable overrides](#machinedeployment-variable-overrides)
18+
* [MachineDeployment variable overrides](#machinedeployment-and-machinepool-variable-overrides)
1919
* [Builtin variables](#builtin-variables)
2020
* [Complex variable types](#complex-variable-types)
2121
* [Using variable values in JSON patches](#using-variable-values-in-json-patches)
@@ -438,11 +438,87 @@ spec:
438438
template: "{{ .cluster.name }}-{{ .machinePool.topologyName }}-{{ .random }}"
439439
```
440440

441+
### Defining a custom namespace for ClusterClass object
442+
443+
As a user, I may need to create a `Cluster` from a `ClusterClass` object that exists only in a different namespace. To uniquely identify the `ClusterClass`, a `NamespacedName` ref is constructed from combination of:
444+
* `cluster.spec.topology.classNamespace` - namespace of the `ClusterClass` object.
445+
* `cluster.spec.topology.class` - name of the `ClusterClass` object.
446+
447+
Example of the `Cluster` object with the `name/namespace` reference:
448+
449+
```yaml
450+
apiVersion: cluster.x-k8s.io/v1beta1
451+
kind: Cluster
452+
metadata:
453+
name: my-docker-cluster
454+
namespace: default
455+
spec:
456+
topology:
457+
class: docker-clusterclass-v0.1.0
458+
classNamespace: default
459+
version: v1.22.4
460+
controlPlane:
461+
replicas: 3
462+
workers:
463+
machineDeployments:
464+
- class: default-worker
465+
name: md-0
466+
replicas: 4
467+
failureDomain: region
468+
```
469+
470+
471+
#### Securing cross-namespace reference to the ClusterClass
472+
473+
It is often desirable to restrict free cross-namespace `ClusterClass` access for the `Cluster` object. This can be implemented by defining a [`ValidatingAdmissionPolicy`](https://kubernetes.io/docs/reference/access-authn-authz/validating-admission-policy/#what-is-validating-admission-policy) on the `Cluster` object.
474+
475+
An example of such policy may be:
476+
477+
```yaml
478+
apiVersion: admissionregistration.k8s.io/v1
479+
kind: ValidatingAdmissionPolicy
480+
metadata:
481+
name: "cluster-class-ref.cluster.x-k8s.io"
482+
spec:
483+
failurePolicy: Fail
484+
paramKind:
485+
apiVersion: v1
486+
kind: Secret
487+
matchConstraints:
488+
resourceRules:
489+
- apiGroups: ["cluster.x-k8s.io"]
490+
apiVersions: ["v1beta1"]
491+
operations: ["CREATE", "UPDATE"]
492+
resources: ["clusters"]
493+
validations:
494+
- expression: "!has(object.spec.topology.classNamespace) || object.spec.topology.classNamespace in params.data"
495+
---
496+
apiVersion: admissionregistration.k8s.io/v1
497+
kind: ValidatingAdmissionPolicyBinding
498+
metadata:
499+
name: "cluster-class-ref-binding.cluster.x-k8s.io"
500+
spec:
501+
policyName: "cluster-class-ref.cluster.x-k8s.io"
502+
validationActions: [Deny]
503+
paramRef:
504+
name: "allowed-namespaces.cluster-class-ref.cluster.x-k8s.io"
505+
namespace: "default"
506+
parameterNotFoundAction: Deny
507+
---
508+
apiVersion: v1
509+
kind: Secret
510+
metadata:
511+
name: "allowed-namespaces.cluster-class-ref.cluster.x-k8s.io"
512+
namespace: "default"
513+
data:
514+
default: ""
515+
```
516+
441517
## Advanced features of ClusterClass with patches
442518

443519
This section will explain more advanced features of ClusterClass patches.
444520

445-
### MachineDeployment & MachinePool variable overrides
521+
### MachineDeployment and MachinePool variable overrides
446522

447523
If you want to use many variations of MachineDeployments in Clusters, you can either define
448524
a MachineDeployment class for every variation or you can define patches and variables to

internal/apis/core/v1alpha4/conversion.go

+1
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ func (src *Cluster) ConvertTo(dstRaw conversion.Hub) error {
4242
if dst.Spec.Topology == nil {
4343
dst.Spec.Topology = &clusterv1.Topology{}
4444
}
45+
dst.Spec.Topology.ClassNamespace = restored.Spec.Topology.ClassNamespace
4546
dst.Spec.Topology.Variables = restored.Spec.Topology.Variables
4647
dst.Spec.Topology.ControlPlane.Variables = restored.Spec.Topology.ControlPlane.Variables
4748

internal/apis/core/v1alpha4/zz_generated.conversion.go

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/controllers/topology/cluster/cluster_controller.go

+3-2
Original file line numberDiff line numberDiff line change
@@ -492,8 +492,9 @@ func (r *Reconciler) clusterClassToCluster(ctx context.Context, o client.Object)
492492
if err := r.Client.List(
493493
ctx,
494494
clusterList,
495-
client.MatchingFields{index.ClusterClassNameField: clusterClass.Name},
496-
client.InNamespace(clusterClass.Namespace),
495+
client.MatchingFields{
496+
index.ClusterClassRefPath: index.ClusterClassRef(clusterClass),
497+
},
497498
); err != nil {
498499
return nil
499500
}

internal/controllers/topology/cluster/cluster_controller_test.go

+68
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import (
5252
var (
5353
clusterName1 = "cluster1"
5454
clusterName2 = "cluster2"
55+
clusterName3 = "cluster3"
5556
clusterClassName1 = "class1"
5657
clusterClassName2 = "class2"
5758
infrastructureMachineTemplateName1 = "inframachinetemplate1"
@@ -854,6 +855,19 @@ func setupTestEnvForIntegrationTests(ns *corev1.Namespace) (func() error, error)
854855
Build()).
855856
Build()
856857

858+
// Cross ns referencing cluster
859+
cluster3 := builder.Cluster(ns.Name, clusterName3).
860+
WithTopology(
861+
builder.ClusterTopology().
862+
WithClass(clusterClass.Name).
863+
WithClassNamespace("other").
864+
WithMachineDeployment(machineDeploymentTopology2).
865+
WithMachinePool(machinePoolTopology2).
866+
WithVersion("1.21.0").
867+
WithControlPlaneReplicas(1).
868+
Build()).
869+
Build()
870+
857871
// Setup kubeconfig secrets for the clusters, so the ClusterCacheTracker works.
858872
cluster1Secret := kubeconfig.GenerateSecret(cluster1, kubeconfig.FromEnvTestConfig(env.Config, cluster1))
859873
cluster2Secret := kubeconfig.GenerateSecret(cluster2, kubeconfig.FromEnvTestConfig(env.Config, cluster2))
@@ -876,6 +890,7 @@ func setupTestEnvForIntegrationTests(ns *corev1.Namespace) (func() error, error)
876890
clusterClassForRebase,
877891
cluster1,
878892
cluster2,
893+
cluster3,
879894
cluster1Secret,
880895
cluster2Secret,
881896
}
@@ -1577,3 +1592,56 @@ func TestReconciler_ValidateCluster(t *testing.T) {
15771592
})
15781593
}
15791594
}
1595+
1596+
func TestClusterClassToCluster(t *testing.T) {
1597+
utilfeature.SetFeatureGateDuringTest(t, feature.Gates, feature.ClusterTopology, true)
1598+
g := NewWithT(t)
1599+
1600+
ns, err := env.CreateNamespace(ctx, "cluster-reconcile-namespace")
1601+
g.Expect(err).ToNot(HaveOccurred())
1602+
1603+
// Create the objects needed for the integration test:
1604+
cleanup, err := setupTestEnvForIntegrationTests(ns)
1605+
g.Expect(err).ToNot(HaveOccurred())
1606+
1607+
// Defer a cleanup function that deletes each of the objects created during setupTestEnvForIntegrationTests.
1608+
defer func() {
1609+
g.Expect(cleanup()).To(Succeed())
1610+
}()
1611+
1612+
tests := []struct {
1613+
name string
1614+
clusterClass *clusterv1.ClusterClass
1615+
expected []reconcile.Request
1616+
}{
1617+
{
1618+
name: "ClusterClass change should request reconcile for the referenced class",
1619+
clusterClass: builder.ClusterClass(ns.Name, clusterClassName1).Build(),
1620+
expected: []reconcile.Request{
1621+
{NamespacedName: client.ObjectKeyFromObject(builder.Cluster(ns.Name, clusterName1).Build())},
1622+
{NamespacedName: client.ObjectKeyFromObject(builder.Cluster(ns.Name, clusterName2).Build())},
1623+
},
1624+
},
1625+
{
1626+
name: "ClusterClass with no matching name and namespace should not trigger reconcile",
1627+
clusterClass: builder.ClusterClass("other", clusterClassName2).Build(),
1628+
expected: []reconcile.Request{},
1629+
},
1630+
{
1631+
name: "Different ClusterClass with matching name and namespace should trigger reconcile",
1632+
clusterClass: builder.ClusterClass("other", clusterClassName1).Build(),
1633+
expected: []reconcile.Request{
1634+
{NamespacedName: client.ObjectKeyFromObject(builder.Cluster(ns.Name, clusterName3).Build())},
1635+
},
1636+
},
1637+
}
1638+
1639+
for _, tt := range tests {
1640+
t.Run(tt.name, func(*testing.T) {
1641+
r := &Reconciler{Client: env.GetClient()}
1642+
1643+
requests := r.clusterClassToCluster(ctx, tt.clusterClass)
1644+
g.Expect(requests).To(ConsistOf(tt.expected))
1645+
})
1646+
}
1647+
}

0 commit comments

Comments
 (0)