Skip to content

Commit 889ff76

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

330 files changed

Lines changed: 19527 additions & 40 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: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
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 TestClusterProfileOverlayAndValues(t *testing.T) {
94+
flags := installCmdFlags{
95+
Component: common.EventingComponent,
96+
Namespace: "knative-eventing",
97+
Version: "1.18.0",
98+
ClusterProfile: "spoke",
99+
ClusterProfileNamespace: "fleet-system",
100+
}
101+
overlay := getOverlayYamlContent(&flags)
102+
values := getYamlValuesContent(&flags)
103+
104+
if !strings.Contains(overlay, "clusterProfileRef") {
105+
t.Fatalf("expected clusterProfileRef overlay, got:\n%s", overlay)
106+
}
107+
if !strings.Contains(values, "cluster_profile: spoke") || !strings.Contains(values, "cluster_profile_namespace: fleet-system") {
108+
t.Fatalf("expected cluster profile values, got:\n%s", values)
109+
}
110+
}
111+
112+
func TestClusterProfileOverlayRenders(t *testing.T) {
113+
flags := installCmdFlags{
114+
Component: common.ServingComponent,
115+
Namespace: "knative-serving",
116+
Version: "1.18.0",
117+
ClusterProfile: "spoke",
118+
ClusterProfileNamespace: "fleet-system",
119+
Istio: true,
120+
}
121+
yttp := common.YttProcessor{
122+
BaseData: []byte(`apiVersion: operator.knative.dev/v1beta1
123+
kind: KnativeServing
124+
metadata:
125+
name: knative-serving
126+
namespace: knative-serving
127+
spec:
128+
version: latest
129+
`),
130+
OverlayData: []byte(getOverlayYamlContent(&flags)),
131+
ValuesData: []byte(getYamlValuesContent(&flags)),
132+
}
133+
result, err := yttp.GenerateOutput()
134+
if err != nil {
135+
t.Fatalf("expected cluster profile overlay to render: %v", err)
136+
}
137+
if !strings.Contains(result, "clusterProfileRef:") ||
138+
!strings.Contains(result, "name: spoke") ||
139+
!strings.Contains(result, "namespace: fleet-system") {
140+
t.Fatalf("expected rendered cluster profile ref, got:\n%s", result)
141+
}
142+
}
143+
144+
func TestClusterProfileProviderFileArg(t *testing.T) {
145+
for _, tt := range []struct {
146+
name string
147+
args []string
148+
want bool
149+
}{{
150+
name: "inline",
151+
args: []string{"--clusterprofile-provider-file=/var/run/provider.json"},
152+
want: true,
153+
}, {
154+
name: "split",
155+
args: []string{"--clusterprofile-provider-file", "/var/run/provider.json"},
156+
want: true,
157+
}, {
158+
name: "empty inline",
159+
args: []string{"--clusterprofile-provider-file="},
160+
}, {
161+
name: "empty split",
162+
args: []string{"--clusterprofile-provider-file", ""},
163+
}, {
164+
name: "next flag",
165+
args: []string{"--clusterprofile-provider-file", "--other"},
166+
}} {
167+
t.Run(tt.name, func(t *testing.T) {
168+
testingUtil.AssertEqual(t, hasClusterProfileProviderFileArg(tt.args), tt.want)
169+
})
170+
}
171+
}
172+
173+
func TestValidateOperatorMulticlusterEnabled(t *testing.T) {
174+
for _, tt := range []struct {
175+
name string
176+
container corev1.Container
177+
}{{
178+
name: "provider file in args",
179+
container: corev1.Container{
180+
Name: common.KnativeOperatorName,
181+
Args: []string{"--clusterprofile-provider-file=/var/run/provider.json"},
182+
},
183+
}, {
184+
name: "provider file in command",
185+
container: corev1.Container{
186+
Name: common.KnativeOperatorName,
187+
Command: []string{"manager", "--clusterprofile-provider-file=/var/run/provider.json"},
188+
},
189+
}} {
190+
t.Run(tt.name, func(t *testing.T) {
191+
p := &pkg.OperatorParams{
192+
NewKubeClient: func() (kubernetes.Interface, error) {
193+
return kubefake.NewSimpleClientset(
194+
&appsv1.Deployment{
195+
ObjectMeta: metav1.ObjectMeta{Name: common.KnativeOperatorName, Namespace: "operator-ns"},
196+
Spec: appsv1.DeploymentSpec{Template: corev1.PodTemplateSpec{Spec: corev1.PodSpec{Containers: []corev1.Container{tt.container}}}},
197+
},
198+
), nil
199+
},
200+
}
201+
testingUtil.AssertEqual(t, validateOperatorMulticlusterEnabled("operator-ns", p), nil)
202+
})
203+
}
204+
205+
missingProvider := &pkg.OperatorParams{
206+
NewKubeClient: func() (kubernetes.Interface, error) {
207+
return kubefake.NewSimpleClientset(
208+
&appsv1.Deployment{
209+
ObjectMeta: metav1.ObjectMeta{Name: common.KnativeOperatorName, Namespace: "operator-ns"},
210+
Spec: appsv1.DeploymentSpec{Template: corev1.PodTemplateSpec{Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: common.KnativeOperatorName}}}}},
211+
},
212+
), nil
213+
},
214+
}
215+
if err := validateOperatorMulticlusterEnabled("operator-ns", missingProvider); err == nil || !strings.Contains(err.Error(), "--clusterprofile-provider-file") {
216+
t.Fatalf("expected provider-file error, got %v", err)
217+
}
218+
}
219+
220+
func TestValidateClusterProfileCRDSupport(t *testing.T) {
221+
p := &pkg.OperatorParams{
222+
NewDynamicClient: func() (dynamic.Interface, error) {
223+
return dynamicfake.NewSimpleDynamicClient(runtime.NewScheme(), clusterProfileCRD("knativeservings.operator.knative.dev", true)), nil
224+
},
225+
}
226+
testingUtil.AssertEqual(t, validateClusterProfileCRDSupport(common.ServingComponent, p), nil)
227+
228+
missingField := &pkg.OperatorParams{
229+
NewDynamicClient: func() (dynamic.Interface, error) {
230+
return dynamicfake.NewSimpleDynamicClient(runtime.NewScheme(), clusterProfileCRD("knativeservings.operator.knative.dev", false)), nil
231+
},
232+
}
233+
if err := validateClusterProfileCRDSupport(common.ServingComponent, missingField); err == nil || !strings.Contains(err.Error(), "does not support spec.clusterProfileRef") {
234+
t.Fatalf("expected CRD support error, got %v", err)
235+
}
236+
}
237+
238+
func TestRemoteReadinessTargetClusterResolvedFalse(t *testing.T) {
239+
ks := &operatorv1beta1.KnativeServing{
240+
Status: operatorv1beta1.KnativeServingStatus{
241+
Status: duckv1.Status{Conditions: duckv1.Conditions{{
242+
Type: base.TargetClusterResolved,
243+
Status: corev1.ConditionFalse,
244+
Reason: base.ReasonMulticlusterDisabled,
245+
Message: "multi-cluster is disabled",
246+
}}},
247+
},
248+
}
249+
ready, err := IsRemoteKnativeServingReady(ks, common.Latest, nil)
250+
testingUtil.AssertEqual(t, ready, false)
251+
if err != nil {
252+
t.Fatalf("expected TargetClusterResolved=False to keep polling, got %v", err)
253+
}
254+
255+
diagnostics := servingRemoteDiagnostics(ks)
256+
if !strings.Contains(diagnostics, string(base.TargetClusterResolved)) || !strings.Contains(diagnostics, base.ReasonMulticlusterDisabled) {
257+
t.Fatalf("expected remote diagnostics, got %s", diagnostics)
258+
}
259+
260+
ke := &operatorv1beta1.KnativeEventing{
261+
Status: operatorv1beta1.KnativeEventingStatus{
262+
Status: duckv1.Status{Conditions: duckv1.Conditions{{
263+
Type: base.TargetClusterResolved,
264+
Status: corev1.ConditionFalse,
265+
Reason: base.ReasonMulticlusterDisabled,
266+
Message: "multi-cluster is disabled",
267+
}}},
268+
},
269+
}
270+
ready, err = IsRemoteKnativeEventingReady(ke, common.Latest, nil)
271+
testingUtil.AssertEqual(t, ready, false)
272+
if err != nil {
273+
t.Fatalf("expected TargetClusterResolved=False to keep polling, got %v", err)
274+
}
275+
276+
diagnostics = eventingRemoteDiagnostics(ke)
277+
if !strings.Contains(diagnostics, string(base.TargetClusterResolved)) || !strings.Contains(diagnostics, base.ReasonMulticlusterDisabled) {
278+
t.Fatalf("expected remote diagnostics, got %s", diagnostics)
279+
}
280+
}
281+
282+
func clusterProfileCRD(name string, includeClusterProfileRef bool) *unstructured.Unstructured {
283+
specProperties := map[string]interface{}{}
284+
if includeClusterProfileRef {
285+
specProperties["clusterProfileRef"] = map[string]interface{}{"type": "object"}
286+
}
287+
288+
return &unstructured.Unstructured{
289+
Object: map[string]interface{}{
290+
"apiVersion": "apiextensions.k8s.io/v1",
291+
"kind": "CustomResourceDefinition",
292+
"metadata": map[string]interface{}{
293+
"name": name,
294+
},
295+
"spec": map[string]interface{}{
296+
"versions": []interface{}{
297+
map[string]interface{}{
298+
"name": "v1beta1",
299+
"served": true,
300+
"schema": map[string]interface{}{
301+
"openAPIV3Schema": map[string]interface{}{
302+
"properties": map[string]interface{}{
303+
"spec": map[string]interface{}{
304+
"type": "object",
305+
"properties": specProperties,
306+
},
307+
},
308+
},
309+
},
310+
},
311+
},
312+
},
313+
},
314+
}
315+
}

0 commit comments

Comments
 (0)