diff --git a/cli/pkg/workspace/plugin/create.go b/cli/pkg/workspace/plugin/create.go index b4c1a361838..54b976c3c87 100644 --- a/cli/pkg/workspace/plugin/create.go +++ b/cli/pkg/workspace/plugin/create.go @@ -127,17 +127,17 @@ func (o *CreateWorkspaceOptions) Run(ctx context.Context) error { return fmt.Errorf("--ignore-existing must not be used with non-absolute type path") } - var structuredWorkspaceType tenancyv1alpha1.WorkspaceTypeReference + var structuredWorkspaceType *tenancyv1alpha1.WorkspaceTypeReference if o.Type != "" { separatorIndex := strings.LastIndex(o.Type, ":") switch separatorIndex { case -1: - structuredWorkspaceType = tenancyv1alpha1.WorkspaceTypeReference{ + structuredWorkspaceType = &tenancyv1alpha1.WorkspaceTypeReference{ Name: tenancyv1alpha1.WorkspaceTypeName(strings.ToLower(o.Type)), // path is defaulted through admission } default: - structuredWorkspaceType = tenancyv1alpha1.WorkspaceTypeReference{ + structuredWorkspaceType = &tenancyv1alpha1.WorkspaceTypeReference{ Name: tenancyv1alpha1.WorkspaceTypeName(strings.ToLower(o.Type[separatorIndex+1:])), Path: o.Type[:separatorIndex], } diff --git a/cli/pkg/workspace/plugin/create_test.go b/cli/pkg/workspace/plugin/create_test.go index 7d1b88fc365..61348530024 100644 --- a/cli/pkg/workspace/plugin/create_test.go +++ b/cli/pkg/workspace/plugin/create_test.go @@ -50,7 +50,7 @@ func TestCreate(t *testing.T) { markReady bool newWorkspaceName string - newWorkspaceType tenancyv1alpha1.WorkspaceTypeReference + newWorkspaceType *tenancyv1alpha1.WorkspaceTypeReference useAfterCreation, ignoreExisting bool expected *clientcmdapi.Config @@ -145,7 +145,7 @@ func TestCreate(t *testing.T) { }, existingWorkspaces: []string{"bar"}, newWorkspaceName: "bar", - newWorkspaceType: tenancyv1alpha1.WorkspaceTypeReference{Path: "root", Name: "universal"}, + newWorkspaceType: &tenancyv1alpha1.WorkspaceTypeReference{Path: "root", Name: "universal"}, useAfterCreation: true, markReady: true, ignoreExisting: true, @@ -171,7 +171,7 @@ func TestCreate(t *testing.T) { }, newWorkspaceName: "bar", ignoreExisting: true, - newWorkspaceType: tenancyv1alpha1.WorkspaceTypeReference{Name: "universal"}, + newWorkspaceType: &tenancyv1alpha1.WorkspaceTypeReference{Name: "universal"}, wantErr: true, }, } @@ -197,7 +197,7 @@ func TestCreate(t *testing.T) { }, Spec: tenancyv1alpha1.WorkspaceSpec{ URL: fmt.Sprintf("https://test%s", currentClusterName.Join(name).RequestPath()), - Type: tenancyv1alpha1.WorkspaceTypeReference{ + Type: &tenancyv1alpha1.WorkspaceTypeReference{ Name: "universal", Path: "root", }, @@ -209,10 +209,9 @@ func TestCreate(t *testing.T) { } client := kcpfakeclient.NewSimpleClientset(objects...) - workspaceType := tt.newWorkspaceType //nolint:govet // TODO(sttts): fixing this above breaks the test - empty := tenancyv1alpha1.WorkspaceTypeReference{} - if workspaceType == empty { - workspaceType = tenancyv1alpha1.WorkspaceTypeReference{ + workspaceType := tt.newWorkspaceType + if tt.newWorkspaceType == nil { + workspaceType = &tenancyv1alpha1.WorkspaceTypeReference{ Name: "universal", Path: "root", } diff --git a/cli/pkg/workspace/plugin/use.go b/cli/pkg/workspace/plugin/use.go index 4a599062223..58de5767919 100644 --- a/cli/pkg/workspace/plugin/use.go +++ b/cli/pkg/workspace/plugin/use.go @@ -225,7 +225,7 @@ func (o *UseWorkspaceOptions) Run(ctx context.Context) (err error) { if ws, err := o.kcpClusterClient.Cluster(parentClusterName).TenancyV1alpha1().Workspaces().Get(ctx, workspaceName, metav1.GetOptions{}); apierrors.IsNotFound(err) { notFound = true } else if err == nil { - workspaceType = &ws.Spec.Type + workspaceType = ws.Spec.Type } } } diff --git a/cli/pkg/workspace/plugin/use_test.go b/cli/pkg/workspace/plugin/use_test.go index bf3d4c02166..38b17684388 100644 --- a/cli/pkg/workspace/plugin/use_test.go +++ b/cli/pkg/workspace/plugin/use_test.go @@ -754,7 +754,7 @@ func TestUse(t *testing.T) { Annotations: map[string]string{logicalcluster.AnnotationKey: lcluster.String()}, }, Spec: tenancyv1alpha1.WorkspaceSpec{ - Type: tenancyv1alpha1.WorkspaceTypeReference{ + Type: &tenancyv1alpha1.WorkspaceTypeReference{ Name: "universal", Path: "root", }, @@ -780,7 +780,7 @@ func TestUse(t *testing.T) { }, Spec: tenancyv1alpha1.WorkspaceSpec{ URL: fmt.Sprintf("https://test%s", homeWorkspace.RequestPath()), - Type: tenancyv1alpha1.WorkspaceTypeReference{ + Type: &tenancyv1alpha1.WorkspaceTypeReference{ Name: "home", Path: "root", }, diff --git a/config/crds/tenancy.kcp.io_workspaces.yaml b/config/crds/tenancy.kcp.io_workspaces.yaml index 7035d657276..b78a0d55b9f 100644 --- a/config/crds/tenancy.kcp.io_workspaces.yaml +++ b/config/crds/tenancy.kcp.io_workspaces.yaml @@ -27,7 +27,7 @@ spec: name: Region type: string - description: The current phase (e.g. Scheduling, Initializing, Ready, Deleting) - jsonPath: .metadata.labels['tenancy\.kcp\.io/phase'] + jsonPath: .status.phase name: Phase type: string - description: URL to access the workspace @@ -147,6 +147,45 @@ spec: type: object x-kubernetes-map-type: atomic type: object + mount: + description: |- + Mount is a reference to a an object implementing a mounting feature. It is used to orchestrate + where the traffic, intended for the workspace, is sent. + If specified, logicalcluster will not be created and the workspace will be mounted + using reference mount object. + properties: + ref: + description: Reference is an ObjectReference to the object that + is mounted. + properties: + apiVersion: + description: APIVersion is the API group and version of the + object. + type: string + kind: + description: Kind is the kind of the object. + type: string + name: + description: Name is the name of the object. + type: string + namespace: + description: Namespace is the namespace of the object. + type: string + required: + - apiVersion + - kind + - name + type: object + x-kubernetes-validations: + - message: apiVersion is immutable + rule: has(oldSelf.apiVersion) == has(self.apiVersion) + - message: kind is immutable + rule: has(oldSelf.kind) == has(self.kind) + - message: name is immutable + rule: has(oldSelf.name) == has(self.name) + required: + - ref + type: object type: description: |- type defines properties of the workspace both on creation (e.g. initial diff --git a/config/root-phase0/apiexport-tenancy.kcp.io.yaml b/config/root-phase0/apiexport-tenancy.kcp.io.yaml index cd57b4e6a10..6df41191d1f 100644 --- a/config/root-phase0/apiexport-tenancy.kcp.io.yaml +++ b/config/root-phase0/apiexport-tenancy.kcp.io.yaml @@ -9,7 +9,7 @@ spec: resources: - group: tenancy.kcp.io name: workspaces - schema: v241020-fce06d31d.workspaces.tenancy.kcp.io + schema: v250315-28b43d5a9.workspaces.tenancy.kcp.io storage: crd: {} - group: tenancy.kcp.io diff --git a/config/root-phase0/apiresourceschema-workspaces.tenancy.kcp.io.yaml b/config/root-phase0/apiresourceschema-workspaces.tenancy.kcp.io.yaml index bb58fc43ac5..35152186073 100644 --- a/config/root-phase0/apiresourceschema-workspaces.tenancy.kcp.io.yaml +++ b/config/root-phase0/apiresourceschema-workspaces.tenancy.kcp.io.yaml @@ -2,7 +2,7 @@ apiVersion: apis.kcp.io/v1alpha1 kind: APIResourceSchema metadata: creationTimestamp: null - name: v241020-fce06d31d.workspaces.tenancy.kcp.io + name: v250315-28b43d5a9.workspaces.tenancy.kcp.io spec: group: tenancy.kcp.io names: @@ -26,7 +26,7 @@ spec: name: Region type: string - description: The current phase (e.g. Scheduling, Initializing, Ready, Deleting) - jsonPath: .metadata.labels['tenancy\.kcp\.io/phase'] + jsonPath: .status.phase name: Phase type: string - description: URL to access the workspace @@ -145,6 +145,45 @@ spec: type: object x-kubernetes-map-type: atomic type: object + mount: + description: |- + Mount is a reference to a an object implementing a mounting feature. It is used to orchestrate + where the traffic, intended for the workspace, is sent. + If specified, logicalcluster will not be created and the workspace will be mounted + using reference mount object. + properties: + ref: + description: Reference is an ObjectReference to the object that + is mounted. + properties: + apiVersion: + description: APIVersion is the API group and version of the + object. + type: string + kind: + description: Kind is the kind of the object. + type: string + name: + description: Name is the name of the object. + type: string + namespace: + description: Namespace is the namespace of the object. + type: string + required: + - apiVersion + - kind + - name + type: object + x-kubernetes-validations: + - message: apiVersion is immutable + rule: has(oldSelf.apiVersion) == has(self.apiVersion) + - message: kind is immutable + rule: has(oldSelf.kind) == has(self.kind) + - message: name is immutable + rule: has(oldSelf.name) == has(self.name) + required: + - ref + type: object type: description: |- type defines properties of the workspace both on creation (e.g. initial diff --git a/pkg/admission/workspace/admission.go b/pkg/admission/workspace/admission.go index 2abab086a7b..3226ffc2927 100644 --- a/pkg/admission/workspace/admission.go +++ b/pkg/admission/workspace/admission.go @@ -131,7 +131,8 @@ func (o *workspace) Admit(ctx context.Context, a admission.Attributes, _ admissi // - has a valid type and it is not mutated // - the cluster is not removed // - the user is recorded in annotations on create -// - the required groups match with the LogicalCluster. +// - the required groups match with the LogicalCluster +// - only system privileged users can set both spec.Type and spec.Mount. func (o *workspace) Validate(ctx context.Context, a admission.Attributes, _ admission.ObjectInterfaces) (err error) { clusterName, err := genericapirequest.ClusterNameFrom(ctx) if err != nil { @@ -164,30 +165,62 @@ func (o *workspace) Validate(ctx context.Context, a admission.Attributes, _ admi return fmt.Errorf("failed to convert unstructured to Workspace: %w", err) } - if old.Spec.Cluster != "" && ws.Spec.Cluster == "" { - return admission.NewForbidden(a, errors.New("spec.cluster cannot be unset")) - } - if old.Spec.Cluster != ws.Spec.Cluster && !isSystemPrivileged { - return admission.NewForbidden(a, errors.New("spec.cluster can only be changed by system privileged users")) - } - if old.Spec.URL != ws.Spec.URL && !isSystemPrivileged { - return admission.NewForbidden(a, errors.New("spec.URL can only be changed by system privileged users")) - } + // Not a mountpoint - validate the spec fields + if !old.Spec.IsMounted() { + if old.Spec.Cluster != "" && ws.Spec.Cluster == "" { + return admission.NewForbidden(a, errors.New("spec.cluster cannot be unset")) + } + if old.Spec.Cluster != ws.Spec.Cluster && !isSystemPrivileged { + return admission.NewForbidden(a, errors.New("spec.cluster can only be changed by system privileged users")) + } + if old.Spec.URL != ws.Spec.URL && !isSystemPrivileged { + return admission.NewForbidden(a, errors.New("spec.URL can only be changed by system privileged users")) + } - if errs := validation.ValidateImmutableField(ws.Spec.Type, old.Spec.Type, field.NewPath("spec", "type")); len(errs) > 0 { - return admission.NewForbidden(a, errs.ToAggregate()) - } - if old.Spec.Type.Path != ws.Spec.Type.Path || old.Spec.Type.Name != ws.Spec.Type.Name { - return admission.NewForbidden(a, errors.New("spec.type is immutable")) - } + if errs := validation.ValidateImmutableField(ws.Spec.Type, old.Spec.Type, field.NewPath("spec", "type")); len(errs) > 0 { + return admission.NewForbidden(a, errs.ToAggregate()) + } + if old.Spec.Type.Path != ws.Spec.Type.Path || old.Spec.Type.Name != ws.Spec.Type.Name { + return admission.NewForbidden(a, errors.New("spec.type is immutable")) + } + // If we're transitioning to "Ready", make sure that spec.cluster and spec.URL are set. + // This applies only for non-mounted workspaces. + if old.Status.Phase != corev1alpha1.LogicalClusterPhaseReady && ws.Status.Phase == corev1alpha1.LogicalClusterPhaseReady { + if ws.Spec.Cluster == "" { + return admission.NewForbidden(a, fmt.Errorf("spec.cluster must be set for phase %s", ws.Status.Phase)) + } + if ws.Spec.URL == "" { + return admission.NewForbidden(a, fmt.Errorf("spec.URL must be set for phase %s", ws.Status.Phase)) + } + } + } else { + // Mounted - validate the mount fields + if old.Spec.Mount.Reference.Kind != ws.Spec.Mount.Reference.Kind { + return admission.NewForbidden(a, errors.New("spec.mount.kind is immutable")) + } + if old.Spec.Mount.Reference.Name != ws.Spec.Mount.Reference.Name { + return admission.NewForbidden(a, errors.New("spec.mount.name is immutable")) + } + if old.Spec.Mount.Reference.Namespace != ws.Spec.Mount.Reference.Namespace { + return admission.NewForbidden(a, errors.New("spec.mount.namespace is immutable")) + } + if old.Spec.Mount.Reference.APIVersion != ws.Spec.Mount.Reference.APIVersion { + return admission.NewForbidden(a, errors.New("spec.mount.apiVersion is immutable")) + } - // If we're transitioning to "Ready", make sure that spec.cluster and spec.URL are set. - if old.Status.Phase != corev1alpha1.LogicalClusterPhaseReady && ws.Status.Phase == corev1alpha1.LogicalClusterPhaseReady { - if ws.Spec.Cluster == "" { - return admission.NewForbidden(a, fmt.Errorf("spec.cluster must be set for phase %s", ws.Status.Phase)) + // if not system privileged, disallow setting spec.type + if !isSystemPrivileged && ws.Spec.Type != nil { + return admission.NewForbidden(a, errors.New("spec.type cannot be set for mounted workspaces")) } - if ws.Spec.URL == "" { - return admission.NewForbidden(a, fmt.Errorf("spec.URL must be set for phase %s", ws.Status.Phase)) + // Check for immutability of spec.type via pointers checks first. + if !isSystemPrivileged && ((old.Spec.Type == nil && ws.Spec.Type != nil) || (old.Spec.Type != nil && ws.Spec.Type == nil)) { + return admission.NewForbidden(a, errors.New("spec.type is immutable")) + } + // Check for immutability of spec.type via field checks. + if !isSystemPrivileged && old.Spec.Type != nil && ws.Spec.Type != nil { + if old.Spec.Type.Path != ws.Spec.Type.Path || old.Spec.Type.Name != ws.Spec.Type.Name { + return admission.NewForbidden(a, errors.New("spec.type is immutable")) + } } } case admission.Create: @@ -223,6 +256,22 @@ func (o *workspace) Validate(ctx context.Context, a admission.Attributes, _ admi return admission.NewForbidden(a, fmt.Errorf("missing required groups annotation %s=%s", authorization.RequiredGroupsAnnotationKey, expected)) } } + + if ws.Spec.IsMounted() { + if ws.Spec.Mount.Reference.Kind == "" { + return admission.NewForbidden(a, errors.New("spec.mount.kind must be set")) + } + if ws.Spec.Mount.Reference.Name == "" { + return admission.NewForbidden(a, errors.New("spec.mount.name must be set")) + } + if ws.Spec.Mount.Reference.APIVersion == "" { + return admission.NewForbidden(a, errors.New("spec.mount.apiVersion must be set")) + } + + if !isSystemPrivileged && ws.Spec.Type != nil { + return admission.NewForbidden(a, errors.New("spec.type cannot be set for mounted workspaces")) + } + } } return nil diff --git a/pkg/admission/workspace/admission_test.go b/pkg/admission/workspace/admission_test.go index 8aed1dc4714..b68446dbe13 100644 --- a/pkg/admission/workspace/admission_test.go +++ b/pkg/admission/workspace/admission_test.go @@ -106,7 +106,7 @@ func TestAdmit(t *testing.T) { Name: "test", }, Spec: tenancyv1alpha1.WorkspaceSpec{ - Type: tenancyv1alpha1.WorkspaceTypeReference{ + Type: &tenancyv1alpha1.WorkspaceTypeReference{ Name: "foo", Path: "root:org", }, @@ -127,7 +127,7 @@ func TestAdmit(t *testing.T) { }, }, Spec: tenancyv1alpha1.WorkspaceSpec{ - Type: tenancyv1alpha1.WorkspaceTypeReference{ + Type: &tenancyv1alpha1.WorkspaceTypeReference{ Name: "foo", Path: "root:org", }, @@ -148,7 +148,7 @@ func TestAdmit(t *testing.T) { }, }, Spec: tenancyv1alpha1.WorkspaceSpec{ - Type: tenancyv1alpha1.WorkspaceTypeReference{ + Type: &tenancyv1alpha1.WorkspaceTypeReference{ Name: "Foo", Path: "root:org", }, @@ -169,7 +169,7 @@ func TestAdmit(t *testing.T) { }, }, Spec: tenancyv1alpha1.WorkspaceSpec{ - Type: tenancyv1alpha1.WorkspaceTypeReference{ + Type: &tenancyv1alpha1.WorkspaceTypeReference{ Name: "Foo", Path: "root:org", }, @@ -190,7 +190,7 @@ func TestAdmit(t *testing.T) { }, }, Spec: tenancyv1alpha1.WorkspaceSpec{ - Type: tenancyv1alpha1.WorkspaceTypeReference{ + Type: &tenancyv1alpha1.WorkspaceTypeReference{ Name: "Foo", Path: "root:org", }, @@ -211,7 +211,7 @@ func TestAdmit(t *testing.T) { }, }, Spec: tenancyv1alpha1.WorkspaceSpec{ - Type: tenancyv1alpha1.WorkspaceTypeReference{ + Type: &tenancyv1alpha1.WorkspaceTypeReference{ Name: "Foo", Path: "root:org", }, @@ -335,7 +335,7 @@ func TestValidate(t *testing.T) { Annotations: map[string]string{"experimental.tenancy.kcp.io/owner": "{}"}, }, Spec: tenancyv1alpha1.WorkspaceSpec{ - Type: tenancyv1alpha1.WorkspaceTypeReference{ + Type: &tenancyv1alpha1.WorkspaceTypeReference{ Name: "foo", Path: "root:org", }, @@ -347,7 +347,7 @@ func TestValidate(t *testing.T) { Annotations: map[string]string{"experimental.tenancy.kcp.io/owner": "{}"}, }, Spec: tenancyv1alpha1.WorkspaceSpec{ - Type: tenancyv1alpha1.WorkspaceTypeReference{ + Type: &tenancyv1alpha1.WorkspaceTypeReference{ Name: "universal", Path: "root:org", }, @@ -366,7 +366,7 @@ func TestValidate(t *testing.T) { Annotations: map[string]string{"experimental.tenancy.kcp.io/owner": "{}"}, }, Spec: tenancyv1alpha1.WorkspaceSpec{ - Type: tenancyv1alpha1.WorkspaceTypeReference{ + Type: &tenancyv1alpha1.WorkspaceTypeReference{ Name: "foo", Path: "root:org", }, @@ -379,7 +379,7 @@ func TestValidate(t *testing.T) { }, Spec: tenancyv1alpha1.WorkspaceSpec{ Cluster: "somewhere", - Type: tenancyv1alpha1.WorkspaceTypeReference{ + Type: &tenancyv1alpha1.WorkspaceTypeReference{ Name: "foo", Path: "root:org", }, @@ -400,7 +400,7 @@ func TestValidate(t *testing.T) { Spec: tenancyv1alpha1.WorkspaceSpec{ Cluster: "somewhere", URL: "https://kcp.bigcorp.com/clusters/org:test", - Type: tenancyv1alpha1.WorkspaceTypeReference{ + Type: &tenancyv1alpha1.WorkspaceTypeReference{ Name: "foo", Path: "root:org", }, @@ -416,7 +416,7 @@ func TestValidate(t *testing.T) { Annotations: map[string]string{"experimental.tenancy.kcp.io/owner": "{}"}, }, Spec: tenancyv1alpha1.WorkspaceSpec{ - Type: tenancyv1alpha1.WorkspaceTypeReference{ + Type: &tenancyv1alpha1.WorkspaceTypeReference{ Name: "foo", Path: "root:org", }, @@ -440,7 +440,7 @@ func TestValidate(t *testing.T) { Spec: tenancyv1alpha1.WorkspaceSpec{ Cluster: "somewhere", URL: "https://kcp.bigcorp.com/clusters/org:test", - Type: tenancyv1alpha1.WorkspaceTypeReference{ + Type: &tenancyv1alpha1.WorkspaceTypeReference{ Name: "foo", Path: "root:org", }, @@ -464,7 +464,7 @@ func TestValidate(t *testing.T) { Spec: tenancyv1alpha1.WorkspaceSpec{ Cluster: "somewhere", URL: "https://kcp.bigcorp.com/clusters/org:test", - Type: tenancyv1alpha1.WorkspaceTypeReference{ + Type: &tenancyv1alpha1.WorkspaceTypeReference{ Name: "foo", Path: "root:org", }, @@ -489,7 +489,7 @@ func TestValidate(t *testing.T) { Spec: tenancyv1alpha1.WorkspaceSpec{ Cluster: "somewhere", URL: "https://kcp.bigcorp.com/clusters/org:test", - Type: tenancyv1alpha1.WorkspaceTypeReference{ + Type: &tenancyv1alpha1.WorkspaceTypeReference{ Name: "foo", Path: "root:org", }, @@ -507,7 +507,7 @@ func TestValidate(t *testing.T) { Spec: tenancyv1alpha1.WorkspaceSpec{ Cluster: "somewhere", URL: "https://kcp.otherbigcorp.com/clusters/org:test", - Type: tenancyv1alpha1.WorkspaceTypeReference{ + Type: &tenancyv1alpha1.WorkspaceTypeReference{ Name: "foo", Path: "root:org", }, @@ -531,7 +531,7 @@ func TestValidate(t *testing.T) { }, Spec: tenancyv1alpha1.WorkspaceSpec{ Cluster: "somewhere", - Type: tenancyv1alpha1.WorkspaceTypeReference{ + Type: &tenancyv1alpha1.WorkspaceTypeReference{ Name: "foo", Path: "root:org", }, @@ -547,7 +547,7 @@ func TestValidate(t *testing.T) { Annotations: map[string]string{"experimental.tenancy.kcp.io/owner": "{}"}, }, Spec: tenancyv1alpha1.WorkspaceSpec{ - Type: tenancyv1alpha1.WorkspaceTypeReference{ + Type: &tenancyv1alpha1.WorkspaceTypeReference{ Name: "foo", Path: "root:org", }, @@ -594,7 +594,7 @@ func TestValidate(t *testing.T) { Annotations: map[string]string{"experimental.tenancy.kcp.io/owner": "{}"}, }, Spec: tenancyv1alpha1.WorkspaceSpec{ - Type: tenancyv1alpha1.WorkspaceTypeReference{ + Type: &tenancyv1alpha1.WorkspaceTypeReference{ Name: "foo", Path: "root:org", }, @@ -622,7 +622,7 @@ func TestValidate(t *testing.T) { }, }, Spec: tenancyv1alpha1.WorkspaceSpec{ - Type: tenancyv1alpha1.WorkspaceTypeReference{ + Type: &tenancyv1alpha1.WorkspaceTypeReference{ Name: "Foo", Path: "root:org", }, @@ -652,7 +652,7 @@ func TestValidate(t *testing.T) { }, }, Spec: tenancyv1alpha1.WorkspaceSpec{ - Type: tenancyv1alpha1.WorkspaceTypeReference{ + Type: &tenancyv1alpha1.WorkspaceTypeReference{ Name: "Foo", Path: "root:org", }, diff --git a/pkg/admission/workspacetypeexists/admission.go b/pkg/admission/workspacetypeexists/admission.go index 14a1c49d3f6..f01fa5e6d45 100644 --- a/pkg/admission/workspacetypeexists/admission.go +++ b/pkg/admission/workspacetypeexists/admission.go @@ -32,6 +32,7 @@ import ( "k8s.io/apiserver/pkg/authorization/authorizer" genericapirequest "k8s.io/apiserver/pkg/endpoints/request" "k8s.io/client-go/tools/cache" + "k8s.io/utils/ptr" kcpkubernetesclientset "github.com/kcp-dev/client-go/kubernetes" "github.com/kcp-dev/logicalcluster/v3" @@ -118,6 +119,10 @@ func (o *workspacetypeExists) Admit(ctx context.Context, a admission.Attributes, return fmt.Errorf("failed to convert unstructured to Workspace: %w", err) } + if ws.Spec.Mount != nil { + return nil + } + if !o.WaitForReady() { return admission.NewForbidden(a, fmt.Errorf("not yet ready to handle request")) } @@ -132,8 +137,8 @@ func (o *workspacetypeExists) Admit(ctx context.Context, a admission.Attributes, } // if the user has not provided any type, use the default from the parent workspace - empty := tenancyv1alpha1.WorkspaceTypeReference{} - if ws.Spec.Type == empty { + // We default to workspaceType only if not mount. + if ws.Spec.Type == nil || (ws.Spec.Type != nil && ws.Spec.Type.Name == "") { typeAnnotation, found := logicalCluster.Annotations[tenancyv1alpha1.LogicalClusterTypeAnnotationKey] if !found { return admission.NewForbidden(a, fmt.Errorf("annotation %s on LogicalCluster must be set", tenancyv1alpha1.LogicalClusterTypeAnnotationKey)) @@ -149,7 +154,7 @@ func (o *workspacetypeExists) Admit(ctx context.Context, a admission.Attributes, if parentWt.Spec.DefaultChildWorkspaceType == nil { return admission.NewForbidden(a, errors.New("spec.defaultChildWorkspaceType of workspace type %s:%s must be set")) } - ws.Spec.Type = tenancyv1alpha1.WorkspaceTypeReference{ + ws.Spec.Type = &tenancyv1alpha1.WorkspaceTypeReference{ Path: parentWt.Spec.DefaultChildWorkspaceType.Path, Name: parentWt.Spec.DefaultChildWorkspaceType.Name, } @@ -231,6 +236,10 @@ func (o *workspacetypeExists) Validate(ctx context.Context, a admission.Attribut return fmt.Errorf("failed to convert unstructured to Workspace: %w", err) } + if ws.Spec.Mount != nil { + return nil + } + switch a.GetOperation() { case admission.Update: if a.GetOldObject().GetObjectKind().GroupVersionKind() != tenancyv1alpha1.SchemeGroupVersion.WithKind("Workspace") { @@ -245,7 +254,7 @@ func (o *workspacetypeExists) Validate(ctx context.Context, a admission.Attribut return fmt.Errorf("failed to convert unstructured to Workspace: %w", err) } - if old.Spec.Type != ws.Spec.Type { + if !ptr.Equal[tenancyv1alpha1.WorkspaceTypeReference](old.Spec.Type, ws.Spec.Type) { return admission.NewForbidden(a, errors.New("spec.type is immutable")) } case admission.Create: diff --git a/pkg/admission/workspacetypeexists/admission_test.go b/pkg/admission/workspacetypeexists/admission_test.go index 02c18c0b355..2f8d79fa0c7 100644 --- a/pkg/admission/workspacetypeexists/admission_test.go +++ b/pkg/admission/workspacetypeexists/admission_test.go @@ -876,7 +876,7 @@ func newWorkspace(qualifiedName string) wsBuilder { func (b wsBuilder) withType(qualifiedName string) wsBuilder { path, name := logicalcluster.NewPath(qualifiedName).Split() - b.Spec.Type = tenancyv1alpha1.WorkspaceTypeReference{ + b.Spec.Type = &tenancyv1alpha1.WorkspaceTypeReference{ Path: path.String(), Name: tenancyv1alpha1.WorkspaceTypeName(name), } diff --git a/pkg/index/index.go b/pkg/index/index.go index 777509ab887..30052f51f51 100644 --- a/pkg/index/index.go +++ b/pkg/index/index.go @@ -21,6 +21,8 @@ import ( "strings" "sync" + "k8s.io/apimachinery/pkg/api/equality" + "github.com/kcp-dev/logicalcluster/v3" corev1alpha1 "github.com/kcp-dev/kcp/sdk/apis/core/v1alpha1" @@ -61,7 +63,7 @@ func New(rewriters []PathRewriter) *State { // Experimental feature: allow mounts to be used with Workspaces // structure: (shard, logical cluster, workspace name) -> string serialized mount objects // This should be simplified once we promote this to workspace structure. - shardClusterWorkspaceMountAnnotation: map[string]map[logicalcluster.Name]map[string]string{}, + shardClusterWorkspaceMount: map[string]map[logicalcluster.Name]map[string]tenancyv1alpha1.WorkspaceSpec{}, // shardClusterWorkspaceNameErrorCode is a map of shar,logical cluster, workspace to error code when we want to return an error code // instead of a URL. @@ -82,7 +84,7 @@ type State struct { shardClusterParentCluster map[string]map[logicalcluster.Name]logicalcluster.Name // (shard name, logical cluster) -> parent logical cluster shardBaseURLs map[string]string // shard name -> base URL // Experimental feature: allow mounts to be used with Workspaces - shardClusterWorkspaceMountAnnotation map[string]map[logicalcluster.Name]map[string]string // (shard name, logical cluster, workspace name) -> mount object string + shardClusterWorkspaceMount map[string]map[logicalcluster.Name]map[string]tenancyv1alpha1.WorkspaceSpec // (shard name, logical cluster, workspace name) -> WorkspaceSpec shardClusterWorkspaceNameErrorCode map[string]map[logicalcluster.Name]map[string]int // (shard name, logical cluster, workspace name) -> error code } @@ -129,14 +131,16 @@ func (c *State) UpsertWorkspace(shard string, ws *tenancyv1alpha1.Workspace) { c.shardClusterParentCluster[shard][logicalcluster.Name(ws.Spec.Cluster)] = clusterName } - if mountObjString := c.shardClusterWorkspaceMountAnnotation[shard][clusterName][ws.Name]; mountObjString != ws.Annotations[tenancyv1alpha1.ExperimentalWorkspaceMountAnnotationKey] { - if c.shardClusterWorkspaceMountAnnotation[shard] == nil { - c.shardClusterWorkspaceMountAnnotation[shard] = map[logicalcluster.Name]map[string]string{} - } - if c.shardClusterWorkspaceMountAnnotation[shard][clusterName] == nil { - c.shardClusterWorkspaceMountAnnotation[shard][clusterName] = map[string]string{} + if ws.Spec.Mount != nil { + if wsSpec := c.shardClusterWorkspaceMount[shard][clusterName][ws.Name]; !equality.Semantic.DeepEqual(wsSpec, ws.Spec) { + if c.shardClusterWorkspaceMount[shard] == nil { + c.shardClusterWorkspaceMount[shard] = map[logicalcluster.Name]map[string]tenancyv1alpha1.WorkspaceSpec{} + } + if c.shardClusterWorkspaceMount[shard][clusterName] == nil { + c.shardClusterWorkspaceMount[shard][clusterName] = map[string]tenancyv1alpha1.WorkspaceSpec{} + } + c.shardClusterWorkspaceMount[shard][clusterName][ws.Name] = ws.Spec } - c.shardClusterWorkspaceMountAnnotation[shard][clusterName][ws.Name] = ws.Annotations[tenancyv1alpha1.ExperimentalWorkspaceMountAnnotationKey] } } @@ -145,7 +149,7 @@ func (c *State) DeleteWorkspace(shard string, ws *tenancyv1alpha1.Workspace) { c.lock.RLock() _, foundCluster := c.shardClusterWorkspaceNameCluster[shard][clusterName][ws.Name] - _, foundMount := c.shardClusterWorkspaceMountAnnotation[shard][clusterName][ws.Name] + _, foundMount := c.shardClusterWorkspaceMount[shard][clusterName][ws.Name] c.lock.RUnlock() if !foundCluster && !foundMount { @@ -175,10 +179,10 @@ func (c *State) DeleteWorkspace(shard string, ws *tenancyv1alpha1.Workspace) { } } - if _, foundMount = c.shardClusterWorkspaceMountAnnotation[shard][clusterName][ws.Name]; foundMount { - delete(c.shardClusterWorkspaceMountAnnotation[shard][clusterName], ws.Name) - if len(c.shardClusterWorkspaceMountAnnotation[shard][clusterName]) == 0 { - delete(c.shardClusterWorkspaceMountAnnotation[shard], clusterName) + if _, foundMount = c.shardClusterWorkspaceMount[shard][clusterName][ws.Name]; foundMount { + delete(c.shardClusterWorkspaceMount[shard][clusterName], ws.Name) + if len(c.shardClusterWorkspaceMount[shard][clusterName]) == 0 { + delete(c.shardClusterWorkspaceMount[shard], clusterName) } } delete(c.shardClusterWorkspaceNameErrorCode[shard][clusterName], ws.Name) @@ -271,28 +275,28 @@ func (c *State) Lookup(path logicalcluster.Path) (Result, bool) { continue } - // check mounts, if found return url and true - val, foundMount := c.shardClusterWorkspaceMountAnnotation[shard][cluster][s] // experimental feature - if foundMount { - mount, err := tenancyv1alpha1.ParseTenancyMountAnnotation(val) - if !(err != nil || mount == nil || mount.MountStatus.URL == "") { - u, err := url.Parse(mount.MountStatus.URL) - if err != nil { - // default to workspace itself. - } else { - return Result{URL: u.String()}, true - } - } - } - if ec, found := c.shardClusterWorkspaceNameErrorCode[shard][cluster][s]; found { errorCode = ec } var found bool + originalCluster := cluster cluster, found = c.shardClusterWorkspaceNameCluster[shard][cluster][s] if !found { + // We not gonna find the cluster if we using mounts and spec.cluster was never set. + // Lets check if we have a mount for this workspace, and if we do, we can return the URL from the mount. + // Else we get back to default behavior. + wsSpec, foundMount := c.shardClusterWorkspaceMount[shard][originalCluster][s] // experimental feature + if foundMount { + if wsSpec.Mount != nil && wsSpec.URL != "" { + u, err := url.Parse(wsSpec.URL) + if err == nil { + return Result{URL: u.String(), ErrorCode: errorCode}, true + } + } + } return Result{}, false } + shard, found = c.clusterShards[cluster] if !found { return Result{}, false diff --git a/pkg/index/index_test.go b/pkg/index/index_test.go index 58fca0e606a..b810ab46526 100644 --- a/pkg/index/index_test.go +++ b/pkg/index/index_test.go @@ -25,6 +25,7 @@ import ( corev1alpha1 "github.com/kcp-dev/kcp/sdk/apis/core/v1alpha1" tenancyv1alpha1 "github.com/kcp-dev/kcp/sdk/apis/tenancy/v1alpha1" + conditionsv1alpha1 "github.com/kcp-dev/kcp/sdk/apis/third_party/conditions/apis/conditions/v1alpha1" ) type shardStub struct { @@ -246,16 +247,23 @@ func TestLookup(t *testing.T) { }, initialWorkspacesToUpsert: map[string][]*tenancyv1alpha1.Workspace{ "root": {newWorkspace("org", "root", "one")}, - "beta": {newWorkspaceWithAnnotation("rh", "one", "two", map[string]string{ - "experimental.tenancy.kcp.io/mount": `{"spec":{"ref":{"kind":"KubeCluster","name":"prod-cluster","apiVersion":"proxy.kcp.dev/v1alpha1"}},"status":{"phase":"Ready","url":"https://kcp.dev.local/services/custom-url/proxy"}}`, - })}, + "beta": { + withURL( + withPhase( + newWorkspaceWithMount("mount", "one", "", tenancyv1alpha1.ObjectReference{ + Kind: "KubeCluster", + Name: "prod-cluster", + APIVersion: "proxy.kcp.dev/v1alpha1", + }), + "Ready"), + "https://kcp.dev.local/services/custom-url/proxy")}, }, initialLogicalClustersToUpsert: map[string][]*corev1alpha1.LogicalCluster{ "root": {newLogicalCluster("root")}, "beta": {newLogicalCluster("one")}, "gama": {newLogicalCluster("two")}, }, - targetPath: logicalcluster.NewPath("one:rh"), + targetPath: logicalcluster.NewPath("one:mount"), expectFound: true, expectedCluster: "", expectedShard: "", @@ -279,9 +287,11 @@ func TestLookup(t *testing.T) { }, initialWorkspacesToUpsert: map[string][]*tenancyv1alpha1.Workspace{ "root": {newWorkspace("org", "root", "one")}, - "beta": {withPhase(newWorkspaceWithAnnotation("rh", "one", "two", map[string]string{ - "experimental.tenancy.kcp.io/mount": `{"spec":{"ref":{"kind":"KubeCluster","name":"prod-cluster","apiVersion":"proxy.kcp.dev/v1alpha1"}},"status":{"phase":"Ready","url":"https://kcp.dev.local/services/custom-url/proxy"}}`, - }), corev1alpha1.LogicalClusterPhaseUnavailable)}, + "beta": {withURL(withPhase(newWorkspaceWithMount("rh", "one", "", tenancyv1alpha1.ObjectReference{ + Kind: "KubeCluster", + Name: "prod-cluster", + APIVersion: "proxy.kcp.dev/v1alpha1", + }), corev1alpha1.LogicalClusterPhaseUnavailable), "https://kcp.dev.local/services/custom-url/proxy")}, }, initialLogicalClustersToUpsert: map[string][]*corev1alpha1.LogicalCluster{ "root": {newLogicalCluster("root")}, @@ -290,6 +300,7 @@ func TestLookup(t *testing.T) { }, targetPath: logicalcluster.NewPath("one:rh"), expectFound: true, + expectedError: 503, expectedCluster: "", expectedShard: "", expectedURL: "https://kcp.dev.local/services/custom-url/proxy", @@ -498,13 +509,6 @@ func TestUpsertWorkspace(t *testing.T) { target.UpsertWorkspace("root", newWorkspace("org", "root", "44")) r, found = target.Lookup(logicalcluster.NewPath("root:org")) validateLookupOutput(t, logicalcluster.NewPath("root:org"), r.Shard, r.Cluster, r.URL, found, "root", "44", "", true) - - // Upsert workspace with changed mount annotation - target.UpsertWorkspace("root", newWorkspaceWithAnnotation("org", "root", "44", map[string]string{ - "experimental.tenancy.kcp.io/mount": `{"spec":{"ref":{"kind":"KubeCluster","name":"prod-cluster","apiVersion":"proxy.kcp.dev/v1alpha1"}},"status":{"phase":"Ready","url":"https://kcp.dev.local/services/custom-url/proxy"}}`, - })) - r, found = target.Lookup(logicalcluster.NewPath("root:org")) - validateLookupOutput(t, logicalcluster.NewPath("root:org"), r.Shard, r.Cluster, r.URL, found, "", "", "https://kcp.dev.local/services/custom-url/proxy", true) } func validateLookupOutput(t *testing.T, path logicalcluster.Path, shard string, cluster logicalcluster.Name, url string, found bool, expectedShard string, expectedCluster logicalcluster.Name, expectedURL string, expectToFind bool) { @@ -532,11 +536,14 @@ func newWorkspace(name, cluster, scheduledCluster string) *tenancyv1alpha1.Works } } -func newWorkspaceWithAnnotation(name, cluster, scheduledCluster string, annotations map[string]string) *tenancyv1alpha1.Workspace { +func newWorkspaceWithMount(name, cluster, scheduledCluster string, ref tenancyv1alpha1.ObjectReference) *tenancyv1alpha1.Workspace { ws := newWorkspace(name, cluster, scheduledCluster) - for k, v := range annotations { - ws.Annotations[k] = v - } + ws.Spec.Mount = &tenancyv1alpha1.Mount{Reference: ref} + return ws +} + +func WithCondition(ws *tenancyv1alpha1.Workspace, condition conditionsv1alpha1.Condition) *tenancyv1alpha1.Workspace { + ws.Status.Conditions = []conditionsv1alpha1.Condition{condition} return ws } @@ -545,6 +552,11 @@ func withPhase(ws *tenancyv1alpha1.Workspace, phase corev1alpha1.LogicalClusterP return ws } +func withURL(ws *tenancyv1alpha1.Workspace, url string) *tenancyv1alpha1.Workspace { + ws.Spec.URL = url + return ws +} + func newLogicalCluster(cluster string) *corev1alpha1.LogicalCluster { return &corev1alpha1.LogicalCluster{ ObjectMeta: metav1.ObjectMeta{Name: "cluster", Annotations: map[string]string{"kcp.io/cluster": cluster}}, diff --git a/pkg/indexers/indexers.go b/pkg/indexers/indexers.go index 68673a6ce90..68ba9b0b885 100644 --- a/pkg/indexers/indexers.go +++ b/pkg/indexers/indexers.go @@ -137,3 +137,19 @@ func ByPathAndNameWithFallback[T runtime.Object](groupResource schema.GroupResou // Didn't find it locally - try remote return ByPathAndName[T](groupResource, globalIndexer, path, name) } + +// IndexByWorkspaceLogicalClusterAndURL indexes by logical cluster path and object name, if the annotation exists. +func IndexByWorkspaceLogicalClusterAndURL(obj interface{}) ([]string, error) { + metaObj, ok := obj.(metav1.Object) + if !ok { + return []string{}, fmt.Errorf("obj is supposed to be a metav1.Object, but is %T", obj) + } + if path, found := metaObj.GetAnnotations()[core.LogicalClusterPathAnnotationKey]; found { + return []string{ + logicalcluster.NewPath(path).Join(metaObj.GetName()).String(), + logicalcluster.From(metaObj).Path().Join(metaObj.GetName()).String(), + }, nil + } + + return []string{logicalcluster.From(metaObj).Path().Join(metaObj.GetName()).String()}, nil +} diff --git a/pkg/indexers/workspace.go b/pkg/indexers/workspace.go new file mode 100644 index 00000000000..e10be623a7b --- /dev/null +++ b/pkg/indexers/workspace.go @@ -0,0 +1,38 @@ +/* +Copyright 2022 The KCP Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package indexers + +import ( + "fmt" + + tenancyv1alpha1 "github.com/kcp-dev/kcp/sdk/apis/tenancy/v1alpha1" +) + +const ( + // WorkspaceByURL is the indexer workspace by its url. + WorkspaceByURL = "WorkspaceByURL" +) + +// IndexAPIExportByIdentity is an index function that indexes an APIExport by its identity hash. +func IndexWorkspaceByURL(obj interface{}) (string, error) { + ws, ok := obj.(*tenancyv1alpha1.Workspace) + if !ok { + return "", fmt.Errorf("obj %T is not an APIExportEndpointSlice", obj) + } + + return ws.Spec.URL, nil +} diff --git a/pkg/indexers/workspace_test.go b/pkg/indexers/workspace_test.go new file mode 100644 index 00000000000..aa80cc09ec2 --- /dev/null +++ b/pkg/indexers/workspace_test.go @@ -0,0 +1,75 @@ +/* +Copyright 2025 The KCP Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package indexers + +import ( + "reflect" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + tenancyv1alpha1 "github.com/kcp-dev/kcp/sdk/apis/tenancy/v1alpha1" +) + +func TestIndexWorkspaceByURL(t *testing.T) { + tests := map[string]struct { + obj interface{} + want string + wantErr bool + }{ + "not a Workspace": { + obj: "not a Workspace", + want: "", + wantErr: true, + }, + "valid Workspace without url": { + obj: &tenancyv1alpha1.Workspace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + Spec: tenancyv1alpha1.WorkspaceSpec{}, + }, + wantErr: false, + want: "", + }, + "valid Workspace with url": { + obj: &tenancyv1alpha1.Workspace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + Spec: tenancyv1alpha1.WorkspaceSpec{ + URL: "https://example.com", + }, + }, + wantErr: false, + want: "https://example.com", + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + got, err := IndexWorkspaceByURL(tt.obj) + if (err != nil) != tt.wantErr { + t.Errorf("IndexWorkspaceByURL() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("IndexWorkspaceByURL() got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/openapi/zz_generated.openapi.go b/pkg/openapi/zz_generated.openapi.go index d38f03cc1b5..b18f2f0c5f4 100644 --- a/pkg/openapi/zz_generated.openapi.go +++ b/pkg/openapi/zz_generated.openapi.go @@ -92,8 +92,6 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA "github.com/kcp-dev/kcp/sdk/apis/core/v1alpha1.ShardStatus": schema_sdk_apis_core_v1alpha1_ShardStatus(ref), "github.com/kcp-dev/kcp/sdk/apis/tenancy/v1alpha1.APIExportReference": schema_sdk_apis_tenancy_v1alpha1_APIExportReference(ref), "github.com/kcp-dev/kcp/sdk/apis/tenancy/v1alpha1.Mount": schema_sdk_apis_tenancy_v1alpha1_Mount(ref), - "github.com/kcp-dev/kcp/sdk/apis/tenancy/v1alpha1.MountSpec": schema_sdk_apis_tenancy_v1alpha1_MountSpec(ref), - "github.com/kcp-dev/kcp/sdk/apis/tenancy/v1alpha1.MountStatus": schema_sdk_apis_tenancy_v1alpha1_MountStatus(ref), "github.com/kcp-dev/kcp/sdk/apis/tenancy/v1alpha1.ObjectReference": schema_sdk_apis_tenancy_v1alpha1_ObjectReference(ref), "github.com/kcp-dev/kcp/sdk/apis/tenancy/v1alpha1.VirtualWorkspace": schema_sdk_apis_tenancy_v1alpha1_VirtualWorkspace(ref), "github.com/kcp-dev/kcp/sdk/apis/tenancy/v1alpha1.Workspace": schema_sdk_apis_tenancy_v1alpha1_Workspace(ref), @@ -2799,44 +2797,18 @@ func schema_sdk_apis_tenancy_v1alpha1_Mount(ref common.ReferenceCallback) common return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ - Description: "Mount is a workspace mount that can be used to mount a workspace into another workspace or resource. Mounting itself is done at front proxy level.", + Description: "Mount is a reference to a an object implementing a mounting feature. It is used to orchestrate where the traffic, intended for the workspace, is sent.", Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "spec": { - SchemaProps: spec.SchemaProps{ - Description: "MountSpec is the spec of the mount.", - Default: map[string]interface{}{}, - Ref: ref("github.com/kcp-dev/kcp/sdk/apis/tenancy/v1alpha1.MountSpec"), - }, - }, - "status": { - SchemaProps: spec.SchemaProps{ - Description: "MountStatus is the status of the mount.", - Default: map[string]interface{}{}, - Ref: ref("github.com/kcp-dev/kcp/sdk/apis/tenancy/v1alpha1.MountStatus"), - }, - }, - }, - }, - }, - Dependencies: []string{ - "github.com/kcp-dev/kcp/sdk/apis/tenancy/v1alpha1.MountSpec", "github.com/kcp-dev/kcp/sdk/apis/tenancy/v1alpha1.MountStatus"}, - } -} - -func schema_sdk_apis_tenancy_v1alpha1_MountSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, Properties: map[string]spec.Schema{ "ref": { SchemaProps: spec.SchemaProps{ Description: "Reference is an ObjectReference to the object that is mounted.", + Default: map[string]interface{}{}, Ref: ref("github.com/kcp-dev/kcp/sdk/apis/tenancy/v1alpha1.ObjectReference"), }, }, }, + Required: []string{"ref"}, }, }, Dependencies: []string{ @@ -2844,49 +2816,6 @@ func schema_sdk_apis_tenancy_v1alpha1_MountSpec(ref common.ReferenceCallback) co } } -func schema_sdk_apis_tenancy_v1alpha1_MountStatus(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "MountStatus is the status of a mount. It is used to indicate the status of a mount, potentially managed outside of the core API.", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "phase": { - SchemaProps: spec.SchemaProps{ - Description: "Phase of the mount (Initializing, Connecting, Ready, Unknown).", - Type: []string{"string"}, - Format: "", - }, - }, - "conditions": { - SchemaProps: spec.SchemaProps{ - Description: "Conditions is a list of conditions and their status. Current processing state of the Mount.", - Type: []string{"array"}, - Items: &spec.SchemaOrArray{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/kcp-dev/kcp/sdk/apis/third_party/conditions/apis/conditions/v1alpha1.Condition"), - }, - }, - }, - }, - }, - "url": { - SchemaProps: spec.SchemaProps{ - Description: "URL is the URL of the mount. Mount is considered mountable when URL is set.", - Type: []string{"string"}, - Format: "", - }, - }, - }, - }, - }, - Dependencies: []string{ - "github.com/kcp-dev/kcp/sdk/apis/third_party/conditions/apis/conditions/v1alpha1.Condition"}, - } -} - func schema_sdk_apis_tenancy_v1alpha1_ObjectReference(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ @@ -3079,7 +3008,6 @@ func schema_sdk_apis_tenancy_v1alpha1_WorkspaceSpec(ref common.ReferenceCallback "type": { SchemaProps: spec.SchemaProps{ Description: "type defines properties of the workspace both on creation (e.g. initial resources and initially installed APIs) and during runtime (e.g. permissions). If no type is provided, the default type for the workspace in which this workspace is nesting will be used.\n\nThe type is a reference to a WorkspaceType in the listed workspace, but lower-cased. The WorkspaceType existence is validated at admission during creation. The type is immutable after creation. The use of a type is gated via the RBAC workspacetypes/use resource permission.", - Default: map[string]interface{}{}, Ref: ref("github.com/kcp-dev/kcp/sdk/apis/tenancy/v1alpha1.WorkspaceTypeReference"), }, }, @@ -3103,11 +3031,17 @@ func schema_sdk_apis_tenancy_v1alpha1_WorkspaceSpec(ref common.ReferenceCallback Format: "", }, }, + "mount": { + SchemaProps: spec.SchemaProps{ + Description: "Mount is a reference to a an object implementing a mounting feature. It is used to orchestrate where the traffic, intended for the workspace, is sent. If specified, logicalcluster will not be created and the workspace will be mounted using reference mount object.", + Ref: ref("github.com/kcp-dev/kcp/sdk/apis/tenancy/v1alpha1.Mount"), + }, + }, }, }, }, Dependencies: []string{ - "github.com/kcp-dev/kcp/sdk/apis/tenancy/v1alpha1.WorkspaceLocation", "github.com/kcp-dev/kcp/sdk/apis/tenancy/v1alpha1.WorkspaceTypeReference"}, + "github.com/kcp-dev/kcp/sdk/apis/tenancy/v1alpha1.Mount", "github.com/kcp-dev/kcp/sdk/apis/tenancy/v1alpha1.WorkspaceLocation", "github.com/kcp-dev/kcp/sdk/apis/tenancy/v1alpha1.WorkspaceTypeReference"}, } } diff --git a/pkg/reconciler/tenancy/workspace/workspace_controller.go b/pkg/reconciler/tenancy/workspace/workspace_controller.go index 804da31c22a..c4403249abd 100644 --- a/pkg/reconciler/tenancy/workspace/workspace_controller.go +++ b/pkg/reconciler/tenancy/workspace/workspace_controller.go @@ -275,7 +275,8 @@ func (c *Controller) process(ctx context.Context, key string) (bool, error) { } // InstallIndexers adds the additional indexers that this controller requires to the informers. -func InstallIndexers(workspaceInformer tenancyv1alpha1informers.WorkspaceClusterInformer, +func InstallIndexers( + workspaceInformer tenancyv1alpha1informers.WorkspaceClusterInformer, globalShardInformer corev1alpha1informers.ShardClusterInformer, globalWorkspaceTypeInformer tenancyv1alpha1informers.WorkspaceTypeClusterInformer, ) { diff --git a/pkg/reconciler/tenancy/workspace/workspace_reconcile_metadata.go b/pkg/reconciler/tenancy/workspace/workspace_reconcile_metadata.go index 4a3d556b2d3..4981e0a8ca8 100644 --- a/pkg/reconciler/tenancy/workspace/workspace_reconcile_metadata.go +++ b/pkg/reconciler/tenancy/workspace/workspace_reconcile_metadata.go @@ -32,6 +32,9 @@ type metaDataReconciler struct { func (r *metaDataReconciler) reconcile(ctx context.Context, workspace *tenancyv1alpha1.Workspace) (reconcileStatus, error) { logger := klog.FromContext(ctx).WithValues("reconciler", "metadata") + if workspace.Spec.IsMounted() { + return reconcileStatusContinue, nil + } changed := false expected := string(workspace.Status.Phase) diff --git a/pkg/reconciler/tenancy/workspace/workspace_reconcile_phase.go b/pkg/reconciler/tenancy/workspace/workspace_reconcile_phase.go index 76b5f9a986c..f7cc9ecd0d3 100644 --- a/pkg/reconciler/tenancy/workspace/workspace_reconcile_phase.go +++ b/pkg/reconciler/tenancy/workspace/workspace_reconcile_phase.go @@ -42,50 +42,13 @@ type phaseReconciler struct { func (r *phaseReconciler) reconcile(ctx context.Context, workspace *tenancyv1alpha1.Workspace) (reconcileStatus, error) { logger := klog.FromContext(ctx).WithValues("reconciler", "phase") - switch workspace.Status.Phase { - case corev1alpha1.LogicalClusterPhaseScheduling: - if workspace.Spec.URL != "" && workspace.Spec.Cluster != "" { - workspace.Status.Phase = corev1alpha1.LogicalClusterPhaseInitializing - } - case corev1alpha1.LogicalClusterPhaseInitializing: - logger = logger.WithValues("cluster", workspace.Spec.Cluster) - - logicalCluster, err := r.getLogicalCluster(ctx, logicalcluster.NewPath(workspace.Spec.Cluster)) - if err != nil && !apierrors.IsNotFound(err) { - return reconcileStatusStopAndRequeue, err - } else if apierrors.IsNotFound(err) { - logger.Info("LogicalCluster disappeared") - conditions.MarkFalse(workspace, tenancyv1alpha1.WorkspaceInitialized, tenancyv1alpha1.WorkspaceInitializedWorkspaceDisappeared, conditionsv1alpha1.ConditionSeverityError, "LogicalCluster disappeared") - return reconcileStatusContinue, nil - } - - workspace.Status.Initializers = logicalCluster.Status.Initializers - - if initializers := workspace.Status.Initializers; len(initializers) > 0 { - after := time.Since(logicalCluster.CreationTimestamp.Time) / 5 - if maxDuration := time.Minute * 10; after > maxDuration { - after = maxDuration + if !workspace.Spec.IsMounted() { + switch workspace.Status.Phase { + case corev1alpha1.LogicalClusterPhaseScheduling: + if workspace.Spec.URL != "" && workspace.Spec.Cluster != "" { + workspace.Status.Phase = corev1alpha1.LogicalClusterPhaseInitializing } - logger.V(3).Info("LogicalCluster still has initializers, requeueing", "initializers", initializers, "after", after) - conditions.MarkFalse(workspace, tenancyv1alpha1.WorkspaceInitialized, tenancyv1alpha1.WorkspaceInitializedInitializerExists, conditionsv1alpha1.ConditionSeverityInfo, "Initializers still exist: %v", workspace.Status.Initializers) - r.requeueAfter(workspace, after) - return reconcileStatusContinue, nil - } - - logger.V(3).Info("LogicalCluster is ready") - workspace.Status.Phase = corev1alpha1.LogicalClusterPhaseReady - conditions.MarkTrue(workspace, tenancyv1alpha1.WorkspaceInitialized) - - case corev1alpha1.LogicalClusterPhaseUnavailable: - if updateTerminalConditionPhase(workspace) { - return reconcileStatusStopAndRequeue, nil - } - return reconcileStatusContinue, nil - - case corev1alpha1.LogicalClusterPhaseReady: - // On delete we need to wait for the logical cluster to be deleted - // before we can mark the workspace as deleted. - if !workspace.DeletionTimestamp.IsZero() { + case corev1alpha1.LogicalClusterPhaseInitializing: logger = logger.WithValues("cluster", workspace.Spec.Cluster) logicalCluster, err := r.getLogicalCluster(ctx, logicalcluster.NewPath(workspace.Spec.Cluster)) @@ -93,37 +56,76 @@ func (r *phaseReconciler) reconcile(ctx context.Context, workspace *tenancyv1alp return reconcileStatusStopAndRequeue, err } else if apierrors.IsNotFound(err) { logger.Info("LogicalCluster disappeared") - conditions.MarkTrue(workspace, tenancyv1alpha1.WorkspaceContentDeleted) + conditions.MarkFalse(workspace, tenancyv1alpha1.WorkspaceInitialized, tenancyv1alpha1.WorkspaceInitializedWorkspaceDisappeared, conditionsv1alpha1.ConditionSeverityError, "LogicalCluster disappeared") return reconcileStatusContinue, nil } - if !conditions.IsTrue(workspace, tenancyv1alpha1.WorkspaceContentDeleted) { + workspace.Status.Initializers = logicalCluster.Status.Initializers + + if initializers := workspace.Status.Initializers; len(initializers) > 0 { after := time.Since(logicalCluster.CreationTimestamp.Time) / 5 - if max := time.Minute * 10; after > max { - after = max - } - cond := conditions.Get(logicalCluster, tenancyv1alpha1.WorkspaceContentDeleted) - if cond != nil { - conditions.Set(workspace, cond) - logger.V(3).Info("LogicalCluster is still deleting, requeueing", "reason", cond.Reason, "message", cond.Message, "after", after) - } else { - logger.V(3).Info("LogicalCluster is still deleting, requeueing", "after", after) + if maxDuration := time.Minute * 10; after > maxDuration { + after = maxDuration } + logger.V(3).Info("LogicalCluster still has initializers, requeueing", "initializers", initializers, "after", after) + conditions.MarkFalse(workspace, tenancyv1alpha1.WorkspaceInitialized, tenancyv1alpha1.WorkspaceInitializedInitializerExists, conditionsv1alpha1.ConditionSeverityInfo, "Initializers still exist: %v", workspace.Status.Initializers) r.requeueAfter(workspace, after) return reconcileStatusContinue, nil } - logger.Info("workspace content is deleted") + logger.V(3).Info("LogicalCluster is ready") + workspace.Status.Phase = corev1alpha1.LogicalClusterPhaseReady + conditions.MarkTrue(workspace, tenancyv1alpha1.WorkspaceInitialized) + + case corev1alpha1.LogicalClusterPhaseUnavailable: + if updateTerminalConditionPhase(workspace) { + return reconcileStatusStopAndRequeue, nil + } return reconcileStatusContinue, nil - } - // if workspace is ready, we check if it suppose to be ready by checking conditions. - if updateTerminalConditionPhase(workspace) { - logger.Info("workspace phase changed", "status", workspace.Status) - return reconcileStatusStopAndRequeue, nil + case corev1alpha1.LogicalClusterPhaseReady: + // On delete we need to wait for the logical cluster to be deleted + // before we can mark the workspace as deleted. + if !workspace.DeletionTimestamp.IsZero() { + logger = logger.WithValues("cluster", workspace.Spec.Cluster) + + logicalCluster, err := r.getLogicalCluster(ctx, logicalcluster.NewPath(workspace.Spec.Cluster)) + if err != nil && !apierrors.IsNotFound(err) { + return reconcileStatusStopAndRequeue, err + } else if apierrors.IsNotFound(err) { + logger.Info("LogicalCluster disappeared") + conditions.MarkTrue(workspace, tenancyv1alpha1.WorkspaceContentDeleted) + return reconcileStatusContinue, nil + } + + if !conditions.IsTrue(workspace, tenancyv1alpha1.WorkspaceContentDeleted) { + after := time.Since(logicalCluster.CreationTimestamp.Time) / 5 + if max := time.Minute * 10; after > max { + after = max + } + cond := conditions.Get(logicalCluster, tenancyv1alpha1.WorkspaceContentDeleted) + if cond != nil { + conditions.Set(workspace, cond) + logger.V(3).Info("LogicalCluster is still deleting, requeueing", "reason", cond.Reason, "message", cond.Message, "after", after) + } else { + logger.V(3).Info("LogicalCluster is still deleting, requeueing", "after", after) + } + r.requeueAfter(workspace, after) + return reconcileStatusContinue, nil + } + + logger.Info("workspace content is deleted") + return reconcileStatusContinue, nil + } } } + // if workspace is ready, we check if it suppose to be ready by checking conditions. + if updateTerminalConditionPhase(workspace) { + logger.Info("workspace phase changed", "status", workspace.Status) + return reconcileStatusStopAndRequeue, nil + } + return reconcileStatusContinue, nil } @@ -137,6 +139,7 @@ func updateTerminalConditionPhase(workspace *tenancyv1alpha1.Workspace) bool { break } } + if notReady && workspace.Status.Phase != corev1alpha1.LogicalClusterPhaseUnavailable { workspace.Status.Phase = corev1alpha1.LogicalClusterPhaseUnavailable return true diff --git a/pkg/reconciler/tenancy/workspace/workspace_reconcile_scheduling.go b/pkg/reconciler/tenancy/workspace/workspace_reconcile_scheduling.go index 0db796e0bb2..159a6069b31 100644 --- a/pkg/reconciler/tenancy/workspace/workspace_reconcile_scheduling.go +++ b/pkg/reconciler/tenancy/workspace/workspace_reconcile_scheduling.go @@ -83,6 +83,9 @@ type schedulingReconciler struct { func (r *schedulingReconciler) reconcile(ctx context.Context, workspace *tenancyv1alpha1.Workspace) (reconcileStatus, error) { logger := klog.FromContext(ctx).WithValues("reconciler", "scheduling") + if workspace.Spec.IsMounted() { + return reconcileStatusContinue, nil + } switch { case !workspace.DeletionTimestamp.IsZero(): diff --git a/pkg/reconciler/tenancy/workspace/workspace_reconcile_scheduling_test.go b/pkg/reconciler/tenancy/workspace/workspace_reconcile_scheduling_test.go index 319d0b1b68a..c36af3bf9bf 100644 --- a/pkg/reconciler/tenancy/workspace/workspace_reconcile_scheduling_test.go +++ b/pkg/reconciler/tenancy/workspace/workspace_reconcile_scheduling_test.go @@ -400,7 +400,7 @@ func wellKnownFooWSForPhaseTwo() *tenancyv1alpha1.Workspace { ws.Annotations["experimental.tenancy.kcp.io/owner"] = `{"username":"kcp-admin"}` ws.Finalizers = append(ws.Finalizers, "core.kcp.io/logicalcluster") // type info is assigned by an admission plugin - ws.Spec.Type = tenancyv1alpha1.WorkspaceTypeReference{ + ws.Spec.Type = &tenancyv1alpha1.WorkspaceTypeReference{ Name: "universal", Path: "root", } diff --git a/pkg/reconciler/tenancy/workspacemounts/workspace_indexes.go b/pkg/reconciler/tenancy/workspacemounts/workspace_indexes.go new file mode 100644 index 00000000000..4389893bcba --- /dev/null +++ b/pkg/reconciler/tenancy/workspacemounts/workspace_indexes.go @@ -0,0 +1,96 @@ +/* +Copyright 2022 The KCP Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package workspacemounts + +import ( + "encoding/json" + "fmt" + "strings" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/kcp-dev/logicalcluster/v3" + + tenancyv1alpha1 "github.com/kcp-dev/kcp/sdk/apis/tenancy/v1alpha1" +) + +const workspaceByURL = "WorkspaceByURL" + +// indexWorkspaceByURL is an index for workspaces by their URL. +func indexWorkspaceByURL(obj interface{}) ([]string, error) { + ws, ok := obj.(*tenancyv1alpha1.Workspace) + if !ok { + return nil, fmt.Errorf("obj %T is not an Workspace", obj) + } + + return []string{ws.Spec.URL}, nil +} + +const workspaceMountsReferenceIndex = "WorkspacesByMountReference" + +type workspaceMountsReferenceKey struct { + ClusterName string `json:"clusterName"` + Group string `json:"group"` + Resource string `json:"resource"` + Name string `json:"name"` + Namespace string `json:"namespace,omitempty"` +} + +func indexWorkspaceByMountObject(obj interface{}) ([]string, error) { + ws, ok := obj.(*tenancyv1alpha1.Workspace) + if !ok { + return []string{}, fmt.Errorf("obj is supposed to be a Workspace, but is %T", obj) + } + + if ws.Spec.Mount == nil { + return nil, nil + } + + key := workspaceMountsReferenceKey{ + ClusterName: logicalcluster.From(ws).String(), + // TODO(sttts): do proper REST mapping + Resource: strings.ToLower(ws.Spec.Mount.Reference.Kind) + "s", + Name: ws.Spec.Mount.Reference.Name, + Namespace: ws.Spec.Mount.Reference.Namespace, + } + cs := strings.SplitN(ws.Spec.Mount.Reference.APIVersion, "/", 2) + if len(cs) == 2 { + key.Group = cs[0] + } + bs, err := json.Marshal(key) + if err != nil { + return nil, fmt.Errorf("unable to marshal mount reference: %w", err) + } + + return []string{string(bs)}, nil +} + +func indexWorkspaceByMountObjectValue(gvr schema.GroupVersionResource, obj *unstructured.Unstructured) (string, error) { + key := workspaceMountsReferenceKey{ + ClusterName: logicalcluster.From(obj).String(), + Group: gvr.Group, + Resource: gvr.Resource, + Name: obj.GetName(), + Namespace: obj.GetNamespace(), + } + bs, err := json.Marshal(key) + if err != nil { + return "", fmt.Errorf("unable to marshal mount reference: %w", err) + } + return string(bs), nil +} diff --git a/pkg/reconciler/tenancy/workspacemounts/workspacemounts_controller.go b/pkg/reconciler/tenancy/workspacemounts/workspacemounts_controller.go index 51e99efa84e..0d4fcb75446 100644 --- a/pkg/reconciler/tenancy/workspacemounts/workspacemounts_controller.go +++ b/pkg/reconciler/tenancy/workspacemounts/workspacemounts_controller.go @@ -18,15 +18,14 @@ package workspacemounts import ( "context" - "encoding/json" "fmt" "strings" "time" kerrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" - utilerrors "k8s.io/apimachinery/pkg/util/errors" utilruntime "k8s.io/apimachinery/pkg/util/runtime" "k8s.io/apimachinery/pkg/util/wait" "k8s.io/client-go/tools/cache" @@ -197,20 +196,66 @@ func (c *Controller) process(ctx context.Context, key string) (bool, error) { logger := logging.WithObject(klog.FromContext(ctx), workspace) ctx = klog.NewContext(ctx, logger) - var errs []error - requeue, err := c.reconcile(ctx, workspace) + getMountObjectFunc := func(ctx context.Context, cluster logicalcluster.Path, ref tenancyv1alpha1.ObjectReference) (*unstructured.Unstructured, error) { + // TODO(sttts): do proper REST mapping. + resource := strings.ToLower(ref.Kind) + "s" + gvr := schema.GroupVersionResource{Resource: resource} + cs := strings.SplitN(ref.APIVersion, "/", 2) + if len(cs) == 2 { + gvr.Group = cs[0] + gvr.Version = cs[1] + } else { + gvr.Version = ref.APIVersion + } + return c.dynamicClusterClient.Cluster(cluster).Resource(gvr).Get(ctx, ref.Name, metav1.GetOptions{}) + } + + // the following logic is a deviation from the standard pattern of reconcilers + // because the spec and status are both being updated here + // they need to be updated separately through the patch committer + + // reconcile the status + statusUpdater := &workspaceStatusUpdater{ + getMountObject: getMountObjectFunc, + } + + status, err := statusUpdater.reconcile(ctx, workspace) if err != nil { - errs = append(errs, err) + return false, err + } + + if status == reconcileStatusStopAndRequeue { + return true, nil } // If the object being reconciled changed as a result, update it. oldResource := &workspaceResource{ObjectMeta: old.ObjectMeta, Spec: &old.Spec, Status: &old.Status} - newResource := &workspaceResource{ObjectMeta: workspace.ObjectMeta, Spec: &workspace.Spec, Status: &workspace.Status} + newResource := &workspaceResource{ObjectMeta: workspace.ObjectMeta, Spec: &old.Spec, Status: &workspace.Status} if err := c.commit(ctx, oldResource, newResource); err != nil { - errs = append(errs, err) + return false, err } - return requeue, utilerrors.NewAggregate(errs) + // reconcile the spec + specUpdater := &workspaceSpecUpdater{ + getMountObject: getMountObjectFunc, + workspaceIndexer: c.workspaceIndexer, + } + status, err = specUpdater.reconcile(ctx, workspace) + if err != nil { + return false, err + } + if status == reconcileStatusStopAndRequeue { + return true, nil + } + + // If the object being reconciled changed as a result, update it. + oldResource = &workspaceResource{ObjectMeta: workspace.ObjectMeta, Spec: &old.Spec, Status: &old.Status} + newResource = &workspaceResource{ObjectMeta: workspace.ObjectMeta, Spec: &workspace.Spec, Status: &old.Status} + if err := c.commit(ctx, oldResource, newResource); err != nil { + return false, err + } + + return false, nil } // enqueuePotentiallyMountResource looks for workspaces referencing this kind. @@ -232,16 +277,6 @@ func (c *Controller) enqueuePotentiallyMountResource(gvr schema.GroupVersionReso } } -const workspaceMountsReferenceIndex = "WorkspacesByMountReference" - -type workspaceMountsReferenceKey struct { - ClusterName string `json:"clusterName"` - Group string `json:"group"` - Resource string `json:"resource"` - Name string `json:"name"` - Namespace string `json:"namespace,omitempty"` -} - // InstallIndexers adds the additional indexers that this controller requires to the informers. func InstallIndexers( workspaceInformer tenancyv1alpha1informers.WorkspaceClusterInformer, @@ -249,57 +284,8 @@ func InstallIndexers( indexers.AddIfNotPresentOrDie(workspaceInformer.Informer().GetIndexer(), cache.Indexers{ workspaceMountsReferenceIndex: indexWorkspaceByMountObject, }) -} - -func indexWorkspaceByMountObject(obj interface{}) ([]string, error) { - ws, ok := obj.(*tenancyv1alpha1.Workspace) - if !ok { - return []string{}, fmt.Errorf("obj is supposed to be a Workspace, but is %T", obj) - } - - v, ok := ws.Annotations[tenancyv1alpha1.ExperimentalWorkspaceMountAnnotationKey] - if !ok { - return nil, nil - } - - mount, err := tenancyv1alpha1.ParseTenancyMountAnnotation(v) - if err != nil { - return nil, fmt.Errorf("unable to parse mount annotation: %w", err) - } - if mount.MountSpec.Reference == nil { - return nil, nil - } - - key := workspaceMountsReferenceKey{ - ClusterName: logicalcluster.From(ws).String(), - // TODO(sttts): do proper REST mapping - Resource: strings.ToLower(mount.MountSpec.Reference.Kind) + "s", - Name: mount.MountSpec.Reference.Name, - Namespace: mount.MountSpec.Reference.Namespace, - } - cs := strings.SplitN(mount.MountSpec.Reference.APIVersion, "/", 2) - if len(cs) == 2 { - key.Group = cs[0] - } - bs, err := json.Marshal(key) - if err != nil { - return nil, fmt.Errorf("unable to marshal mount reference: %w", err) - } - - return []string{string(bs)}, nil -} -func indexWorkspaceByMountObjectValue(gvr schema.GroupVersionResource, obj *unstructured.Unstructured) (string, error) { - key := workspaceMountsReferenceKey{ - ClusterName: logicalcluster.From(obj).String(), - Group: gvr.Group, - Resource: gvr.Resource, - Name: obj.GetName(), - Namespace: obj.GetNamespace(), - } - bs, err := json.Marshal(key) - if err != nil { - return "", fmt.Errorf("unable to marshal mount reference: %w", err) - } - return string(bs), nil + indexers.AddIfNotPresentOrDie(workspaceInformer.Informer().GetIndexer(), cache.Indexers{ + workspaceByURL: indexWorkspaceByURL, + }) } diff --git a/pkg/reconciler/tenancy/workspacemounts/workspacemounts_reconcile.go b/pkg/reconciler/tenancy/workspacemounts/workspacemounts_reconcile.go index 776c496c8c7..ffd6f08a003 100644 --- a/pkg/reconciler/tenancy/workspacemounts/workspacemounts_reconcile.go +++ b/pkg/reconciler/tenancy/workspacemounts/workspacemounts_reconcile.go @@ -16,70 +16,9 @@ limitations under the License. package workspacemounts -import ( - "context" - "strings" - - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime/schema" - utilerrors "k8s.io/apimachinery/pkg/util/errors" - - "github.com/kcp-dev/logicalcluster/v3" - - tenancyv1alpha1 "github.com/kcp-dev/kcp/sdk/apis/tenancy/v1alpha1" -) - type reconcileStatus int const ( reconcileStatusStopAndRequeue reconcileStatus = iota reconcileStatusContinue ) - -type reconciler interface { - reconcile(ctx context.Context, workspace *tenancyv1alpha1.Workspace) (reconcileStatus, error) -} - -// reconcile reconciles the workspace objects. It is intended to be single reconciler for all the -// workspace replated operations. For now it has single reconciler that updates the status of the -// workspace based on the mount status. -func (c *Controller) reconcile(ctx context.Context, ws *tenancyv1alpha1.Workspace) (bool, error) { - getMountObjectFunc := func(ctx context.Context, cluster logicalcluster.Path, ref *tenancyv1alpha1.ObjectReference) (*unstructured.Unstructured, error) { - // TODO(sttts): do proper REST mapping. - resource := strings.ToLower(ref.Kind) + "s" - gvr := schema.GroupVersionResource{Resource: resource} - cs := strings.SplitN(ref.APIVersion, "/", 2) - if len(cs) == 2 { - gvr.Group = cs[0] - gvr.Version = cs[1] - } else { - gvr.Version = ref.APIVersion - } - return c.dynamicClusterClient.Cluster(cluster).Resource(gvr).Get(ctx, ref.Name, metav1.GetOptions{}) - } - - reconcilers := []reconciler{ - &workspaceStatusUpdater{ - getMountObject: getMountObjectFunc, - }, - } - - var errs []error - - requeue := false - for _, r := range reconcilers { - var err error - var status reconcileStatus - status, err = r.reconcile(ctx, ws) - if err != nil { - errs = append(errs, err) - } - if status == reconcileStatusStopAndRequeue { - requeue = true - break - } - } - - return requeue, utilerrors.NewAggregate(errs) -} diff --git a/pkg/reconciler/tenancy/workspacemounts/workspacemounts_reconcile_updater.go b/pkg/reconciler/tenancy/workspacemounts/workspacemounts_reconcile_updater.go index cda3ed32736..cc399bfbe28 100644 --- a/pkg/reconciler/tenancy/workspacemounts/workspacemounts_reconcile_updater.go +++ b/pkg/reconciler/tenancy/workspacemounts/workspacemounts_reconcile_updater.go @@ -18,13 +18,17 @@ package workspacemounts import ( "context" + "fmt" kerrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/cache" "github.com/kcp-dev/logicalcluster/v3" + "github.com/kcp-dev/kcp/pkg/indexers" + corev1alpha1 "github.com/kcp-dev/kcp/sdk/apis/core/v1alpha1" tenancyv1alpha1 "github.com/kcp-dev/kcp/sdk/apis/tenancy/v1alpha1" conditionsv1alpha1 "github.com/kcp-dev/kcp/sdk/apis/third_party/conditions/apis/conditions/v1alpha1" "github.com/kcp-dev/kcp/sdk/apis/third_party/conditions/util/conditions" @@ -35,103 +39,112 @@ import ( // we should add second reconciler that will update the status of the workspace so triggering // it to be "not ready" if the mount is not ready. type workspaceStatusUpdater struct { - getMountObject func(ctx context.Context, cluster logicalcluster.Path, ref *tenancyv1alpha1.ObjectReference) (*unstructured.Unstructured, error) + getMountObject func(ctx context.Context, cluster logicalcluster.Path, ref tenancyv1alpha1.ObjectReference) (*unstructured.Unstructured, error) } func (r *workspaceStatusUpdater) reconcile(ctx context.Context, workspace *tenancyv1alpha1.Workspace) (reconcileStatus, error) { - v, ok := workspace.Annotations[tenancyv1alpha1.ExperimentalWorkspaceMountAnnotationKey] - if !ok { - // no mount annotation, might be nothing or mount was soft "deleted" by removing the annotation. - // Delete the condition. - conditions.Delete(workspace, tenancyv1alpha1.MountConditionReady) + if workspace.Spec.Mount == nil { return reconcileStatusContinue, nil } - mount, err := tenancyv1alpha1.ParseTenancyMountAnnotation(v) - if err != nil { - conditions.MarkFalse( - workspace, - tenancyv1alpha1.MountConditionReady, - tenancyv1alpha1.MountAnnotationInvalidReason, - conditionsv1alpha1.ConditionSeverityError, - "Annotation %q is invalid: %v", tenancyv1alpha1.ExperimentalWorkspaceMountAnnotationKey, err, - ) - return reconcileStatusContinue, nil - } else if mount == nil { - conditions.MarkFalse( - workspace, - tenancyv1alpha1.MountConditionReady, - tenancyv1alpha1.MountAnnotationInvalidReason, - conditionsv1alpha1.ConditionSeverityError, - "Annotation %q is invalid: no mount JSON", tenancyv1alpha1.ExperimentalWorkspaceMountAnnotationKey, - ) + + if !workspace.DeletionTimestamp.IsZero() { return reconcileStatusContinue, nil } - switch { - case !workspace.DeletionTimestamp.IsZero(): - return reconcileStatusContinue, nil - case mount != nil && mount.MountSpec.Reference != nil: - obj, err := r.getMountObject(ctx, logicalcluster.From(workspace).Path(), mount.MountSpec.Reference) - if err != nil { - return reconcileStatusStopAndRequeue, err - } else if kerrors.IsNotFound(err) { + obj, err := r.getMountObject(ctx, logicalcluster.From(workspace).Path(), workspace.Spec.Mount.Reference) + if err != nil { + if kerrors.IsNotFound(err) { conditions.MarkFalse( workspace, tenancyv1alpha1.MountConditionReady, tenancyv1alpha1.MountObjectNotFoundReason, conditionsv1alpha1.ConditionSeverityError, "%s %q not found", - obj.GroupVersionKind().Kind, types.NamespacedName{Namespace: obj.GetNamespace(), Name: obj.GetName()}, + workspace.Spec.Mount.Reference.Kind, types.NamespacedName{Namespace: workspace.Spec.Mount.Reference.Namespace, Name: workspace.Spec.Mount.Reference.Name}, ) return reconcileStatusContinue, nil } + return reconcileStatusStopAndRequeue, err + } - // we are working on status field. As this is "loose coupling, we parse it out" - // Mount point implementors must expose status.{URL,phase} as a fields. - // We are not interested in the rest of the status. - statusPhase, ok, err := unstructured.NestedString(obj.Object, "status", "phase") - if err != nil || !ok { - conditions.MarkFalse( - workspace, - tenancyv1alpha1.MountConditionReady, - tenancyv1alpha1.MountObjectNotReadyReason, - conditionsv1alpha1.ConditionSeverityError, - "Mount is not reporting ready. See %s %q status for more details", - obj.GroupVersionKind().Kind, obj.GetName(), - ) + // we are working on status field. As this is "loose coupling, we parse it out" + // Mount point implementors must expose status.{URL,phase} as a fields. + // We are not interested in the rest of the status. + statusPhase, ok, err := unstructured.NestedString(obj.Object, "status", "phase") + if err != nil || !ok { + conditions.MarkFalse( + workspace, + tenancyv1alpha1.MountConditionReady, + tenancyv1alpha1.MountObjectNotReadyReason, + conditionsv1alpha1.ConditionSeverityError, + "Mount is not reporting ready. See %s %q status for more details", + obj.GroupVersionKind().Kind, obj.GetName(), + ) - return reconcileStatusContinue, nil //nolint:nilerr // we ignore the error intentionally. Not helpful. - } + return reconcileStatusContinue, nil //nolint:nilerr // we ignore the error intentionally. Not helpful. + } - // url might not be there if the mount is not ready - statusURL, _, _ := unstructured.NestedString(obj.Object, "status", "URL") + // Inject condition into the workspace. + // This is a loose coupling, we are not interested in the rest of the status. + switch tenancyv1alpha1.MountPhaseType(statusPhase) { + case tenancyv1alpha1.MountPhaseReady: + conditions.MarkTrue(workspace, tenancyv1alpha1.MountConditionReady) + workspace.Status.Phase = corev1alpha1.LogicalClusterPhaseReady + default: + conditions.MarkFalse( + workspace, + tenancyv1alpha1.MountConditionReady, + tenancyv1alpha1.MountObjectNotReadyReason, + conditionsv1alpha1.ConditionSeverityError, + "Mount is not reporting ready. See %s %q status for more details", + obj.GroupVersionKind().Kind, obj.GetName(), + ) + } - // Only Spec or Status can be updated, not both. - if mount.MountStatus.Phase != tenancyv1alpha1.MountPhaseType(statusPhase) || mount.MountStatus.URL != statusURL { - mount.MountStatus.Phase = tenancyv1alpha1.MountPhaseType(statusPhase) - mount.MountStatus.URL = statusURL + return reconcileStatusContinue, nil +} - workspace.Annotations[tenancyv1alpha1.ExperimentalWorkspaceMountAnnotationKey] = mount.String() +// workspaceSpecUpdater updates the spec of the workspace based on the mount status. +type workspaceSpecUpdater struct { + getMountObject func(ctx context.Context, cluster logicalcluster.Path, ref tenancyv1alpha1.ObjectReference) (*unstructured.Unstructured, error) + workspaceIndexer cache.Indexer +} - return reconcileStatusStopAndRequeue, nil - } +func (r *workspaceSpecUpdater) reconcile(ctx context.Context, workspace *tenancyv1alpha1.Workspace) (reconcileStatus, error) { + if workspace.Spec.Mount == nil { + return reconcileStatusContinue, nil + } - // Inject condition into the workspace. - // This is a loose coupling, we are not interested in the rest of the status. - switch mount.MountStatus.Phase { - case tenancyv1alpha1.MountPhaseReady: - conditions.MarkTrue(workspace, tenancyv1alpha1.MountConditionReady) - default: - conditions.MarkFalse( - workspace, - tenancyv1alpha1.MountConditionReady, - tenancyv1alpha1.MountObjectNotReadyReason, - conditionsv1alpha1.ConditionSeverityError, - "Mount is not reporting ready. See %s %q status for more details", - obj.GroupVersionKind().Kind, obj.GetName(), - ) + if !workspace.DeletionTimestamp.IsZero() { + return reconcileStatusContinue, nil + } + + obj, err := r.getMountObject(ctx, logicalcluster.From(workspace).Path(), workspace.Spec.Mount.Reference) + if err != nil { + if kerrors.IsNotFound(err) { + return reconcileStatusContinue, nil } + return reconcileStatusStopAndRequeue, err + } + + statusURL, found, err := unstructured.NestedString(obj.Object, "status", "URL") + if !found || err != nil { + return reconcileStatusStopAndRequeue, fmt.Errorf("unable to read .status.URL, found %v, err: %w", found, err) } + workspace.Spec.URL = statusURL + + // After this is best effort. It will not work cross-shards mounts type resolution. + wss, err := indexers.ByIndex[*tenancyv1alpha1.Workspace](r.workspaceIndexer, workspaceByURL, statusURL) + if err != nil { + return reconcileStatusContinue, nil //nolint:nilerr + } + + if len(wss) != 1 { + return reconcileStatusContinue, nil + } + + workspace.Spec.Cluster = wss[0].Spec.Cluster + workspace.Spec.Type = wss[0].Spec.Type return reconcileStatusContinue, nil } diff --git a/pkg/virtual/framework/internalapis/fixtures/workspaces.yaml b/pkg/virtual/framework/internalapis/fixtures/workspaces.yaml index 86dacb096cf..034e88f6851 100644 --- a/pkg/virtual/framework/internalapis/fixtures/workspaces.yaml +++ b/pkg/virtual/framework/internalapis/fixtures/workspaces.yaml @@ -97,6 +97,37 @@ spec: type: object type: object type: object + mount: + description: Mount is a reference to a an object implementing a mounting + feature. It is used to orchestrate where the traffic, intended for + the workspace, is sent. If specified, logicalcluster will not be created + and the workspace will be mounted using reference mount object. + properties: + ref: + description: Reference is an ObjectReference to the object that + is mounted. + properties: + apiVersion: + description: APIVersion is the API group and version of the + object. + type: string + kind: + description: Kind is the kind of the object. + type: string + name: + description: Name is the name of the object. + type: string + namespace: + description: Namespace is the namespace of the object. + type: string + required: + - apiVersion + - kind + - name + type: object + required: + - ref + type: object type: description: |- type defines properties of the workspace both on creation (e.g. initial resources and initially installed APIs) and during runtime (e.g. permissions). If no type is provided, the default type for the workspace in which this workspace is nesting will be used. diff --git a/pkg/virtual/framework/internalapis/import_test.go b/pkg/virtual/framework/internalapis/import_test.go index 18d2e32340a..68e3d1fa000 100644 --- a/pkg/virtual/framework/internalapis/import_test.go +++ b/pkg/virtual/framework/internalapis/import_test.go @@ -92,6 +92,8 @@ func TestImportInternalAPIs(t *testing.T) { require.NoError(t, err) actualContent, err := yaml.Marshal(s) require.NoError(t, err) + // If you just changed the schema and wondering "how do I make this test pass?", uncomment the following line + // os.WriteFile(path.Join("fixtures", s.Spec.Names.Plural+".yaml"), actualContent, 0644) require.Emptyf(t, cmp.Diff(strings.Split(string(expectedContent), "\n"), strings.Split(string(actualContent), "\n")), "%s was not identical to the expected content", s.Name) } } diff --git a/sdk/apis/tenancy/v1alpha1/types_mounts.go b/sdk/apis/tenancy/v1alpha1/types_mounts.go index b438223349b..d4008a8f613 100644 --- a/sdk/apis/tenancy/v1alpha1/types_mounts.go +++ b/sdk/apis/tenancy/v1alpha1/types_mounts.go @@ -17,9 +17,6 @@ limitations under the License. package v1alpha1 import ( - "encoding/json" - "fmt" - conditionsv1alpha1 "github.com/kcp-dev/kcp/sdk/apis/third_party/conditions/apis/conditions/v1alpha1" ) @@ -50,64 +47,3 @@ const ( // MountObjectNotReadyReason is the reason for the mount object not being in ready phase. MountObjectNotReadyReason = "MountObjectNotReady" ) - -// Mount is a workspace mount that can be used to mount a workspace into another workspace or resource. -// Mounting itself is done at front proxy level. -type Mount struct { - // MountSpec is the spec of the mount. - MountSpec MountSpec `json:"spec,omitempty"` - // MountStatus is the status of the mount. - MountStatus MountStatus `json:"status,omitempty"` -} - -type MountSpec struct { - // Reference is an ObjectReference to the object that is mounted. - Reference *ObjectReference `json:"ref,omitempty"` -} - -type ObjectReference struct { - // APIVersion is the API group and version of the object. - APIVersion string `json:"apiVersion"` - // Kind is the kind of the object. - Kind string `json:"kind"` - // Name is the name of the object. - Name string `json:"name"` - // Namespace is the namespace of the object. - Namespace string `json:"namespace,omitempty"` -} - -// MountStatus is the status of a mount. It is used to indicate the status of a mount, -// potentially managed outside of the core API. -type MountStatus struct { - // Phase of the mount (Initializing, Connecting, Ready, Unknown). - // - // +kubebuilder:default=Initializing - Phase MountPhaseType `json:"phase,omitempty"` - // Conditions is a list of conditions and their status. - // Current processing state of the Mount. - // +optional - Conditions conditionsv1alpha1.Conditions `json:"conditions,omitempty"` - - // URL is the URL of the mount. Mount is considered mountable when URL is set. - // +optional - URL string `json:"url,omitempty"` -} - -// ParseTenancyMountAnnotation parses the value of the annotation into a Mount. -func ParseTenancyMountAnnotation(value string) (*Mount, error) { - if value == "" { - return nil, fmt.Errorf("mount annotation is empty") - } - var mount Mount - err := json.Unmarshal([]byte(value), &mount) - return &mount, err -} - -// String returns the string representation of the mount. -func (m *Mount) String() string { - b, err := json.Marshal(m) - if err != nil { - panic(err) // :'( but will go away once it graduates from annotation to spec - } - return string(b) -} diff --git a/sdk/apis/tenancy/v1alpha1/types_mounts_test.go b/sdk/apis/tenancy/v1alpha1/types_mounts_test.go deleted file mode 100644 index dac60fc54d7..00000000000 --- a/sdk/apis/tenancy/v1alpha1/types_mounts_test.go +++ /dev/null @@ -1,45 +0,0 @@ -/* -Copyright 2023 The KCP Authors. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package v1alpha1 - -import ( - "testing" - - "github.com/google/go-cmp/cmp" -) - -func TestParseTenancyMountAnnotation(t *testing.T) { - input := "{\"spec\":{\"ref\":{\"apiVersion\":\"proxy.faros.sh/v1alpha1\",\"kind\":\"KubeCluster\",\"name\":\"dev-cluster\"}},\"status\":{}}" - expected := &Mount{ - MountSpec: MountSpec{ - Reference: &ObjectReference{ - APIVersion: "proxy.faros.sh/v1alpha1", - Kind: "KubeCluster", - Name: "dev-cluster", - }, - }, - MountStatus: MountStatus{}, - } - - v, err := ParseTenancyMountAnnotation(input) - if err != nil { - t.Fatal(err) - } - if cmp.Diff(v, expected) != "" { - t.Fatalf("unexpected diff: %s", cmp.Diff(v, &expected)) - } -} diff --git a/sdk/apis/tenancy/v1alpha1/types_workspace.go b/sdk/apis/tenancy/v1alpha1/types_workspace.go index a9a12d846c6..d5b484b9621 100644 --- a/sdk/apis/tenancy/v1alpha1/types_workspace.go +++ b/sdk/apis/tenancy/v1alpha1/types_workspace.go @@ -117,7 +117,7 @@ const LogicalClusterTypeAnnotationKey = "internal.tenancy.kcp.io/type" // +kubebuilder:resource:scope=Cluster,categories=kcp,shortName=ws // +kubebuilder:printcolumn:name="Type",type=string,JSONPath=`.spec.type.name`,description="Type of the workspace" // +kubebuilder:printcolumn:name="Region",type=string,JSONPath=`.metadata.labels['region']`,description="The region this workspace is in" -// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.metadata.labels['tenancy\.kcp\.io/phase']`,description="The current phase (e.g. Scheduling, Initializing, Ready, Deleting)" +// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase`,description="The current phase (e.g. Scheduling, Initializing, Ready, Deleting)" // +kubebuilder:printcolumn:name="URL",type=string,JSONPath=`.spec.URL`,description="URL to access the workspace" // +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" type Workspace struct { @@ -149,7 +149,7 @@ type WorkspaceSpec struct { // +kubebuilder:validation:XValidation:rule="self.name == oldSelf.name",message="name is immutable" // +kubebuilder:validation:XValidation:rule="has(oldSelf.path) == has(self.path)",message="path is immutable" // +kubebuilder:validation:XValidation:rule="!has(oldSelf.path) || !has(self.path) || self.path == oldSelf.path",message="path is immutable" - Type WorkspaceTypeReference `json:"type,omitempty"` + Type *WorkspaceTypeReference `json:"type,omitempty"` // location constraints where this workspace can be scheduled to. // @@ -174,6 +174,50 @@ type WorkspaceSpec struct { // // +kubebuilder:format:uri URL string `json:"URL,omitempty"` + + // Mount is a reference to a an object implementing a mounting feature. It is used to orchestrate + // where the traffic, intended for the workspace, is sent. + // If specified, logicalcluster will not be created and the workspace will be mounted + // using reference mount object. + // + // +optional + Mount *Mount `json:"mount,omitempty"` +} + +func (in *WorkspaceSpec) IsMounted() bool { + return in.Mount != nil +} + +// Mount is a reference to a an object implementing a mounting feature. It is used to orchestrate +// where the traffic, intended for the workspace, is sent. +type Mount struct { + // Reference is an ObjectReference to the object that is mounted. + // + // +required + // +kubebuilder:validation:Required + // +kubebuilder:validation:XValidation:rule="has(oldSelf.apiVersion) == has(self.apiVersion)",message="apiVersion is immutable" + // +kubebuilder:validation:XValidation:rule="has(oldSelf.kind) == has(self.kind)",message="kind is immutable" + // +kubebuilder:validation:XValidation:rule="has(oldSelf.name) == has(self.name)",message="name is immutable" + Reference ObjectReference `json:"ref"` +} + +type ObjectReference struct { + // APIVersion is the API group and version of the object. + // + // +required + APIVersion string `json:"apiVersion"` + // Kind is the kind of the object. + // + // +required + Kind string `json:"kind"` + // Name is the name of the object. + // + // +required + Name string `json:"name"` + // Namespace is the namespace of the object. + // + // +optional + Namespace string `json:"namespace,omitempty"` } type WorkspaceLocation struct { diff --git a/sdk/apis/tenancy/v1alpha1/zz_generated.deepcopy.go b/sdk/apis/tenancy/v1alpha1/zz_generated.deepcopy.go index 00c52d70deb..dbd6fdacba2 100644 --- a/sdk/apis/tenancy/v1alpha1/zz_generated.deepcopy.go +++ b/sdk/apis/tenancy/v1alpha1/zz_generated.deepcopy.go @@ -47,8 +47,7 @@ func (in *APIExportReference) DeepCopy() *APIExportReference { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Mount) DeepCopyInto(out *Mount) { *out = *in - in.MountSpec.DeepCopyInto(&out.MountSpec) - in.MountStatus.DeepCopyInto(&out.MountStatus) + out.Reference = in.Reference return } @@ -62,50 +61,6 @@ func (in *Mount) DeepCopy() *Mount { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MountSpec) DeepCopyInto(out *MountSpec) { - *out = *in - if in.Reference != nil { - in, out := &in.Reference, &out.Reference - *out = new(ObjectReference) - **out = **in - } - return -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MountSpec. -func (in *MountSpec) DeepCopy() *MountSpec { - if in == nil { - return nil - } - out := new(MountSpec) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MountStatus) DeepCopyInto(out *MountStatus) { - *out = *in - if in.Conditions != nil { - in, out := &in.Conditions, &out.Conditions - *out = make(conditionsv1alpha1.Conditions, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } - return -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MountStatus. -func (in *MountStatus) DeepCopy() *MountStatus { - if in == nil { - return nil - } - out := new(MountStatus) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ObjectReference) DeepCopyInto(out *ObjectReference) { *out = *in @@ -223,12 +178,21 @@ func (in *WorkspaceLocation) DeepCopy() *WorkspaceLocation { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *WorkspaceSpec) DeepCopyInto(out *WorkspaceSpec) { *out = *in - out.Type = in.Type + if in.Type != nil { + in, out := &in.Type, &out.Type + *out = new(WorkspaceTypeReference) + **out = **in + } if in.Location != nil { in, out := &in.Location, &out.Location *out = new(WorkspaceLocation) (*in).DeepCopyInto(*out) } + if in.Mount != nil { + in, out := &in.Mount, &out.Mount + *out = new(Mount) + **out = **in + } return } diff --git a/sdk/client/applyconfiguration/tenancy/v1alpha1/mount.go b/sdk/client/applyconfiguration/tenancy/v1alpha1/mount.go new file mode 100644 index 00000000000..bfd107f5df2 --- /dev/null +++ b/sdk/client/applyconfiguration/tenancy/v1alpha1/mount.go @@ -0,0 +1,39 @@ +/* +Copyright The KCP Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1alpha1 + +// MountApplyConfiguration represents a declarative configuration of the Mount type for use +// with apply. +type MountApplyConfiguration struct { + Reference *ObjectReferenceApplyConfiguration `json:"ref,omitempty"` +} + +// MountApplyConfiguration constructs a declarative configuration of the Mount type for use with +// apply. +func Mount() *MountApplyConfiguration { + return &MountApplyConfiguration{} +} + +// WithReference sets the Reference field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Reference field is set to the value of the last call. +func (b *MountApplyConfiguration) WithReference(value *ObjectReferenceApplyConfiguration) *MountApplyConfiguration { + b.Reference = value + return b +} diff --git a/sdk/client/applyconfiguration/tenancy/v1alpha1/objectreference.go b/sdk/client/applyconfiguration/tenancy/v1alpha1/objectreference.go new file mode 100644 index 00000000000..24ce770ea76 --- /dev/null +++ b/sdk/client/applyconfiguration/tenancy/v1alpha1/objectreference.go @@ -0,0 +1,66 @@ +/* +Copyright The KCP Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1alpha1 + +// ObjectReferenceApplyConfiguration represents a declarative configuration of the ObjectReference type for use +// with apply. +type ObjectReferenceApplyConfiguration struct { + APIVersion *string `json:"apiVersion,omitempty"` + Kind *string `json:"kind,omitempty"` + Name *string `json:"name,omitempty"` + Namespace *string `json:"namespace,omitempty"` +} + +// ObjectReferenceApplyConfiguration constructs a declarative configuration of the ObjectReference type for use with +// apply. +func ObjectReference() *ObjectReferenceApplyConfiguration { + return &ObjectReferenceApplyConfiguration{} +} + +// WithAPIVersion sets the APIVersion field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the APIVersion field is set to the value of the last call. +func (b *ObjectReferenceApplyConfiguration) WithAPIVersion(value string) *ObjectReferenceApplyConfiguration { + b.APIVersion = &value + return b +} + +// WithKind sets the Kind field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Kind field is set to the value of the last call. +func (b *ObjectReferenceApplyConfiguration) WithKind(value string) *ObjectReferenceApplyConfiguration { + b.Kind = &value + return b +} + +// WithName sets the Name field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Name field is set to the value of the last call. +func (b *ObjectReferenceApplyConfiguration) WithName(value string) *ObjectReferenceApplyConfiguration { + b.Name = &value + return b +} + +// WithNamespace sets the Namespace field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Namespace field is set to the value of the last call. +func (b *ObjectReferenceApplyConfiguration) WithNamespace(value string) *ObjectReferenceApplyConfiguration { + b.Namespace = &value + return b +} diff --git a/sdk/client/applyconfiguration/tenancy/v1alpha1/workspacespec.go b/sdk/client/applyconfiguration/tenancy/v1alpha1/workspacespec.go index f9e649d53d3..e3afbae1fb4 100644 --- a/sdk/client/applyconfiguration/tenancy/v1alpha1/workspacespec.go +++ b/sdk/client/applyconfiguration/tenancy/v1alpha1/workspacespec.go @@ -25,6 +25,7 @@ type WorkspaceSpecApplyConfiguration struct { Location *WorkspaceLocationApplyConfiguration `json:"location,omitempty"` Cluster *string `json:"cluster,omitempty"` URL *string `json:"URL,omitempty"` + Mount *MountApplyConfiguration `json:"mount,omitempty"` } // WorkspaceSpecApplyConfiguration constructs a declarative configuration of the WorkspaceSpec type for use with @@ -64,3 +65,11 @@ func (b *WorkspaceSpecApplyConfiguration) WithURL(value string) *WorkspaceSpecAp b.URL = &value return b } + +// WithMount sets the Mount field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Mount field is set to the value of the last call. +func (b *WorkspaceSpecApplyConfiguration) WithMount(value *MountApplyConfiguration) *WorkspaceSpecApplyConfiguration { + b.Mount = value + return b +} diff --git a/sdk/client/applyconfiguration/utils.go b/sdk/client/applyconfiguration/utils.go index e55a21bbf73..1ad1222e75e 100644 --- a/sdk/client/applyconfiguration/utils.go +++ b/sdk/client/applyconfiguration/utils.go @@ -173,6 +173,10 @@ func ForKind(kind schema.GroupVersionKind) interface{} { // Group=tenancy.kcp.io, Version=v1alpha1 case tenancyv1alpha1.SchemeGroupVersion.WithKind("APIExportReference"): return &applyconfigurationtenancyv1alpha1.APIExportReferenceApplyConfiguration{} + case tenancyv1alpha1.SchemeGroupVersion.WithKind("Mount"): + return &applyconfigurationtenancyv1alpha1.MountApplyConfiguration{} + case tenancyv1alpha1.SchemeGroupVersion.WithKind("ObjectReference"): + return &applyconfigurationtenancyv1alpha1.ObjectReferenceApplyConfiguration{} case tenancyv1alpha1.SchemeGroupVersion.WithKind("VirtualWorkspace"): return &applyconfigurationtenancyv1alpha1.VirtualWorkspaceApplyConfiguration{} case tenancyv1alpha1.SchemeGroupVersion.WithKind("Workspace"): diff --git a/sdk/testing/workspaces.go b/sdk/testing/workspaces.go index e7c4301dc63..330979e1993 100644 --- a/sdk/testing/workspaces.go +++ b/sdk/testing/workspaces.go @@ -83,7 +83,7 @@ func WithLocation(w tenancyv1alpha1.WorkspaceLocation) UnprivilegedWorkspaceOpti // WithType sets the type of the workspace. func WithType(path logicalcluster.Path, name tenancyv1alpha1.WorkspaceTypeName) UnprivilegedWorkspaceOption { return func(ws *tenancyv1alpha1.Workspace) { - ws.Spec.Type = tenancyv1alpha1.WorkspaceTypeReference{ + ws.Spec.Type = &tenancyv1alpha1.WorkspaceTypeReference{ Name: name, Path: path.String(), } @@ -118,7 +118,7 @@ func NewLowLevelWorkspaceFixture[O WorkspaceOption](t TestingT, createClusterCli GenerateName: "e2e-workspace-", }, Spec: tenancyv1alpha1.WorkspaceSpec{ - Type: tenancyv1alpha1.WorkspaceTypeReference{ + Type: &tenancyv1alpha1.WorkspaceTypeReference{ Name: tenancyv1alpha1.WorkspaceTypeName("universal"), Path: "root", }, diff --git a/test/e2e/mounts/apiresourceschema_kubecluster.yaml b/test/e2e/mounts/crd_kubecluster.yaml similarity index 100% rename from test/e2e/mounts/apiresourceschema_kubecluster.yaml rename to test/e2e/mounts/crd_kubecluster.yaml diff --git a/test/e2e/mounts/mounts_machinery_test.go b/test/e2e/mounts/mounts_machinery_test.go index 9b56e02cb9e..96e9a849469 100644 --- a/test/e2e/mounts/mounts_machinery_test.go +++ b/test/e2e/mounts/mounts_machinery_test.go @@ -19,7 +19,6 @@ package homeworkspaces import ( "context" "embed" - "encoding/json" "fmt" "testing" "time" @@ -38,8 +37,10 @@ import ( kcpapiextensionsv1client "github.com/kcp-dev/client-go/apiextensions/client/typed/apiextensions/v1" kcpdynamic "github.com/kcp-dev/client-go/dynamic" + "github.com/kcp-dev/logicalcluster/v3" "github.com/kcp-dev/kcp/config/helpers" + "github.com/kcp-dev/kcp/sdk/apis/core" corev1alpha1 "github.com/kcp-dev/kcp/sdk/apis/core/v1alpha1" tenancyv1alpha1 "github.com/kcp-dev/kcp/sdk/apis/tenancy/v1alpha1" "github.com/kcp-dev/kcp/sdk/apis/third_party/conditions/util/conditions" @@ -52,6 +53,12 @@ import ( //go:embed *.yaml var testFiles embed.FS +// TestMountsMachinery is exercising the mounts api. In production we should be configurating front-proxy +// with urls for routing requests to right backend, potentially virtual-workspaces using mappings file. +// This is due to reason front-proxy will not route traffic to not-trusted urls. +// To test this in the text, we will route traffic to another workspace. This kinda simulates mounts in +// symbolic links fashion. + func TestMountsMachinery(t *testing.T) { t.Parallel() framework.Suite(t, "control-plane") @@ -63,10 +70,19 @@ func TestMountsMachinery(t *testing.T) { ctx, cancelFunc := context.WithCancel(context.Background()) t.Cleanup(cancelFunc) - orgPath, _ := framework.NewOrganizationFixture(t, server) //nolint:staticcheck // TODO: switch to NewWorkspaceFixture. + // This will create structure as bellow for testing: + // └── root + // └── e2e-workspace-n784f + // ├── destination + // └── source + // └── mount + // + rootOrg, _ := kcptesting.NewWorkspaceFixture(t, server, core.RootCluster.Path()) - mountWorkspaceName := "mounts-machinery" - mountPath, _ := kcptesting.NewWorkspaceFixture(t, server, orgPath, kcptesting.WithName("%s", mountWorkspaceName)) + sourcePath, _ := kcptesting.NewWorkspaceFixture(t, server, rootOrg, + kcptesting.WithName("source")) + _, destinationWorkspaceObj := kcptesting.NewWorkspaceFixture(t, server, rootOrg, + kcptesting.WithName("destination")) cfg := server.BaseConfig(t) kcpClusterClient, err := kcpclientset.NewForConfig(cfg) @@ -81,49 +97,44 @@ func TestMountsMachinery(t *testing.T) { orgProviderKCPClient, err := kcpclientset.NewForConfig(cfg) require.NoError(t, err, "error creating cowboys provider kcp client") - t.Logf("Install a mount object APIResourceSchema into workspace %q", orgPath) - mapper := restmapper.NewDeferredDiscoveryRESTMapper(memory.NewMemCacheClient(orgProviderKCPClient.Cluster(orgPath).Discovery())) - err = helpers.CreateResourceFromFS(ctx, dynamicClusterClient.Cluster(orgPath), mapper, nil, "apiresourceschema_kubecluster.yaml", testFiles) + t.Logf("Install a mount object CRD into workspace %q", sourcePath) + mapper := restmapper.NewDeferredDiscoveryRESTMapper(memory.NewMemCacheClient(orgProviderKCPClient.Cluster(sourcePath).Discovery())) + err = helpers.CreateResourceFromFS(ctx, dynamicClusterClient.Cluster(sourcePath), mapper, nil, "crd_kubecluster.yaml", testFiles) require.NoError(t, err) t.Logf("Waiting for CRD to be established") name := "kubeclusters.contrib.kcp.io" kcptestinghelpers.Eventually(t, func() (bool, string) { - crd, err := extensionsClusterClient.Cluster(orgPath).CustomResourceDefinitions().Get(ctx, name, metav1.GetOptions{}) + crd, err := extensionsClusterClient.Cluster(sourcePath).CustomResourceDefinitions().Get(ctx, name, metav1.GetOptions{}) require.NoError(t, err) return crdhelpers.IsCRDConditionTrue(crd, apiextensionsv1.Established), yamlMarshal(t, crd) }, wait.ForeverTestTimeout, 100*time.Millisecond, "waiting for CRD to be established") - t.Logf("Install a mount object into workspace %q", orgPath) + t.Logf("Install a mount object into workspace %q", sourcePath) kcptestinghelpers.Eventually(t, func() (bool, string) { - mapper := restmapper.NewDeferredDiscoveryRESTMapper(memory.NewMemCacheClient(orgProviderKCPClient.Cluster(orgPath).Discovery())) - err = helpers.CreateResourceFromFS(ctx, dynamicClusterClient.Cluster(orgPath), mapper, nil, "kubecluster_mounts.yaml", testFiles) + mapper := restmapper.NewDeferredDiscoveryRESTMapper(memory.NewMemCacheClient(orgProviderKCPClient.Cluster(sourcePath).Discovery())) + err = helpers.CreateResourceFromFS(ctx, dynamicClusterClient.Cluster(sourcePath), mapper, nil, "kubecluster_mounts.yaml", testFiles) return err == nil, fmt.Sprintf("%v", err) }, wait.ForeverTestTimeout, 100*time.Millisecond, "waiting for mount object to be installed") - // At this point we have object backing the mount object. So lets add mount annotation to the workspace. - // But order should not matter. - t.Logf("Set a mount annotation onto workspace %q", orgPath) - mount := tenancyv1alpha1.Mount{ - MountSpec: tenancyv1alpha1.MountSpec{ - Reference: &tenancyv1alpha1.ObjectReference{ - APIVersion: "contrib.kcp.io/v1alpha1", - Kind: "KubeCluster", - Name: "proxy-cluster", // must match name in kubecluster_mounts.yaml + mountWorkspaceName := "mount" + workspace := tenancyv1alpha1.Workspace{ + ObjectMeta: metav1.ObjectMeta{ + Name: mountWorkspaceName, + }, + Spec: tenancyv1alpha1.WorkspaceSpec{ + Mount: &tenancyv1alpha1.Mount{ + Reference: tenancyv1alpha1.ObjectReference{ + APIVersion: "contrib.kcp.io/v1alpha1", + Kind: "KubeCluster", + Name: "proxy-cluster", + }, }, }, } - annValue, err := json.Marshal(mount) - require.NoError(t, err) - err = retry.RetryOnConflict(retry.DefaultRetry, func() error { - current, err := kcpClusterClient.Cluster(orgPath).TenancyV1alpha1().Workspaces().Get(ctx, mountWorkspaceName, metav1.GetOptions{}) - require.NoError(t, err) - - current.Annotations[tenancyv1alpha1.ExperimentalWorkspaceMountAnnotationKey] = string(annValue) - _, err = kcpClusterClient.Cluster(orgPath).TenancyV1alpha1().Workspaces().Update(ctx, current, metav1.UpdateOptions{}) - return err - }) + t.Logf("Create a workspace %q", sourcePath) + _, err = kcpClusterClient.Cluster(sourcePath).TenancyV1alpha1().Workspaces().Create(ctx, &workspace, metav1.CreateOptions{}) require.NoError(t, err) t.Log("Updating the mount object to be ready") @@ -133,10 +144,18 @@ func TestMountsMachinery(t *testing.T) { Resource: "kubeclusters", } err = retry.RetryOnConflict(retry.DefaultRetry, func() error { - currentMount, err := dynamicClusterClient.Cluster(orgPath).Resource(mountGVR).Namespace("").Get(ctx, "proxy-cluster", metav1.GetOptions{}) + destinationWorkspace, err := kcpClusterClient.Cluster(rootOrg).TenancyV1alpha1().Workspaces().Get(ctx, destinationWorkspaceObj.Name, metav1.GetOptions{}) + require.NoError(t, err) + + if destinationWorkspace.Spec.URL == "" { + return fmt.Errorf("destination workspace %q is not ready", destinationWorkspaceObj.Name) + } + + currentMount, err := dynamicClusterClient.Cluster(sourcePath).Resource(mountGVR).Namespace("").Get(ctx, "proxy-cluster", metav1.GetOptions{}) require.NoError(t, err) currentMount.Object["status"] = map[string]interface{}{ + "URL": destinationWorkspaceObj.Spec.URL, "conditions": []interface{}{ map[string]interface{}{ "lastTransitionTime": "2024-10-19T12:30:47Z", @@ -149,35 +168,36 @@ func TestMountsMachinery(t *testing.T) { "phase": "Ready", } - _, err = dynamicClusterClient.Cluster(orgPath).Resource(mountGVR).UpdateStatus(ctx, currentMount, metav1.UpdateOptions{}) + _, err = dynamicClusterClient.Cluster(sourcePath).Resource(mountGVR).UpdateStatus(ctx, currentMount, metav1.UpdateOptions{}) return err }) require.NoError(t, err) t.Log("Workspace should have WorkspaceMountReady and WorkspaceInitialized conditions, both true") kcptestinghelpers.Eventually(t, func() (bool, string) { - current, err := kcpClusterClient.Cluster(orgPath).TenancyV1alpha1().Workspaces().Get(ctx, mountWorkspaceName, metav1.GetOptions{}) + current, err := kcpClusterClient.Cluster(sourcePath).TenancyV1alpha1().Workspaces().Get(ctx, mountWorkspaceName, metav1.GetOptions{}) if err != nil { return false, err.Error() } - initialized := conditions.IsTrue(current, tenancyv1alpha1.WorkspaceInitialized) ready := conditions.IsTrue(current, tenancyv1alpha1.MountConditionReady) - return initialized && ready, yamlMarshal(t, current) - }, wait.ForeverTestTimeout, 100*time.Millisecond, "waiting for WorkspaceMountReady and WorkspaceInitialized conditions to be true") + return ready, yamlMarshal(t, current) + }, wait.ForeverTestTimeout, 100*time.Millisecond, "waiting for WorkspaceMountReady condition to be true") + + cluster := logicalcluster.NewPath(sourcePath.String() + ":" + mountWorkspaceName) t.Log("Workspace access should work") kcptestinghelpers.Eventually(t, func() (bool, string) { - _, err := kcpClusterClient.Cluster(mountPath).ApisV1alpha2().APIExports().List(ctx, metav1.ListOptions{}) + _, err := kcpClusterClient.Cluster(cluster).ApisV1alpha1().APIExports().List(ctx, metav1.ListOptions{}) return err == nil, fmt.Sprintf("err = %v", err) }, wait.ForeverTestTimeout, 100*time.Millisecond, "waiting for workspace access to work") t.Log("Set mount to not ready") err = retry.RetryOnConflict(retry.DefaultRetry, func() error { - current, err := dynamicClusterClient.Cluster(orgPath).Resource(mountGVR).Namespace("").Get(ctx, "proxy-cluster", metav1.GetOptions{}) + current, err := dynamicClusterClient.Cluster(sourcePath).Resource(mountGVR).Namespace("").Get(ctx, "proxy-cluster", metav1.GetOptions{}) require.NoError(t, err) current.Object["status"].(map[string]interface{})["phase"] = "Connecting" - updated, err := dynamicClusterClient.Cluster(orgPath).Resource(mountGVR).Namespace("").UpdateStatus(ctx, current, metav1.UpdateOptions{}) + updated, err := dynamicClusterClient.Cluster(sourcePath).Resource(mountGVR).Namespace("").UpdateStatus(ctx, current, metav1.UpdateOptions{}) t.Logf("Updated mount object: %v", yamlMarshal(t, updated)) return err }) @@ -185,14 +205,14 @@ func TestMountsMachinery(t *testing.T) { t.Log("Workspace phase should become unavailable") kcptestinghelpers.Eventually(t, func() (bool, string) { - current, err := kcpClusterClient.Cluster(orgPath).TenancyV1alpha1().Workspaces().Get(ctx, mountWorkspaceName, metav1.GetOptions{}) + current, err := kcpClusterClient.Cluster(sourcePath).TenancyV1alpha1().Workspaces().Get(ctx, mountWorkspaceName, metav1.GetOptions{}) require.NoError(t, err) return current.Status.Phase == corev1alpha1.LogicalClusterPhaseUnavailable, yamlMarshal(t, current) }, wait.ForeverTestTimeout, 100*time.Millisecond, "waiting for workspace to become unavailable") t.Logf("Workspace access should eventually fail") kcptestinghelpers.Eventually(t, func() (bool, string) { - _, err = kcpClusterClient.Cluster(mountPath).ApisV1alpha2().APIExports().List(ctx, metav1.ListOptions{}) + _, err = kcpClusterClient.Cluster(cluster).ApisV1alpha1().APIExports().List(ctx, metav1.ListOptions{}) return err != nil, fmt.Sprintf("err = %v", err) }, wait.ForeverTestTimeout, 100*time.Millisecond, "waiting for workspace access to fail") } diff --git a/test/e2e/reconciler/workspacedeletion/controller_test.go b/test/e2e/reconciler/workspacedeletion/controller_test.go index c5eeb213d5e..ca2e97c18b5 100644 --- a/test/e2e/reconciler/workspacedeletion/controller_test.go +++ b/test/e2e/reconciler/workspacedeletion/controller_test.go @@ -68,7 +68,7 @@ func TestWorkspaceDeletion(t *testing.T) { workspace, err := server.kcpClusterClient.Cluster(orgPath).TenancyV1alpha1().Workspaces().Create(ctx, &tenancyv1alpha1.Workspace{ ObjectMeta: metav1.ObjectMeta{Name: "ws-cleanup"}, Spec: tenancyv1alpha1.WorkspaceSpec{ - Type: tenancyv1alpha1.WorkspaceTypeReference{ + Type: &tenancyv1alpha1.WorkspaceTypeReference{ Name: "universal", Path: "root", }, diff --git a/test/e2e/virtual/initializingworkspaces/virtualworkspace_test.go b/test/e2e/virtual/initializingworkspaces/virtualworkspace_test.go index 4e5898ad401..e8c0b70fdc2 100644 --- a/test/e2e/virtual/initializingworkspaces/virtualworkspace_test.go +++ b/test/e2e/virtual/initializingworkspaces/virtualworkspace_test.go @@ -578,7 +578,7 @@ func workspaceForType(workspaceType *tenancyv1alpha1.WorkspaceType, testLabelSel Labels: testLabelSelector, }, Spec: tenancyv1alpha1.WorkspaceSpec{ - Type: tenancyv1alpha1.WorkspaceTypeReference{ + Type: &tenancyv1alpha1.WorkspaceTypeReference{ Name: tenancyv1alpha1.WorkspaceTypeName(workspaceType.Name), Path: logicalcluster.From(workspaceType).String(), }, diff --git a/test/e2e/workspacetype/controller_test.go b/test/e2e/workspacetype/controller_test.go index 7ac07c10396..b54588d6c92 100644 --- a/test/e2e/workspacetype/controller_test.go +++ b/test/e2e/workspacetype/controller_test.go @@ -82,7 +82,7 @@ func TestWorkspaceTypes(t *testing.T) { t.Logf("Expect workspace to be of universal type, and no initializers") workspace, err = server.kcpClusterClient.TenancyV1alpha1().Workspaces().Cluster(server.orgPath).Get(ctx, workspace.Name, metav1.GetOptions{}) require.NoError(t, err, "failed to get workspace") - require.Equalf(t, workspace.Spec.Type, tenancyv1alpha1.WorkspaceTypeReference{ + require.Equalf(t, workspace.Spec.Type, &tenancyv1alpha1.WorkspaceTypeReference{ Name: "universal", Path: "root", }, "workspace type is not universal") @@ -99,7 +99,7 @@ func TestWorkspaceTypes(t *testing.T) { workspace, err := server.kcpClusterClient.TenancyV1alpha1().Workspaces().Cluster(universalPath).Create(ctx, &tenancyv1alpha1.Workspace{ ObjectMeta: metav1.ObjectMeta{Name: "myapp"}, Spec: tenancyv1alpha1.WorkspaceSpec{ - Type: tenancyv1alpha1.WorkspaceTypeReference{ + Type: &tenancyv1alpha1.WorkspaceTypeReference{ Name: "foo", Path: "root", }, @@ -126,7 +126,7 @@ func TestWorkspaceTypes(t *testing.T) { // note: admission is informer based and hence would race with this create call workspace, err = server.kcpClusterClient.TenancyV1alpha1().Workspaces().Cluster(universalPath).Create(ctx, &tenancyv1alpha1.Workspace{ ObjectMeta: metav1.ObjectMeta{Name: "myapp"}, - Spec: tenancyv1alpha1.WorkspaceSpec{Type: tenancyv1alpha1.WorkspaceTypeReference{ + Spec: tenancyv1alpha1.WorkspaceSpec{Type: &tenancyv1alpha1.WorkspaceTypeReference{ Name: tenancyv1alpha1.TypeName(wt.Name), Path: logicalcluster.From(wt).String(), }}, @@ -139,7 +139,7 @@ func TestWorkspaceTypes(t *testing.T) { server.RunningServer.Artifact(t, func() (runtime.Object, error) { return server.kcpClusterClient.TenancyV1alpha1().Workspaces().Cluster(universalPath).Get(ctx, "myapp", metav1.GetOptions{}) }) - require.Equal(t, workspace.Spec.Type, tenancyv1alpha1.WorkspaceTypeReference{ + require.Equal(t, workspace.Spec.Type, &tenancyv1alpha1.WorkspaceTypeReference{ Name: "foo", Path: logicalcluster.From(wt).String(), }) @@ -220,7 +220,7 @@ func TestWorkspaceTypes(t *testing.T) { // note: admission is informer based and hence would race with this create call workspace, err = user1KcpClient.TenancyV1alpha1().Workspaces().Cluster(universalPath).Create(ctx, &tenancyv1alpha1.Workspace{ ObjectMeta: metav1.ObjectMeta{Name: "myapp"}, - Spec: tenancyv1alpha1.WorkspaceSpec{Type: tenancyv1alpha1.WorkspaceTypeReference{ + Spec: tenancyv1alpha1.WorkspaceSpec{Type: &tenancyv1alpha1.WorkspaceTypeReference{ Name: tenancyv1alpha1.TypeName(wt.Name), Path: logicalcluster.From(wt).String(), }}, @@ -234,7 +234,7 @@ func TestWorkspaceTypes(t *testing.T) { return server.kcpClusterClient.Cluster(universalPath).TenancyV1alpha1().Workspaces().Get(ctx, "myapp", metav1.GetOptions{}) }) require.Equal(t, workspace.Spec.Type, - tenancyv1alpha1.WorkspaceTypeReference{ + &tenancyv1alpha1.WorkspaceTypeReference{ Name: "bar", Path: logicalcluster.From(wt).String(), }) @@ -279,7 +279,7 @@ func TestWorkspaceTypes(t *testing.T) { workspace, err = server.kcpClusterClient.TenancyV1alpha1().Workspaces().Cluster(universalPath).Create(ctx, &tenancyv1alpha1.Workspace{ ObjectMeta: metav1.ObjectMeta{Name: "myapp"}, Spec: tenancyv1alpha1.WorkspaceSpec{ - Type: tenancyv1alpha1.WorkspaceTypeReference{ + Type: &tenancyv1alpha1.WorkspaceTypeReference{ Name: "foo", Path: logicalcluster.From(wt).String(), },