From 154d5327d6f84b7bcf3e3611f94a3ebfe0f36fe1 Mon Sep 17 00:00:00 2001 From: Mangirdas Judeikis Date: Fri, 23 Oct 2026 16:26:17 +0300 Subject: [PATCH 1/7] Add APIIdentity --- config/crds/admin.kcp.io_apiidentities.yaml | 196 ++++++++ docs/content/concepts/apis/exporting-apis.md | 25 +- docs/content/concepts/sharding/shards.md | 13 + hack/update-codegen-clients.sh | 4 + hack/update-codegen-crds.sh | 3 +- pkg/admission/apiexport/admission.go | 48 ++ .../apiexport/admission_status_test.go | 87 ++++ pkg/cache/client/shard/shard.go | 6 + pkg/cache/server/bootstrap/bootstrap.go | 1 + .../admin/apiidentity/controller.go | 366 +++++++++++++++ .../admin/apiidentity/controller_test.go | 353 ++++++++++++++ pkg/reconciler/admin/apiidentity/registry.go | 48 ++ .../apis/apiexport/apiexport_controller.go | 53 +++ .../apiexport/apiexport_controller_test.go | 9 + .../apis/apiexport/apiexport_events_test.go | 2 +- .../apis/apiexport/apiexport_reconcile.go | 39 +- .../apiexport/apiexport_reconcile_identity.go | 186 ++++++++ .../apiexport_reconcile_identity_test.go | 197 ++++++++ pkg/server/controllers.go | 46 ++ pkg/server/server.go | 7 + pkg/virtual/admin/authorizer/authorizer.go | 10 +- .../admin/authorizer/authorizer_test.go | 47 +- pkg/virtual/admin/builder/apiidentities.go | 193 ++++++++ pkg/virtual/admin/builder/build.go | 44 +- pkg/virtual/admin/builder/build_test.go | 47 ++ pkg/virtual/admin/builder/forwarding.go | 10 +- pkg/virtual/admin/builder/schema.go | 17 +- .../kcp-dev/sdk/apis/admin/register.go | 25 + .../kcp-dev/sdk/apis/admin/v1alpha1/doc.go | 23 + .../sdk/apis/admin/v1alpha1/register.go | 52 +++ .../apis/admin/v1alpha1/types_apiidentity.go | 172 +++++++ .../admin/v1alpha1/zz_generated.deepcopy.go | 153 ++++++ .../admin/v1alpha1/zz_generated.model_name.go | 47 ++ .../sdk/apis/apis/v1alpha2/types_apiexport.go | 3 + .../admin/v1alpha1/apiidentity.go | 251 ++++++++++ .../admin/v1alpha1/apiidentitystatus.go | 89 ++++ .../admin/v1alpha1/exportreference.go | 63 +++ .../admin/v1alpha1/holder.go | 71 +++ .../sdk/client/applyconfiguration/utils.go | 138 +++--- .../client/clientset/versioned/clientset.go | 13 + .../clientset/versioned/cluster/clientset.go | 13 + .../versioned/cluster/fake/clientset.go | 13 + .../versioned/cluster/fake/register.go | 2 + .../versioned/cluster/scheme/register.go | 2 + .../typed/admin/v1alpha1/admin_client.go | 103 +++++ .../typed/admin/v1alpha1/apiidentity.go | 69 +++ .../cluster/typed/admin/v1alpha1/doc.go | 20 + .../typed/admin/v1alpha1/fake/admin_client.go | 61 +++ .../typed/admin/v1alpha1/fake/apiidentity.go | 89 ++++ .../cluster/typed/admin/v1alpha1/fake/doc.go | 20 + .../admin/v1alpha1/generated_expansion.go | 21 + .../versioned/fake/clientset_generated.go | 7 + .../clientset/versioned/fake/register.go | 2 + .../clientset/versioned/scheme/register.go | 2 + .../typed/admin/v1alpha1/admin_client.go | 102 ++++ .../typed/admin/v1alpha1/apiidentity.go | 71 +++ .../versioned/typed/admin/v1alpha1/doc.go | 20 + .../typed/admin/v1alpha1/fake/doc.go | 20 + .../admin/v1alpha1/fake/fake_admin_client.go | 41 ++ .../admin/v1alpha1/fake/fake_apiidentity.go | 54 +++ .../admin/v1alpha1/generated_expansion.go | 21 + .../externalversions/admin/interface.go | 67 +++ .../admin/v1alpha1/apiidentity.go | 182 ++++++++ .../admin/v1alpha1/interface.go | 64 +++ .../informers/externalversions/factory.go | 11 + .../informers/externalversions/generic.go | 36 +- .../listers/admin/v1alpha1/apiidentity.go | 102 ++++ .../admin/v1alpha1/expansion_generated.go | 27 ++ .../github.com/kcp-dev/sdk/cmd/apigen/main.go | 51 +- .../sdk/openapi/zz_generated.openapi.go | 437 ++++++++++++++---- .../apiexport_identity_registry_test.go | 210 +++++++++ test/e2e/virtual/admin/apiidentities_test.go | 156 +++++++ 72 files changed, 4987 insertions(+), 266 deletions(-) create mode 100644 config/crds/admin.kcp.io_apiidentities.yaml create mode 100644 pkg/admission/apiexport/admission_status_test.go create mode 100644 pkg/reconciler/admin/apiidentity/controller.go create mode 100644 pkg/reconciler/admin/apiidentity/controller_test.go create mode 100644 pkg/reconciler/admin/apiidentity/registry.go create mode 100644 pkg/reconciler/apis/apiexport/apiexport_reconcile_identity.go create mode 100644 pkg/reconciler/apis/apiexport/apiexport_reconcile_identity_test.go create mode 100644 pkg/virtual/admin/builder/apiidentities.go create mode 100644 staging/src/github.com/kcp-dev/sdk/apis/admin/register.go create mode 100644 staging/src/github.com/kcp-dev/sdk/apis/admin/v1alpha1/doc.go create mode 100644 staging/src/github.com/kcp-dev/sdk/apis/admin/v1alpha1/register.go create mode 100644 staging/src/github.com/kcp-dev/sdk/apis/admin/v1alpha1/types_apiidentity.go create mode 100644 staging/src/github.com/kcp-dev/sdk/apis/admin/v1alpha1/zz_generated.deepcopy.go create mode 100644 staging/src/github.com/kcp-dev/sdk/apis/admin/v1alpha1/zz_generated.model_name.go create mode 100644 staging/src/github.com/kcp-dev/sdk/client/applyconfiguration/admin/v1alpha1/apiidentity.go create mode 100644 staging/src/github.com/kcp-dev/sdk/client/applyconfiguration/admin/v1alpha1/apiidentitystatus.go create mode 100644 staging/src/github.com/kcp-dev/sdk/client/applyconfiguration/admin/v1alpha1/exportreference.go create mode 100644 staging/src/github.com/kcp-dev/sdk/client/applyconfiguration/admin/v1alpha1/holder.go create mode 100644 staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/cluster/typed/admin/v1alpha1/admin_client.go create mode 100644 staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/cluster/typed/admin/v1alpha1/apiidentity.go create mode 100644 staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/cluster/typed/admin/v1alpha1/doc.go create mode 100644 staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/cluster/typed/admin/v1alpha1/fake/admin_client.go create mode 100644 staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/cluster/typed/admin/v1alpha1/fake/apiidentity.go create mode 100644 staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/cluster/typed/admin/v1alpha1/fake/doc.go create mode 100644 staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/cluster/typed/admin/v1alpha1/generated_expansion.go create mode 100644 staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/typed/admin/v1alpha1/admin_client.go create mode 100644 staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/typed/admin/v1alpha1/apiidentity.go create mode 100644 staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/typed/admin/v1alpha1/doc.go create mode 100644 staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/typed/admin/v1alpha1/fake/doc.go create mode 100644 staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/typed/admin/v1alpha1/fake/fake_admin_client.go create mode 100644 staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/typed/admin/v1alpha1/fake/fake_apiidentity.go create mode 100644 staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/typed/admin/v1alpha1/generated_expansion.go create mode 100644 staging/src/github.com/kcp-dev/sdk/client/informers/externalversions/admin/interface.go create mode 100644 staging/src/github.com/kcp-dev/sdk/client/informers/externalversions/admin/v1alpha1/apiidentity.go create mode 100644 staging/src/github.com/kcp-dev/sdk/client/informers/externalversions/admin/v1alpha1/interface.go create mode 100644 staging/src/github.com/kcp-dev/sdk/client/listers/admin/v1alpha1/apiidentity.go create mode 100644 staging/src/github.com/kcp-dev/sdk/client/listers/admin/v1alpha1/expansion_generated.go create mode 100644 test/e2e/apibinding/apiexport_identity_registry_test.go create mode 100644 test/e2e/virtual/admin/apiidentities_test.go diff --git a/config/crds/admin.kcp.io_apiidentities.yaml b/config/crds/admin.kcp.io_apiidentities.yaml new file mode 100644 index 00000000000..4c4a58d1c95 --- /dev/null +++ b/config/crds/admin.kcp.io_apiidentities.yaml @@ -0,0 +1,196 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.3 + name: apiidentities.admin.kcp.io +spec: + group: admin.kcp.io + names: + categories: + - kcp + kind: APIIdentity + listKind: APIIdentityList + plural: apiidentities + singular: apiidentity + scope: Cluster + versions: + - additionalPrinterColumns: + - description: APIExport owning the identity + jsonPath: .status.owner.name + name: Owner + type: string + - description: Logical cluster of the owner + jsonPath: .status.owner.cluster + name: Cluster + type: string + - jsonPath: .status.conditions[?(@.type=="Shared")].status + name: Holders + priority: 1 + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + APIIdentity is the registry entry of one API identity: the record of which + APIExport owns the identity and which other APIExports hold it. There is + exactly one entry per identity in an installation, named after the + identity value, living on the cache server where every shard can read it + and served read-only through the Admin workspace. + + Entries are written by kcp only. The owner is recorded when the identity + is first claimed, which is the atomic create of the entry; holders record + themselves when they get the identity. When the owner's APIExport is + deleted the identity passes to the oldest holder. Nothing that could be + used to obtain the identity is recorded here, only who has it. + + The object has a status field but no status subresource: it lives on the + cache server, whose status subresource does not honor shard scopes, and it + is written by kcp only, so spec and status are written together in one + update of the whole object. + 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 + status: + description: |- + APIIdentityStatus is the derived state of an identity. The object has no + spec: nothing about it is user intent. + properties: + conditions: + description: conditions is a list of conditions that apply to the + identity. + items: + description: Condition defines an observation of a object operational + state. + properties: + lastTransitionTime: + description: |- + Last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when + the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + A human readable message indicating details about the transition. + This field may be empty. + type: string + reason: + description: |- + The reason for the condition's last transition in CamelCase. + The specific API may choose whether or not this field is considered a guaranteed API. + This field may not be empty. + type: string + severity: + description: |- + Severity provides an explicit classification of Reason code, so the users or machines can immediately + understand the current situation and act accordingly. + The Severity field MUST be set only when Status=False. + type: string + status: + description: Status of the condition, one of True, False, Unknown. + type: string + type: + description: |- + Type of condition in CamelCase or in foo.example.com/CamelCase. + Many .condition.type values are consistent across resources like Available, but because arbitrary conditions + can be useful (see .node.status.conditions), the ability to deconflict is important. + type: string + required: + - lastTransitionTime + - status + - type + type: object + type: array + core: + description: |- + core is true for the identities of the core APIExports in the root + workspace, which every shard needs before it can serve. + type: boolean + holders: + description: |- + holders lists the APIExports, other than the owner, that serve the + identity: those whose identity secret carries the same key. A holder + records itself when it gets the identity and is dropped when it is + deleted. + items: + description: Holder is an APIExport, other than the owner, that + serves the identity. + properties: + cluster: + description: cluster is the logical cluster name hosting the + APIExport. + minLength: 1 + type: string + name: + description: name is the name of the APIExport. + minLength: 1 + type: string + since: + description: since is when the holder was recorded. + format: date-time + type: string + uid: + description: |- + uid is the UID of the APIExport, so that an export deleted and + recreated under the same name can be told apart from the original. + type: string + required: + - cluster + - name + type: object + type: array + x-kubernetes-list-map-keys: + - cluster + - name + x-kubernetes-list-type: map + owner: + description: |- + owner is the APIExport that holds the identity authoritatively: the + first one to use it. It is written by kcp when the identity is claimed + and changed by kcp only when the owner is deleted and a holder + succeeds it, or an orphaned identity is reclaimed. + properties: + cluster: + description: cluster is the logical cluster name hosting the APIExport. + minLength: 1 + type: string + name: + description: name is the name of the APIExport. + minLength: 1 + type: string + uid: + description: |- + uid is the UID of the APIExport, so that an export deleted and + recreated under the same name can be told apart from the original. + type: string + required: + - cluster + - name + type: object + required: + - owner + type: object + type: object + served: true + storage: true + subresources: {} diff --git a/docs/content/concepts/apis/exporting-apis.md b/docs/content/concepts/apis/exporting-apis.md index 762b781bf91..e664d575d66 100644 --- a/docs/content/concepts/apis/exporting-apis.md +++ b/docs/content/concepts/apis/exporting-apis.md @@ -163,15 +163,34 @@ above), kcp automatically generates one for you. The identity is always stored i the `APIExport.` By default, it is created in the `kcp-system` namespace in a secret whose name is the name of the `APIExport`. -An `APIExport`'s identity is similar to a private key; you should never share it with anyone. +An `APIExport`'s identity key is similar to a private key; you should never share it with anyone. -The identity's **hash** is similar to a public key; it is not private and there are times when other consumers of your -exported APIs need to know and reference it. +The identity's **hash** (`status.identityHash`) is similar to a public key; it is not private and there are times +when other consumers of your exported APIs need to know and reference it. Given 2 Workspaces, each with its own `APIExport` that exports `widgets.example.kcp.io`, kcp uses the identity hash (in a mostly transparent manner) to ensure the correct instances associated with the appropriate `APIResourceSchema` are served to clients. See [Build Your Controller](#build-your-controller) for more information. +#### Sharing an identity with another APIExport + +Two `APIExports` whose secrets carry the same `key` have the same identity: they serve the same resources out of the +same storage, which is how an `APIExport` is split into two or moved to another workspace without touching the +consumers' objects. Today that works by copying the secret, and it keeps working. + +kcp records every identity in a registry (`APIIdentity` in `admin.kcp.io`, on the cache server, listed read-only +in the [Admin workspace](../sharding/shards.md#the-admin-workspace-admin) with `kubectl ws use :admin`): the first +`APIExport` to use an identity is its **owner**, every other `APIExport` whose secret carries the same `key` is a +**holder**, and the entry names both. An identity with holders carries the `Shared` condition. The registry does +not change who may serve an identity, which is still whoever has the secret; it makes sharing visible and gives the +identity a lifecycle of its own. + +When the owner's `APIExport` is deleted, the identity passes to the oldest holder. The decision is made from the +`APIExports` replicated to the cache server: an owner missing from the cache is marked `OwnerMissing`, and after a +grace period it is taken as gone. An identity with no owner and no holder left is marked `Orphaned` and is reclaimed by +the next `APIExport` whose secret carries the original `key`. +`status.identityHash` is set once the identity is recorded in the registry and is immutable afterwards. + ### Permission Claims When a consumer creates an `APIBinding` that binds to an `APIExport`, the API provider who owns the `APIExport` diff --git a/docs/content/concepts/sharding/shards.md b/docs/content/concepts/sharding/shards.md index 0e42b367969..93e3699c011 100644 --- a/docs/content/concepts/sharding/shards.md +++ b/docs/content/concepts/sharding/shards.md @@ -108,6 +108,19 @@ root us-east-2 https://127.0.0.1:6444 ... shard-1 us-east-1 https://127.0.0.1:6445 ... ``` +The Admin workspace also serves the API identity registry read-only: +`apiidentities.admin.kcp.io` lists every APIExport identity in the +installation, named by the identity hash, with the owning `APIExport`, the +other exports holding it and whether it is a core identity. See +[Exporting APIs](../apis/exporting-apis.md#sharing-an-identity-with-another-apiexport). + +```sh +$ kubectl get apiidentities +NAME OWNER CLUSTER AGE +0e2fc4327ee7af2b2b9a0f2b4b1a8c4c7a0d6c2f0f1b6e5a9d8c7b6a5f4e3d2c tenancy.kcp.io root 3d +5fdf7c7aaf407fd1594566869803f565bb84d22156cef5c445d2ee13ac2cfca6 widgets 2cynbfy2m0wtjqcs 2h +``` + Access requires membership in the `system:kcp:admin` group; the serving path does not depend on the root shard. To reach the view through a front-proxy, add a `/services/` path mapping; any shard can be the backend. diff --git a/hack/update-codegen-clients.sh b/hack/update-codegen-clients.sh index 1b46bca3310..f23d03fa77c 100755 --- a/hack/update-codegen-clients.sh +++ b/hack/update-codegen-clients.sh @@ -51,6 +51,7 @@ mkdir -p ${SDK_PKG}/client/{clientset,applyconfiguration,listers,informers} github.com/kcp-dev/sdk/apis/apis/v1alpha1 \ github.com/kcp-dev/sdk/apis/apis/v1alpha2 \ github.com/kcp-dev/sdk/apis/cache/v1alpha1 \ + github.com/kcp-dev/sdk/apis/admin/v1alpha1 \ github.com/kcp-dev/sdk/apis/migration/v1alpha1 \ github.com/kcp-dev/sdk/apis/topology/v1alpha1 \ github.com/kcp-dev/sdk/apis/third_party/conditions/apis/conditions/v1alpha1 \ @@ -68,6 +69,7 @@ mkdir -p ${SDK_PKG}/client/{clientset,applyconfiguration,listers,informers} --input github.com/kcp-dev/sdk/apis/apis/v1alpha2 \ --input github.com/kcp-dev/sdk/apis/topology/v1alpha1 \ --input github.com/kcp-dev/sdk/apis/cache/v1alpha1 \ + --input github.com/kcp-dev/sdk/apis/admin/v1alpha1 \ --input github.com/kcp-dev/sdk/apis/migration/v1alpha1 \ --input-base="" \ --apply-configuration-package=github.com/kcp-dev/sdk/client/applyconfiguration \ @@ -151,6 +153,7 @@ go install "${OPENAPI_PKG}"/cmd/openapi-gen github.com/kcp-dev/sdk/apis/apis/v1alpha2 \ github.com/kcp-dev/sdk/apis/topology/v1alpha1 \ github.com/kcp-dev/sdk/apis/cache/v1alpha1 \ + github.com/kcp-dev/sdk/apis/admin/v1alpha1 \ github.com/kcp-dev/sdk/apis/migration/v1alpha1 \ github.com/kcp-dev/sdk/apis/third_party/conditions/apis/conditions/v1alpha1 @@ -165,6 +168,7 @@ go install "${OPENAPI_PKG}"/cmd/openapi-gen github.com/kcp-dev/sdk/apis/apis/v1alpha2 \ github.com/kcp-dev/sdk/apis/topology/v1alpha1 \ github.com/kcp-dev/sdk/apis/cache/v1alpha1 \ + github.com/kcp-dev/sdk/apis/admin/v1alpha1 \ github.com/kcp-dev/sdk/apis/migration/v1alpha1 \ github.com/kcp-dev/sdk/apis/third_party/conditions/apis/conditions/v1alpha1 \ k8s.io/apimachinery/pkg/apis/meta/v1 \ diff --git a/hack/update-codegen-crds.sh b/hack/update-codegen-crds.sh index 8ceaecf07af..7a6456bf4d3 100755 --- a/hack/update-codegen-crds.sh +++ b/hack/update-codegen-crds.sh @@ -47,7 +47,8 @@ done ( ${KCP_APIGEN_GEN} --input-dir "${REPO_ROOT}"/config/crds --output-dir "${REPO_ROOT}"/config/root-phase0 \ - --ignore-export-schemas cachedobjects.cache.kcp.io + --ignore-export-schemas cachedobjects.cache.kcp.io \ + --ignore-schemas apiidentities.admin.kcp.io ) diff --git a/pkg/admission/apiexport/admission.go b/pkg/admission/apiexport/admission.go index f8c66fcd249..68a1a42eca4 100644 --- a/pkg/admission/apiexport/admission.go +++ b/pkg/admission/apiexport/admission.go @@ -27,6 +27,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/validation/field" "k8s.io/apiserver/pkg/admission" + "k8s.io/apiserver/pkg/authentication/user" "github.com/kcp-dev/sdk/apis/apis" apisv1alpha1 "github.com/kcp-dev/sdk/apis/apis/v1alpha1" @@ -75,6 +76,13 @@ func (e *APIExportAdmission) Validate(ctx context.Context, a admission.Attribute return fmt.Errorf("unexpected type %T", a.GetObject()) } + if a.GetSubresource() == "status" { + if a.GetOperation() == admission.Update { + return validateStatusUpdate(a, u) + } + return nil + } + switch a.GetKind().GroupVersion().Version { case apisv1alpha1.SchemeGroupVersion.Version: // v1alpha1 is deprecated, but we still need to support it for a while @@ -124,6 +132,46 @@ func (e *APIExportAdmission) Validate(ctx context.Context, a admission.Attribute return nil } +// validateStatusUpdate keeps status.identityHash immutable for everyone but +// kcp itself. The identity keys the storage of every consumer's bound +// instances and is what permission claims and wildcard clients address the +// export by; a status write that changed it would make the writer's export +// serve another export's consumers' data. kcp changes it only to withdraw an +// authorization. The field sits at the same path in every served version. +func validateStatusUpdate(a admission.Attributes, u *unstructured.Unstructured) error { + old, ok := a.GetOldObject().(*unstructured.Unstructured) + if !ok { + return fmt.Errorf("unexpected type %T", a.GetOldObject()) + } + oldHash, _, err := unstructured.NestedString(old.Object, "status", "identityHash") + if err != nil { + return fmt.Errorf("failed to read status.identityHash: %w", err) + } + newHash, _, err := unstructured.NestedString(u.Object, "status", "identityHash") + if err != nil { + return fmt.Errorf("failed to read status.identityHash: %w", err) + } + if oldHash == "" || newHash == oldHash || isSystemPrivileged(a) { + return nil + } + return admission.NewForbidden(a, + field.Forbidden( + field.NewPath("status").Child("identityHash"), + "identityHash is immutable once set")) +} + +func isSystemPrivileged(a admission.Attributes) bool { + if a.GetUserInfo() == nil { + return false + } + for _, g := range a.GetUserInfo().GetGroups() { + if g == user.SystemPrivilegedGroup { + return true + } + } + return false +} + func (e *APIExportAdmission) validatev1alpha2(_ context.Context, a admission.Attributes, ae *apisv1alpha2.APIExport) (err error) { for i, pc := range ae.Spec.PermissionClaims { if pc.IdentityHash == "" && !e.isBuiltIn(pc.GroupResource) && pc.Group != apis.GroupName { diff --git a/pkg/admission/apiexport/admission_status_test.go b/pkg/admission/apiexport/admission_status_test.go new file mode 100644 index 00000000000..21fb0407a5b --- /dev/null +++ b/pkg/admission/apiexport/admission_status_test.go @@ -0,0 +1,87 @@ +/* +Copyright 2026 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" + "testing" + + "github.com/stretchr/testify/require" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apiserver/pkg/admission" + "k8s.io/apiserver/pkg/authentication/user" + + "github.com/kcp-dev/sdk/apis/apis" + apisv1alpha2 "github.com/kcp-dev/sdk/apis/apis/v1alpha2" + + "github.com/kcp-dev/kcp/pkg/admission/helpers" +) + +func statusAttr(oldHash, newHash string, groups ...string) admission.Attributes { + export := func(hash string) *apisv1alpha2.APIExport { + return &apisv1alpha2.APIExport{ + ObjectMeta: metav1.ObjectMeta{Name: "cool-something"}, + Status: apisv1alpha2.APIExportStatus{IdentityHash: hash}, + } + } + return admission.NewAttributesRecord( + helpers.ToUnstructuredOrDie(export(newHash)), + helpers.ToUnstructuredOrDie(export(oldHash)), + apisv1alpha2.Kind("APIExport").WithVersion("v1alpha2"), + "", + "cool-something", + apisv1alpha2.Resource("apiexports").WithVersion("v1alpha2"), + "status", + admission.Update, + &metav1.UpdateOptions{}, + false, + &user.DefaultInfo{Name: "someone", Groups: groups}, + ) +} + +func TestStatusIdentityHashImmutable(t *testing.T) { + t.Parallel() + + cases := map[string]struct { + oldHash, newHash string + groups []string + wantErr string + }{ + "first assignment is allowed": {oldHash: "", newHash: "abc"}, + "unchanged is allowed": {oldHash: "abc", newHash: "abc"}, + "other status fields may change": {oldHash: "", newHash: ""}, + "changing it is forbidden": {oldHash: "abc", newHash: "def", wantErr: "identityHash is immutable once set"}, + "clearing it is forbidden": {oldHash: "abc", newHash: "", wantErr: "identityHash is immutable once set"}, + "kcp itself may withdraw it": {oldHash: "abc", newHash: "", groups: []string{user.SystemPrivilegedGroup}}, + "kcp itself may change it": {oldHash: "abc", newHash: "def", groups: []string{user.SystemPrivilegedGroup}}, + "a workspace admin group does not help": {oldHash: "abc", newHash: "def", groups: []string{"system:kcp:workspace:access", "system:authenticated"}, wantErr: "identityHash is immutable once set"}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + plugin := NewAPIExportAdmission(func(apis.GroupResource) bool { return false }) + err := plugin.Validate(context.Background(), statusAttr(tc.oldHash, tc.newHash, tc.groups...), nil) + if tc.wantErr == "" { + require.NoError(t, err) + return + } + require.Error(t, err) + require.Contains(t, err.Error(), tc.wantErr) + }) + } +} diff --git a/pkg/cache/client/shard/shard.go b/pkg/cache/client/shard/shard.go index b9d9a860fee..f8d8003a09f 100644 --- a/pkg/cache/client/shard/shard.go +++ b/pkg/cache/client/shard/shard.go @@ -29,6 +29,12 @@ const ( // to assign resources to a particular shard. type Name string +// Registry is the shard scope on the cache server under which kcp keeps +// installation-wide registry objects that any shard may write, such as the +// APIIdentity entries of admin.kcp.io. Every shard writes under this one +// scope, so a create of a given name is atomic across the installation. +const Registry Name = "system:registry" + var ( // Wildcard is the name indicating cross-shards requests. Wildcard = Name("*") diff --git a/pkg/cache/server/bootstrap/bootstrap.go b/pkg/cache/server/bootstrap/bootstrap.go index 31585a1e048..54d21e19527 100644 --- a/pkg/cache/server/bootstrap/bootstrap.go +++ b/pkg/cache/server/bootstrap/bootstrap.go @@ -48,6 +48,7 @@ func Bootstrap(ctx context.Context, apiExtensionsClusterClient kcpapiextensionsc {"core.kcp.io", "logicalclusters"}, {"migration.kcp.io", "logicalclustermigrations"}, {"core.kcp.io", "shards"}, + {"admin.kcp.io", "apiidentities"}, {"cache.kcp.io", "cachedobjects"}, {"cache.kcp.io", "clustercachedresources"}, {"cache.kcp.io", "clustercachedresourceendpointslices"}, diff --git a/pkg/reconciler/admin/apiidentity/controller.go b/pkg/reconciler/admin/apiidentity/controller.go new file mode 100644 index 00000000000..d0d1af4c656 --- /dev/null +++ b/pkg/reconciler/admin/apiidentity/controller.go @@ -0,0 +1,366 @@ +/* +Copyright 2026 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 apiidentity maintains the APIIdentity registry on the cache server. +// +// Every identity has one registry entry, created by the APIExport controller +// of the shard whose export claimed it first. This controller runs on every +// shard and decides from the APIExports replicated to the cache server, +// which is the installation-wide view: the shard hosting the owner keeps the +// derived status current; an owner missing from the cache for longer than +// the grace period is gone, and the shard hosting the oldest remaining +// holder takes the identity over, or the identity is orphaned when no holder +// is left. +package apiidentity + +import ( + "context" + "fmt" + "sort" + "time" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/equality" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/tools/cache" + "k8s.io/client-go/util/retry" + "k8s.io/client-go/util/workqueue" + "k8s.io/klog/v2" + + "github.com/kcp-dev/logicalcluster/v3" + adminv1alpha1 "github.com/kcp-dev/sdk/apis/admin/v1alpha1" + apisv1alpha2 "github.com/kcp-dev/sdk/apis/apis/v1alpha2" + "github.com/kcp-dev/sdk/apis/third_party/conditions/util/conditions" + kcpclientset "github.com/kcp-dev/sdk/client/clientset/versioned/cluster" + adminv1alpha1informers "github.com/kcp-dev/sdk/client/informers/externalversions/admin/v1alpha1" + apisv1alpha2informers "github.com/kcp-dev/sdk/client/informers/externalversions/apis/v1alpha2" + + configshard "github.com/kcp-dev/kcp/config/shard" + "github.com/kcp-dev/kcp/pkg/logging" + "github.com/kcp-dev/kcp/pkg/reconciler/events" + "github.com/kcp-dev/kcp/pkg/tombstone" +) + +const ( + ControllerName = "kcp-apiidentity" + + // MissingGracePeriod is how long an export may be missing from the cache + // server before it is taken as gone. It covers the window between an + // entry being written and the export's replicated copy arriving, and a + // copy disappearing for a moment, as during a workspace migration. + MissingGracePeriod = 30 * time.Second +) + +// NewController creates the registry controller of this shard. +func NewController( + cacheKcpClusterClient kcpclientset.ClusterInterface, + apiIdentityInformer adminv1alpha1informers.APIIdentityClusterInformer, + apiExportInformer, globalAPIExportInformer apisv1alpha2informers.APIExportClusterInformer, + isCore func(*apisv1alpha2.APIExport) bool, +) (*Controller, error) { + queue := workqueue.NewTypedRateLimitingQueueWithConfig( + workqueue.DefaultTypedControllerRateLimiter[string](), + workqueue.TypedRateLimitingQueueConfig[string]{Name: ControllerName}, + ) + c := &Controller{ + queue: queue, + registry: registryScoped{cacheKcpClusterClient.Cluster(configshard.SystemShardCluster.Path()).AdminV1alpha1().APIIdentities()}, + getAPIIdentity: func(name string) (*adminv1alpha1.APIIdentity, error) { + return apiIdentityInformer.Lister().Cluster(configshard.SystemShardCluster).Get(name) + }, + getLocalAPIExport: func(clusterName logicalcluster.Name, name string) (*apisv1alpha2.APIExport, error) { + return apiExportInformer.Lister().Cluster(clusterName).Get(name) + }, + getGlobalAPIExport: func(clusterName logicalcluster.Name, name string) (*apisv1alpha2.APIExport, error) { + return globalAPIExportInformer.Lister().Cluster(clusterName).Get(name) + }, + requeueAfter: queue.AddAfter, + isCore: isCore, + } + + _, _ = apiIdentityInformer.Informer().AddEventHandler(events.WithoutSyncs(cache.ResourceEventHandlerFuncs{ + AddFunc: func(obj interface{}) { c.enqueue(obj.(*adminv1alpha1.APIIdentity).Name) }, + UpdateFunc: func(_, newObj interface{}) { c.enqueue(newObj.(*adminv1alpha1.APIIdentity).Name) }, + })) + + // local exports: the owner's shard maintains the entry. + _, _ = apiExportInformer.Informer().AddEventHandler(events.WithoutSyncs(exportHandler(c))) + + // replicated exports: the installation-wide view every decision is made + // from. An export appearing in or vanishing from the cache changes what + // the entry should say. + _, _ = globalAPIExportInformer.Informer().AddEventHandler(events.WithoutSyncs(exportHandler(c))) + + return c, nil +} + +func exportHandler(c *Controller) cache.ResourceEventHandlerFuncs { + return cache.ResourceEventHandlerFuncs{ + AddFunc: func(obj interface{}) { c.enqueueExport(obj.(*apisv1alpha2.APIExport)) }, + UpdateFunc: func(oldObj, newObj interface{}) { + oldExport, newExport := oldObj.(*apisv1alpha2.APIExport), newObj.(*apisv1alpha2.APIExport) + if oldExport.Status.IdentityHash != newExport.Status.IdentityHash { + c.enqueueExport(oldExport) + } + c.enqueueExport(newExport) + }, + DeleteFunc: func(obj interface{}) { c.enqueueExport(tombstone.Obj[*apisv1alpha2.APIExport](obj)) }, + } +} + +// Controller maintains the registry entries from the cache server's view of +// the APIExports. +type Controller struct { + queue workqueue.TypedRateLimitingInterface[string] + + registry adminv1alpha1client + getAPIIdentity func(name string) (*adminv1alpha1.APIIdentity, error) + getLocalAPIExport func(clusterName logicalcluster.Name, name string) (*apisv1alpha2.APIExport, error) + getGlobalAPIExport func(clusterName logicalcluster.Name, name string) (*apisv1alpha2.APIExport, error) + requeueAfter func(identity string, delay time.Duration) + isCore func(*apisv1alpha2.APIExport) bool +} + +func (c *Controller) enqueue(identity string) { + if identity == "" { + return + } + c.queue.Add(identity) +} + +func (c *Controller) enqueueExport(export *apisv1alpha2.APIExport) { + c.enqueue(export.Status.IdentityHash) +} + +func (c *Controller) Start(ctx context.Context, numThreads int) { + defer utilruntime.HandleCrash() + defer c.queue.ShutDown() + + logger := logging.WithReconciler(klog.FromContext(ctx), ControllerName) + ctx = klog.NewContext(ctx, logger) + logger.Info("Starting controller") + defer logger.Info("Shutting down controller") + + for range numThreads { + go wait.UntilWithContext(ctx, c.startWorker, time.Second) + } + <-ctx.Done() +} + +func (c *Controller) startWorker(ctx context.Context) { + for c.processNextWorkItem(ctx) { + } +} + +func (c *Controller) processNextWorkItem(ctx context.Context) bool { + key, quit := c.queue.Get() + if quit { + return false + } + defer c.queue.Done(key) + + logger := logging.WithQueueKey(klog.FromContext(ctx), key) + ctx = klog.NewContext(ctx, logger) + if err := c.reconcile(ctx, key); err != nil { + utilruntime.HandleError(fmt.Errorf("%s failed to reconcile identity %q: %w", ControllerName, key, err)) + c.queue.AddRateLimited(key) + return true + } + c.queue.Forget(key) + return true +} + +func (c *Controller) reconcile(ctx context.Context, identity string) error { + entry, err := c.getAPIIdentity(identity) + if apierrors.IsNotFound(err) { + return nil + } + if err != nil { + return err + } + + owner, err := c.getGlobalAPIExport(logicalcluster.Name(entry.Status.Owner.Cluster), entry.Status.Owner.Name) + if err != nil && !apierrors.IsNotFound(err) { + return err + } + if err == nil { + return c.maintain(ctx, entry, owner) + } + return c.ownerMissing(ctx, entry) +} + +// maintain keeps the derived status of an entry whose owner is in the cache. +// Any shard clears a stale OwnerMissing; the shard hosting the owner writes +// the rest. +func (c *Controller) maintain(ctx context.Context, entry *adminv1alpha1.APIIdentity, owner *apisv1alpha2.APIExport) error { + _, err := c.getLocalAPIExport(logicalcluster.From(owner), owner.Name) + if err != nil && !apierrors.IsNotFound(err) { + return err + } + hostsOwner := err == nil + + return c.updateStatus(ctx, entry.Name, func(entry *adminv1alpha1.APIIdentity) { + conditions.Delete(entry, adminv1alpha1.APIIdentityOwnerMissing) + if !hostsOwner { + return + } + entry.Status.Core = c.isCore(owner) + // an owner that succeeded from holder is not a holder any more + dropHolders(entry, append(c.goneHolders(entry), ref(owner))) + conditions.Delete(entry, adminv1alpha1.APIIdentityOrphaned) + }) +} + +// ownerMissing handles an entry whose owner is not in the cache. The first +// look records when it went missing; after the grace period the owner is +// gone and the identity passes to the oldest holder still in the cache, or +// is orphaned. +func (c *Controller) ownerMissing(ctx context.Context, entry *adminv1alpha1.APIIdentity) error { + logger := klog.FromContext(ctx) + + if conditions.IsTrue(entry, adminv1alpha1.APIIdentityOrphaned) { + // nobody serves it; whoever has the key reclaims it + return nil + } + + missing := conditions.Get(entry, adminv1alpha1.APIIdentityOwnerMissing) + if missing == nil || missing.Status != corev1.ConditionTrue { + logger.V(2).Info("owner of identity is not in the cache, waiting for the grace period", "owner", entry.Status.Owner.Cluster+"|"+entry.Status.Owner.Name) + if err := c.updateStatus(ctx, entry.Name, func(entry *adminv1alpha1.APIIdentity) { + conditions.MarkTrue(entry, adminv1alpha1.APIIdentityOwnerMissing) + }); err != nil { + return err + } + c.requeueAfter(entry.Name, MissingGracePeriod) + return nil + } + if remaining := MissingGracePeriod - time.Since(missing.LastTransitionTime.Time); remaining > 0 { + c.requeueAfter(entry.Name, remaining) + return nil + } + + // the owner is gone + successor := c.oldestHolderInCache(entry) + if successor == nil { + logger.Info("identity has no owner and no holder left, marking it orphaned") + return c.updateStatus(ctx, entry.Name, func(entry *adminv1alpha1.APIIdentity) { + dropHolders(entry, holderRefs(entry)) + conditions.Delete(entry, adminv1alpha1.APIIdentityOwnerMissing) + conditions.MarkTrue(entry, adminv1alpha1.APIIdentityOrphaned) + }) + } + + if _, err := c.getLocalAPIExport(logicalcluster.Name(successor.Cluster), successor.Name); err != nil { + if apierrors.IsNotFound(err) { + return nil // the successor's shard performs the succession + } + return err + } + + logger.Info("owner of identity is gone, passing it to the oldest holder", "successor", successor.Cluster+"|"+successor.Name) + return c.updateStatus(ctx, entry.Name, func(entry *adminv1alpha1.APIIdentity) { + entry.Status.Owner = successor.ExportReference + dropHolders(entry, []adminv1alpha1.ExportReference{successor.ExportReference}) + conditions.Delete(entry, adminv1alpha1.APIIdentityOwnerMissing) + conditions.MarkFalse(entry, adminv1alpha1.APIIdentityOrphaned, adminv1alpha1.APIIdentityOwnerSucceededReason, "", "") + }) +} + +// oldestHolderInCache returns the holder that has been recorded the longest +// among those whose export is in the cache, or nil. +func (c *Controller) oldestHolderInCache(entry *adminv1alpha1.APIIdentity) *adminv1alpha1.Holder { + holders := append([]adminv1alpha1.Holder(nil), entry.Status.Holders...) + sort.SliceStable(holders, func(i, j int) bool { + if !holders[i].Since.Equal(&holders[j].Since) { + return holders[i].Since.Before(&holders[j].Since) + } + return holders[i].Cluster+"|"+holders[i].Name < holders[j].Cluster+"|"+holders[j].Name + }) + for i := range holders { + if _, err := c.getGlobalAPIExport(logicalcluster.Name(holders[i].Cluster), holders[i].Name); err == nil { + return &holders[i] + } + } + return nil +} + +// goneHolders lists the holders that are not in the cache and were recorded +// longer ago than the grace period. A younger one may still be replicating. +func (c *Controller) goneHolders(entry *adminv1alpha1.APIIdentity) []adminv1alpha1.ExportReference { + var gone []adminv1alpha1.ExportReference + for _, holder := range entry.Status.Holders { + if time.Since(holder.Since.Time) < MissingGracePeriod { + continue + } + if _, err := c.getGlobalAPIExport(logicalcluster.Name(holder.Cluster), holder.Name); apierrors.IsNotFound(err) { + gone = append(gone, holder.ExportReference) + } + } + return gone +} + +func holderRefs(entry *adminv1alpha1.APIIdentity) []adminv1alpha1.ExportReference { + refs := make([]adminv1alpha1.ExportReference, 0, len(entry.Status.Holders)) + for _, holder := range entry.Status.Holders { + refs = append(refs, holder.ExportReference) + } + return refs +} + +// dropHolders removes the given holders and keeps the Shared condition in +// step with the remaining ones. +func dropHolders(entry *adminv1alpha1.APIIdentity, refs []adminv1alpha1.ExportReference) { + for _, r := range refs { + removeHolder(entry, r) + } + if len(entry.Status.Holders) > 0 { + conditions.MarkTrue(entry, adminv1alpha1.APIIdentityShared) + } else { + conditions.Delete(entry, adminv1alpha1.APIIdentityShared) + } +} + +func (c *Controller) updateStatus(ctx context.Context, name string, mutate func(*adminv1alpha1.APIIdentity)) error { + return retry.RetryOnConflict(retry.DefaultBackoff, func() error { + current, err := c.registry.Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return err + } + desired := current.DeepCopy() + mutate(desired) + if equality.Semantic.DeepEqual(current.Status, desired.Status) { + return nil + } + _, err = c.registry.Update(ctx, desired, metav1.UpdateOptions{}) + return err + }) +} + +func ref(export *apisv1alpha2.APIExport) adminv1alpha1.ExportReference { + return adminv1alpha1.ExportReference{Cluster: logicalcluster.From(export).String(), Name: export.Name, UID: string(export.UID)} +} + +func removeHolder(entry *adminv1alpha1.APIIdentity, r adminv1alpha1.ExportReference) { + for i := range entry.Status.Holders { + if entry.Status.Holders[i].Equal(r) { + entry.Status.Holders = append(entry.Status.Holders[:i], entry.Status.Holders[i+1:]...) + return + } + } +} diff --git a/pkg/reconciler/admin/apiidentity/controller_test.go b/pkg/reconciler/admin/apiidentity/controller_test.go new file mode 100644 index 00000000000..a9d8c6d7c13 --- /dev/null +++ b/pkg/reconciler/admin/apiidentity/controller_test.go @@ -0,0 +1,353 @@ +/* +Copyright 2026 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 apiidentity + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/util/workqueue" + + "github.com/kcp-dev/logicalcluster/v3" + adminv1alpha1 "github.com/kcp-dev/sdk/apis/admin/v1alpha1" + apisv1alpha2 "github.com/kcp-dev/sdk/apis/apis/v1alpha2" + "github.com/kcp-dev/sdk/apis/third_party/conditions/util/conditions" +) + +const identity = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + +// fakeRegistry is an in-memory APIIdentity store. +type fakeRegistry struct { + mu sync.Mutex + entries map[string]*adminv1alpha1.APIIdentity +} + +func (r *fakeRegistry) Get(_ context.Context, name string, _ metav1.GetOptions) (*adminv1alpha1.APIIdentity, error) { + r.mu.Lock() + defer r.mu.Unlock() + entry, ok := r.entries[name] + if !ok { + return nil, apierrors.NewNotFound(adminv1alpha1.Resource("apiidentities"), name) + } + return entry.DeepCopy(), nil +} + +func (r *fakeRegistry) Update(_ context.Context, entry *adminv1alpha1.APIIdentity, _ metav1.UpdateOptions) (*adminv1alpha1.APIIdentity, error) { + r.mu.Lock() + defer r.mu.Unlock() + r.entries[entry.Name] = entry.DeepCopy() + return entry.DeepCopy(), nil +} + +// fixture is one shard's view: the exports it hosts (local), the exports +// replicated to the cache (global, which includes the local ones) and the +// registry. +type fixture struct { + t *testing.T + registry *fakeRegistry + local map[string]*apisv1alpha2.APIExport // cluster|name + global map[string]*apisv1alpha2.APIExport + requeued []time.Duration + c *Controller +} + +func newFixture(t *testing.T) *fixture { + t.Helper() + f := &fixture{ + t: t, + registry: &fakeRegistry{entries: map[string]*adminv1alpha1.APIIdentity{}}, + local: map[string]*apisv1alpha2.APIExport{}, + global: map[string]*apisv1alpha2.APIExport{}, + } + lookup := func(m map[string]*apisv1alpha2.APIExport) func(logicalcluster.Name, string) (*apisv1alpha2.APIExport, error) { + return func(clusterName logicalcluster.Name, name string) (*apisv1alpha2.APIExport, error) { + if export, ok := m[clusterName.String()+"|"+name]; ok { + return export, nil + } + return nil, apierrors.NewNotFound(apisv1alpha2.Resource("apiexports"), name) + } + } + f.c = &Controller{ + queue: workqueue.NewTypedRateLimitingQueueWithConfig( + workqueue.DefaultTypedControllerRateLimiter[string](), + workqueue.TypedRateLimitingQueueConfig[string]{Name: ControllerName}, + ), + registry: f.registry, + getAPIIdentity: func(name string) (*adminv1alpha1.APIIdentity, error) { + return f.registry.Get(context.Background(), name, metav1.GetOptions{}) + }, + getLocalAPIExport: lookup(f.local), + getGlobalAPIExport: lookup(f.global), + requeueAfter: func(_ string, delay time.Duration) { f.requeued = append(f.requeued, delay) }, + isCore: func(*apisv1alpha2.APIExport) bool { return false }, + } + return f +} + +// host puts an export on this shard, which also replicates it to the cache. +func (f *fixture) host(export *apisv1alpha2.APIExport) { + key := logicalcluster.From(export).String() + "|" + export.Name + f.local[key] = export + f.global[key] = export +} + +// elsewhere puts an export on another shard: in the cache only. +func (f *fixture) elsewhere(export *apisv1alpha2.APIExport) { + f.global[logicalcluster.From(export).String()+"|"+export.Name] = export +} + +func export(cluster, name, uid string) *apisv1alpha2.APIExport { + return &apisv1alpha2.APIExport{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + UID: types.UID(uid), + Annotations: map[string]string{logicalcluster.AnnotationKey: cluster}, + }, + Status: apisv1alpha2.APIExportStatus{IdentityHash: identity}, + } +} + +// entry records owner and holders; holders were recorded long enough ago to +// be past the grace period, the oldest first. +func (f *fixture) entry(owner *apisv1alpha2.APIExport, holders ...*apisv1alpha2.APIExport) *adminv1alpha1.APIIdentity { + e := &adminv1alpha1.APIIdentity{ + ObjectMeta: metav1.ObjectMeta{Name: identity}, + Status: adminv1alpha1.APIIdentityStatus{Owner: ref(owner)}, + } + for i, h := range holders { + e.Status.Holders = append(e.Status.Holders, adminv1alpha1.Holder{ + ExportReference: ref(h), + Since: metav1.NewTime(time.Now().Add(-3*MissingGracePeriod + time.Duration(i)*time.Second)), + }) + } + if len(holders) > 0 { + conditions.MarkTrue(e, adminv1alpha1.APIIdentityShared) + } + f.registry.entries[identity] = e + return e +} + +// ownerMissingFor marks the entry's owner missing since the given duration +// ago. +func (f *fixture) ownerMissingFor(d time.Duration) { + since := time.Now().Add(-d) + e := f.registry.entries[identity] + conditions.MarkTrue(e, adminv1alpha1.APIIdentityOwnerMissing) + for i := range e.Status.Conditions { + if e.Status.Conditions[i].Type == adminv1alpha1.APIIdentityOwnerMissing { + e.Status.Conditions[i].LastTransitionTime = metav1.NewTime(since) + } + } +} + +func (f *fixture) reconcile() *adminv1alpha1.APIIdentity { + f.t.Helper() + require.NoError(f.t, f.c.reconcile(context.Background(), identity)) + entry, err := f.registry.Get(context.Background(), identity, metav1.GetOptions{}) + require.NoError(f.t, err) + return entry +} + +func TestHostedOwnerIsMaintained(t *testing.T) { + t.Parallel() + f := newFixture(t) + f.c.isCore = func(*apisv1alpha2.APIExport) bool { return true } + + owner := export("provider", "wildwest", "owner-uid") + holder := export("consumer", "wildwest", "holder-uid") + f.host(owner) + f.elsewhere(holder) + e := f.entry(owner, holder) + conditions.MarkTrue(e, adminv1alpha1.APIIdentityOrphaned) + conditions.MarkTrue(e, adminv1alpha1.APIIdentityOwnerMissing) + + entry := f.reconcile() + require.True(t, entry.Status.Core) + require.Len(t, entry.Status.Holders, 1) + require.True(t, conditions.IsTrue(entry, adminv1alpha1.APIIdentityShared)) + require.Nil(t, conditions.Get(entry, adminv1alpha1.APIIdentityOrphaned), "an owner in the cache is not orphaned") + require.Nil(t, conditions.Get(entry, adminv1alpha1.APIIdentityOwnerMissing)) + require.Empty(t, f.requeued) +} + +func TestOwnerHostedElsewhereOnlyClearsOwnerMissing(t *testing.T) { + t.Parallel() + f := newFixture(t) + + owner := export("provider", "wildwest", "owner-uid") + dead := export("consumer", "wildwest", "holder-uid") + f.elsewhere(owner) + e := f.entry(owner, dead) // the holder is not in the cache any more + conditions.MarkTrue(e, adminv1alpha1.APIIdentityOwnerMissing) + + entry := f.reconcile() + require.Nil(t, conditions.Get(entry, adminv1alpha1.APIIdentityOwnerMissing), "the owner is back") + require.Len(t, entry.Status.Holders, 1, "only the owner's shard maintains the holders") +} + +func TestOwnerMissingStartsTheGracePeriod(t *testing.T) { + t.Parallel() + f := newFixture(t) + + owner := export("provider", "wildwest", "owner-uid") + holder := export("consumer", "wildwest", "holder-uid") + f.host(holder) + f.entry(owner, holder) // the owner is not in the cache: not replicated yet, or deleted + + entry := f.reconcile() + require.Equal(t, ref(owner), entry.Status.Owner, "ownership must not move before the grace period") + require.True(t, conditions.IsTrue(entry, adminv1alpha1.APIIdentityOwnerMissing)) + require.Len(t, entry.Status.Holders, 1) + require.Equal(t, []time.Duration{MissingGracePeriod}, f.requeued) + + // still within the grace period: wait for the rest of it + f.ownerMissingFor(MissingGracePeriod / 4) + entry = f.reconcile() + require.Equal(t, ref(owner), entry.Status.Owner) + require.Len(t, f.requeued, 2) + require.InDelta(t, 3*MissingGracePeriod/4, f.requeued[1], float64(time.Second), "requeued for the rest of the grace period") +} + +func TestGoneOwnerPassesToOldestHolderHostedHere(t *testing.T) { + t.Parallel() + f := newFixture(t) + + owner := export("provider", "wildwest", "owner-uid") + oldest := export("consumer-a", "wildwest", "a-uid") + newer := export("consumer-b", "wildwest", "b-uid") + f.host(oldest) + f.elsewhere(newer) + f.entry(owner, oldest, newer) + f.ownerMissingFor(MissingGracePeriod) + + entry := f.reconcile() + require.Equal(t, ref(oldest), entry.Status.Owner, "the identity passes to the oldest holder") + require.Len(t, entry.Status.Holders, 1) + require.Equal(t, ref(newer), entry.Status.Holders[0].ExportReference) + require.True(t, conditions.IsTrue(entry, adminv1alpha1.APIIdentityShared)) + require.False(t, conditions.IsTrue(entry, adminv1alpha1.APIIdentityOrphaned)) + require.Equal(t, adminv1alpha1.APIIdentityOwnerSucceededReason, conditions.GetReason(entry, adminv1alpha1.APIIdentityOrphaned)) + require.Nil(t, conditions.Get(entry, adminv1alpha1.APIIdentityOwnerMissing)) + require.Empty(t, f.requeued) +} + +func TestGoneOwnerSkipsHoldersNotInTheCache(t *testing.T) { + t.Parallel() + f := newFixture(t) + + owner := export("provider", "wildwest", "owner-uid") + oldestButGone := export("consumer-a", "wildwest", "a-uid") + alive := export("consumer-b", "wildwest", "b-uid") + f.host(alive) + f.entry(owner, oldestButGone, alive) + f.ownerMissingFor(MissingGracePeriod) + + entry := f.reconcile() + require.Equal(t, ref(alive), entry.Status.Owner, "a holder that is not in the cache cannot succeed") +} + +func TestSuccessorHostedElsewhereIsLeftToItsShard(t *testing.T) { + t.Parallel() + f := newFixture(t) + + owner := export("provider", "wildwest", "owner-uid") + remote := export("consumer-a", "wildwest", "a-uid") + f.elsewhere(remote) + f.entry(owner, remote) + f.ownerMissingFor(MissingGracePeriod) + + entry := f.reconcile() + require.Equal(t, ref(owner), entry.Status.Owner, "the successor's shard performs the succession") + require.Len(t, entry.Status.Holders, 1) + require.True(t, conditions.IsTrue(entry, adminv1alpha1.APIIdentityOwnerMissing)) +} + +func TestGoneOwnerWithoutHoldersIsOrphaned(t *testing.T) { + t.Parallel() + f := newFixture(t) + + owner := export("provider", "wildwest", "owner-uid") + dead := export("consumer", "wildwest", "holder-uid") + f.entry(owner, dead) // neither is in the cache + f.ownerMissingFor(MissingGracePeriod) + + entry := f.reconcile() + require.True(t, conditions.IsTrue(entry, adminv1alpha1.APIIdentityOrphaned)) + require.Empty(t, entry.Status.Holders) + require.Nil(t, conditions.Get(entry, adminv1alpha1.APIIdentityShared)) + require.Nil(t, conditions.Get(entry, adminv1alpha1.APIIdentityOwnerMissing)) + + // an orphaned entry is left alone until somebody reclaims it + entry = f.reconcile() + require.True(t, conditions.IsTrue(entry, adminv1alpha1.APIIdentityOrphaned)) + require.Empty(t, f.requeued) +} + +func TestRecreatedOwnerIsNotGone(t *testing.T) { + t.Parallel() + f := newFixture(t) + + owner := export("provider", "wildwest", "old-uid") + holder := export("consumer", "wildwest", "holder-uid") + f.host(holder) + f.entry(owner, holder) + f.ownerMissingFor(MissingGracePeriod) + + // the owner was recreated under its name and the registry already + // records the new UID; its copy is back in the cache + recreated := export("provider", "wildwest", "new-uid") + f.elsewhere(recreated) + f.registry.entries[identity].Status.Owner.UID = "new-uid" + + entry := f.reconcile() + require.Equal(t, "new-uid", entry.Status.Owner.UID) + require.Len(t, entry.Status.Holders, 1) + require.Nil(t, conditions.Get(entry, adminv1alpha1.APIIdentityOwnerMissing)) + require.False(t, conditions.IsTrue(entry, adminv1alpha1.APIIdentityOrphaned)) +} + +func TestMaintainDropsHoldersGoneFromTheCache(t *testing.T) { + t.Parallel() + f := newFixture(t) + + owner := export("provider", "wildwest", "owner-uid") + dead := export("provider", "cowboys", "dead-uid") + alive := export("elsewhere", "wildwest", "alive-uid") + young := export("newcomer", "wildwest", "young-uid") + f.host(owner) + f.elsewhere(alive) + e := f.entry(owner, dead, alive, young) + e.Status.Holders[2].Since = metav1.NewTime(time.Now()) // just recorded, copy not replicated yet + + entry := f.reconcile() + require.Len(t, entry.Status.Holders, 2) + require.Equal(t, ref(alive), entry.Status.Holders[0].ExportReference) + require.Equal(t, ref(young), entry.Status.Holders[1].ExportReference, "a holder within its grace period is kept") + require.True(t, conditions.IsTrue(entry, adminv1alpha1.APIIdentityShared)) + + f.registry.entries[identity].Status.Holders[1].Since = metav1.NewTime(time.Now().Add(-MissingGracePeriod)) + entry = f.reconcile() + require.Len(t, entry.Status.Holders, 1, "past the grace period a holder missing from the cache is dropped") + require.Equal(t, ref(alive), entry.Status.Holders[0].ExportReference) +} diff --git a/pkg/reconciler/admin/apiidentity/registry.go b/pkg/reconciler/admin/apiidentity/registry.go new file mode 100644 index 00000000000..922f43b29b7 --- /dev/null +++ b/pkg/reconciler/admin/apiidentity/registry.go @@ -0,0 +1,48 @@ +/* +Copyright 2026 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 apiidentity + +import ( + "context" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + adminv1alpha1 "github.com/kcp-dev/sdk/apis/admin/v1alpha1" + + cacheclient "github.com/kcp-dev/kcp/pkg/cache/client" + "github.com/kcp-dev/kcp/pkg/cache/client/shard" +) + +// adminv1alpha1client is the slice of the APIIdentity client the controller +// uses, so tests can fake it. +type adminv1alpha1client interface { + Get(ctx context.Context, name string, opts metav1.GetOptions) (*adminv1alpha1.APIIdentity, error) + Update(ctx context.Context, entry *adminv1alpha1.APIIdentity, opts metav1.UpdateOptions) (*adminv1alpha1.APIIdentity, error) +} + +// registryScoped addresses the registry's shard scope on the cache server. +type registryScoped struct { + adminv1alpha1client +} + +func (r registryScoped) Get(ctx context.Context, name string, opts metav1.GetOptions) (*adminv1alpha1.APIIdentity, error) { + return r.adminv1alpha1client.Get(cacheclient.WithShardInContext(ctx, shard.Registry), name, opts) +} + +func (r registryScoped) Update(ctx context.Context, entry *adminv1alpha1.APIIdentity, opts metav1.UpdateOptions) (*adminv1alpha1.APIIdentity, error) { + return r.adminv1alpha1client.Update(cacheclient.WithShardInContext(ctx, shard.Registry), entry, opts) +} diff --git a/pkg/reconciler/apis/apiexport/apiexport_controller.go b/pkg/reconciler/apis/apiexport/apiexport_controller.go index 4e3c81c7df2..cd118d85ada 100644 --- a/pkg/reconciler/apis/apiexport/apiexport_controller.go +++ b/pkg/reconciler/apis/apiexport/apiexport_controller.go @@ -37,15 +37,20 @@ import ( kcpcorev1informers "github.com/kcp-dev/client-go/informers/core/v1" kcpkubernetesclientset "github.com/kcp-dev/client-go/kubernetes" "github.com/kcp-dev/logicalcluster/v3" + adminv1alpha1 "github.com/kcp-dev/sdk/apis/admin/v1alpha1" apisv1alpha1 "github.com/kcp-dev/sdk/apis/apis/v1alpha1" apisv1alpha2 "github.com/kcp-dev/sdk/apis/apis/v1alpha2" corev1alpha1 "github.com/kcp-dev/sdk/apis/core/v1alpha1" kcpclientset "github.com/kcp-dev/sdk/client/clientset/versioned/cluster" apisv1alpha2client "github.com/kcp-dev/sdk/client/clientset/versioned/typed/apis/v1alpha2" + adminv1alpha1informers "github.com/kcp-dev/sdk/client/informers/externalversions/admin/v1alpha1" apisv1alpha1informers "github.com/kcp-dev/sdk/client/informers/externalversions/apis/v1alpha1" apisv1alpha2informers "github.com/kcp-dev/sdk/client/informers/externalversions/apis/v1alpha2" corev1alpha1informers "github.com/kcp-dev/sdk/client/informers/externalversions/core/v1alpha1" + configshard "github.com/kcp-dev/kcp/config/shard" + cacheclient "github.com/kcp-dev/kcp/pkg/cache/client" + "github.com/kcp-dev/kcp/pkg/cache/client/shard" "github.com/kcp-dev/kcp/pkg/indexers" "github.com/kcp-dev/kcp/pkg/logging" "github.com/kcp-dev/kcp/pkg/reconciler/committer" @@ -61,9 +66,12 @@ const ( ) // NewController returns a new controller for APIExports. +// cacheKcpClusterClient and apiIdentityInformer serve the identity registry. func NewController( kcpClusterClient kcpclientset.ClusterInterface, apiExportInformer apisv1alpha2informers.APIExportClusterInformer, + cacheKcpClusterClient kcpclientset.ClusterInterface, + apiIdentityInformer adminv1alpha1informers.APIIdentityClusterInformer, apiExportEndpointSliceInformer apisv1alpha1informers.APIExportEndpointSliceClusterInformer, globalShardInformer corev1alpha1informers.ShardClusterInformer, kubeClusterClient kcpkubernetesclientset.ClusterInterface, @@ -139,6 +147,17 @@ func NewController( commit: committer.NewCommitter[*APIExport, Patcher, *APIExportSpec, *APIExportStatus](kcpClusterClient.ApisV1alpha2().APIExports()), + getAPIIdentityLive: func(ctx context.Context, name string) (*adminv1alpha1.APIIdentity, error) { + return cacheKcpClusterClient.Cluster(configshard.SystemShardCluster.Path()).AdminV1alpha1().APIIdentities().Get(cacheclient.WithShardInContext(ctx, shard.Registry), name, metav1.GetOptions{}) + }, + createAPIIdentity: func(ctx context.Context, entry *adminv1alpha1.APIIdentity) (*adminv1alpha1.APIIdentity, error) { + return cacheKcpClusterClient.Cluster(configshard.SystemShardCluster.Path()).AdminV1alpha1().APIIdentities().Create(cacheclient.WithShardInContext(ctx, shard.Registry), entry, metav1.CreateOptions{}) + }, + updateAPIIdentity: func(ctx context.Context, entry *adminv1alpha1.APIIdentity) (*adminv1alpha1.APIIdentity, error) { + return cacheKcpClusterClient.Cluster(configshard.SystemShardCluster.Path()).AdminV1alpha1().APIIdentities().Update(cacheclient.WithShardInContext(ctx, shard.Registry), entry, metav1.UpdateOptions{}) + }, + now: time.Now, + countedAPIExportConditions: make(map[string]map[string]string), readyAPIExports: make(map[string]struct{}), } @@ -196,9 +215,36 @@ func NewController( }, )) + c.getAPIIdentity = func(name string) (*adminv1alpha1.APIIdentity, error) { + return apiIdentityInformer.Lister().Cluster(configshard.SystemShardCluster).Get(name) + } + + // a registry entry that changes, or that the cache server lost, has the + // local exports of the identity record themselves again. + _, _ = apiIdentityInformer.Informer().AddEventHandler(events.WithoutSyncs(cache.ResourceEventHandlerFuncs{ + AddFunc: func(obj interface{}) { c.enqueueHoldersOf(obj.(*adminv1alpha1.APIIdentity).Name) }, + UpdateFunc: func(_, newObj interface{}) { c.enqueueHoldersOf(newObj.(*adminv1alpha1.APIIdentity).Name) }, + DeleteFunc: func(obj interface{}) { c.enqueueHoldersOf(tombstone.Obj[*adminv1alpha1.APIIdentity](obj).Name) }, + })) + return c, nil } +// enqueueHoldersOf enqueues the local APIExports that serve, or may be waiting +// to serve, the identity. +func (c *controller) enqueueHoldersOf(identityHash string) { + exports, err := c.listAPIExports() + if err != nil { + utilruntime.HandleError(err) + return + } + for _, export := range exports { + if export.Status.IdentityHash == identityHash || export.Status.IdentityHash == "" { + c.enqueueAPIExport(export) + } + } +} + type APIExport = apisv1alpha2.APIExport type APIExportSpec = apisv1alpha2.APIExportSpec type APIExportStatus = apisv1alpha2.APIExportStatus @@ -231,6 +277,13 @@ type controller struct { listShards func() ([]*corev1alpha1.Shard, error) + // identity registry + getAPIIdentity func(name string) (*adminv1alpha1.APIIdentity, error) + getAPIIdentityLive func(ctx context.Context, name string) (*adminv1alpha1.APIIdentity, error) + createAPIIdentity func(ctx context.Context, entry *adminv1alpha1.APIIdentity) (*adminv1alpha1.APIIdentity, error) + updateAPIIdentity func(ctx context.Context, entry *adminv1alpha1.APIIdentity) (*adminv1alpha1.APIIdentity, error) + now func() time.Time + commit CommitFunc // metricsMu protects countedAPIExportConditions and readyAPIExports. diff --git a/pkg/reconciler/apis/apiexport/apiexport_controller_test.go b/pkg/reconciler/apis/apiexport/apiexport_controller_test.go index 5edc8083ff2..69027c71bd9 100644 --- a/pkg/reconciler/apis/apiexport/apiexport_controller_test.go +++ b/pkg/reconciler/apis/apiexport/apiexport_controller_test.go @@ -102,6 +102,7 @@ func TestReconcile(t *testing.T) { secretExists: false, wantVerifyFailure: true, + wantError: true, }, "identity verification fails when hash from secret's key differs with APIExport's hash": { secretRefSet: true, @@ -110,6 +111,7 @@ func TestReconcile(t *testing.T) { secretHashDoesntMatchAPIExportStatus: true, wantVerifyFailure: true, + wantError: true, }, "able to fix identity verification by returning to secret with correct key/hash": { secretRefSet: true, @@ -184,7 +186,14 @@ func TestReconcile(t *testing.T) { expectedHash := fmt.Sprintf("%x", sha256.Sum256([]byte(expectedKey))) someOtherKey := "def" + registry := newIdentityFixture().controller() c := &controller{ + queue: registry.queue, + now: registry.now, + getAPIIdentity: registry.getAPIIdentity, + getAPIIdentityLive: registry.getAPIIdentityLive, + createAPIIdentity: registry.createAPIIdentity, + updateAPIIdentity: registry.updateAPIIdentity, getNamespace: func(clusterName logicalcluster.Name, name string) (*corev1.Namespace, error) { return &corev1.Namespace{}, nil }, diff --git a/pkg/reconciler/apis/apiexport/apiexport_events_test.go b/pkg/reconciler/apis/apiexport/apiexport_events_test.go index b8bf8f6acd0..986c1cf7c11 100644 --- a/pkg/reconciler/apis/apiexport/apiexport_events_test.go +++ b/pkg/reconciler/apis/apiexport/apiexport_events_test.go @@ -54,7 +54,7 @@ func TestDeleteHandlers(t *testing.T) { factory := kcpinformers.NewSharedInformerFactory(client, 0) kubeFactory := kubeinformers.NewSharedInformerFactory(nil, 0) secrets, shards, slices := &recordingInformer{}, &recordingInformer{}, &recordingInformer{} - c, err := NewController(client, factory.Apis().V1alpha2().APIExports(), + c, err := NewController(client, factory.Apis().V1alpha2().APIExports(), client, factory.Admin().V1alpha1().APIIdentities(), &recordingSliceInformer{informer: slices}, &recordingShardInformer{informer: shards}, nil, kubeFactory.Core().V1().Namespaces(), &recordingSecretInformer{informer: secrets}, "root") require.NoError(t, err) diff --git a/pkg/reconciler/apis/apiexport/apiexport_reconcile.go b/pkg/reconciler/apis/apiexport/apiexport_reconcile.go index 43f37993cf8..10f5d7523c6 100644 --- a/pkg/reconciler/apis/apiexport/apiexport_reconcile.go +++ b/pkg/reconciler/apis/apiexport/apiexport_reconcile.go @@ -89,16 +89,11 @@ func (c *controller) reconcile(ctx context.Context, apiExport *apisv1alpha2.APIE return nil } - // Ref exists - make sure it's valid - if err := c.updateOrVerifyIdentitySecretHash(ctx, clusterName, apiExport); err != nil { - conditions.MarkFalse( - apiExport, - apisv1alpha2.APIExportIdentityValid, - apisv1alpha2.IdentityVerificationFailedReason, - conditionsv1alpha1.ConditionSeverityError, - "%v", - err, - ) + // Ref exists - make sure it's valid and the export is authorized to + // serve the identity. reconcileIdentity reports every failure on the + // IdentityValid condition itself; the error only drives the retry. + if err := c.reconcileIdentity(ctx, apiExport); err != nil { + return err } // Ensure the APIExportEndpointSlice exists @@ -189,30 +184,6 @@ func (c *controller) createIdentitySecret(ctx context.Context, clusterName logic return c.createSecret(ctx, clusterName, secret) } -func (c *controller) updateOrVerifyIdentitySecretHash(ctx context.Context, clusterName logicalcluster.Name, apiExport *apisv1alpha2.APIExport) error { - secret, err := c.getSecret(ctx, clusterName, apiExport.Spec.Identity.SecretRef.Namespace, apiExport.Spec.Identity.SecretRef.Name) - if err != nil { - return err - } - - hash, err := identity.IdentityHash(secret) - if err != nil { - return err - } - - if apiExport.Status.IdentityHash == "" { - apiExport.Status.IdentityHash = hash - } - - if apiExport.Status.IdentityHash != hash { - return fmt.Errorf("hash mismatch: identity secret hash %q must match status.identityHash %q", hash, apiExport.Status.IdentityHash) - } - - conditions.MarkTrue(apiExport, apisv1alpha2.APIExportIdentityValid) - - return nil -} - func (c *controller) updateVirtualWorkspaceURLs(ctx context.Context, apiExport *apisv1alpha2.APIExport) error { logger := klog.FromContext(ctx) shards, err := c.listShards() diff --git a/pkg/reconciler/apis/apiexport/apiexport_reconcile_identity.go b/pkg/reconciler/apis/apiexport/apiexport_reconcile_identity.go new file mode 100644 index 00000000000..202db8034cc --- /dev/null +++ b/pkg/reconciler/apis/apiexport/apiexport_reconcile_identity.go @@ -0,0 +1,186 @@ +/* +Copyright 2026 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" + "time" + + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/util/retry" + "k8s.io/klog/v2" + + "github.com/kcp-dev/logicalcluster/v3" + adminv1alpha1 "github.com/kcp-dev/sdk/apis/admin/v1alpha1" + apisv1alpha2 "github.com/kcp-dev/sdk/apis/apis/v1alpha2" + conditionsv1alpha1 "github.com/kcp-dev/sdk/apis/third_party/conditions/apis/conditions/v1alpha1" + "github.com/kcp-dev/sdk/apis/third_party/conditions/util/conditions" + + configshard "github.com/kcp-dev/kcp/config/shard" + "github.com/kcp-dev/kcp/pkg/identity" +) + +// reconcileIdentity assigns the export's identity and records it in the +// registry. +// +// The identity is the hash of the secret's identity key, as always. The +// registry (the APIIdentity on the cache server, created atomically by the +// first export to claim the identity) records which APIExport owns the +// identity and which others hold it, so that sharing an identity by copying +// the secret is visible and an identity whose owner is deleted passes on to +// a holder. +// +// status.identityHash is set once the identity is recorded; an identity +// that cannot be recorded is retried, with IdentityValid=False meanwhile. +func (c *controller) reconcileIdentity(ctx context.Context, apiExport *apisv1alpha2.APIExport) error { + logger := klog.FromContext(ctx) + clusterName := logicalcluster.From(apiExport) + + secret, err := c.getSecret(ctx, clusterName, apiExport.Spec.Identity.SecretRef.Namespace, apiExport.Spec.Identity.SecretRef.Name) + if err != nil { + return c.identityVerificationFailed(apiExport, err) + } + hash, err := identity.IdentityHash(secret) + if err != nil { + return c.identityVerificationFailed(apiExport, err) + } + if apiExport.Status.IdentityHash != "" && apiExport.Status.IdentityHash != hash { + return c.identityVerificationFailed(apiExport, fmt.Errorf("hash mismatch: identity secret hash %q must match status.identityHash %q", hash, apiExport.Status.IdentityHash)) + } + me := adminv1alpha1.ExportReference{Cluster: clusterName.String(), Name: apiExport.Name, UID: string(apiExport.UID)} + + // claim: the first export to create the registry entry owns the identity. + entry, err := c.getAPIIdentity(hash) + if err != nil && !errors.IsNotFound(err) { + return err + } + if errors.IsNotFound(err) { + entry, err = c.createAPIIdentity(ctx, &adminv1alpha1.APIIdentity{ + ObjectMeta: metav1.ObjectMeta{ + Name: hash, + Annotations: map[string]string{logicalcluster.AnnotationKey: configshard.SystemShardCluster.String()}, + }, + Status: adminv1alpha1.APIIdentityStatus{Owner: me}, + }) + switch { + case errors.IsAlreadyExists(err): + // somebody else claimed it first, possibly on another shard and + // not replicated to our informer yet: read it authoritatively. + entry, err = c.getAPIIdentityLive(ctx, hash) + if err != nil { + return c.identityClaimFailed(apiExport, fmt.Errorf("reading APIIdentity %s: %w", hash, err)) + } + case err != nil: + return c.identityClaimFailed(apiExport, fmt.Errorf("claiming APIIdentity %s: %w", hash, err)) + default: + logger.V(2).Info("claimed identity", "identity", hash) + } + } + + switch { + case entry.Status.Owner.Equal(me): + // the owner, possibly recreated under the same name (GitOps): keep + // the registry's UID current. + if entry.Status.Owner.UID != me.UID { + if err := c.setAPIIdentityOwner(ctx, entry.Name, me); err != nil { + return c.identityClaimFailed(apiExport, err) + } + } + c.setIdentity(apiExport, hash) + return nil + + case conditions.IsTrue(entry, adminv1alpha1.APIIdentityOrphaned): + // nobody owns it and nobody holds it; whoever has the key may take it + // over, and we do, since we produced the identity from the key. + if err := c.setAPIIdentityOwner(ctx, entry.Name, me); err != nil { + return c.identityClaimFailed(apiExport, err) + } + logger.V(2).Info("reclaimed orphaned identity", "identity", hash) + c.setIdentity(apiExport, hash) + return nil + } + + // a holder: whoever has the key serves the identity, as always; the + // registry records it. + c.setIdentity(apiExport, hash) + return c.updateAPIIdentityHolders(ctx, entry.Name, func(entry *adminv1alpha1.APIIdentity) bool { + return addHolder(entry, me, c.now()) + }) +} + +func (c *controller) setIdentity(apiExport *apisv1alpha2.APIExport, hash string) { + apiExport.Status.IdentityHash = hash + conditions.MarkTrue(apiExport, apisv1alpha2.APIExportIdentityValid) +} + +func (c *controller) identityVerificationFailed(apiExport *apisv1alpha2.APIExport, err error) error { + conditions.MarkFalse(apiExport, apisv1alpha2.APIExportIdentityValid, apisv1alpha2.IdentityVerificationFailedReason, conditionsv1alpha1.ConditionSeverityError, "%v", err) + return err +} + +func (c *controller) identityClaimFailed(apiExport *apisv1alpha2.APIExport, err error) error { + conditions.MarkFalse(apiExport, apisv1alpha2.APIExportIdentityValid, apisv1alpha2.IdentityClaimFailedReason, conditionsv1alpha1.ConditionSeverityError, "%v", err) + return err +} + +// setAPIIdentityOwner records the export as the owner of the identity. +func (c *controller) setAPIIdentityOwner(ctx context.Context, name string, owner adminv1alpha1.ExportReference) error { + return retry.RetryOnConflict(retry.DefaultBackoff, func() error { + entry, err := c.getAPIIdentityLive(ctx, name) + if err != nil { + return err + } + if entry.Status.Owner == owner { + return nil + } + entry.Status.Owner = owner + _, err = c.updateAPIIdentity(ctx, entry) + return err + }) +} + +// updateAPIIdentityHolders applies mutate to the holders of the identity and +// writes the entry when it changed anything. +func (c *controller) updateAPIIdentityHolders(ctx context.Context, name string, mutate func(*adminv1alpha1.APIIdentity) bool) error { + return retry.RetryOnConflict(retry.DefaultBackoff, func() error { + entry, err := c.getAPIIdentityLive(ctx, name) + if err != nil { + return err + } + if !mutate(entry) { + return nil + } + _, err = c.updateAPIIdentity(ctx, entry) + return err + }) +} + +func addHolder(entry *adminv1alpha1.APIIdentity, ref adminv1alpha1.ExportReference, now time.Time) bool { + for i := range entry.Status.Holders { + if entry.Status.Holders[i].Equal(ref) { + if entry.Status.Holders[i].UID == ref.UID { + return false + } + entry.Status.Holders[i].UID = ref.UID + return true + } + } + entry.Status.Holders = append(entry.Status.Holders, adminv1alpha1.Holder{ExportReference: ref, Since: metav1.NewTime(now)}) + return true +} diff --git a/pkg/reconciler/apis/apiexport/apiexport_reconcile_identity_test.go b/pkg/reconciler/apis/apiexport/apiexport_reconcile_identity_test.go new file mode 100644 index 00000000000..9f4ec799e3c --- /dev/null +++ b/pkg/reconciler/apis/apiexport/apiexport_reconcile_identity_test.go @@ -0,0 +1,197 @@ +/* +Copyright 2026 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" + "crypto/sha256" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/util/workqueue" + + "github.com/kcp-dev/logicalcluster/v3" + adminv1alpha1 "github.com/kcp-dev/sdk/apis/admin/v1alpha1" + apisv1alpha1 "github.com/kcp-dev/sdk/apis/apis/v1alpha1" + apisv1alpha2 "github.com/kcp-dev/sdk/apis/apis/v1alpha2" + "github.com/kcp-dev/sdk/apis/third_party/conditions/util/conditions" +) + +func sha(s string) string { return fmt.Sprintf("%x", sha256.Sum256([]byte(s))) } + +// identityFixture is an in-memory registry plus the secrets and exports the +// reconciler looks up. +type identityFixture struct { + registry map[string]*adminv1alpha1.APIIdentity + secrets map[string]*corev1.Secret // by cluster|name + created int +} + +func newIdentityFixture() *identityFixture { + return &identityFixture{ + registry: map[string]*adminv1alpha1.APIIdentity{}, + secrets: map[string]*corev1.Secret{}, + } +} + +func (f *identityFixture) controller() *controller { + return &controller{ + queue: workqueue.NewTypedRateLimitingQueue(workqueue.DefaultTypedControllerRateLimiter[string]()), + now: func() time.Time { return time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) }, + getSecret: func(_ context.Context, clusterName logicalcluster.Name, _, name string) (*corev1.Secret, error) { + if s, ok := f.secrets[clusterName.String()+"|"+name]; ok { + return s, nil + } + return nil, apierrors.NewNotFound(corev1.Resource("secrets"), name) + }, + getAPIIdentity: func(name string) (*adminv1alpha1.APIIdentity, error) { + if e, ok := f.registry[name]; ok { + return e.DeepCopy(), nil + } + return nil, apierrors.NewNotFound(adminv1alpha1.Resource("apiidentities"), name) + }, + getAPIIdentityLive: func(_ context.Context, name string) (*adminv1alpha1.APIIdentity, error) { + if e, ok := f.registry[name]; ok { + return e.DeepCopy(), nil + } + return nil, apierrors.NewNotFound(adminv1alpha1.Resource("apiidentities"), name) + }, + createAPIIdentity: func(_ context.Context, entry *adminv1alpha1.APIIdentity) (*adminv1alpha1.APIIdentity, error) { + if _, ok := f.registry[entry.Name]; ok { + return nil, apierrors.NewAlreadyExists(adminv1alpha1.Resource("apiidentities"), entry.Name) + } + f.created++ + f.registry[entry.Name] = entry.DeepCopy() + return entry.DeepCopy(), nil + }, + updateAPIIdentity: func(_ context.Context, entry *adminv1alpha1.APIIdentity) (*adminv1alpha1.APIIdentity, error) { + f.registry[entry.Name] = entry.DeepCopy() + return entry.DeepCopy(), nil + }, + } +} + +func (f *identityFixture) secret(cluster, name, key string) { + f.secrets[cluster+"|"+name] = &corev1.Secret{Data: map[string][]byte{apisv1alpha1.SecretKeyAPIExportIdentity: []byte(key)}} +} + +func export(cluster, name, uid string) *apisv1alpha2.APIExport { + return &apisv1alpha2.APIExport{ + ObjectMeta: metav1.ObjectMeta{Name: name, UID: types.UID(uid), Annotations: map[string]string{logicalcluster.AnnotationKey: cluster}}, + Spec: apisv1alpha2.APIExportSpec{Identity: &apisv1alpha2.Identity{SecretRef: &corev1.SecretReference{Namespace: "kcp-system", Name: name}}}, + } +} + +func TestReconcileIdentity(t *testing.T) { + t.Parallel() + ctx := context.Background() + + const ( + ownerCluster = "ws-owner" + holderCluster = "ws-holder" + ) + key := "the-key" + identity := sha(key) + + t.Run("first export claims the identity and owns it", func(t *testing.T) { + t.Parallel() + f := newIdentityFixture() + f.secret(ownerCluster, "networking", key) + c := f.controller() + + ae := export(ownerCluster, "networking", "uid-1") + require.NoError(t, c.reconcileIdentity(ctx, ae)) + + require.Equal(t, identity, ae.Status.IdentityHash) + require.True(t, conditions.IsTrue(ae, apisv1alpha2.APIExportIdentityValid)) + require.Equal(t, 1, f.created) + require.Equal(t, adminv1alpha1.ExportReference{Cluster: ownerCluster, Name: "networking", UID: "uid-1"}, f.registry[identity].Status.Owner) + }) + + t.Run("a recreated owner keeps the identity and refreshes its UID", func(t *testing.T) { + t.Parallel() + f := newIdentityFixture() + f.secret(ownerCluster, "networking", key) + f.registry[identity] = &adminv1alpha1.APIIdentity{ObjectMeta: metav1.ObjectMeta{Name: identity}, Status: adminv1alpha1.APIIdentityStatus{Owner: adminv1alpha1.ExportReference{Cluster: ownerCluster, Name: "networking", UID: "old"}}} + c := f.controller() + + ae := export(ownerCluster, "networking", "new") + require.NoError(t, c.reconcileIdentity(ctx, ae)) + + require.Equal(t, identity, ae.Status.IdentityHash) + require.Equal(t, "new", f.registry[identity].Status.Owner.UID) + require.Equal(t, 0, f.created, "no second claim") + }) + + t.Run("a holder of the key shares the identity and is recorded", func(t *testing.T) { + t.Parallel() + f := newIdentityFixture() + f.secret(holderCluster, "networking-v2", key) + f.registry[identity] = &adminv1alpha1.APIIdentity{ObjectMeta: metav1.ObjectMeta{Name: identity}, Status: adminv1alpha1.APIIdentityStatus{Owner: adminv1alpha1.ExportReference{Cluster: ownerCluster, Name: "networking"}}} + c := f.controller() + + ae := export(holderCluster, "networking-v2", "h") + require.NoError(t, c.reconcileIdentity(ctx, ae)) + + require.Equal(t, identity, ae.Status.IdentityHash, "copying the secret keeps working as today") + require.True(t, conditions.IsTrue(ae, apisv1alpha2.APIExportIdentityValid)) + require.Len(t, f.registry[identity].Status.Holders, 1) + require.Equal(t, holderCluster, f.registry[identity].Status.Holders[0].Cluster) + + require.NoError(t, c.reconcileIdentity(ctx, ae)) + require.Len(t, f.registry[identity].Status.Holders, 1, "recorded once") + }) + + t.Run("an orphaned identity is reclaimed by whoever holds the key", func(t *testing.T) { + t.Parallel() + f := newIdentityFixture() + f.secret(holderCluster, "networking-v2", key) + entry := &adminv1alpha1.APIIdentity{ObjectMeta: metav1.ObjectMeta{Name: identity}, Status: adminv1alpha1.APIIdentityStatus{Owner: adminv1alpha1.ExportReference{Cluster: ownerCluster, Name: "networking"}}} + conditions.MarkTrue(entry, adminv1alpha1.APIIdentityOrphaned) + f.registry[identity] = entry + c := f.controller() + + ae := export(holderCluster, "networking-v2", "h") + require.NoError(t, c.reconcileIdentity(ctx, ae)) + + require.Equal(t, identity, ae.Status.IdentityHash) + require.Equal(t, holderCluster, f.registry[identity].Status.Owner.Cluster) + }) + + t.Run("a secret edited in place fails verification", func(t *testing.T) { + t.Parallel() + f := newIdentityFixture() + f.secret(ownerCluster, "networking", "another-key") + c := f.controller() + + ae := export(ownerCluster, "networking", "o") + ae.Status.IdentityHash = identity + require.Error(t, c.reconcileIdentity(ctx, ae)) + + cond := conditions.Get(ae, apisv1alpha2.APIExportIdentityValid) + require.NotNil(t, cond) + require.Equal(t, apisv1alpha2.IdentityVerificationFailedReason, cond.Reason) + require.Equal(t, 0, f.created) + }) +} diff --git a/pkg/server/controllers.go b/pkg/server/controllers.go index 2779c7fd404..6e8789af5bf 100644 --- a/pkg/server/controllers.go +++ b/pkg/server/controllers.go @@ -59,6 +59,8 @@ import ( kcpkubernetesclientset "github.com/kcp-dev/client-go/kubernetes" kcpmetadata "github.com/kcp-dev/client-go/metadata" "github.com/kcp-dev/logicalcluster/v3" + apisv1alpha2 "github.com/kcp-dev/sdk/apis/apis/v1alpha2" + "github.com/kcp-dev/sdk/apis/core" corev1alpha1 "github.com/kcp-dev/sdk/apis/core/v1alpha1" tenancyv1alpha1 "github.com/kcp-dev/sdk/apis/tenancy/v1alpha1" kcpclientset "github.com/kcp-dev/sdk/client/clientset/versioned/cluster" @@ -71,6 +73,7 @@ import ( "github.com/kcp-dev/kcp/pkg/informer" "github.com/kcp-dev/kcp/pkg/objectcount" permissionclaimlabler "github.com/kcp-dev/kcp/pkg/permissionclaim" + "github.com/kcp-dev/kcp/pkg/reconciler/admin/apiidentity" "github.com/kcp-dev/kcp/pkg/reconciler/apis/apibinding" "github.com/kcp-dev/kcp/pkg/reconciler/apis/apibindingdeletion" "github.com/kcp-dev/kcp/pkg/reconciler/apis/apiexport" @@ -113,6 +116,7 @@ import ( "github.com/kcp-dev/kcp/pkg/reconciler/tenancy/workspacemounts" "github.com/kcp-dev/kcp/pkg/reconciler/tenancy/workspacetype" "github.com/kcp-dev/kcp/pkg/reconciler/topology/partitionset" + kcpbootstrap "github.com/kcp-dev/kcp/pkg/server/bootstrap" initializingworkspacesbuilder "github.com/kcp-dev/kcp/pkg/virtual/initializingworkspaces/builder" _ "net/http/pprof" @@ -1344,9 +1348,13 @@ func (s *Server) installAPIExportController(ctx context.Context, config *rest.Co return err } + apiIdentityInformer := s.CacheKcpSharedInformerFactory.Admin().V1alpha1().APIIdentities() + c, err := apiexport.NewController( kcpClusterClient, s.KcpSharedInformerFactory.Apis().V1alpha2().APIExports(), + s.KcpCacheClusterClient, + apiIdentityInformer, s.KcpSharedInformerFactory.Apis().V1alpha1().APIExportEndpointSlices(), s.CacheKcpSharedInformerFactory.Core().V1alpha1().Shards(), kubeClusterClient, @@ -1367,6 +1375,7 @@ func (s *Server) installAPIExportController(ctx context.Context, config *rest.Co return wait.PollUntilContextCancel(ctx, waitPollInterval, true, func(ctx context.Context) (bool, error) { return s.ApiExtensionsSharedInformerFactory.Apiextensions().V1().CustomResourceDefinitions().Informer().HasSynced() && s.KcpSharedInformerFactory.Apis().V1alpha2().APIExports().Informer().HasSynced() && + apiIdentityInformer.Informer().HasSynced() && s.KcpSharedInformerFactory.Apis().V1alpha1().APIExportEndpointSlices().Informer().HasSynced() && s.KubeSharedInformerFactory.Core().V1().Namespaces().Informer().HasSynced() && s.KubeSharedInformerFactory.Core().V1().Secrets().Informer().HasSynced(), nil @@ -1378,6 +1387,43 @@ func (s *Server) installAPIExportController(ctx context.Context, config *rest.Co }) } +// installAPIIdentityController runs the registry controller of this shard: +// it maintains the APIIdentity entries of the identities whose owner this +// shard hosts and passes an identity on when its owner is deleted. +func (s *Server) installAPIIdentityController(_ context.Context) error { + coreExports := sets.New[string]() + for _, name := range kcpbootstrap.KcpRootGroupExportNames { + coreExports.Insert(name) + } + for _, name := range kcpbootstrap.KcpRootGroupResourceExportNames { + coreExports.Insert(name) + } + isCore := func(export *apisv1alpha2.APIExport) bool { + return logicalcluster.From(export) == core.RootCluster && coreExports.Has(export.Name) + } + + apiIdentities := s.CacheKcpSharedInformerFactory.Admin().V1alpha1().APIIdentities() + localExports := s.KcpSharedInformerFactory.Apis().V1alpha2().APIExports() + globalExports := s.CacheKcpSharedInformerFactory.Apis().V1alpha2().APIExports() + + c, err := apiidentity.NewController(s.KcpCacheClusterClient, apiIdentities, localExports, globalExports, isCore) + if err != nil { + return err + } + + return s.registerController(&controllerWrapper{ + Name: apiidentity.ControllerName, + Wait: func(ctx context.Context, s *Server) error { + return wait.PollUntilContextCancel(ctx, waitPollInterval, true, func(ctx context.Context) (bool, error) { + return apiIdentities.Informer().HasSynced() && localExports.Informer().HasSynced() && globalExports.Informer().HasSynced(), nil + }) + }, + Runner: func(ctx context.Context) { + c.Start(ctx, 2) + }, + }) +} + func (s *Server) installApisReplicateClusterRoleControllers(ctx context.Context, config *rest.Config) error { config = rest.CopyConfig(config) config = rest.AddUserAgent(config, apisreplicateclusterrole.ControllerName) diff --git a/pkg/server/server.go b/pkg/server/server.go index f1fcca6ccd2..88fc8385628 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -344,6 +344,9 @@ func (s *Server) installControllers(ctx context.Context, controllerConfig *rest. if err := s.installAPIExportController(ctx, controllerConfig); err != nil { return err } + if err := s.installAPIIdentityController(ctx); err != nil { + return err + } } if s.Options.Controllers.EnableAll || enabled.Has("apisreplicateclusterrole") { @@ -526,6 +529,10 @@ func (s *Server) Run(ctx context.Context) error { go s.KcpSharedInformerFactory.Apis().V1alpha2().APIExports().Informer().Run(hookContext.Done()) go s.KcpSharedInformerFactory.Apis().V1alpha1().APIExportEndpointSlices().Informer().Run(hookContext.Done()) go s.CacheKcpSharedInformerFactory.Apis().V1alpha2().APIExports().Informer().Run(hookContext.Done()) + // The APIExport controller claims identities in the registry before + // the core identities are resolved below, so the registry informer + // has to run before the rest of the cache informers. + go s.CacheKcpSharedInformerFactory.Admin().V1alpha1().APIIdentities().Informer().Run(hookContext.Done()) go s.CacheKcpSharedInformerFactory.Cache().V1alpha1().ClusterCachedResources().Informer().Run(hookContext.Done()) go s.CacheKcpSharedInformerFactory.Cache().V1alpha1().ClusterCachedResourceEndpointSlices().Informer().Run(hookContext.Done()) go s.KcpSharedInformerFactory.Core().V1alpha1().LogicalClusters().Informer().Run(hookContext.Done()) diff --git a/pkg/virtual/admin/authorizer/authorizer.go b/pkg/virtual/admin/authorizer/authorizer.go index a791ad6f2ff..4859cd673de 100644 --- a/pkg/virtual/admin/authorizer/authorizer.go +++ b/pkg/virtual/admin/authorizer/authorizer.go @@ -22,6 +22,8 @@ import ( "k8s.io/apiserver/pkg/authorization/authorizer" + corev1alpha1 "github.com/kcp-dev/sdk/apis/core/v1alpha1" + "github.com/kcp-dev/kcp/pkg/authorization/bootstrap" ) @@ -40,7 +42,13 @@ func NewAdminAuthorizer() authorizer.Authorizer { func (a *adminAuthorizer) Authorize(_ context.Context, attr authorizer.Attributes) (authorizer.Decision, string, error) { switch attr.GetVerb() { - case "get", "list", "watch", "update", "patch": + case "get", "list", "watch": + case "update", "patch": + // only Shard objects accept (allow-listed) writes; every other view + // is derived state and read-only. + if attr.GetAPIGroup() != corev1alpha1.SchemeGroupVersion.Group || attr.GetResource() != "shards" { + return authorizer.DecisionDeny, "resource is read-only in the admin workspace", nil + } default: return authorizer.DecisionDeny, "verb not supported by the admin workspace", nil } diff --git a/pkg/virtual/admin/authorizer/authorizer_test.go b/pkg/virtual/admin/authorizer/authorizer_test.go index 886312ae73a..00ac95f562b 100644 --- a/pkg/virtual/admin/authorizer/authorizer_test.go +++ b/pkg/virtual/admin/authorizer/authorizer_test.go @@ -32,10 +32,12 @@ func TestAdminAuthorizer(t *testing.T) { t.Parallel() for _, tc := range []struct { - name string - groups []string - verb string - want authorizer.Decision + name string + groups []string + verb string + group string // defaults to core.kcp.io + resource string // defaults to shards + want authorizer.Decision }{ { // The external administrator identity, passed through by the @@ -96,16 +98,49 @@ func TestAdminAuthorizer(t *testing.T) { verb: "delete", want: authorizer.DecisionDeny, }, + { + name: "system:kcp:admin may read the identity registry", + groups: []string{bootstrap.SystemKcpAdminGroup}, + verb: "list", + group: "admin.kcp.io", + resource: "apiidentities", + want: authorizer.DecisionAllow, + }, + { + // The registry is derived state written by kcp only; the + // allow-listed writes exist for Shards alone. + name: "system:masters may not update the identity registry", + groups: []string{bootstrap.SystemMastersGroup}, + verb: "update", + group: "admin.kcp.io", + resource: "apiidentities", + want: authorizer.DecisionDeny, + }, + { + name: "system:kcp:admin may not patch the identity registry", + groups: []string{bootstrap.SystemKcpAdminGroup}, + verb: "patch", + group: "admin.kcp.io", + resource: "apiidentities", + want: authorizer.DecisionDeny, + }, } { t.Run(tc.name, func(t *testing.T) { t.Parallel() + group, resource := tc.group, tc.resource + if group == "" { + group = "core.kcp.io" + } + if resource == "" { + resource = "shards" + } got, reason, err := NewAdminAuthorizer().Authorize(context.Background(), authorizer.AttributesRecord{ User: &user.DefaultInfo{Name: "tester", Groups: tc.groups}, Verb: tc.verb, - APIGroup: "core.kcp.io", + APIGroup: group, APIVersion: "v1alpha1", - Resource: "shards", + Resource: resource, ResourceRequest: true, }) require.NoError(t, err) diff --git a/pkg/virtual/admin/builder/apiidentities.go b/pkg/virtual/admin/builder/apiidentities.go new file mode 100644 index 00000000000..9f91e5a5c90 --- /dev/null +++ b/pkg/virtual/admin/builder/apiidentities.go @@ -0,0 +1,193 @@ +/* +Copyright 2026 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 builder + +import ( + "context" + "fmt" + "sync" + + metainternalversion "k8s.io/apimachinery/pkg/apis/meta/internalversion" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/watch" + genericapirequest "k8s.io/apiserver/pkg/endpoints/request" + genericapiserver "k8s.io/apiserver/pkg/server" + + kcpdynamic "github.com/kcp-dev/client-go/dynamic" + adminv1alpha1 "github.com/kcp-dev/sdk/apis/admin/v1alpha1" + "github.com/kcp-dev/virtual-workspace-framework/pkg/dynamic/apidefinition" + "github.com/kcp-dev/virtual-workspace-framework/pkg/dynamic/apiserver" + "github.com/kcp-dev/virtual-workspace-framework/pkg/forwardingregistry" + + configshard "github.com/kcp-dev/kcp/config/shard" + cacheclient "github.com/kcp-dev/kcp/pkg/cache/client" + "github.com/kcp-dev/kcp/pkg/cache/client/shard" + "github.com/kcp-dev/kcp/pkg/reconciler/cache/replication" +) + +// provideAPIIdentitiesRestStorage builds the REST storage for the identity +// registry view: GET/LIST/WATCH forwarded to the registry scope of the cache +// server, where the APIExport controllers of every shard record the +// identities they assign. The view is read-only: the entries are derived +// state, written by kcp only. +func provideAPIIdentitiesRestStorage( + mainConfig genericapiserver.CompletedConfig, + cacheDynamicClusterClient kcpdynamic.ClusterInterface, +) (apidefinition.APIDefinition, error) { + ctx, cancelFn := context.WithCancel(context.Background()) + + clientFunc := forwardingregistry.DynamicClusterClientFunc(func(_ context.Context) (kcpdynamic.ClusterInterface, error) { + return cacheDynamicClusterClient, nil + }) + + restProvider, err := forwardingregistry.ProvideReadOnlyRestStorage(ctx, clientFunc, withRegistryView(), nil) + if err != nil { + cancelFn() + return nil, err + } + + def, err := apiserver.CreateServingInfoFor(mainConfig, APIIdentitiesSchema, adminv1alpha1.SchemeGroupVersion.Version, restProvider) + if err != nil { + cancelFn() + return nil, err + } + + return &apiDefinitionWithCancel{ + APIDefinition: def, + cancelFn: cancelFn, + }, nil +} + +// registryContext retargets a request context at the registry scope of the +// cache server: the one shard scope and logical cluster every APIIdentity +// lives under, whichever cluster segment the caller used. +func registryContext(ctx context.Context) context.Context { + ctx = genericapirequest.WithCluster(ctx, genericapirequest.Cluster{Name: configshard.SystemShardCluster}) + return cacheclient.WithShardInContext(ctx, shard.Registry) +} + +// stripCacheBookkeeping removes the cache-server bookkeeping annotations from +// an object read from the cache. The kcp.io/cluster annotation is kept: it +// tells the consumer which logical cluster the object belongs to. +func stripCacheBookkeeping(obj *unstructured.Unstructured) { + annotations := obj.GetAnnotations() + if annotations == nil { + return + } + delete(annotations, shard.AnnotationKey) + delete(annotations, replication.AnnotationKeyOriginalResourceUID) + delete(annotations, replication.AnnotationKeyOriginalResourceVersion) + obj.SetAnnotations(annotations) +} + +// withRegistryView decorates the StoreFuncs so that every read is served from +// the registry scope of the cache server, with the cache bookkeeping +// annotations stripped from what is returned. +func withRegistryView() forwardingregistry.StorageWrapper { + return forwardingregistry.StorageWrapperFunc(func(_ schema.GroupResource, storage *forwardingregistry.StoreFuncs) { + delegateGet := storage.GetterFunc + storage.GetterFunc = func(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) { + result, err := delegateGet(registryContext(ctx), name, options) + if err != nil { + return nil, err + } + obj, ok := result.(*unstructured.Unstructured) + if !ok { + return nil, fmt.Errorf("expected *unstructured.Unstructured from the registry, got %T", result) + } + stripCacheBookkeeping(obj) + return obj, nil + } + + delegateList := storage.ListerFunc + storage.ListerFunc = func(ctx context.Context, options *metainternalversion.ListOptions) (runtime.Object, error) { + result, err := delegateList(registryContext(ctx), options) + if err != nil { + return nil, err + } + list, ok := result.(*unstructured.UnstructuredList) + if !ok { + return nil, fmt.Errorf("expected *unstructured.UnstructuredList from the registry, got %T", result) + } + for i := range list.Items { + stripCacheBookkeeping(&list.Items[i]) + } + return list, nil + } + + delegateWatch := storage.WatcherFunc + storage.WatcherFunc = func(ctx context.Context, options *metainternalversion.ListOptions) (watch.Interface, error) { + w, err := delegateWatch(registryContext(ctx), options) + if err != nil { + return nil, err + } + return newStrippingWatch(ctx, w), nil + } + }) +} + +// strippingWatch wraps a watch.Interface, stripping the cache bookkeeping +// annotations from every object it forwards. Bookmarks and errors pass +// through untouched. +type strippingWatch struct { + delegate watch.Interface + resultChan chan watch.Event + stopOnce sync.Once +} + +func newStrippingWatch(ctx context.Context, delegate watch.Interface) *strippingWatch { + w := &strippingWatch{ + delegate: delegate, + resultChan: make(chan watch.Event, 100), // Matches outgoingBufSize=100 in k8s.io/apiserver/pkg/storage/etcd3/watcher.go. + } + + go func() { + defer close(w.resultChan) + for { + select { + case event, ok := <-delegate.ResultChan(): + if !ok { + return + } + if obj, ok := event.Object.(*unstructured.Unstructured); ok { + stripCacheBookkeeping(obj) + } + select { + case w.resultChan <- event: + case <-ctx.Done(): + delegate.Stop() + return + } + case <-ctx.Done(): + delegate.Stop() + return + } + } + }() + return w +} + +func (w *strippingWatch) Stop() { + w.stopOnce.Do(func() { w.delegate.Stop() }) +} + +func (w *strippingWatch) ResultChan() <-chan watch.Event { + return w.resultChan +} diff --git a/pkg/virtual/admin/builder/build.go b/pkg/virtual/admin/builder/build.go index 130290a88d6..8f732ed0382 100644 --- a/pkg/virtual/admin/builder/build.go +++ b/pkg/virtual/admin/builder/build.go @@ -22,12 +22,12 @@ import ( "strings" "sync" - "k8s.io/apimachinery/pkg/runtime/schema" genericapirequest "k8s.io/apiserver/pkg/endpoints/request" genericapiserver "k8s.io/apiserver/pkg/server" kcpdynamic "github.com/kcp-dev/client-go/dynamic" "github.com/kcp-dev/logicalcluster/v3" + adminv1alpha1 "github.com/kcp-dev/sdk/apis/admin/v1alpha1" "github.com/kcp-dev/sdk/apis/core" corev1alpha1 "github.com/kcp-dev/sdk/apis/core/v1alpha1" "github.com/kcp-dev/virtual-workspace-framework/framework" @@ -41,18 +41,20 @@ import ( adminauthorizer "github.com/kcp-dev/kcp/pkg/virtual/admin/authorizer" ) -// apiDomainKey is the single, static API domain of the shards view: the VW -// serves one fixed API set regardless of the caller. +// apiDomainKey is the single, static API domain of the Admin workspace: the +// VW serves one fixed API set regardless of the caller. const apiDomainKey = dynamiccontext.APIDomainKey(admin.VirtualWorkspaceName) -// BuildVirtualWorkspace builds the Admin workspace virtual workspace. Its -// first resource is a read-only, cache-backed aggregate view of every -// shard's Shard object. +// BuildVirtualWorkspace builds the Admin workspace virtual workspace. It +// serves two cache-backed, installation-wide views: every shard's Shard +// object, writable for the allow-listed operational fields, and the +// read-only APIIdentity registry. // // URL patterns: // // /services/admin/apis/core.kcp.io/v1alpha1/shards (plain clients, e.g. kubectl via `ws use :admin`) -// /services/admin/clusters//apis/core.kcp.io/... (cluster-aware clients; the segment is accepted and ignored) +// /services/admin/apis/admin.kcp.io/v1alpha1/apiidentities +// /services/admin/clusters//apis/... (cluster-aware clients; the segment is accepted and ignored) func BuildVirtualWorkspace( rootPathPrefix string, cacheDynamicClusterClient kcpdynamic.ClusterInterface, @@ -82,7 +84,7 @@ func BuildVirtualWorkspace( } }), BootstrapAPISetManagement: func(mainConfig genericapiserver.CompletedConfig) (apidefinition.APIDefinitionSetGetter, error) { - provider := &singleResourceAPIDefinitionSetProvider{ + provider := &adminAPIDefinitionSetProvider{ config: mainConfig, cacheDynamicClusterClient: cacheDynamicClusterClient, } @@ -103,7 +105,7 @@ func BuildVirtualWorkspace( // digestURL accepts /services/admin and everything below it. An optional // /clusters/ segment (added by cluster-aware clients) is parsed -// and stripped; the shards view is identical for every value. +// and stripped; the views are identical for every value. func digestURL(urlPath, rootPathPrefix string) (cluster genericapirequest.Cluster, logicalPath string, accepted bool) { if !strings.HasPrefix(urlPath, rootPathPrefix) { return genericapirequest.Cluster{}, "", false @@ -139,9 +141,9 @@ func digestURL(urlPath, rootPathPrefix string) (cluster genericapirequest.Cluste return cluster, prefix, true } -// singleResourceAPIDefinitionSetProvider serves the one fixed API of this -// virtual workspace, lazily constructing the serving info once. -type singleResourceAPIDefinitionSetProvider struct { +// adminAPIDefinitionSetProvider serves the fixed API set of this virtual +// workspace, lazily constructing the serving info once. +type adminAPIDefinitionSetProvider struct { config genericapiserver.CompletedConfig cacheDynamicClusterClient kcpdynamic.ClusterInterface @@ -149,7 +151,7 @@ type singleResourceAPIDefinitionSetProvider struct { cached apidefinition.APIDefinitionSet } -func (p *singleResourceAPIDefinitionSetProvider) GetAPIDefinitionSet(_ context.Context, key dynamiccontext.APIDomainKey) (apidefinition.APIDefinitionSet, bool, error) { +func (p *adminAPIDefinitionSetProvider) GetAPIDefinitionSet(_ context.Context, key dynamiccontext.APIDomainKey) (apidefinition.APIDefinitionSet, bool, error) { if key != apiDomainKey { return nil, false, nil } @@ -160,22 +162,24 @@ func (p *singleResourceAPIDefinitionSetProvider) GetAPIDefinitionSet(_ context.C return p.cached, true, nil } - apiDef, err := provideShardsRestStorage(p.config, p.cacheDynamicClusterClient) + shardsDef, err := provideShardsRestStorage(p.config, p.cacheDynamicClusterClient) if err != nil { return nil, false, err } + apiIdentitiesDef, err := provideAPIIdentitiesRestStorage(p.config, p.cacheDynamicClusterClient) + if err != nil { + shardsDef.TearDown() + return nil, false, err + } p.cached = apidefinition.APIDefinitionSet{ - schema.GroupVersionResource{ - Group: corev1alpha1.SchemeGroupVersion.Group, - Version: corev1alpha1.SchemeGroupVersion.Version, - Resource: "shards", - }: apiDef, + corev1alpha1.SchemeGroupVersion.WithResource("shards"): shardsDef, + adminv1alpha1.SchemeGroupVersion.WithResource("apiidentities"): apiIdentitiesDef, } return p.cached, true, nil } -var _ apidefinition.APIDefinitionSetGetter = &singleResourceAPIDefinitionSetProvider{} +var _ apidefinition.APIDefinitionSetGetter = &adminAPIDefinitionSetProvider{} func newAuthorizer() *authorization.Decorator { auth := adminauthorizer.NewAdminAuthorizer() diff --git a/pkg/virtual/admin/builder/build_test.go b/pkg/virtual/admin/builder/build_test.go index 816fe59c7b3..3f13a49684c 100644 --- a/pkg/virtual/admin/builder/build_test.go +++ b/pkg/virtual/admin/builder/build_test.go @@ -26,9 +26,14 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/watch" + genericapirequest "k8s.io/apiserver/pkg/endpoints/request" "github.com/kcp-dev/logicalcluster/v3" corev1alpha1 "github.com/kcp-dev/sdk/apis/core/v1alpha1" + + configshard "github.com/kcp-dev/kcp/config/shard" + cacheclient "github.com/kcp-dev/kcp/pkg/cache/client" + "github.com/kcp-dev/kcp/pkg/cache/client/shard" ) func TestDigestURL(t *testing.T) { @@ -44,6 +49,7 @@ func TestDigestURL(t *testing.T) { }{ {urlPath: "/services/admin", accepted: true, clusterName: "root", prefixToTrim: "/services/admin"}, {urlPath: "/services/admin/apis/core.kcp.io/v1alpha1/shards", accepted: true, clusterName: "root", prefixToTrim: "/services/admin"}, + {urlPath: "/services/admin/apis/admin.kcp.io/v1alpha1/apiidentities", accepted: true, clusterName: "root", prefixToTrim: "/services/admin"}, {urlPath: "/services/admin/clusters/*/apis/core.kcp.io/v1alpha1/shards", accepted: true, wildcard: true, prefixToTrim: "/services/admin/clusters/*"}, {urlPath: "/services/admin/clusters/myws/apis/core.kcp.io/v1alpha1/shards", accepted: true, clusterName: "myws", prefixToTrim: "/services/admin/clusters/myws"}, {urlPath: "/services/adminfoo/apis", accepted: false}, @@ -389,3 +395,44 @@ func TestMutableFieldUpdate(t *testing.T) { }) } } + +func TestRegistryContextTargetsTheRegistryScope(t *testing.T) { + t.Parallel() + ctx := registryContext(context.Background()) + + cluster, err := genericapirequest.ValidClusterFrom(ctx) + require.NoError(t, err) + require.Equal(t, configshard.SystemShardCluster, cluster.Name) + require.False(t, cluster.Wildcard) + + require.Equal(t, shard.Registry, cacheclient.ShardFromContext(ctx)) +} + +func TestStrippingWatchStripsCacheBookkeeping(t *testing.T) { + t.Parallel() + source := watch.NewFake() + w := newStrippingWatch(context.Background(), source) + + entry := &unstructured.Unstructured{} + entry.SetAPIVersion("admin.kcp.io/v1alpha1") + entry.SetKind("APIIdentity") + entry.SetName("abc") + entry.SetAnnotations(map[string]string{ + logicalcluster.AnnotationKey: "system:shard", + "kcp.io/shard": "system:registry", + "cache.kcp.io/original-resource-version": "42", + }) + source.Add(entry) + source.Action(watch.Bookmark, &unstructured.Unstructured{}) + source.Stop() + + var got []watch.Event + for event := range w.ResultChan() { + got = append(got, event) + } + require.Len(t, got, 2) + require.Equal(t, watch.Added, got[0].Type) + anns := got[0].Object.(*unstructured.Unstructured).GetAnnotations() + require.Equal(t, map[string]string{logicalcluster.AnnotationKey: "system:shard"}, anns) + require.Equal(t, watch.Bookmark, got[1].Type) +} diff --git a/pkg/virtual/admin/builder/forwarding.go b/pkg/virtual/admin/builder/forwarding.go index 7c195902733..323a8f18eda 100644 --- a/pkg/virtual/admin/builder/forwarding.go +++ b/pkg/virtual/admin/builder/forwarding.go @@ -52,7 +52,6 @@ import ( configshard "github.com/kcp-dev/kcp/config/shard" cacheclient "github.com/kcp-dev/kcp/pkg/cache/client" "github.com/kcp-dev/kcp/pkg/cache/client/shard" - "github.com/kcp-dev/kcp/pkg/reconciler/cache/replication" ) // provideShardsRestStorage builds the REST storage for the shards view: @@ -169,14 +168,7 @@ func sourceContext(ctx context.Context) context.Context { // Shard object. The kcp.io/cluster annotation is kept: it tells the consumer // in which logical cluster the authoritative object lives. func fixupShard(obj *unstructured.Unstructured) { - annotations := obj.GetAnnotations() - if annotations == nil { - return - } - delete(annotations, shard.AnnotationKey) - delete(annotations, replication.AnnotationKeyOriginalResourceUID) - delete(annotations, replication.AnnotationKeyOriginalResourceVersion) - obj.SetAnnotations(annotations) + stripCacheBookkeeping(obj) } // stripField removes the field at the given path from the object, pruning diff --git a/pkg/virtual/admin/builder/schema.go b/pkg/virtual/admin/builder/schema.go index 0f0e2ba75ec..81c5d45a098 100644 --- a/pkg/virtual/admin/builder/schema.go +++ b/pkg/virtual/admin/builder/schema.go @@ -30,14 +30,25 @@ import ( // derived from the embedded CRD manifest. var ShardsSchema *apisv1alpha1.APIResourceSchema +// APIIdentitiesSchema is the APIResourceSchema for admin.kcp.io/v1alpha1 +// APIIdentities, derived from the embedded CRD manifest. +var APIIdentitiesSchema *apisv1alpha1.APIResourceSchema + func init() { + ShardsSchema = mustSchema("core.kcp.io_shards.yaml") + APIIdentitiesSchema = mustSchema("admin.kcp.io_apiidentities.yaml") +} + +// mustSchema converts the named embedded CRD manifest into an +// APIResourceSchema. +func mustSchema(fileName string) *apisv1alpha1.APIResourceSchema { crd := apiextensionsv1.CustomResourceDefinition{} - if err := configcrds.Unmarshal("core.kcp.io_shards.yaml", &crd); err != nil { - panic(fmt.Sprintf("failed to unmarshal shards CRD: %v", err)) + if err := configcrds.Unmarshal(fileName, &crd); err != nil { + panic(fmt.Sprintf("failed to unmarshal CRD %s: %v", fileName, err)) } schema, err := apisv1alpha1.CRDToAPIResourceSchema(&crd, "crd") if err != nil { panic(fmt.Sprintf("failed to convert CRD %s.%s to APIResourceSchema: %v", crd.Spec.Names.Plural, crd.Spec.Group, err)) } - ShardsSchema = schema + return schema } diff --git a/staging/src/github.com/kcp-dev/sdk/apis/admin/register.go b/staging/src/github.com/kcp-dev/sdk/apis/admin/register.go new file mode 100644 index 00000000000..92b28ad3259 --- /dev/null +++ b/staging/src/github.com/kcp-dev/sdk/apis/admin/register.go @@ -0,0 +1,25 @@ +/* +Copyright 2026 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 admin holds the installation-wide administrative APIs of kcp: +// objects that are owned by kcp itself, live on the cache server and are +// served read-only through the Admin workspace. +package admin + +const ( + // GroupName is the API group of the administrative APIs. + GroupName = "admin.kcp.io" +) diff --git a/staging/src/github.com/kcp-dev/sdk/apis/admin/v1alpha1/doc.go b/staging/src/github.com/kcp-dev/sdk/apis/admin/v1alpha1/doc.go new file mode 100644 index 00000000000..aad70b185bb --- /dev/null +++ b/staging/src/github.com/kcp-dev/sdk/apis/admin/v1alpha1/doc.go @@ -0,0 +1,23 @@ +/* +Copyright 2026 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. +*/ + +// +k8s:deepcopy-gen=package,register +// +groupName=admin.kcp.io +// +k8s:openapi-gen=true +// +k8s:openapi-model-package=com.github.kcp-dev.sdk.apis.admin.v1alpha1 + +// Package v1alpha1 contains the v1alpha1 version of the administrative APIs. +package v1alpha1 diff --git a/staging/src/github.com/kcp-dev/sdk/apis/admin/v1alpha1/register.go b/staging/src/github.com/kcp-dev/sdk/apis/admin/v1alpha1/register.go new file mode 100644 index 00000000000..6a3ae3137ee --- /dev/null +++ b/staging/src/github.com/kcp-dev/sdk/apis/admin/v1alpha1/register.go @@ -0,0 +1,52 @@ +/* +Copyright 2026 The kcp Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/kcp-dev/sdk/apis/admin" +) + +// SchemeGroupVersion is group version used to register these objects. +var SchemeGroupVersion = schema.GroupVersion{Group: admin.GroupName, Version: "v1alpha1"} + +// Kind takes an unqualified kind and returns back a Group qualified GroupKind. +func Kind(kind string) schema.GroupKind { + return SchemeGroupVersion.WithKind(kind).GroupKind() +} + +// Resource takes an unqualified resource and returns a Group qualified GroupResource. +func Resource(resource string) schema.GroupResource { + return SchemeGroupVersion.WithResource(resource).GroupResource() +} + +var ( + SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes) + AddToScheme = SchemeBuilder.AddToScheme +) + +func addKnownTypes(scheme *runtime.Scheme) error { + scheme.AddKnownTypes(SchemeGroupVersion, + &APIIdentity{}, + &APIIdentityList{}, + ) + metav1.AddToGroupVersion(scheme, SchemeGroupVersion) + return nil +} diff --git a/staging/src/github.com/kcp-dev/sdk/apis/admin/v1alpha1/types_apiidentity.go b/staging/src/github.com/kcp-dev/sdk/apis/admin/v1alpha1/types_apiidentity.go new file mode 100644 index 00000000000..66daeb718d2 --- /dev/null +++ b/staging/src/github.com/kcp-dev/sdk/apis/admin/v1alpha1/types_apiidentity.go @@ -0,0 +1,172 @@ +/* +Copyright 2026 The kcp Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + conditionsv1alpha1 "github.com/kcp-dev/sdk/apis/third_party/conditions/apis/conditions/v1alpha1" + "github.com/kcp-dev/sdk/apis/third_party/conditions/util/conditions" +) + +// APIIdentity is the registry entry of one API identity: the record of which +// APIExport owns the identity and which other APIExports hold it. There is +// exactly one entry per identity in an installation, named after the +// identity value, living on the cache server where every shard can read it +// and served read-only through the Admin workspace. +// +// Entries are written by kcp only. The owner is recorded when the identity +// is first claimed, which is the atomic create of the entry; holders record +// themselves when they get the identity. When the owner's APIExport is +// deleted the identity passes to the oldest holder. Nothing that could be +// used to obtain the identity is recorded here, only who has it. +// +// The object has a status field but no status subresource: it lives on the +// cache server, whose status subresource does not honor shard scopes, and it +// is written by kcp only, so spec and status are written together in one +// update of the whole object. +// +// +crd +// +genclient +// +genclient:nonNamespaced +// +genclient:noStatus +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +// +kubebuilder:resource:scope=Cluster,categories=kcp +// +kubebuilder:printcolumn:name="Owner",type=string,JSONPath=`.status.owner.name`,description="APIExport owning the identity" +// +kubebuilder:printcolumn:name="Cluster",type=string,JSONPath=`.status.owner.cluster`,description="Logical cluster of the owner" +// +kubebuilder:printcolumn:name="Holders",type=string,JSONPath=`.status.conditions[?(@.type=="Shared")].status`,priority=1 +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" +type APIIdentity struct { + metav1.TypeMeta `json:",inline"` + // +optional + metav1.ObjectMeta `json:"metadata,omitempty"` + + // +optional + Status APIIdentityStatus `json:"status,omitempty"` +} + +// ExportReference identifies an APIExport across the installation. +type ExportReference struct { + // cluster is the logical cluster name hosting the APIExport. + // + // +required + // +kubebuilder:validation:MinLength=1 + Cluster string `json:"cluster"` + + // name is the name of the APIExport. + // + // +required + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` + + // uid is the UID of the APIExport, so that an export deleted and + // recreated under the same name can be told apart from the original. + // + // +optional + UID string `json:"uid,omitempty"` +} + +// Equal reports whether two references name the same APIExport, ignoring +// the UID: a recreated export under the same name in the same cluster is +// the same export for the purpose of holding an identity. +func (r ExportReference) Equal(o ExportReference) bool { + return r.Cluster == o.Cluster && r.Name == o.Name +} + +// Holder is an APIExport, other than the owner, that serves the identity. +type Holder struct { + ExportReference `json:",inline"` + + // since is when the holder was recorded. + // + // +optional + Since metav1.Time `json:"since,omitempty"` +} + +// APIIdentityStatus is the derived state of an identity. The object has no +// spec: nothing about it is user intent. +type APIIdentityStatus struct { + // owner is the APIExport that holds the identity authoritatively: the + // first one to use it. It is written by kcp when the identity is claimed + // and changed by kcp only when the owner is deleted and a holder + // succeeds it, or an orphaned identity is reclaimed. + // + // +required + Owner ExportReference `json:"owner"` + + // holders lists the APIExports, other than the owner, that serve the + // identity: those whose identity secret carries the same key. A holder + // records itself when it gets the identity and is dropped when it is + // deleted. + // + // +optional + // +listType=map + // +listMapKey=cluster + // +listMapKey=name + Holders []Holder `json:"holders,omitempty"` + + // core is true for the identities of the core APIExports in the root + // workspace, which every shard needs before it can serve. + // + // +optional + Core bool `json:"core,omitempty"` + + // conditions is a list of conditions that apply to the identity. + // + // +optional + Conditions conditionsv1alpha1.Conditions `json:"conditions,omitempty"` +} + +const ( + // APIIdentityShared is True while holders is non-empty. + APIIdentityShared conditionsv1alpha1.ConditionType = "Shared" + + // APIIdentityOrphaned is True while the owner APIExport is gone and no + // holder could succeed it. An orphaned identity can be reclaimed only + // with the secret it was created from. + APIIdentityOrphaned conditionsv1alpha1.ConditionType = "Orphaned" + + // APIIdentityOwnerMissing is True while the owner APIExport is not in + // the cache server. Its transition time starts the grace period after + // which the owner is taken as gone and the identity passes on. + APIIdentityOwnerMissing conditionsv1alpha1.ConditionType = "OwnerMissing" + + // APIIdentityOwnerSucceededReason is set on the Orphaned condition + // (False) after the identity passed to a holder. + APIIdentityOwnerSucceededReason = "OwnerSucceeded" +) + +func (in *APIIdentity) SetConditions(c conditionsv1alpha1.Conditions) { + in.Status.Conditions = c +} + +func (in *APIIdentity) GetConditions() conditionsv1alpha1.Conditions { + return in.Status.Conditions +} + +var _ conditions.Getter = &APIIdentity{} +var _ conditions.Setter = &APIIdentity{} + +// APIIdentityList is a list of APIIdentity resources. +// +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type APIIdentityList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata"` + + Items []APIIdentity `json:"items"` +} diff --git a/staging/src/github.com/kcp-dev/sdk/apis/admin/v1alpha1/zz_generated.deepcopy.go b/staging/src/github.com/kcp-dev/sdk/apis/admin/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 00000000000..727494ddeb3 --- /dev/null +++ b/staging/src/github.com/kcp-dev/sdk/apis/admin/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,153 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +/* +Copyright The kcp Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by deepcopy-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + runtime "k8s.io/apimachinery/pkg/runtime" + + conditionsv1alpha1 "github.com/kcp-dev/sdk/apis/third_party/conditions/apis/conditions/v1alpha1" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *APIIdentity) DeepCopyInto(out *APIIdentity) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Status.DeepCopyInto(&out.Status) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new APIIdentity. +func (in *APIIdentity) DeepCopy() *APIIdentity { + if in == nil { + return nil + } + out := new(APIIdentity) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *APIIdentity) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *APIIdentityList) DeepCopyInto(out *APIIdentityList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]APIIdentity, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new APIIdentityList. +func (in *APIIdentityList) DeepCopy() *APIIdentityList { + if in == nil { + return nil + } + out := new(APIIdentityList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *APIIdentityList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *APIIdentityStatus) DeepCopyInto(out *APIIdentityStatus) { + *out = *in + out.Owner = in.Owner + if in.Holders != nil { + in, out := &in.Holders, &out.Holders + *out = make([]Holder, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make(conditionsv1alpha1.Conditions, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new APIIdentityStatus. +func (in *APIIdentityStatus) DeepCopy() *APIIdentityStatus { + if in == nil { + return nil + } + out := new(APIIdentityStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ExportReference) DeepCopyInto(out *ExportReference) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExportReference. +func (in *ExportReference) DeepCopy() *ExportReference { + if in == nil { + return nil + } + out := new(ExportReference) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Holder) DeepCopyInto(out *Holder) { + *out = *in + out.ExportReference = in.ExportReference + in.Since.DeepCopyInto(&out.Since) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Holder. +func (in *Holder) DeepCopy() *Holder { + if in == nil { + return nil + } + out := new(Holder) + in.DeepCopyInto(out) + return out +} diff --git a/staging/src/github.com/kcp-dev/sdk/apis/admin/v1alpha1/zz_generated.model_name.go b/staging/src/github.com/kcp-dev/sdk/apis/admin/v1alpha1/zz_generated.model_name.go new file mode 100644 index 00000000000..8b958ee5f41 --- /dev/null +++ b/staging/src/github.com/kcp-dev/sdk/apis/admin/v1alpha1/zz_generated.model_name.go @@ -0,0 +1,47 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +/* +Copyright The kcp Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by openapi-gen. DO NOT EDIT. + +package v1alpha1 + +// OpenAPIModelName returns the OpenAPI model name for this type. +func (in APIIdentity) OpenAPIModelName() string { + return "com.github.kcp-dev.sdk.apis.admin.v1alpha1.APIIdentity" +} + +// OpenAPIModelName returns the OpenAPI model name for this type. +func (in APIIdentityList) OpenAPIModelName() string { + return "com.github.kcp-dev.sdk.apis.admin.v1alpha1.APIIdentityList" +} + +// OpenAPIModelName returns the OpenAPI model name for this type. +func (in APIIdentityStatus) OpenAPIModelName() string { + return "com.github.kcp-dev.sdk.apis.admin.v1alpha1.APIIdentityStatus" +} + +// OpenAPIModelName returns the OpenAPI model name for this type. +func (in ExportReference) OpenAPIModelName() string { + return "com.github.kcp-dev.sdk.apis.admin.v1alpha1.ExportReference" +} + +// OpenAPIModelName returns the OpenAPI model name for this type. +func (in Holder) OpenAPIModelName() string { + return "com.github.kcp-dev.sdk.apis.admin.v1alpha1.Holder" +} diff --git a/staging/src/github.com/kcp-dev/sdk/apis/apis/v1alpha2/types_apiexport.go b/staging/src/github.com/kcp-dev/sdk/apis/apis/v1alpha2/types_apiexport.go index 6cc6c6638fd..aaeeeca88e9 100644 --- a/staging/src/github.com/kcp-dev/sdk/apis/apis/v1alpha2/types_apiexport.go +++ b/staging/src/github.com/kcp-dev/sdk/apis/apis/v1alpha2/types_apiexport.go @@ -37,6 +37,9 @@ const ( IdentityVerificationFailedReason = "IdentityVerificationFailed" IdentityGenerationFailedReason = "IdentityGenerationFailed" + // IdentityClaimFailedReason means the identity could not be recorded in + // the APIIdentity registry on the cache server; kcp retries. + IdentityClaimFailedReason = "IdentityClaimFailed" APIExportVirtualWorkspaceURLsReady conditionsv1alpha1.ConditionType = "VirtualWorkspaceURLsReady" diff --git a/staging/src/github.com/kcp-dev/sdk/client/applyconfiguration/admin/v1alpha1/apiidentity.go b/staging/src/github.com/kcp-dev/sdk/client/applyconfiguration/admin/v1alpha1/apiidentity.go new file mode 100644 index 00000000000..15481b809c8 --- /dev/null +++ b/staging/src/github.com/kcp-dev/sdk/client/applyconfiguration/admin/v1alpha1/apiidentity.go @@ -0,0 +1,251 @@ +/* +Copyright The kcp Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + + v1 "github.com/kcp-dev/sdk/client/applyconfiguration/meta/v1" +) + +// APIIdentityApplyConfiguration represents a declarative configuration of the APIIdentity type for use +// with apply. +// +// APIIdentity is the registry entry of one API identity: the record of which +// APIExport owns the identity and which other APIExports hold it. There is +// exactly one entry per identity in an installation, named after the +// identity value, living on the cache server where every shard can read it +// and served read-only through the Admin workspace. +// +// Entries are written by kcp only. The owner is recorded when the identity +// is first claimed, which is the atomic create of the entry; holders record +// themselves when they get the identity. When the owner's APIExport is +// deleted the identity passes to the oldest holder. Nothing that could be +// used to obtain the identity is recorded here, only who has it. +// +// The object has a status field but no status subresource: it lives on the +// cache server, whose status subresource does not honor shard scopes, and it +// is written by kcp only, so spec and status are written together in one +// update of the whole object. +type APIIdentityApplyConfiguration struct { + v1.TypeMetaApplyConfiguration `json:",inline"` + *v1.ObjectMetaApplyConfiguration `json:"metadata,omitempty"` + Status *APIIdentityStatusApplyConfiguration `json:"status,omitempty"` +} + +// APIIdentity constructs a declarative configuration of the APIIdentity type for use with +// apply. +func APIIdentity(name string) *APIIdentityApplyConfiguration { + b := &APIIdentityApplyConfiguration{} + b.WithName(name) + b.WithKind("APIIdentity") + b.WithAPIVersion("admin.kcp.io/v1alpha1") + return b +} + +func (b APIIdentityApplyConfiguration) IsApplyConfiguration() {} + +// WithKind sets the Kind field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Kind field is set to the value of the last call. +func (b *APIIdentityApplyConfiguration) WithKind(value string) *APIIdentityApplyConfiguration { + b.TypeMetaApplyConfiguration.Kind = &value + return b +} + +// WithAPIVersion sets the APIVersion field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the APIVersion field is set to the value of the last call. +func (b *APIIdentityApplyConfiguration) WithAPIVersion(value string) *APIIdentityApplyConfiguration { + b.TypeMetaApplyConfiguration.APIVersion = &value + return b +} + +// WithName sets the Name field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Name field is set to the value of the last call. +func (b *APIIdentityApplyConfiguration) WithName(value string) *APIIdentityApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.Name = &value + return b +} + +// WithGenerateName sets the GenerateName field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the GenerateName field is set to the value of the last call. +func (b *APIIdentityApplyConfiguration) WithGenerateName(value string) *APIIdentityApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.GenerateName = &value + return b +} + +// WithNamespace sets the Namespace field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Namespace field is set to the value of the last call. +func (b *APIIdentityApplyConfiguration) WithNamespace(value string) *APIIdentityApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.Namespace = &value + return b +} + +// WithUID sets the UID field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the UID field is set to the value of the last call. +func (b *APIIdentityApplyConfiguration) WithUID(value types.UID) *APIIdentityApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.UID = &value + return b +} + +// WithResourceVersion sets the ResourceVersion field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the ResourceVersion field is set to the value of the last call. +func (b *APIIdentityApplyConfiguration) WithResourceVersion(value string) *APIIdentityApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.ResourceVersion = &value + return b +} + +// WithGeneration sets the Generation field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Generation field is set to the value of the last call. +func (b *APIIdentityApplyConfiguration) WithGeneration(value int64) *APIIdentityApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.Generation = &value + return b +} + +// WithCreationTimestamp sets the CreationTimestamp field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the CreationTimestamp field is set to the value of the last call. +func (b *APIIdentityApplyConfiguration) WithCreationTimestamp(value metav1.Time) *APIIdentityApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.CreationTimestamp = &value + return b +} + +// WithDeletionTimestamp sets the DeletionTimestamp field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the DeletionTimestamp field is set to the value of the last call. +func (b *APIIdentityApplyConfiguration) WithDeletionTimestamp(value metav1.Time) *APIIdentityApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.DeletionTimestamp = &value + return b +} + +// WithDeletionGracePeriodSeconds sets the DeletionGracePeriodSeconds field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the DeletionGracePeriodSeconds field is set to the value of the last call. +func (b *APIIdentityApplyConfiguration) WithDeletionGracePeriodSeconds(value int64) *APIIdentityApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.DeletionGracePeriodSeconds = &value + return b +} + +// WithLabels puts the entries into the Labels field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, the entries provided by each call will be put on the Labels field, +// overwriting an existing map entries in Labels field with the same key. +func (b *APIIdentityApplyConfiguration) WithLabels(entries map[string]string) *APIIdentityApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + if b.ObjectMetaApplyConfiguration.Labels == nil && len(entries) > 0 { + b.ObjectMetaApplyConfiguration.Labels = make(map[string]string, len(entries)) + } + for k, v := range entries { + b.ObjectMetaApplyConfiguration.Labels[k] = v + } + return b +} + +// WithAnnotations puts the entries into the Annotations field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, the entries provided by each call will be put on the Annotations field, +// overwriting an existing map entries in Annotations field with the same key. +func (b *APIIdentityApplyConfiguration) WithAnnotations(entries map[string]string) *APIIdentityApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + if b.ObjectMetaApplyConfiguration.Annotations == nil && len(entries) > 0 { + b.ObjectMetaApplyConfiguration.Annotations = make(map[string]string, len(entries)) + } + for k, v := range entries { + b.ObjectMetaApplyConfiguration.Annotations[k] = v + } + return b +} + +// WithOwnerReferences adds the given value to the OwnerReferences field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the OwnerReferences field. +func (b *APIIdentityApplyConfiguration) WithOwnerReferences(values ...*v1.OwnerReferenceApplyConfiguration) *APIIdentityApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + for i := range values { + if values[i] == nil { + panic("nil value passed to WithOwnerReferences") + } + b.ObjectMetaApplyConfiguration.OwnerReferences = append(b.ObjectMetaApplyConfiguration.OwnerReferences, *values[i]) + } + return b +} + +// WithFinalizers adds the given value to the Finalizers field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the Finalizers field. +func (b *APIIdentityApplyConfiguration) WithFinalizers(values ...string) *APIIdentityApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + for i := range values { + b.ObjectMetaApplyConfiguration.Finalizers = append(b.ObjectMetaApplyConfiguration.Finalizers, values[i]) + } + return b +} + +func (b *APIIdentityApplyConfiguration) ensureObjectMetaApplyConfigurationExists() { + if b.ObjectMetaApplyConfiguration == nil { + b.ObjectMetaApplyConfiguration = &v1.ObjectMetaApplyConfiguration{} + } +} + +// WithStatus sets the Status field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Status field is set to the value of the last call. +func (b *APIIdentityApplyConfiguration) WithStatus(value *APIIdentityStatusApplyConfiguration) *APIIdentityApplyConfiguration { + b.Status = value + return b +} + +// GetKind retrieves the value of the Kind field in the declarative configuration. +func (b *APIIdentityApplyConfiguration) GetKind() *string { + return b.TypeMetaApplyConfiguration.Kind +} + +// GetAPIVersion retrieves the value of the APIVersion field in the declarative configuration. +func (b *APIIdentityApplyConfiguration) GetAPIVersion() *string { + return b.TypeMetaApplyConfiguration.APIVersion +} + +// GetName retrieves the value of the Name field in the declarative configuration. +func (b *APIIdentityApplyConfiguration) GetName() *string { + b.ensureObjectMetaApplyConfigurationExists() + return b.ObjectMetaApplyConfiguration.Name +} + +// GetNamespace retrieves the value of the Namespace field in the declarative configuration. +func (b *APIIdentityApplyConfiguration) GetNamespace() *string { + b.ensureObjectMetaApplyConfigurationExists() + return b.ObjectMetaApplyConfiguration.Namespace +} diff --git a/staging/src/github.com/kcp-dev/sdk/client/applyconfiguration/admin/v1alpha1/apiidentitystatus.go b/staging/src/github.com/kcp-dev/sdk/client/applyconfiguration/admin/v1alpha1/apiidentitystatus.go new file mode 100644 index 00000000000..6918f9fbf2c --- /dev/null +++ b/staging/src/github.com/kcp-dev/sdk/client/applyconfiguration/admin/v1alpha1/apiidentitystatus.go @@ -0,0 +1,89 @@ +/* +Copyright The kcp Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + conditionsv1alpha1 "github.com/kcp-dev/sdk/apis/third_party/conditions/apis/conditions/v1alpha1" +) + +// APIIdentityStatusApplyConfiguration represents a declarative configuration of the APIIdentityStatus type for use +// with apply. +// +// APIIdentityStatus is the derived state of an identity. The object has no +// spec: nothing about it is user intent. +type APIIdentityStatusApplyConfiguration struct { + // owner is the APIExport that holds the identity authoritatively: the + // first one to use it. It is written by kcp when the identity is claimed + // and changed by kcp only when the owner is deleted and a holder + // succeeds it, or an orphaned identity is reclaimed. + Owner *ExportReferenceApplyConfiguration `json:"owner,omitempty"` + // holders lists the APIExports, other than the owner, that serve the + // identity: those whose identity secret carries the same key. A holder + // records itself when it gets the identity and is dropped when it is + // deleted. + Holders []HolderApplyConfiguration `json:"holders,omitempty"` + // core is true for the identities of the core APIExports in the root + // workspace, which every shard needs before it can serve. + Core *bool `json:"core,omitempty"` + // conditions is a list of conditions that apply to the identity. + Conditions *conditionsv1alpha1.Conditions `json:"conditions,omitempty"` +} + +// APIIdentityStatusApplyConfiguration constructs a declarative configuration of the APIIdentityStatus type for use with +// apply. +func APIIdentityStatus() *APIIdentityStatusApplyConfiguration { + return &APIIdentityStatusApplyConfiguration{} +} + +// WithOwner sets the Owner field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Owner field is set to the value of the last call. +func (b *APIIdentityStatusApplyConfiguration) WithOwner(value *ExportReferenceApplyConfiguration) *APIIdentityStatusApplyConfiguration { + b.Owner = value + return b +} + +// WithHolders adds the given value to the Holders field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the Holders field. +func (b *APIIdentityStatusApplyConfiguration) WithHolders(values ...*HolderApplyConfiguration) *APIIdentityStatusApplyConfiguration { + for i := range values { + if values[i] == nil { + panic("nil value passed to WithHolders") + } + b.Holders = append(b.Holders, *values[i]) + } + return b +} + +// WithCore sets the Core field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Core field is set to the value of the last call. +func (b *APIIdentityStatusApplyConfiguration) WithCore(value bool) *APIIdentityStatusApplyConfiguration { + b.Core = &value + return b +} + +// WithConditions sets the Conditions field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Conditions field is set to the value of the last call. +func (b *APIIdentityStatusApplyConfiguration) WithConditions(value conditionsv1alpha1.Conditions) *APIIdentityStatusApplyConfiguration { + b.Conditions = &value + return b +} diff --git a/staging/src/github.com/kcp-dev/sdk/client/applyconfiguration/admin/v1alpha1/exportreference.go b/staging/src/github.com/kcp-dev/sdk/client/applyconfiguration/admin/v1alpha1/exportreference.go new file mode 100644 index 00000000000..3df81c5b9e0 --- /dev/null +++ b/staging/src/github.com/kcp-dev/sdk/client/applyconfiguration/admin/v1alpha1/exportreference.go @@ -0,0 +1,63 @@ +/* +Copyright The kcp Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1alpha1 + +// ExportReferenceApplyConfiguration represents a declarative configuration of the ExportReference type for use +// with apply. +// +// ExportReference identifies an APIExport across the installation. +type ExportReferenceApplyConfiguration struct { + // cluster is the logical cluster name hosting the APIExport. + Cluster *string `json:"cluster,omitempty"` + // name is the name of the APIExport. + Name *string `json:"name,omitempty"` + // uid is the UID of the APIExport, so that an export deleted and + // recreated under the same name can be told apart from the original. + UID *string `json:"uid,omitempty"` +} + +// ExportReferenceApplyConfiguration constructs a declarative configuration of the ExportReference type for use with +// apply. +func ExportReference() *ExportReferenceApplyConfiguration { + return &ExportReferenceApplyConfiguration{} +} + +// WithCluster sets the Cluster field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Cluster field is set to the value of the last call. +func (b *ExportReferenceApplyConfiguration) WithCluster(value string) *ExportReferenceApplyConfiguration { + b.Cluster = &value + return b +} + +// WithName sets the Name field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Name field is set to the value of the last call. +func (b *ExportReferenceApplyConfiguration) WithName(value string) *ExportReferenceApplyConfiguration { + b.Name = &value + return b +} + +// WithUID sets the UID field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the UID field is set to the value of the last call. +func (b *ExportReferenceApplyConfiguration) WithUID(value string) *ExportReferenceApplyConfiguration { + b.UID = &value + return b +} diff --git a/staging/src/github.com/kcp-dev/sdk/client/applyconfiguration/admin/v1alpha1/holder.go b/staging/src/github.com/kcp-dev/sdk/client/applyconfiguration/admin/v1alpha1/holder.go new file mode 100644 index 00000000000..c48482dcfbf --- /dev/null +++ b/staging/src/github.com/kcp-dev/sdk/client/applyconfiguration/admin/v1alpha1/holder.go @@ -0,0 +1,71 @@ +/* +Copyright The kcp Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// HolderApplyConfiguration represents a declarative configuration of the Holder type for use +// with apply. +// +// Holder is an APIExport, other than the owner, that serves the identity. +type HolderApplyConfiguration struct { + ExportReferenceApplyConfiguration `json:",inline"` + // since is when the holder was recorded. + Since *v1.Time `json:"since,omitempty"` +} + +// HolderApplyConfiguration constructs a declarative configuration of the Holder type for use with +// apply. +func Holder() *HolderApplyConfiguration { + return &HolderApplyConfiguration{} +} + +// WithCluster sets the Cluster field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Cluster field is set to the value of the last call. +func (b *HolderApplyConfiguration) WithCluster(value string) *HolderApplyConfiguration { + b.ExportReferenceApplyConfiguration.Cluster = &value + return b +} + +// WithName sets the Name field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Name field is set to the value of the last call. +func (b *HolderApplyConfiguration) WithName(value string) *HolderApplyConfiguration { + b.ExportReferenceApplyConfiguration.Name = &value + return b +} + +// WithUID sets the UID field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the UID field is set to the value of the last call. +func (b *HolderApplyConfiguration) WithUID(value string) *HolderApplyConfiguration { + b.ExportReferenceApplyConfiguration.UID = &value + return b +} + +// WithSince sets the Since field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Since field is set to the value of the last call. +func (b *HolderApplyConfiguration) WithSince(value v1.Time) *HolderApplyConfiguration { + b.Since = &value + return b +} diff --git a/staging/src/github.com/kcp-dev/sdk/client/applyconfiguration/utils.go b/staging/src/github.com/kcp-dev/sdk/client/applyconfiguration/utils.go index 4a0ea5ba332..da7b18fbaa1 100644 --- a/staging/src/github.com/kcp-dev/sdk/client/applyconfiguration/utils.go +++ b/staging/src/github.com/kcp-dev/sdk/client/applyconfiguration/utils.go @@ -25,7 +25,8 @@ import ( managedfields "k8s.io/apimachinery/pkg/util/managedfields" metav1 "k8s.io/client-go/applyconfigurations/meta/v1" - v1alpha1 "github.com/kcp-dev/sdk/apis/apis/v1alpha1" + v1alpha1 "github.com/kcp-dev/sdk/apis/admin/v1alpha1" + apisv1alpha1 "github.com/kcp-dev/sdk/apis/apis/v1alpha1" v1alpha2 "github.com/kcp-dev/sdk/apis/apis/v1alpha2" cachev1alpha1 "github.com/kcp-dev/sdk/apis/cache/v1alpha1" corev1alpha1 "github.com/kcp-dev/sdk/apis/core/v1alpha1" @@ -33,7 +34,8 @@ import ( tenancyv1alpha1 "github.com/kcp-dev/sdk/apis/tenancy/v1alpha1" conditionsv1alpha1 "github.com/kcp-dev/sdk/apis/third_party/conditions/apis/conditions/v1alpha1" topologyv1alpha1 "github.com/kcp-dev/sdk/apis/topology/v1alpha1" - apisv1alpha1 "github.com/kcp-dev/sdk/client/applyconfiguration/apis/v1alpha1" + adminv1alpha1 "github.com/kcp-dev/sdk/client/applyconfiguration/admin/v1alpha1" + applyconfigurationapisv1alpha1 "github.com/kcp-dev/sdk/client/applyconfiguration/apis/v1alpha1" apisv1alpha2 "github.com/kcp-dev/sdk/client/applyconfiguration/apis/v1alpha2" applyconfigurationcachev1alpha1 "github.com/kcp-dev/sdk/client/applyconfiguration/cache/v1alpha1" applyconfigurationconditionsv1alpha1 "github.com/kcp-dev/sdk/client/applyconfiguration/conditions/v1alpha1" @@ -49,67 +51,77 @@ import ( // apply configuration type exists for the given GroupVersionKind. func ForKind(kind schema.GroupVersionKind) interface{} { switch kind { - // Group=apis.kcp.io, Version=v1alpha1 - case v1alpha1.SchemeGroupVersion.WithKind("AcceptablePermissionClaim"): - return &apisv1alpha1.AcceptablePermissionClaimApplyConfiguration{} - case v1alpha1.SchemeGroupVersion.WithKind("APIBinding"): - return &apisv1alpha1.APIBindingApplyConfiguration{} - case v1alpha1.SchemeGroupVersion.WithKind("APIBindingSpec"): - return &apisv1alpha1.APIBindingSpecApplyConfiguration{} - case v1alpha1.SchemeGroupVersion.WithKind("APIBindingStatus"): - return &apisv1alpha1.APIBindingStatusApplyConfiguration{} - case v1alpha1.SchemeGroupVersion.WithKind("APIConversion"): - return &apisv1alpha1.APIConversionApplyConfiguration{} - case v1alpha1.SchemeGroupVersion.WithKind("APIConversionRule"): - return &apisv1alpha1.APIConversionRuleApplyConfiguration{} - case v1alpha1.SchemeGroupVersion.WithKind("APIConversionSpec"): - return &apisv1alpha1.APIConversionSpecApplyConfiguration{} - case v1alpha1.SchemeGroupVersion.WithKind("APIExport"): - return &apisv1alpha1.APIExportApplyConfiguration{} - case v1alpha1.SchemeGroupVersion.WithKind("APIExportEndpointSlice"): - return &apisv1alpha1.APIExportEndpointSliceApplyConfiguration{} - case v1alpha1.SchemeGroupVersion.WithKind("APIExportEndpointSliceSpec"): - return &apisv1alpha1.APIExportEndpointSliceSpecApplyConfiguration{} - case v1alpha1.SchemeGroupVersion.WithKind("APIExportEndpointSliceStatus"): - return &apisv1alpha1.APIExportEndpointSliceStatusApplyConfiguration{} - case v1alpha1.SchemeGroupVersion.WithKind("APIExportSpec"): - return &apisv1alpha1.APIExportSpecApplyConfiguration{} - case v1alpha1.SchemeGroupVersion.WithKind("APIExportStatus"): - return &apisv1alpha1.APIExportStatusApplyConfiguration{} - case v1alpha1.SchemeGroupVersion.WithKind("APIResourceSchema"): - return &apisv1alpha1.APIResourceSchemaApplyConfiguration{} - case v1alpha1.SchemeGroupVersion.WithKind("APIResourceSchemaSpec"): - return &apisv1alpha1.APIResourceSchemaSpecApplyConfiguration{} - case v1alpha1.SchemeGroupVersion.WithKind("APIResourceVersion"): - return &apisv1alpha1.APIResourceVersionApplyConfiguration{} - case v1alpha1.SchemeGroupVersion.WithKind("APIVersionConversion"): - return &apisv1alpha1.APIVersionConversionApplyConfiguration{} - case v1alpha1.SchemeGroupVersion.WithKind("BindingReference"): - return &apisv1alpha1.BindingReferenceApplyConfiguration{} - case v1alpha1.SchemeGroupVersion.WithKind("BoundAPIResource"): - return &apisv1alpha1.BoundAPIResourceApplyConfiguration{} - case v1alpha1.SchemeGroupVersion.WithKind("BoundAPIResourceSchema"): - return &apisv1alpha1.BoundAPIResourceSchemaApplyConfiguration{} - case v1alpha1.SchemeGroupVersion.WithKind("CustomResourceConversion"): - return &apisv1alpha1.CustomResourceConversionApplyConfiguration{} - case v1alpha1.SchemeGroupVersion.WithKind("ExportBindingReference"): - return &apisv1alpha1.ExportBindingReferenceApplyConfiguration{} - case v1alpha1.SchemeGroupVersion.WithKind("GroupResource"): - return &apisv1alpha1.GroupResourceApplyConfiguration{} - case v1alpha1.SchemeGroupVersion.WithKind("Identity"): - return &apisv1alpha1.IdentityApplyConfiguration{} - case v1alpha1.SchemeGroupVersion.WithKind("MaximalPermissionPolicy"): - return &apisv1alpha1.MaximalPermissionPolicyApplyConfiguration{} - case v1alpha1.SchemeGroupVersion.WithKind("PermissionClaim"): - return &apisv1alpha1.PermissionClaimApplyConfiguration{} - case v1alpha1.SchemeGroupVersion.WithKind("ResourceSelector"): - return &apisv1alpha1.ResourceSelectorApplyConfiguration{} - case v1alpha1.SchemeGroupVersion.WithKind("VirtualWorkspace"): - return &apisv1alpha1.VirtualWorkspaceApplyConfiguration{} - case v1alpha1.SchemeGroupVersion.WithKind("WebhookClientConfig"): - return &apisv1alpha1.WebhookClientConfigApplyConfiguration{} - case v1alpha1.SchemeGroupVersion.WithKind("WebhookConversion"): - return &apisv1alpha1.WebhookConversionApplyConfiguration{} + // Group=admin.kcp.io, Version=v1alpha1 + case v1alpha1.SchemeGroupVersion.WithKind("APIIdentity"): + return &adminv1alpha1.APIIdentityApplyConfiguration{} + case v1alpha1.SchemeGroupVersion.WithKind("APIIdentityStatus"): + return &adminv1alpha1.APIIdentityStatusApplyConfiguration{} + case v1alpha1.SchemeGroupVersion.WithKind("ExportReference"): + return &adminv1alpha1.ExportReferenceApplyConfiguration{} + case v1alpha1.SchemeGroupVersion.WithKind("Holder"): + return &adminv1alpha1.HolderApplyConfiguration{} + + // Group=apis.kcp.io, Version=v1alpha1 + case apisv1alpha1.SchemeGroupVersion.WithKind("AcceptablePermissionClaim"): + return &applyconfigurationapisv1alpha1.AcceptablePermissionClaimApplyConfiguration{} + case apisv1alpha1.SchemeGroupVersion.WithKind("APIBinding"): + return &applyconfigurationapisv1alpha1.APIBindingApplyConfiguration{} + case apisv1alpha1.SchemeGroupVersion.WithKind("APIBindingSpec"): + return &applyconfigurationapisv1alpha1.APIBindingSpecApplyConfiguration{} + case apisv1alpha1.SchemeGroupVersion.WithKind("APIBindingStatus"): + return &applyconfigurationapisv1alpha1.APIBindingStatusApplyConfiguration{} + case apisv1alpha1.SchemeGroupVersion.WithKind("APIConversion"): + return &applyconfigurationapisv1alpha1.APIConversionApplyConfiguration{} + case apisv1alpha1.SchemeGroupVersion.WithKind("APIConversionRule"): + return &applyconfigurationapisv1alpha1.APIConversionRuleApplyConfiguration{} + case apisv1alpha1.SchemeGroupVersion.WithKind("APIConversionSpec"): + return &applyconfigurationapisv1alpha1.APIConversionSpecApplyConfiguration{} + case apisv1alpha1.SchemeGroupVersion.WithKind("APIExport"): + return &applyconfigurationapisv1alpha1.APIExportApplyConfiguration{} + case apisv1alpha1.SchemeGroupVersion.WithKind("APIExportEndpointSlice"): + return &applyconfigurationapisv1alpha1.APIExportEndpointSliceApplyConfiguration{} + case apisv1alpha1.SchemeGroupVersion.WithKind("APIExportEndpointSliceSpec"): + return &applyconfigurationapisv1alpha1.APIExportEndpointSliceSpecApplyConfiguration{} + case apisv1alpha1.SchemeGroupVersion.WithKind("APIExportEndpointSliceStatus"): + return &applyconfigurationapisv1alpha1.APIExportEndpointSliceStatusApplyConfiguration{} + case apisv1alpha1.SchemeGroupVersion.WithKind("APIExportSpec"): + return &applyconfigurationapisv1alpha1.APIExportSpecApplyConfiguration{} + case apisv1alpha1.SchemeGroupVersion.WithKind("APIExportStatus"): + return &applyconfigurationapisv1alpha1.APIExportStatusApplyConfiguration{} + case apisv1alpha1.SchemeGroupVersion.WithKind("APIResourceSchema"): + return &applyconfigurationapisv1alpha1.APIResourceSchemaApplyConfiguration{} + case apisv1alpha1.SchemeGroupVersion.WithKind("APIResourceSchemaSpec"): + return &applyconfigurationapisv1alpha1.APIResourceSchemaSpecApplyConfiguration{} + case apisv1alpha1.SchemeGroupVersion.WithKind("APIResourceVersion"): + return &applyconfigurationapisv1alpha1.APIResourceVersionApplyConfiguration{} + case apisv1alpha1.SchemeGroupVersion.WithKind("APIVersionConversion"): + return &applyconfigurationapisv1alpha1.APIVersionConversionApplyConfiguration{} + case apisv1alpha1.SchemeGroupVersion.WithKind("BindingReference"): + return &applyconfigurationapisv1alpha1.BindingReferenceApplyConfiguration{} + case apisv1alpha1.SchemeGroupVersion.WithKind("BoundAPIResource"): + return &applyconfigurationapisv1alpha1.BoundAPIResourceApplyConfiguration{} + case apisv1alpha1.SchemeGroupVersion.WithKind("BoundAPIResourceSchema"): + return &applyconfigurationapisv1alpha1.BoundAPIResourceSchemaApplyConfiguration{} + case apisv1alpha1.SchemeGroupVersion.WithKind("CustomResourceConversion"): + return &applyconfigurationapisv1alpha1.CustomResourceConversionApplyConfiguration{} + case apisv1alpha1.SchemeGroupVersion.WithKind("ExportBindingReference"): + return &applyconfigurationapisv1alpha1.ExportBindingReferenceApplyConfiguration{} + case apisv1alpha1.SchemeGroupVersion.WithKind("GroupResource"): + return &applyconfigurationapisv1alpha1.GroupResourceApplyConfiguration{} + case apisv1alpha1.SchemeGroupVersion.WithKind("Identity"): + return &applyconfigurationapisv1alpha1.IdentityApplyConfiguration{} + case apisv1alpha1.SchemeGroupVersion.WithKind("MaximalPermissionPolicy"): + return &applyconfigurationapisv1alpha1.MaximalPermissionPolicyApplyConfiguration{} + case apisv1alpha1.SchemeGroupVersion.WithKind("PermissionClaim"): + return &applyconfigurationapisv1alpha1.PermissionClaimApplyConfiguration{} + case apisv1alpha1.SchemeGroupVersion.WithKind("ResourceSelector"): + return &applyconfigurationapisv1alpha1.ResourceSelectorApplyConfiguration{} + case apisv1alpha1.SchemeGroupVersion.WithKind("VirtualWorkspace"): + return &applyconfigurationapisv1alpha1.VirtualWorkspaceApplyConfiguration{} + case apisv1alpha1.SchemeGroupVersion.WithKind("WebhookClientConfig"): + return &applyconfigurationapisv1alpha1.WebhookClientConfigApplyConfiguration{} + case apisv1alpha1.SchemeGroupVersion.WithKind("WebhookConversion"): + return &applyconfigurationapisv1alpha1.WebhookConversionApplyConfiguration{} // Group=apis.kcp.io, Version=v1alpha2 case v1alpha2.SchemeGroupVersion.WithKind("AcceptablePermissionClaim"): diff --git a/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/clientset.go b/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/clientset.go index dd061114ab8..5820e7e93a6 100644 --- a/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/clientset.go +++ b/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/clientset.go @@ -26,6 +26,7 @@ import ( rest "k8s.io/client-go/rest" flowcontrol "k8s.io/client-go/util/flowcontrol" + adminv1alpha1 "github.com/kcp-dev/sdk/client/clientset/versioned/typed/admin/v1alpha1" apisv1alpha1 "github.com/kcp-dev/sdk/client/clientset/versioned/typed/apis/v1alpha1" apisv1alpha2 "github.com/kcp-dev/sdk/client/clientset/versioned/typed/apis/v1alpha2" cachev1alpha1 "github.com/kcp-dev/sdk/client/clientset/versioned/typed/cache/v1alpha1" @@ -37,6 +38,7 @@ import ( type Interface interface { Discovery() discovery.DiscoveryInterface + AdminV1alpha1() adminv1alpha1.AdminV1alpha1Interface ApisV1alpha1() apisv1alpha1.ApisV1alpha1Interface ApisV1alpha2() apisv1alpha2.ApisV1alpha2Interface CacheV1alpha1() cachev1alpha1.CacheV1alpha1Interface @@ -49,6 +51,7 @@ type Interface interface { // Clientset contains the clients for groups. type Clientset struct { *discovery.DiscoveryClient + adminV1alpha1 *adminv1alpha1.AdminV1alpha1Client apisV1alpha1 *apisv1alpha1.ApisV1alpha1Client apisV1alpha2 *apisv1alpha2.ApisV1alpha2Client cacheV1alpha1 *cachev1alpha1.CacheV1alpha1Client @@ -58,6 +61,11 @@ type Clientset struct { topologyV1alpha1 *topologyv1alpha1.TopologyV1alpha1Client } +// AdminV1alpha1 retrieves the AdminV1alpha1Client +func (c *Clientset) AdminV1alpha1() adminv1alpha1.AdminV1alpha1Interface { + return c.adminV1alpha1 +} + // ApisV1alpha1 retrieves the ApisV1alpha1Client func (c *Clientset) ApisV1alpha1() apisv1alpha1.ApisV1alpha1Interface { return c.apisV1alpha1 @@ -137,6 +145,10 @@ func NewForConfigAndClient(c *rest.Config, httpClient *http.Client) (*Clientset, var cs Clientset var err error + cs.adminV1alpha1, err = adminv1alpha1.NewForConfigAndClient(&configShallowCopy, httpClient) + if err != nil { + return nil, err + } cs.apisV1alpha1, err = apisv1alpha1.NewForConfigAndClient(&configShallowCopy, httpClient) if err != nil { return nil, err @@ -186,6 +198,7 @@ func NewForConfigOrDie(c *rest.Config) *Clientset { // New creates a new Clientset for the given RESTClient. func New(c rest.Interface) *Clientset { var cs Clientset + cs.adminV1alpha1 = adminv1alpha1.New(c) cs.apisV1alpha1 = apisv1alpha1.New(c) cs.apisV1alpha2 = apisv1alpha2.New(c) cs.cacheV1alpha1 = cachev1alpha1.New(c) diff --git a/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/cluster/clientset.go b/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/cluster/clientset.go index f79a81cf9b6..06ff600e51d 100644 --- a/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/cluster/clientset.go +++ b/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/cluster/clientset.go @@ -29,6 +29,7 @@ import ( kcpclient "github.com/kcp-dev/apimachinery/v2/pkg/client" "github.com/kcp-dev/logicalcluster/v3" client "github.com/kcp-dev/sdk/client/clientset/versioned" + adminv1alpha1 "github.com/kcp-dev/sdk/client/clientset/versioned/cluster/typed/admin/v1alpha1" apisv1alpha1 "github.com/kcp-dev/sdk/client/clientset/versioned/cluster/typed/apis/v1alpha1" apisv1alpha2 "github.com/kcp-dev/sdk/client/clientset/versioned/cluster/typed/apis/v1alpha2" cachev1alpha1 "github.com/kcp-dev/sdk/client/clientset/versioned/cluster/typed/cache/v1alpha1" @@ -41,6 +42,7 @@ import ( type ClusterInterface interface { Cluster(logicalcluster.Path) client.Interface Discovery() discovery.DiscoveryInterface + AdminV1alpha1() adminv1alpha1.AdminV1alpha1ClusterInterface ApisV1alpha1() apisv1alpha1.ApisV1alpha1ClusterInterface ApisV1alpha2() apisv1alpha2.ApisV1alpha2ClusterInterface CacheV1alpha1() cachev1alpha1.CacheV1alpha1ClusterInterface @@ -54,6 +56,7 @@ type ClusterInterface interface { type ClusterClientset struct { *discovery.DiscoveryClient clientCache kcpclient.Cache[*client.Clientset] + adminV1alpha1 *adminv1alpha1.AdminV1alpha1ClusterClient apisV1alpha1 *apisv1alpha1.ApisV1alpha1ClusterClient apisV1alpha2 *apisv1alpha2.ApisV1alpha2ClusterClient cacheV1alpha1 *cachev1alpha1.CacheV1alpha1ClusterClient @@ -71,6 +74,11 @@ func (c *ClusterClientset) Discovery() discovery.DiscoveryInterface { return c.DiscoveryClient } +// AdminV1alpha1 retrieves the AdminV1alpha1ClusterClient. +func (c *ClusterClientset) AdminV1alpha1() adminv1alpha1.AdminV1alpha1ClusterInterface { + return c.adminV1alpha1 +} + // ApisV1alpha1 retrieves the ApisV1alpha1ClusterClient. func (c *ClusterClientset) ApisV1alpha1() apisv1alpha1.ApisV1alpha1ClusterInterface { return c.apisV1alpha1 @@ -158,6 +166,10 @@ func NewForConfigAndClient(c *rest.Config, httpClient *http.Client) (*ClusterCli var cs ClusterClientset cs.clientCache = cache var err error + cs.adminV1alpha1, err = adminv1alpha1.NewForConfigAndClient(&configShallowCopy, httpClient) + if err != nil { + return nil, err + } cs.apisV1alpha1, err = apisv1alpha1.NewForConfigAndClient(&configShallowCopy, httpClient) if err != nil { return nil, err @@ -207,6 +219,7 @@ func NewForConfigOrDie(c *rest.Config) *ClusterClientset { // New creates a new ClusterClientset for the given RESTClient. func New(c *rest.Config) *ClusterClientset { var cs ClusterClientset + cs.adminV1alpha1 = adminv1alpha1.NewForConfigOrDie(c) cs.apisV1alpha1 = apisv1alpha1.NewForConfigOrDie(c) cs.apisV1alpha2 = apisv1alpha2.NewForConfigOrDie(c) cs.cacheV1alpha1 = cachev1alpha1.NewForConfigOrDie(c) diff --git a/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/cluster/fake/clientset.go b/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/cluster/fake/clientset.go index 9b1532c35e9..319a4e7f732 100644 --- a/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/cluster/fake/clientset.go +++ b/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/cluster/fake/clientset.go @@ -29,6 +29,8 @@ import ( clientset "github.com/kcp-dev/sdk/client/clientset/versioned" kcpclientset "github.com/kcp-dev/sdk/client/clientset/versioned/cluster" kcpclientscheme "github.com/kcp-dev/sdk/client/clientset/versioned/cluster/scheme" + kcpadminv1alpha1 "github.com/kcp-dev/sdk/client/clientset/versioned/cluster/typed/admin/v1alpha1" + kcpfakeadminv1alpha1 "github.com/kcp-dev/sdk/client/clientset/versioned/cluster/typed/admin/v1alpha1/fake" kcpapisv1alpha1 "github.com/kcp-dev/sdk/client/clientset/versioned/cluster/typed/apis/v1alpha1" kcpfakeapisv1alpha1 "github.com/kcp-dev/sdk/client/clientset/versioned/cluster/typed/apis/v1alpha1/fake" kcpapisv1alpha2 "github.com/kcp-dev/sdk/client/clientset/versioned/cluster/typed/apis/v1alpha2" @@ -43,6 +45,7 @@ import ( kcpfaketenancyv1alpha1 "github.com/kcp-dev/sdk/client/clientset/versioned/cluster/typed/tenancy/v1alpha1/fake" kcptopologyv1alpha1 "github.com/kcp-dev/sdk/client/clientset/versioned/cluster/typed/topology/v1alpha1" kcpfaketopologyv1alpha1 "github.com/kcp-dev/sdk/client/clientset/versioned/cluster/typed/topology/v1alpha1/fake" + adminv1alpha1 "github.com/kcp-dev/sdk/client/clientset/versioned/typed/admin/v1alpha1" apisv1alpha1 "github.com/kcp-dev/sdk/client/clientset/versioned/typed/apis/v1alpha1" apisv1alpha2 "github.com/kcp-dev/sdk/client/clientset/versioned/typed/apis/v1alpha2" cachev1alpha1 "github.com/kcp-dev/sdk/client/clientset/versioned/typed/cache/v1alpha1" @@ -125,6 +128,11 @@ func (c *ClusterClientset) Cluster(clusterPath logicalcluster.Path) clientset.In } } +// AdminV1alpha1 retrieves the AdminV1alpha1ClusterClient +func (c *ClusterClientset) AdminV1alpha1() kcpadminv1alpha1.AdminV1alpha1ClusterInterface { + return &kcpfakeadminv1alpha1.AdminV1alpha1ClusterClient{Fake: &c.Fake} +} + // ApisV1alpha1 retrieves the ApisV1alpha1ClusterClient func (c *ClusterClientset) ApisV1alpha1() kcpapisv1alpha1.ApisV1alpha1ClusterInterface { return &kcpfakeapisv1alpha1.ApisV1alpha1ClusterClient{Fake: &c.Fake} @@ -203,6 +211,11 @@ func NewClientset(objects ...runtime.Object) *ClusterClientset { return cs } +// AdminV1alpha1 retrieves the AdminV1alpha1Client +func (c *Clientset) AdminV1alpha1() adminv1alpha1.AdminV1alpha1Interface { + return &kcpfakeadminv1alpha1.AdminV1alpha1Client{Fake: c.Fake, ClusterPath: c.clusterPath} +} + // ApisV1alpha1 retrieves the ApisV1alpha1Client func (c *Clientset) ApisV1alpha1() apisv1alpha1.ApisV1alpha1Interface { return &kcpfakeapisv1alpha1.ApisV1alpha1Client{Fake: c.Fake, ClusterPath: c.clusterPath} diff --git a/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/cluster/fake/register.go b/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/cluster/fake/register.go index 9701248653e..522d106f7f2 100644 --- a/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/cluster/fake/register.go +++ b/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/cluster/fake/register.go @@ -25,6 +25,7 @@ import ( serializer "k8s.io/apimachinery/pkg/runtime/serializer" utilruntime "k8s.io/apimachinery/pkg/util/runtime" + adminv1alpha1 "github.com/kcp-dev/sdk/apis/admin/v1alpha1" apisv1alpha1 "github.com/kcp-dev/sdk/apis/apis/v1alpha1" apisv1alpha2 "github.com/kcp-dev/sdk/apis/apis/v1alpha2" cachev1alpha1 "github.com/kcp-dev/sdk/apis/cache/v1alpha1" @@ -38,6 +39,7 @@ var scheme = runtime.NewScheme() var codecs = serializer.NewCodecFactory(scheme) var localSchemeBuilder = runtime.SchemeBuilder{ + adminv1alpha1.AddToScheme, apisv1alpha1.AddToScheme, apisv1alpha2.AddToScheme, cachev1alpha1.AddToScheme, diff --git a/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/cluster/scheme/register.go b/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/cluster/scheme/register.go index f2981eeee15..6aab37902f8 100644 --- a/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/cluster/scheme/register.go +++ b/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/cluster/scheme/register.go @@ -25,6 +25,7 @@ import ( serializer "k8s.io/apimachinery/pkg/runtime/serializer" utilruntime "k8s.io/apimachinery/pkg/util/runtime" + adminv1alpha1 "github.com/kcp-dev/sdk/apis/admin/v1alpha1" apisv1alpha1 "github.com/kcp-dev/sdk/apis/apis/v1alpha1" apisv1alpha2 "github.com/kcp-dev/sdk/apis/apis/v1alpha2" cachev1alpha1 "github.com/kcp-dev/sdk/apis/cache/v1alpha1" @@ -38,6 +39,7 @@ var Scheme = runtime.NewScheme() var Codecs = serializer.NewCodecFactory(Scheme) var ParameterCodec = runtime.NewParameterCodec(Scheme) var localSchemeBuilder = runtime.SchemeBuilder{ + adminv1alpha1.AddToScheme, apisv1alpha1.AddToScheme, apisv1alpha2.AddToScheme, cachev1alpha1.AddToScheme, diff --git a/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/cluster/typed/admin/v1alpha1/admin_client.go b/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/cluster/typed/admin/v1alpha1/admin_client.go new file mode 100644 index 00000000000..29ce8f8bf9d --- /dev/null +++ b/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/cluster/typed/admin/v1alpha1/admin_client.go @@ -0,0 +1,103 @@ +/* +Copyright The kcp Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by cluster-client-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + http "net/http" + + rest "k8s.io/client-go/rest" + + kcpclient "github.com/kcp-dev/apimachinery/v2/pkg/client" + "github.com/kcp-dev/logicalcluster/v3" + kcpadminv1alpha1 "github.com/kcp-dev/sdk/apis/admin/v1alpha1" + kcpscheme "github.com/kcp-dev/sdk/client/clientset/versioned/cluster/scheme" + kcpv1alpha1 "github.com/kcp-dev/sdk/client/clientset/versioned/typed/admin/v1alpha1" +) + +type AdminV1alpha1ClusterInterface interface { + AdminV1alpha1ClusterScoper + APIIdentitiesClusterGetter +} + +type AdminV1alpha1ClusterScoper interface { + Cluster(logicalcluster.Path) kcpv1alpha1.AdminV1alpha1Interface +} + +// AdminV1alpha1ClusterClient is used to interact with features provided by the admin.kcp.io group. +type AdminV1alpha1ClusterClient struct { + clientCache kcpclient.Cache[*kcpv1alpha1.AdminV1alpha1Client] +} + +func (c *AdminV1alpha1ClusterClient) Cluster(clusterPath logicalcluster.Path) kcpv1alpha1.AdminV1alpha1Interface { + if clusterPath == logicalcluster.Wildcard { + panic("A specific cluster must be provided when scoping, not the wildcard.") + } + return c.clientCache.ClusterOrDie(clusterPath) +} + +func (c *AdminV1alpha1ClusterClient) APIIdentities() APIIdentityClusterInterface { + return &aPIIdentitiesClusterInterface{clientCache: c.clientCache} +} + +// NewForConfig creates a new AdminV1alpha1ClusterClient for the given config. +// NewForConfig is equivalent to NewForConfigAndClient(c, httpClient), +// where httpClient was generated with rest.HTTPClientFor(c). +func NewForConfig(c *rest.Config) (*AdminV1alpha1ClusterClient, error) { + config := *c + setConfigDefaults(&config) + httpClient, err := rest.HTTPClientFor(&config) + if err != nil { + return nil, err + } + return NewForConfigAndClient(&config, httpClient) +} + +// NewForConfigAndClient creates a new AdminV1alpha1ClusterClient for the given config and http client. +// Note the http client provided takes precedence over the configured transport values. +func NewForConfigAndClient(c *rest.Config, h *http.Client) (*AdminV1alpha1ClusterClient, error) { + cache := kcpclient.NewCache(c, h, &kcpclient.Constructor[*kcpv1alpha1.AdminV1alpha1Client]{ + NewForConfigAndClient: kcpv1alpha1.NewForConfigAndClient, + }) + if _, err := cache.Cluster(logicalcluster.Name("root").Path()); err != nil { + return nil, err + } + + return &AdminV1alpha1ClusterClient{clientCache: cache}, nil +} + +// NewForConfigOrDie creates a new AdminV1alpha1ClusterClient for the given config and +// panics if there is an error in the config. +func NewForConfigOrDie(c *rest.Config) *AdminV1alpha1ClusterClient { + client, err := NewForConfig(c) + if err != nil { + panic(err) + } + return client +} + +func setConfigDefaults(config *rest.Config) { + gv := kcpadminv1alpha1.SchemeGroupVersion + config.GroupVersion = &gv + config.APIPath = "/apis" + config.NegotiatedSerializer = rest.CodecFactoryForGeneratedClient(kcpscheme.Scheme, kcpscheme.Codecs).WithoutConversion() + + if config.UserAgent == "" { + config.UserAgent = rest.DefaultKubernetesUserAgent() + } +} diff --git a/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/cluster/typed/admin/v1alpha1/apiidentity.go b/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/cluster/typed/admin/v1alpha1/apiidentity.go new file mode 100644 index 00000000000..acf4436530d --- /dev/null +++ b/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/cluster/typed/admin/v1alpha1/apiidentity.go @@ -0,0 +1,69 @@ +/* +Copyright The kcp Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by cluster-client-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + context "context" + + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + watch "k8s.io/apimachinery/pkg/watch" + + kcpclient "github.com/kcp-dev/apimachinery/v2/pkg/client" + "github.com/kcp-dev/logicalcluster/v3" + kcpadminv1alpha1 "github.com/kcp-dev/sdk/apis/admin/v1alpha1" + kcpv1alpha1 "github.com/kcp-dev/sdk/client/clientset/versioned/typed/admin/v1alpha1" +) + +// APIIdentitiesClusterGetter has a method to return a APIIdentityClusterInterface. +// A group's cluster client should implement this interface. +type APIIdentitiesClusterGetter interface { + APIIdentities() APIIdentityClusterInterface +} + +// APIIdentityClusterInterface can operate on APIIdentities across all clusters, +// or scope down to one cluster and return a kcpv1alpha1.APIIdentityInterface. +type APIIdentityClusterInterface interface { + Cluster(logicalcluster.Path) kcpv1alpha1.APIIdentityInterface + List(ctx context.Context, opts v1.ListOptions) (*kcpadminv1alpha1.APIIdentityList, error) + Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) + APIIdentityClusterExpansion +} + +type aPIIdentitiesClusterInterface struct { + clientCache kcpclient.Cache[*kcpv1alpha1.AdminV1alpha1Client] +} + +// Cluster scopes the client down to a particular cluster. +func (c *aPIIdentitiesClusterInterface) Cluster(clusterPath logicalcluster.Path) kcpv1alpha1.APIIdentityInterface { + if clusterPath == logicalcluster.Wildcard { + panic("A specific cluster must be provided when scoping, not the wildcard.") + } + + return c.clientCache.ClusterOrDie(clusterPath).APIIdentities() +} + +// List returns the entire collection of all APIIdentities across all clusters. +func (c *aPIIdentitiesClusterInterface) List(ctx context.Context, opts v1.ListOptions) (*kcpadminv1alpha1.APIIdentityList, error) { + return c.clientCache.ClusterOrDie(logicalcluster.Wildcard).APIIdentities().List(ctx, opts) +} + +// Watch begins to watch all APIIdentities across all clusters. +func (c *aPIIdentitiesClusterInterface) Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) { + return c.clientCache.ClusterOrDie(logicalcluster.Wildcard).APIIdentities().Watch(ctx, opts) +} diff --git a/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/cluster/typed/admin/v1alpha1/doc.go b/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/cluster/typed/admin/v1alpha1/doc.go new file mode 100644 index 00000000000..3e873237924 --- /dev/null +++ b/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/cluster/typed/admin/v1alpha1/doc.go @@ -0,0 +1,20 @@ +/* +Copyright The kcp Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by cluster-client-gen. DO NOT EDIT. + +// This package has the automatically generated typed clients. +package v1alpha1 diff --git a/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/cluster/typed/admin/v1alpha1/fake/admin_client.go b/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/cluster/typed/admin/v1alpha1/fake/admin_client.go new file mode 100644 index 00000000000..7099f5e3f27 --- /dev/null +++ b/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/cluster/typed/admin/v1alpha1/fake/admin_client.go @@ -0,0 +1,61 @@ +/* +Copyright The kcp Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by cluster-client-gen. DO NOT EDIT. + +package fake + +import ( + rest "k8s.io/client-go/rest" + + kcptesting "github.com/kcp-dev/client-go/third_party/k8s.io/client-go/testing" + "github.com/kcp-dev/logicalcluster/v3" + kcpadminv1alpha1 "github.com/kcp-dev/sdk/client/clientset/versioned/cluster/typed/admin/v1alpha1" + adminv1alpha1 "github.com/kcp-dev/sdk/client/clientset/versioned/typed/admin/v1alpha1" +) + +var _ kcpadminv1alpha1.AdminV1alpha1ClusterInterface = (*AdminV1alpha1ClusterClient)(nil) + +type AdminV1alpha1ClusterClient struct { + *kcptesting.Fake +} + +func (c *AdminV1alpha1ClusterClient) Cluster(clusterPath logicalcluster.Path) adminv1alpha1.AdminV1alpha1Interface { + if clusterPath == logicalcluster.Wildcard { + panic("A specific cluster must be provided when scoping, not the wildcard.") + } + return &AdminV1alpha1Client{Fake: c.Fake, ClusterPath: clusterPath} +} + +func (c *AdminV1alpha1ClusterClient) APIIdentities() kcpadminv1alpha1.APIIdentityClusterInterface { + return newFakeAPIIdentityClusterClient(c) +} + +type AdminV1alpha1Client struct { + *kcptesting.Fake + ClusterPath logicalcluster.Path +} + +func (c *AdminV1alpha1Client) APIIdentities() adminv1alpha1.APIIdentityInterface { + return newFakeAPIIdentityClient(c.Fake, c.ClusterPath) +} + +// RESTClient returns a RESTClient that is used to communicate +// with API server by this client implementation. +func (c *AdminV1alpha1Client) RESTClient() rest.Interface { + var ret *rest.RESTClient + return ret +} diff --git a/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/cluster/typed/admin/v1alpha1/fake/apiidentity.go b/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/cluster/typed/admin/v1alpha1/fake/apiidentity.go new file mode 100644 index 00000000000..938faacb49f --- /dev/null +++ b/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/cluster/typed/admin/v1alpha1/fake/apiidentity.go @@ -0,0 +1,89 @@ +/* +Copyright The kcp Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by cluster-client-gen. DO NOT EDIT. + +package fake + +import ( + kcpgentype "github.com/kcp-dev/client-go/third_party/k8s.io/client-go/gentype" + kcptesting "github.com/kcp-dev/client-go/third_party/k8s.io/client-go/testing" + "github.com/kcp-dev/logicalcluster/v3" + adminv1alpha1 "github.com/kcp-dev/sdk/apis/admin/v1alpha1" + kcpv1alpha1 "github.com/kcp-dev/sdk/client/applyconfiguration/admin/v1alpha1" + typedkcpadminv1alpha1 "github.com/kcp-dev/sdk/client/clientset/versioned/cluster/typed/admin/v1alpha1" + typedadminv1alpha1 "github.com/kcp-dev/sdk/client/clientset/versioned/typed/admin/v1alpha1" +) + +// aPIIdentityClusterClient implements APIIdentityClusterInterface +type aPIIdentityClusterClient struct { + *kcpgentype.FakeClusterClientWithList[*adminv1alpha1.APIIdentity, *adminv1alpha1.APIIdentityList] + Fake *kcptesting.Fake +} + +func newFakeAPIIdentityClusterClient(fake *AdminV1alpha1ClusterClient) typedkcpadminv1alpha1.APIIdentityClusterInterface { + return &aPIIdentityClusterClient{ + kcpgentype.NewFakeClusterClientWithList[*adminv1alpha1.APIIdentity, *adminv1alpha1.APIIdentityList]( + fake.Fake, + adminv1alpha1.SchemeGroupVersion.WithResource("apiidentities"), + adminv1alpha1.SchemeGroupVersion.WithKind("APIIdentity"), + func() *adminv1alpha1.APIIdentity { return &adminv1alpha1.APIIdentity{} }, + func() *adminv1alpha1.APIIdentityList { return &adminv1alpha1.APIIdentityList{} }, + func(dst, src *adminv1alpha1.APIIdentityList) { dst.ListMeta = src.ListMeta }, + func(list *adminv1alpha1.APIIdentityList) []*adminv1alpha1.APIIdentity { + return kcpgentype.ToPointerSlice(list.Items) + }, + func(list *adminv1alpha1.APIIdentityList, items []*adminv1alpha1.APIIdentity) { + list.Items = kcpgentype.FromPointerSlice(items) + }, + ), + fake.Fake, + } +} + +func (c *aPIIdentityClusterClient) Cluster(cluster logicalcluster.Path) typedadminv1alpha1.APIIdentityInterface { + return newFakeAPIIdentityClient(c.Fake, cluster) +} + +// aPIIdentityScopedClient implements APIIdentityInterface +type aPIIdentityScopedClient struct { + *kcpgentype.FakeClientWithListAndApply[*adminv1alpha1.APIIdentity, *adminv1alpha1.APIIdentityList, *kcpv1alpha1.APIIdentityApplyConfiguration] + Fake *kcptesting.Fake + ClusterPath logicalcluster.Path +} + +func newFakeAPIIdentityClient(fake *kcptesting.Fake, clusterPath logicalcluster.Path) typedadminv1alpha1.APIIdentityInterface { + return &aPIIdentityScopedClient{ + kcpgentype.NewFakeClientWithListAndApply[*adminv1alpha1.APIIdentity, *adminv1alpha1.APIIdentityList, *kcpv1alpha1.APIIdentityApplyConfiguration]( + fake, + clusterPath, + "", + adminv1alpha1.SchemeGroupVersion.WithResource("apiidentities"), + adminv1alpha1.SchemeGroupVersion.WithKind("APIIdentity"), + func() *adminv1alpha1.APIIdentity { return &adminv1alpha1.APIIdentity{} }, + func() *adminv1alpha1.APIIdentityList { return &adminv1alpha1.APIIdentityList{} }, + func(dst, src *adminv1alpha1.APIIdentityList) { dst.ListMeta = src.ListMeta }, + func(list *adminv1alpha1.APIIdentityList) []*adminv1alpha1.APIIdentity { + return kcpgentype.ToPointerSlice(list.Items) + }, + func(list *adminv1alpha1.APIIdentityList, items []*adminv1alpha1.APIIdentity) { + list.Items = kcpgentype.FromPointerSlice(items) + }, + ), + fake, + clusterPath, + } +} diff --git a/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/cluster/typed/admin/v1alpha1/fake/doc.go b/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/cluster/typed/admin/v1alpha1/fake/doc.go new file mode 100644 index 00000000000..100cd63c05b --- /dev/null +++ b/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/cluster/typed/admin/v1alpha1/fake/doc.go @@ -0,0 +1,20 @@ +/* +Copyright The kcp Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by cluster-client-gen. DO NOT EDIT. + +// Package fake has the automatically generated cluster clients. +package fake diff --git a/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/cluster/typed/admin/v1alpha1/generated_expansion.go b/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/cluster/typed/admin/v1alpha1/generated_expansion.go new file mode 100644 index 00000000000..28f295ee6c9 --- /dev/null +++ b/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/cluster/typed/admin/v1alpha1/generated_expansion.go @@ -0,0 +1,21 @@ +/* +Copyright The kcp Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by cluster-client-gen. DO NOT EDIT. + +package v1alpha1 + +type APIIdentityClusterExpansion interface{} diff --git a/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/fake/clientset_generated.go b/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/fake/clientset_generated.go index f5e771300df..dd5b93c8692 100644 --- a/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/fake/clientset_generated.go +++ b/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/fake/clientset_generated.go @@ -28,6 +28,8 @@ import ( applyconfiguration "github.com/kcp-dev/sdk/client/applyconfiguration" clientset "github.com/kcp-dev/sdk/client/clientset/versioned" + adminv1alpha1 "github.com/kcp-dev/sdk/client/clientset/versioned/typed/admin/v1alpha1" + fakeadminv1alpha1 "github.com/kcp-dev/sdk/client/clientset/versioned/typed/admin/v1alpha1/fake" apisv1alpha1 "github.com/kcp-dev/sdk/client/clientset/versioned/typed/apis/v1alpha1" fakeapisv1alpha1 "github.com/kcp-dev/sdk/client/clientset/versioned/typed/apis/v1alpha1/fake" apisv1alpha2 "github.com/kcp-dev/sdk/client/clientset/versioned/typed/apis/v1alpha2" @@ -149,6 +151,11 @@ var ( _ testing.FakeClient = &Clientset{} ) +// AdminV1alpha1 retrieves the AdminV1alpha1Client +func (c *Clientset) AdminV1alpha1() adminv1alpha1.AdminV1alpha1Interface { + return &fakeadminv1alpha1.FakeAdminV1alpha1{Fake: &c.Fake} +} + // ApisV1alpha1 retrieves the ApisV1alpha1Client func (c *Clientset) ApisV1alpha1() apisv1alpha1.ApisV1alpha1Interface { return &fakeapisv1alpha1.FakeApisV1alpha1{Fake: &c.Fake} diff --git a/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/fake/register.go b/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/fake/register.go index 4656aa8f03a..5de56d6c012 100644 --- a/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/fake/register.go +++ b/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/fake/register.go @@ -25,6 +25,7 @@ import ( serializer "k8s.io/apimachinery/pkg/runtime/serializer" utilruntime "k8s.io/apimachinery/pkg/util/runtime" + adminv1alpha1 "github.com/kcp-dev/sdk/apis/admin/v1alpha1" apisv1alpha1 "github.com/kcp-dev/sdk/apis/apis/v1alpha1" apisv1alpha2 "github.com/kcp-dev/sdk/apis/apis/v1alpha2" cachev1alpha1 "github.com/kcp-dev/sdk/apis/cache/v1alpha1" @@ -38,6 +39,7 @@ var scheme = runtime.NewScheme() var codecs = serializer.NewCodecFactory(scheme) var localSchemeBuilder = runtime.SchemeBuilder{ + adminv1alpha1.AddToScheme, apisv1alpha1.AddToScheme, apisv1alpha2.AddToScheme, cachev1alpha1.AddToScheme, diff --git a/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/scheme/register.go b/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/scheme/register.go index f86bd546c4f..29cd396f5b0 100644 --- a/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/scheme/register.go +++ b/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/scheme/register.go @@ -25,6 +25,7 @@ import ( serializer "k8s.io/apimachinery/pkg/runtime/serializer" utilruntime "k8s.io/apimachinery/pkg/util/runtime" + adminv1alpha1 "github.com/kcp-dev/sdk/apis/admin/v1alpha1" apisv1alpha1 "github.com/kcp-dev/sdk/apis/apis/v1alpha1" apisv1alpha2 "github.com/kcp-dev/sdk/apis/apis/v1alpha2" cachev1alpha1 "github.com/kcp-dev/sdk/apis/cache/v1alpha1" @@ -38,6 +39,7 @@ var Scheme = runtime.NewScheme() var Codecs = serializer.NewCodecFactory(Scheme) var ParameterCodec = runtime.NewParameterCodec(Scheme) var localSchemeBuilder = runtime.SchemeBuilder{ + adminv1alpha1.AddToScheme, apisv1alpha1.AddToScheme, apisv1alpha2.AddToScheme, cachev1alpha1.AddToScheme, diff --git a/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/typed/admin/v1alpha1/admin_client.go b/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/typed/admin/v1alpha1/admin_client.go new file mode 100644 index 00000000000..81b4e59acf3 --- /dev/null +++ b/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/typed/admin/v1alpha1/admin_client.go @@ -0,0 +1,102 @@ +/* +Copyright The kcp Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + http "net/http" + + rest "k8s.io/client-go/rest" + + adminv1alpha1 "github.com/kcp-dev/sdk/apis/admin/v1alpha1" + scheme "github.com/kcp-dev/sdk/client/clientset/versioned/scheme" +) + +type AdminV1alpha1Interface interface { + RESTClient() rest.Interface + APIIdentitiesGetter +} + +// AdminV1alpha1Client is used to interact with features provided by the admin.kcp.io group. +type AdminV1alpha1Client struct { + restClient rest.Interface +} + +func (c *AdminV1alpha1Client) APIIdentities() APIIdentityInterface { + return newAPIIdentities(c) +} + +// NewForConfig creates a new AdminV1alpha1Client for the given config. +// NewForConfig is equivalent to NewForConfigAndClient(c, httpClient), +// where httpClient was generated with rest.HTTPClientFor(c). +func NewForConfig(c *rest.Config) (*AdminV1alpha1Client, error) { + config := *c + setConfigDefaults(&config) + httpClient, err := rest.HTTPClientFor(&config) + if err != nil { + return nil, err + } + return NewForConfigAndClient(&config, httpClient) +} + +// NewForConfigAndClient creates a new AdminV1alpha1Client for the given config and http client. +// Note the http client provided takes precedence over the configured transport values. +func NewForConfigAndClient(c *rest.Config, h *http.Client) (*AdminV1alpha1Client, error) { + config := *c + setConfigDefaults(&config) + client, err := rest.RESTClientForConfigAndClient(&config, h) + if err != nil { + return nil, err + } + return &AdminV1alpha1Client{client}, nil +} + +// NewForConfigOrDie creates a new AdminV1alpha1Client for the given config and +// panics if there is an error in the config. +func NewForConfigOrDie(c *rest.Config) *AdminV1alpha1Client { + client, err := NewForConfig(c) + if err != nil { + panic(err) + } + return client +} + +// New creates a new AdminV1alpha1Client for the given RESTClient. +func New(c rest.Interface) *AdminV1alpha1Client { + return &AdminV1alpha1Client{c} +} + +func setConfigDefaults(config *rest.Config) { + gv := adminv1alpha1.SchemeGroupVersion + config.GroupVersion = &gv + config.APIPath = "/apis" + config.NegotiatedSerializer = rest.CodecFactoryForGeneratedClient(scheme.Scheme, scheme.Codecs).WithoutConversion() + + if config.UserAgent == "" { + config.UserAgent = rest.DefaultKubernetesUserAgent() + } +} + +// RESTClient returns a RESTClient that is used to communicate +// with API server by this client implementation. +func (c *AdminV1alpha1Client) RESTClient() rest.Interface { + if c == nil { + return nil + } + return c.restClient +} diff --git a/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/typed/admin/v1alpha1/apiidentity.go b/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/typed/admin/v1alpha1/apiidentity.go new file mode 100644 index 00000000000..94f8de2db16 --- /dev/null +++ b/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/typed/admin/v1alpha1/apiidentity.go @@ -0,0 +1,71 @@ +/* +Copyright The kcp Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + context "context" + + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + gentype "k8s.io/client-go/gentype" + + adminv1alpha1 "github.com/kcp-dev/sdk/apis/admin/v1alpha1" + applyconfigurationadminv1alpha1 "github.com/kcp-dev/sdk/client/applyconfiguration/admin/v1alpha1" + scheme "github.com/kcp-dev/sdk/client/clientset/versioned/scheme" +) + +// APIIdentitiesGetter has a method to return a APIIdentityInterface. +// A group's client should implement this interface. +type APIIdentitiesGetter interface { + APIIdentities() APIIdentityInterface +} + +// APIIdentityInterface has methods to work with APIIdentity resources. +type APIIdentityInterface interface { + Create(ctx context.Context, aPIIdentity *adminv1alpha1.APIIdentity, opts v1.CreateOptions) (*adminv1alpha1.APIIdentity, error) + Update(ctx context.Context, aPIIdentity *adminv1alpha1.APIIdentity, opts v1.UpdateOptions) (*adminv1alpha1.APIIdentity, error) + Delete(ctx context.Context, name string, opts v1.DeleteOptions) error + DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error + Get(ctx context.Context, name string, opts v1.GetOptions) (*adminv1alpha1.APIIdentity, error) + List(ctx context.Context, opts v1.ListOptions) (*adminv1alpha1.APIIdentityList, error) + Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) + Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *adminv1alpha1.APIIdentity, err error) + Apply(ctx context.Context, aPIIdentity *applyconfigurationadminv1alpha1.APIIdentityApplyConfiguration, opts v1.ApplyOptions) (result *adminv1alpha1.APIIdentity, err error) + APIIdentityExpansion +} + +// aPIIdentities implements APIIdentityInterface +type aPIIdentities struct { + *gentype.ClientWithListAndApply[*adminv1alpha1.APIIdentity, *adminv1alpha1.APIIdentityList, *applyconfigurationadminv1alpha1.APIIdentityApplyConfiguration] +} + +// newAPIIdentities returns a APIIdentities +func newAPIIdentities(c *AdminV1alpha1Client) *aPIIdentities { + return &aPIIdentities{ + gentype.NewClientWithListAndApply[*adminv1alpha1.APIIdentity, *adminv1alpha1.APIIdentityList, *applyconfigurationadminv1alpha1.APIIdentityApplyConfiguration]( + "apiidentities", + c.RESTClient(), + scheme.ParameterCodec, + "", + func() *adminv1alpha1.APIIdentity { return &adminv1alpha1.APIIdentity{} }, + func() *adminv1alpha1.APIIdentityList { return &adminv1alpha1.APIIdentityList{} }, + ), + } +} diff --git a/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/typed/admin/v1alpha1/doc.go b/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/typed/admin/v1alpha1/doc.go new file mode 100644 index 00000000000..2be316615e0 --- /dev/null +++ b/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/typed/admin/v1alpha1/doc.go @@ -0,0 +1,20 @@ +/* +Copyright The kcp Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by client-gen. DO NOT EDIT. + +// This package has the automatically generated typed clients. +package v1alpha1 diff --git a/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/typed/admin/v1alpha1/fake/doc.go b/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/typed/admin/v1alpha1/fake/doc.go new file mode 100644 index 00000000000..59e4de8efe6 --- /dev/null +++ b/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/typed/admin/v1alpha1/fake/doc.go @@ -0,0 +1,20 @@ +/* +Copyright The kcp Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by client-gen. DO NOT EDIT. + +// Package fake has the automatically generated clients. +package fake diff --git a/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/typed/admin/v1alpha1/fake/fake_admin_client.go b/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/typed/admin/v1alpha1/fake/fake_admin_client.go new file mode 100644 index 00000000000..a455f34dbf4 --- /dev/null +++ b/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/typed/admin/v1alpha1/fake/fake_admin_client.go @@ -0,0 +1,41 @@ +/* +Copyright The kcp Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + rest "k8s.io/client-go/rest" + testing "k8s.io/client-go/testing" + + v1alpha1 "github.com/kcp-dev/sdk/client/clientset/versioned/typed/admin/v1alpha1" +) + +type FakeAdminV1alpha1 struct { + *testing.Fake +} + +func (c *FakeAdminV1alpha1) APIIdentities() v1alpha1.APIIdentityInterface { + return newFakeAPIIdentities(c) +} + +// RESTClient returns a RESTClient that is used to communicate +// with API server by this client implementation. +func (c *FakeAdminV1alpha1) RESTClient() rest.Interface { + var ret *rest.RESTClient + return ret +} diff --git a/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/typed/admin/v1alpha1/fake/fake_apiidentity.go b/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/typed/admin/v1alpha1/fake/fake_apiidentity.go new file mode 100644 index 00000000000..33f59b4539d --- /dev/null +++ b/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/typed/admin/v1alpha1/fake/fake_apiidentity.go @@ -0,0 +1,54 @@ +/* +Copyright The kcp Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + gentype "k8s.io/client-go/gentype" + + v1alpha1 "github.com/kcp-dev/sdk/apis/admin/v1alpha1" + adminv1alpha1 "github.com/kcp-dev/sdk/client/applyconfiguration/admin/v1alpha1" + typedadminv1alpha1 "github.com/kcp-dev/sdk/client/clientset/versioned/typed/admin/v1alpha1" +) + +// fakeAPIIdentities implements APIIdentityInterface +type fakeAPIIdentities struct { + *gentype.FakeClientWithListAndApply[*v1alpha1.APIIdentity, *v1alpha1.APIIdentityList, *adminv1alpha1.APIIdentityApplyConfiguration] + Fake *FakeAdminV1alpha1 +} + +func newFakeAPIIdentities(fake *FakeAdminV1alpha1) typedadminv1alpha1.APIIdentityInterface { + return &fakeAPIIdentities{ + gentype.NewFakeClientWithListAndApply[*v1alpha1.APIIdentity, *v1alpha1.APIIdentityList, *adminv1alpha1.APIIdentityApplyConfiguration]( + fake.Fake, + "", + v1alpha1.SchemeGroupVersion.WithResource("apiidentities"), + v1alpha1.SchemeGroupVersion.WithKind("APIIdentity"), + func() *v1alpha1.APIIdentity { return &v1alpha1.APIIdentity{} }, + func() *v1alpha1.APIIdentityList { return &v1alpha1.APIIdentityList{} }, + func(dst, src *v1alpha1.APIIdentityList) { dst.ListMeta = src.ListMeta }, + func(list *v1alpha1.APIIdentityList) []*v1alpha1.APIIdentity { + return gentype.ToPointerSlice(list.Items) + }, + func(list *v1alpha1.APIIdentityList, items []*v1alpha1.APIIdentity) { + list.Items = gentype.FromPointerSlice(items) + }, + ), + fake, + } +} diff --git a/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/typed/admin/v1alpha1/generated_expansion.go b/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/typed/admin/v1alpha1/generated_expansion.go new file mode 100644 index 00000000000..eca9ba68999 --- /dev/null +++ b/staging/src/github.com/kcp-dev/sdk/client/clientset/versioned/typed/admin/v1alpha1/generated_expansion.go @@ -0,0 +1,21 @@ +/* +Copyright The kcp Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha1 + +type APIIdentityExpansion interface{} diff --git a/staging/src/github.com/kcp-dev/sdk/client/informers/externalversions/admin/interface.go b/staging/src/github.com/kcp-dev/sdk/client/informers/externalversions/admin/interface.go new file mode 100644 index 00000000000..256c725c2cb --- /dev/null +++ b/staging/src/github.com/kcp-dev/sdk/client/informers/externalversions/admin/interface.go @@ -0,0 +1,67 @@ +/* +Copyright The kcp Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by cluster-informer-gen. DO NOT EDIT. + +package admin + +import ( + kcpv1alpha1 "github.com/kcp-dev/sdk/client/informers/externalversions/admin/v1alpha1" + kcpinternalinterfaces "github.com/kcp-dev/sdk/client/informers/externalversions/internalinterfaces" +) + +// ClusterInterface provides access to each of this group's versions. +type ClusterInterface interface { + // V1alpha1 provides access to shared informers for resources in V1alpha1. + V1alpha1() kcpv1alpha1.ClusterInterface +} + +type group struct { + factory kcpinternalinterfaces.SharedInformerFactory + tweakListOptions kcpinternalinterfaces.TweakListOptionsFunc +} + +// New returns a new ClusterInterface. +func New(f kcpinternalinterfaces.SharedInformerFactory, tweakListOptions kcpinternalinterfaces.TweakListOptionsFunc) ClusterInterface { + return &group{factory: f, tweakListOptions: tweakListOptions} +} + +// V1alpha1 returns a new kcpv1alpha1.ClusterInterface. +func (g *group) V1alpha1() kcpv1alpha1.ClusterInterface { + return kcpv1alpha1.New(g.factory, g.tweakListOptions) +} + +// Interface provides access to each of this group's versions. +type Interface interface { + // V1alpha1 provides access to shared informers for resources in V1alpha1. + V1alpha1() kcpv1alpha1.Interface +} + +type scopedGroup struct { + factory kcpinternalinterfaces.SharedScopedInformerFactory + tweakListOptions kcpinternalinterfaces.TweakListOptionsFunc + namespace string +} + +// New returns a new Interface. +func NewScoped(f kcpinternalinterfaces.SharedScopedInformerFactory, namespace string, tweakListOptions kcpinternalinterfaces.TweakListOptionsFunc) Interface { + return &scopedGroup{factory: f, namespace: namespace, tweakListOptions: tweakListOptions} +} + +// V1alpha1 returns a new kcpv1alpha1.Interface. +func (g *scopedGroup) V1alpha1() kcpv1alpha1.Interface { + return kcpv1alpha1.NewScoped(g.factory, g.namespace, g.tweakListOptions) +} diff --git a/staging/src/github.com/kcp-dev/sdk/client/informers/externalversions/admin/v1alpha1/apiidentity.go b/staging/src/github.com/kcp-dev/sdk/client/informers/externalversions/admin/v1alpha1/apiidentity.go new file mode 100644 index 00000000000..685af6ab226 --- /dev/null +++ b/staging/src/github.com/kcp-dev/sdk/client/informers/externalversions/admin/v1alpha1/apiidentity.go @@ -0,0 +1,182 @@ +/* +Copyright The kcp Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by cluster-informer-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + context "context" + time "time" + + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + watch "k8s.io/apimachinery/pkg/watch" + cache "k8s.io/client-go/tools/cache" + + kcpcache "github.com/kcp-dev/apimachinery/v2/pkg/cache" + kcpinformers "github.com/kcp-dev/apimachinery/v2/third_party/informers" + logicalcluster "github.com/kcp-dev/logicalcluster/v3" + kcpadminv1alpha1 "github.com/kcp-dev/sdk/apis/admin/v1alpha1" + kcpversioned "github.com/kcp-dev/sdk/client/clientset/versioned" + kcpcluster "github.com/kcp-dev/sdk/client/clientset/versioned/cluster" + kcpinternalinterfaces "github.com/kcp-dev/sdk/client/informers/externalversions/internalinterfaces" + kcpv1alpha1 "github.com/kcp-dev/sdk/client/listers/admin/v1alpha1" +) + +// APIIdentityClusterInformer provides access to a shared informer and lister for +// APIIdentities. +type APIIdentityClusterInformer interface { + Cluster(logicalcluster.Name) APIIdentityInformer + ClusterWithContext(context.Context, logicalcluster.Name) APIIdentityInformer + Informer() kcpcache.ScopeableSharedIndexInformer + Lister() kcpv1alpha1.APIIdentityClusterLister +} + +type aPIIdentityClusterInformer struct { + factory kcpinternalinterfaces.SharedInformerFactory + tweakListOptions kcpinternalinterfaces.TweakListOptionsFunc +} + +// NewAPIIdentityClusterInformer constructs a new informer for APIIdentity type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewAPIIdentityClusterInformer(client kcpcluster.ClusterInterface, resyncPeriod time.Duration, indexers cache.Indexers) kcpcache.ScopeableSharedIndexInformer { + return NewFilteredAPIIdentityClusterInformer(client, resyncPeriod, indexers, nil) +} + +// NewFilteredAPIIdentityClusterInformer constructs a new informer for APIIdentity type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewFilteredAPIIdentityClusterInformer(client kcpcluster.ClusterInterface, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions kcpinternalinterfaces.TweakListOptionsFunc) kcpcache.ScopeableSharedIndexInformer { + return kcpinformers.NewSharedIndexInformer( + cache.ToListWatcherWithWatchListSemantics(&cache.ListWatch{ + ListFunc: func(options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.AdminV1alpha1().APIIdentities().List(context.Background(), options) + }, + WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.AdminV1alpha1().APIIdentities().Watch(context.Background(), options) + }, + }, client), + &kcpadminv1alpha1.APIIdentity{}, + resyncPeriod, + indexers, + ) +} + +func (i *aPIIdentityClusterInformer) defaultInformer(client kcpcluster.ClusterInterface, resyncPeriod time.Duration) kcpcache.ScopeableSharedIndexInformer { + return NewFilteredAPIIdentityClusterInformer(client, resyncPeriod, cache.Indexers{ + kcpcache.ClusterIndexName: kcpcache.ClusterIndexFunc, + kcpcache.ClusterAndNamespaceIndexName: kcpcache.ClusterAndNamespaceIndexFunc, + }, i.tweakListOptions) +} + +func (i *aPIIdentityClusterInformer) Informer() kcpcache.ScopeableSharedIndexInformer { + return i.factory.InformerFor(&kcpadminv1alpha1.APIIdentity{}, i.defaultInformer) +} + +func (i *aPIIdentityClusterInformer) Lister() kcpv1alpha1.APIIdentityClusterLister { + return kcpv1alpha1.NewAPIIdentityClusterLister(i.Informer().GetIndexer()) +} + +func (i *aPIIdentityClusterInformer) Cluster(clusterName logicalcluster.Name) APIIdentityInformer { + return &aPIIdentityInformer{ + informer: i.Informer().Cluster(clusterName), + lister: i.Lister().Cluster(clusterName), + } +} + +func (i *aPIIdentityClusterInformer) ClusterWithContext(ctx context.Context, clusterName logicalcluster.Name) APIIdentityInformer { + return &aPIIdentityInformer{ + informer: i.Informer().ClusterWithContext(ctx, clusterName), + lister: i.Lister().Cluster(clusterName), + } +} + +type aPIIdentityInformer struct { + informer cache.SharedIndexInformer + lister kcpv1alpha1.APIIdentityLister +} + +func (i *aPIIdentityInformer) Informer() cache.SharedIndexInformer { + return i.informer +} + +func (i *aPIIdentityInformer) Lister() kcpv1alpha1.APIIdentityLister { + return i.lister +} + +// APIIdentityInformer provides access to a shared informer and lister for +// APIIdentities. +type APIIdentityInformer interface { + Informer() cache.SharedIndexInformer + Lister() kcpv1alpha1.APIIdentityLister +} + +type aPIIdentityScopedInformer struct { + factory kcpinternalinterfaces.SharedScopedInformerFactory + tweakListOptions kcpinternalinterfaces.TweakListOptionsFunc +} + +// NewAPIIdentityInformer constructs a new informer for APIIdentity type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewAPIIdentityInformer(client kcpversioned.Interface, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewFilteredAPIIdentityInformer(client, resyncPeriod, indexers, nil) +} + +// NewFilteredAPIIdentityInformer constructs a new informer for APIIdentity type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewFilteredAPIIdentityInformer(client kcpversioned.Interface, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions kcpinternalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer { + return cache.NewSharedIndexInformer( + cache.ToListWatcherWithWatchListSemantics(&cache.ListWatch{ + ListFunc: func(options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.AdminV1alpha1().APIIdentities().List(context.Background(), options) + }, + WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.AdminV1alpha1().APIIdentities().Watch(context.Background(), options) + }, + }, client), + &kcpadminv1alpha1.APIIdentity{}, + resyncPeriod, + indexers, + ) +} + +func (i *aPIIdentityScopedInformer) Informer() cache.SharedIndexInformer { + return i.factory.InformerFor(&kcpadminv1alpha1.APIIdentity{}, i.defaultInformer) +} + +func (i *aPIIdentityScopedInformer) Lister() kcpv1alpha1.APIIdentityLister { + return kcpv1alpha1.NewAPIIdentityLister(i.Informer().GetIndexer()) +} + +func (i *aPIIdentityScopedInformer) defaultInformer(client kcpversioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewFilteredAPIIdentityInformer(client, resyncPeriod, cache.Indexers{}, i.tweakListOptions) +} diff --git a/staging/src/github.com/kcp-dev/sdk/client/informers/externalversions/admin/v1alpha1/interface.go b/staging/src/github.com/kcp-dev/sdk/client/informers/externalversions/admin/v1alpha1/interface.go new file mode 100644 index 00000000000..a2752ad9b83 --- /dev/null +++ b/staging/src/github.com/kcp-dev/sdk/client/informers/externalversions/admin/v1alpha1/interface.go @@ -0,0 +1,64 @@ +/* +Copyright The kcp Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by cluster-informer-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + kcpinternalinterfaces "github.com/kcp-dev/sdk/client/informers/externalversions/internalinterfaces" +) + +type ClusterInterface interface { + // APIIdentities returns a APIIdentityClusterInformer. + APIIdentities() APIIdentityClusterInformer +} + +type version struct { + factory kcpinternalinterfaces.SharedInformerFactory + tweakListOptions kcpinternalinterfaces.TweakListOptionsFunc +} + +// New returns a new Interface. +func New(f kcpinternalinterfaces.SharedInformerFactory, tweakListOptions kcpinternalinterfaces.TweakListOptionsFunc) ClusterInterface { + return &version{factory: f, tweakListOptions: tweakListOptions} +} + +// APIIdentities returns a APIIdentityClusterInformer. +func (v *version) APIIdentities() APIIdentityClusterInformer { + return &aPIIdentityClusterInformer{factory: v.factory, tweakListOptions: v.tweakListOptions} +} + +type Interface interface { + // APIIdentities returns a APIIdentityInformer. + APIIdentities() APIIdentityInformer +} + +type scopedVersion struct { + factory kcpinternalinterfaces.SharedScopedInformerFactory + tweakListOptions kcpinternalinterfaces.TweakListOptionsFunc + namespace string +} + +// New returns a new Interface. +func NewScoped(f kcpinternalinterfaces.SharedScopedInformerFactory, namespace string, tweakListOptions kcpinternalinterfaces.TweakListOptionsFunc) Interface { + return &scopedVersion{factory: f, tweakListOptions: tweakListOptions} +} + +// APIIdentities returns a APIIdentityInformer. +func (v *scopedVersion) APIIdentities() APIIdentityInformer { + return &aPIIdentityScopedInformer{factory: v.factory, tweakListOptions: v.tweakListOptions} +} diff --git a/staging/src/github.com/kcp-dev/sdk/client/informers/externalversions/factory.go b/staging/src/github.com/kcp-dev/sdk/client/informers/externalversions/factory.go index d12e41c288a..1b348925cdd 100644 --- a/staging/src/github.com/kcp-dev/sdk/client/informers/externalversions/factory.go +++ b/staging/src/github.com/kcp-dev/sdk/client/informers/externalversions/factory.go @@ -32,6 +32,7 @@ import ( logicalcluster "github.com/kcp-dev/logicalcluster/v3" kcpversioned "github.com/kcp-dev/sdk/client/clientset/versioned" kcpcluster "github.com/kcp-dev/sdk/client/clientset/versioned/cluster" + kcpadmin "github.com/kcp-dev/sdk/client/informers/externalversions/admin" kcpapis "github.com/kcp-dev/sdk/client/informers/externalversions/apis" kcpexternalversionscache "github.com/kcp-dev/sdk/client/informers/externalversions/cache" kcpcore "github.com/kcp-dev/sdk/client/informers/externalversions/core" @@ -281,6 +282,7 @@ type SharedInformerFactory interface { // client. InformerFor(obj runtime.Object, newFunc kcpinternalinterfaces.NewInformerFunc) kcpcache.ScopeableSharedIndexInformer + Admin() kcpadmin.ClusterInterface Apis() kcpapis.ClusterInterface Cache() kcpexternalversionscache.ClusterInterface Core() kcpcore.ClusterInterface @@ -289,6 +291,10 @@ type SharedInformerFactory interface { Topology() kcptopology.ClusterInterface } +func (f *sharedInformerFactory) Admin() kcpadmin.ClusterInterface { + return kcpadmin.New(f, f.tweakListOptions) +} + func (f *sharedInformerFactory) Apis() kcpapis.ClusterInterface { return kcpapis.New(f, f.tweakListOptions) } @@ -457,6 +463,7 @@ type SharedScopedInformerFactory interface { ForResource(resource schema.GroupVersionResource) (GenericInformer, error) WaitForCacheSync(stopCh <-chan struct{}) map[reflect.Type]bool + Admin() kcpadmin.Interface Apis() kcpapis.Interface Cache() kcpexternalversionscache.Interface Core() kcpcore.Interface @@ -465,6 +472,10 @@ type SharedScopedInformerFactory interface { Topology() kcptopology.Interface } +func (f *sharedScopedInformerFactory) Admin() kcpadmin.Interface { + return kcpadmin.NewScoped(f, f.namespace, f.tweakListOptions) +} + func (f *sharedScopedInformerFactory) Apis() kcpapis.Interface { return kcpapis.NewScoped(f, f.namespace, f.tweakListOptions) } diff --git a/staging/src/github.com/kcp-dev/sdk/client/informers/externalversions/generic.go b/staging/src/github.com/kcp-dev/sdk/client/informers/externalversions/generic.go index a66540870e1..95560c568ee 100644 --- a/staging/src/github.com/kcp-dev/sdk/client/informers/externalversions/generic.go +++ b/staging/src/github.com/kcp-dev/sdk/client/informers/externalversions/generic.go @@ -27,7 +27,8 @@ import ( kcpcache "github.com/kcp-dev/apimachinery/v2/pkg/cache" logicalcluster "github.com/kcp-dev/logicalcluster/v3" - kcpv1alpha1 "github.com/kcp-dev/sdk/apis/apis/v1alpha1" + kcpv1alpha1 "github.com/kcp-dev/sdk/apis/admin/v1alpha1" + kcpapisv1alpha1 "github.com/kcp-dev/sdk/apis/apis/v1alpha1" kcpv1alpha2 "github.com/kcp-dev/sdk/apis/apis/v1alpha2" kcpcachev1alpha1 "github.com/kcp-dev/sdk/apis/cache/v1alpha1" kcpcorev1alpha1 "github.com/kcp-dev/sdk/apis/core/v1alpha1" @@ -99,16 +100,20 @@ func (i *genericInformer) Lister() cache.GenericLister { // TODO extend this to unknown resources with a client pool func (f *sharedInformerFactory) ForResource(resource schema.GroupVersionResource) (GenericClusterInformer, error) { switch resource { - // Group=apis.kcp.io, Version=v1alpha1 - case kcpv1alpha1.SchemeGroupVersion.WithResource("apibindings"): + // Group=admin.kcp.io, Version=v1alpha1 + case kcpv1alpha1.SchemeGroupVersion.WithResource("apiidentities"): + return &genericClusterInformer{resource: resource.GroupResource(), informer: f.Admin().V1alpha1().APIIdentities().Informer()}, nil + + // Group=apis.kcp.io, Version=v1alpha1 + case kcpapisv1alpha1.SchemeGroupVersion.WithResource("apibindings"): return &genericClusterInformer{resource: resource.GroupResource(), informer: f.Apis().V1alpha1().APIBindings().Informer()}, nil - case kcpv1alpha1.SchemeGroupVersion.WithResource("apiconversions"): + case kcpapisv1alpha1.SchemeGroupVersion.WithResource("apiconversions"): return &genericClusterInformer{resource: resource.GroupResource(), informer: f.Apis().V1alpha1().APIConversions().Informer()}, nil - case kcpv1alpha1.SchemeGroupVersion.WithResource("apiexports"): + case kcpapisv1alpha1.SchemeGroupVersion.WithResource("apiexports"): return &genericClusterInformer{resource: resource.GroupResource(), informer: f.Apis().V1alpha1().APIExports().Informer()}, nil - case kcpv1alpha1.SchemeGroupVersion.WithResource("apiexportendpointslices"): + case kcpapisv1alpha1.SchemeGroupVersion.WithResource("apiexportendpointslices"): return &genericClusterInformer{resource: resource.GroupResource(), informer: f.Apis().V1alpha1().APIExportEndpointSlices().Informer()}, nil - case kcpv1alpha1.SchemeGroupVersion.WithResource("apiresourceschemas"): + case kcpapisv1alpha1.SchemeGroupVersion.WithResource("apiresourceschemas"): return &genericClusterInformer{resource: resource.GroupResource(), informer: f.Apis().V1alpha1().APIResourceSchemas().Informer()}, nil // Group=apis.kcp.io, Version=v1alpha2 @@ -156,20 +161,25 @@ func (f *sharedInformerFactory) ForResource(resource schema.GroupVersionResource // TODO extend this to unknown resources with a client pool func (f *sharedScopedInformerFactory) ForResource(resource schema.GroupVersionResource) (GenericInformer, error) { switch resource { - // Group=apis.kcp.io, Version=v1alpha1 - case kcpv1alpha1.SchemeGroupVersion.WithResource("apibindings"): + // Group=admin.kcp.io, Version=v1alpha1 + case kcpv1alpha1.SchemeGroupVersion.WithResource("apiidentities"): + informer := f.Admin().V1alpha1().APIIdentities().Informer() + return &genericInformer{lister: cache.NewGenericLister(informer.GetIndexer(), resource.GroupResource()), informer: informer}, nil + + // Group=apis.kcp.io, Version=v1alpha1 + case kcpapisv1alpha1.SchemeGroupVersion.WithResource("apibindings"): informer := f.Apis().V1alpha1().APIBindings().Informer() return &genericInformer{lister: cache.NewGenericLister(informer.GetIndexer(), resource.GroupResource()), informer: informer}, nil - case kcpv1alpha1.SchemeGroupVersion.WithResource("apiconversions"): + case kcpapisv1alpha1.SchemeGroupVersion.WithResource("apiconversions"): informer := f.Apis().V1alpha1().APIConversions().Informer() return &genericInformer{lister: cache.NewGenericLister(informer.GetIndexer(), resource.GroupResource()), informer: informer}, nil - case kcpv1alpha1.SchemeGroupVersion.WithResource("apiexports"): + case kcpapisv1alpha1.SchemeGroupVersion.WithResource("apiexports"): informer := f.Apis().V1alpha1().APIExports().Informer() return &genericInformer{lister: cache.NewGenericLister(informer.GetIndexer(), resource.GroupResource()), informer: informer}, nil - case kcpv1alpha1.SchemeGroupVersion.WithResource("apiexportendpointslices"): + case kcpapisv1alpha1.SchemeGroupVersion.WithResource("apiexportendpointslices"): informer := f.Apis().V1alpha1().APIExportEndpointSlices().Informer() return &genericInformer{lister: cache.NewGenericLister(informer.GetIndexer(), resource.GroupResource()), informer: informer}, nil - case kcpv1alpha1.SchemeGroupVersion.WithResource("apiresourceschemas"): + case kcpapisv1alpha1.SchemeGroupVersion.WithResource("apiresourceschemas"): informer := f.Apis().V1alpha1().APIResourceSchemas().Informer() return &genericInformer{lister: cache.NewGenericLister(informer.GetIndexer(), resource.GroupResource()), informer: informer}, nil diff --git a/staging/src/github.com/kcp-dev/sdk/client/listers/admin/v1alpha1/apiidentity.go b/staging/src/github.com/kcp-dev/sdk/client/listers/admin/v1alpha1/apiidentity.go new file mode 100644 index 00000000000..51a37cf7ed4 --- /dev/null +++ b/staging/src/github.com/kcp-dev/sdk/client/listers/admin/v1alpha1/apiidentity.go @@ -0,0 +1,102 @@ +/* +Copyright The kcp Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by cluster-lister-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/labels" + "k8s.io/client-go/tools/cache" + + kcplisters "github.com/kcp-dev/client-go/third_party/k8s.io/client-go/listers" + "github.com/kcp-dev/logicalcluster/v3" + kcpv1alpha1 "github.com/kcp-dev/sdk/apis/admin/v1alpha1" +) + +// APIIdentityClusterLister helps list APIIdentities across all workspaces, +// or scope down to a APIIdentityLister for one workspace. +// All objects returned here must be treated as read-only. +type APIIdentityClusterLister interface { + // List lists all APIIdentities in the indexer. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*kcpv1alpha1.APIIdentity, err error) + // Cluster returns a lister that can list and get APIIdentities in one workspace. + Cluster(clusterName logicalcluster.Name) APIIdentityLister + APIIdentityClusterListerExpansion +} + +// aPIIdentityClusterLister implements the APIIdentityClusterLister interface. +type aPIIdentityClusterLister struct { + kcplisters.ResourceClusterIndexer[*kcpv1alpha1.APIIdentity] +} + +var _ APIIdentityClusterLister = new(aPIIdentityClusterLister) + +// NewAPIIdentityClusterLister returns a new APIIdentityClusterLister. +// We assume that the indexer: +// - is fed by a cross-workspace LIST+WATCH +// - uses kcpcache.MetaClusterNamespaceKeyFunc as the key function +// - has the kcpcache.ClusterIndex as an index +func NewAPIIdentityClusterLister(indexer cache.Indexer) APIIdentityClusterLister { + return &aPIIdentityClusterLister{ + kcplisters.NewCluster[*kcpv1alpha1.APIIdentity](indexer, kcpv1alpha1.Resource("apiidentity")), + } +} + +// Cluster scopes the lister to one workspace, allowing users to list and get APIIdentities. +func (l *aPIIdentityClusterLister) Cluster(clusterName logicalcluster.Name) APIIdentityLister { + return &aPIIdentityLister{ + l.ResourceClusterIndexer.WithCluster(clusterName), + } +} + +// aPIIdentityLister can list all APIIdentities inside a workspace +// or scope down to a APIIdentityNamespaceLister for one namespace. +type aPIIdentityLister struct { + kcplisters.ResourceIndexer[*kcpv1alpha1.APIIdentity] +} + +var _ APIIdentityLister = new(aPIIdentityLister) + +// APIIdentityLister can list all APIIdentities, or get one in particular. +// All objects returned here must be treated as read-only. +type APIIdentityLister interface { + // List lists all APIIdentities in the indexer. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*kcpv1alpha1.APIIdentity, err error) + // Get retrieves the APIIdentity from the indexer for a given workspace and name. + // Objects returned here must be treated as read-only. + Get(name string) (*kcpv1alpha1.APIIdentity, error) + APIIdentityListerExpansion +} + +// NewAPIIdentityLister returns a new APIIdentityLister. +// We assume that the indexer: +// - is fed by a cross-workspace LIST+WATCH +// - uses kcpcache.MetaClusterNamespaceKeyFunc as the key function +// - has the kcpcache.ClusterIndex as an index +func NewAPIIdentityLister(indexer cache.Indexer) APIIdentityLister { + return &aPIIdentityLister{ + kcplisters.New[*kcpv1alpha1.APIIdentity](indexer, kcpv1alpha1.Resource("apiidentity")), + } +} + +// aPIIdentityScopedLister can list all APIIdentities inside a workspace +// or scope down to a APIIdentityNamespaceLister. +type aPIIdentityScopedLister struct { + kcplisters.ResourceIndexer[*kcpv1alpha1.APIIdentity] +} diff --git a/staging/src/github.com/kcp-dev/sdk/client/listers/admin/v1alpha1/expansion_generated.go b/staging/src/github.com/kcp-dev/sdk/client/listers/admin/v1alpha1/expansion_generated.go new file mode 100644 index 00000000000..3ce24577057 --- /dev/null +++ b/staging/src/github.com/kcp-dev/sdk/client/listers/admin/v1alpha1/expansion_generated.go @@ -0,0 +1,27 @@ +/* +Copyright The kcp Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by cluster-lister-gen. DO NOT EDIT. + +package v1alpha1 + +// APIIdentityClusterListerExpansion allows custom methods to be added to +// APIIdentityClusterLister. +type APIIdentityClusterListerExpansion interface{} + +// APIIdentityListerExpansion allows custom methods to be added to +// APIIdentityLister. +type APIIdentityListerExpansion interface{} diff --git a/staging/src/github.com/kcp-dev/sdk/cmd/apigen/main.go b/staging/src/github.com/kcp-dev/sdk/cmd/apigen/main.go index 2875f8769db..c00d66e3661 100644 --- a/staging/src/github.com/kcp-dev/sdk/cmd/apigen/main.go +++ b/staging/src/github.com/kcp-dev/sdk/cmd/apigen/main.go @@ -61,18 +61,41 @@ type options struct { inputDir string outputDir string ignoreExportSchemas []string + ignoreSchemas []string preserveResources bool headerFile string ignoreExportSchemasParsed []metav1.GroupResource + ignoreSchemasParsed []metav1.GroupResource headerText string } +// parseGroupResources parses strings like "resource.group.org" into GroupResources. +func parseGroupResources(in []string) ([]metav1.GroupResource, error) { + var out []metav1.GroupResource + for _, s := range in { + parts := strings.Split(s, ".") + if len(parts) < 2 { + return nil, fmt.Errorf("invalid schema %q: must have at least 2 dot-separated segments", s) + } + + resource := parts[0] + group := strings.Join(parts[1:], ".") + if group == "core" { + group = "" + } + group = strings.TrimSuffix(group, "/") + out = append(out, metav1.GroupResource{Group: group, Resource: resource}) + } + return out, nil +} + func bindOptions(fs *pflag.FlagSet) *options { o := options{} fs.StringVar(&o.inputDir, "input-dir", "", "Directory containing CustomResourceDefinition YAML files.") fs.StringVar(&o.outputDir, "output-dir", "", "Directory where APIResourceSchemas and APIExports will be written.") fs.StringSliceVar(&o.ignoreExportSchemas, "ignore-export-schemas", []string{}, "Comma-separated list of 'Resource.Group' to be ignored for APIExports generation.") + fs.StringSliceVar(&o.ignoreSchemas, "ignore-schemas", []string{}, "Comma-separated list of 'Resource.Group' for which no APIResourceSchema is generated at all.") fs.BoolVar(&o.preserveResources, "preserve-resources", false, "Preserve existing resources in APIExport manifests instead of overriding them.") fs.StringVar(&o.headerFile, "header-file", "", "Path to a file containing boilerplate header text; the contents will be prepended to all generated files.") return &o @@ -85,22 +108,12 @@ func (o *options) Validate() error { if o.outputDir == "" { return fmt.Errorf("--output-dir is required") } - if len(o.ignoreExportSchemas) > 0 { - for _, s := range o.ignoreExportSchemas { - // parse strings like "resource.group.org" - parts := strings.Split(s, ".") - if len(parts) < 2 { - return fmt.Errorf("invalid schema %q: must have at least 2 dot-separated segments", s) - } - - resource := parts[0] - group := strings.Join(parts[1:], ".") - if group == "core" { - group = "" - } - group = strings.TrimSuffix(group, "/") - o.ignoreExportSchemasParsed = append(o.ignoreExportSchemasParsed, metav1.GroupResource{Group: group, Resource: resource}) - } + var err error + if o.ignoreExportSchemasParsed, err = parseGroupResources(o.ignoreExportSchemas); err != nil { + return err + } + if o.ignoreSchemasParsed, err = parseGroupResources(o.ignoreSchemas); err != nil { + return err } if o.headerFile != "" { headerText, err := os.ReadFile(o.headerFile) @@ -155,6 +168,12 @@ func main() { logger.Error(err, "Could not load CustomResourceDefinitions.") os.Exit(1) } + for _, gr := range opts.ignoreSchemasParsed { + if _, ok := crds[gr]; ok { + logger.Info(fmt.Sprintf("Skipping APIResourceSchema generation for %s", gr)) + delete(crds, gr) + } + } if _, err := os.Stat(opts.outputDir); os.IsNotExist(err) { if err := os.MkdirAll(opts.outputDir, os.FileMode(0755)); err != nil { diff --git a/staging/src/github.com/kcp-dev/sdk/openapi/zz_generated.openapi.go b/staging/src/github.com/kcp-dev/sdk/openapi/zz_generated.openapi.go index e8c36fcb221..8706f2ba2b8 100644 --- a/staging/src/github.com/kcp-dev/sdk/openapi/zz_generated.openapi.go +++ b/staging/src/github.com/kcp-dev/sdk/openapi/zz_generated.openapi.go @@ -28,7 +28,8 @@ import ( common "k8s.io/kube-openapi/pkg/common" spec "k8s.io/kube-openapi/pkg/validation/spec" - v1alpha1 "github.com/kcp-dev/sdk/apis/apis/v1alpha1" + v1alpha1 "github.com/kcp-dev/sdk/apis/admin/v1alpha1" + apisv1alpha1 "github.com/kcp-dev/sdk/apis/apis/v1alpha1" v1alpha2 "github.com/kcp-dev/sdk/apis/apis/v1alpha2" cachev1alpha1 "github.com/kcp-dev/sdk/apis/cache/v1alpha1" corev1alpha1 "github.com/kcp-dev/sdk/apis/core/v1alpha1" @@ -40,42 +41,47 @@ import ( func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition { return map[string]common.OpenAPIDefinition{ - v1alpha1.APIBinding{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_APIBinding(ref), - v1alpha1.APIBindingList{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_APIBindingList(ref), - v1alpha1.APIBindingSpec{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_APIBindingSpec(ref), - v1alpha1.APIBindingStatus{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_APIBindingStatus(ref), - v1alpha1.APIConversion{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_APIConversion(ref), - v1alpha1.APIConversionList{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_APIConversionList(ref), - v1alpha1.APIConversionRule{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_APIConversionRule(ref), - v1alpha1.APIConversionSpec{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_APIConversionSpec(ref), - v1alpha1.APIExport{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_APIExport(ref), - v1alpha1.APIExportEndpointSlice{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_APIExportEndpointSlice(ref), - v1alpha1.APIExportEndpointSliceList{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_APIExportEndpointSliceList(ref), - v1alpha1.APIExportEndpointSliceSpec{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_APIExportEndpointSliceSpec(ref), - v1alpha1.APIExportEndpointSliceStatus{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_APIExportEndpointSliceStatus(ref), - v1alpha1.APIExportList{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_APIExportList(ref), - v1alpha1.APIExportSpec{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_APIExportSpec(ref), - v1alpha1.APIExportStatus{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_APIExportStatus(ref), - v1alpha1.APIResourceSchema{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_APIResourceSchema(ref), - v1alpha1.APIResourceSchemaList{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_APIResourceSchemaList(ref), - v1alpha1.APIResourceSchemaSpec{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_APIResourceSchemaSpec(ref), - v1alpha1.APIResourceVersion{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_APIResourceVersion(ref), - v1alpha1.APIVersionConversion{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_APIVersionConversion(ref), - v1alpha1.AcceptablePermissionClaim{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_AcceptablePermissionClaim(ref), - v1alpha1.BindingReference{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_BindingReference(ref), - v1alpha1.BoundAPIResource{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_BoundAPIResource(ref), - v1alpha1.BoundAPIResourceSchema{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_BoundAPIResourceSchema(ref), - v1alpha1.CustomResourceConversion{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_CustomResourceConversion(ref), - v1alpha1.ExportBindingReference{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_ExportBindingReference(ref), - v1alpha1.GroupResource{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_GroupResource(ref), - v1alpha1.Identity{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_Identity(ref), - v1alpha1.LocalAPIExportPolicy{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_LocalAPIExportPolicy(ref), - v1alpha1.MaximalPermissionPolicy{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_MaximalPermissionPolicy(ref), - v1alpha1.PermissionClaim{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_PermissionClaim(ref), - v1alpha1.ResourceSelector{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_ResourceSelector(ref), - v1alpha1.VirtualWorkspace{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_VirtualWorkspace(ref), - v1alpha1.WebhookClientConfig{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_WebhookClientConfig(ref), - v1alpha1.WebhookConversion{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_WebhookConversion(ref), + v1alpha1.APIIdentity{}.OpenAPIModelName(): schema_sdk_apis_admin_v1alpha1_APIIdentity(ref), + v1alpha1.APIIdentityList{}.OpenAPIModelName(): schema_sdk_apis_admin_v1alpha1_APIIdentityList(ref), + v1alpha1.APIIdentityStatus{}.OpenAPIModelName(): schema_sdk_apis_admin_v1alpha1_APIIdentityStatus(ref), + v1alpha1.ExportReference{}.OpenAPIModelName(): schema_sdk_apis_admin_v1alpha1_ExportReference(ref), + v1alpha1.Holder{}.OpenAPIModelName(): schema_sdk_apis_admin_v1alpha1_Holder(ref), + apisv1alpha1.APIBinding{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_APIBinding(ref), + apisv1alpha1.APIBindingList{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_APIBindingList(ref), + apisv1alpha1.APIBindingSpec{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_APIBindingSpec(ref), + apisv1alpha1.APIBindingStatus{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_APIBindingStatus(ref), + apisv1alpha1.APIConversion{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_APIConversion(ref), + apisv1alpha1.APIConversionList{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_APIConversionList(ref), + apisv1alpha1.APIConversionRule{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_APIConversionRule(ref), + apisv1alpha1.APIConversionSpec{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_APIConversionSpec(ref), + apisv1alpha1.APIExport{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_APIExport(ref), + apisv1alpha1.APIExportEndpointSlice{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_APIExportEndpointSlice(ref), + apisv1alpha1.APIExportEndpointSliceList{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_APIExportEndpointSliceList(ref), + apisv1alpha1.APIExportEndpointSliceSpec{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_APIExportEndpointSliceSpec(ref), + apisv1alpha1.APIExportEndpointSliceStatus{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_APIExportEndpointSliceStatus(ref), + apisv1alpha1.APIExportList{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_APIExportList(ref), + apisv1alpha1.APIExportSpec{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_APIExportSpec(ref), + apisv1alpha1.APIExportStatus{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_APIExportStatus(ref), + apisv1alpha1.APIResourceSchema{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_APIResourceSchema(ref), + apisv1alpha1.APIResourceSchemaList{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_APIResourceSchemaList(ref), + apisv1alpha1.APIResourceSchemaSpec{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_APIResourceSchemaSpec(ref), + apisv1alpha1.APIResourceVersion{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_APIResourceVersion(ref), + apisv1alpha1.APIVersionConversion{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_APIVersionConversion(ref), + apisv1alpha1.AcceptablePermissionClaim{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_AcceptablePermissionClaim(ref), + apisv1alpha1.BindingReference{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_BindingReference(ref), + apisv1alpha1.BoundAPIResource{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_BoundAPIResource(ref), + apisv1alpha1.BoundAPIResourceSchema{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_BoundAPIResourceSchema(ref), + apisv1alpha1.CustomResourceConversion{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_CustomResourceConversion(ref), + apisv1alpha1.ExportBindingReference{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_ExportBindingReference(ref), + apisv1alpha1.GroupResource{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_GroupResource(ref), + apisv1alpha1.Identity{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_Identity(ref), + apisv1alpha1.LocalAPIExportPolicy{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_LocalAPIExportPolicy(ref), + apisv1alpha1.MaximalPermissionPolicy{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_MaximalPermissionPolicy(ref), + apisv1alpha1.PermissionClaim{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_PermissionClaim(ref), + apisv1alpha1.ResourceSelector{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_ResourceSelector(ref), + apisv1alpha1.VirtualWorkspace{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_VirtualWorkspace(ref), + apisv1alpha1.WebhookClientConfig{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_WebhookClientConfig(ref), + apisv1alpha1.WebhookConversion{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha1_WebhookConversion(ref), v1alpha2.APIBinding{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha2_APIBinding(ref), v1alpha2.APIBindingList{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha2_APIBindingList(ref), v1alpha2.APIBindingSpec{}.OpenAPIModelName(): schema_sdk_apis_apis_v1alpha2_APIBindingSpec(ref), @@ -229,6 +235,245 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA } } +func schema_sdk_apis_admin_v1alpha1_APIIdentity(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "APIIdentity is the registry entry of one API identity: the record of which APIExport owns the identity and which other APIExports hold it. There is exactly one entry per identity in an installation, named after the identity value, living on the cache server where every shard can read it and served read-only through the Admin workspace.\n\nEntries are written by kcp only. The owner is recorded when the identity is first claimed, which is the atomic create of the entry; holders record themselves when they get the identity. When the owner's APIExport is deleted the identity passes to the oldest holder. Nothing that could be used to obtain the identity is recorded here, only who has it.\n\nThe object has a status field but no status subresource: it lives on the cache server, whose status subresource does not honor shard scopes, and it is written by kcp only, so spec and status are written together in one update of the whole object.", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + 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{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + 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{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref(v1.ObjectMeta{}.OpenAPIModelName()), + }, + }, + "status": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref(v1alpha1.APIIdentityStatus{}.OpenAPIModelName()), + }, + }, + }, + }, + }, + Dependencies: []string{ + v1alpha1.APIIdentityStatus{}.OpenAPIModelName(), v1.ObjectMeta{}.OpenAPIModelName()}, + } +} + +func schema_sdk_apis_admin_v1alpha1_APIIdentityList(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "APIIdentityList is a list of APIIdentity resources.", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + 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{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + 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{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref(v1.ListMeta{}.OpenAPIModelName()), + }, + }, + "items": { + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref(v1alpha1.APIIdentity{}.OpenAPIModelName()), + }, + }, + }, + }, + }, + }, + Required: []string{"metadata", "items"}, + }, + }, + Dependencies: []string{ + v1alpha1.APIIdentity{}.OpenAPIModelName(), v1.ListMeta{}.OpenAPIModelName()}, + } +} + +func schema_sdk_apis_admin_v1alpha1_APIIdentityStatus(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "APIIdentityStatus is the derived state of an identity. The object has no spec: nothing about it is user intent.", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "owner": { + SchemaProps: spec.SchemaProps{ + Description: "owner is the APIExport that holds the identity authoritatively: the first one to use it. It is written by kcp when the identity is claimed and changed by kcp only when the owner is deleted and a holder succeeds it, or an orphaned identity is reclaimed.", + Default: map[string]interface{}{}, + Ref: ref(v1alpha1.ExportReference{}.OpenAPIModelName()), + }, + }, + "holders": { + VendorExtensible: spec.VendorExtensible{ + Extensions: spec.Extensions{ + "x-kubernetes-list-map-keys": []interface{}{ + "cluster", + "name", + }, + "x-kubernetes-list-type": "map", + }, + }, + SchemaProps: spec.SchemaProps{ + Description: "holders lists the APIExports, other than the owner, that serve the identity: those whose identity secret carries the same key. A holder records itself when it gets the identity and is dropped when it is deleted.", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref(v1alpha1.Holder{}.OpenAPIModelName()), + }, + }, + }, + }, + }, + "core": { + SchemaProps: spec.SchemaProps{ + Description: "core is true for the identities of the core APIExports in the root workspace, which every shard needs before it can serve.", + Type: []string{"boolean"}, + Format: "", + }, + }, + "conditions": { + SchemaProps: spec.SchemaProps{ + Description: "conditions is a list of conditions that apply to the identity.", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref(conditionsv1alpha1.Condition{}.OpenAPIModelName()), + }, + }, + }, + }, + }, + }, + Required: []string{"owner"}, + }, + }, + Dependencies: []string{ + v1alpha1.ExportReference{}.OpenAPIModelName(), v1alpha1.Holder{}.OpenAPIModelName(), conditionsv1alpha1.Condition{}.OpenAPIModelName()}, + } +} + +func schema_sdk_apis_admin_v1alpha1_ExportReference(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "ExportReference identifies an APIExport across the installation.", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "cluster": { + SchemaProps: spec.SchemaProps{ + Description: "cluster is the logical cluster name hosting the APIExport.", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "name": { + SchemaProps: spec.SchemaProps{ + Description: "name is the name of the APIExport.", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "uid": { + SchemaProps: spec.SchemaProps{ + Description: "uid is the UID of the APIExport, so that an export deleted and recreated under the same name can be told apart from the original.", + Type: []string{"string"}, + Format: "", + }, + }, + }, + Required: []string{"cluster", "name"}, + }, + }, + } +} + +func schema_sdk_apis_admin_v1alpha1_Holder(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "Holder is an APIExport, other than the owner, that serves the identity.", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "cluster": { + SchemaProps: spec.SchemaProps{ + Description: "cluster is the logical cluster name hosting the APIExport.", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "name": { + SchemaProps: spec.SchemaProps{ + Description: "name is the name of the APIExport.", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "uid": { + SchemaProps: spec.SchemaProps{ + Description: "uid is the UID of the APIExport, so that an export deleted and recreated under the same name can be told apart from the original.", + Type: []string{"string"}, + Format: "", + }, + }, + "since": { + SchemaProps: spec.SchemaProps{ + Description: "since is when the holder was recorded.", + Ref: ref(v1.Time{}.OpenAPIModelName()), + }, + }, + }, + Required: []string{"cluster", "name"}, + }, + }, + Dependencies: []string{ + v1.Time{}.OpenAPIModelName()}, + } +} + func schema_sdk_apis_apis_v1alpha1_APIBinding(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ @@ -260,14 +505,14 @@ func schema_sdk_apis_apis_v1alpha1_APIBinding(ref common.ReferenceCallback) comm SchemaProps: spec.SchemaProps{ Description: "Spec holds the desired state.", Default: map[string]interface{}{}, - Ref: ref(v1alpha1.APIBindingSpec{}.OpenAPIModelName()), + Ref: ref(apisv1alpha1.APIBindingSpec{}.OpenAPIModelName()), }, }, "status": { SchemaProps: spec.SchemaProps{ Description: "Status communicates the observed state.", Default: map[string]interface{}{}, - Ref: ref(v1alpha1.APIBindingStatus{}.OpenAPIModelName()), + Ref: ref(apisv1alpha1.APIBindingStatus{}.OpenAPIModelName()), }, }, }, @@ -275,7 +520,7 @@ func schema_sdk_apis_apis_v1alpha1_APIBinding(ref common.ReferenceCallback) comm }, }, Dependencies: []string{ - v1alpha1.APIBindingSpec{}.OpenAPIModelName(), v1alpha1.APIBindingStatus{}.OpenAPIModelName(), v1.ObjectMeta{}.OpenAPIModelName()}, + apisv1alpha1.APIBindingSpec{}.OpenAPIModelName(), apisv1alpha1.APIBindingStatus{}.OpenAPIModelName(), v1.ObjectMeta{}.OpenAPIModelName()}, } } @@ -313,7 +558,7 @@ func schema_sdk_apis_apis_v1alpha1_APIBindingList(ref common.ReferenceCallback) Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref(v1alpha1.APIBinding{}.OpenAPIModelName()), + Ref: ref(apisv1alpha1.APIBinding{}.OpenAPIModelName()), }, }, }, @@ -324,7 +569,7 @@ func schema_sdk_apis_apis_v1alpha1_APIBindingList(ref common.ReferenceCallback) }, }, Dependencies: []string{ - v1alpha1.APIBinding{}.OpenAPIModelName(), v1.ListMeta{}.OpenAPIModelName()}, + apisv1alpha1.APIBinding{}.OpenAPIModelName(), v1.ListMeta{}.OpenAPIModelName()}, } } @@ -339,7 +584,7 @@ func schema_sdk_apis_apis_v1alpha1_APIBindingSpec(ref common.ReferenceCallback) SchemaProps: spec.SchemaProps{ Description: "reference uniquely identifies an API to bind to.", Default: map[string]interface{}{}, - Ref: ref(v1alpha1.BindingReference{}.OpenAPIModelName()), + Ref: ref(apisv1alpha1.BindingReference{}.OpenAPIModelName()), }, }, "permissionClaims": { @@ -350,7 +595,7 @@ func schema_sdk_apis_apis_v1alpha1_APIBindingSpec(ref common.ReferenceCallback) Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref(v1alpha1.AcceptablePermissionClaim{}.OpenAPIModelName()), + Ref: ref(apisv1alpha1.AcceptablePermissionClaim{}.OpenAPIModelName()), }, }, }, @@ -361,7 +606,7 @@ func schema_sdk_apis_apis_v1alpha1_APIBindingSpec(ref common.ReferenceCallback) }, }, Dependencies: []string{ - v1alpha1.AcceptablePermissionClaim{}.OpenAPIModelName(), v1alpha1.BindingReference{}.OpenAPIModelName()}, + apisv1alpha1.AcceptablePermissionClaim{}.OpenAPIModelName(), apisv1alpha1.BindingReference{}.OpenAPIModelName()}, } } @@ -396,7 +641,7 @@ func schema_sdk_apis_apis_v1alpha1_APIBindingStatus(ref common.ReferenceCallback Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref(v1alpha1.BoundAPIResource{}.OpenAPIModelName()), + Ref: ref(apisv1alpha1.BoundAPIResource{}.OpenAPIModelName()), }, }, }, @@ -431,7 +676,7 @@ func schema_sdk_apis_apis_v1alpha1_APIBindingStatus(ref common.ReferenceCallback Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref(v1alpha1.PermissionClaim{}.OpenAPIModelName()), + Ref: ref(apisv1alpha1.PermissionClaim{}.OpenAPIModelName()), }, }, }, @@ -445,7 +690,7 @@ func schema_sdk_apis_apis_v1alpha1_APIBindingStatus(ref common.ReferenceCallback Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref(v1alpha1.PermissionClaim{}.OpenAPIModelName()), + Ref: ref(apisv1alpha1.PermissionClaim{}.OpenAPIModelName()), }, }, }, @@ -455,7 +700,7 @@ func schema_sdk_apis_apis_v1alpha1_APIBindingStatus(ref common.ReferenceCallback }, }, Dependencies: []string{ - v1alpha1.BoundAPIResource{}.OpenAPIModelName(), v1alpha1.PermissionClaim{}.OpenAPIModelName(), conditionsv1alpha1.Condition{}.OpenAPIModelName()}, + apisv1alpha1.BoundAPIResource{}.OpenAPIModelName(), apisv1alpha1.PermissionClaim{}.OpenAPIModelName(), conditionsv1alpha1.Condition{}.OpenAPIModelName()}, } } @@ -490,7 +735,7 @@ func schema_sdk_apis_apis_v1alpha1_APIConversion(ref common.ReferenceCallback) c SchemaProps: spec.SchemaProps{ Description: "Spec holds the desired state.", Default: map[string]interface{}{}, - Ref: ref(v1alpha1.APIConversionSpec{}.OpenAPIModelName()), + Ref: ref(apisv1alpha1.APIConversionSpec{}.OpenAPIModelName()), }, }, }, @@ -498,7 +743,7 @@ func schema_sdk_apis_apis_v1alpha1_APIConversion(ref common.ReferenceCallback) c }, }, Dependencies: []string{ - v1alpha1.APIConversionSpec{}.OpenAPIModelName(), v1.ObjectMeta{}.OpenAPIModelName()}, + apisv1alpha1.APIConversionSpec{}.OpenAPIModelName(), v1.ObjectMeta{}.OpenAPIModelName()}, } } @@ -536,7 +781,7 @@ func schema_sdk_apis_apis_v1alpha1_APIConversionList(ref common.ReferenceCallbac Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref(v1alpha1.APIConversion{}.OpenAPIModelName()), + Ref: ref(apisv1alpha1.APIConversion{}.OpenAPIModelName()), }, }, }, @@ -547,7 +792,7 @@ func schema_sdk_apis_apis_v1alpha1_APIConversionList(ref common.ReferenceCallbac }, }, Dependencies: []string{ - v1alpha1.APIConversion{}.OpenAPIModelName(), v1.ListMeta{}.OpenAPIModelName()}, + apisv1alpha1.APIConversion{}.OpenAPIModelName(), v1.ListMeta{}.OpenAPIModelName()}, } } @@ -612,7 +857,7 @@ func schema_sdk_apis_apis_v1alpha1_APIConversionSpec(ref common.ReferenceCallbac Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref(v1alpha1.APIVersionConversion{}.OpenAPIModelName()), + Ref: ref(apisv1alpha1.APIVersionConversion{}.OpenAPIModelName()), }, }, }, @@ -623,7 +868,7 @@ func schema_sdk_apis_apis_v1alpha1_APIConversionSpec(ref common.ReferenceCallbac }, }, Dependencies: []string{ - v1alpha1.APIVersionConversion{}.OpenAPIModelName()}, + apisv1alpha1.APIVersionConversion{}.OpenAPIModelName()}, } } @@ -658,21 +903,21 @@ func schema_sdk_apis_apis_v1alpha1_APIExport(ref common.ReferenceCallback) commo SchemaProps: spec.SchemaProps{ Description: "Spec holds the desired state.", Default: map[string]interface{}{}, - Ref: ref(v1alpha1.APIExportSpec{}.OpenAPIModelName()), + Ref: ref(apisv1alpha1.APIExportSpec{}.OpenAPIModelName()), }, }, "status": { SchemaProps: spec.SchemaProps{ Description: "Status communicates the observed state.", Default: map[string]interface{}{}, - Ref: ref(v1alpha1.APIExportStatus{}.OpenAPIModelName()), + Ref: ref(apisv1alpha1.APIExportStatus{}.OpenAPIModelName()), }, }, }, }, }, Dependencies: []string{ - v1alpha1.APIExportSpec{}.OpenAPIModelName(), v1alpha1.APIExportStatus{}.OpenAPIModelName(), v1.ObjectMeta{}.OpenAPIModelName()}, + apisv1alpha1.APIExportSpec{}.OpenAPIModelName(), apisv1alpha1.APIExportStatus{}.OpenAPIModelName(), v1.ObjectMeta{}.OpenAPIModelName()}, } } @@ -707,21 +952,21 @@ func schema_sdk_apis_apis_v1alpha1_APIExportEndpointSlice(ref common.ReferenceCa SchemaProps: spec.SchemaProps{ Description: "spec holds the desired state: - the targeted APIExport - an optional partition for filtering", Default: map[string]interface{}{}, - Ref: ref(v1alpha1.APIExportEndpointSliceSpec{}.OpenAPIModelName()), + Ref: ref(apisv1alpha1.APIExportEndpointSliceSpec{}.OpenAPIModelName()), }, }, "status": { SchemaProps: spec.SchemaProps{ Description: "status communicates the observed state: the filtered list of endpoints for the APIExport service.", Default: map[string]interface{}{}, - Ref: ref(v1alpha1.APIExportEndpointSliceStatus{}.OpenAPIModelName()), + Ref: ref(apisv1alpha1.APIExportEndpointSliceStatus{}.OpenAPIModelName()), }, }, }, }, }, Dependencies: []string{ - v1alpha1.APIExportEndpointSliceSpec{}.OpenAPIModelName(), v1alpha1.APIExportEndpointSliceStatus{}.OpenAPIModelName(), v1.ObjectMeta{}.OpenAPIModelName()}, + apisv1alpha1.APIExportEndpointSliceSpec{}.OpenAPIModelName(), apisv1alpha1.APIExportEndpointSliceStatus{}.OpenAPIModelName(), v1.ObjectMeta{}.OpenAPIModelName()}, } } @@ -759,7 +1004,7 @@ func schema_sdk_apis_apis_v1alpha1_APIExportEndpointSliceList(ref common.Referen Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref(v1alpha1.APIExportEndpointSlice{}.OpenAPIModelName()), + Ref: ref(apisv1alpha1.APIExportEndpointSlice{}.OpenAPIModelName()), }, }, }, @@ -770,7 +1015,7 @@ func schema_sdk_apis_apis_v1alpha1_APIExportEndpointSliceList(ref common.Referen }, }, Dependencies: []string{ - v1alpha1.APIExportEndpointSlice{}.OpenAPIModelName(), v1.ListMeta{}.OpenAPIModelName()}, + apisv1alpha1.APIExportEndpointSlice{}.OpenAPIModelName(), v1.ListMeta{}.OpenAPIModelName()}, } } @@ -785,7 +1030,7 @@ func schema_sdk_apis_apis_v1alpha1_APIExportEndpointSliceSpec(ref common.Referen SchemaProps: spec.SchemaProps{ Description: "export points to the API export.", Default: map[string]interface{}{}, - Ref: ref(v1alpha1.ExportBindingReference{}.OpenAPIModelName()), + Ref: ref(apisv1alpha1.ExportBindingReference{}.OpenAPIModelName()), }, }, "partition": { @@ -800,7 +1045,7 @@ func schema_sdk_apis_apis_v1alpha1_APIExportEndpointSliceSpec(ref common.Referen }, }, Dependencies: []string{ - v1alpha1.ExportBindingReference{}.OpenAPIModelName()}, + apisv1alpha1.ExportBindingReference{}.OpenAPIModelName()}, } } @@ -896,7 +1141,7 @@ func schema_sdk_apis_apis_v1alpha1_APIExportList(ref common.ReferenceCallback) c Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref(v1alpha1.APIExport{}.OpenAPIModelName()), + Ref: ref(apisv1alpha1.APIExport{}.OpenAPIModelName()), }, }, }, @@ -907,7 +1152,7 @@ func schema_sdk_apis_apis_v1alpha1_APIExportList(ref common.ReferenceCallback) c }, }, Dependencies: []string{ - v1alpha1.APIExport{}.OpenAPIModelName(), v1.ListMeta{}.OpenAPIModelName()}, + apisv1alpha1.APIExport{}.OpenAPIModelName(), v1.ListMeta{}.OpenAPIModelName()}, } } @@ -941,13 +1186,13 @@ func schema_sdk_apis_apis_v1alpha1_APIExportSpec(ref common.ReferenceCallback) c "identity": { SchemaProps: spec.SchemaProps{ Description: "identity points to a secret that contains the API identity in the 'key' file. The API identity determines an unique etcd prefix for objects stored via this APIExport.\n\nDifferent APIExport in a workspace can share a common identity, or have different ones. The identity (the secret) can also be transferred to another workspace when the APIExport is moved.\n\nThe identity is a secret of the API provider. The APIBindings referencing this APIExport will store a derived, non-sensitive value of this identity.\n\nThe identity of an APIExport cannot be changed. A derived, non-sensitive value of the identity key is stored in the APIExport status and this value is immutable.\n\nThe identity is defaulted. A secret with the name of the APIExport is automatically created.", - Ref: ref(v1alpha1.Identity{}.OpenAPIModelName()), + Ref: ref(apisv1alpha1.Identity{}.OpenAPIModelName()), }, }, "maximalPermissionPolicy": { SchemaProps: spec.SchemaProps{ Description: "maximalPermissionPolicy will allow for a service provider to set an upper bound on what is allowed for a consumer of this API. If the policy is not set, no upper bound is applied, i.e the consuming users can do whatever the user workspace allows the user to do.\n\nThe policy consists of RBAC (Cluster)Roles and (Cluster)Bindings. A request of a user in a workspace that binds to this APIExport via an APIBinding is additionally checked against these rules, with the user name and the groups prefixed with `apis.kcp.io:binding:`.\n\nFor example: assume a user `adam` with groups `system:authenticated` and `a-team` binds to this APIExport in another workspace root:org:ws. Then a request in that workspace against a resource of this APIExport is authorized as every other request in that workspace, but in addition the RBAC policy here in the APIExport workspace has to grant access to the user `apis.kcp.io:binding:adam` with the groups `apis.kcp.io:binding:system:authenticated` and `apis.kcp.io:binding:a-team`.\n\nIf an APIExport with a maximalPermissionPolicy is deleted, these additional checks will no longer be applied to resources of this APIExport.\n\nFor example: Assume an APIExport with a maximalPermissionPolicy that only allows creation and deletion of resources, but not updates - if the APIExport is deleted users will be able to update the resources of the APIExport again (given the constraints of their workspace's RBAC policies).", - Ref: ref(v1alpha1.MaximalPermissionPolicy{}.OpenAPIModelName()), + Ref: ref(apisv1alpha1.MaximalPermissionPolicy{}.OpenAPIModelName()), }, }, "permissionClaims": { @@ -967,7 +1212,7 @@ func schema_sdk_apis_apis_v1alpha1_APIExportSpec(ref common.ReferenceCallback) c Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref(v1alpha1.PermissionClaim{}.OpenAPIModelName()), + Ref: ref(apisv1alpha1.PermissionClaim{}.OpenAPIModelName()), }, }, }, @@ -977,7 +1222,7 @@ func schema_sdk_apis_apis_v1alpha1_APIExportSpec(ref common.ReferenceCallback) c }, }, Dependencies: []string{ - v1alpha1.Identity{}.OpenAPIModelName(), v1alpha1.MaximalPermissionPolicy{}.OpenAPIModelName(), v1alpha1.PermissionClaim{}.OpenAPIModelName()}, + apisv1alpha1.Identity{}.OpenAPIModelName(), apisv1alpha1.MaximalPermissionPolicy{}.OpenAPIModelName(), apisv1alpha1.PermissionClaim{}.OpenAPIModelName()}, } } @@ -1017,7 +1262,7 @@ func schema_sdk_apis_apis_v1alpha1_APIExportStatus(ref common.ReferenceCallback) Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref(v1alpha1.VirtualWorkspace{}.OpenAPIModelName()), + Ref: ref(apisv1alpha1.VirtualWorkspace{}.OpenAPIModelName()), }, }, }, @@ -1027,7 +1272,7 @@ func schema_sdk_apis_apis_v1alpha1_APIExportStatus(ref common.ReferenceCallback) }, }, Dependencies: []string{ - v1alpha1.VirtualWorkspace{}.OpenAPIModelName(), conditionsv1alpha1.Condition{}.OpenAPIModelName()}, + apisv1alpha1.VirtualWorkspace{}.OpenAPIModelName(), conditionsv1alpha1.Condition{}.OpenAPIModelName()}, } } @@ -1062,14 +1307,14 @@ func schema_sdk_apis_apis_v1alpha1_APIResourceSchema(ref common.ReferenceCallbac SchemaProps: spec.SchemaProps{ Description: "Spec holds the desired state.", Default: map[string]interface{}{}, - Ref: ref(v1alpha1.APIResourceSchemaSpec{}.OpenAPIModelName()), + Ref: ref(apisv1alpha1.APIResourceSchemaSpec{}.OpenAPIModelName()), }, }, }, }, }, Dependencies: []string{ - v1alpha1.APIResourceSchemaSpec{}.OpenAPIModelName(), v1.ObjectMeta{}.OpenAPIModelName()}, + apisv1alpha1.APIResourceSchemaSpec{}.OpenAPIModelName(), v1.ObjectMeta{}.OpenAPIModelName()}, } } @@ -1107,7 +1352,7 @@ func schema_sdk_apis_apis_v1alpha1_APIResourceSchemaList(ref common.ReferenceCal Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref(v1alpha1.APIResourceSchema{}.OpenAPIModelName()), + Ref: ref(apisv1alpha1.APIResourceSchema{}.OpenAPIModelName()), }, }, }, @@ -1118,7 +1363,7 @@ func schema_sdk_apis_apis_v1alpha1_APIResourceSchemaList(ref common.ReferenceCal }, }, Dependencies: []string{ - v1alpha1.APIResourceSchema{}.OpenAPIModelName(), v1.ListMeta{}.OpenAPIModelName()}, + apisv1alpha1.APIResourceSchema{}.OpenAPIModelName(), v1.ListMeta{}.OpenAPIModelName()}, } } @@ -1168,7 +1413,7 @@ func schema_sdk_apis_apis_v1alpha1_APIResourceSchemaSpec(ref common.ReferenceCal Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref(v1alpha1.APIResourceVersion{}.OpenAPIModelName()), + Ref: ref(apisv1alpha1.APIResourceVersion{}.OpenAPIModelName()), }, }, }, @@ -1184,7 +1429,7 @@ func schema_sdk_apis_apis_v1alpha1_APIResourceSchemaSpec(ref common.ReferenceCal "conversion": { SchemaProps: spec.SchemaProps{ Description: "conversion defines conversion settings for the defined custom resource.", - Ref: ref(v1alpha1.CustomResourceConversion{}.OpenAPIModelName()), + Ref: ref(apisv1alpha1.CustomResourceConversion{}.OpenAPIModelName()), }, }, }, @@ -1192,7 +1437,7 @@ func schema_sdk_apis_apis_v1alpha1_APIResourceSchemaSpec(ref common.ReferenceCal }, }, Dependencies: []string{ - v1alpha1.APIResourceVersion{}.OpenAPIModelName(), v1alpha1.CustomResourceConversion{}.OpenAPIModelName(), "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1.CustomResourceDefinitionNames"}, + apisv1alpha1.APIResourceVersion{}.OpenAPIModelName(), apisv1alpha1.CustomResourceConversion{}.OpenAPIModelName(), "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1.CustomResourceDefinitionNames"}, } } @@ -1348,7 +1593,7 @@ func schema_sdk_apis_apis_v1alpha1_APIVersionConversion(ref common.ReferenceCall Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref(v1alpha1.APIConversionRule{}.OpenAPIModelName()), + Ref: ref(apisv1alpha1.APIConversionRule{}.OpenAPIModelName()), }, }, }, @@ -1374,7 +1619,7 @@ func schema_sdk_apis_apis_v1alpha1_APIVersionConversion(ref common.ReferenceCall }, }, Dependencies: []string{ - v1alpha1.APIConversionRule{}.OpenAPIModelName()}, + apisv1alpha1.APIConversionRule{}.OpenAPIModelName()}, } } @@ -1415,7 +1660,7 @@ func schema_sdk_apis_apis_v1alpha1_AcceptablePermissionClaim(ref common.Referenc Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref(v1alpha1.ResourceSelector{}.OpenAPIModelName()), + Ref: ref(apisv1alpha1.ResourceSelector{}.OpenAPIModelName()), }, }, }, @@ -1440,7 +1685,7 @@ func schema_sdk_apis_apis_v1alpha1_AcceptablePermissionClaim(ref common.Referenc }, }, Dependencies: []string{ - v1alpha1.ResourceSelector{}.OpenAPIModelName()}, + apisv1alpha1.ResourceSelector{}.OpenAPIModelName()}, } } @@ -1454,14 +1699,14 @@ func schema_sdk_apis_apis_v1alpha1_BindingReference(ref common.ReferenceCallback "export": { SchemaProps: spec.SchemaProps{ Description: "export is a reference to an APIExport by cluster name and export name. The creator of the APIBinding needs to have access to the APIExport with the verb `bind` in order to bind to it.", - Ref: ref(v1alpha1.ExportBindingReference{}.OpenAPIModelName()), + Ref: ref(apisv1alpha1.ExportBindingReference{}.OpenAPIModelName()), }, }, }, }, }, Dependencies: []string{ - v1alpha1.ExportBindingReference{}.OpenAPIModelName()}, + apisv1alpha1.ExportBindingReference{}.OpenAPIModelName()}, } } @@ -1492,7 +1737,7 @@ func schema_sdk_apis_apis_v1alpha1_BoundAPIResource(ref common.ReferenceCallback SchemaProps: spec.SchemaProps{ Description: "Schema references the APIResourceSchema that is bound to this API.", Default: map[string]interface{}{}, - Ref: ref(v1alpha1.BoundAPIResourceSchema{}.OpenAPIModelName()), + Ref: ref(apisv1alpha1.BoundAPIResourceSchema{}.OpenAPIModelName()), }, }, "storageVersions": { @@ -1520,7 +1765,7 @@ func schema_sdk_apis_apis_v1alpha1_BoundAPIResource(ref common.ReferenceCallback }, }, Dependencies: []string{ - v1alpha1.BoundAPIResourceSchema{}.OpenAPIModelName()}, + apisv1alpha1.BoundAPIResourceSchema{}.OpenAPIModelName()}, } } @@ -1580,7 +1825,7 @@ func schema_sdk_apis_apis_v1alpha1_CustomResourceConversion(ref common.Reference "webhook": { SchemaProps: spec.SchemaProps{ Description: "webhook describes how to call the conversion webhook. Required when `strategy` is set to `\"Webhook\"`.", - Ref: ref(v1alpha1.WebhookConversion{}.OpenAPIModelName()), + Ref: ref(apisv1alpha1.WebhookConversion{}.OpenAPIModelName()), }, }, }, @@ -1588,7 +1833,7 @@ func schema_sdk_apis_apis_v1alpha1_CustomResourceConversion(ref common.Reference }, }, Dependencies: []string{ - v1alpha1.WebhookConversion{}.OpenAPIModelName()}, + apisv1alpha1.WebhookConversion{}.OpenAPIModelName()}, } } @@ -1692,14 +1937,14 @@ func schema_sdk_apis_apis_v1alpha1_MaximalPermissionPolicy(ref common.ReferenceC "local": { SchemaProps: spec.SchemaProps{ Description: "local is the policy that is defined in same workspace as the API Export.", - Ref: ref(v1alpha1.LocalAPIExportPolicy{}.OpenAPIModelName()), + Ref: ref(apisv1alpha1.LocalAPIExportPolicy{}.OpenAPIModelName()), }, }, }, }, }, Dependencies: []string{ - v1alpha1.LocalAPIExportPolicy{}.OpenAPIModelName()}, + apisv1alpha1.LocalAPIExportPolicy{}.OpenAPIModelName()}, } } @@ -1740,7 +1985,7 @@ func schema_sdk_apis_apis_v1alpha1_PermissionClaim(ref common.ReferenceCallback) Schema: &spec.Schema{ SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, - Ref: ref(v1alpha1.ResourceSelector{}.OpenAPIModelName()), + Ref: ref(apisv1alpha1.ResourceSelector{}.OpenAPIModelName()), }, }, }, @@ -1758,7 +2003,7 @@ func schema_sdk_apis_apis_v1alpha1_PermissionClaim(ref common.ReferenceCallback) }, }, Dependencies: []string{ - v1alpha1.ResourceSelector{}.OpenAPIModelName()}, + apisv1alpha1.ResourceSelector{}.OpenAPIModelName()}, } } @@ -1846,7 +2091,7 @@ func schema_sdk_apis_apis_v1alpha1_WebhookConversion(ref common.ReferenceCallbac "clientConfig": { SchemaProps: spec.SchemaProps{ Description: "clientConfig is the instructions for how to call the webhook if strategy is `Webhook`.", - Ref: ref(v1alpha1.WebhookClientConfig{}.OpenAPIModelName()), + Ref: ref(apisv1alpha1.WebhookClientConfig{}.OpenAPIModelName()), }, }, "conversionReviewVersions": { @@ -1874,7 +2119,7 @@ func schema_sdk_apis_apis_v1alpha1_WebhookConversion(ref common.ReferenceCallbac }, }, Dependencies: []string{ - v1alpha1.WebhookClientConfig{}.OpenAPIModelName()}, + apisv1alpha1.WebhookClientConfig{}.OpenAPIModelName()}, } } diff --git a/test/e2e/apibinding/apiexport_identity_registry_test.go b/test/e2e/apibinding/apiexport_identity_registry_test.go new file mode 100644 index 00000000000..28123bd14fa --- /dev/null +++ b/test/e2e/apibinding/apiexport_identity_registry_test.go @@ -0,0 +1,210 @@ +/* +Copyright 2026 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 apibinding + +import ( + "fmt" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/require" + + corev1 "k8s.io/api/core/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/rest" + "k8s.io/client-go/restmapper" + + kcpdynamic "github.com/kcp-dev/client-go/dynamic" + kcpkubernetesclientset "github.com/kcp-dev/client-go/kubernetes" + "github.com/kcp-dev/logicalcluster/v3" + adminv1alpha1 "github.com/kcp-dev/sdk/apis/admin/v1alpha1" + apisv1alpha1 "github.com/kcp-dev/sdk/apis/apis/v1alpha1" + apisv1alpha2 "github.com/kcp-dev/sdk/apis/apis/v1alpha2" + "github.com/kcp-dev/sdk/apis/core" + tenancyv1alpha1 "github.com/kcp-dev/sdk/apis/tenancy/v1alpha1" + "github.com/kcp-dev/sdk/apis/third_party/conditions/util/conditions" + kcpclientset "github.com/kcp-dev/sdk/client/clientset/versioned" + kcpclusterclientset "github.com/kcp-dev/sdk/client/clientset/versioned/cluster" + kcptesting "github.com/kcp-dev/sdk/testing" + kcptestinghelpers "github.com/kcp-dev/sdk/testing/helpers" + + "github.com/kcp-dev/kcp/config/helpers" + "github.com/kcp-dev/kcp/pkg/reconciler/admin/apiidentity" + "github.com/kcp-dev/kcp/test/e2e/framework" +) + +// TestAPIExportIdentityRegistry exercises the identity registry: the first +// APIExport to use an identity owns it, another APIExport with a copy of the +// key shares the identity and is recorded as a holder, the oldest holder +// inherits the identity when the owner is deleted, and an identity nobody +// serves any more is orphaned and reclaimed by the next export with the key. +func TestAPIExportIdentityRegistry(t *testing.T) { + t.Parallel() + framework.Suite(t, "control-plane") + + server := kcptesting.SharedKcpServer(t) + + orgPath, _ := kcptesting.NewWorkspaceFixture(t, server, core.RootCluster.Path(), kcptesting.WithType(core.RootCluster.Path(), "organization")) + ownerPath, ownerWS := kcptesting.NewWorkspaceFixture(t, server, orgPath) + holderPath, holderWS := kcptesting.NewWorkspaceFixture(t, server, orgPath) + newcomerPath, newcomerWS := kcptesting.NewWorkspaceFixture(t, server, orgPath) + + cfg := server.BaseConfig(t) + kcpClusterClient, err := kcpclusterclientset.NewForConfig(cfg) + require.NoError(t, err) + kubeClusterClient, err := kcpkubernetesclientset.NewForConfig(cfg) + require.NoError(t, err) + dynamicClusterClient, err := kcpdynamic.NewForConfig(cfg) + require.NoError(t, err) + + // the registry is read through the Admin workspace + vwCfg := rest.CopyConfig(cfg) + vwURL, err := url.Parse(cfg.Host) + require.NoError(t, err) + vwURL.Path = "/services/admin" + vwCfg.Host = vwURL.String() + adminClient, err := kcpclientset.NewForConfig(vwCfg) + require.NoError(t, err) + + for _, path := range []logicalcluster.Path{ownerPath, holderPath, newcomerPath} { + t.Logf("Install cowboys APIResourceSchema into %q", path) + mapper := restmapper.NewDeferredDiscoveryRESTMapper(memory.NewMemCacheClient(kcpClusterClient.Cluster(path).Discovery())) + require.NoError(t, helpers.CreateResourceFromFS(t.Context(), dynamicClusterClient.Cluster(path), mapper, nil, "apiresourceschema_cowboys.yaml", testFiles)) + } + + newExport := func() *apisv1alpha2.APIExport { + return &apisv1alpha2.APIExport{ + ObjectMeta: metav1.ObjectMeta{Name: "wildwest"}, + Spec: apisv1alpha2.APIExportSpec{ + Resources: []apisv1alpha2.ResourceSchema{{ + Name: "cowboys", Group: "wildwest.dev", Schema: "today.cowboys.wildwest.dev", + Storage: apisv1alpha2.ResourceSchemaStorage{CRD: &apisv1alpha2.ResourceSchemaStorageCRD{}}, + }}, + }, + } + } + exportRef := func(ws *tenancyv1alpha1.Workspace, export *apisv1alpha2.APIExport) adminv1alpha1.ExportReference { + return adminv1alpha1.ExportReference{Cluster: ws.Spec.Cluster, Name: export.Name, UID: string(export.UID)} + } + getExport := func(path logicalcluster.Path) (*apisv1alpha2.APIExport, error) { + return kcpClusterClient.Cluster(path).ApisV1alpha2().APIExports().Get(t.Context(), "wildwest", metav1.GetOptions{}) + } + waitForIdentity := func(path logicalcluster.Path) *apisv1alpha2.APIExport { + t.Helper() + var export *apisv1alpha2.APIExport + kcptestinghelpers.Eventually(t, func() (bool, string) { + var err error + export, err = getExport(path) + if err != nil { + return false, err.Error() + } + cond := conditions.Get(export, apisv1alpha2.APIExportIdentityValid) + if cond == nil || cond.Status != corev1.ConditionTrue || export.Status.IdentityHash == "" { + return false, fmt.Sprintf("IdentityValid=%v identityHash=%q", cond, export.Status.IdentityHash) + } + return true, "" + }, wait.ForeverTestTimeout, 100*time.Millisecond) + return export + } + waitForEntry := func(identity string, timeout time.Duration, check func(*adminv1alpha1.APIIdentity) (bool, string)) { + t.Helper() + kcptestinghelpers.Eventually(t, func() (bool, string) { + entry, err := adminClient.AdminV1alpha1().APIIdentities().Get(t.Context(), identity, metav1.GetOptions{}) + if err != nil { + return false, err.Error() + } + return check(entry) + }, timeout, 250*time.Millisecond) + } + // a deleted owner is taken as gone only after the grace period + afterGrace := apiidentity.MissingGracePeriod + wait.ForeverTestTimeout + copyKey := func(from, to logicalcluster.Path) { + t.Helper() + secret, err := kubeClusterClient.Cluster(from).CoreV1().Secrets("kcp-system").Get(t.Context(), "wildwest", metav1.GetOptions{}) + require.NoError(t, err) + _, err = kubeClusterClient.Cluster(to).CoreV1().Namespaces().Create(t.Context(), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "kcp-system"}}, metav1.CreateOptions{}) + require.True(t, err == nil || apierrors.IsAlreadyExists(err), "creating kcp-system: %v", err) + _, err = kubeClusterClient.Cluster(to).CoreV1().Secrets("kcp-system").Create(t.Context(), &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "wildwest"}, + Data: map[string][]byte{apisv1alpha1.SecretKeyAPIExportIdentity: secret.Data[apisv1alpha1.SecretKeyAPIExportIdentity]}, + }, metav1.CreateOptions{}) + require.NoError(t, err) + } + createWithKey := func(path logicalcluster.Path) { + t.Helper() + export := newExport() + export.Spec.Identity = &apisv1alpha2.Identity{SecretRef: &corev1.SecretReference{Namespace: "kcp-system", Name: "wildwest"}} + _, err := kcpClusterClient.Cluster(path).ApisV1alpha2().APIExports().Create(t.Context(), export, metav1.CreateOptions{}) + require.NoError(t, err) + } + + t.Logf("Create the owner APIExport in %q: it claims the identity", ownerPath) + _, err = kcpClusterClient.Cluster(ownerPath).ApisV1alpha2().APIExports().Create(t.Context(), newExport(), metav1.CreateOptions{}) + require.NoError(t, err) + owner := waitForIdentity(ownerPath) + identity := owner.Status.IdentityHash + waitForEntry(identity, wait.ForeverTestTimeout, func(entry *adminv1alpha1.APIIdentity) (bool, string) { + return entry.Status.Owner == exportRef(ownerWS, owner) && len(entry.Status.Holders) == 0 && !entry.Status.Core, + fmt.Sprintf("owner=%v holders=%v core=%v", entry.Status.Owner, entry.Status.Holders, entry.Status.Core) + }) + + t.Logf("Copy the owner's key into %q: the holder shares the identity and is recorded", holderPath) + copyKey(ownerPath, holderPath) + createWithKey(holderPath) + holder := waitForIdentity(holderPath) + require.Equal(t, identity, holder.Status.IdentityHash, "same key, same identity") + waitForEntry(identity, wait.ForeverTestTimeout, func(entry *adminv1alpha1.APIIdentity) (bool, string) { + if len(entry.Status.Holders) != 1 || !entry.Status.Holders[0].Equal(exportRef(holderWS, holder)) { + return false, fmt.Sprintf("holders=%v", entry.Status.Holders) + } + return conditions.IsTrue(entry, adminv1alpha1.APIIdentityShared), "not marked Shared yet" + }) + + t.Logf("Delete the owner: the holder becomes the owner and keeps serving") + require.NoError(t, kcpClusterClient.Cluster(ownerPath).ApisV1alpha2().APIExports().Delete(t.Context(), "wildwest", metav1.DeleteOptions{})) + waitForEntry(identity, afterGrace, func(entry *adminv1alpha1.APIIdentity) (bool, string) { + if !entry.Status.Owner.Equal(exportRef(holderWS, holder)) { + return false, fmt.Sprintf("owner=%v", entry.Status.Owner) + } + return len(entry.Status.Holders) == 0 && !conditions.IsTrue(entry, adminv1alpha1.APIIdentityOrphaned), + fmt.Sprintf("holders=%v orphaned=%v", entry.Status.Holders, conditions.Get(entry, adminv1alpha1.APIIdentityOrphaned)) + }) + holder, err = getExport(holderPath) + require.NoError(t, err) + require.Equal(t, identity, holder.Status.IdentityHash, "the identity does not change hands' value") + require.True(t, conditions.IsTrue(holder, apisv1alpha2.APIExportIdentityValid)) + + t.Logf("Delete the last export: the identity is orphaned") + require.NoError(t, kcpClusterClient.Cluster(holderPath).ApisV1alpha2().APIExports().Delete(t.Context(), "wildwest", metav1.DeleteOptions{})) + waitForEntry(identity, afterGrace, func(entry *adminv1alpha1.APIIdentity) (bool, string) { + return conditions.IsTrue(entry, adminv1alpha1.APIIdentityOrphaned), fmt.Sprintf("orphaned=%v", conditions.Get(entry, adminv1alpha1.APIIdentityOrphaned)) + }) + + t.Logf("A newcomer with the key reclaims the orphaned identity") + copyKey(holderPath, newcomerPath) + createWithKey(newcomerPath) + newcomer := waitForIdentity(newcomerPath) + require.Equal(t, identity, newcomer.Status.IdentityHash) + waitForEntry(identity, wait.ForeverTestTimeout, func(entry *adminv1alpha1.APIIdentity) (bool, string) { + return entry.Status.Owner == exportRef(newcomerWS, newcomer) && conditions.Get(entry, adminv1alpha1.APIIdentityOrphaned) == nil, + fmt.Sprintf("owner=%v orphaned=%v", entry.Status.Owner, conditions.Get(entry, adminv1alpha1.APIIdentityOrphaned)) + }) +} diff --git a/test/e2e/virtual/admin/apiidentities_test.go b/test/e2e/virtual/admin/apiidentities_test.go new file mode 100644 index 00000000000..bf29c606727 --- /dev/null +++ b/test/e2e/virtual/admin/apiidentities_test.go @@ -0,0 +1,156 @@ +/* +Copyright 2026 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 admin + +import ( + "context" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/rest" + + "github.com/kcp-dev/logicalcluster/v3" + adminv1alpha1 "github.com/kcp-dev/sdk/apis/admin/v1alpha1" + apisv1alpha2 "github.com/kcp-dev/sdk/apis/apis/v1alpha2" + "github.com/kcp-dev/sdk/apis/core" + kcpclientset "github.com/kcp-dev/sdk/client/clientset/versioned" + kcpclusterclientset "github.com/kcp-dev/sdk/client/clientset/versioned/cluster" + kcptesting "github.com/kcp-dev/sdk/testing" + + "github.com/kcp-dev/kcp/test/e2e/framework" +) + +// TestAdminWorkspaceAPIIdentities exercises the identity registry view of the +// Admin workspace (/services/admin): the core identities are listed with +// their root exports as owners, a new export's identity shows up with the +// export as owner and is delivered on a watch, bookkeeping annotations are +// stripped, and the view is read-only. +func TestAdminWorkspaceAPIIdentities(t *testing.T) { + t.Parallel() + framework.Suite(t, "control-plane") + + server := kcptesting.SharedKcpServer(t) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + cfg := server.BaseConfig(t) + kcpClusterClient, err := kcpclusterclientset.NewForConfig(cfg) + require.NoError(t, err) + + vwCfg := rest.CopyConfig(cfg) + vwURL, err := url.Parse(cfg.Host) + require.NoError(t, err) + vwURL.Path = "/services/admin" + vwCfg.Host = vwURL.String() + adminClient, err := kcpclientset.NewForConfig(vwCfg) + require.NoError(t, err) + + t.Logf("The core identities are registered with their root exports as owners") + tenancyExport, err := kcpClusterClient.Cluster(core.RootCluster.Path()).ApisV1alpha2().APIExports().Get(ctx, "tenancy.kcp.io", metav1.GetOptions{}) + require.NoError(t, err) + require.NotEmpty(t, tenancyExport.Status.IdentityHash) + + var registry *adminv1alpha1.APIIdentityList + require.EventuallyWithT(t, func(c *assert.CollectT) { + registry, err = adminClient.AdminV1alpha1().APIIdentities().List(ctx, metav1.ListOptions{}) + require.NoError(c, err) + var tenancy *adminv1alpha1.APIIdentity + for i := range registry.Items { + require.NotContains(c, registry.Items[i].Annotations, "kcp.io/shard", "cache bookkeeping annotations must be stripped") + if registry.Items[i].Name == tenancyExport.Status.IdentityHash { + tenancy = ®istry.Items[i] + } + } + require.NotNil(c, tenancy, "the tenancy.kcp.io identity must be registered") + require.True(c, tenancy.Status.Core, "tenancy.kcp.io is a core identity") + require.Equal(c, adminv1alpha1.ExportReference{Cluster: core.RootCluster.String(), Name: "tenancy.kcp.io", UID: string(tenancyExport.UID)}, tenancy.Status.Owner) + }, wait.ForeverTestTimeout, 100*time.Millisecond) + + t.Logf("Get the core identity by name through the Admin workspace") + for _, rv := range []string{"", "0", registry.ResourceVersion} { + entry, err := adminClient.AdminV1alpha1().APIIdentities().Get(ctx, tenancyExport.Status.IdentityHash, metav1.GetOptions{ResourceVersion: rv}) + require.NoError(t, err, "failed to get identity with resourceVersion %q", rv) + require.Equal(t, tenancyExport.Status.IdentityHash, entry.Name) + require.NotContains(t, entry.Annotations, "kcp.io/shard", "cache bookkeeping annotations must be stripped") + } + _, err = adminClient.AdminV1alpha1().APIIdentities().Get(ctx, "does-not-exist", metav1.GetOptions{}) + require.True(t, apierrors.IsNotFound(err), "expected NotFound, got %v", err) + + t.Logf("A new APIExport's identity is registered with the export as owner, and delivered on a watch") + w, err := adminClient.AdminV1alpha1().APIIdentities().Watch(ctx, metav1.ListOptions{ResourceVersion: registry.ResourceVersion}) + require.NoError(t, err) + t.Cleanup(w.Stop) + + orgPath, _ := kcptesting.NewWorkspaceFixture(t, server, core.RootCluster.Path(), kcptesting.WithType(core.RootCluster.Path(), "organization")) + providerPath, providerWS := kcptesting.NewWorkspaceFixture(t, server, orgPath) + export, err := kcpClusterClient.Cluster(providerPath).ApisV1alpha2().APIExports().Create(ctx, &apisv1alpha2.APIExport{ + ObjectMeta: metav1.ObjectMeta{Name: "widgets"}, + }, metav1.CreateOptions{}) + require.NoError(t, err) + + require.EventuallyWithT(t, func(c *assert.CollectT) { + export, err = kcpClusterClient.Cluster(providerPath).ApisV1alpha2().APIExports().Get(ctx, export.Name, metav1.GetOptions{}) + require.NoError(c, err) + require.NotEmpty(c, export.Status.IdentityHash, "the export must get an identity") + }, wait.ForeverTestTimeout, 100*time.Millisecond) + + want := adminv1alpha1.ExportReference{Cluster: logicalcluster.Name(providerWS.Spec.Cluster).String(), Name: export.Name, UID: string(export.UID)} + require.EventuallyWithT(t, func(c *assert.CollectT) { + entry, err := adminClient.AdminV1alpha1().APIIdentities().Get(ctx, export.Status.IdentityHash, metav1.GetOptions{}) + require.NoError(c, err) + require.Equal(c, want, entry.Status.Owner) + require.False(c, entry.Status.Core) + require.NotContains(c, entry.Annotations, "kcp.io/shard", "cache bookkeeping annotations must be stripped") + }, wait.ForeverTestTimeout, 100*time.Millisecond) + + deadline := time.After(wait.ForeverTestTimeout) + for seen := false; !seen; { + select { + case event, ok := <-w.ResultChan(): + require.True(t, ok, "the watch closed before the new identity was delivered") + if event.Type == watch.Bookmark || event.Type == watch.Error { + continue + } + entry, ok := event.Object.(*adminv1alpha1.APIIdentity) + require.True(t, ok, "unexpected watch object %T", event.Object) + require.NotContains(t, entry.Annotations, "kcp.io/shard", "cache bookkeeping annotations must be stripped") + seen = entry.Name == export.Status.IdentityHash && entry.Status.Owner.Equal(want) + case <-deadline: + t.Fatalf("timed out waiting for the watch to deliver identity %s", export.Status.IdentityHash) + } + } + + t.Logf("The registry is read-only through the Admin workspace") + entry, err := adminClient.AdminV1alpha1().APIIdentities().Get(ctx, export.Status.IdentityHash, metav1.GetOptions{}) + require.NoError(t, err) + entry.Status.Core = true + _, err = adminClient.AdminV1alpha1().APIIdentities().Update(ctx, entry, metav1.UpdateOptions{}) + require.Error(t, err) + require.True(t, apierrors.IsMethodNotSupported(err) || apierrors.IsForbidden(err), "expected the update to be rejected, got %v", err) + err = adminClient.AdminV1alpha1().APIIdentities().Delete(ctx, entry.Name, metav1.DeleteOptions{}) + require.Error(t, err) + require.True(t, apierrors.IsMethodNotSupported(err) || apierrors.IsForbidden(err), "expected the delete to be rejected, got %v", err) +} From 5f5b88594b491be8d9c9459882740bbba59bb633 Mon Sep 17 00:00:00 2001 From: Mangirdas Judeikis Date: Fri, 23 Oct 2026 16:42:06 +0300 Subject: [PATCH 2/7] Add e2e and holders logic --- config/crds/admin.kcp.io_apiidentities.yaml | 17 +++------- docs/content/concepts/apis/exporting-apis.md | 4 +-- .../admin/apiidentity/controller.go | 33 +++++-------------- .../admin/apiidentity/controller_test.go | 32 +++++++++--------- pkg/reconciler/admin/apiidentity/registry.go | 5 +++ .../apiexport/apiexport_reconcile_identity.go | 13 +------- .../apiexport_reconcile_identity_test.go | 16 --------- .../apis/admin/v1alpha1/types_apiidentity.go | 26 +++------------ .../admin/v1alpha1/apiidentity.go | 14 ++------ .../admin/v1alpha1/apiidentitystatus.go | 3 +- .../sdk/openapi/zz_generated.openapi.go | 4 +-- .../apiexport_identity_registry_test.go | 28 ++++++++-------- 12 files changed, 63 insertions(+), 132 deletions(-) diff --git a/config/crds/admin.kcp.io_apiidentities.yaml b/config/crds/admin.kcp.io_apiidentities.yaml index 4c4a58d1c95..7749d8faf8b 100644 --- a/config/crds/admin.kcp.io_apiidentities.yaml +++ b/config/crds/admin.kcp.io_apiidentities.yaml @@ -40,18 +40,8 @@ spec: APIExport owns the identity and which other APIExports hold it. There is exactly one entry per identity in an installation, named after the identity value, living on the cache server where every shard can read it - and served read-only through the Admin workspace. - - Entries are written by kcp only. The owner is recorded when the identity - is first claimed, which is the atomic create of the entry; holders record - themselves when they get the identity. When the owner's APIExport is - deleted the identity passes to the oldest holder. Nothing that could be - used to obtain the identity is recorded here, only who has it. - - The object has a status field but no status subresource: it lives on the - cache server, whose status subresource does not honor shard scopes, and it - is written by kcp only, so spec and status are written together in one - update of the whole object. + and served read-only through the Admin workspace. Entries are written by + kcp only. properties: apiVersion: description: |- @@ -168,7 +158,8 @@ spec: owner is the APIExport that holds the identity authoritatively: the first one to use it. It is written by kcp when the identity is claimed and changed by kcp only when the owner is deleted and a holder - succeeds it, or an orphaned identity is reclaimed. + succeeds it. An entry whose owner is gone and that has no holder left + is removed. properties: cluster: description: cluster is the logical cluster name hosting the APIExport. diff --git a/docs/content/concepts/apis/exporting-apis.md b/docs/content/concepts/apis/exporting-apis.md index e664d575d66..e3a4df6c500 100644 --- a/docs/content/concepts/apis/exporting-apis.md +++ b/docs/content/concepts/apis/exporting-apis.md @@ -187,8 +187,8 @@ identity a lifecycle of its own. When the owner's `APIExport` is deleted, the identity passes to the oldest holder. The decision is made from the `APIExports` replicated to the cache server: an owner missing from the cache is marked `OwnerMissing`, and after a -grace period it is taken as gone. An identity with no owner and no holder left is marked `Orphaned` and is reclaimed by -the next `APIExport` whose secret carries the original `key`. +grace period it is taken as gone. An identity with no owner and no holder left is removed from the registry; the +next `APIExport` whose secret carries the original `key` registers it anew. `status.identityHash` is set once the identity is recorded in the registry and is immutable afterwards. ### Permission Claims diff --git a/pkg/reconciler/admin/apiidentity/controller.go b/pkg/reconciler/admin/apiidentity/controller.go index d0d1af4c656..cf9b8f96ea2 100644 --- a/pkg/reconciler/admin/apiidentity/controller.go +++ b/pkg/reconciler/admin/apiidentity/controller.go @@ -22,8 +22,8 @@ limitations under the License. // which is the installation-wide view: the shard hosting the owner keeps the // derived status current; an owner missing from the cache for longer than // the grace period is gone, and the shard hosting the oldest remaining -// holder takes the identity over, or the identity is orphaned when no holder -// is left. +// holder takes the identity over, or the entry is removed when no holder is +// left. package apiidentity import ( @@ -223,22 +223,17 @@ func (c *Controller) maintain(ctx context.Context, entry *adminv1alpha1.APIIdent entry.Status.Core = c.isCore(owner) // an owner that succeeded from holder is not a holder any more dropHolders(entry, append(c.goneHolders(entry), ref(owner))) - conditions.Delete(entry, adminv1alpha1.APIIdentityOrphaned) }) } // ownerMissing handles an entry whose owner is not in the cache. The first // look records when it went missing; after the grace period the owner is // gone and the identity passes to the oldest holder still in the cache, or -// is orphaned. +// the entry is removed when there is none: the next export with the key +// registers the identity anew. func (c *Controller) ownerMissing(ctx context.Context, entry *adminv1alpha1.APIIdentity) error { logger := klog.FromContext(ctx) - if conditions.IsTrue(entry, adminv1alpha1.APIIdentityOrphaned) { - // nobody serves it; whoever has the key reclaims it - return nil - } - missing := conditions.Get(entry, adminv1alpha1.APIIdentityOwnerMissing) if missing == nil || missing.Status != corev1.ConditionTrue { logger.V(2).Info("owner of identity is not in the cache, waiting for the grace period", "owner", entry.Status.Owner.Cluster+"|"+entry.Status.Owner.Name) @@ -258,12 +253,11 @@ func (c *Controller) ownerMissing(ctx context.Context, entry *adminv1alpha1.APII // the owner is gone successor := c.oldestHolderInCache(entry) if successor == nil { - logger.Info("identity has no owner and no holder left, marking it orphaned") - return c.updateStatus(ctx, entry.Name, func(entry *adminv1alpha1.APIIdentity) { - dropHolders(entry, holderRefs(entry)) - conditions.Delete(entry, adminv1alpha1.APIIdentityOwnerMissing) - conditions.MarkTrue(entry, adminv1alpha1.APIIdentityOrphaned) - }) + logger.Info("identity has no owner and no holder left, removing it from the registry") + if err := c.registry.Delete(ctx, entry.Name, metav1.DeleteOptions{}); err != nil && !apierrors.IsNotFound(err) { + return err + } + return nil } if _, err := c.getLocalAPIExport(logicalcluster.Name(successor.Cluster), successor.Name); err != nil { @@ -278,7 +272,6 @@ func (c *Controller) ownerMissing(ctx context.Context, entry *adminv1alpha1.APII entry.Status.Owner = successor.ExportReference dropHolders(entry, []adminv1alpha1.ExportReference{successor.ExportReference}) conditions.Delete(entry, adminv1alpha1.APIIdentityOwnerMissing) - conditions.MarkFalse(entry, adminv1alpha1.APIIdentityOrphaned, adminv1alpha1.APIIdentityOwnerSucceededReason, "", "") }) } @@ -315,14 +308,6 @@ func (c *Controller) goneHolders(entry *adminv1alpha1.APIIdentity) []adminv1alph return gone } -func holderRefs(entry *adminv1alpha1.APIIdentity) []adminv1alpha1.ExportReference { - refs := make([]adminv1alpha1.ExportReference, 0, len(entry.Status.Holders)) - for _, holder := range entry.Status.Holders { - refs = append(refs, holder.ExportReference) - } - return refs -} - // dropHolders removes the given holders and keeps the Shared condition in // step with the remaining ones. func dropHolders(entry *adminv1alpha1.APIIdentity, refs []adminv1alpha1.ExportReference) { diff --git a/pkg/reconciler/admin/apiidentity/controller_test.go b/pkg/reconciler/admin/apiidentity/controller_test.go index a9d8c6d7c13..e1be4fadb39 100644 --- a/pkg/reconciler/admin/apiidentity/controller_test.go +++ b/pkg/reconciler/admin/apiidentity/controller_test.go @@ -53,6 +53,16 @@ func (r *fakeRegistry) Get(_ context.Context, name string, _ metav1.GetOptions) return entry.DeepCopy(), nil } +func (r *fakeRegistry) Delete(_ context.Context, name string, _ metav1.DeleteOptions) error { + r.mu.Lock() + defer r.mu.Unlock() + if _, ok := r.entries[name]; !ok { + return apierrors.NewNotFound(adminv1alpha1.Resource("apiidentities"), name) + } + delete(r.entries, name) + return nil +} + func (r *fakeRegistry) Update(_ context.Context, entry *adminv1alpha1.APIIdentity, _ metav1.UpdateOptions) (*adminv1alpha1.APIIdentity, error) { r.mu.Lock() defer r.mu.Unlock() @@ -179,14 +189,12 @@ func TestHostedOwnerIsMaintained(t *testing.T) { f.host(owner) f.elsewhere(holder) e := f.entry(owner, holder) - conditions.MarkTrue(e, adminv1alpha1.APIIdentityOrphaned) conditions.MarkTrue(e, adminv1alpha1.APIIdentityOwnerMissing) entry := f.reconcile() require.True(t, entry.Status.Core) require.Len(t, entry.Status.Holders, 1) require.True(t, conditions.IsTrue(entry, adminv1alpha1.APIIdentityShared)) - require.Nil(t, conditions.Get(entry, adminv1alpha1.APIIdentityOrphaned), "an owner in the cache is not orphaned") require.Nil(t, conditions.Get(entry, adminv1alpha1.APIIdentityOwnerMissing)) require.Empty(t, f.requeued) } @@ -246,8 +254,6 @@ func TestGoneOwnerPassesToOldestHolderHostedHere(t *testing.T) { require.Len(t, entry.Status.Holders, 1) require.Equal(t, ref(newer), entry.Status.Holders[0].ExportReference) require.True(t, conditions.IsTrue(entry, adminv1alpha1.APIIdentityShared)) - require.False(t, conditions.IsTrue(entry, adminv1alpha1.APIIdentityOrphaned)) - require.Equal(t, adminv1alpha1.APIIdentityOwnerSucceededReason, conditions.GetReason(entry, adminv1alpha1.APIIdentityOrphaned)) require.Nil(t, conditions.Get(entry, adminv1alpha1.APIIdentityOwnerMissing)) require.Empty(t, f.requeued) } @@ -283,7 +289,7 @@ func TestSuccessorHostedElsewhereIsLeftToItsShard(t *testing.T) { require.True(t, conditions.IsTrue(entry, adminv1alpha1.APIIdentityOwnerMissing)) } -func TestGoneOwnerWithoutHoldersIsOrphaned(t *testing.T) { +func TestGoneOwnerWithoutHoldersIsRemoved(t *testing.T) { t.Parallel() f := newFixture(t) @@ -292,16 +298,13 @@ func TestGoneOwnerWithoutHoldersIsOrphaned(t *testing.T) { f.entry(owner, dead) // neither is in the cache f.ownerMissingFor(MissingGracePeriod) - entry := f.reconcile() - require.True(t, conditions.IsTrue(entry, adminv1alpha1.APIIdentityOrphaned)) - require.Empty(t, entry.Status.Holders) - require.Nil(t, conditions.Get(entry, adminv1alpha1.APIIdentityShared)) - require.Nil(t, conditions.Get(entry, adminv1alpha1.APIIdentityOwnerMissing)) - - // an orphaned entry is left alone until somebody reclaims it - entry = f.reconcile() - require.True(t, conditions.IsTrue(entry, adminv1alpha1.APIIdentityOrphaned)) + require.NoError(t, f.c.reconcile(context.Background(), identity)) + _, err := f.registry.Get(context.Background(), identity, metav1.GetOptions{}) + require.True(t, apierrors.IsNotFound(err), "the entry must be gone, got %v", err) require.Empty(t, f.requeued) + + // a gone entry is nothing to reconcile + require.NoError(t, f.c.reconcile(context.Background(), identity)) } func TestRecreatedOwnerIsNotGone(t *testing.T) { @@ -324,7 +327,6 @@ func TestRecreatedOwnerIsNotGone(t *testing.T) { require.Equal(t, "new-uid", entry.Status.Owner.UID) require.Len(t, entry.Status.Holders, 1) require.Nil(t, conditions.Get(entry, adminv1alpha1.APIIdentityOwnerMissing)) - require.False(t, conditions.IsTrue(entry, adminv1alpha1.APIIdentityOrphaned)) } func TestMaintainDropsHoldersGoneFromTheCache(t *testing.T) { diff --git a/pkg/reconciler/admin/apiidentity/registry.go b/pkg/reconciler/admin/apiidentity/registry.go index 922f43b29b7..45b6c22f688 100644 --- a/pkg/reconciler/admin/apiidentity/registry.go +++ b/pkg/reconciler/admin/apiidentity/registry.go @@ -32,6 +32,7 @@ import ( type adminv1alpha1client interface { Get(ctx context.Context, name string, opts metav1.GetOptions) (*adminv1alpha1.APIIdentity, error) Update(ctx context.Context, entry *adminv1alpha1.APIIdentity, opts metav1.UpdateOptions) (*adminv1alpha1.APIIdentity, error) + Delete(ctx context.Context, name string, opts metav1.DeleteOptions) error } // registryScoped addresses the registry's shard scope on the cache server. @@ -46,3 +47,7 @@ func (r registryScoped) Get(ctx context.Context, name string, opts metav1.GetOpt func (r registryScoped) Update(ctx context.Context, entry *adminv1alpha1.APIIdentity, opts metav1.UpdateOptions) (*adminv1alpha1.APIIdentity, error) { return r.adminv1alpha1client.Update(cacheclient.WithShardInContext(ctx, shard.Registry), entry, opts) } + +func (r registryScoped) Delete(ctx context.Context, name string, opts metav1.DeleteOptions) error { + return r.adminv1alpha1client.Delete(cacheclient.WithShardInContext(ctx, shard.Registry), name, opts) +} diff --git a/pkg/reconciler/apis/apiexport/apiexport_reconcile_identity.go b/pkg/reconciler/apis/apiexport/apiexport_reconcile_identity.go index 202db8034cc..b34433e66e1 100644 --- a/pkg/reconciler/apis/apiexport/apiexport_reconcile_identity.go +++ b/pkg/reconciler/apis/apiexport/apiexport_reconcile_identity.go @@ -93,8 +93,7 @@ func (c *controller) reconcileIdentity(ctx context.Context, apiExport *apisv1alp } } - switch { - case entry.Status.Owner.Equal(me): + if entry.Status.Owner.Equal(me) { // the owner, possibly recreated under the same name (GitOps): keep // the registry's UID current. if entry.Status.Owner.UID != me.UID { @@ -104,16 +103,6 @@ func (c *controller) reconcileIdentity(ctx context.Context, apiExport *apisv1alp } c.setIdentity(apiExport, hash) return nil - - case conditions.IsTrue(entry, adminv1alpha1.APIIdentityOrphaned): - // nobody owns it and nobody holds it; whoever has the key may take it - // over, and we do, since we produced the identity from the key. - if err := c.setAPIIdentityOwner(ctx, entry.Name, me); err != nil { - return c.identityClaimFailed(apiExport, err) - } - logger.V(2).Info("reclaimed orphaned identity", "identity", hash) - c.setIdentity(apiExport, hash) - return nil } // a holder: whoever has the key serves the identity, as always; the diff --git a/pkg/reconciler/apis/apiexport/apiexport_reconcile_identity_test.go b/pkg/reconciler/apis/apiexport/apiexport_reconcile_identity_test.go index 9f4ec799e3c..8f2f3c32cfb 100644 --- a/pkg/reconciler/apis/apiexport/apiexport_reconcile_identity_test.go +++ b/pkg/reconciler/apis/apiexport/apiexport_reconcile_identity_test.go @@ -163,22 +163,6 @@ func TestReconcileIdentity(t *testing.T) { require.Len(t, f.registry[identity].Status.Holders, 1, "recorded once") }) - t.Run("an orphaned identity is reclaimed by whoever holds the key", func(t *testing.T) { - t.Parallel() - f := newIdentityFixture() - f.secret(holderCluster, "networking-v2", key) - entry := &adminv1alpha1.APIIdentity{ObjectMeta: metav1.ObjectMeta{Name: identity}, Status: adminv1alpha1.APIIdentityStatus{Owner: adminv1alpha1.ExportReference{Cluster: ownerCluster, Name: "networking"}}} - conditions.MarkTrue(entry, adminv1alpha1.APIIdentityOrphaned) - f.registry[identity] = entry - c := f.controller() - - ae := export(holderCluster, "networking-v2", "h") - require.NoError(t, c.reconcileIdentity(ctx, ae)) - - require.Equal(t, identity, ae.Status.IdentityHash) - require.Equal(t, holderCluster, f.registry[identity].Status.Owner.Cluster) - }) - t.Run("a secret edited in place fails verification", func(t *testing.T) { t.Parallel() f := newIdentityFixture() diff --git a/staging/src/github.com/kcp-dev/sdk/apis/admin/v1alpha1/types_apiidentity.go b/staging/src/github.com/kcp-dev/sdk/apis/admin/v1alpha1/types_apiidentity.go index 66daeb718d2..9ac5444c40d 100644 --- a/staging/src/github.com/kcp-dev/sdk/apis/admin/v1alpha1/types_apiidentity.go +++ b/staging/src/github.com/kcp-dev/sdk/apis/admin/v1alpha1/types_apiidentity.go @@ -27,18 +27,8 @@ import ( // APIExport owns the identity and which other APIExports hold it. There is // exactly one entry per identity in an installation, named after the // identity value, living on the cache server where every shard can read it -// and served read-only through the Admin workspace. -// -// Entries are written by kcp only. The owner is recorded when the identity -// is first claimed, which is the atomic create of the entry; holders record -// themselves when they get the identity. When the owner's APIExport is -// deleted the identity passes to the oldest holder. Nothing that could be -// used to obtain the identity is recorded here, only who has it. -// -// The object has a status field but no status subresource: it lives on the -// cache server, whose status subresource does not honor shard scopes, and it -// is written by kcp only, so spec and status are written together in one -// update of the whole object. +// and served read-only through the Admin workspace. Entries are written by +// kcp only. // // +crd // +genclient @@ -103,7 +93,8 @@ type APIIdentityStatus struct { // owner is the APIExport that holds the identity authoritatively: the // first one to use it. It is written by kcp when the identity is claimed // and changed by kcp only when the owner is deleted and a holder - // succeeds it, or an orphaned identity is reclaimed. + // succeeds it. An entry whose owner is gone and that has no holder left + // is removed. // // +required Owner ExportReference `json:"owner"` @@ -135,19 +126,10 @@ const ( // APIIdentityShared is True while holders is non-empty. APIIdentityShared conditionsv1alpha1.ConditionType = "Shared" - // APIIdentityOrphaned is True while the owner APIExport is gone and no - // holder could succeed it. An orphaned identity can be reclaimed only - // with the secret it was created from. - APIIdentityOrphaned conditionsv1alpha1.ConditionType = "Orphaned" - // APIIdentityOwnerMissing is True while the owner APIExport is not in // the cache server. Its transition time starts the grace period after // which the owner is taken as gone and the identity passes on. APIIdentityOwnerMissing conditionsv1alpha1.ConditionType = "OwnerMissing" - - // APIIdentityOwnerSucceededReason is set on the Orphaned condition - // (False) after the identity passed to a holder. - APIIdentityOwnerSucceededReason = "OwnerSucceeded" ) func (in *APIIdentity) SetConditions(c conditionsv1alpha1.Conditions) { diff --git a/staging/src/github.com/kcp-dev/sdk/client/applyconfiguration/admin/v1alpha1/apiidentity.go b/staging/src/github.com/kcp-dev/sdk/client/applyconfiguration/admin/v1alpha1/apiidentity.go index 15481b809c8..8836f17a93d 100644 --- a/staging/src/github.com/kcp-dev/sdk/client/applyconfiguration/admin/v1alpha1/apiidentity.go +++ b/staging/src/github.com/kcp-dev/sdk/client/applyconfiguration/admin/v1alpha1/apiidentity.go @@ -32,18 +32,8 @@ import ( // APIExport owns the identity and which other APIExports hold it. There is // exactly one entry per identity in an installation, named after the // identity value, living on the cache server where every shard can read it -// and served read-only through the Admin workspace. -// -// Entries are written by kcp only. The owner is recorded when the identity -// is first claimed, which is the atomic create of the entry; holders record -// themselves when they get the identity. When the owner's APIExport is -// deleted the identity passes to the oldest holder. Nothing that could be -// used to obtain the identity is recorded here, only who has it. -// -// The object has a status field but no status subresource: it lives on the -// cache server, whose status subresource does not honor shard scopes, and it -// is written by kcp only, so spec and status are written together in one -// update of the whole object. +// and served read-only through the Admin workspace. Entries are written by +// kcp only. type APIIdentityApplyConfiguration struct { v1.TypeMetaApplyConfiguration `json:",inline"` *v1.ObjectMetaApplyConfiguration `json:"metadata,omitempty"` diff --git a/staging/src/github.com/kcp-dev/sdk/client/applyconfiguration/admin/v1alpha1/apiidentitystatus.go b/staging/src/github.com/kcp-dev/sdk/client/applyconfiguration/admin/v1alpha1/apiidentitystatus.go index 6918f9fbf2c..15a47ce410f 100644 --- a/staging/src/github.com/kcp-dev/sdk/client/applyconfiguration/admin/v1alpha1/apiidentitystatus.go +++ b/staging/src/github.com/kcp-dev/sdk/client/applyconfiguration/admin/v1alpha1/apiidentitystatus.go @@ -31,7 +31,8 @@ type APIIdentityStatusApplyConfiguration struct { // owner is the APIExport that holds the identity authoritatively: the // first one to use it. It is written by kcp when the identity is claimed // and changed by kcp only when the owner is deleted and a holder - // succeeds it, or an orphaned identity is reclaimed. + // succeeds it. An entry whose owner is gone and that has no holder left + // is removed. Owner *ExportReferenceApplyConfiguration `json:"owner,omitempty"` // holders lists the APIExports, other than the owner, that serve the // identity: those whose identity secret carries the same key. A holder diff --git a/staging/src/github.com/kcp-dev/sdk/openapi/zz_generated.openapi.go b/staging/src/github.com/kcp-dev/sdk/openapi/zz_generated.openapi.go index 8706f2ba2b8..eba980cfd98 100644 --- a/staging/src/github.com/kcp-dev/sdk/openapi/zz_generated.openapi.go +++ b/staging/src/github.com/kcp-dev/sdk/openapi/zz_generated.openapi.go @@ -239,7 +239,7 @@ func schema_sdk_apis_admin_v1alpha1_APIIdentity(ref common.ReferenceCallback) co return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ - Description: "APIIdentity is the registry entry of one API identity: the record of which APIExport owns the identity and which other APIExports hold it. There is exactly one entry per identity in an installation, named after the identity value, living on the cache server where every shard can read it and served read-only through the Admin workspace.\n\nEntries are written by kcp only. The owner is recorded when the identity is first claimed, which is the atomic create of the entry; holders record themselves when they get the identity. When the owner's APIExport is deleted the identity passes to the oldest holder. Nothing that could be used to obtain the identity is recorded here, only who has it.\n\nThe object has a status field but no status subresource: it lives on the cache server, whose status subresource does not honor shard scopes, and it is written by kcp only, so spec and status are written together in one update of the whole object.", + Description: "APIIdentity is the registry entry of one API identity: the record of which APIExport owns the identity and which other APIExports hold it. There is exactly one entry per identity in an installation, named after the identity value, living on the cache server where every shard can read it and served read-only through the Admin workspace. Entries are written by kcp only.", Type: []string{"object"}, Properties: map[string]spec.Schema{ "kind": { @@ -334,7 +334,7 @@ func schema_sdk_apis_admin_v1alpha1_APIIdentityStatus(ref common.ReferenceCallba Properties: map[string]spec.Schema{ "owner": { SchemaProps: spec.SchemaProps{ - Description: "owner is the APIExport that holds the identity authoritatively: the first one to use it. It is written by kcp when the identity is claimed and changed by kcp only when the owner is deleted and a holder succeeds it, or an orphaned identity is reclaimed.", + Description: "owner is the APIExport that holds the identity authoritatively: the first one to use it. It is written by kcp when the identity is claimed and changed by kcp only when the owner is deleted and a holder succeeds it. An entry whose owner is gone and that has no holder left is removed.", Default: map[string]interface{}{}, Ref: ref(v1alpha1.ExportReference{}.OpenAPIModelName()), }, diff --git a/test/e2e/apibinding/apiexport_identity_registry_test.go b/test/e2e/apibinding/apiexport_identity_registry_test.go index 28123bd14fa..dcebeeb6b01 100644 --- a/test/e2e/apibinding/apiexport_identity_registry_test.go +++ b/test/e2e/apibinding/apiexport_identity_registry_test.go @@ -54,8 +54,9 @@ import ( // TestAPIExportIdentityRegistry exercises the identity registry: the first // APIExport to use an identity owns it, another APIExport with a copy of the // key shares the identity and is recorded as a holder, the oldest holder -// inherits the identity when the owner is deleted, and an identity nobody -// serves any more is orphaned and reclaimed by the next export with the key. +// inherits the identity when the owner is deleted, the entry is gone once +// the last export is deleted, and the next export with the key registers +// the identity anew. func TestAPIExportIdentityRegistry(t *testing.T) { t.Parallel() framework.Suite(t, "control-plane") @@ -184,27 +185,28 @@ func TestAPIExportIdentityRegistry(t *testing.T) { if !entry.Status.Owner.Equal(exportRef(holderWS, holder)) { return false, fmt.Sprintf("owner=%v", entry.Status.Owner) } - return len(entry.Status.Holders) == 0 && !conditions.IsTrue(entry, adminv1alpha1.APIIdentityOrphaned), - fmt.Sprintf("holders=%v orphaned=%v", entry.Status.Holders, conditions.Get(entry, adminv1alpha1.APIIdentityOrphaned)) + return len(entry.Status.Holders) == 0 && conditions.Get(entry, adminv1alpha1.APIIdentityOwnerMissing) == nil, + fmt.Sprintf("holders=%v ownerMissing=%v", entry.Status.Holders, conditions.Get(entry, adminv1alpha1.APIIdentityOwnerMissing)) }) holder, err = getExport(holderPath) require.NoError(t, err) require.Equal(t, identity, holder.Status.IdentityHash, "the identity does not change hands' value") require.True(t, conditions.IsTrue(holder, apisv1alpha2.APIExportIdentityValid)) - t.Logf("Delete the last export: the identity is orphaned") + t.Logf("Delete the last export: the identity is gone from the registry") + copyKey(holderPath, newcomerPath) // keep the key around for the newcomer require.NoError(t, kcpClusterClient.Cluster(holderPath).ApisV1alpha2().APIExports().Delete(t.Context(), "wildwest", metav1.DeleteOptions{})) - waitForEntry(identity, afterGrace, func(entry *adminv1alpha1.APIIdentity) (bool, string) { - return conditions.IsTrue(entry, adminv1alpha1.APIIdentityOrphaned), fmt.Sprintf("orphaned=%v", conditions.Get(entry, adminv1alpha1.APIIdentityOrphaned)) - }) + kcptestinghelpers.Eventually(t, func() (bool, string) { + _, err := adminClient.AdminV1alpha1().APIIdentities().Get(t.Context(), identity, metav1.GetOptions{}) + return apierrors.IsNotFound(err), fmt.Sprintf("get: %v", err) + }, afterGrace, 250*time.Millisecond) - t.Logf("A newcomer with the key reclaims the orphaned identity") - copyKey(holderPath, newcomerPath) + t.Logf("A newcomer with the key registers the identity anew") createWithKey(newcomerPath) newcomer := waitForIdentity(newcomerPath) - require.Equal(t, identity, newcomer.Status.IdentityHash) + require.Equal(t, identity, newcomer.Status.IdentityHash, "same key, same identity") waitForEntry(identity, wait.ForeverTestTimeout, func(entry *adminv1alpha1.APIIdentity) (bool, string) { - return entry.Status.Owner == exportRef(newcomerWS, newcomer) && conditions.Get(entry, adminv1alpha1.APIIdentityOrphaned) == nil, - fmt.Sprintf("owner=%v orphaned=%v", entry.Status.Owner, conditions.Get(entry, adminv1alpha1.APIIdentityOrphaned)) + return entry.Status.Owner == exportRef(newcomerWS, newcomer) && len(entry.Status.Holders) == 0, + fmt.Sprintf("owner=%v holders=%v", entry.Status.Owner, entry.Status.Holders) }) } From a8b0d9c36140caded02635ee09efaed12e2e4480 Mon Sep 17 00:00:00 2001 From: Mangirdas Judeikis Date: Fri, 23 Oct 2026 16:48:57 +0300 Subject: [PATCH 3/7] add print col --- config/crds/admin.kcp.io_apiidentities.yaml | 9 +++++++-- docs/content/concepts/sharding/shards.md | 6 +++--- .../kcp-dev/sdk/apis/admin/v1alpha1/types_apiidentity.go | 3 ++- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/config/crds/admin.kcp.io_apiidentities.yaml b/config/crds/admin.kcp.io_apiidentities.yaml index 7749d8faf8b..6be6a0e5aab 100644 --- a/config/crds/admin.kcp.io_apiidentities.yaml +++ b/config/crds/admin.kcp.io_apiidentities.yaml @@ -25,8 +25,13 @@ spec: jsonPath: .status.owner.cluster name: Cluster type: string - - jsonPath: .status.conditions[?(@.type=="Shared")].status - name: Holders + - description: Identity of a core APIExport in root + jsonPath: .status.core + name: Core + type: boolean + - description: True while other APIExports hold the identity + jsonPath: .status.conditions[?(@.type=="Shared")].status + name: Shared priority: 1 type: string - jsonPath: .metadata.creationTimestamp diff --git a/docs/content/concepts/sharding/shards.md b/docs/content/concepts/sharding/shards.md index 93e3699c011..b821e75be9f 100644 --- a/docs/content/concepts/sharding/shards.md +++ b/docs/content/concepts/sharding/shards.md @@ -116,9 +116,9 @@ other exports holding it and whether it is a core identity. See ```sh $ kubectl get apiidentities -NAME OWNER CLUSTER AGE -0e2fc4327ee7af2b2b9a0f2b4b1a8c4c7a0d6c2f0f1b6e5a9d8c7b6a5f4e3d2c tenancy.kcp.io root 3d -5fdf7c7aaf407fd1594566869803f565bb84d22156cef5c445d2ee13ac2cfca6 widgets 2cynbfy2m0wtjqcs 2h +NAME OWNER CLUSTER CORE AGE +0e2fc4327ee7af2b2b9a0f2b4b1a8c4c7a0d6c2f0f1b6e5a9d8c7b6a5f4e3d2c tenancy.kcp.io root true 3d +5fdf7c7aaf407fd1594566869803f565bb84d22156cef5c445d2ee13ac2cfca6 widgets 2cynbfy2m0wtjqcs false 2h ``` Access requires membership in the `system:kcp:admin` group; the serving diff --git a/staging/src/github.com/kcp-dev/sdk/apis/admin/v1alpha1/types_apiidentity.go b/staging/src/github.com/kcp-dev/sdk/apis/admin/v1alpha1/types_apiidentity.go index 9ac5444c40d..2a78d2e7cbe 100644 --- a/staging/src/github.com/kcp-dev/sdk/apis/admin/v1alpha1/types_apiidentity.go +++ b/staging/src/github.com/kcp-dev/sdk/apis/admin/v1alpha1/types_apiidentity.go @@ -38,7 +38,8 @@ import ( // +kubebuilder:resource:scope=Cluster,categories=kcp // +kubebuilder:printcolumn:name="Owner",type=string,JSONPath=`.status.owner.name`,description="APIExport owning the identity" // +kubebuilder:printcolumn:name="Cluster",type=string,JSONPath=`.status.owner.cluster`,description="Logical cluster of the owner" -// +kubebuilder:printcolumn:name="Holders",type=string,JSONPath=`.status.conditions[?(@.type=="Shared")].status`,priority=1 +// +kubebuilder:printcolumn:name="Core",type=boolean,JSONPath=`.status.core`,description="Identity of a core APIExport in root" +// +kubebuilder:printcolumn:name="Shared",type=string,JSONPath=`.status.conditions[?(@.type=="Shared")].status`,priority=1,description="True while other APIExports hold the identity" // +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" type APIIdentity struct { metav1.TypeMeta `json:",inline"` From f6ecc752d94abfdc96c7156c8e9646944b8babee Mon Sep 17 00:00:00 2001 From: Mangirdas Judeikis Date: Mon, 26 Oct 2026 08:35:29 +0200 Subject: [PATCH 4/7] Add graceperiod and labels --- docs/content/concepts/apis/exporting-apis.md | 2 +- docs/content/concepts/sharding/shards.md | 3 ++ .../admin/apiidentity/controller.go | 34 ++++++++++++------- .../admin/apiidentity/controller_test.go | 28 +++++++++------ .../apiexport/apiexport_reconcile_identity.go | 1 + pkg/server/controllers.go | 2 +- pkg/server/options/options.go | 13 +++++-- .../apis/admin/v1alpha1/types_apiidentity.go | 21 ++++++++++++ .../apiexport_identity_registry_test.go | 10 +++--- test/e2e/virtual/admin/apiidentities_test.go | 15 ++++++++ 10 files changed, 97 insertions(+), 32 deletions(-) diff --git a/docs/content/concepts/apis/exporting-apis.md b/docs/content/concepts/apis/exporting-apis.md index e3a4df6c500..32d65f0d98a 100644 --- a/docs/content/concepts/apis/exporting-apis.md +++ b/docs/content/concepts/apis/exporting-apis.md @@ -187,7 +187,7 @@ identity a lifecycle of its own. When the owner's `APIExport` is deleted, the identity passes to the oldest holder. The decision is made from the `APIExports` replicated to the cache server: an owner missing from the cache is marked `OwnerMissing`, and after a -grace period it is taken as gone. An identity with no owner and no holder left is removed from the registry; the +grace period (an hour by default, `--api-identity-missing-grace-period`) it is taken as gone. An identity with no owner and no holder left is removed from the registry; the next `APIExport` whose secret carries the original `key` registers it anew. `status.identityHash` is set once the identity is recorded in the registry and is immutable afterwards. diff --git a/docs/content/concepts/sharding/shards.md b/docs/content/concepts/sharding/shards.md index b821e75be9f..3d649480f70 100644 --- a/docs/content/concepts/sharding/shards.md +++ b/docs/content/concepts/sharding/shards.md @@ -119,6 +119,9 @@ $ kubectl get apiidentities NAME OWNER CLUSTER CORE AGE 0e2fc4327ee7af2b2b9a0f2b4b1a8c4c7a0d6c2f0f1b6e5a9d8c7b6a5f4e3d2c tenancy.kcp.io root true 3d 5fdf7c7aaf407fd1594566869803f565bb84d22156cef5c445d2ee13ac2cfca6 widgets 2cynbfy2m0wtjqcs false 2h +$ kubectl get apiidentities -l admin.kcp.io/core=true # the core identities +$ kubectl get apiidentities -l admin.kcp.io/owner-cluster=root # owned by exports in root +$ kubectl get apiidentities -l admin.kcp.io/owner=widgets # owned by an export named widgets ``` Access requires membership in the `system:kcp:admin` group; the serving diff --git a/pkg/reconciler/admin/apiidentity/controller.go b/pkg/reconciler/admin/apiidentity/controller.go index cf9b8f96ea2..fa1023d9e13 100644 --- a/pkg/reconciler/admin/apiidentity/controller.go +++ b/pkg/reconciler/admin/apiidentity/controller.go @@ -60,11 +60,12 @@ import ( const ( ControllerName = "kcp-apiidentity" - // MissingGracePeriod is how long an export may be missing from the cache - // server before it is taken as gone. It covers the window between an - // entry being written and the export's replicated copy arriving, and a - // copy disappearing for a moment, as during a workspace migration. - MissingGracePeriod = 30 * time.Second + // DefaultMissingGracePeriod is the default for how long an export may be + // missing from the cache server before it is taken as gone. It has to + // cover the window between an entry being written and the export's + // replicated copy arriving, and a copy disappearing for a while, as + // during a workspace migration or a cache server rebuild. + DefaultMissingGracePeriod = 60 * time.Minute ) // NewController creates the registry controller of this shard. @@ -73,6 +74,7 @@ func NewController( apiIdentityInformer adminv1alpha1informers.APIIdentityClusterInformer, apiExportInformer, globalAPIExportInformer apisv1alpha2informers.APIExportClusterInformer, isCore func(*apisv1alpha2.APIExport) bool, + missingGracePeriod time.Duration, ) (*Controller, error) { queue := workqueue.NewTypedRateLimitingQueueWithConfig( workqueue.DefaultTypedControllerRateLimiter[string](), @@ -92,6 +94,7 @@ func NewController( }, requeueAfter: queue.AddAfter, isCore: isCore, + gracePeriod: missingGracePeriod, } _, _ = apiIdentityInformer.Informer().AddEventHandler(events.WithoutSyncs(cache.ResourceEventHandlerFuncs{ @@ -135,6 +138,7 @@ type Controller struct { getGlobalAPIExport func(clusterName logicalcluster.Name, name string) (*apisv1alpha2.APIExport, error) requeueAfter func(identity string, delay time.Duration) isCore func(*apisv1alpha2.APIExport) bool + gracePeriod time.Duration } func (c *Controller) enqueue(identity string) { @@ -215,12 +219,13 @@ func (c *Controller) maintain(ctx context.Context, entry *adminv1alpha1.APIIdent } hostsOwner := err == nil - return c.updateStatus(ctx, entry.Name, func(entry *adminv1alpha1.APIIdentity) { + return c.update(ctx, entry.Name, func(entry *adminv1alpha1.APIIdentity) { conditions.Delete(entry, adminv1alpha1.APIIdentityOwnerMissing) if !hostsOwner { return } entry.Status.Core = c.isCore(owner) + entry.Labels = adminv1alpha1.Labels(entry.Status.Owner, entry.Status.Core) // an owner that succeeded from holder is not a holder any more dropHolders(entry, append(c.goneHolders(entry), ref(owner))) }) @@ -237,15 +242,15 @@ func (c *Controller) ownerMissing(ctx context.Context, entry *adminv1alpha1.APII missing := conditions.Get(entry, adminv1alpha1.APIIdentityOwnerMissing) if missing == nil || missing.Status != corev1.ConditionTrue { logger.V(2).Info("owner of identity is not in the cache, waiting for the grace period", "owner", entry.Status.Owner.Cluster+"|"+entry.Status.Owner.Name) - if err := c.updateStatus(ctx, entry.Name, func(entry *adminv1alpha1.APIIdentity) { + if err := c.update(ctx, entry.Name, func(entry *adminv1alpha1.APIIdentity) { conditions.MarkTrue(entry, adminv1alpha1.APIIdentityOwnerMissing) }); err != nil { return err } - c.requeueAfter(entry.Name, MissingGracePeriod) + c.requeueAfter(entry.Name, c.gracePeriod) return nil } - if remaining := MissingGracePeriod - time.Since(missing.LastTransitionTime.Time); remaining > 0 { + if remaining := c.gracePeriod - time.Since(missing.LastTransitionTime.Time); remaining > 0 { c.requeueAfter(entry.Name, remaining) return nil } @@ -268,8 +273,9 @@ func (c *Controller) ownerMissing(ctx context.Context, entry *adminv1alpha1.APII } logger.Info("owner of identity is gone, passing it to the oldest holder", "successor", successor.Cluster+"|"+successor.Name) - return c.updateStatus(ctx, entry.Name, func(entry *adminv1alpha1.APIIdentity) { + return c.update(ctx, entry.Name, func(entry *adminv1alpha1.APIIdentity) { entry.Status.Owner = successor.ExportReference + entry.Labels = adminv1alpha1.Labels(entry.Status.Owner, entry.Status.Core) dropHolders(entry, []adminv1alpha1.ExportReference{successor.ExportReference}) conditions.Delete(entry, adminv1alpha1.APIIdentityOwnerMissing) }) @@ -298,7 +304,7 @@ func (c *Controller) oldestHolderInCache(entry *adminv1alpha1.APIIdentity) *admi func (c *Controller) goneHolders(entry *adminv1alpha1.APIIdentity) []adminv1alpha1.ExportReference { var gone []adminv1alpha1.ExportReference for _, holder := range entry.Status.Holders { - if time.Since(holder.Since.Time) < MissingGracePeriod { + if time.Since(holder.Since.Time) < c.gracePeriod { continue } if _, err := c.getGlobalAPIExport(logicalcluster.Name(holder.Cluster), holder.Name); apierrors.IsNotFound(err) { @@ -321,7 +327,9 @@ func dropHolders(entry *adminv1alpha1.APIIdentity, refs []adminv1alpha1.ExportRe } } -func (c *Controller) updateStatus(ctx context.Context, name string, mutate func(*adminv1alpha1.APIIdentity)) error { +// update applies mutate to the live entry and writes it when its status or +// labels changed. +func (c *Controller) update(ctx context.Context, name string, mutate func(*adminv1alpha1.APIIdentity)) error { return retry.RetryOnConflict(retry.DefaultBackoff, func() error { current, err := c.registry.Get(ctx, name, metav1.GetOptions{}) if err != nil { @@ -329,7 +337,7 @@ func (c *Controller) updateStatus(ctx context.Context, name string, mutate func( } desired := current.DeepCopy() mutate(desired) - if equality.Semantic.DeepEqual(current.Status, desired.Status) { + if equality.Semantic.DeepEqual(current.Status, desired.Status) && equality.Semantic.DeepEqual(current.Labels, desired.Labels) { return nil } _, err = c.registry.Update(ctx, desired, metav1.UpdateOptions{}) diff --git a/pkg/reconciler/admin/apiidentity/controller_test.go b/pkg/reconciler/admin/apiidentity/controller_test.go index e1be4fadb39..2f2f718e326 100644 --- a/pkg/reconciler/admin/apiidentity/controller_test.go +++ b/pkg/reconciler/admin/apiidentity/controller_test.go @@ -35,7 +35,10 @@ import ( "github.com/kcp-dev/sdk/apis/third_party/conditions/util/conditions" ) -const identity = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +const ( + identity = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + grace = time.Minute +) // fakeRegistry is an in-memory APIIdentity store. type fakeRegistry struct { @@ -111,6 +114,7 @@ func newFixture(t *testing.T) *fixture { getGlobalAPIExport: lookup(f.global), requeueAfter: func(_ string, delay time.Duration) { f.requeued = append(f.requeued, delay) }, isCore: func(*apisv1alpha2.APIExport) bool { return false }, + gracePeriod: grace, } return f } @@ -148,7 +152,7 @@ func (f *fixture) entry(owner *apisv1alpha2.APIExport, holders ...*apisv1alpha2. for i, h := range holders { e.Status.Holders = append(e.Status.Holders, adminv1alpha1.Holder{ ExportReference: ref(h), - Since: metav1.NewTime(time.Now().Add(-3*MissingGracePeriod + time.Duration(i)*time.Second)), + Since: metav1.NewTime(time.Now().Add(-3*grace + time.Duration(i)*time.Second)), }) } if len(holders) > 0 { @@ -193,6 +197,7 @@ func TestHostedOwnerIsMaintained(t *testing.T) { entry := f.reconcile() require.True(t, entry.Status.Core) + require.Equal(t, adminv1alpha1.Labels(ref(owner), true), entry.Labels) require.Len(t, entry.Status.Holders, 1) require.True(t, conditions.IsTrue(entry, adminv1alpha1.APIIdentityShared)) require.Nil(t, conditions.Get(entry, adminv1alpha1.APIIdentityOwnerMissing)) @@ -227,14 +232,14 @@ func TestOwnerMissingStartsTheGracePeriod(t *testing.T) { require.Equal(t, ref(owner), entry.Status.Owner, "ownership must not move before the grace period") require.True(t, conditions.IsTrue(entry, adminv1alpha1.APIIdentityOwnerMissing)) require.Len(t, entry.Status.Holders, 1) - require.Equal(t, []time.Duration{MissingGracePeriod}, f.requeued) + require.Equal(t, []time.Duration{grace}, f.requeued) // still within the grace period: wait for the rest of it - f.ownerMissingFor(MissingGracePeriod / 4) + f.ownerMissingFor(grace / 4) entry = f.reconcile() require.Equal(t, ref(owner), entry.Status.Owner) require.Len(t, f.requeued, 2) - require.InDelta(t, 3*MissingGracePeriod/4, f.requeued[1], float64(time.Second), "requeued for the rest of the grace period") + require.InDelta(t, 3*grace/4, f.requeued[1], float64(time.Second), "requeued for the rest of the grace period") } func TestGoneOwnerPassesToOldestHolderHostedHere(t *testing.T) { @@ -247,10 +252,11 @@ func TestGoneOwnerPassesToOldestHolderHostedHere(t *testing.T) { f.host(oldest) f.elsewhere(newer) f.entry(owner, oldest, newer) - f.ownerMissingFor(MissingGracePeriod) + f.ownerMissingFor(grace) entry := f.reconcile() require.Equal(t, ref(oldest), entry.Status.Owner, "the identity passes to the oldest holder") + require.Equal(t, adminv1alpha1.Labels(ref(oldest), false), entry.Labels, "the labels follow the owner") require.Len(t, entry.Status.Holders, 1) require.Equal(t, ref(newer), entry.Status.Holders[0].ExportReference) require.True(t, conditions.IsTrue(entry, adminv1alpha1.APIIdentityShared)) @@ -267,7 +273,7 @@ func TestGoneOwnerSkipsHoldersNotInTheCache(t *testing.T) { alive := export("consumer-b", "wildwest", "b-uid") f.host(alive) f.entry(owner, oldestButGone, alive) - f.ownerMissingFor(MissingGracePeriod) + f.ownerMissingFor(grace) entry := f.reconcile() require.Equal(t, ref(alive), entry.Status.Owner, "a holder that is not in the cache cannot succeed") @@ -281,7 +287,7 @@ func TestSuccessorHostedElsewhereIsLeftToItsShard(t *testing.T) { remote := export("consumer-a", "wildwest", "a-uid") f.elsewhere(remote) f.entry(owner, remote) - f.ownerMissingFor(MissingGracePeriod) + f.ownerMissingFor(grace) entry := f.reconcile() require.Equal(t, ref(owner), entry.Status.Owner, "the successor's shard performs the succession") @@ -296,7 +302,7 @@ func TestGoneOwnerWithoutHoldersIsRemoved(t *testing.T) { owner := export("provider", "wildwest", "owner-uid") dead := export("consumer", "wildwest", "holder-uid") f.entry(owner, dead) // neither is in the cache - f.ownerMissingFor(MissingGracePeriod) + f.ownerMissingFor(grace) require.NoError(t, f.c.reconcile(context.Background(), identity)) _, err := f.registry.Get(context.Background(), identity, metav1.GetOptions{}) @@ -315,7 +321,7 @@ func TestRecreatedOwnerIsNotGone(t *testing.T) { holder := export("consumer", "wildwest", "holder-uid") f.host(holder) f.entry(owner, holder) - f.ownerMissingFor(MissingGracePeriod) + f.ownerMissingFor(grace) // the owner was recreated under its name and the registry already // records the new UID; its copy is back in the cache @@ -348,7 +354,7 @@ func TestMaintainDropsHoldersGoneFromTheCache(t *testing.T) { require.Equal(t, ref(young), entry.Status.Holders[1].ExportReference, "a holder within its grace period is kept") require.True(t, conditions.IsTrue(entry, adminv1alpha1.APIIdentityShared)) - f.registry.entries[identity].Status.Holders[1].Since = metav1.NewTime(time.Now().Add(-MissingGracePeriod)) + f.registry.entries[identity].Status.Holders[1].Since = metav1.NewTime(time.Now().Add(-grace)) entry = f.reconcile() require.Len(t, entry.Status.Holders, 1, "past the grace period a holder missing from the cache is dropped") require.Equal(t, ref(alive), entry.Status.Holders[0].ExportReference) diff --git a/pkg/reconciler/apis/apiexport/apiexport_reconcile_identity.go b/pkg/reconciler/apis/apiexport/apiexport_reconcile_identity.go index b34433e66e1..77b5b5d4073 100644 --- a/pkg/reconciler/apis/apiexport/apiexport_reconcile_identity.go +++ b/pkg/reconciler/apis/apiexport/apiexport_reconcile_identity.go @@ -74,6 +74,7 @@ func (c *controller) reconcileIdentity(ctx context.Context, apiExport *apisv1alp entry, err = c.createAPIIdentity(ctx, &adminv1alpha1.APIIdentity{ ObjectMeta: metav1.ObjectMeta{ Name: hash, + Labels: adminv1alpha1.Labels(me, false), // core is decided by the registry controller Annotations: map[string]string{logicalcluster.AnnotationKey: configshard.SystemShardCluster.String()}, }, Status: adminv1alpha1.APIIdentityStatus{Owner: me}, diff --git a/pkg/server/controllers.go b/pkg/server/controllers.go index 6e8789af5bf..346b0bbd919 100644 --- a/pkg/server/controllers.go +++ b/pkg/server/controllers.go @@ -1406,7 +1406,7 @@ func (s *Server) installAPIIdentityController(_ context.Context) error { localExports := s.KcpSharedInformerFactory.Apis().V1alpha2().APIExports() globalExports := s.CacheKcpSharedInformerFactory.Apis().V1alpha2().APIExports() - c, err := apiidentity.NewController(s.KcpCacheClusterClient, apiIdentities, localExports, globalExports, isCore) + c, err := apiidentity.NewController(s.KcpCacheClusterClient, apiIdentities, localExports, globalExports, isCore, s.Options.Extra.APIIdentityMissingGracePeriod) if err != nil { return err } diff --git a/pkg/server/options/options.go b/pkg/server/options/options.go index 2a68ac90d9c..787ef465811 100644 --- a/pkg/server/options/options.go +++ b/pkg/server/options/options.go @@ -38,6 +38,7 @@ import ( kcpadmission "github.com/kcp-dev/kcp/pkg/admission" kcpfeatures "github.com/kcp-dev/kcp/pkg/features" + "github.com/kcp-dev/kcp/pkg/reconciler/admin/apiidentity" "github.com/kcp-dev/kcp/pkg/server/options/batteries" kcpserviceaccount "github.com/kcp-dev/kcp/pkg/server/serviceaccount" ) @@ -85,8 +86,12 @@ type ExtraOptions struct { LogicalClusterAdminKubeconfig string ExternalLogicalClusterAdminKubeconfig string ConversionCELTransformationTimeout time.Duration - RootIdentitiesFile string - BatteriesIncluded []string + // APIIdentityMissingGracePeriod is how long an APIExport may be missing + // from the cache server before its API identity is taken over by another + // APIExport holding it, or removed from the registry. + APIIdentityMissingGracePeriod time.Duration + RootIdentitiesFile string + BatteriesIncluded []string // DEVELOPMENT ONLY. AdditionalMappingsFile is the path to a file that contains additional mappings // for the mini-front-proxy to use. The file should be in the format of the // --miniproxy-mapping-file flag of the front-proxy. Do NOT expose this flag to users via main server options. @@ -141,6 +146,8 @@ func NewOptions(rootDir string) *Options { LogicalClusterTotalObjectLimit: 0, LogicalClusterObjectCountScanInterval: 60 * time.Second, + APIIdentityMissingGracePeriod: apiidentity.DefaultMissingGracePeriod, + BatteriesIncluded: sets.List[string](batteries.Defaults), ServiceAccountCache: saCache, }, @@ -217,6 +224,8 @@ func (o *Options) AddFlags(fss *cliflag.NamedFlagSets) { fs.Int64Var(&o.Extra.LogicalClusterTotalObjectLimit, "logical-cluster-total-object-limit", o.Extra.LogicalClusterTotalObjectLimit, "Maximum total number of objects allowed in a logical cluster on this shard. 0 disables the default limit. The "+corev1alpha1.LogicalClusterMaxTotalObjectsAnnotationKey+" annotation on a LogicalCluster overrides this value for that logical cluster.") fs.DurationVar(&o.Extra.LogicalClusterObjectCountScanInterval, "logical-cluster-object-count-scan-interval", o.Extra.LogicalClusterObjectCountScanInterval, "Interval at which etcd is scanned to count objects per logical cluster for total object count limit enforcement.") + fs.DurationVar(&o.Extra.APIIdentityMissingGracePeriod, "api-identity-missing-grace-period", o.Extra.APIIdentityMissingGracePeriod, "How long an APIExport may be missing from the cache server before its API identity passes to another APIExport holding it, or is removed from the registry when none does.") + fs.StringSliceVar(&o.Extra.BatteriesIncluded, "batteries-included", o.Extra.BatteriesIncluded, fmt.Sprintf( `A list of batteries included (= default objects that might be unwanted in production, but are very helpful in trying out kcp or for development). These are the possible values: %s. diff --git a/staging/src/github.com/kcp-dev/sdk/apis/admin/v1alpha1/types_apiidentity.go b/staging/src/github.com/kcp-dev/sdk/apis/admin/v1alpha1/types_apiidentity.go index 2a78d2e7cbe..34ca48b8ba4 100644 --- a/staging/src/github.com/kcp-dev/sdk/apis/admin/v1alpha1/types_apiidentity.go +++ b/staging/src/github.com/kcp-dev/sdk/apis/admin/v1alpha1/types_apiidentity.go @@ -123,6 +123,27 @@ type APIIdentityStatus struct { Conditions conditionsv1alpha1.Conditions `json:"conditions,omitempty"` } +const ( + // APIIdentityOwnerLabel is the name of the APIExport that owns the + // identity, so that entries can be selected by export name. + APIIdentityOwnerLabel = "admin.kcp.io/owner" + // APIIdentityOwnerClusterLabel is the logical cluster of the APIExport + // that owns the identity, so that entries can be selected by workspace. + APIIdentityOwnerClusterLabel = "admin.kcp.io/owner-cluster" + // APIIdentityCoreLabel is "true" on the identities of the core + // APIExports in root and "false" on every other one. + APIIdentityCoreLabel = "admin.kcp.io/core" +) + +// Labels returns the labels an entry carries for its owner and core flag. +func Labels(owner ExportReference, core bool) map[string]string { + return map[string]string{ + APIIdentityOwnerLabel: owner.Name, + APIIdentityOwnerClusterLabel: owner.Cluster, + APIIdentityCoreLabel: map[bool]string{true: "true", false: "false"}[core], + } +} + const ( // APIIdentityShared is True while holders is non-empty. APIIdentityShared conditionsv1alpha1.ConditionType = "Shared" diff --git a/test/e2e/apibinding/apiexport_identity_registry_test.go b/test/e2e/apibinding/apiexport_identity_registry_test.go index dcebeeb6b01..e9343e6c8e6 100644 --- a/test/e2e/apibinding/apiexport_identity_registry_test.go +++ b/test/e2e/apibinding/apiexport_identity_registry_test.go @@ -45,9 +45,9 @@ import ( kcpclusterclientset "github.com/kcp-dev/sdk/client/clientset/versioned/cluster" kcptesting "github.com/kcp-dev/sdk/testing" kcptestinghelpers "github.com/kcp-dev/sdk/testing/helpers" + kcptestingserver "github.com/kcp-dev/sdk/testing/server" "github.com/kcp-dev/kcp/config/helpers" - "github.com/kcp-dev/kcp/pkg/reconciler/admin/apiidentity" "github.com/kcp-dev/kcp/test/e2e/framework" ) @@ -61,7 +61,10 @@ func TestAPIExportIdentityRegistry(t *testing.T) { t.Parallel() framework.Suite(t, "control-plane") - server := kcptesting.SharedKcpServer(t) + // a deleted owner is taken as gone only after the grace period, which is + // an hour by default: run against a private server with a short one. + const grace = 10 * time.Second + server := kcptesting.PrivateKcpServer(t, kcptestingserver.WithCustomArguments("--api-identity-missing-grace-period="+grace.String())) orgPath, _ := kcptesting.NewWorkspaceFixture(t, server, core.RootCluster.Path(), kcptesting.WithType(core.RootCluster.Path(), "organization")) ownerPath, ownerWS := kcptesting.NewWorkspaceFixture(t, server, orgPath) @@ -135,8 +138,7 @@ func TestAPIExportIdentityRegistry(t *testing.T) { return check(entry) }, timeout, 250*time.Millisecond) } - // a deleted owner is taken as gone only after the grace period - afterGrace := apiidentity.MissingGracePeriod + wait.ForeverTestTimeout + afterGrace := grace + wait.ForeverTestTimeout copyKey := func(from, to logicalcluster.Path) { t.Helper() secret, err := kubeClusterClient.Cluster(from).CoreV1().Secrets("kcp-system").Get(t.Context(), "wildwest", metav1.GetOptions{}) diff --git a/test/e2e/virtual/admin/apiidentities_test.go b/test/e2e/virtual/admin/apiidentities_test.go index bf29c606727..d9488a5f1be 100644 --- a/test/e2e/virtual/admin/apiidentities_test.go +++ b/test/e2e/virtual/admin/apiidentities_test.go @@ -87,8 +87,23 @@ func TestAdminWorkspaceAPIIdentities(t *testing.T) { require.NotNil(c, tenancy, "the tenancy.kcp.io identity must be registered") require.True(c, tenancy.Status.Core, "tenancy.kcp.io is a core identity") require.Equal(c, adminv1alpha1.ExportReference{Cluster: core.RootCluster.String(), Name: "tenancy.kcp.io", UID: string(tenancyExport.UID)}, tenancy.Status.Owner) + require.Equal(c, adminv1alpha1.Labels(tenancy.Status.Owner, true), tenancy.Labels) }, wait.ForeverTestTimeout, 100*time.Millisecond) + t.Logf("Select identities by label: core, by owner cluster, by owner name") + for selector, want := range map[string]func(*adminv1alpha1.APIIdentity) bool{ + adminv1alpha1.APIIdentityCoreLabel + "=true": func(e *adminv1alpha1.APIIdentity) bool { return e.Status.Core }, + adminv1alpha1.APIIdentityOwnerClusterLabel + "=" + core.RootCluster.String(): func(e *adminv1alpha1.APIIdentity) bool { return e.Status.Owner.Cluster == core.RootCluster.String() }, + adminv1alpha1.APIIdentityOwnerLabel + "=tenancy.kcp.io": func(e *adminv1alpha1.APIIdentity) bool { return e.Status.Owner.Name == "tenancy.kcp.io" }, + } { + selected, err := adminClient.AdminV1alpha1().APIIdentities().List(ctx, metav1.ListOptions{LabelSelector: selector}) + require.NoError(t, err, "selector %q", selector) + require.NotEmpty(t, selected.Items, "selector %q selected nothing", selector) + for i := range selected.Items { + require.True(t, want(&selected.Items[i]), "selector %q selected %s, owner %v core %v", selector, selected.Items[i].Name, selected.Items[i].Status.Owner, selected.Items[i].Status.Core) + } + } + t.Logf("Get the core identity by name through the Admin workspace") for _, rv := range []string{"", "0", registry.ResourceVersion} { entry, err := adminClient.AdminV1alpha1().APIIdentities().Get(ctx, tenancyExport.Status.IdentityHash, metav1.GetOptions{ResourceVersion: rv}) From 8b2cb5c8df7f3b38ecae2dc23430373c3babf961 Mon Sep 17 00:00:00 2001 From: Mangirdas Judeikis Date: Mon, 26 Oct 2026 08:55:42 +0200 Subject: [PATCH 5/7] rename labels --- docs/content/concepts/sharding/shards.md | 6 +++--- .../kcp-dev/sdk/apis/admin/v1alpha1/types_apiidentity.go | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/content/concepts/sharding/shards.md b/docs/content/concepts/sharding/shards.md index 3d649480f70..399909de99f 100644 --- a/docs/content/concepts/sharding/shards.md +++ b/docs/content/concepts/sharding/shards.md @@ -119,9 +119,9 @@ $ kubectl get apiidentities NAME OWNER CLUSTER CORE AGE 0e2fc4327ee7af2b2b9a0f2b4b1a8c4c7a0d6c2f0f1b6e5a9d8c7b6a5f4e3d2c tenancy.kcp.io root true 3d 5fdf7c7aaf407fd1594566869803f565bb84d22156cef5c445d2ee13ac2cfca6 widgets 2cynbfy2m0wtjqcs false 2h -$ kubectl get apiidentities -l admin.kcp.io/core=true # the core identities -$ kubectl get apiidentities -l admin.kcp.io/owner-cluster=root # owned by exports in root -$ kubectl get apiidentities -l admin.kcp.io/owner=widgets # owned by an export named widgets +$ kubectl get apiidentities -l apiidentities.admin.kcp.io/core=true # the core identities +$ kubectl get apiidentities -l apiidentities.admin.kcp.io/owner-cluster=root # owned by exports in root +$ kubectl get apiidentities -l apiidentities.admin.kcp.io/owner=widgets # owned by an export named widgets ``` Access requires membership in the `system:kcp:admin` group; the serving diff --git a/staging/src/github.com/kcp-dev/sdk/apis/admin/v1alpha1/types_apiidentity.go b/staging/src/github.com/kcp-dev/sdk/apis/admin/v1alpha1/types_apiidentity.go index 34ca48b8ba4..119a7b563f7 100644 --- a/staging/src/github.com/kcp-dev/sdk/apis/admin/v1alpha1/types_apiidentity.go +++ b/staging/src/github.com/kcp-dev/sdk/apis/admin/v1alpha1/types_apiidentity.go @@ -126,13 +126,13 @@ type APIIdentityStatus struct { const ( // APIIdentityOwnerLabel is the name of the APIExport that owns the // identity, so that entries can be selected by export name. - APIIdentityOwnerLabel = "admin.kcp.io/owner" + APIIdentityOwnerLabel = "apiidentities.admin.kcp.io/owner" // APIIdentityOwnerClusterLabel is the logical cluster of the APIExport // that owns the identity, so that entries can be selected by workspace. - APIIdentityOwnerClusterLabel = "admin.kcp.io/owner-cluster" + APIIdentityOwnerClusterLabel = "apiidentities.admin.kcp.io/owner-cluster" // APIIdentityCoreLabel is "true" on the identities of the core // APIExports in root and "false" on every other one. - APIIdentityCoreLabel = "admin.kcp.io/core" + APIIdentityCoreLabel = "apiidentities.admin.kcp.io/core" ) // Labels returns the labels an entry carries for its owner and core flag. From 6778e653978d7f798f8da4edabb7e1720dd238f0 Mon Sep 17 00:00:00 2001 From: Mangirdas Judeikis Date: Mon, 26 Oct 2026 10:23:37 +0200 Subject: [PATCH 6/7] Move FP to use APIIdentity --- cmd/sharded-test-server/frontproxy.go | 1 - docs/content/concepts/sharding/shards.md | 10 +- docs/content/setup/production/metrics.md | 7 ++ pkg/proxy/config.go | 59 ++++++----- pkg/proxy/metrics_authorizer.go | 57 +++++++++++ pkg/proxy/metrics_authorizer_test.go | 58 +++++++++++ pkg/proxy/options/authentication.go | 9 ++ pkg/proxy/options/options.go | 18 ++-- pkg/proxy/server.go | 119 +++++++++++++---------- pkg/server/bootstrap/identity.go | 82 ++++++++++++++-- pkg/server/bootstrap/identity_test.go | 77 +++++++++++++++ 11 files changed, 404 insertions(+), 93 deletions(-) create mode 100644 pkg/proxy/metrics_authorizer.go create mode 100644 pkg/proxy/metrics_authorizer_test.go diff --git a/cmd/sharded-test-server/frontproxy.go b/cmd/sharded-test-server/frontproxy.go index b1e1ad0b2f8..414458a9cb6 100644 --- a/cmd/sharded-test-server/frontproxy.go +++ b/cmd/sharded-test-server/frontproxy.go @@ -137,7 +137,6 @@ func startFrontProxy( "--bind-address="+hostIP, fmt.Sprintf("--mapping-file=%s", filepath.Join(workDirPath, ".kcp-front-proxy", "mapping.yaml")), fmt.Sprintf("--root-directory=%s", filepath.Join(workDirPath, ".kcp-front-proxy")), - fmt.Sprintf("--root-kubeconfig=%s", filepath.Join(workDirPath, ".kcp", "root.kubeconfig")), fmt.Sprintf("--shards-kubeconfig=%s", filepath.Join(workDirPath, ".kcp-front-proxy", "shards.kubeconfig")), fmt.Sprintf("--shard-peer-kubeconfig=%s", filepath.Join(workDirPath, ".kcp-front-proxy", "peers.kubeconfig")), fmt.Sprintf("--client-ca-file=%s", filepath.Join(workDirPath, ".kcp", "client-ca.crt")), diff --git a/docs/content/concepts/sharding/shards.md b/docs/content/concepts/sharding/shards.md index 399909de99f..1a55859109a 100644 --- a/docs/content/concepts/sharding/shards.md +++ b/docs/content/concepts/sharding/shards.md @@ -128,10 +128,12 @@ Access requires membership in the `system:kcp:admin` group; the serving path does not depend on the root shard. To reach the view through a front-proxy, add a `/services/` path mapping; any shard can be the backend. -The front-proxy itself discovers shards through the Admin workspace: the -kubeconfigs given via `--shard-peer-kubeconfig` (falling back to the -`--root-kubeconfig` server) are seeds, used to bootstrap discovery and as a -last-resort fallback only. Every shard observed through the view joins the +The front-proxy itself discovers shards through the Admin workspace, and +reads the identities of the core APIs from the `apiidentities` registry +there, so it starts and runs without the root shard: the kubeconfigs given +via `--shard-peer-kubeconfig` (falling back to the `--root-kubeconfig` +server) are seeds, used to bootstrap discovery and as a last-resort fallback +only. Every shard observed through the view joins the failover peer set at runtime and decommissioned shards leave it; requests are distributed across the discovered shards, not the seeds. With short-lived shards the discovery channel therefore keeps working as long as any currently diff --git a/docs/content/setup/production/metrics.md b/docs/content/setup/production/metrics.md index a6e17282034..09371ef404d 100644 --- a/docs/content/setup/production/metrics.md +++ b/docs/content/setup/production/metrics.md @@ -134,3 +134,10 @@ scrape_configs: - targets: - cache.kcp.svc:6443 ``` + +## Scraping the front-proxy + +The front-proxy's own `/metrics` is authorized by group: members of +`system:kcp:metrics-reader`, `system:kcp:admin` or `system:masters` may read +it. Give the scraper identity the `system:kcp:metrics-reader` group, for +example as a client certificate organization or an OIDC group claim. diff --git a/pkg/proxy/config.go b/pkg/proxy/config.go index ae3a94f65f3..2550562b9e6 100644 --- a/pkg/proxy/config.go +++ b/pkg/proxy/config.go @@ -44,17 +44,20 @@ type completedConfig struct { } type ExtraConfig struct { - // resolveIdentities is to be called on server start until it succeeds. It injects the kcp - // resource identities into the rest.Config used by the client. Only after it succeeds, - // the clients can wildcard-list/watch most kcp resources. - ResolveIdentities func(ctx context.Context) error - // CoreIdentityHandler keeps the identities resolved by ResolveIdentities - // in sync with the APIExports in the root workspace, e.g. across an - // APIExport identity rotation. Register it on an informer of those - // APIExports. + // CoreIdentityHandler feeds the identities of the core APIs, which the + // clients built from ShardsConfig inject into their wildcard requests, + // from the APIIdentity registry that the Admin workspace of the peers + // serves. Register it on an informer of those entries. CoreIdentityHandler cache.ResourceEventHandler - RootShardConfig *rest.Config - ShardsConfig *rest.Config + // CoreIdentitiesReady fails while the identity of a core API is still + // unknown. It is a readiness check: requests needing an identity fail + // until it arrives, nothing blocks startup on it. + CoreIdentitiesReady func() error + // RootShardConfig points at the root shard and is set only when + // --root-kubeconfig is given. Service account authentication still needs + // it. + RootShardConfig *rest.Config + ShardsConfig *rest.Config // PeersConfig points at the Admin workspace (/services/admin) of the // peer shards with round-robin and per-request failover, and is the only // source for Shard discovery. When no peers are configured, the root @@ -89,17 +92,22 @@ func NewConfig(ctx context.Context, opts *proxyoptions.Options) (*Config, error) Options: opts, } - // get root API identities - //nolint:staticcheck // the deprecated root kubeconfig remains the source for identity resolution until identities are resolvable without the root shard. - nonIdentityRootConfig, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(&clientcmd.ClientConfigLoadingRules{ExplicitPath: c.Options.RootKubeconfig}, nil).ClientConfig() - if err != nil { - return nil, fmt.Errorf("failed to load root kubeconfig: %w", err) - } - + // The identities of the core APIs come from the APIIdentity registry, + // read through the Admin workspace of the peers; the root shard is not + // asked for them. var kcpShardIdentityRoundTripper func(rt http.RoundTripper) http.RoundTripper - kcpShardIdentityRoundTripper, c.ResolveIdentities, c.CoreIdentityHandler = bootstrap.NewWildcardIdentitiesWrappingRoundTripper(bootstrap.KcpRootGroupExportNames, bootstrap.KcpRootGroupResourceExportNames, nonIdentityRootConfig, nil) - c.RootShardConfig = rest.CopyConfig(nonIdentityRootConfig) - c.RootShardConfig.Wrap(kcpShardIdentityRoundTripper) + kcpShardIdentityRoundTripper, c.CoreIdentityHandler, c.CoreIdentitiesReady = bootstrap.NewRegistryWildcardIdentities(bootstrap.KcpRootGroupExportNames, bootstrap.KcpRootGroupResourceExportNames) + + //nolint:staticcheck // the deprecated root kubeconfig is still needed for service account authentication and as the default seed peer. + if c.Options.RootKubeconfig != "" { + //nolint:staticcheck // see above. + rootConfig, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(&clientcmd.ClientConfigLoadingRules{ExplicitPath: c.Options.RootKubeconfig}, nil).ClientConfig() + if err != nil { + return nil, fmt.Errorf("failed to load root kubeconfig: %w", err) + } + c.RootShardConfig = rest.CopyConfig(rootConfig) + c.RootShardConfig.Wrap(kcpShardIdentityRoundTripper) + } var loopbackClientConfig *rest.Config if err := c.Options.SecureServing.ApplyTo(&c.ServingInfo, &loopbackClientConfig); err != nil { @@ -109,6 +117,7 @@ func NewConfig(ctx context.Context, opts *proxyoptions.Options) (*Config, error) return nil, err } + var err error c.ShardsConfig, err = clientcmd.NewNonInteractiveDeferredLoadingClientConfig( &clientcmd.ClientConfigLoadingRules{ExplicitPath: c.Options.ShardsKubeconfig}, // We override the Server here so that the user doesn't have to specify unused server value @@ -119,12 +128,16 @@ func NewConfig(ctx context.Context, opts *proxyoptions.Options) (*Config, error) } c.ShardsConfig.Wrap(kcpShardIdentityRoundTripper) - // Shard discovery always goes through the Admin workspace of the peers; - // the root workspace is no longer read for it. Without configured peers, - // the root kubeconfig's server is the seed peer. + // Shard discovery and the core identities always go through the Admin + // workspace of the peers; the root shard is never asked. Without + // configured peers, the root kubeconfig's server is the seed peer. peerKubeconfigs := c.Options.ShardsPeerKubeconfigs if len(peerKubeconfigs) == 0 { //nolint:staticcheck // the deprecated root kubeconfig doubles as the default seed peer for deployments that predate --shard-peer-kubeconfig. + if c.Options.RootKubeconfig == "" { + return nil, fmt.Errorf("--shard-peer-kubeconfig is required") + } + //nolint:staticcheck // see above. peerKubeconfigs = []string{c.Options.RootKubeconfig} } c.PeersConfig, c.Peers, err = NewPeersConfig(peerKubeconfigs) diff --git a/pkg/proxy/metrics_authorizer.go b/pkg/proxy/metrics_authorizer.go new file mode 100644 index 00000000000..5393827136f --- /dev/null +++ b/pkg/proxy/metrics_authorizer.go @@ -0,0 +1,57 @@ +/* +Copyright 2026 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 proxy + +import ( + "context" + "slices" + + "k8s.io/apiserver/pkg/authorization/authorizer" + + bootstrappolicy "github.com/kcp-dev/kcp/pkg/authorization/bootstrap" +) + +// metricsReaderGroups may read the front-proxy's /metrics. The proxy has +// no RBAC of its own and must not depend on the root shard, so access is +// decided from the caller's groups: the administrator groups that the Admin +// workspace accepts, and system:kcp:metrics-reader, the name of the +// ClusterRole that authorizes scraping on the shards, used as a group here. +var metricsReaderGroups = []string{ + bootstrappolicy.SystemKcpAdminGroup, + bootstrappolicy.SystemMastersGroup, + bootstrappolicy.SystemKcpMetricsReader, +} + +type metricsGroupAuthorizer struct{} + +func newMetricsGroupAuthorizer() authorizer.Authorizer { + return metricsGroupAuthorizer{} +} + +// Authorize allows members of metricsReaderGroups and has no opinion on +// everybody else, so that a further authorizer in a union can still allow. +func (metricsGroupAuthorizer) Authorize(_ context.Context, attr authorizer.Attributes) (authorizer.Decision, string, error) { + if attr.GetUser() == nil { + return authorizer.DecisionNoOpinion, "", nil + } + for _, group := range attr.GetUser().GetGroups() { + if slices.Contains(metricsReaderGroups, group) { + return authorizer.DecisionAllow, "user is a member of the " + group + " group", nil + } + } + return authorizer.DecisionNoOpinion, "", nil +} diff --git a/pkg/proxy/metrics_authorizer_test.go b/pkg/proxy/metrics_authorizer_test.go new file mode 100644 index 00000000000..dc6f889a2b1 --- /dev/null +++ b/pkg/proxy/metrics_authorizer_test.go @@ -0,0 +1,58 @@ +/* +Copyright 2026 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 proxy + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "k8s.io/apiserver/pkg/authentication/user" + "k8s.io/apiserver/pkg/authorization/authorizer" + + bootstrappolicy "github.com/kcp-dev/kcp/pkg/authorization/bootstrap" +) + +func TestMetricsGroupAuthorizer(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + groups []string + want authorizer.Decision + }{ + {name: "kcp admin may scrape", groups: []string{bootstrappolicy.SystemKcpAdminGroup, user.AllAuthenticated}, want: authorizer.DecisionAllow}, + {name: "system:masters may scrape", groups: []string{bootstrappolicy.SystemMastersGroup}, want: authorizer.DecisionAllow}, + {name: "the metrics-reader group may scrape", groups: []string{bootstrappolicy.SystemKcpMetricsReader}, want: authorizer.DecisionAllow}, + // no opinion, not deny: a further authorizer in the union (the :root + // SubjectAccessReview while --root-kubeconfig is given) may still allow. + {name: "an ordinary user gets no opinion", groups: []string{user.AllAuthenticated}, want: authorizer.DecisionNoOpinion}, + {name: "no groups gets no opinion", groups: nil, want: authorizer.DecisionNoOpinion}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, _, err := newMetricsGroupAuthorizer().Authorize(context.Background(), authorizer.AttributesRecord{ + User: &user.DefaultInfo{Name: "scraper", Groups: tc.groups}, + Verb: "get", + Path: "/metrics", + }) + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} diff --git a/pkg/proxy/options/authentication.go b/pkg/proxy/options/authentication.go index 3da26ca6a0a..973ed863947 100644 --- a/pkg/proxy/options/authentication.go +++ b/pkg/proxy/options/authentication.go @@ -96,6 +96,12 @@ func (c *Authentication) tokenAuthEnabled() bool { return c.BuiltInOptions.TokenFile != nil && c.BuiltInOptions.TokenFile.TokenFile != "" } +// ServiceAccountAuthEnabled reports whether service account tokens are +// accepted, which needs a client to the root shard. +func (c *Authentication) ServiceAccountAuthEnabled() bool { + return c.serviceAccountAuthEnabled() +} + func (c *Authentication) serviceAccountAuthEnabled() bool { return c.BuiltInOptions.ServiceAccounts != nil && len(c.BuiltInOptions.ServiceAccounts.KeyFiles) != 0 } @@ -129,6 +135,9 @@ func (c *Authentication) ApplyTo(ctx context.Context, authenticationInfo *generi // Set for service account auth, if enabled if c.serviceAccountAuthEnabled() { + if rootShardConfig == nil { + return fmt.Errorf("service account authentication needs --root-kubeconfig") + } authenticationInfo.APIAudiences = c.BuiltInOptions.APIAudiences if len(c.BuiltInOptions.ServiceAccounts.Issuers) != 0 && len(c.BuiltInOptions.APIAudiences) == 0 { authenticationInfo.APIAudiences = c.BuiltInOptions.ServiceAccounts.Issuers diff --git a/pkg/proxy/options/options.go b/pkg/proxy/options/options.go index 878b63bd4f4..3f6e64c8529 100644 --- a/pkg/proxy/options/options.go +++ b/pkg/proxy/options/options.go @@ -32,10 +32,8 @@ type Options struct { RootDirectory string // RootKubeconfig points at the root shard. // - // Deprecated: shard discovery uses ShardsPeerKubeconfigs (the Admin - // workspace of the peer shards) instead. The root kubeconfig is still - // required for APIExport identity resolution and will be removed once - // identities are resolvable without the root shard. + // TODO: This is going to be removed once service account + // authentication no longer requires the root shard. RootKubeconfig string ShardsKubeconfig string ShardsPeerKubeconfigs []string @@ -65,12 +63,12 @@ func (o *Options) AddFlags(fss *cliflag.NamedFlagSets) { fs := fss.FlagSet("proxy") fs.StringVar(&o.MappingFile, "mapping-file", o.MappingFile, "Config file mapping paths to backends") fs.StringVar(&o.RootDirectory, "root-directory", o.RootDirectory, "Root directory.") - fs.StringVar(&o.RootKubeconfig, "root-kubeconfig", o.RootKubeconfig, "The path to the kubeconfig of the root shard.") - if err := fs.MarkDeprecated("root-kubeconfig", "use --shard-peer-kubeconfig for shard discovery; --root-kubeconfig is still required for APIExport identity resolution and will be removed once identities are resolvable without the root shard."); err != nil { + fs.StringVar(&o.RootKubeconfig, "root-kubeconfig", o.RootKubeconfig, "The path to the kubeconfig of the root shard. Only needed for service account authentication; without --shard-peer-kubeconfig its server also acts as the seed peer.") + if err := fs.MarkDeprecated("root-kubeconfig", "use --shard-peer-kubeconfig; the root shard is needed neither for shard discovery nor for the identities of the core APIs any more. It is still required for service account authentication."); err != nil { panic(err) } fs.StringVar(&o.ShardsKubeconfig, "shards-kubeconfig", o.ShardsKubeconfig, "The path to the kubeconfig used for communication with all shards. The server name if provided is replaced with a shard's hostname.") - fs.StringSliceVar(&o.ShardsPeerKubeconfigs, "shard-peer-kubeconfig", o.ShardsPeerKubeconfigs, "Repeatable path to a kubeconfig whose named clusters are seed peer shards; all files' clusters are merged into one seed list. The proxy discovers Shards through the Admin workspace (/services/admin) of the peers - requests are distributed round-robin with failover - instead of watching the root shard. If unset, the --root-kubeconfig server acts as the only seed peer. The seeds are used for bootstrapping and as a last-resort fallback only: once shards are discovered, requests go to the discovered shards, which join and leave the peer set at runtime, so the files do not have to track short-lived shards.") + fs.StringSliceVar(&o.ShardsPeerKubeconfigs, "shard-peer-kubeconfig", o.ShardsPeerKubeconfigs, "Repeatable path to a kubeconfig whose named clusters are seed peer shards; all files' clusters are merged into one seed list. The proxy discovers Shards and the identities of the core APIs through the Admin workspace (/services/admin) of the peers - requests are distributed round-robin with failover - and never talks to the root shard for them. If unset, the --root-kubeconfig server acts as the only seed peer. The seeds are used for bootstrapping and as a last-resort fallback only: once shards are discovered, requests go to the discovered shards, which join and leave the peer set at runtime, so the files do not have to track short-lived shards.") fs.StringVar(&o.ProfilerAddress, "profiler-address", "", "[Address]:port to bind the profiler to") fs.StringSliceVar(&o.CorsAllowedOriginList, "cors-allowed-origins", o.CorsAllowedOriginList, "List of allowed origins for CORS, comma separated. An allowed origin can be a regular expression to support subdomain matching. If this list is empty CORS will not be enabled.") } @@ -103,6 +101,12 @@ func (o *Options) Validate() []error { if len(o.ShardsKubeconfig) == 0 { errs = append(errs, fmt.Errorf("--shards-kubeconfig is required")) } + if len(o.ShardsPeerKubeconfigs) == 0 && len(o.RootKubeconfig) == 0 { + errs = append(errs, fmt.Errorf("--shard-peer-kubeconfig is required")) + } + if o.Authentication.ServiceAccountAuthEnabled() && len(o.RootKubeconfig) == 0 { + errs = append(errs, fmt.Errorf("--root-kubeconfig is required for service account authentication")) + } errs = append(errs, o.SecureServing.Validate()...) errs = append(errs, o.Authentication.Validate()...) diff --git a/pkg/proxy/server.go b/pkg/proxy/server.go index 34d5f7cf56a..58649663037 100644 --- a/pkg/proxy/server.go +++ b/pkg/proxy/server.go @@ -20,16 +20,18 @@ import ( "context" "fmt" "net/http" + "reflect" "slices" "time" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/runtime/serializer" - "k8s.io/apimachinery/pkg/util/wait" "k8s.io/apiserver/pkg/authentication/authenticator" "k8s.io/apiserver/pkg/authorization/authorizer" + "k8s.io/apiserver/pkg/authorization/union" genericapifilters "k8s.io/apiserver/pkg/endpoints/filters" "k8s.io/apiserver/pkg/endpoints/request" genericfilters "k8s.io/apiserver/pkg/server/filters" @@ -40,6 +42,7 @@ import ( "k8s.io/klog/v2" kcpkubernetesclientset "github.com/kcp-dev/client-go/kubernetes" + adminv1alpha1 "github.com/kcp-dev/sdk/apis/admin/v1alpha1" "github.com/kcp-dev/sdk/apis/core" corev1alpha1 "github.com/kcp-dev/sdk/apis/core/v1alpha1" kcpclientset "github.com/kcp-dev/sdk/client/clientset/versioned/cluster" @@ -64,10 +67,10 @@ type Server struct { IndexController *index.Controller AuthController *authentication.Controller KcpSharedInformerFactory kcpinformers.SharedScopedInformerFactory - // RootAPIExportInformerFactory watches the APIExports of the root workspace - // on the root shard. Their identities are injected into wildcard - // requests and follow an identity rotation through this informer. - RootAPIExportInformerFactory kcpinformers.SharedScopedInformerFactory + // CoreIdentityInformerFactory watches the APIIdentity registry entries + // owned by the root workspace's APIExports, through the Admin workspace + // of the peers. Their identities are injected into wildcard requests. + CoreIdentityInformerFactory kcpinformers.SharedScopedInformerFactory } func NewServer(ctx context.Context, c CompletedConfig) (*Server, error) { @@ -94,22 +97,22 @@ func NewServer(ctx context.Context, c CompletedConfig) (*Server, error) { // the root workspace is not read for discovery. The Admin workspace // accepts /clusters// paths, so the standard scoped informer // factory works against it unchanged. - rootShardConfigInformerClient, err := kcpclientset.NewForConfig(s.CompletedConfig.PeersConfig) + peersClient, err := kcpclientset.NewForConfig(s.CompletedConfig.PeersConfig) if err != nil { return s, fmt.Errorf("failed to create client for informers: %w", err) } - s.KcpSharedInformerFactory = kcpinformers.NewSharedScopedInformerFactoryWithOptions(rootShardConfigInformerClient.Cluster(core.RootCluster.Path()), 30*time.Minute) + s.KcpSharedInformerFactory = kcpinformers.NewSharedScopedInformerFactoryWithOptions(peersClient.Cluster(core.RootCluster.Path()), 30*time.Minute) // The identities of the core APIs are those of the root workspace's - // APIExports, which only the root shard serves. An identity rotation - // flips an export's status.identityHash; the handler swaps the injected - // identity as the change arrives. - rootShardKcpClient, err := kcpclientset.NewForConfig(s.CompletedConfig.RootShardConfig) - if err != nil { - return s, fmt.Errorf("failed to create root shard client: %w", err) - } - s.RootAPIExportInformerFactory = kcpinformers.NewSharedScopedInformerFactoryWithOptions(rootShardKcpClient.Cluster(core.RootCluster.Path()), 30*time.Minute) - _, _ = s.RootAPIExportInformerFactory.Apis().V1alpha2().APIExports().Informer().AddEventHandler(s.CompletedConfig.CoreIdentityHandler) + // APIExports. They are read from the APIIdentity registry, which the + // Admin workspace of every peer serves from the cache server, so the + // root shard is not needed for them: an own factory, restricted to the + // entries owned by exports in root, feeds the identity handler. + s.CoreIdentityInformerFactory = kcpinformers.NewSharedScopedInformerFactoryWithOptions(peersClient.Cluster(core.RootCluster.Path()), 30*time.Minute, + kcpinformers.WithTweakListOptions(func(options *metav1.ListOptions) { + options.LabelSelector = labels.SelectorFromSet(labels.Set{adminv1alpha1.APIIdentityOwnerClusterLabel: core.RootCluster.String()}).String() + })) + _, _ = s.CoreIdentityInformerFactory.Admin().V1alpha1().APIIdentities().Informer().AddEventHandler(s.CompletedConfig.CoreIdentityHandler) // Feed every discovered shard into the peer failover set: the configured // peers are seeds for bootstrapping and last-resort fallback only, and @@ -209,17 +212,22 @@ func NewServer(ctx context.Context, c CompletedConfig) (*Server, error) { handler = genericfilters.WithCORS(handler, c.Options.CorsAllowedOriginList, nil, nil, nil, "true") mux := http.NewServeMux() - healthz.InstallReadyzHandler(mux, healthz.NewInformerSyncHealthz(s.KcpSharedInformerFactory)) + healthz.InstallReadyzHandler(mux, + healthz.NewInformerSyncHealthz(cacheSyncWaiters{s.KcpSharedInformerFactory, s.CoreIdentityInformerFactory}), + healthz.NamedCheck("core-identities", func(*http.Request) error { return s.CompletedConfig.CoreIdentitiesReady() }), + ) healthz.InstallLivezHandler(mux, healthz.PingHealthz) // /metrics is served locally by the front proxy (it is not proxied to a // shard), so it needs its own authentication and authorization: without it // the endpoint is world-readable and leaks shard-wide operational data - // (logical-cluster counts, client-cert expiry, request patterns). Access is - // delegated to :root via SubjectAccessReview, so the same - // system:kcp:metrics-reader binding that authorizes scraping on the shards - // authorizes it here too. Registered before "/" so it takes precedence over - // the proxy handler for this exact path. + // (logical-cluster counts, client-cert expiry, request patterns). Members + // of the system:kcp:admin, system:masters and system:kcp:metrics-reader + // groups may scrape; with --root-kubeconfig a :root SubjectAccessReview + // is consulted as well, so the system:kcp:metrics-reader binding that + // authorizes scraping on the shards keeps working here during the + // deprecation. Registered before "/" so it takes precedence over the proxy + // handler for this exact path. metricsHandler, err := newProtectedMetricsHandler(c.AuthenticationInfo.Authenticator, c.RootShardConfig, requestInfoFactory) if err != nil { return nil, fmt.Errorf("failed to create protected metrics handler: %w", err) @@ -232,17 +240,37 @@ func NewServer(ctx context.Context, c CompletedConfig) (*Server, error) { return s, nil } -func newProtectedMetricsHandler(auth authenticator.Request, rootShardConfig *restclient.Config, requestInfoResolver request.RequestInfoResolver) (http.Handler, error) { - kubeClusterClient, err := kcpkubernetesclientset.NewForConfig(rootShardConfig) - if err != nil { - return nil, fmt.Errorf("failed to create root shard client for metrics authorization: %w", err) +// cacheSyncWaiters combines informer factories into one informer-sync +// readiness check. +type cacheSyncWaiters []interface { + WaitForCacheSync(stopCh <-chan struct{}) map[reflect.Type]bool +} + +func (w cacheSyncWaiters) WaitForCacheSync(stopCh <-chan struct{}) map[reflect.Type]bool { + merged := map[reflect.Type]bool{} + for _, waiter := range w { + for informerType, synced := range waiter.WaitForCacheSync(stopCh) { + merged[informerType] = synced + } } - authz, err := delegated.NewDelegatedAuthorizer(core.RootCluster, kubeClusterClient, delegated.Options{}) - if err != nil { - return nil, fmt.Errorf("failed to create metrics authorizer: %w", err) + return merged +} + +func newProtectedMetricsHandler(auth authenticator.Request, rootShardConfig *restclient.Config, requestInfoResolver request.RequestInfoResolver) (http.Handler, error) { + authorizers := []authorizer.Authorizer{newMetricsGroupAuthorizer()} + if rootShardConfig != nil { + kubeClusterClient, err := kcpkubernetesclientset.NewForConfig(rootShardConfig) + if err != nil { + return nil, fmt.Errorf("failed to create root shard client for metrics authorization: %w", err) + } + authz, err := delegated.NewDelegatedAuthorizer(core.RootCluster, kubeClusterClient, delegated.Options{}) + if err != nil { + return nil, fmt.Errorf("failed to create metrics authorizer: %w", err) + } + authorizers = append(authorizers, authz) } - return withMetricsAuthorization(legacyregistry.Handler(), auth, authz, requestInfoResolver), nil + return withMetricsAuthorization(legacyregistry.Handler(), auth, union.New(authorizers...), requestInfoResolver), nil } func withMetricsAuthorization(h http.Handler, auth authenticator.Request, authz authorizer.Authorizer, requestInfoResolver request.RequestInfoResolver) http.Handler { @@ -268,21 +296,18 @@ func (s *Server) PrepareRun(ctx context.Context) (preparedServer, error) { func (s preparedServer) Run(ctx context.Context) error { logger := klog.FromContext(ctx).WithValues("component", "proxy") - var identityErr error - if err := wait.PollUntilContextCancel(ctx, time.Millisecond*500, true, func(ctx context.Context) (bool, error) { - if err := s.CompletedConfig.ResolveIdentities(ctx); err != nil { - logger.V(3).Info("failed to resolve identities, keeping trying", "err", err) - identityErr = err - return false, nil - } - return true, nil - }); err != nil { - err = fmt.Errorf("failed to get or create identities: %w", err) - if identityErr != nil { - err = fmt.Errorf("%v: %w", err, identityErr) - } - return err + // Both factories read the Admin workspace of the peers: any reachable + // shard suffices, the root shard is not special. The core identities + // arrive through the second one; until they do, requests that need them + // fail and readiness reports it, nothing blocks here on the root shard. + s.KcpSharedInformerFactory.Start(ctx.Done()) + s.CoreIdentityInformerFactory.Start(ctx.Done()) + s.KcpSharedInformerFactory.WaitForCacheSync(ctx.Done()) + s.CoreIdentityInformerFactory.WaitForCacheSync(ctx.Done()) + if err := s.CompletedConfig.CoreIdentitiesReady(); err != nil { + logger.Info("starting without all core identities, waiting for the registry", "reason", err.Error()) } + // start indexes go s.IndexController.Start(ctx, 2) @@ -290,12 +315,6 @@ func (s preparedServer) Run(ctx context.Context) error { go s.AuthController.Start(ctx, 2) } - s.KcpSharedInformerFactory.Start(ctx.Done()) - s.KcpSharedInformerFactory.WaitForCacheSync(ctx.Done()) - // not waited for: the identities are resolved already, and the proxy - // must not depend on the root shard to become ready. - s.RootAPIExportInformerFactory.Start(ctx.Done()) - doneCh, _, err := s.CompletedConfig.ServingInfo.Serve(s.Handler, time.Second*60, ctx.Done()) if err != nil { return err diff --git a/pkg/server/bootstrap/identity.go b/pkg/server/bootstrap/identity.go index c1492739615..d9654e7749a 100644 --- a/pkg/server/bootstrap/identity.go +++ b/pkg/server/bootstrap/identity.go @@ -37,6 +37,7 @@ import ( kcpkubernetesclientset "github.com/kcp-dev/client-go/kubernetes" "github.com/kcp-dev/logicalcluster/v3" + adminv1alpha1 "github.com/kcp-dev/sdk/apis/admin/v1alpha1" apisv1alpha2 "github.com/kcp-dev/sdk/apis/apis/v1alpha2" "github.com/kcp-dev/sdk/apis/core" corev1alpha1 "github.com/kcp-dev/sdk/apis/core/v1alpha1" @@ -237,6 +238,78 @@ func NewWildcardIdentitiesWrappingRoundTripper(groupExportNames map[string]strin apiExportHandler(ids) } +// NewRegistryWildcardIdentities creates the identity-injecting round tripper +// for a component that learns the identities of the core APIExports from +// the APIIdentity registry instead of from the root shard: the front-proxy, +// which reads the registry through the Admin workspace of its peer shards. +// +// The returned handler is registered on an informer of the APIIdentity +// entries; it feeds the identities as the entries arrive. The returned ready +// func fails while any core identity is still unknown and is meant as a +// readiness check: requests that need an identity fail until it arrives, +// nothing blocks startup on it. +func NewRegistryWildcardIdentities(groupExportNames map[string]string, + groupResourceExportNames map[schema.GroupResource]string) (wrap func(rt http.RoundTripper) http.RoundTripper, handler cache.ResourceEventHandler, ready func() error) { + ids := newIdentities(groupExportNames, groupResourceExportNames) + return injectKcpIdentities(ids), apiIdentityHandler(ids), ids.ready +} + +// apiIdentityHandler returns an informer event handler that keeps the +// identities in sync with the APIIdentity registry: an entry owned by one of +// the core APIExports in the root workspace names that export's identity. +// Entries of other exports and deleted entries are ignored: an identity in +// use is only ever replaced by a new one. +func apiIdentityHandler(ids *identities) cache.ResourceEventHandler { + exportNames := coreExportNames(ids) + update := func(obj interface{}) { + entry, ok := obj.(*adminv1alpha1.APIIdentity) + if !ok { + return + } + owner := entry.Status.Owner + if owner.Cluster != core.RootCluster.String() || !exportNames.Has(owner.Name) { + return + } + ids.set(owner.Name, entry.Name) + } + return cache.ResourceEventHandlerFuncs{ + AddFunc: func(obj interface{}) { update(obj) }, + UpdateFunc: func(_, obj interface{}) { update(obj) }, + } +} + +func coreExportNames(ids *identities) sets.Set[string] { + exportNames := sets.New[string]() + for _, name := range ids.groupExportNames { + exportNames.Insert(name) + } + for _, name := range ids.groupResourceExportNames { + exportNames.Insert(name) + } + return exportNames +} + +// ready fails while the identity of a core APIExport is unknown. +func (ids *identities) ready() error { + ids.lock.RLock() + defer ids.lock.RUnlock() + missing := sets.New[string]() + for group, id := range ids.groupIdentities { + if id == "" { + missing.Insert(ids.groupExportNames[group]) + } + } + for gr, id := range ids.groupResourceIdentities { + if id == "" { + missing.Insert(ids.groupResourceExportNames[gr]) + } + } + if missing.Len() == 0 { + return nil + } + return fmt.Errorf("the identities of the core APIExports %v are not known yet", sets.List(missing)) +} + // apiExportHandler returns an informer event handler that keeps the // identities in sync with status.identityHash of the APIExports in the root // workspace. An identity rotation flips that hash; the handler swaps the @@ -250,14 +323,7 @@ func NewWildcardIdentitiesWrappingRoundTripper(groupExportNames map[string]strin // APIExports: the shard's local one, one against the cache server, or a // scoped one on the root workspace. func apiExportHandler(ids *identities) cache.ResourceEventHandler { - exportNames := sets.New[string]() - for _, name := range ids.groupExportNames { - exportNames.Insert(name) - } - for _, name := range ids.groupResourceExportNames { - exportNames.Insert(name) - } - + exportNames := coreExportNames(ids) update := func(obj interface{}) { export, ok := obj.(*apisv1alpha2.APIExport) if !ok { diff --git a/pkg/server/bootstrap/identity_test.go b/pkg/server/bootstrap/identity_test.go index 6690410d1d7..3413b5ac8ac 100644 --- a/pkg/server/bootstrap/identity_test.go +++ b/pkg/server/bootstrap/identity_test.go @@ -38,6 +38,7 @@ import ( "k8s.io/client-go/tools/cache" "github.com/kcp-dev/logicalcluster/v3" + adminv1alpha1 "github.com/kcp-dev/sdk/apis/admin/v1alpha1" apisv1alpha2 "github.com/kcp-dev/sdk/apis/apis/v1alpha2" ) @@ -162,6 +163,82 @@ func TestAPIExportHandler(t *testing.T) { }) } +func TestAPIIdentityHandler(t *testing.T) { + t.Parallel() + + tenancyPath := "/clusters/*/apis/tenancy.kcp.io/v1alpha1/workspaces" + shardsPath := "/clusters/*/apis/core.kcp.io/v1alpha1/shards" + + t.Run("feeds the identities of the core exports in root and reports readiness", func(t *testing.T) { + t.Parallel() + wrap, handler, ready := NewRegistryWildcardIdentities(testGroupExportNames, testGroupResourceExportNames) + require.NotNil(t, wrap) + require.Error(t, ready(), "nothing is known yet") + + handler.OnAdd(entry("root", "tenancy.kcp.io", oldHash), true) + require.Error(t, ready(), "the shards identity is still unknown") + + handler.OnAdd(entry("root", "shards.core.kcp.io", newHash), true) + require.NoError(t, ready()) + }) + + ignored := []struct { + name string + obj interface{} + }{ + {name: "an entry owned by an export of another workspace", obj: entry("root:org", "tenancy.kcp.io", newHash)}, + {name: "an entry owned by an export that serves no core API", obj: entry("root", "cowboys", newHash)}, + {name: "a tombstone", obj: cache.DeletedFinalStateUnknown{Key: newHash, Obj: entry("root", "tenancy.kcp.io", newHash)}}, + {name: "another object", obj: export("root", "tenancy.kcp.io", newHash)}, + } + for _, scenario := range ignored { + t.Run("ignores "+scenario.name, func(t *testing.T) { + t.Parallel() + ids := newIdentities(testGroupExportNames, testGroupResourceExportNames) + apiIdentityHandler(ids).OnAdd(entry("root", "tenancy.kcp.io", oldHash), true) + + handler := apiIdentityHandler(ids) + handler.OnAdd(scenario.obj, false) + handler.OnUpdate(scenario.obj, scenario.obj) + + if got, want := decorate(t, ids, tenancyPath), tenancyPath+":"+oldHash; got != want { + t.Errorf("expected %q, got %q", want, got) + } + }) + } + + t.Run("replaces the identity of the changed export only", func(t *testing.T) { + t.Parallel() + ids := newIdentities(testGroupExportNames, testGroupResourceExportNames) + handler := apiIdentityHandler(ids) + handler.OnAdd(entry("root", "tenancy.kcp.io", oldHash), true) + handler.OnAdd(entry("root", "shards.core.kcp.io", oldHash), true) + watch := newExpirableWatchBody(io.NopCloser(strings.NewReader("")), true) + ids.trackWatch(oldHash, watch) + + handler.OnUpdate(entry("root", "tenancy.kcp.io", oldHash), entry("root", "tenancy.kcp.io", newHash)) + + if got, want := decorate(t, ids, tenancyPath), tenancyPath+":"+newHash; got != want { + t.Errorf("expected %q, got %q", want, got) + } + if got, want := decorate(t, ids, shardsPath), shardsPath+":"+oldHash; got != want { + t.Errorf("an unchanged export must keep its identity: expected %q, got %q", want, got) + } + if !watch.expired { + t.Error("watches using the replaced identity must be expired") + } + }) +} + +// entry is a registry entry named after the identity and owned by the +// export of the given name in the given cluster. +func entry(cluster, exportName, identity string) *adminv1alpha1.APIIdentity { + return &adminv1alpha1.APIIdentity{ + ObjectMeta: metav1.ObjectMeta{Name: identity}, + Status: adminv1alpha1.APIIdentityStatus{Owner: adminv1alpha1.ExportReference{Cluster: cluster, Name: exportName}}, + } +} + // TestWatchExpiresOnIdentityChange runs a real watch through the round // tripper and checks that an identity change ends it with a 410 Expired // error event, which makes reflectors relist. From 8e0e22cc4777bac78d3bea9c051d15c816fcd78c Mon Sep 17 00:00:00 2001 From: Mangirdas Judeikis Date: Mon, 26 Oct 2026 11:57:07 +0200 Subject: [PATCH 7/7] Service account shard awarness --- docs/content/concepts/sharding/shards.md | 5 +- pkg/proxy/config.go | 48 ++-- pkg/proxy/metrics_authorizer_test.go | 4 +- pkg/proxy/options/authentication.go | 46 ++-- pkg/proxy/options/options.go | 19 +- pkg/proxy/server.go | 57 ++--- pkg/proxy/serviceaccounts.go | 239 ++++++++++++++++++ pkg/proxy/serviceaccounts_test.go | 129 ++++++++++ .../admin/authorizer/authorizer_test.go | 2 +- .../migration_server_test.go | 2 +- 10 files changed, 430 insertions(+), 121 deletions(-) create mode 100644 pkg/proxy/serviceaccounts.go create mode 100644 pkg/proxy/serviceaccounts_test.go diff --git a/docs/content/concepts/sharding/shards.md b/docs/content/concepts/sharding/shards.md index 1a55859109a..93cab46a6e2 100644 --- a/docs/content/concepts/sharding/shards.md +++ b/docs/content/concepts/sharding/shards.md @@ -131,9 +131,8 @@ front-proxy, add a `/services/` path mapping; any shard can be the backend. The front-proxy itself discovers shards through the Admin workspace, and reads the identities of the core APIs from the `apiidentities` registry there, so it starts and runs without the root shard: the kubeconfigs given -via `--shard-peer-kubeconfig` (falling back to the `--root-kubeconfig` -server) are seeds, used to bootstrap discovery and as a last-resort fallback -only. Every shard observed through the view joins the +via `--shard-peer-kubeconfig` are seeds, used to bootstrap discovery and as +a last-resort fallback only. Every shard observed through the view joins the failover peer set at runtime and decommissioned shards leave it; requests are distributed across the discovered shards, not the seeds. With short-lived shards the discovery channel therefore keeps working as long as any currently diff --git a/pkg/proxy/config.go b/pkg/proxy/config.go index 2550562b9e6..385488facad 100644 --- a/pkg/proxy/config.go +++ b/pkg/proxy/config.go @@ -53,15 +53,14 @@ type ExtraConfig struct { // unknown. It is a readiness check: requests needing an identity fail // until it arrives, nothing blocks startup on it. CoreIdentitiesReady func() error - // RootShardConfig points at the root shard and is set only when - // --root-kubeconfig is given. Service account authentication still needs - // it. - RootShardConfig *rest.Config - ShardsConfig *rest.Config + ShardsConfig *rest.Config + // ServiceAccounts resolves service accounts and token secrets on the + // shard hosting them, through the index, for service account + // authentication. + ServiceAccounts *ShardRoutedServiceAccounts // PeersConfig points at the Admin workspace (/services/admin) of the // peer shards with round-robin and per-request failover, and is the only - // source for Shard discovery. When no peers are configured, the root - // kubeconfig's server acts as the seed peer. + // source for Shard discovery and the core identities. PeersConfig *rest.Config // Peers is the failover set behind PeersConfig: the configured seed // peers plus every discovered shard, fed at runtime from the shard @@ -98,24 +97,10 @@ func NewConfig(ctx context.Context, opts *proxyoptions.Options) (*Config, error) var kcpShardIdentityRoundTripper func(rt http.RoundTripper) http.RoundTripper kcpShardIdentityRoundTripper, c.CoreIdentityHandler, c.CoreIdentitiesReady = bootstrap.NewRegistryWildcardIdentities(bootstrap.KcpRootGroupExportNames, bootstrap.KcpRootGroupResourceExportNames) - //nolint:staticcheck // the deprecated root kubeconfig is still needed for service account authentication and as the default seed peer. - if c.Options.RootKubeconfig != "" { - //nolint:staticcheck // see above. - rootConfig, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(&clientcmd.ClientConfigLoadingRules{ExplicitPath: c.Options.RootKubeconfig}, nil).ClientConfig() - if err != nil { - return nil, fmt.Errorf("failed to load root kubeconfig: %w", err) - } - c.RootShardConfig = rest.CopyConfig(rootConfig) - c.RootShardConfig.Wrap(kcpShardIdentityRoundTripper) - } - var loopbackClientConfig *rest.Config if err := c.Options.SecureServing.ApplyTo(&c.ServingInfo, &loopbackClientConfig); err != nil { return nil, err } - if err := c.Options.Authentication.ApplyTo(ctx, &c.AuthenticationInfo, c.ServingInfo, c.RootShardConfig); err != nil { - return nil, err - } var err error c.ShardsConfig, err = clientcmd.NewNonInteractiveDeferredLoadingClientConfig( @@ -128,19 +113,16 @@ func NewConfig(ctx context.Context, opts *proxyoptions.Options) (*Config, error) } c.ShardsConfig.Wrap(kcpShardIdentityRoundTripper) - // Shard discovery and the core identities always go through the Admin - // workspace of the peers; the root shard is never asked. Without - // configured peers, the root kubeconfig's server is the seed peer. - peerKubeconfigs := c.Options.ShardsPeerKubeconfigs - if len(peerKubeconfigs) == 0 { - //nolint:staticcheck // the deprecated root kubeconfig doubles as the default seed peer for deployments that predate --shard-peer-kubeconfig. - if c.Options.RootKubeconfig == "" { - return nil, fmt.Errorf("--shard-peer-kubeconfig is required") - } - //nolint:staticcheck // see above. - peerKubeconfigs = []string{c.Options.RootKubeconfig} + // Service account tokens name their logical cluster; the lookup finds the + // hosting shard through the index and asks it, with the shards credential. + c.ServiceAccounts = NewShardRoutedServiceAccounts(c.ShardsConfig) + if err := c.Options.Authentication.ApplyTo(ctx, &c.AuthenticationInfo, c.ServingInfo, c.ServiceAccounts); err != nil { + return nil, err } - c.PeersConfig, c.Peers, err = NewPeersConfig(peerKubeconfigs) + + // Shard discovery and the core identities always go through the Admin + // workspace of the peers; the root shard is never asked. + c.PeersConfig, c.Peers, err = NewPeersConfig(c.Options.ShardsPeerKubeconfigs) if err != nil { return nil, err } diff --git a/pkg/proxy/metrics_authorizer_test.go b/pkg/proxy/metrics_authorizer_test.go index dc6f889a2b1..35c9a11458e 100644 --- a/pkg/proxy/metrics_authorizer_test.go +++ b/pkg/proxy/metrics_authorizer_test.go @@ -39,8 +39,8 @@ func TestMetricsGroupAuthorizer(t *testing.T) { {name: "kcp admin may scrape", groups: []string{bootstrappolicy.SystemKcpAdminGroup, user.AllAuthenticated}, want: authorizer.DecisionAllow}, {name: "system:masters may scrape", groups: []string{bootstrappolicy.SystemMastersGroup}, want: authorizer.DecisionAllow}, {name: "the metrics-reader group may scrape", groups: []string{bootstrappolicy.SystemKcpMetricsReader}, want: authorizer.DecisionAllow}, - // no opinion, not deny: a further authorizer in the union (the :root - // SubjectAccessReview while --root-kubeconfig is given) may still allow. + // no opinion rather than deny: the authorization filter treats it as + // forbidden, and a further authorizer could still be chained. {name: "an ordinary user gets no opinion", groups: []string{user.AllAuthenticated}, want: authorizer.DecisionNoOpinion}, {name: "no groups gets no opinion", groups: nil, want: authorizer.DecisionNoOpinion}, } { diff --git a/pkg/proxy/options/authentication.go b/pkg/proxy/options/authentication.go index 973ed863947..6d321cfae91 100644 --- a/pkg/proxy/options/authentication.go +++ b/pkg/proxy/options/authentication.go @@ -20,27 +20,29 @@ import ( "context" "fmt" "strings" - "time" "github.com/spf13/pflag" "k8s.io/apimachinery/pkg/util/sets" "k8s.io/apiserver/pkg/authentication/user" genericapiserver "k8s.io/apiserver/pkg/server" - "k8s.io/client-go/rest" "k8s.io/client-go/util/keyutil" - serviceaccountcontroller "k8s.io/kubernetes/pkg/controller/serviceaccount" kubeoptions "k8s.io/kubernetes/pkg/kubeapiserver/options" "k8s.io/kubernetes/pkg/serviceaccount" - kcpkubernetesinformers "github.com/kcp-dev/client-go/informers" - kcpkubernetesclientset "github.com/kcp-dev/client-go/kubernetes" + kcpcorev1client "github.com/kcp-dev/client-go/kubernetes/typed/core/v1" kcpauthentication "github.com/kcp-dev/kcp/pkg/authentication" "github.com/kcp-dev/kcp/pkg/authorization/bootstrap" ) -const resyncPeriod = 10 * time.Hour +// ServiceAccountLookup resolves the service accounts and token secrets that +// service account tokens name, on whichever shard hosts them, and gives the +// legacy token validator a writer for its last-used annotations. +type ServiceAccountLookup interface { + serviceaccount.ServiceAccountTokenClusterGetter + SecretsWriter() kcpcorev1client.SecretClusterInterface +} // Authentication wraps BuiltInAuthenticationOptions so we can minimize the // dependencies on apiserver auth machinery, specifically by overriding the @@ -96,17 +98,15 @@ func (c *Authentication) tokenAuthEnabled() bool { return c.BuiltInOptions.TokenFile != nil && c.BuiltInOptions.TokenFile.TokenFile != "" } -// ServiceAccountAuthEnabled reports whether service account tokens are -// accepted, which needs a client to the root shard. -func (c *Authentication) ServiceAccountAuthEnabled() bool { - return c.serviceAccountAuthEnabled() -} - func (c *Authentication) serviceAccountAuthEnabled() bool { return c.BuiltInOptions.ServiceAccounts != nil && len(c.BuiltInOptions.ServiceAccounts.KeyFiles) != 0 } -func (c *Authentication) ApplyTo(ctx context.Context, authenticationInfo *genericapiserver.AuthenticationInfo, servingInfo *genericapiserver.SecureServingInfo, rootShardConfig *rest.Config) error { +// ApplyTo builds the authenticator. serviceAccounts resolves the service +// accounts and token secrets that service account tokens name, on whichever +// shard hosts them; it is only consulted when service account authentication +// is enabled. +func (c *Authentication) ApplyTo(ctx context.Context, authenticationInfo *genericapiserver.AuthenticationInfo, servingInfo *genericapiserver.SecureServingInfo, serviceAccounts ServiceAccountLookup) error { // Note BuiltInAuthenticationOptions.ApplyTo is not called, so we // can reduce the dependencies pulled in from auth methods which aren't enabled authenticatorConfig, err := c.BuiltInOptions.ToAuthenticationConfig() @@ -135,28 +135,16 @@ func (c *Authentication) ApplyTo(ctx context.Context, authenticationInfo *generi // Set for service account auth, if enabled if c.serviceAccountAuthEnabled() { - if rootShardConfig == nil { - return fmt.Errorf("service account authentication needs --root-kubeconfig") + if serviceAccounts == nil { + return fmt.Errorf("service account authentication needs a service account lookup") } authenticationInfo.APIAudiences = c.BuiltInOptions.APIAudiences if len(c.BuiltInOptions.ServiceAccounts.Issuers) != 0 && len(c.BuiltInOptions.APIAudiences) == 0 { authenticationInfo.APIAudiences = c.BuiltInOptions.ServiceAccounts.Issuers } - config := rest.CopyConfig(rootShardConfig) - tokenGetterClient, err := kcpkubernetesclientset.NewForConfig(config) - if err != nil { - return fmt.Errorf("failed to create client for ServiceAccountTokenGetter: %w", err) - } - - versionedInformers := kcpkubernetesinformers.NewSharedInformerFactory(tokenGetterClient, resyncPeriod) - - authenticatorConfig.ServiceAccountTokenGetter = serviceaccountcontroller.NewClusterGetterFromClient( - tokenGetterClient, - versionedInformers.Core().V1().Secrets().Lister(), - versionedInformers.Core().V1().ServiceAccounts().Lister(), - ) - authenticatorConfig.SecretsWriter = tokenGetterClient.CoreV1().Secrets() + authenticatorConfig.ServiceAccountTokenGetter = serviceAccounts + authenticatorConfig.SecretsWriter = serviceAccounts.SecretsWriter() if len(c.BuiltInOptions.ServiceAccounts.KeyFiles) > 0 { // Load and set the public keys. diff --git a/pkg/proxy/options/options.go b/pkg/proxy/options/options.go index 3f6e64c8529..e59a0c6c2bf 100644 --- a/pkg/proxy/options/options.go +++ b/pkg/proxy/options/options.go @@ -30,10 +30,11 @@ type Options struct { Authentication Authentication MappingFile string RootDirectory string - // RootKubeconfig points at the root shard. + // RootKubeconfig is accepted so that existing deployments keep starting. // - // TODO: This is going to be removed once service account - // authentication no longer requires the root shard. + // Deprecated: ignored. The proxy does not use the root shard; shard + // discovery, the core identities and service account lookups go through + // the peers. RootKubeconfig string ShardsKubeconfig string ShardsPeerKubeconfigs []string @@ -45,7 +46,6 @@ func NewOptions() *Options { o := &Options{ SecureServing: *apiserveroptions.NewSecureServingOptions().WithLoopback(), Authentication: *NewAuthentication(), - RootKubeconfig: "", RootDirectory: ".kcp", } @@ -63,12 +63,12 @@ func (o *Options) AddFlags(fss *cliflag.NamedFlagSets) { fs := fss.FlagSet("proxy") fs.StringVar(&o.MappingFile, "mapping-file", o.MappingFile, "Config file mapping paths to backends") fs.StringVar(&o.RootDirectory, "root-directory", o.RootDirectory, "Root directory.") - fs.StringVar(&o.RootKubeconfig, "root-kubeconfig", o.RootKubeconfig, "The path to the kubeconfig of the root shard. Only needed for service account authentication; without --shard-peer-kubeconfig its server also acts as the seed peer.") - if err := fs.MarkDeprecated("root-kubeconfig", "use --shard-peer-kubeconfig; the root shard is needed neither for shard discovery nor for the identities of the core APIs any more. It is still required for service account authentication."); err != nil { + fs.StringVar(&o.RootKubeconfig, "root-kubeconfig", "", "Ignored. The proxy does not use the root shard any more.") + if err := fs.MarkDeprecated("root-kubeconfig", "it is ignored; the proxy discovers shards, the core identities and service accounts through --shard-peer-kubeconfig."); err != nil { panic(err) } fs.StringVar(&o.ShardsKubeconfig, "shards-kubeconfig", o.ShardsKubeconfig, "The path to the kubeconfig used for communication with all shards. The server name if provided is replaced with a shard's hostname.") - fs.StringSliceVar(&o.ShardsPeerKubeconfigs, "shard-peer-kubeconfig", o.ShardsPeerKubeconfigs, "Repeatable path to a kubeconfig whose named clusters are seed peer shards; all files' clusters are merged into one seed list. The proxy discovers Shards and the identities of the core APIs through the Admin workspace (/services/admin) of the peers - requests are distributed round-robin with failover - and never talks to the root shard for them. If unset, the --root-kubeconfig server acts as the only seed peer. The seeds are used for bootstrapping and as a last-resort fallback only: once shards are discovered, requests go to the discovered shards, which join and leave the peer set at runtime, so the files do not have to track short-lived shards.") + fs.StringSliceVar(&o.ShardsPeerKubeconfigs, "shard-peer-kubeconfig", o.ShardsPeerKubeconfigs, "Repeatable path to a kubeconfig whose named clusters are seed peer shards; all files' clusters are merged into one seed list. The proxy discovers Shards and the identities of the core APIs through the Admin workspace (/services/admin) of the peers - requests are distributed round-robin with failover - and never talks to the root shard for them. The seeds are used for bootstrapping and as a last-resort fallback only: once shards are discovered, requests go to the discovered shards, which join and leave the peer set at runtime, so the files do not have to track short-lived shards.") fs.StringVar(&o.ProfilerAddress, "profiler-address", "", "[Address]:port to bind the profiler to") fs.StringSliceVar(&o.CorsAllowedOriginList, "cors-allowed-origins", o.CorsAllowedOriginList, "List of allowed origins for CORS, comma separated. An allowed origin can be a regular expression to support subdomain matching. If this list is empty CORS will not be enabled.") } @@ -101,12 +101,9 @@ func (o *Options) Validate() []error { if len(o.ShardsKubeconfig) == 0 { errs = append(errs, fmt.Errorf("--shards-kubeconfig is required")) } - if len(o.ShardsPeerKubeconfigs) == 0 && len(o.RootKubeconfig) == 0 { + if len(o.ShardsPeerKubeconfigs) == 0 { errs = append(errs, fmt.Errorf("--shard-peer-kubeconfig is required")) } - if o.Authentication.ServiceAccountAuthEnabled() && len(o.RootKubeconfig) == 0 { - errs = append(errs, fmt.Errorf("--root-kubeconfig is required for service account authentication")) - } errs = append(errs, o.SecureServing.Validate()...) errs = append(errs, o.Authentication.Validate()...) diff --git a/pkg/proxy/server.go b/pkg/proxy/server.go index 58649663037..67f0ece0fee 100644 --- a/pkg/proxy/server.go +++ b/pkg/proxy/server.go @@ -31,7 +31,6 @@ import ( "k8s.io/apimachinery/pkg/runtime/serializer" "k8s.io/apiserver/pkg/authentication/authenticator" "k8s.io/apiserver/pkg/authorization/authorizer" - "k8s.io/apiserver/pkg/authorization/union" genericapifilters "k8s.io/apiserver/pkg/endpoints/filters" "k8s.io/apiserver/pkg/endpoints/request" genericfilters "k8s.io/apiserver/pkg/server/filters" @@ -41,7 +40,6 @@ import ( "k8s.io/component-base/metrics/legacyregistry" "k8s.io/klog/v2" - kcpkubernetesclientset "github.com/kcp-dev/client-go/kubernetes" adminv1alpha1 "github.com/kcp-dev/sdk/apis/admin/v1alpha1" "github.com/kcp-dev/sdk/apis/core" corev1alpha1 "github.com/kcp-dev/sdk/apis/core/v1alpha1" @@ -49,7 +47,6 @@ import ( kcpinformers "github.com/kcp-dev/sdk/client/informers/externalversions" "github.com/kcp-dev/kcp/pkg/authentication" - "github.com/kcp-dev/kcp/pkg/authorization/delegated" kcpfeatures "github.com/kcp-dev/kcp/pkg/features" frontproxyfilters "github.com/kcp-dev/kcp/pkg/proxy/filters" "github.com/kcp-dev/kcp/pkg/proxy/index" @@ -160,6 +157,7 @@ func NewServer(ctx context.Context, c CompletedConfig) (*Server, error) { // watching a number of resources on each. The controller is then also satisfying the Index // interface. s.IndexController = index.NewController(ctx, s.KcpSharedInformerFactory.Core().V1alpha1().Shards(), getClientFunc) + s.CompletedConfig.ServiceAccounts.SetIndex(s.IndexController) handler, err := NewHandler(ctx, mappings) if err != nil { @@ -223,16 +221,9 @@ func NewServer(ctx context.Context, c CompletedConfig) (*Server, error) { // the endpoint is world-readable and leaks shard-wide operational data // (logical-cluster counts, client-cert expiry, request patterns). Members // of the system:kcp:admin, system:masters and system:kcp:metrics-reader - // groups may scrape; with --root-kubeconfig a :root SubjectAccessReview - // is consulted as well, so the system:kcp:metrics-reader binding that - // authorizes scraping on the shards keeps working here during the - // deprecation. Registered before "/" so it takes precedence over the proxy - // handler for this exact path. - metricsHandler, err := newProtectedMetricsHandler(c.AuthenticationInfo.Authenticator, c.RootShardConfig, requestInfoFactory) - if err != nil { - return nil, fmt.Errorf("failed to create protected metrics handler: %w", err) - } - mux.Handle("/metrics", metricsHandler) + // groups may scrape. Registered before "/" so it takes precedence over + // the proxy handler for this exact path. + mux.Handle("/metrics", withMetricsAuthorization(legacyregistry.Handler(), c.AuthenticationInfo.Authenticator, newMetricsGroupAuthorizer(), requestInfoFactory)) mux.Handle("/", handler) s.Handler = mux @@ -256,23 +247,6 @@ func (w cacheSyncWaiters) WaitForCacheSync(stopCh <-chan struct{}) map[reflect.T return merged } -func newProtectedMetricsHandler(auth authenticator.Request, rootShardConfig *restclient.Config, requestInfoResolver request.RequestInfoResolver) (http.Handler, error) { - authorizers := []authorizer.Authorizer{newMetricsGroupAuthorizer()} - if rootShardConfig != nil { - kubeClusterClient, err := kcpkubernetesclientset.NewForConfig(rootShardConfig) - if err != nil { - return nil, fmt.Errorf("failed to create root shard client for metrics authorization: %w", err) - } - authz, err := delegated.NewDelegatedAuthorizer(core.RootCluster, kubeClusterClient, delegated.Options{}) - if err != nil { - return nil, fmt.Errorf("failed to create metrics authorizer: %w", err) - } - authorizers = append(authorizers, authz) - } - - return withMetricsAuthorization(legacyregistry.Handler(), auth, union.New(authorizers...), requestInfoResolver), nil -} - func withMetricsAuthorization(h http.Handler, auth authenticator.Request, authz authorizer.Authorizer, requestInfoResolver request.RequestInfoResolver) http.Handler { scheme := runtime.NewScheme() metav1.AddToGroupVersion(scheme, schema.GroupVersion{Version: "v1"}) @@ -297,23 +271,24 @@ func (s preparedServer) Run(ctx context.Context) error { logger := klog.FromContext(ctx).WithValues("component", "proxy") // Both factories read the Admin workspace of the peers: any reachable - // shard suffices, the root shard is not special. The core identities - // arrive through the second one; until they do, requests that need them - // fail and readiness reports it, nothing blocks here on the root shard. + // shard suffices, the root shard is not special. Nothing is awaited + // before serving: /readyz reports the informers' sync and the core + // identities, and requests that need either fail until they are there. s.KcpSharedInformerFactory.Start(ctx.Done()) s.CoreIdentityInformerFactory.Start(ctx.Done()) - s.KcpSharedInformerFactory.WaitForCacheSync(ctx.Done()) - s.CoreIdentityInformerFactory.WaitForCacheSync(ctx.Done()) - if err := s.CompletedConfig.CoreIdentitiesReady(); err != nil { - logger.Info("starting without all core identities, waiting for the registry", "reason", err.Error()) - } - - // start indexes go s.IndexController.Start(ctx, 2) - if s.AuthController != nil { go s.AuthController.Start(ctx, 2) } + go func() { + s.KcpSharedInformerFactory.WaitForCacheSync(ctx.Done()) + s.CoreIdentityInformerFactory.WaitForCacheSync(ctx.Done()) + if err := s.CompletedConfig.CoreIdentitiesReady(); err != nil { + logger.Info("informers synced, core identities not all known yet", "reason", err.Error()) + return + } + logger.Info("informers synced, core identities known") + }() doneCh, _, err := s.CompletedConfig.ServingInfo.Serve(s.Handler, time.Second*60, ctx.Done()) if err != nil { diff --git a/pkg/proxy/serviceaccounts.go b/pkg/proxy/serviceaccounts.go new file mode 100644 index 00000000000..154713e0038 --- /dev/null +++ b/pkg/proxy/serviceaccounts.go @@ -0,0 +1,239 @@ +/* +Copyright 2026 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 proxy + +import ( + "context" + "sync" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/watch" + corev1ac "k8s.io/client-go/applyconfigurations/core/v1" + typedcorev1 "k8s.io/client-go/kubernetes/typed/core/v1" + "k8s.io/client-go/rest" + "k8s.io/kubernetes/pkg/serviceaccount" + + kcpkubernetesclientset "github.com/kcp-dev/client-go/kubernetes" + kcpcorev1client "github.com/kcp-dev/client-go/kubernetes/typed/core/v1" + "github.com/kcp-dev/logicalcluster/v3" + + kcpindex "github.com/kcp-dev/kcp/pkg/index" +) + +// ShardRoutedServiceAccounts looks ServiceAccounts and their token Secrets up +// on the shard that hosts the logical cluster a service account token names, +// found through the proxy's index, using the shards credential. It is what +// lets the proxy validate service account tokens of workspaces on any shard +// without a connection to the root shard. +// +// It serves two roles for the authenticator: the token getter, through +// Cluster, and the writer the legacy token validator stamps last-used +// annotations with, through SecretsWriter. +type ShardRoutedServiceAccounts struct { + shardsConfig *rest.Config + + mu sync.Mutex + index shardIndex // nil until the index controller exists + clients map[string]kcpkubernetesclientset.ClusterInterface // by shard base URL +} + +// NewShardRoutedServiceAccounts creates the lookup over the shards credential. +// The index is attached with SetIndex once the index controller exists; +// lookups before that fail with ServiceUnavailable. +func NewShardRoutedServiceAccounts(shardsConfig *rest.Config) *ShardRoutedServiceAccounts { + return &ShardRoutedServiceAccounts{ + shardsConfig: shardsConfig, + clients: map[string]kcpkubernetesclientset.ClusterInterface{}, + } +} + +// shardIndex is the part of the proxy's index the lookups need: which shard +// hosts a logical cluster, and where that shard is. +type shardIndex interface { + Lookup(path logicalcluster.Path) (kcpindex.Result, bool) + BaseURL(shardName string) (string, bool) +} + +// SetIndex attaches the index the lookups resolve shards through. +func (s *ShardRoutedServiceAccounts) SetIndex(index shardIndex) { + s.mu.Lock() + defer s.mu.Unlock() + s.index = index +} + +// clientFor returns a client for the shard hosting the logical cluster. +func (s *ShardRoutedServiceAccounts) clientFor(cluster logicalcluster.Name) (kcpkubernetesclientset.ClusterInterface, error) { + s.mu.Lock() + index := s.index + s.mu.Unlock() + if index == nil { + return nil, apierrors.NewServiceUnavailable("the shard index is not started yet") + } + + result, found := index.Lookup(cluster.Path()) + if !found || result.ErrorCode != 0 || result.Shard == "" { + return nil, apierrors.NewNotFound(corev1.Resource("logicalclusters"), cluster.String()) + } + baseURL, ok := index.BaseURL(result.Shard) + if !ok { + return nil, apierrors.NewNotFound(corev1.Resource("shards"), result.Shard) + } + + s.mu.Lock() + defer s.mu.Unlock() + if client, ok := s.clients[baseURL]; ok { + return client, nil + } + config := rest.CopyConfig(s.shardsConfig) + config.Host = baseURL + client, err := kcpkubernetesclientset.NewForConfig(config) + if err != nil { + return nil, err + } + s.clients[baseURL] = client + return client, nil +} + +// Cluster implements serviceaccount.ServiceAccountTokenClusterGetter. +func (s *ShardRoutedServiceAccounts) Cluster(cluster logicalcluster.Name) serviceaccount.ServiceAccountTokenGetter { + return clusterServiceAccounts{lookup: s, cluster: cluster} +} + +// SecretsWriter returns the secret client the legacy token validator writes +// through, routed the same way as the lookups. +func (s *ShardRoutedServiceAccounts) SecretsWriter() kcpcorev1client.SecretClusterInterface { + return routedSecrets{lookup: s} +} + +type clusterServiceAccounts struct { + lookup *ShardRoutedServiceAccounts + cluster logicalcluster.Name +} + +func (c clusterServiceAccounts) GetServiceAccount(ctx context.Context, namespace, name string) (*corev1.ServiceAccount, error) { + client, err := c.lookup.clientFor(c.cluster) + if err != nil { + return nil, err + } + return client.Cluster(c.cluster.Path()).CoreV1().ServiceAccounts(namespace).Get(ctx, name, metav1.GetOptions{}) +} + +func (c clusterServiceAccounts) GetSecret(ctx context.Context, namespace, name string) (*corev1.Secret, error) { + client, err := c.lookup.clientFor(c.cluster) + if err != nil { + return nil, err + } + return client.Cluster(c.cluster.Path()).CoreV1().Secrets(namespace).Get(ctx, name, metav1.GetOptions{}) +} + +// GetPod and GetNode back bound tokens of pods and nodes, which do not exist +// in kcp. +func (c clusterServiceAccounts) GetPod(_ context.Context, namespace, name string) (*corev1.Pod, error) { + return nil, apierrors.NewNotFound(corev1.Resource("pods"), namespace+"/"+name) +} + +func (c clusterServiceAccounts) GetNode(_ context.Context, name string) (*corev1.Node, error) { + return nil, apierrors.NewNotFound(corev1.Resource("nodes"), name) +} + +// routedSecrets is a SecretClusterInterface that routes per logical cluster; +// listing or watching across clusters is not something the proxy does. +type routedSecrets struct { + lookup *ShardRoutedServiceAccounts +} + +func (r routedSecrets) Cluster(path logicalcluster.Path) kcpcorev1client.SecretsNamespacer { + cluster, ok := path.Name() + if !ok { + return erroringSecretsNamespacer{err: apierrors.NewBadRequest("secrets are addressed by logical cluster name, not path " + path.String())} + } + client, err := r.lookup.clientFor(cluster) + if err != nil { + return erroringSecretsNamespacer{err: err} + } + return clusterSecretsNamespacer{secrets: client.Cluster(cluster.Path()).CoreV1()} +} + +func (routedSecrets) List(context.Context, metav1.ListOptions) (*corev1.SecretList, error) { + return nil, apierrors.NewMethodNotSupported(corev1.Resource("secrets"), "list") +} + +func (routedSecrets) Watch(context.Context, metav1.ListOptions) (watch.Interface, error) { + return nil, apierrors.NewMethodNotSupported(corev1.Resource("secrets"), "watch") +} + +type clusterSecretsNamespacer struct { + secrets typedcorev1.SecretsGetter +} + +func (n clusterSecretsNamespacer) Namespace(namespace string) typedcorev1.SecretInterface { + return n.secrets.Secrets(namespace) +} + +// erroringSecretsNamespacer answers every call with the lookup error. +type erroringSecretsNamespacer struct { + err error +} + +func (n erroringSecretsNamespacer) Namespace(string) typedcorev1.SecretInterface { + return erroringSecrets(n) +} + +type erroringSecrets struct { + err error +} + +var _ typedcorev1.SecretInterface = erroringSecrets{} + +func (e erroringSecrets) Create(context.Context, *corev1.Secret, metav1.CreateOptions) (*corev1.Secret, error) { + return nil, e.err +} + +func (e erroringSecrets) Update(context.Context, *corev1.Secret, metav1.UpdateOptions) (*corev1.Secret, error) { + return nil, e.err +} + +func (e erroringSecrets) Delete(context.Context, string, metav1.DeleteOptions) error { + return e.err +} + +func (e erroringSecrets) DeleteCollection(context.Context, metav1.DeleteOptions, metav1.ListOptions) error { + return e.err +} + +func (e erroringSecrets) Get(context.Context, string, metav1.GetOptions) (*corev1.Secret, error) { + return nil, e.err +} + +func (e erroringSecrets) List(context.Context, metav1.ListOptions) (*corev1.SecretList, error) { + return nil, e.err +} + +func (e erroringSecrets) Watch(context.Context, metav1.ListOptions) (watch.Interface, error) { + return nil, e.err +} + +func (e erroringSecrets) Patch(context.Context, string, types.PatchType, []byte, metav1.PatchOptions, ...string) (*corev1.Secret, error) { + return nil, e.err +} + +func (e erroringSecrets) Apply(context.Context, *corev1ac.SecretApplyConfiguration, metav1.ApplyOptions) (*corev1.Secret, error) { + return nil, e.err +} diff --git a/pkg/proxy/serviceaccounts_test.go b/pkg/proxy/serviceaccounts_test.go new file mode 100644 index 00000000000..2fc7971951b --- /dev/null +++ b/pkg/proxy/serviceaccounts_test.go @@ -0,0 +1,129 @@ +/* +Copyright 2026 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 proxy + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/require" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/rest" + + "github.com/kcp-dev/logicalcluster/v3" + + kcpindex "github.com/kcp-dev/kcp/pkg/index" +) + +// fakeIndex maps logical clusters to shards and shards to base URLs. +type fakeIndex struct { + shards map[logicalcluster.Name]string + urls map[string]string +} + +func (f fakeIndex) Lookup(path logicalcluster.Path) (kcpindex.Result, bool) { + name, ok := path.Name() + if !ok { + return kcpindex.Result{}, false + } + shard, ok := f.shards[name] + if !ok { + return kcpindex.Result{}, false + } + return kcpindex.Result{Shard: shard, Cluster: name, URL: f.urls[shard]}, true +} + +func (f fakeIndex) BaseURL(shard string) (string, bool) { + url, ok := f.urls[shard] + return url, ok +} + +// TestShardRoutedServiceAccounts walks the lookup through its states in +// order: before the index exists, with a known cluster, with an unknown one. +func TestShardRoutedServiceAccounts(t *testing.T) { + t.Parallel() + + // a fake shard answering the lookups the authenticator makes + var requests atomic.Int32 + shard := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/clusters/ws1/api/v1/namespaces/default/serviceaccounts/sa": + _ = json.NewEncoder(w).Encode(&corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: "sa", Namespace: "default", UID: "sa-uid"}}) + case r.Method == http.MethodGet && r.URL.Path == "/clusters/ws1/api/v1/namespaces/default/secrets/token": + _ = json.NewEncoder(w).Encode(&corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "token", Namespace: "default"}}) + case r.Method == http.MethodPatch && r.URL.Path == "/clusters/ws1/api/v1/namespaces/default/secrets/token": + _ = json.NewEncoder(w).Encode(&corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "token", Namespace: "default", Annotations: map[string]string{"patched": "true"}}}) + default: + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"kind":"Status","apiVersion":"v1","status":"Failure","reason":"NotFound","code":404}`)) + } + })) + t.Cleanup(shard.Close) + + config := &rest.Config{Host: "https://kcp.io/fake", TLSClientConfig: rest.TLSClientConfig{Insecure: true}} + lookup := NewShardRoutedServiceAccounts(config) + ctx := context.Background() + + t.Log("fails closed before the index exists") + _, err := lookup.Cluster("ws1").GetServiceAccount(ctx, "default", "sa") + require.True(t, apierrors.IsServiceUnavailable(err), "got %v", err) + + lookup.SetIndex(fakeIndex{ + shards: map[logicalcluster.Name]string{"ws1": "shard-a"}, + urls: map[string]string{"shard-a": shard.URL}, + }) + + t.Log("looks the service account and secret up on the hosting shard") + sa, err := lookup.Cluster("ws1").GetServiceAccount(ctx, "default", "sa") + require.NoError(t, err) + require.Equal(t, types.UID("sa-uid"), sa.UID) + secret, err := lookup.Cluster("ws1").GetSecret(ctx, "default", "token") + require.NoError(t, err) + require.Equal(t, "token", secret.Name) + + t.Log("writes the token secret on the hosting shard") + secret, err = lookup.SecretsWriter().Cluster(logicalcluster.NewPath("ws1")).Namespace("default").Patch(ctx, "token", types.MergePatchType, []byte(`{}`), metav1.PatchOptions{}) + require.NoError(t, err) + require.Equal(t, "true", secret.Annotations["patched"]) + require.Positive(t, requests.Load(), "the shard must have been asked") + + t.Log("a cluster the index does not know is not found") + _, err = lookup.Cluster("unknown").GetServiceAccount(ctx, "default", "sa") + require.True(t, apierrors.IsNotFound(err), "got %v", err) + _, err = lookup.SecretsWriter().Cluster(logicalcluster.NewPath("unknown")).Namespace("default").Get(ctx, "token", metav1.GetOptions{}) + require.True(t, apierrors.IsNotFound(err), "got %v", err) + + t.Log("pods and nodes do not exist in kcp") + _, err = lookup.Cluster("ws1").GetPod(ctx, "default", "pod") + require.True(t, apierrors.IsNotFound(err)) + _, err = lookup.Cluster("ws1").GetNode(ctx, "node") + require.True(t, apierrors.IsNotFound(err)) + + t.Log("listing across clusters is not supported") + _, err = lookup.SecretsWriter().List(ctx, metav1.ListOptions{}) + require.True(t, apierrors.IsMethodNotSupported(err), "got %v", err) +} diff --git a/pkg/virtual/admin/authorizer/authorizer_test.go b/pkg/virtual/admin/authorizer/authorizer_test.go index 00ac95f562b..5173ccb6625 100644 --- a/pkg/virtual/admin/authorizer/authorizer_test.go +++ b/pkg/virtual/admin/authorizer/authorizer_test.go @@ -49,7 +49,7 @@ func TestAdminAuthorizer(t *testing.T) { }, { // The front-proxy discovers shards through this view with its - // --root-kubeconfig client certificate, which carries + // --shard-peer-kubeconfig client certificate, which carries // system:masters. Denying it here would break shard discovery, so // this case is the regression guard. name: "system:masters may read", diff --git a/test/e2e/logicalclustermigration/migration_server_test.go b/test/e2e/logicalclustermigration/migration_server_test.go index 779c76ed39b..c3c24c65d72 100644 --- a/test/e2e/logicalclustermigration/migration_server_test.go +++ b/test/e2e/logicalclustermigration/migration_server_test.go @@ -144,7 +144,7 @@ func privateMigrationServerWithArgs(t *testing.T, shardArgs map[string][]string) command = append(command, "--bind-address=127.0.0.1", "--secure-port="+proxyPort, "--root-directory="+filepath.Join(dataDir, "proxy"), - "--root-kubeconfig="+rootConfig, "--shard-peer-kubeconfig="+rootConfig, + "--shard-peer-kubeconfig="+rootConfig, "--shards-kubeconfig="+rootConfig, "--mapping-file="+mappingFile, "--client-ca-file="+clientCAFile, "--tls-cert-file="+certFile, "--tls-private-key-file="+keyFile,