Skip to content

Commit c976c8b

Browse files
committed
Add ClusterProfile install support
Signed-off-by: kahirokunn <okinakahiro@gmail.com>
1 parent fa1cb1d commit c976c8b

330 files changed

Lines changed: 19504 additions & 32 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,5 +52,30 @@ Run it
5252
kn operator -h
5353
```
5454

55+
## Remote Component Installs With ClusterProfile
56+
57+
You can install Serving or Eventing to a remote target cluster by creating the
58+
hub `KnativeServing` or `KnativeEventing` CR with `spec.clusterProfileRef`:
59+
60+
```sh
61+
kn operator install -c serving \
62+
--namespace knative-serving \
63+
--cluster-profile spoke \
64+
--cluster-profile-namespace fleet-system
65+
66+
kn operator install -c eventing \
67+
--namespace knative-eventing \
68+
--cluster-profile spoke \
69+
--cluster-profile-namespace fleet-system
70+
```
71+
72+
For remote installs, `--kubeconfig` must point to the hub cluster. The Knative
73+
Operator must already be installed on the hub and configured with
74+
`--clusterprofile-provider-file`; this plugin does not create provider
75+
configuration.
76+
77+
`spec.clusterProfileRef` is immutable. Moving a component to another
78+
ClusterProfile requires deleting and recreating the component CR.
79+
5580
You can use the built binary to run the commands. You can also use the bash scripts directly to run your commands.
5681
All the bash scripts are available under the directory [scripts](scripts/).
Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
/*
2+
Copyright 2026 The Knative Authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package install
18+
19+
import (
20+
"strings"
21+
"testing"
22+
23+
appsv1 "k8s.io/api/apps/v1"
24+
corev1 "k8s.io/api/core/v1"
25+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
26+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
27+
"k8s.io/apimachinery/pkg/runtime"
28+
"k8s.io/client-go/dynamic"
29+
dynamicfake "k8s.io/client-go/dynamic/fake"
30+
"k8s.io/client-go/kubernetes"
31+
kubefake "k8s.io/client-go/kubernetes/fake"
32+
"knative.dev/kn-plugin-operator/pkg"
33+
"knative.dev/kn-plugin-operator/pkg/command/common"
34+
"knative.dev/kn-plugin-operator/pkg/command/testingUtil"
35+
"knative.dev/operator/pkg/apis/operator/base"
36+
operatorv1beta1 "knative.dev/operator/pkg/apis/operator/v1beta1"
37+
duckv1 "knative.dev/pkg/apis/duck/v1"
38+
)
39+
40+
func TestValidateClusterProfileFlags(t *testing.T) {
41+
for _, tt := range []struct {
42+
name string
43+
flags installCmdFlags
44+
wantErr string
45+
}{{
46+
name: "no flags",
47+
flags: installCmdFlags{},
48+
}, {
49+
name: "both flags",
50+
flags: installCmdFlags{
51+
Component: common.ServingComponent,
52+
ClusterProfile: " spoke ",
53+
ClusterProfileNamespace: " fleet-system ",
54+
},
55+
}, {
56+
name: "missing namespace",
57+
flags: installCmdFlags{
58+
Component: common.ServingComponent,
59+
ClusterProfile: "spoke",
60+
},
61+
wantErr: "must be provided together",
62+
}, {
63+
name: "operator install",
64+
flags: installCmdFlags{
65+
ClusterProfile: "spoke",
66+
ClusterProfileNamespace: "fleet-system",
67+
},
68+
wantErr: "require --component serving or --component eventing",
69+
}, {
70+
name: "blank profile",
71+
flags: installCmdFlags{
72+
Component: common.ServingComponent,
73+
ClusterProfile: " ",
74+
ClusterProfileNamespace: "fleet-system",
75+
},
76+
wantErr: "must be non-empty",
77+
}} {
78+
t.Run(tt.name, func(t *testing.T) {
79+
err := validateClusterProfileFlags(&tt.flags)
80+
if tt.wantErr == "" {
81+
testingUtil.AssertEqual(t, err, nil)
82+
} else if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
83+
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
84+
}
85+
if tt.name == "both flags" {
86+
testingUtil.AssertEqual(t, tt.flags.ClusterProfile, "spoke")
87+
testingUtil.AssertEqual(t, tt.flags.ClusterProfileNamespace, "fleet-system")
88+
}
89+
})
90+
}
91+
}
92+
93+
func TestValidateExistingClusterProfileRef(t *testing.T) {
94+
flags := installCmdFlags{
95+
Component: common.ServingComponent,
96+
ClusterProfile: "spoke",
97+
ClusterProfileNamespace: "fleet-system",
98+
}
99+
same := &base.ClusterProfileReference{Name: "spoke", Namespace: "fleet-system"}
100+
different := &base.ClusterProfileReference{Name: "other", Namespace: "fleet-system"}
101+
102+
testingUtil.AssertEqual(t, validateExistingClusterProfileRef(&flags, false, nil), nil)
103+
testingUtil.AssertEqual(t, validateExistingClusterProfileRef(&flags, true, same), nil)
104+
if err := validateExistingClusterProfileRef(&flags, true, nil); err == nil || !strings.Contains(err.Error(), "immutable") {
105+
t.Fatalf("expected immutable error for existing local CR, got %v", err)
106+
}
107+
if err := validateExistingClusterProfileRef(&flags, true, different); err == nil || !strings.Contains(err.Error(), "immutable") {
108+
t.Fatalf("expected immutable error for different ref, got %v", err)
109+
}
110+
}
111+
112+
func TestClusterProfileOverlayAndValues(t *testing.T) {
113+
flags := installCmdFlags{
114+
Component: common.EventingComponent,
115+
Namespace: "knative-eventing",
116+
Version: "1.18.0",
117+
ClusterProfile: "spoke",
118+
ClusterProfileNamespace: "fleet-system",
119+
}
120+
overlay := getOverlayYamlContent(&flags)
121+
values := getYamlValuesContent(&flags)
122+
123+
if !strings.Contains(overlay, "clusterProfileRef") {
124+
t.Fatalf("expected clusterProfileRef overlay, got:\n%s", overlay)
125+
}
126+
if !strings.Contains(values, "cluster_profile: spoke") || !strings.Contains(values, "cluster_profile_namespace: fleet-system") {
127+
t.Fatalf("expected cluster profile values, got:\n%s", values)
128+
}
129+
}
130+
131+
func TestClusterProfileOverlayRenders(t *testing.T) {
132+
flags := installCmdFlags{
133+
Component: common.ServingComponent,
134+
Namespace: "knative-serving",
135+
Version: "1.18.0",
136+
ClusterProfile: "spoke",
137+
ClusterProfileNamespace: "fleet-system",
138+
Istio: true,
139+
}
140+
yttp := common.YttProcessor{
141+
BaseData: []byte(`apiVersion: operator.knative.dev/v1beta1
142+
kind: KnativeServing
143+
metadata:
144+
name: knative-serving
145+
namespace: knative-serving
146+
spec:
147+
version: latest
148+
`),
149+
OverlayData: []byte(getOverlayYamlContent(&flags)),
150+
ValuesData: []byte(getYamlValuesContent(&flags)),
151+
}
152+
result, err := yttp.GenerateOutput()
153+
if err != nil {
154+
t.Fatalf("expected cluster profile overlay to render: %v", err)
155+
}
156+
if !strings.Contains(result, "clusterProfileRef:") ||
157+
!strings.Contains(result, "name: spoke") ||
158+
!strings.Contains(result, "namespace: fleet-system") {
159+
t.Fatalf("expected rendered cluster profile ref, got:\n%s", result)
160+
}
161+
}
162+
163+
func TestClusterProfileProviderFileArg(t *testing.T) {
164+
for _, tt := range []struct {
165+
name string
166+
args []string
167+
want bool
168+
}{{
169+
name: "inline",
170+
args: []string{"--clusterprofile-provider-file=/var/run/provider.json"},
171+
want: true,
172+
}, {
173+
name: "split",
174+
args: []string{"--clusterprofile-provider-file", "/var/run/provider.json"},
175+
want: true,
176+
}, {
177+
name: "empty inline",
178+
args: []string{"--clusterprofile-provider-file="},
179+
}, {
180+
name: "empty split",
181+
args: []string{"--clusterprofile-provider-file", ""},
182+
}, {
183+
name: "next flag",
184+
args: []string{"--clusterprofile-provider-file", "--other"},
185+
}} {
186+
t.Run(tt.name, func(t *testing.T) {
187+
testingUtil.AssertEqual(t, hasClusterProfileProviderFileArg(tt.args), tt.want)
188+
})
189+
}
190+
}
191+
192+
func TestValidateOperatorMulticlusterEnabled(t *testing.T) {
193+
for _, tt := range []struct {
194+
name string
195+
container corev1.Container
196+
}{{
197+
name: "provider file in args",
198+
container: corev1.Container{
199+
Name: common.KnativeOperatorName,
200+
Args: []string{"--clusterprofile-provider-file=/var/run/provider.json"},
201+
},
202+
}, {
203+
name: "provider file in command",
204+
container: corev1.Container{
205+
Name: common.KnativeOperatorName,
206+
Command: []string{"manager", "--clusterprofile-provider-file=/var/run/provider.json"},
207+
},
208+
}} {
209+
t.Run(tt.name, func(t *testing.T) {
210+
p := &pkg.OperatorParams{
211+
NewKubeClient: func() (kubernetes.Interface, error) {
212+
return kubefake.NewSimpleClientset(
213+
&appsv1.Deployment{
214+
ObjectMeta: metav1.ObjectMeta{Name: common.KnativeOperatorName, Namespace: "operator-ns"},
215+
Spec: appsv1.DeploymentSpec{Template: corev1.PodTemplateSpec{Spec: corev1.PodSpec{Containers: []corev1.Container{tt.container}}}},
216+
},
217+
), nil
218+
},
219+
}
220+
testingUtil.AssertEqual(t, validateOperatorMulticlusterEnabled("operator-ns", p), nil)
221+
})
222+
}
223+
224+
missingProvider := &pkg.OperatorParams{
225+
NewKubeClient: func() (kubernetes.Interface, error) {
226+
return kubefake.NewSimpleClientset(
227+
&appsv1.Deployment{
228+
ObjectMeta: metav1.ObjectMeta{Name: common.KnativeOperatorName, Namespace: "operator-ns"},
229+
Spec: appsv1.DeploymentSpec{Template: corev1.PodTemplateSpec{Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: common.KnativeOperatorName}}}}},
230+
},
231+
), nil
232+
},
233+
}
234+
if err := validateOperatorMulticlusterEnabled("operator-ns", missingProvider); err == nil || !strings.Contains(err.Error(), "--clusterprofile-provider-file") {
235+
t.Fatalf("expected provider-file error, got %v", err)
236+
}
237+
}
238+
239+
func TestValidateClusterProfileCRDSupport(t *testing.T) {
240+
p := &pkg.OperatorParams{
241+
NewDynamicClient: func() (dynamic.Interface, error) {
242+
return dynamicfake.NewSimpleDynamicClient(runtime.NewScheme(), clusterProfileCRD("knativeservings.operator.knative.dev", true)), nil
243+
},
244+
}
245+
testingUtil.AssertEqual(t, validateClusterProfileCRDSupport(common.ServingComponent, p), nil)
246+
247+
missingField := &pkg.OperatorParams{
248+
NewDynamicClient: func() (dynamic.Interface, error) {
249+
return dynamicfake.NewSimpleDynamicClient(runtime.NewScheme(), clusterProfileCRD("knativeservings.operator.knative.dev", false)), nil
250+
},
251+
}
252+
if err := validateClusterProfileCRDSupport(common.ServingComponent, missingField); err == nil || !strings.Contains(err.Error(), "does not support spec.clusterProfileRef") {
253+
t.Fatalf("expected CRD support error, got %v", err)
254+
}
255+
}
256+
257+
func TestRemoteReadinessTargetClusterResolvedFalse(t *testing.T) {
258+
ks := &operatorv1beta1.KnativeServing{
259+
Status: operatorv1beta1.KnativeServingStatus{
260+
Status: duckv1.Status{Conditions: duckv1.Conditions{{
261+
Type: base.TargetClusterResolved,
262+
Status: corev1.ConditionFalse,
263+
Reason: base.ReasonMulticlusterDisabled,
264+
Message: "multi-cluster is disabled",
265+
}}},
266+
},
267+
}
268+
ready, err := IsRemoteKnativeServingReady(ks, common.Latest, nil)
269+
testingUtil.AssertEqual(t, ready, false)
270+
if err == nil || !strings.Contains(err.Error(), base.ReasonMulticlusterDisabled) {
271+
t.Fatalf("expected TargetClusterResolved reason, got %v", err)
272+
}
273+
274+
diagnostics := servingRemoteDiagnostics(ks)
275+
if !strings.Contains(diagnostics, string(base.TargetClusterResolved)) || !strings.Contains(diagnostics, base.ReasonMulticlusterDisabled) {
276+
t.Fatalf("expected remote diagnostics, got %s", diagnostics)
277+
}
278+
}
279+
280+
func clusterProfileCRD(name string, includeClusterProfileRef bool) *unstructured.Unstructured {
281+
specProperties := map[string]interface{}{}
282+
if includeClusterProfileRef {
283+
specProperties["clusterProfileRef"] = map[string]interface{}{"type": "object"}
284+
}
285+
286+
return &unstructured.Unstructured{
287+
Object: map[string]interface{}{
288+
"apiVersion": "apiextensions.k8s.io/v1",
289+
"kind": "CustomResourceDefinition",
290+
"metadata": map[string]interface{}{
291+
"name": name,
292+
},
293+
"spec": map[string]interface{}{
294+
"versions": []interface{}{
295+
map[string]interface{}{
296+
"name": "v1beta1",
297+
"served": true,
298+
"schema": map[string]interface{}{
299+
"openAPIV3Schema": map[string]interface{}{
300+
"properties": map[string]interface{}{
301+
"spec": map[string]interface{}{
302+
"type": "object",
303+
"properties": specProperties,
304+
},
305+
},
306+
},
307+
},
308+
},
309+
},
310+
},
311+
},
312+
}
313+
}

0 commit comments

Comments
 (0)