diff --git a/config/crds/tenancy.kcp.dev_clusterworkspaces.yaml b/config/crds/tenancy.kcp.dev_clusterworkspaces.yaml index 99967d0190ec..096b002923a4 100644 --- a/config/crds/tenancy.kcp.dev_clusterworkspaces.yaml +++ b/config/crds/tenancy.kcp.dev_clusterworkspaces.yaml @@ -68,17 +68,25 @@ spec: readOnly: type: boolean type: - default: Universal description: "type defines properties of the workspace both on creation (e.g. initial resources and initially installed APIs) and during runtime (e.g. permissions). \n The type is a reference to a ClusterWorkspaceType - in the same workspace with the same name, but lower-cased. The ClusterWorkspaceType + in the listed workspace, but lower-cased. The ClusterWorkspaceType existence is validated at admission during creation, with the exception of the \"Universal\" type whose existence is not required but respected if it exists. The type is immutable after creation. The use of a type is gated via the RBAC clusterworkspacetypes/use resource permission." - pattern: ^[A-Z][a-zA-Z0-9]+$ - type: string + properties: + name: + description: name is the name of the ClusterWorkspaceType + pattern: ^[A-Z][a-zA-Z0-9]+$ + type: string + path: + description: path is an absolute reference to the workspace that + owns this type, e.g. root:org:ws. + pattern: ^root(:[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + type: object type: object status: description: ClusterWorkspaceStatus communicates the observed state of diff --git a/config/crds/tenancy.kcp.dev_clusterworkspacetypes.yaml b/config/crds/tenancy.kcp.dev_clusterworkspacetypes.yaml index 1108caf1b899..3b01a224f50c 100644 --- a/config/crds/tenancy.kcp.dev_clusterworkspacetypes.yaml +++ b/config/crds/tenancy.kcp.dev_clusterworkspacetypes.yaml @@ -44,6 +44,64 @@ spec: description: additionalWorkspaceLabels are a set of labels that will be added to a ClusterWorkspace on creation. type: object + allowedSubWorkspaceTypes: + description: "allowedSubWorkspaceTypes is a list of ClusterWorkspaceTypes + that can be created in a workspace of this type. \n If a workspace + type extends a type definition of the same name at a higher level, + the sets of allowed sub-workspace types are merged. A ClusterWorkspaceType + can only add allowed sub-workspace types from the same workspace. + \n For example, extending types from parent workspaces is not allowed, + as seen with the following ClusterWorkspaceType objects: \n apiVersion: + tenancy.kcp.dev/v1alpha1 | apiVersion: tenancy.kcp.dev/v1alpha1 + kind: ClusterWorkspaceType | kind: ClusterWorkspaceType + metadata: | metadata: name: organization + \ | name: organization clusterName: root | + \ clusterName: root:something spec: | + spec: allowedSubWorkspaceTypes: [] | allowedSubWorkspaceTypes: + \ | - organization \n The Organization + type in root:something cannot add a type from higher in the chain, + only that type can allow itself to be a sub-type. \n However, extending + types from your own workspace is valid, as seen with the following + ClusterWorkspaceType objects: \n apiVersion: tenancy.kcp.dev/v1alpha1 + | apiVersion: tenancy.kcp.dev/v1alpha1 kind: ClusterWorkspaceType + \ | kind: ClusterWorkspaceType metadata: | + metadata: name: organization | name: team clusterName: + root | clusterName: root:something spec: | + spec: allowedSubWorkspaceTypes: [] | allowedSubWorkspaceTypes: + [] | apiVersion: tenancy.kcp.dev/v1alpha1 + \ | kind: ClusterWorkspaceType + \ | metadata: | + \ name: organization | clusterName: + root:something | spec: | + \ allowedSubWorkspaceTypes: | + \ - team \n The Organization type in root:something is allowed to + extend the allowed sub-types with a new Type that exists at the + same level in the hierarchy. \n Furthermore, co-locating aliases + is allowed, as is seen with the following ClusterWorkspaceType objects: + \n apiVersion: tenancy.kcp.dev/v1alpha1 | apiVersion: tenancy.kcp.dev/v1alpha1 + kind: ClusterWorkspaceType | kind: ClusterWorkspaceType + metadata: | metadata: name: organization + \ | name: team clusterName: team | + \ clusterName: root:something spec: | + spec: allowedSubWorkspaceTypes: | allowedSubWorkspaceTypes: + \ - name: team | - name: team \n The Team + type in root:something can now have nested ClusterWorkspaces created + of either type root:team *or* root:something:team. \n By default + no type is allowed. This means no other workspace can be nested + within a workspace of the given type." + items: + type: string + type: array + defaultSubWorkspaceType: + description: "defaultSubWorkspaceType is the ClusterWorkspaceType + that will be used by default if another, nested ClusterWorkspace + is created in a workspace of this type. The default behaviour requires + the user to specify a type. \n If a workspace type extends a type + definition of the same name at a higher level, the child workspace's + default is preferred. A ClusterWorkspaceType can only add a default + type from the same workspace. Not specifying defaultSubWorkspaceType + or an empty string means to inherit the value from the super-workspace." + type: string initializers: description: initializers are set of a ClusterWorkspace on creation and must be cleared by a controller before the workspace can be diff --git a/config/root/clusterworkspace-default.yaml b/config/root/clusterworkspace-default.yaml index 12accc2b9b8f..fe4a172d2c5c 100644 --- a/config/root/clusterworkspace-default.yaml +++ b/config/root/clusterworkspace-default.yaml @@ -3,4 +3,6 @@ kind: ClusterWorkspace metadata: name: default spec: - type: Organization + type: + name: Organization + path: root diff --git a/config/root/clusterworkspacetype-organization.yaml b/config/root/clusterworkspacetype-organization.yaml index 908e13a69bf7..faaa279e3d60 100644 --- a/config/root/clusterworkspacetype-organization.yaml +++ b/config/root/clusterworkspacetype-organization.yaml @@ -1,7 +1,11 @@ -apiVersion: tenancy.kcp.dev/v1alpha1 -kind: ClusterWorkspaceType -metadata: - name: organization -spec: - initializers: - - tenancy.kcp.dev/organization + apiVersion: tenancy.kcp.dev/v1alpha1 + kind: ClusterWorkspaceType + metadata: + name: organization + spec: + initializers: + - tenancy.kcp.dev/organization + defaultSubWorkspaceType: Team + allowedSubWorkspaceTypes: + - Team + - Universal \ No newline at end of file diff --git a/config/organization/clusterworkspacetype-team.yaml b/config/root/clusterworkspacetype-team.yaml similarity index 62% rename from config/organization/clusterworkspacetype-team.yaml rename to config/root/clusterworkspacetype-team.yaml index f6543dff180d..ca6224ce6171 100644 --- a/config/organization/clusterworkspacetype-team.yaml +++ b/config/root/clusterworkspacetype-team.yaml @@ -5,3 +5,6 @@ metadata: spec: initializers: - tenancy.kcp.dev/team + defaultSubWorkspaceType: Universal + allowedSubWorkspaceTypes: + - Universal \ No newline at end of file diff --git a/config/organization/clusterworkspacetype-universal.yaml b/config/root/clusterworkspacetype-universal.yaml similarity index 73% rename from config/organization/clusterworkspacetype-universal.yaml rename to config/root/clusterworkspacetype-universal.yaml index 0a8b1a1da947..7b9304eac6bd 100644 --- a/config/organization/clusterworkspacetype-universal.yaml +++ b/config/root/clusterworkspacetype-universal.yaml @@ -7,3 +7,6 @@ spec: - tenancy.kcp.dev/universal additionalWorkspaceLabels: workloads.kcp.dev/schedulable: "true" + defaultSubWorkspaceType: Universal + allowedSubWorkspaceTypes: + - Universal \ No newline at end of file diff --git a/config/root/clusterworkspacetype-use-clusterrole.yaml b/config/root/clusterworkspacetype-use-clusterrole.yaml new file mode 100644 index 000000000000..d674bbfd35c8 --- /dev/null +++ b/config/root/clusterworkspacetype-use-clusterrole.yaml @@ -0,0 +1,13 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: system:kcp:universal-clusterworkspacetype-use +rules: +- apiGroups: ["tenancy.kcp.dev"] + resources: + - "clusterworkspacetypes" + resourceNames: + - "universal" + - "organization" + - "team" + verbs: ["use"] diff --git a/config/root/clusterworkspacetype-use-clusterrolebinding.yaml b/config/root/clusterworkspacetype-use-clusterrolebinding.yaml new file mode 100644 index 000000000000..d9c5ddf0240d --- /dev/null +++ b/config/root/clusterworkspacetype-use-clusterrolebinding.yaml @@ -0,0 +1,12 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: system:kcp:authenticated:universal-clusterworkspacetype-use +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: system:kcp:universal-clusterworkspacetype-use +subjects: +- apiGroup: rbac.authorization.k8s.io + kind: Group + name: system:authenticated diff --git a/config/team/clusterworkspacetype-universal.yaml b/config/team/clusterworkspacetype-universal.yaml deleted file mode 100644 index 0a8b1a1da947..000000000000 --- a/config/team/clusterworkspacetype-universal.yaml +++ /dev/null @@ -1,9 +0,0 @@ -apiVersion: tenancy.kcp.dev/v1alpha1 -kind: ClusterWorkspaceType -metadata: - name: universal -spec: - initializers: - - tenancy.kcp.dev/universal - additionalWorkspaceLabels: - workloads.kcp.dev/schedulable: "true" diff --git a/pkg/admission/clusterworkspace/admission.go b/pkg/admission/clusterworkspace/admission.go index 54f310a9acff..a9ed3f54bb6c 100644 --- a/pkg/admission/clusterworkspace/admission.go +++ b/pkg/admission/clusterworkspace/admission.go @@ -107,7 +107,7 @@ func (o *clusterWorkspace) Validate(ctx context.Context, a admission.Attributes, if errs := validation.ValidateImmutableField(cw.Spec.Type, old.Spec.Type, field.NewPath("spec", "type")); len(errs) > 0 { return admission.NewForbidden(a, errs.ToAggregate()) } - if old.Spec.Type != cw.Spec.Type { + if old.Spec.Type.Path != cw.Spec.Type.Path || old.Spec.Type.Name != cw.Spec.Type.Name { return admission.NewForbidden(a, errors.New("spec.type is immutable")) } diff --git a/pkg/admission/clusterworkspace/admission_test.go b/pkg/admission/clusterworkspace/admission_test.go index 010feac5b399..3d2d5f03793d 100644 --- a/pkg/admission/clusterworkspace/admission_test.go +++ b/pkg/admission/clusterworkspace/admission_test.go @@ -76,7 +76,10 @@ func TestValidate(t *testing.T) { Name: "test", }, Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ - Type: "Foo", + Type: tenancyv1alpha1.ClusterWorkspaceTypeReference{ + Name: "Foo", + Path: "root:org", + }, }, }, &tenancyv1alpha1.ClusterWorkspace{ @@ -84,7 +87,10 @@ func TestValidate(t *testing.T) { Name: "test", }, Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ - Type: "Universal", + Type: tenancyv1alpha1.ClusterWorkspaceTypeReference{ + Name: "Universal", + Path: "root:org", + }, }, }), wantErr: true, @@ -96,7 +102,10 @@ func TestValidate(t *testing.T) { Name: "test", }, Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ - Type: "Foo", + Type: tenancyv1alpha1.ClusterWorkspaceTypeReference{ + Name: "Foo", + Path: "root:org", + }, }, Status: tenancyv1alpha1.ClusterWorkspaceStatus{ Location: tenancyv1alpha1.ClusterWorkspaceLocation{ @@ -108,7 +117,10 @@ func TestValidate(t *testing.T) { Name: "test", }, Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ - Type: "Foo", + Type: tenancyv1alpha1.ClusterWorkspaceTypeReference{ + Name: "Foo", + Path: "root:org", + }, }, Status: tenancyv1alpha1.ClusterWorkspaceStatus{ Location: tenancyv1alpha1.ClusterWorkspaceLocation{ @@ -125,7 +137,10 @@ func TestValidate(t *testing.T) { Name: "test", }, Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ - Type: "Foo", + Type: tenancyv1alpha1.ClusterWorkspaceTypeReference{ + Name: "Foo", + Path: "root:org", + }, }, Status: tenancyv1alpha1.ClusterWorkspaceStatus{}, }, @@ -134,7 +149,10 @@ func TestValidate(t *testing.T) { Name: "test", }, Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ - Type: "Foo", + Type: tenancyv1alpha1.ClusterWorkspaceTypeReference{ + Name: "Foo", + Path: "root:org", + }, }, Status: tenancyv1alpha1.ClusterWorkspaceStatus{ BaseURL: "https://cluster/clsuters/test", @@ -149,7 +167,10 @@ func TestValidate(t *testing.T) { Name: "test", }, Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ - Type: "Foo", + Type: tenancyv1alpha1.ClusterWorkspaceTypeReference{ + Name: "Foo", + Path: "root:org", + }, }, Status: tenancyv1alpha1.ClusterWorkspaceStatus{ Phase: tenancyv1alpha1.ClusterWorkspacePhaseReady, @@ -163,7 +184,10 @@ func TestValidate(t *testing.T) { Name: "test", }, Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ - Type: "Foo", + Type: tenancyv1alpha1.ClusterWorkspaceTypeReference{ + Name: "Foo", + Path: "root:org", + }, }, Status: tenancyv1alpha1.ClusterWorkspaceStatus{ Phase: tenancyv1alpha1.ClusterWorkspacePhaseInitializing, @@ -179,7 +203,10 @@ func TestValidate(t *testing.T) { Name: "test", }, Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ - Type: "Foo", + Type: tenancyv1alpha1.ClusterWorkspaceTypeReference{ + Name: "Foo", + Path: "root:org", + }, }, Status: tenancyv1alpha1.ClusterWorkspaceStatus{ Phase: tenancyv1alpha1.ClusterWorkspacePhaseReady, @@ -193,7 +220,10 @@ func TestValidate(t *testing.T) { Name: "test", }, Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ - Type: "Foo", + Type: tenancyv1alpha1.ClusterWorkspaceTypeReference{ + Name: "Foo", + Path: "root:org", + }, }, Status: tenancyv1alpha1.ClusterWorkspaceStatus{ Phase: tenancyv1alpha1.ClusterWorkspacePhaseInitializing, @@ -210,7 +240,10 @@ func TestValidate(t *testing.T) { Name: "test", }, Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ - Type: "Foo", + Type: tenancyv1alpha1.ClusterWorkspaceTypeReference{ + Name: "Foo", + Path: "root:org", + }, }, Status: tenancyv1alpha1.ClusterWorkspaceStatus{ Phase: tenancyv1alpha1.ClusterWorkspacePhaseReady, @@ -224,7 +257,10 @@ func TestValidate(t *testing.T) { Name: "test", }, Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ - Type: "Foo", + Type: tenancyv1alpha1.ClusterWorkspaceTypeReference{ + Name: "Foo", + Path: "root:org", + }, }, Status: tenancyv1alpha1.ClusterWorkspaceStatus{ Phase: tenancyv1alpha1.ClusterWorkspacePhaseScheduling, @@ -239,7 +275,10 @@ func TestValidate(t *testing.T) { Name: "test", }, Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ - Type: "Foo", + Type: tenancyv1alpha1.ClusterWorkspaceTypeReference{ + Name: "Foo", + Path: "root:org", + }, }, Status: tenancyv1alpha1.ClusterWorkspaceStatus{ Phase: tenancyv1alpha1.ClusterWorkspacePhaseReady, @@ -256,7 +295,10 @@ func TestValidate(t *testing.T) { Name: "test", }, Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ - Type: "Foo", + Type: tenancyv1alpha1.ClusterWorkspaceTypeReference{ + Name: "Foo", + Path: "root:org", + }, }, Status: tenancyv1alpha1.ClusterWorkspaceStatus{ Phase: tenancyv1alpha1.ClusterWorkspacePhaseReady, @@ -269,7 +311,10 @@ func TestValidate(t *testing.T) { Name: "test", }, Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ - Type: "Foo", + Type: tenancyv1alpha1.ClusterWorkspaceTypeReference{ + Name: "Foo", + Path: "root:org", + }, }, Status: tenancyv1alpha1.ClusterWorkspaceStatus{ Phase: tenancyv1alpha1.ClusterWorkspacePhaseScheduling, @@ -285,7 +330,10 @@ func TestValidate(t *testing.T) { Name: "test", }, Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ - Type: "Foo", + Type: tenancyv1alpha1.ClusterWorkspaceTypeReference{ + Name: "Foo", + Path: "root:org", + }, }, Status: tenancyv1alpha1.ClusterWorkspaceStatus{ Phase: tenancyv1alpha1.ClusterWorkspacePhaseInitializing, @@ -299,7 +347,10 @@ func TestValidate(t *testing.T) { Name: "test", }, Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ - Type: "Foo", + Type: tenancyv1alpha1.ClusterWorkspaceTypeReference{ + Name: "Foo", + Path: "root:org", + }, }, Status: tenancyv1alpha1.ClusterWorkspaceStatus{ Phase: tenancyv1alpha1.ClusterWorkspacePhaseReady, diff --git a/pkg/admission/clusterworkspaceshard/admission_test.go b/pkg/admission/clusterworkspaceshard/admission_test.go index 24634e4d0fbc..ab4608470d41 100644 --- a/pkg/admission/clusterworkspaceshard/admission_test.go +++ b/pkg/admission/clusterworkspaceshard/admission_test.go @@ -281,7 +281,9 @@ func TestAdmit(t *testing.T) { "name": "test", "creationTimestamp": nil, }, - "spec": map[string]interface{}{}, + "spec": map[string]interface{}{ + "type": map[string]interface{}{}, + }, "status": map[string]interface{}{ "location": map[string]interface{}{}, }, diff --git a/pkg/admission/clusterworkspacetypeexists/admission.go b/pkg/admission/clusterworkspacetypeexists/admission.go index c61af1960513..3dad19acb73a 100644 --- a/pkg/admission/clusterworkspacetypeexists/admission.go +++ b/pkg/admission/clusterworkspacetypeexists/admission.go @@ -24,7 +24,6 @@ import ( "strings" "github.com/kcp-dev/logicalcluster" - apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" @@ -63,6 +62,7 @@ func Register(plugins *admission.Plugins) { type clusterWorkspaceTypeExists struct { *admission.Handler typeLister tenancyv1alpha1lister.ClusterWorkspaceTypeLister + workspaceLister tenancyv1alpha1lister.ClusterWorkspaceLister kubeClusterClient *kubernetes.Cluster createAuthorizer delegated.DelegatedAuthorizerFactory @@ -97,22 +97,11 @@ func (o *clusterWorkspaceTypeExists) Admit(ctx context.Context, a admission.Attr return admission.NewForbidden(a, fmt.Errorf("not yet ready to handle request")) } - clusterName, err := genericapirequest.ClusterNameFrom(ctx) - if err != nil { - return apierrors.NewInternalError(err) - } - - cwt, err := o.typeLister.Get(clusters.ToClusterAwareKey(clusterName, strings.ToLower(cw.Spec.Type))) - if err != nil && apierrors.IsNotFound(err) { - if cw.Spec.Type == "Universal" { - return nil // Universal is always valid - } - return admission.NewForbidden(a, fmt.Errorf("spec.type %q does not exist", cw.Spec.Type)) - } else if err != nil { - return admission.NewForbidden(a, err) - } - if a.GetOperation() == admission.Create { + cwt, resolutionError := o.resolveClusterWorkspaceType(cw.Spec.Type) + if resolutionError != nil { + return admission.NewForbidden(a, resolutionError) + } addAdditionalWorkspaceLabels(cwt, cw) return updateUnstructured(u, cw) @@ -134,6 +123,11 @@ func (o *clusterWorkspaceTypeExists) Admit(ctx context.Context, a admission.Attr return fmt.Errorf("failed to convert unstructured to ClusterWorkspace: %w", err) } + cwt, resolutionError := o.resolveClusterWorkspaceType(cw.Spec.Type) + if resolutionError != nil { + return admission.NewForbidden(a, resolutionError) + } + // we only admit at state transition to initializing transitioningToInitializing := old.Status.Phase != tenancyv1alpha1.ClusterWorkspacePhaseInitializing && @@ -156,6 +150,40 @@ func (o *clusterWorkspaceTypeExists) Admit(ctx context.Context, a admission.Attr return updateUnstructured(u, cw) } +func (o *clusterWorkspaceTypeExists) resolveValidClusterWorkspaceType(parentClusterName logicalcluster.Name, ref tenancyv1alpha1.ClusterWorkspaceTypeReference) (*tenancyv1alpha1.ClusterWorkspaceType, error) { + cwt, resolutionError := o.resolveClusterWorkspaceType(ref) + if resolutionError != nil { + return nil, resolutionError + } + grandparent, parentHasParents := parentClusterName.Parent() + if !parentHasParents { + // the clusterWorkspace exists in the root logical cluster, and therefore there is no + // higher clusterWorkspaceType to check for allowed sub-types; the mere presence of the + // clusterWorkspaceType is enough + return cwt, nil + } + parentCluster, err := o.workspaceLister.Get(clusters.ToClusterAwareKey(grandparent, parentClusterName.Base())) + if err != nil { + return nil, fmt.Errorf("could not resolve parent cluster workspace %q: %w", parentClusterName.String(), err) + } + parentCwt, resolutionError := o.resolveClusterWorkspaceType(parentCluster.Spec.Type) + if resolutionError != nil { + return nil, fmt.Errorf("could not resolve type %q of parent cluster workspace %q: %w", parentCluster.Spec.Type.String(), parentClusterName.String(), err) + } + if !sets.NewString(parentCwt.Spec.AllowedSubWorkspaceTypes...).Has(ref.Name) { + return nil, fmt.Errorf("parent cluster workspace %q (of type %s) does not allow for sub-workspaces of type %s", parentClusterName, parentCluster.Spec.Type.String(), ref.String()) + } + return cwt, nil +} + +func (o *clusterWorkspaceTypeExists) resolveClusterWorkspaceType(ref tenancyv1alpha1.ClusterWorkspaceTypeReference) (*tenancyv1alpha1.ClusterWorkspaceType, error) { + cwt, err := o.typeLister.Get(clusters.ToClusterAwareKey(logicalcluster.New(ref.Path), strings.ToLower(ref.Name))) + if apierrors.IsNotFound(err) { + return nil, fmt.Errorf("spec.type %q does not exist", ref.String()) + } + return cwt, err +} + // Validate ensures that // - has a valid type // - has valid initializers when transitioning to initializing @@ -219,14 +247,10 @@ func (o *clusterWorkspaceTypeExists) Validate(ctx context.Context, a admission.A return apierrors.NewInternalError(err) } - cwt, err = o.typeLister.Get(clusters.ToClusterAwareKey(clusterName, strings.ToLower(cw.Spec.Type))) - if err != nil && apierrors.IsNotFound(err) { - if cw.Spec.Type == "Universal" { - return nil // Universal is always valid - } - return admission.NewForbidden(a, fmt.Errorf("spec.type %q does not exist", cw.Spec.Type)) - } else if err != nil { - return admission.NewForbidden(a, err) + var resolutionError error + cwt, resolutionError = o.resolveValidClusterWorkspaceType(clusterName, cw.Spec.Type) + if resolutionError != nil { + return admission.NewForbidden(a, resolutionError) } } @@ -275,12 +299,20 @@ func (o *clusterWorkspaceTypeExists) ValidateInitialization() error { if o.typeLister == nil { return fmt.Errorf(PluginName + " plugin needs an ClusterWorkspaceType lister") } + if o.workspaceLister == nil { + return fmt.Errorf(PluginName + " plugin needs an ClusterWorkspace lister") + } return nil } func (o *clusterWorkspaceTypeExists) SetKcpInformers(informers kcpinformers.SharedInformerFactory) { - o.SetReadyFunc(informers.Tenancy().V1alpha1().ClusterWorkspaceTypes().Informer().HasSynced) + typesReady := informers.Tenancy().V1alpha1().ClusterWorkspaceTypes().Informer().HasSynced + workspacesReady := informers.Tenancy().V1alpha1().ClusterWorkspaces().Informer().HasSynced + o.SetReadyFunc(func() bool { + return typesReady() && workspacesReady() + }) o.typeLister = informers.Tenancy().V1alpha1().ClusterWorkspaceTypes().Lister() + o.workspaceLister = informers.Tenancy().V1alpha1().ClusterWorkspaces().Lister() } func (o *clusterWorkspaceTypeExists) SetKubeClusterClient(kubeClusterClient *kubernetes.Cluster) { diff --git a/pkg/admission/clusterworkspacetypeexists/admission_test.go b/pkg/admission/clusterworkspacetypeexists/admission_test.go index dacaf2abf37c..6e5820715216 100644 --- a/pkg/admission/clusterworkspacetypeexists/admission_test.go +++ b/pkg/admission/clusterworkspacetypeexists/admission_test.go @@ -19,6 +19,7 @@ package clusterworkspacetypeexists import ( "context" "errors" + "fmt" "testing" "github.com/kcp-dev/logicalcluster" @@ -98,7 +99,10 @@ func TestAdmit(t *testing.T) { Name: "test", }, Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ - Type: "Foo", + Type: tenancyv1alpha1.ClusterWorkspaceTypeReference{ + Name: "Foo", + Path: "root:org", + }, }, Status: tenancyv1alpha1.ClusterWorkspaceStatus{ Phase: tenancyv1alpha1.ClusterWorkspacePhaseInitializing, @@ -112,7 +116,10 @@ func TestAdmit(t *testing.T) { Name: "test", }, Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ - Type: "Foo", + Type: tenancyv1alpha1.ClusterWorkspaceTypeReference{ + Name: "Foo", + Path: "root:org", + }, }, Status: tenancyv1alpha1.ClusterWorkspaceStatus{ Phase: tenancyv1alpha1.ClusterWorkspacePhaseScheduling, @@ -124,7 +131,10 @@ func TestAdmit(t *testing.T) { Name: "test", }, Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ - Type: "Foo", + Type: tenancyv1alpha1.ClusterWorkspaceTypeReference{ + Name: "Foo", + Path: "root:org", + }, }, Status: tenancyv1alpha1.ClusterWorkspaceStatus{ Phase: tenancyv1alpha1.ClusterWorkspacePhaseInitializing, @@ -151,7 +161,10 @@ func TestAdmit(t *testing.T) { Name: "test", }, Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ - Type: "Foo", + Type: tenancyv1alpha1.ClusterWorkspaceTypeReference{ + Name: "Foo", + Path: "root:org", + }, }, Status: tenancyv1alpha1.ClusterWorkspaceStatus{ Phase: tenancyv1alpha1.ClusterWorkspacePhaseReady, @@ -165,7 +178,10 @@ func TestAdmit(t *testing.T) { Name: "test", }, Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ - Type: "Foo", + Type: tenancyv1alpha1.ClusterWorkspaceTypeReference{ + Name: "Foo", + Path: "root:org", + }, }, Status: tenancyv1alpha1.ClusterWorkspaceStatus{ Phase: tenancyv1alpha1.ClusterWorkspacePhaseScheduling, @@ -177,52 +193,13 @@ func TestAdmit(t *testing.T) { Name: "test", }, Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ - Type: "Foo", - }, - Status: tenancyv1alpha1.ClusterWorkspaceStatus{ - Phase: tenancyv1alpha1.ClusterWorkspacePhaseReady, - Location: tenancyv1alpha1.ClusterWorkspaceLocation{Current: "somewhere"}, - BaseURL: "https://kcp.bigcorp.com/clusters/org:test", - }, - }, - }, - { - name: "does nothing for universal type", - a: updateAttr(&tenancyv1alpha1.ClusterWorkspace{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - }, - Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ - Type: "Universal", - }, - Status: tenancyv1alpha1.ClusterWorkspaceStatus{ - Phase: tenancyv1alpha1.ClusterWorkspacePhaseInitializing, - Initializers: []tenancyv1alpha1.ClusterWorkspaceInitializer{}, - Location: tenancyv1alpha1.ClusterWorkspaceLocation{Current: "somewhere"}, - BaseURL: "https://kcp.bigcorp.com/clusters/org:test", - }, - }, - &tenancyv1alpha1.ClusterWorkspace{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", + Type: tenancyv1alpha1.ClusterWorkspaceTypeReference{ + Name: "Foo", + Path: "root:org", }, - Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ - Type: "Universal", - }, - Status: tenancyv1alpha1.ClusterWorkspaceStatus{ - Phase: tenancyv1alpha1.ClusterWorkspacePhaseScheduling, - Initializers: []tenancyv1alpha1.ClusterWorkspaceInitializer{}, - }, - }), - expectedObj: &tenancyv1alpha1.ClusterWorkspace{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - }, - Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ - Type: "Universal", }, Status: tenancyv1alpha1.ClusterWorkspaceStatus{ - Phase: tenancyv1alpha1.ClusterWorkspacePhaseInitializing, + Phase: tenancyv1alpha1.ClusterWorkspacePhaseReady, Location: tenancyv1alpha1.ClusterWorkspaceLocation{Current: "somewhere"}, BaseURL: "https://kcp.bigcorp.com/clusters/org:test", }, @@ -289,7 +266,10 @@ func TestAdmit(t *testing.T) { }, }, Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ - Type: "Foo", + Type: tenancyv1alpha1.ClusterWorkspaceTypeReference{ + Name: "Foo", + Path: "root:org", + }, }, }), expectedObj: &tenancyv1alpha1.ClusterWorkspace{ @@ -301,7 +281,10 @@ func TestAdmit(t *testing.T) { }, }, Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ - Type: "Foo", + Type: tenancyv1alpha1.ClusterWorkspaceTypeReference{ + Name: "Foo", + Path: "root:org", + }, }, }, }, @@ -312,7 +295,7 @@ func TestAdmit(t *testing.T) { Handler: admission.NewHandler(admission.Create, admission.Update), typeLister: fakeClusterWorkspaceTypeLister(tt.types), } - ctx := request.WithCluster(context.Background(), request.Cluster{Name: logicalcluster.New("root:org")}) + ctx := request.WithCluster(context.Background(), request.Cluster{Name: logicalcluster.New("root:org:ws")}) if err := o.Admit(ctx, tt.a, nil); (err != nil) != tt.wantErr { t.Fatalf("Validate() error = %v, wantErr %v", err, tt.wantErr) } else if err == nil { @@ -329,9 +312,11 @@ func TestAdmit(t *testing.T) { func TestValidate(t *testing.T) { tests := []struct { - name string - types []*tenancyv1alpha1.ClusterWorkspaceType - attr admission.Attributes + name string + types []*tenancyv1alpha1.ClusterWorkspaceType + workspaces []*tenancyv1alpha1.ClusterWorkspace + attr admission.Attributes + path logicalcluster.Name authzDecision authorizer.Decision authzError error @@ -340,7 +325,29 @@ func TestValidate(t *testing.T) { }{ { name: "passes create if type exists", + path: logicalcluster.New("root:org:ws"), + workspaces: []*tenancyv1alpha1.ClusterWorkspace{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "root:org#$#ws", + }, + Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ + Type: tenancyv1alpha1.ClusterWorkspaceTypeReference{ + Name: "Parent", + Path: "root:org", + }, + }, + }, + }, types: []*tenancyv1alpha1.ClusterWorkspaceType{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "root:org#$#parent", + }, + Spec: tenancyv1alpha1.ClusterWorkspaceTypeSpec{ + AllowedSubWorkspaceTypes: []string{"Foo"}, + }, + }, { ObjectMeta: metav1.ObjectMeta{ Name: "root:org#$#foo", @@ -352,29 +359,22 @@ func TestValidate(t *testing.T) { Name: "test", }, Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ - Type: "Foo", + Type: tenancyv1alpha1.ClusterWorkspaceTypeReference{ + Name: "Foo", + Path: "root:org", + }, }, }), authzDecision: authorizer.DecisionAllow, }, { - name: "fails if type does not exists", - attr: createAttr(&tenancyv1alpha1.ClusterWorkspace{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - }, - Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ - Type: "Foo", - }, - }), - wantErr: true, - }, - { - name: "fails if type only exists in different workspace", + name: "passes create if parent type missing but parent workspace is root", + path: logicalcluster.New("root"), + workspaces: []*tenancyv1alpha1.ClusterWorkspace{}, types: []*tenancyv1alpha1.ClusterWorkspaceType{ { ObjectMeta: metav1.ObjectMeta{ - Name: "root:bigcorp#$#foo", + Name: "root#$#foo", }, }, }, @@ -383,80 +383,208 @@ func TestValidate(t *testing.T) { Name: "test", }, Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ - Type: "Foo", + Type: tenancyv1alpha1.ClusterWorkspaceTypeReference{ + Name: "Foo", + Path: "root", + }, }, }), - wantErr: true, + authzDecision: authorizer.DecisionAllow, }, { - name: "fails if not allowed", + name: "fails if type does not exists", + path: logicalcluster.New("root:org:ws"), attr: createAttr(&tenancyv1alpha1.ClusterWorkspace{ ObjectMeta: metav1.ObjectMeta{ Name: "test", }, Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ - Type: "Foo", + Type: tenancyv1alpha1.ClusterWorkspaceTypeReference{ + Name: "Foo", + Path: "root:org", + }, }, }), - authzDecision: authorizer.DecisionNoOpinion, - wantErr: true, + wantErr: true, }, { - name: "fails if denied", + name: "fails if type only exists in unrelated workspace", + path: logicalcluster.New("root:org:ws"), + workspaces: []*tenancyv1alpha1.ClusterWorkspace{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "root:org#$#ws", + }, + Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ + Type: tenancyv1alpha1.ClusterWorkspaceTypeReference{ + Name: "Parent", + Path: "root:org", + }, + }, + }, + }, + types: []*tenancyv1alpha1.ClusterWorkspaceType{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "root:org#$#parent", + }, + Spec: tenancyv1alpha1.ClusterWorkspaceTypeSpec{ + AllowedSubWorkspaceTypes: []string{"Foo"}, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "root:bigcorp#$#foo", + }, + }, + }, attr: createAttr(&tenancyv1alpha1.ClusterWorkspace{ ObjectMeta: metav1.ObjectMeta{ Name: "test", }, Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ - Type: "Foo", + Type: tenancyv1alpha1.ClusterWorkspaceTypeReference{ + Name: "Foo", + Path: "root:org", + }, }, }), - authzDecision: authorizer.DecisionDeny, - wantErr: true, + wantErr: true, }, { - name: "fails if authz error", + name: "fails if type doesn't allow sub-workspaces", + path: logicalcluster.New("root:org:ws"), + workspaces: []*tenancyv1alpha1.ClusterWorkspace{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "root:org#$#ws", + }, + Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ + Type: tenancyv1alpha1.ClusterWorkspaceTypeReference{ + Name: "Parent", + Path: "root:org", + }, + }, + }, + }, + types: []*tenancyv1alpha1.ClusterWorkspaceType{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "root:org#$#parent", + }, + Spec: tenancyv1alpha1.ClusterWorkspaceTypeSpec{ + AllowedSubWorkspaceTypes: []string{}, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "root:org#$#foo", + }, + }, + }, attr: createAttr(&tenancyv1alpha1.ClusterWorkspace{ ObjectMeta: metav1.ObjectMeta{ Name: "test", }, Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ - Type: "Foo", + Type: tenancyv1alpha1.ClusterWorkspaceTypeReference{ + Name: "Foo", + Path: "root:org", + }, }, }), - authzError: errors.New("authorizer error"), - wantErr: true, + wantErr: true, }, { - name: "Universal always exists implicitly if authorized", + name: "fails if not allowed", + path: logicalcluster.New("root:org:ws"), attr: createAttr(&tenancyv1alpha1.ClusterWorkspace{ ObjectMeta: metav1.ObjectMeta{ Name: "test", }, Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ - Type: "Universal", + Type: tenancyv1alpha1.ClusterWorkspaceTypeReference{ + Name: "Foo", + Path: "root:org", + }, }, }), - authzDecision: authorizer.DecisionAllow, + authzDecision: authorizer.DecisionNoOpinion, + wantErr: true, }, { - name: "Universal fails if not authorized", + name: "fails if denied", + path: logicalcluster.New("root:org:ws"), + workspaces: []*tenancyv1alpha1.ClusterWorkspace{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "root:org#$#ws", + }, + Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ + Type: tenancyv1alpha1.ClusterWorkspaceTypeReference{ + Name: "Parent", + Path: "root:org", + }, + }, + }, + }, + types: []*tenancyv1alpha1.ClusterWorkspaceType{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "root:org#$#parent", + }, + Spec: tenancyv1alpha1.ClusterWorkspaceTypeSpec{ + AllowedSubWorkspaceTypes: []string{"Foo"}, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "root:org#$#foo", + }, + }, + }, attr: createAttr(&tenancyv1alpha1.ClusterWorkspace{ ObjectMeta: metav1.ObjectMeta{ Name: "test", }, Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ - Type: "Universal", + Type: tenancyv1alpha1.ClusterWorkspaceTypeReference{ + Name: "Foo", + Path: "root:org", + }, }, }), - authzDecision: authorizer.DecisionNoOpinion, + authzDecision: authorizer.DecisionDeny, + wantErr: true, }, { - name: "Universal works too when it exists", + name: "fails if authz error", + path: logicalcluster.New("root:org:ws"), + workspaces: []*tenancyv1alpha1.ClusterWorkspace{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "root:org#$#ws", + }, + Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ + Type: tenancyv1alpha1.ClusterWorkspaceTypeReference{ + Name: "Parent", + Path: "root:org", + }, + }, + }, + }, types: []*tenancyv1alpha1.ClusterWorkspaceType{ { ObjectMeta: metav1.ObjectMeta{ - Name: "root:org#$#universal", + Name: "root:org#$#parent", + }, + Spec: tenancyv1alpha1.ClusterWorkspaceTypeSpec{ + AllowedSubWorkspaceTypes: []string{"Foo"}, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "root:org#$#foo", }, }, }, @@ -465,14 +593,40 @@ func TestValidate(t *testing.T) { Name: "test", }, Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ - Type: "Universal", + Type: tenancyv1alpha1.ClusterWorkspaceTypeReference{ + Name: "Foo", + Path: "root:org", + }, }, }), - authzDecision: authorizer.DecisionAllow, + authzError: errors.New("authorizer error"), + wantErr: true, }, { name: "validates initializers on phase transition", + path: logicalcluster.New("root:org:ws"), + workspaces: []*tenancyv1alpha1.ClusterWorkspace{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "root:org#$#ws", + }, + Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ + Type: tenancyv1alpha1.ClusterWorkspaceTypeReference{ + Name: "Parent", + Path: "root:org", + }, + }, + }, + }, types: []*tenancyv1alpha1.ClusterWorkspaceType{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "root:org#$#parent", + }, + Spec: tenancyv1alpha1.ClusterWorkspaceTypeSpec{ + AllowedSubWorkspaceTypes: []string{"Foo"}, + }, + }, { ObjectMeta: metav1.ObjectMeta{ Name: "root:org#$#foo", @@ -487,7 +641,10 @@ func TestValidate(t *testing.T) { Name: "test", }, Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ - Type: "Foo", + Type: tenancyv1alpha1.ClusterWorkspaceTypeReference{ + Name: "Foo", + Path: "root:org", + }, }, Status: tenancyv1alpha1.ClusterWorkspaceStatus{ Phase: tenancyv1alpha1.ClusterWorkspacePhaseInitializing, @@ -499,7 +656,10 @@ func TestValidate(t *testing.T) { Name: "test", }, Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ - Type: "Foo", + Type: tenancyv1alpha1.ClusterWorkspaceTypeReference{ + Name: "Foo", + Path: "root:org", + }, }, Status: tenancyv1alpha1.ClusterWorkspaceStatus{ Phase: tenancyv1alpha1.ClusterWorkspacePhaseScheduling, @@ -509,7 +669,29 @@ func TestValidate(t *testing.T) { }, { name: "passes with all initializers or more on phase transition", + path: logicalcluster.New("root:org:ws"), + workspaces: []*tenancyv1alpha1.ClusterWorkspace{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "root:org#$#ws", + }, + Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ + Type: tenancyv1alpha1.ClusterWorkspaceTypeReference{ + Name: "Parent", + Path: "root:org", + }, + }, + }, + }, types: []*tenancyv1alpha1.ClusterWorkspaceType{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "root:org#$#parent", + }, + Spec: tenancyv1alpha1.ClusterWorkspaceTypeSpec{ + AllowedSubWorkspaceTypes: []string{"Foo"}, + }, + }, { ObjectMeta: metav1.ObjectMeta{ Name: "root:org#$#foo", @@ -525,7 +707,10 @@ func TestValidate(t *testing.T) { Name: "test", }, Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ - Type: "Foo", + Type: tenancyv1alpha1.ClusterWorkspaceTypeReference{ + Name: "Foo", + Path: "root:org", + }, }, Status: tenancyv1alpha1.ClusterWorkspaceStatus{ Phase: tenancyv1alpha1.ClusterWorkspacePhaseInitializing, @@ -539,7 +724,10 @@ func TestValidate(t *testing.T) { Name: "test", }, Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ - Type: "Foo", + Type: tenancyv1alpha1.ClusterWorkspaceTypeReference{ + Name: "Foo", + Path: "root:org", + }, }, Status: tenancyv1alpha1.ClusterWorkspaceStatus{ Phase: tenancyv1alpha1.ClusterWorkspacePhaseScheduling, @@ -548,6 +736,7 @@ func TestValidate(t *testing.T) { }, { name: "ignores different resources", + path: logicalcluster.New("root:org:ws"), types: nil, attr: admission.NewAttributesRecord( &tenancyv1alpha1.ClusterWorkspaceShard{ @@ -571,8 +760,9 @@ func TestValidate(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { o := &clusterWorkspaceTypeExists{ - Handler: admission.NewHandler(admission.Create, admission.Update), - typeLister: fakeClusterWorkspaceTypeLister(tt.types), + Handler: admission.NewHandler(admission.Create, admission.Update), + typeLister: fakeClusterWorkspaceTypeLister(tt.types), + workspaceLister: fakeClusterWorkspaceLister(tt.workspaces), createAuthorizer: func(clusterName logicalcluster.Name, client kubernetes.ClusterInterface) (authorizer.Authorizer, error) { return &fakeAuthorizer{ tt.authzDecision, @@ -580,7 +770,7 @@ func TestValidate(t *testing.T) { }, nil }, } - ctx := request.WithCluster(context.Background(), request.Cluster{Name: logicalcluster.New("root:org")}) + ctx := request.WithCluster(context.Background(), request.Cluster{Name: tt.path}) if err := o.Validate(ctx, tt.attr, nil); (err != nil) != tt.wantErr { t.Fatalf("Validate() error = %v, wantErr %v", err, tt.wantErr) } @@ -604,6 +794,7 @@ func (l fakeClusterWorkspaceTypeLister) Get(name string) (*tenancyv1alpha1.Clust func (l fakeClusterWorkspaceTypeLister) GetWithContext(ctx context.Context, name string) (*tenancyv1alpha1.ClusterWorkspaceType, error) { for _, t := range l { + fmt.Printf("checking for %s against %s (%v)\n", name, t.Name, t.Name == name) if t.Name == name { return t, nil } @@ -611,6 +802,29 @@ func (l fakeClusterWorkspaceTypeLister) GetWithContext(ctx context.Context, name return nil, apierrors.NewNotFound(tenancyv1alpha1.Resource("clusterworkspacetype"), name) } +type fakeClusterWorkspaceLister []*tenancyv1alpha1.ClusterWorkspace + +func (l fakeClusterWorkspaceLister) List(selector labels.Selector) (ret []*tenancyv1alpha1.ClusterWorkspace, err error) { + return l.ListWithContext(context.Background(), selector) +} + +func (l fakeClusterWorkspaceLister) ListWithContext(ctx context.Context, selector labels.Selector) (ret []*tenancyv1alpha1.ClusterWorkspace, err error) { + return l, nil +} + +func (l fakeClusterWorkspaceLister) Get(name string) (*tenancyv1alpha1.ClusterWorkspace, error) { + return l.GetWithContext(context.Background(), name) +} + +func (l fakeClusterWorkspaceLister) GetWithContext(ctx context.Context, name string) (*tenancyv1alpha1.ClusterWorkspace, error) { + for _, t := range l { + if t.Name == name { + return t, nil + } + } + return nil, apierrors.NewNotFound(tenancyv1alpha1.Resource("clusterworkspace"), name) +} + type fakeAuthorizer struct { authorized authorizer.Decision err error diff --git a/pkg/apis/tenancy/projection/workspaces.go b/pkg/apis/tenancy/projection/workspaces.go index 14f356a97f8c..aba05e511b1c 100644 --- a/pkg/apis/tenancy/projection/workspaces.go +++ b/pkg/apis/tenancy/projection/workspaces.go @@ -23,7 +23,7 @@ import ( func ProjectClusterWorkspaceToWorkspace(from *v1alpha1.ClusterWorkspace, to *v1beta1.Workspace) { to.ObjectMeta = from.ObjectMeta - to.Spec.Type = from.Spec.Type + to.Spec.Type = from.Spec.Type.Name to.Status.URL = from.Status.BaseURL to.Status.Phase = from.Status.Phase } diff --git a/pkg/apis/tenancy/v1alpha1/types.go b/pkg/apis/tenancy/v1alpha1/types.go index ae9eb1d702c7..8c65a835a389 100644 --- a/pkg/apis/tenancy/v1alpha1/types.go +++ b/pkg/apis/tenancy/v1alpha1/types.go @@ -75,17 +75,34 @@ type ClusterWorkspaceSpec struct { // type defines properties of the workspace both on creation (e.g. initial // resources and initially installed APIs) and during runtime (e.g. permissions). // - // The type is a reference to a ClusterWorkspaceType in the same workspace - // with the same name, but lower-cased. The ClusterWorkspaceType existence is - // validated at admission during creation, with the exception of the - // "Universal" type whose existence is not required but respected if it exists. - // The type is immutable after creation. The use of a type is gated via - // the RBAC clusterworkspacetypes/use resource permission. + // The type is a reference to a ClusterWorkspaceType in the listed workspace, but + // lower-cased. The ClusterWorkspaceType existence is validated at admission during + // creation, with the exception of the "Universal" type whose existence is not + // required but respected if it exists. The type is immutable after creation. The + // use of a type is gated via the RBAC clusterworkspacetypes/use resource permission. // - // +optional - // +kubebuilder:default:="Universal" + // +required + Type ClusterWorkspaceTypeReference `json:"type,omitempty"` +} + +// ClusterWorkspaceTypeReference is a globally unique, fully qualified reference to a +// cluster workspace type. +type ClusterWorkspaceTypeReference struct { + // name is the name of the ClusterWorkspaceType + // + // +required // +kubebuilder:validation:Pattern=`^[A-Z][a-zA-Z0-9]+$` - Type string `json:"type,omitempty"` + Name string `json:"name,omitempty"` + + // path is an absolute reference to the workspace that owns this type, e.g. root:org:ws. + // + // +required + // +kubebuilder:validation:Pattern:="^root(:[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$" + Path string `json:"path,omitempty"` +} + +func (r ClusterWorkspaceTypeReference) String() string { + return r.Path + "|" + r.Name } // ClusterWorkspaceType specifies behaviour of workspaces of this type. @@ -117,6 +134,81 @@ type ClusterWorkspaceTypeSpec struct { // // +optional AdditionalWorkspaceLabels map[string]string `json:"additionalWorkspaceLabels,omitempty"` + + // defaultSubWorkspaceType is the ClusterWorkspaceType that will be used + // by default if another, nested ClusterWorkspace is created in a workspace + // of this type. The default behaviour requires the user to specify a type. + // + // If a workspace type extends a type definition of the same name at a higher level, + // the child workspace's default is preferred. A ClusterWorkspaceType can only add a + // default type from the same workspace. Not specifying defaultSubWorkspaceType or an + // empty string means to inherit the value from the super-workspace. + // + // +optional + DefaultSubWorkspaceType string `json:"defaultSubWorkspaceType,omitempty"` + + // allowedSubWorkspaceTypes is a list of ClusterWorkspaceTypes that can be + // created in a workspace of this type. + // + // If a workspace type extends a type definition of the same name at a higher level, + // the sets of allowed sub-workspace types are merged. A ClusterWorkspaceType can only + // add allowed sub-workspace types from the same workspace. + // + // For example, extending types from parent workspaces is not allowed, as seen + // with the following ClusterWorkspaceType objects: + // + // apiVersion: tenancy.kcp.dev/v1alpha1 | apiVersion: tenancy.kcp.dev/v1alpha1 + // kind: ClusterWorkspaceType | kind: ClusterWorkspaceType + // metadata: | metadata: + // name: organization | name: organization + // clusterName: root | clusterName: root:something + // spec: | spec: + // allowedSubWorkspaceTypes: [] | allowedSubWorkspaceTypes: + // | - organization + // + // The Organization type in root:something cannot add a type from higher in the chain, + // only that type can allow itself to be a sub-type. + // + // However, extending types from your own workspace is valid, as seen + // with the following ClusterWorkspaceType objects: + // + // apiVersion: tenancy.kcp.dev/v1alpha1 | apiVersion: tenancy.kcp.dev/v1alpha1 + // kind: ClusterWorkspaceType | kind: ClusterWorkspaceType + // metadata: | metadata: + // name: organization | name: team + // clusterName: root | clusterName: root:something + // spec: | spec: + // allowedSubWorkspaceTypes: [] | allowedSubWorkspaceTypes: [] + // | apiVersion: tenancy.kcp.dev/v1alpha1 + // | kind: ClusterWorkspaceType + // | metadata: + // | name: organization + // | clusterName: root:something + // | spec: + // | allowedSubWorkspaceTypes: + // | - team + // + // The Organization type in root:something is allowed to extend the allowed sub-types + // with a new Type that exists at the same level in the hierarchy. + // + // Furthermore, co-locating aliases is allowed, as is seen + // with the following ClusterWorkspaceType objects: + // + // apiVersion: tenancy.kcp.dev/v1alpha1 | apiVersion: tenancy.kcp.dev/v1alpha1 + // kind: ClusterWorkspaceType | kind: ClusterWorkspaceType + // metadata: | metadata: + // name: organization | name: team + // clusterName: team | clusterName: root:something + // spec: | spec: + // allowedSubWorkspaceTypes: | allowedSubWorkspaceTypes: + // - name: team | - name: team + // + // The Team type in root:something can now have nested ClusterWorkspaces created + // of either type root:team *or* root:something:team. + // + // By default no type is allowed. This means no other workspace can be nested + // within a workspace of the given type. + AllowedSubWorkspaceTypes []string `json:"allowedSubWorkspaceTypes,omitempty"` } // ClusterWorkspaceTypeList is a list of cluster workspace types diff --git a/pkg/apis/tenancy/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/tenancy/v1alpha1/zz_generated.deepcopy.go index fda599a9846f..a289bd75c128 100644 --- a/pkg/apis/tenancy/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/tenancy/v1alpha1/zz_generated.deepcopy.go @@ -215,6 +215,7 @@ func (in *ClusterWorkspaceShardStatus) DeepCopy() *ClusterWorkspaceShardStatus { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ClusterWorkspaceSpec) DeepCopyInto(out *ClusterWorkspaceSpec) { *out = *in + out.Type = in.Type return } @@ -317,6 +318,22 @@ func (in *ClusterWorkspaceTypeList) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClusterWorkspaceTypeReference) DeepCopyInto(out *ClusterWorkspaceTypeReference) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterWorkspaceTypeReference. +func (in *ClusterWorkspaceTypeReference) DeepCopy() *ClusterWorkspaceTypeReference { + if in == nil { + return nil + } + out := new(ClusterWorkspaceTypeReference) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ClusterWorkspaceTypeSpec) DeepCopyInto(out *ClusterWorkspaceTypeSpec) { *out = *in @@ -332,6 +349,11 @@ func (in *ClusterWorkspaceTypeSpec) DeepCopyInto(out *ClusterWorkspaceTypeSpec) (*out)[key] = val } } + if in.AllowedSubWorkspaceTypes != nil { + in, out := &in.AllowedSubWorkspaceTypes, &out.AllowedSubWorkspaceTypes + *out = make([]string, len(*in)) + copy(*out, *in) + } return } diff --git a/pkg/openapi/zz_generated.openapi.go b/pkg/openapi/zz_generated.openapi.go index d6b23fd40058..e97fb1c5fc00 100644 --- a/pkg/openapi/zz_generated.openapi.go +++ b/pkg/openapi/zz_generated.openapi.go @@ -82,6 +82,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA "github.com/kcp-dev/kcp/pkg/apis/tenancy/v1alpha1.ClusterWorkspaceStatus": schema_pkg_apis_tenancy_v1alpha1_ClusterWorkspaceStatus(ref), "github.com/kcp-dev/kcp/pkg/apis/tenancy/v1alpha1.ClusterWorkspaceType": schema_pkg_apis_tenancy_v1alpha1_ClusterWorkspaceType(ref), "github.com/kcp-dev/kcp/pkg/apis/tenancy/v1alpha1.ClusterWorkspaceTypeList": schema_pkg_apis_tenancy_v1alpha1_ClusterWorkspaceTypeList(ref), + "github.com/kcp-dev/kcp/pkg/apis/tenancy/v1alpha1.ClusterWorkspaceTypeReference": schema_pkg_apis_tenancy_v1alpha1_ClusterWorkspaceTypeReference(ref), "github.com/kcp-dev/kcp/pkg/apis/tenancy/v1alpha1.ClusterWorkspaceTypeSpec": schema_pkg_apis_tenancy_v1alpha1_ClusterWorkspaceTypeSpec(ref), "github.com/kcp-dev/kcp/pkg/apis/tenancy/v1beta1.Workspace": schema_pkg_apis_tenancy_v1beta1_Workspace(ref), "github.com/kcp-dev/kcp/pkg/apis/tenancy/v1beta1.WorkspaceList": schema_pkg_apis_tenancy_v1beta1_WorkspaceList(ref), @@ -2465,14 +2466,16 @@ func schema_pkg_apis_tenancy_v1alpha1_ClusterWorkspaceSpec(ref common.ReferenceC }, "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).\n\nThe type is a reference to a ClusterWorkspaceType in the same workspace with the same name, but lower-cased. The ClusterWorkspaceType existence is validated at admission during creation, with the exception of the \"Universal\" type whose existence is not required but respected if it exists. The type is immutable after creation. The use of a type is gated via the RBAC clusterworkspacetypes/use resource permission.", - Type: []string{"string"}, - Format: "", + Description: "type defines properties of the workspace both on creation (e.g. initial resources and initially installed APIs) and during runtime (e.g. permissions).\n\nThe type is a reference to a ClusterWorkspaceType in the listed workspace, but lower-cased. The ClusterWorkspaceType existence is validated at admission during creation, with the exception of the \"Universal\" type whose existence is not required but respected if it exists. The type is immutable after creation. The use of a type is gated via the RBAC clusterworkspacetypes/use resource permission.", + Default: map[string]interface{}{}, + Ref: ref("github.com/kcp-dev/kcp/pkg/apis/tenancy/v1alpha1.ClusterWorkspaceTypeReference"), }, }, }, }, }, + Dependencies: []string{ + "github.com/kcp-dev/kcp/pkg/apis/tenancy/v1alpha1.ClusterWorkspaceTypeReference"}, } } @@ -2631,6 +2634,33 @@ func schema_pkg_apis_tenancy_v1alpha1_ClusterWorkspaceTypeList(ref common.Refere } } +func schema_pkg_apis_tenancy_v1alpha1_ClusterWorkspaceTypeReference(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "ClusterWorkspaceTypeReference is a globally unique, fully qualified reference to a cluster workspace type.", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "name": { + SchemaProps: spec.SchemaProps{ + Description: "name is the name of the ClusterWorkspaceType", + Type: []string{"string"}, + Format: "", + }, + }, + "path": { + SchemaProps: spec.SchemaProps{ + Description: "path is an absolute reference to the workspace that owns this type, e.g. root:org:ws.", + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + } +} + func schema_pkg_apis_tenancy_v1alpha1_ClusterWorkspaceTypeSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ @@ -2668,6 +2698,28 @@ func schema_pkg_apis_tenancy_v1alpha1_ClusterWorkspaceTypeSpec(ref common.Refere }, }, }, + "defaultSubWorkspaceType": { + SchemaProps: spec.SchemaProps{ + Description: "defaultSubWorkspaceType is the ClusterWorkspaceType that will be used by default if another, nested ClusterWorkspace is created in a workspace of this type. The default behaviour requires the user to specify a type.\n\nIf a workspace type extends a type definition of the same name at a higher level, the child workspace's default is preferred. A ClusterWorkspaceType can only add a default type from the same workspace. Not specifying defaultSubWorkspaceType or an empty string means to inherit the value from the super-workspace.", + Type: []string{"string"}, + Format: "", + }, + }, + "allowedSubWorkspaceTypes": { + SchemaProps: spec.SchemaProps{ + Description: "allowedSubWorkspaceTypes is a list of ClusterWorkspaceTypes that can be created in a workspace of this type.\n\nIf a workspace type extends a type definition of the same name at a higher level, the sets of allowed sub-workspace types are merged. A ClusterWorkspaceType can only add allowed sub-workspace types from the same workspace.\n\nFor example, extending types from parent workspaces is not allowed, as seen with the following ClusterWorkspaceType objects:\n\napiVersion: tenancy.kcp.dev/v1alpha1 | apiVersion: tenancy.kcp.dev/v1alpha1 kind: ClusterWorkspaceType | kind: ClusterWorkspaceType metadata: | metadata:\n name: organization | name: organization\n clusterName: root | clusterName: root:something\nspec: | spec:\n allowedSubWorkspaceTypes: [] | allowedSubWorkspaceTypes:\n | - organization\n\nThe Organization type in root:something cannot add a type from higher in the chain, only that type can allow itself to be a sub-type.\n\nHowever, extending types from your own workspace is valid, as seen with the following ClusterWorkspaceType objects:\n\napiVersion: tenancy.kcp.dev/v1alpha1 | apiVersion: tenancy.kcp.dev/v1alpha1 kind: ClusterWorkspaceType | kind: ClusterWorkspaceType metadata: | metadata:\n name: organization | name: team\n clusterName: root | clusterName: root:something\nspec: | spec:\n allowedSubWorkspaceTypes: [] | allowedSubWorkspaceTypes: []\n | apiVersion: tenancy.kcp.dev/v1alpha1\n | kind: ClusterWorkspaceType\n | metadata:\n | name: organization\n | clusterName: root:something\n | spec:\n | allowedSubWorkspaceTypes:\n | - team\n\nThe Organization type in root:something is allowed to extend the allowed sub-types with a new Type that exists at the same level in the hierarchy.\n\nFurthermore, co-locating aliases is allowed, as is seen with the following ClusterWorkspaceType objects:\n\napiVersion: tenancy.kcp.dev/v1alpha1 | apiVersion: tenancy.kcp.dev/v1alpha1 kind: ClusterWorkspaceType | kind: ClusterWorkspaceType metadata: | metadata:\n name: organization | name: team\n clusterName: team | clusterName: root:something\nspec: | spec:\n allowedSubWorkspaceTypes: | allowedSubWorkspaceTypes:\n - name: team | - name: team\n\nThe Team type in root:something can now have nested ClusterWorkspaces created of either type root:team *or* root:something:team.\n\nBy default no type is allowed. This means no other workspace can be nested within a workspace of the given type.", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, }, }, }, diff --git a/pkg/reconciler/tenancy/bootstrap/bootstrap_controller.go b/pkg/reconciler/tenancy/bootstrap/bootstrap_controller.go index 149c082f7292..cd7bb4791c93 100644 --- a/pkg/reconciler/tenancy/bootstrap/bootstrap_controller.go +++ b/pkg/reconciler/tenancy/bootstrap/bootstrap_controller.go @@ -53,7 +53,7 @@ func NewController( crdClusterClient apiextensionclientset.ClusterInterface, kcpClusterClient kcpclient.ClusterInterface, workspaceInformer tenancyinformer.ClusterWorkspaceInformer, - workspaceType string, + workspaceType tenancyv1alpha1.ClusterWorkspaceTypeReference, bootstrap func(context.Context, discovery.DiscoveryInterface, dynamic.Interface) error, ) (*controller, error) { controllerName := fmt.Sprintf("%s-%s", controllerNameBase, workspaceType) @@ -96,7 +96,7 @@ type controller struct { syncChecks []cache.InformerSynced - workspaceType string + workspaceType tenancyv1alpha1.ClusterWorkspaceTypeReference bootstrap func(context.Context, discovery.DiscoveryInterface, dynamic.Interface) error } diff --git a/pkg/reconciler/tenancy/bootstrap/bootstrap_reconcile.go b/pkg/reconciler/tenancy/bootstrap/bootstrap_reconcile.go index 82932cd6c872..fde31aea8af3 100644 --- a/pkg/reconciler/tenancy/bootstrap/bootstrap_reconcile.go +++ b/pkg/reconciler/tenancy/bootstrap/bootstrap_reconcile.go @@ -42,7 +42,7 @@ func (c *controller) reconcile(ctx context.Context, workspace *tenancyv1alpha1.C // have we done our work before? found := false - initializerName := tenancyv1alpha1.ClusterWorkspaceInitializer(typeInitializerKeyDomain + "/" + strings.ToLower(c.workspaceType)) + initializerName := tenancyv1alpha1.ClusterWorkspaceInitializer(typeInitializerKeyDomain + "/" + strings.ToLower(c.workspaceType.Name)) for _, i := range workspace.Status.Initializers { if i == initializerName { found = true diff --git a/pkg/server/apiextensions.go b/pkg/server/apiextensions.go index 903865f8065d..201e252aa856 100644 --- a/pkg/server/apiextensions.go +++ b/pkg/server/apiextensions.go @@ -162,7 +162,11 @@ func (p *systemCRDProvider) Keys(clusterName logicalcluster.Name) sets.String { return sets.NewString() } - switch clusterWorkspace.Spec.Type { + if clusterWorkspace.Spec.Type.Path != "root" { + return sets.NewString() + } + + switch clusterWorkspace.Spec.Type.Name { case "Universal": return p.universalCRDs case "Organization", "Team": diff --git a/pkg/server/controllers.go b/pkg/server/controllers.go index 3527a17c5c9e..1378b9381091 100644 --- a/pkg/server/controllers.go +++ b/pkg/server/controllers.go @@ -426,7 +426,7 @@ func (s *Server) installWorkspaceScheduler(ctx context.Context, config *rest.Con crdClusterClient, kcpClusterClient, s.kcpSharedInformerFactory.Tenancy().V1alpha1().ClusterWorkspaces(), - "Organization", + tenancyv1alpha1.ClusterWorkspaceTypeReference{Path: "root", Name: "Organization"}, configorganization.Bootstrap, ) if err != nil { @@ -438,7 +438,7 @@ func (s *Server) installWorkspaceScheduler(ctx context.Context, config *rest.Con crdClusterClient, kcpClusterClient, s.kcpSharedInformerFactory.Tenancy().V1alpha1().ClusterWorkspaces(), - "Team", + tenancyv1alpha1.ClusterWorkspaceTypeReference{Path: "root", Name: "Team"}, configteam.Bootstrap, ) if err != nil { @@ -450,7 +450,7 @@ func (s *Server) installWorkspaceScheduler(ctx context.Context, config *rest.Con crdClusterClient, kcpClusterClient, s.kcpSharedInformerFactory.Tenancy().V1alpha1().ClusterWorkspaces(), - "Universal", + tenancyv1alpha1.ClusterWorkspaceTypeReference{Path: "root", Name: "Universal"}, configuniversal.Bootstrap, ) if err != nil { diff --git a/pkg/virtual/apiexport/builder/build.go b/pkg/virtual/apiexport/builder/build.go index 5d43e6ff5e19..9822c24d2ec0 100644 --- a/pkg/virtual/apiexport/builder/build.go +++ b/pkg/virtual/apiexport/builder/build.go @@ -28,10 +28,11 @@ import ( genericapirequest "k8s.io/apiserver/pkg/endpoints/request" genericapiserver "k8s.io/apiserver/pkg/server" "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" "k8s.io/client-go/tools/cache" - "k8s.io/klog/v2" apisv1alpha1 "github.com/kcp-dev/kcp/pkg/apis/apis/v1alpha1" + "github.com/kcp-dev/kcp/pkg/authorization/delegated" kcpclient "github.com/kcp-dev/kcp/pkg/client/clientset/versioned" kcpinformer "github.com/kcp-dev/kcp/pkg/client/informers/externalversions" "github.com/kcp-dev/kcp/pkg/virtual/apiexport/controllers/apireconciler" @@ -46,6 +47,7 @@ const VirtualWorkspaceName string = "apiexport" func BuildVirtualWorkspace( rootPathPrefix string, + kubeClusterClient kubernetes.ClusterInterface, dynamicClusterClient dynamic.ClusterInterface, kcpClusterClient kcpclient.ClusterInterface, wildcardKcpInformers kcpinformer.SharedInformerFactory, @@ -120,11 +122,6 @@ func BuildVirtualWorkspace( return }, - Authorizer: func(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) { - klog.Error("the authorizer for the 'initializingworkspaces' virtual workspace is not implemented !") - return authorizer.DecisionAllow, "", nil - }, - Ready: func() error { select { case <-readyCh: @@ -177,6 +174,36 @@ func BuildVirtualWorkspace( return apiReconciler, nil }, + Authorizer: getAuthorizer(kubeClusterClient), + } +} + +func getAuthorizer(client kubernetes.ClusterInterface) authorizer.AuthorizerFunc { + return func(ctx context.Context, attr authorizer.Attributes) (authorizer.Decision, string, error) { + apiDomainKey := dynamiccontext.APIDomainKeyFrom(ctx) + parts := strings.Split(string(apiDomainKey), "/") + if len(parts) < 2 { + return authorizer.DecisionNoOpinion, "unable to determine api export", fmt.Errorf("access not permitted") + } + + apiExportCluster, apiExportName := parts[0], parts[1] + authz, err := delegated.NewDelegatedAuthorizer(logicalcluster.New(apiExportCluster), client) + if err != nil { + return authorizer.DecisionNoOpinion, "error", err + } + + SARAttributes := authorizer.AttributesRecord{ + APIGroup: apisv1alpha1.SchemeGroupVersion.Group, + APIVersion: apisv1alpha1.SchemeGroupVersion.Version, + User: attr.GetUser(), + Verb: attr.GetVerb(), + Name: apiExportName, + Resource: "apiexports", + ResourceRequest: true, + Subresource: "content", + } + + return authz.Authorize(ctx, SARAttributes) } } diff --git a/pkg/virtual/apiexport/options/options.go b/pkg/virtual/apiexport/options/options.go index ec59660e328a..fd7bec32067b 100644 --- a/pkg/virtual/apiexport/options/options.go +++ b/pkg/virtual/apiexport/options/options.go @@ -22,6 +22,7 @@ import ( "github.com/spf13/pflag" "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" kcpclientset "github.com/kcp-dev/kcp/pkg/client/clientset/versioned" kcpinformer "github.com/kcp-dev/kcp/pkg/client/informers/externalversions" @@ -53,12 +54,13 @@ func (o *APIExport) Validate(flagPrefix string) []error { func (o *APIExport) NewVirtualWorkspaces( rootPathPrefix string, + kubeClusterClient kubernetes.ClusterInterface, dynamicClusterClient dynamic.ClusterInterface, kcpClusterClient kcpclientset.ClusterInterface, wildcardKcpInformers kcpinformer.SharedInformerFactory, ) (extraInformers []rootapiserver.InformerStart, workspaces []framework.VirtualWorkspace, err error) { virtualWorkspaces := []framework.VirtualWorkspace{ - builder.BuildVirtualWorkspace(path.Join(rootPathPrefix, o.Name()), dynamicClusterClient, kcpClusterClient, wildcardKcpInformers), + builder.BuildVirtualWorkspace(path.Join(rootPathPrefix, o.Name()), kubeClusterClient, dynamicClusterClient, kcpClusterClient, wildcardKcpInformers), } return nil, virtualWorkspaces, nil } diff --git a/pkg/virtual/framework/types.go b/pkg/virtual/framework/types.go index 5ceff46fcc13..5a2f61ffc7b7 100644 --- a/pkg/virtual/framework/types.go +++ b/pkg/virtual/framework/types.go @@ -49,4 +49,5 @@ type VirtualWorkspace interface { ResolveRootPath(urlPath string, context context.Context) (accepted bool, prefixToStrip string, completedContext context.Context) IsReady() error Register(rootAPIServerConfig genericapiserver.CompletedConfig, delegateAPIServer genericapiserver.DelegationTarget) (genericapiserver.DelegationTarget, error) + Authorize(context.Context, authorizer.Attributes) (authorizer.Decision, string, error) } diff --git a/pkg/virtual/options/options.go b/pkg/virtual/options/options.go index 5f3608caa096..eab3bd6bcf55 100644 --- a/pkg/virtual/options/options.go +++ b/pkg/virtual/options/options.go @@ -92,7 +92,7 @@ func (o *Options) NewVirtualWorkspaces( extraInformers = append(extraInformers, inf...) workspaces = append(workspaces, vws...) - inf, vws, err = o.APIExport.NewVirtualWorkspaces(rootPathPrefix, dynamicClusterClient, kcpClusterClient, wildcardKcpInformers) + inf, vws, err = o.APIExport.NewVirtualWorkspaces(rootPathPrefix, kubeClusterClient, dynamicClusterClient, kcpClusterClient, wildcardKcpInformers) if err != nil { return nil, nil, err } diff --git a/pkg/virtual/workspaces/registry/rest.go b/pkg/virtual/workspaces/registry/rest.go index f1a2cb727f31..4678564e2257 100644 --- a/pkg/virtual/workspaces/registry/rest.go +++ b/pkg/virtual/workspaces/registry/rest.go @@ -589,7 +589,10 @@ func (s *REST) Create(ctx context.Context, obj runtime.Object, createValidation clusterWorkspace := &tenancyv1alpha1.ClusterWorkspace{ ObjectMeta: workspace.ObjectMeta, Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ - Type: workspace.Spec.Type, + Type: tenancyv1alpha1.ClusterWorkspaceTypeReference{ + Name: workspace.Spec.Type, + Path: logicalcluster.From(workspace).String(), + }, }, } createdClusterWorkspace, err := s.kcpClusterClient.Cluster(orgClusterName).TenancyV1alpha1().ClusterWorkspaces().Create(ctx, clusterWorkspace, metav1.CreateOptions{}) diff --git a/test/e2e/apibinding/apibinding_authorizer_test.go b/test/e2e/apibinding/apibinding_authorizer_test.go index 5418b1c08166..659976395a53 100644 --- a/test/e2e/apibinding/apibinding_authorizer_test.go +++ b/test/e2e/apibinding/apibinding_authorizer_test.go @@ -162,7 +162,7 @@ func TestAPIBindingAuthorizer(t *testing.T) { require.Error(t, err) } else { t.Logf("Make sure that the status of cowboy can be updated in workspace %q", consumer) - _, err = cowboyClient.UpdateStatus(ctx, &cowboys.Items[0], metav1.UpdateOptions{}) + _, err = cowboyClient.Update(ctx, &cowboys.Items[0], metav1.UpdateOptions{}) require.NoError(t, err, "expected error updating status of cowboys") } } diff --git a/test/e2e/framework/fixture.go b/test/e2e/framework/fixture.go index 3fac6b9a4064..29880149f0b4 100644 --- a/test/e2e/framework/fixture.go +++ b/test/e2e/framework/fixture.go @@ -207,15 +207,29 @@ func NewOrganizationFixture(t *testing.T, server RunningServer) (orgClusterName clusterClient, err := kcpclientset.NewClusterForConfig(cfg) require.NoError(t, err, "failed to create kcp cluster client") - org, err := clusterClient.Cluster(tenancyv1alpha1.RootCluster).TenancyV1alpha1().ClusterWorkspaces().Create(ctx, &tenancyv1alpha1.ClusterWorkspace{ - ObjectMeta: metav1.ObjectMeta{ - GenerateName: "e2e-org-", - }, - Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ - Type: "Organization", - }, - }, metav1.CreateOptions{}) - require.NoError(t, err, "failed to create organization workspace") + // we are referring here to a ClusterWorkspaceType that may have just been created; if the admission controller + // does not have a fresh enough cache, our request will be denied as the admission controller does not know the + // type exists. Therefore, we can require.Eventually our way out of this problem. We expect users to create new + // types very infrequently, so we do not think this will be a serious UX issue in the product. + var org *tenancyv1alpha1.ClusterWorkspace + require.Eventually(t, func() bool { + var err error + org, err = clusterClient.Cluster(tenancyv1alpha1.RootCluster).TenancyV1alpha1().ClusterWorkspaces().Create(ctx, &tenancyv1alpha1.ClusterWorkspace{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "e2e-org-", + }, + Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ + Type: tenancyv1alpha1.ClusterWorkspaceTypeReference{ + Name: "Organization", + Path: "root", + }, + }, + }, metav1.CreateOptions{}) + if err != nil { + t.Logf("error creating org workspace under %s: %v", tenancyv1alpha1.RootCluster, err) + } + return err == nil + }, wait.ForeverTestTimeout, time.Millisecond*100, "failed to create org workspace under %s", tenancyv1alpha1.RootCluster) t.Cleanup(func() { if preserveTestResources() { @@ -269,16 +283,30 @@ func NewWorkspaceWithWorkloads(t *testing.T, server RunningServer, orgClusterNam labels[workloadv1alpha1.WorkspaceSchedulableLabel] = "false" } - ws, err := clusterClient.Cluster(orgClusterName).TenancyV1alpha1().ClusterWorkspaces().Create(ctx, &tenancyv1alpha1.ClusterWorkspace{ - ObjectMeta: metav1.ObjectMeta{ - GenerateName: "e2e-workspace-", - Labels: labels, - }, - Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ - Type: workspaceType, - }, - }, metav1.CreateOptions{}) - require.NoError(t, err, "failed to create workspace") + // we are referring here to a ClusterWorkspaceType that may have just been created; if the admission controller + // does not have a fresh enough cache, our request will be denied as the admission controller does not know the + // type exists. Therefore, we can require.Eventually our way out of this problem. We expect users to create new + // types very infrequently, so we do not think this will be a serious UX issue in the product. + var ws *tenancyv1alpha1.ClusterWorkspace + require.Eventually(t, func() bool { + var err error + ws, err = clusterClient.Cluster(orgClusterName).TenancyV1alpha1().ClusterWorkspaces().Create(ctx, &tenancyv1alpha1.ClusterWorkspace{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "e2e-workspace-", + Labels: labels, + }, + Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ + Type: tenancyv1alpha1.ClusterWorkspaceTypeReference{ + Name: workspaceType, + Path: "root", + }, + }, + }, metav1.CreateOptions{}) + if err != nil { + t.Logf("error creating workspace under %s: %v", orgClusterName, err) + } + return err == nil + }, wait.ForeverTestTimeout, time.Millisecond*100, "failed to create workspace under %s", orgClusterName) t.Cleanup(func() { if preserveTestResources() { diff --git a/test/e2e/virtual/apiexport/apiresourceschema_cowboys.yaml b/test/e2e/virtual/apiexport/apiresourceschema_cowboys.yaml new file mode 100644 index 000000000000..46ba7f1494ae --- /dev/null +++ b/test/e2e/virtual/apiexport/apiresourceschema_cowboys.yaml @@ -0,0 +1,46 @@ +apiVersion: apis.kcp.dev/v1alpha1 +kind: APIResourceSchema +metadata: + name: today.cowboys.wildwest.dev +spec: + group: wildwest.dev + names: + kind: Cowboy + listKind: CowboyList + plural: cowboys + singular: cowboy + scope: Namespaced + versions: + - name: v1alpha1 + schema: + description: Cowboy is part of the wild west + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation + of an object. Servers should convert recognized schemas to the latest + internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this + object represents. Servers may infer this from the endpoint the client + submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + spec: + description: CowboySpec holds the desired state of the Cowboy. + properties: + intent: + type: string + type: object + status: + description: CowboyStatus communicates the observed state of the Cowboy. + properties: + result: + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} \ No newline at end of file diff --git a/test/e2e/virtual/apiexport/support.go b/test/e2e/virtual/apiexport/support.go new file mode 100644 index 000000000000..82eeb0f48e08 --- /dev/null +++ b/test/e2e/virtual/apiexport/support.go @@ -0,0 +1,68 @@ +/* +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 apiexport + +import ( + "embed" + "path" + + "github.com/kcp-dev/logicalcluster" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/rest" + + virtualoptions "github.com/kcp-dev/kcp/cmd/virtual-workspaces/options" + wildwestclientset "github.com/kcp-dev/kcp/test/e2e/fixtures/wildwest/client/clientset/versioned" +) + +//go:embed *.yaml +var testFiles embed.FS + +func groupExists(list *metav1.APIGroupList, group string) bool { + for _, g := range list.Groups { + if g.Name == group { + return true + } + } + return false +} + +func resourceExists(list *metav1.APIResourceList, resource string) bool { + for _, r := range list.APIResources { + if r.Name == resource { + return true + } + } + return false +} + +type virtualClusterClient struct { + config *rest.Config +} + +func (c *virtualClusterClient) Cluster(cluster logicalcluster.Name, exportName string) (*wildwestclientset.Cluster, error) { + config := rest.CopyConfig(c.config) + // /services/apiexport/root:org:ws//clusters/*/api/v1/configmaps + config.Host += path.Join(virtualoptions.DefaultRootPathPrefix, "apiexport", cluster.String(), exportName) + return wildwestclientset.NewClusterForConfig(config) +} + +func userConfig(username string, cfg *rest.Config) *rest.Config { + cfgCopy := rest.CopyConfig(cfg) + cfgCopy.BearerToken = username + "-token" + return cfgCopy +} diff --git a/test/e2e/virtual/apiexport/virtualworkspace_test.go b/test/e2e/virtual/apiexport/virtualworkspace_test.go new file mode 100644 index 000000000000..5de052657858 --- /dev/null +++ b/test/e2e/virtual/apiexport/virtualworkspace_test.go @@ -0,0 +1,264 @@ +/* +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 apiexport + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/kcp-dev/logicalcluster" + "github.com/stretchr/testify/require" + + rbacv1 "k8s.io/api/rbac/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/discovery/cached/memory" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/restmapper" + + "github.com/kcp-dev/kcp/config/helpers" + apisv1alpha1 "github.com/kcp-dev/kcp/pkg/apis/apis/v1alpha1" + clientset "github.com/kcp-dev/kcp/pkg/client/clientset/versioned" + "github.com/kcp-dev/kcp/test/e2e/fixtures/wildwest/apis/wildwest" + wildwestv1alpha1 "github.com/kcp-dev/kcp/test/e2e/fixtures/wildwest/apis/wildwest/v1alpha1" + wildwestclientset "github.com/kcp-dev/kcp/test/e2e/fixtures/wildwest/client/clientset/versioned" + "github.com/kcp-dev/kcp/test/e2e/framework" +) + +func TestAPIExportVirtualWorkspace(t *testing.T) { + t.Parallel() + + server := framework.SharedKcpServer(t) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + // Need to Create a Producer w/ APIExport + orgClusterName := framework.NewOrganizationFixture(t, server) + serviceProviderWorkspace := framework.NewWorkspaceFixture(t, server, orgClusterName, "Universal") + consumerWorkspace := framework.NewWorkspaceFixture(t, server, orgClusterName, "Universal") + + cfg := server.DefaultConfig(t) + + kcpClients, err := clientset.NewClusterForConfig(cfg) + require.NoError(t, err, "failed to construct kcp cluster client for server") + + dynamicClients, err := dynamic.NewClusterForConfig(cfg) + require.NoError(t, err, "failed to construct dynamic cluster client for server") + + kubeClusterClient, err := kubernetes.NewClusterForConfig(cfg) + require.NoError(t, err, "failed to construct kube cluster client for server") + + wildwestClusterClient, err := wildwestclientset.NewClusterForConfig(cfg) + require.NoError(t, err, "failed to construct wildwest cluster client for server") + + framework.AdmitWorkspaceAccess(t, ctx, kubeClusterClient, orgClusterName, []string{"user-1", "user-2"}, nil, []string{"member"}) + framework.AdmitWorkspaceAccess(t, ctx, kubeClusterClient, serviceProviderWorkspace, []string{"user-1", "user-2"}, nil, []string{"member", "access"}) + + setUpServiceProvider(ctx, dynamicClients, kcpClients, kubeClusterClient, serviceProviderWorkspace, t) + + bindConsumerToProvider(ctx, consumerWorkspace, serviceProviderWorkspace, t, kcpClients) + + createCowboyInConsumer(ctx, t, consumerWorkspace, wildwestClusterClient) + + t.Logf("test that the admin user can use the virtual workspace to get cowboys") + vc := virtualClusterClient{cfg} + wildwestVCClient, err := vc.Cluster(serviceProviderWorkspace, "today-cowboys") + require.NoError(t, err) + cowboysProjected, err := wildwestVCClient.Cluster(logicalcluster.Wildcard).WildwestV1alpha1().Cowboys("").List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + require.Equal(t, 1, len(cowboysProjected.Items)) + + // Attempt to use VW using user-1 should expect an error + t.Logf("Make sure that user-1 is denied") + user1VC := virtualClusterClient{config: userConfig("user-1", cfg)} + wwUser1VC, err := user1VC.Cluster(serviceProviderWorkspace, "today-cowboys") + require.NoError(t, err) + _, err = wwUser1VC.Cluster(logicalcluster.Wildcard).WildwestV1alpha1().Cowboys("").List(ctx, metav1.ListOptions{}) + require.True(t, apierrors.IsForbidden(err)) + + // Create clusterRoleBindings for content access. + t.Logf("create the cluster role and bindings to give access to the virtual worksapce for user-1") + cr, crb := createClusterRoleAndBindings("user-1-vw", "user-1", "User", []string{"list", "get"}) + _, err = kubeClusterClient.Cluster(serviceProviderWorkspace).RbacV1().ClusterRoles().Create(ctx, cr, metav1.CreateOptions{}) + require.NoError(t, err) + _, err = kubeClusterClient.Cluster(serviceProviderWorkspace).RbacV1().ClusterRoleBindings().Create(ctx, crb, metav1.CreateOptions{}) + require.NoError(t, err) + + // Get cowboys from the virtual workspace with user-1. + t.Logf("Get Cowboys with user-1") + require.Eventually(t, func() bool { + cbs, err := wwUser1VC.Cluster(logicalcluster.Wildcard).WildwestV1alpha1().Cowboys("").List(ctx, metav1.ListOptions{}) + if err != nil { + return false + } + require.Equal(t, 1, len(cbs.Items)) + + // Attempt to update it should fail + cb := cbs.Items[0] + cb.Status.Result = "updated" + _, err = wwUser1VC.Cluster(logicalcluster.From(&cb)).WildwestV1alpha1().Cowboys(cb.Namespace).UpdateStatus(ctx, &cb, metav1.UpdateOptions{}) + require.Error(t, err) + require.True(t, apierrors.IsForbidden(err)) + + return true + }, wait.ForeverTestTimeout, time.Millisecond*100, "expected user-1 to list cowboys from virtual workspace") + + // Test that users are able to update status of cowboys status + t.Logf("create the cluster role and bindings to give access to the virtual worksapce for user-2") + cr, crb = createClusterRoleAndBindings("user-2-vw", "user-2", "User", []string{"update", "list"}) + _, err = kubeClusterClient.Cluster(serviceProviderWorkspace).RbacV1().ClusterRoles().Create(ctx, cr, metav1.CreateOptions{}) + require.NoError(t, err) + _, err = kubeClusterClient.Cluster(serviceProviderWorkspace).RbacV1().ClusterRoleBindings().Create(ctx, crb, metav1.CreateOptions{}) + require.NoError(t, err) + + user2VC := virtualClusterClient{config: userConfig("user-2", cfg)} + wwUser2VC, err := user2VC.Cluster(serviceProviderWorkspace, "today-cowboys") + require.NoError(t, err) + t.Logf("Get Cowboy and update status with user-2") + require.Eventually(t, func() bool { + cbs, err := wwUser2VC.Cluster(logicalcluster.Wildcard).WildwestV1alpha1().Cowboys("").List(ctx, metav1.ListOptions{}) + if err != nil { + return false + } + if len(cbs.Items) != 1 { + return false + } + + cb := cbs.Items[0] + cb.Status.Result = "updated" + _, err = wwUser2VC.Cluster(logicalcluster.From(&cb)).WildwestV1alpha1().Cowboys(cb.Namespace).UpdateStatus(ctx, &cb, metav1.UpdateOptions{}) + require.NoError(t, err) + return true + }, wait.ForeverTestTimeout, time.Millisecond*100, "expected user-2 to list cowboys from virtual workspace") + +} + +func setUpServiceProvider(ctx context.Context, dynamicClients *dynamic.Cluster, kcpClients *clientset.Cluster, kubeClusterClient *kubernetes.Cluster, serviceProviderWorkspace logicalcluster.Name, t *testing.T) { + t.Logf("Install today cowboys APIResourceSchema into service provider workspace %q", serviceProviderWorkspace) + mapper := restmapper.NewDeferredDiscoveryRESTMapper(memory.NewMemCacheClient(kcpClients.Cluster(serviceProviderWorkspace).Discovery())) + err := helpers.CreateResourceFromFS(ctx, dynamicClients.Cluster(serviceProviderWorkspace), mapper, "apiresourceschema_cowboys.yaml", testFiles) + require.NoError(t, err) + + t.Logf("Create an APIExport for it") + cowboysAPIExport := &apisv1alpha1.APIExport{ + ObjectMeta: metav1.ObjectMeta{ + Name: "today-cowboys", + }, + Spec: apisv1alpha1.APIExportSpec{ + LatestResourceSchemas: []string{"today.cowboys.wildwest.dev"}, + }, + } + _, err = kcpClients.Cluster(serviceProviderWorkspace).ApisV1alpha1().APIExports().Create(ctx, cowboysAPIExport, metav1.CreateOptions{}) + require.NoError(t, err) +} + +func bindConsumerToProvider(ctx context.Context, consumerWorkspace, providerWorkspace logicalcluster.Name, t *testing.T, kcpClients *clientset.Cluster) { + t.Logf("Create an APIBinding in consumer workspace %q that points to the today-cowboys export from %q", consumerWorkspace, providerWorkspace) + apiBinding := &apisv1alpha1.APIBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cowboys", + }, + Spec: apisv1alpha1.APIBindingSpec{ + Reference: apisv1alpha1.ExportReference{ + Workspace: &apisv1alpha1.WorkspaceExportReference{ + Path: providerWorkspace.String(), + ExportName: "today-cowboys", + }, + }, + }, + } + + _, err := kcpClients.Cluster(consumerWorkspace).ApisV1alpha1().APIBindings().Create(ctx, apiBinding, metav1.CreateOptions{}) + require.NoError(t, err) + t.Logf("Make sure %q API group shows up in consumer workspace %q group discovery", wildwest.GroupName, consumerWorkspace) + err = wait.PollImmediateWithContext(ctx, 100*time.Millisecond, wait.ForeverTestTimeout, func(c context.Context) (done bool, err error) { + groups, err := kcpClients.Cluster(consumerWorkspace).Discovery().ServerGroups() + if err != nil { + return false, fmt.Errorf("error retrieving consumer workspace %q group discovery: %w", consumerWorkspace, err) + } + return groupExists(groups, wildwest.GroupName), nil + }) + require.NoError(t, err) + t.Logf("Make sure cowboys API resource shows up in consumer workspace %q group version discovery", consumerWorkspace) + resources, err := kcpClients.Cluster(consumerWorkspace).Discovery().ServerResourcesForGroupVersion(wildwestv1alpha1.SchemeGroupVersion.String()) + require.NoError(t, err, "error retrieving consumer workspace %q API discovery", consumerWorkspace) + require.True(t, resourceExists(resources, "cowboys"), "consumer workspace %q discovery is missing cowboys resource", consumerWorkspace) +} + +func createCowboyInConsumer(ctx context.Context, t *testing.T, consumer1Workspace logicalcluster.Name, wildwestClusterClient *wildwestclientset.Cluster) { + t.Logf("Make sure we can perform CRUD operations against consumer workspace %q for the bound API", consumer1Workspace) + + t.Logf("Make sure list shows nothing to start") + cowboyClient := wildwestClusterClient.Cluster(consumer1Workspace).WildwestV1alpha1().Cowboys("default") + var cowboys *wildwestv1alpha1.CowboyList + // Adding a poll here to wait for the user's to get access via RBAC informer updates. + + require.Eventually(t, func() bool { + var err error + cowboys, err = cowboyClient.List(ctx, metav1.ListOptions{}) + return err == nil + }, wait.ForeverTestTimeout, 100*time.Millisecond, "expected to be able to list ") + require.Zero(t, len(cowboys.Items), "expected 0 cowboys inside consumer workspace %q", consumer1Workspace) + + t.Logf("Create a cowboy CR in consumer workspace %q", consumer1Workspace) + cowboyName := fmt.Sprintf("cowboy-%s", consumer1Workspace.Base()) + cowboy := &wildwestv1alpha1.Cowboy{ + ObjectMeta: metav1.ObjectMeta{ + Name: cowboyName, + Namespace: "default", + }, + } + _, err := cowboyClient.Create(ctx, cowboy, metav1.CreateOptions{}) + require.NoError(t, err, "error creating cowboy in consumer workspace %q", consumer1Workspace) +} + +func createClusterRoleAndBindings(name, subjectName, subjectKind string, verbs []string) (*rbacv1.ClusterRole, *rbacv1.ClusterRoleBinding) { + clusterRole := &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + Rules: []rbacv1.PolicyRule{ + { + Verbs: verbs, + APIGroups: []string{apisv1alpha1.SchemeGroupVersion.Group}, + Resources: []string{"apiexports/content"}, + }, + }, + } + clusterRoleBinding := &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + Subjects: []rbacv1.Subject{ + { + Kind: subjectKind, + Name: subjectName, + }, + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: rbacv1.SchemeGroupVersion.Group, + Kind: "ClusterRole", + Name: name, + }, + } + return clusterRole, clusterRoleBinding +} diff --git a/test/e2e/virtual/initializingworkspaces/virtualworkspace_test.go b/test/e2e/virtual/initializingworkspaces/virtualworkspace_test.go index f8850a947c7b..07122f4fa00b 100644 --- a/test/e2e/virtual/initializingworkspaces/virtualworkspace_test.go +++ b/test/e2e/virtual/initializingworkspaces/virtualworkspace_test.go @@ -139,6 +139,7 @@ func TestInitializingWorkspacesVirtualWorkspaceAccess(t *testing.T) { clusterWorkspaceInitializerNames[name] = tenancyv1alpha1.ClusterWorkspaceInitializer(name + "-" + suffix()) } + clusterWorkspaceTypes := map[string]*tenancyv1alpha1.ClusterWorkspaceType{} for name, initializers := range map[string][]string{ "alpha": {"alpha"}, "beta": {"beta"}, @@ -148,7 +149,7 @@ func TestInitializingWorkspacesVirtualWorkspaceAccess(t *testing.T) { for _, initializerName := range initializers { initializerNames = append(initializerNames, clusterWorkspaceInitializerNames[initializerName]) } - _, err = sourceKcpTenancyClient.ClusterWorkspaceTypes().Create(ctx, &tenancyv1alpha1.ClusterWorkspaceType{ + cwt, err := sourceKcpTenancyClient.ClusterWorkspaceTypes().Create(ctx, &tenancyv1alpha1.ClusterWorkspaceType{ ObjectMeta: metav1.ObjectMeta{ Name: clusterWorkspaceTypeNames[name], }, @@ -157,13 +158,14 @@ func TestInitializingWorkspacesVirtualWorkspaceAccess(t *testing.T) { }, }, metav1.CreateOptions{}) require.NoError(t, err) + clusterWorkspaceTypes[name] = cwt } t.Log("Create workspaces that using the new types, which will get stuck in initializing") for _, workspaceType := range []string{ "alpha", "beta", "gamma", } { - _, err := sourceKcpTenancyClient.ClusterWorkspaces().Create(ctx, workspaceForType(clusterWorkspaceTypeNames[workspaceType], testLabelSelector), metav1.CreateOptions{}) + _, err := sourceKcpTenancyClient.ClusterWorkspaces().Create(ctx, workspaceForType(clusterWorkspaceTypes[workspaceType], testLabelSelector), metav1.CreateOptions{}) require.NoError(t, err) } @@ -211,7 +213,7 @@ func TestInitializingWorkspacesVirtualWorkspaceAccess(t *testing.T) { require.NoError(t, err) workspacesByType := map[string]tenancyv1alpha1.ClusterWorkspace{} for i := range workspaces.Items { - workspacesByType[strings.ToLower(workspaces.Items[i].Spec.Type)] = workspaces.Items[i] + workspacesByType[strings.ToLower(workspaces.Items[i].Spec.Type.Name)] = workspaces.Items[i] } for initializer, expected := range map[string][]tenancyv1alpha1.ClusterWorkspace{ @@ -242,7 +244,7 @@ func TestInitializingWorkspacesVirtualWorkspaceAccess(t *testing.T) { } t.Log("Adding a new workspace that both watchers should see") - ws, err := sourceKcpTenancyClient.ClusterWorkspaces().Create(ctx, workspaceForType(clusterWorkspaceTypeNames["gamma"], testLabelSelector), metav1.CreateOptions{}) + ws, err := sourceKcpTenancyClient.ClusterWorkspaces().Create(ctx, workspaceForType(clusterWorkspaceTypes["gamma"], testLabelSelector), metav1.CreateOptions{}) require.NoError(t, err) require.Eventually(t, func() bool { workspace, err := sourceKcpTenancyClient.ClusterWorkspaces().Get(ctx, ws.Name, metav1.GetOptions{}) @@ -317,14 +319,17 @@ func TestInitializingWorkspacesVirtualWorkspaceAccess(t *testing.T) { } } -func workspaceForType(workspaceType string, testLabelSelector map[string]string) *tenancyv1alpha1.ClusterWorkspace { +func workspaceForType(workspaceType *tenancyv1alpha1.ClusterWorkspaceType, testLabelSelector map[string]string) *tenancyv1alpha1.ClusterWorkspace { return &tenancyv1alpha1.ClusterWorkspace{ ObjectMeta: metav1.ObjectMeta{ GenerateName: "e2e-workspace-", Labels: testLabelSelector, }, Spec: tenancyv1alpha1.ClusterWorkspaceSpec{ - Type: strings.ToUpper(string(workspaceType[0])) + workspaceType[1:], + Type: tenancyv1alpha1.ClusterWorkspaceTypeReference{ + Name: strings.ToUpper(string(workspaceType.Name[0])) + workspaceType.Name[1:], + Path: logicalcluster.From(workspaceType).String(), + }, }, } } diff --git a/test/e2e/workspacetype/controller_test.go b/test/e2e/workspacetype/controller_test.go index 6eaa8f74679e..0d2aff6ca483 100644 --- a/test/e2e/workspacetype/controller_test.go +++ b/test/e2e/workspacetype/controller_test.go @@ -23,10 +23,10 @@ import ( "testing" "time" + "github.com/kcp-dev/logicalcluster" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/wait" "k8s.io/client-go/util/retry" @@ -48,38 +48,21 @@ func TestClusterWorkspaceTypes(t *testing.T) { name string work func(ctx context.Context, t *testing.T, server runningServer) }{ - { - name: "create a workspace without an explicit type", - work: func(ctx context.Context, t *testing.T, server runningServer) { - t.Logf("Create a workspace without explicit type") - workspace, err := server.orgKcpClient.TenancyV1alpha1().ClusterWorkspaces().Create(ctx, &tenancyv1alpha1.ClusterWorkspace{ObjectMeta: metav1.ObjectMeta{Name: "myapp"}}, metav1.CreateOptions{}) - require.NoError(t, err, "failed to create workspace") - server.Artifact(t, func() (runtime.Object, error) { - return server.orgKcpClient.TenancyV1alpha1().ClusterWorkspaces().Get(ctx, workspace.Name, metav1.GetOptions{}) - }) - - err = server.orgExpect(workspace, ready) - require.NoError(t, err) - - t.Logf("Expect workspace to be of Universal type, and no initializers") - workspace, err = server.orgKcpClient.TenancyV1alpha1().ClusterWorkspaces().Get(ctx, workspace.Name, metav1.GetOptions{}) - require.NoError(t, err, "failed to get workspace") - require.Equalf(t, workspace.Spec.Type, "Universal", "workspace type is not universal") - require.Emptyf(t, workspace.Status.Initializers, "workspace has initializers") - }, - }, { name: "create a workspace with an explicit non-existing type", work: func(ctx context.Context, t *testing.T, server runningServer) { t.Logf("Create a workspace with explicit non-existing type") workspace, err := server.orgKcpClient.TenancyV1alpha1().ClusterWorkspaces().Create(ctx, &tenancyv1alpha1.ClusterWorkspace{ ObjectMeta: metav1.ObjectMeta{Name: "myapp"}, - Spec: tenancyv1alpha1.ClusterWorkspaceSpec{Type: "Foo"}, + Spec: tenancyv1alpha1.ClusterWorkspaceSpec{Type: tenancyv1alpha1.ClusterWorkspaceTypeReference{ + Name: "Foo", + Path: "root", + }}, }, metav1.CreateOptions{}) require.Error(t, err, "failed to create workspace") t.Logf("Create type Foo") - _, err = server.orgKcpClient.TenancyV1alpha1().ClusterWorkspaceTypes().Create(ctx, &tenancyv1alpha1.ClusterWorkspaceType{ + cwt, err := server.orgKcpClient.TenancyV1alpha1().ClusterWorkspaceTypes().Create(ctx, &tenancyv1alpha1.ClusterWorkspaceType{ ObjectMeta: metav1.ObjectMeta{Name: "foo"}, Spec: tenancyv1alpha1.ClusterWorkspaceTypeSpec{}, }, metav1.CreateOptions{}) @@ -90,8 +73,14 @@ func TestClusterWorkspaceTypes(t *testing.T) { // note: admission is informer based and hence would race with this create call workspace, err = server.orgKcpClient.TenancyV1alpha1().ClusterWorkspaces().Create(ctx, &tenancyv1alpha1.ClusterWorkspace{ ObjectMeta: metav1.ObjectMeta{Name: "myapp"}, - Spec: tenancyv1alpha1.ClusterWorkspaceSpec{Type: "Foo"}, + Spec: tenancyv1alpha1.ClusterWorkspaceSpec{Type: tenancyv1alpha1.ClusterWorkspaceTypeReference{ + Name: "Foo", + Path: logicalcluster.From(cwt).String(), + }}, }, metav1.CreateOptions{}) + if err != nil { + t.Logf("error creating workspace: %v", err) + } return err == nil }, wait.ForeverTestTimeout, time.Millisecond*100, "failed to create workspace even with type") require.Equal(t, workspace.Spec.Type, "Foo") @@ -105,7 +94,7 @@ func TestClusterWorkspaceTypes(t *testing.T) { name: "create a workspace with a type that has an initializer", work: func(ctx context.Context, t *testing.T, server runningServer) { t.Logf("Create type Foo with an initializer") - _, err := server.orgKcpClient.TenancyV1alpha1().ClusterWorkspaceTypes().Create(ctx, &tenancyv1alpha1.ClusterWorkspaceType{ + cwt, err := server.orgKcpClient.TenancyV1alpha1().ClusterWorkspaceTypes().Create(ctx, &tenancyv1alpha1.ClusterWorkspaceType{ ObjectMeta: metav1.ObjectMeta{Name: "foo"}, Spec: tenancyv1alpha1.ClusterWorkspaceTypeSpec{ Initializers: []tenancyv1alpha1.ClusterWorkspaceInitializer{"a"}, @@ -113,14 +102,20 @@ func TestClusterWorkspaceTypes(t *testing.T) { }, metav1.CreateOptions{}) require.NoError(t, err, "failed to create workspace type") - t.Logf("Create workspace with explicit type Foo again") + t.Logf("Create workspace with explicit type Foo") var workspace *tenancyv1alpha1.ClusterWorkspace require.Eventually(t, func() bool { // note: admission is informer based and hence would race with this create call workspace, err = server.orgKcpClient.TenancyV1alpha1().ClusterWorkspaces().Create(ctx, &tenancyv1alpha1.ClusterWorkspace{ ObjectMeta: metav1.ObjectMeta{Name: "myapp"}, - Spec: tenancyv1alpha1.ClusterWorkspaceSpec{Type: "Foo"}, + Spec: tenancyv1alpha1.ClusterWorkspaceSpec{Type: tenancyv1alpha1.ClusterWorkspaceTypeReference{ + Name: "Foo", + Path: logicalcluster.From(cwt).String(), + }}, }, metav1.CreateOptions{}) + if err != nil { + t.Logf("error creating workspace: %v", err) + } return err == nil }, wait.ForeverTestTimeout, time.Millisecond*100, "failed to create workspace even with type")