From 154d5327d6f84b7bcf3e3611f94a3ebfe0f36fe1 Mon Sep 17 00:00:00 2001 From: Mangirdas Judeikis Date: Fri, 23 Oct 2026 16:26:17 +0300 Subject: [PATCH 1/9] 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/9] 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/9] 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/9] 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/9] 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 6414488d2cd3e80a490a5daad655a31a1b747c03 Mon Sep 17 00:00:00 2001 From: Mangirdas Judeikis Date: Mon, 26 Oct 2026 14:34:21 +0200 Subject: [PATCH 6/9] Rework identity to be virtual/projected resource --- config/crds/admin.kcp.io_apiidentities.yaml | 23 +- docs/content/concepts/apis/exporting-apis.md | 22 +- docs/content/concepts/sharding/shards.md | 7 +- pkg/apiidentity/projector.go | 433 ++++++++++++++++++ pkg/apiidentity/projector_test.go | 305 ++++++++++++ pkg/cache/client/shard/shard.go | 6 - pkg/cache/server/bootstrap/bootstrap.go | 1 - .../admin/apiidentity/controller.go | 359 --------------- .../admin/apiidentity/controller_test.go | 361 --------------- pkg/reconciler/admin/apiidentity/registry.go | 53 --- .../apis/apiexport/apiexport_controller.go | 53 --- .../apiexport/apiexport_controller_test.go | 9 +- .../apis/apiexport/apiexport_events_test.go | 2 +- .../apiexport/apiexport_reconcile_identity.go | 130 +----- .../apiexport_reconcile_identity_test.go | 181 -------- pkg/server/controllers.go | 46 -- pkg/server/options/options.go | 13 +- pkg/server/server.go | 7 - pkg/virtual/admin/builder/apiidentities.go | 144 +++--- pkg/virtual/admin/builder/build.go | 6 +- pkg/virtual/admin/builder/build_test.go | 86 ++-- pkg/virtual/admin/builder/forwarding.go | 15 + pkg/virtual/admin/options/options.go | 9 + pkg/virtual/options/options.go | 1 + .../apis/admin/v1alpha1/types_apiidentity.go | 28 +- .../sdk/apis/apis/v1alpha2/types_apiexport.go | 3 - .../admin/v1alpha1/apiidentity.go | 10 +- .../admin/v1alpha1/apiidentitystatus.go | 11 +- .../admin/v1alpha1/holder.go | 2 +- .../sdk/openapi/zz_generated.openapi.go | 8 +- .../apiexport_identity_registry_test.go | 21 +- 31 files changed, 952 insertions(+), 1403 deletions(-) create mode 100644 pkg/apiidentity/projector.go create mode 100644 pkg/apiidentity/projector_test.go delete mode 100644 pkg/reconciler/admin/apiidentity/controller.go delete mode 100644 pkg/reconciler/admin/apiidentity/controller_test.go delete mode 100644 pkg/reconciler/admin/apiidentity/registry.go delete mode 100644 pkg/reconciler/apis/apiexport/apiexport_reconcile_identity_test.go diff --git a/config/crds/admin.kcp.io_apiidentities.yaml b/config/crds/admin.kcp.io_apiidentities.yaml index 6be6a0e5aab..681b9a3eae3 100644 --- a/config/crds/admin.kcp.io_apiidentities.yaml +++ b/config/crds/admin.kcp.io_apiidentities.yaml @@ -41,12 +41,10 @@ spec: 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. + APIIdentity is the view of one API identity: which APIExport owns it and + which other APIExports hold it. It is computed from the APIExports of the + installation and not stored anywhere; there is one per identity, named + after the identity value, served read-only through the Admin workspace. properties: apiVersion: description: |- @@ -124,9 +122,8 @@ spec: 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. + identity: those whose identity secret carries the same key, oldest + first. items: description: Holder is an APIExport, other than the owner, that serves the identity. @@ -141,7 +138,7 @@ spec: minLength: 1 type: string since: - description: since is when the holder was recorded. + description: since is when the holder was created. format: date-time type: string uid: @@ -161,10 +158,8 @@ spec: 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. An entry whose owner is gone and that has no holder left - is removed. + oldest one holding it. When it is deleted the oldest holder succeeds + it; an identity no APIExport holds any more has no entry. 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 32d65f0d98a..82eae7f7a33 100644 --- a/docs/content/concepts/apis/exporting-apis.md +++ b/docs/content/concepts/apis/exporting-apis.md @@ -178,18 +178,16 @@ Two `APIExports` whose secrets carry the same `key` have the same identity: they 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 (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. +kcp shows every identity as an `APIIdentity` (`admin.kcp.io`), listed read-only in the +[Admin workspace](../sharding/shards.md#the-admin-workspace-admin) with `kubectl ws use :admin`. The view is computed +from the `APIExports` of all shards, as replicated to the cache server; nothing is stored. The oldest `APIExport` holding +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 view does not change who may serve an identity, +which is still whoever has the secret; it makes sharing visible. + +When the owner's `APIExport` is deleted, the oldest holder becomes the owner. An identity no `APIExport` holds any more +has no entry; the next `APIExport` whose secret carries the original `key` brings it back. +`status.identityHash` is immutable once set. ### Permission Claims diff --git a/docs/content/concepts/sharding/shards.md b/docs/content/concepts/sharding/shards.md index 399909de99f..d1c77bd5217 100644 --- a/docs/content/concepts/sharding/shards.md +++ b/docs/content/concepts/sharding/shards.md @@ -108,10 +108,11 @@ 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: +The Admin workspace also serves the API identity view 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 +installation, computed from the replicated `APIExports`, 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 diff --git a/pkg/apiidentity/projector.go b/pkg/apiidentity/projector.go new file mode 100644 index 00000000000..b94e5fe1e78 --- /dev/null +++ b/pkg/apiidentity/projector.go @@ -0,0 +1,433 @@ +/* +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 derives the APIIdentity view from APIExports. An +// identity is held by every APIExport whose status carries its hash; the +// view names the owner and the other holders. Nothing is stored: the view +// is computed from the APIExports replicated to the cache server, which are +// ordinary per-shard data, so it is the same on every shard and needs no +// coordination between them. +package apiidentity + +import ( + "sort" + "strconv" + "sync" + + "k8s.io/apimachinery/pkg/api/equality" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/apimachinery/pkg/watch" + "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" + "github.com/kcp-dev/sdk/apis/core" + "github.com/kcp-dev/sdk/apis/third_party/conditions/util/conditions" + + "github.com/kcp-dev/kcp/pkg/server/bootstrap" + "github.com/kcp-dev/kcp/pkg/tombstone" +) + +// IsCoreExport reports whether the export is one of the core APIExports in +// the root workspace, whose identities every shard needs before it can serve. +func IsCoreExport(export *apisv1alpha2.APIExport) bool { + return logicalcluster.From(export) == core.RootCluster && coreExportNames.Has(export.Name) +} + +var coreExportNames = func() sets.Set[string] { + names := sets.New[string]() + for _, name := range bootstrap.KcpRootGroupExportNames { + names.Insert(name) + } + for _, name := range bootstrap.KcpRootGroupResourceExportNames { + names.Insert(name) + } + return names +}() + +// Project computes the view of one identity from the exports holding it. +// The owner is the oldest export, by creation time, then cluster, then name, +// so every reader of the same exports agrees on it; the others are holders. +// It returns nil when no export holds the identity. +func Project(hash string, exports []*apisv1alpha2.APIExport, isCore func(*apisv1alpha2.APIExport) bool) *adminv1alpha1.APIIdentity { + if len(exports) == 0 { + return nil + } + sorted := append([]*apisv1alpha2.APIExport(nil), exports...) + sort.SliceStable(sorted, func(i, j int) bool { + if !sorted[i].CreationTimestamp.Equal(&sorted[j].CreationTimestamp) { + return sorted[i].CreationTimestamp.Before(&sorted[j].CreationTimestamp) + } + if ci, cj := logicalcluster.From(sorted[i]), logicalcluster.From(sorted[j]); ci != cj { + return ci < cj + } + return sorted[i].Name < sorted[j].Name + }) + + owner := sorted[0] + ownerRef := reference(owner) + core := isCore != nil && isCore(owner) + entry := &adminv1alpha1.APIIdentity{ + TypeMeta: metav1.TypeMeta{APIVersion: adminv1alpha1.SchemeGroupVersion.String(), Kind: "APIIdentity"}, + ObjectMeta: metav1.ObjectMeta{ + Name: hash, + CreationTimestamp: owner.CreationTimestamp, + Labels: adminv1alpha1.Labels(ownerRef, core), + }, + Status: adminv1alpha1.APIIdentityStatus{Owner: ownerRef, Core: core}, + } + for _, holder := range sorted[1:] { + entry.Status.Holders = append(entry.Status.Holders, adminv1alpha1.Holder{ExportReference: reference(holder), Since: holder.CreationTimestamp}) + } + if len(entry.Status.Holders) > 0 { + conditions.MarkTrue(entry, adminv1alpha1.APIIdentityShared) + } + return entry +} + +func reference(export *apisv1alpha2.APIExport) adminv1alpha1.ExportReference { + return adminv1alpha1.ExportReference{Cluster: logicalcluster.From(export).String(), Name: export.Name, UID: string(export.UID)} +} + +// Projector keeps the view of every identity current from APIExport events +// and serves it: by name, as a list, as a watch, and to event handlers. It +// is an event handler for an APIExport informer, normally the one against +// the cache server. Resource versions are its own, counting the changes of +// the view; a watch resuming from one it no longer remembers is told to +// relist, like a watch on an apiserver. +type Projector struct { + isCore func(*apisv1alpha2.APIExport) bool + + lock sync.RWMutex + exports map[string]map[string]*apisv1alpha2.APIExport // identity hash -> cluster|name -> export + entries map[string]*adminv1alpha1.APIIdentity // identity hash -> current view + rv uint64 + history []event + watchers map[*watcher]struct{} + handlers []cache.ResourceEventHandler +} + +// historyLength bounds the events kept for watches resuming from a +// resource version. +const historyLength = 1024 + +type event struct { + rv uint64 + typ watch.EventType + obj *adminv1alpha1.APIIdentity +} + +// NewProjector creates a projector. isCore tells which exports are the core +// ones of the installation; IsCoreExport is the usual choice. +func NewProjector(isCore func(*apisv1alpha2.APIExport) bool) *Projector { + return &Projector{ + isCore: isCore, + exports: map[string]map[string]*apisv1alpha2.APIExport{}, + entries: map[string]*adminv1alpha1.APIIdentity{}, + watchers: map[*watcher]struct{}{}, + } +} + +var _ cache.ResourceEventHandler = &Projector{} + +func (p *Projector) OnAdd(obj any, _ bool) { + if export, ok := obj.(*apisv1alpha2.APIExport); ok { + p.apply(nil, export) + } +} + +func (p *Projector) OnUpdate(oldObj, newObj any) { + oldExport, _ := oldObj.(*apisv1alpha2.APIExport) + if newExport, ok := newObj.(*apisv1alpha2.APIExport); ok { + p.apply(oldExport, newExport) + } +} + +func (p *Projector) OnDelete(obj any) { + if export := tombstone.Obj[*apisv1alpha2.APIExport](obj); export != nil { + p.apply(export, nil) + } +} + +func exportKey(export *apisv1alpha2.APIExport) string { + return logicalcluster.From(export).String() + "|" + export.Name +} + +// apply records that old became new (either may be nil) and re-projects the +// identities affected. +func (p *Projector) apply(oldExport, newExport *apisv1alpha2.APIExport) { + p.lock.Lock() + defer p.lock.Unlock() + + affected := sets.New[string]() + if oldExport != nil && oldExport.Status.IdentityHash != "" { + hash := oldExport.Status.IdentityHash + delete(p.exports[hash], exportKey(oldExport)) + if len(p.exports[hash]) == 0 { + delete(p.exports, hash) + } + affected.Insert(hash) + } + if newExport != nil && newExport.Status.IdentityHash != "" { + hash := newExport.Status.IdentityHash + if p.exports[hash] == nil { + p.exports[hash] = map[string]*apisv1alpha2.APIExport{} + } + p.exports[hash][exportKey(newExport)] = newExport + affected.Insert(hash) + } + for _, hash := range sets.List(affected) { + p.reproject(hash) + } +} + +// reproject recomputes one identity's view and emits the change, if any. +// Called with the lock held. +func (p *Projector) reproject(hash string) { + exports := make([]*apisv1alpha2.APIExport, 0, len(p.exports[hash])) + for _, export := range p.exports[hash] { + exports = append(exports, export) + } + next := Project(hash, exports, p.isCore) + prev := p.entries[hash] + + switch { + case prev == nil && next == nil: + return + case next == nil: + delete(p.entries, hash) + p.emit(watch.Deleted, prev) + case prev == nil: + p.entries[hash] = next + p.emit(watch.Added, next) + default: + carryOverConditionTimes(prev, next) + next.ResourceVersion = prev.ResourceVersion + if equality.Semantic.DeepEqual(prev, next) { + return + } + p.entries[hash] = next + p.emit(watch.Modified, next) + } +} + +// carryOverConditionTimes keeps the transition time of a condition that did +// not change status, so that a recomputation is not a change. +func carryOverConditionTimes(prev, next *adminv1alpha1.APIIdentity) { + for i := range next.Status.Conditions { + if old := conditions.Get(prev, next.Status.Conditions[i].Type); old != nil && old.Status == next.Status.Conditions[i].Status { + next.Status.Conditions[i].LastTransitionTime = old.LastTransitionTime + } + } +} + +// emit stamps the next resource version on the object and delivers the +// event to the watchers and handlers. Called with the lock held. +func (p *Projector) emit(typ watch.EventType, obj *adminv1alpha1.APIIdentity) { + p.rv++ + obj.ResourceVersion = strconv.FormatUint(p.rv, 10) + ev := event{rv: p.rv, typ: typ, obj: obj} + + p.history = append(p.history, ev) + if len(p.history) > historyLength { + p.history = p.history[len(p.history)-historyLength:] + } + for w := range p.watchers { + if !w.deliver(ev) { + delete(p.watchers, w) + } + } + for _, handler := range p.handlers { + switch typ { + case watch.Added: + handler.OnAdd(obj.DeepCopy(), false) + case watch.Modified: + handler.OnUpdate(nil, obj.DeepCopy()) + case watch.Deleted: + handler.OnDelete(obj.DeepCopy()) + } + } +} + +// ResourceVersion is the version of the view: the number of changes so far. +func (p *Projector) ResourceVersion() string { + p.lock.RLock() + defer p.lock.RUnlock() + return strconv.FormatUint(p.rv, 10) +} + +// Get returns the view of the named identity. +func (p *Projector) Get(name string) (*adminv1alpha1.APIIdentity, bool) { + p.lock.RLock() + defer p.lock.RUnlock() + entry, ok := p.entries[name] + if !ok { + return nil, false + } + return entry.DeepCopy(), true +} + +// List returns the views matching the selector, by name. +func (p *Projector) List(selector labels.Selector) []*adminv1alpha1.APIIdentity { + if selector == nil { + selector = labels.Everything() + } + p.lock.RLock() + defer p.lock.RUnlock() + var result []*adminv1alpha1.APIIdentity + for _, entry := range p.entries { + if selector.Matches(labels.Set(entry.Labels)) { + result = append(result, entry.DeepCopy()) + } + } + sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name }) + return result +} + +// AddEventHandler registers a handler for the changes of the view. It gets +// the current entries first, as initial adds. Handlers run with the +// projector's lock held and must not call back into it. +func (p *Projector) AddEventHandler(handler cache.ResourceEventHandler) { + p.lock.Lock() + defer p.lock.Unlock() + p.handlers = append(p.handlers, handler) + for _, entry := range p.entries { + handler.OnAdd(entry.DeepCopy(), true) + } +} + +// WatchOptions are the watch semantics a client asks for. +type WatchOptions struct { + // ResourceVersion is where to start: "" for now, "0" for the current + // entries followed by changes, otherwise the changes after that version. + ResourceVersion string + // SendInitialEvents asks for the current entries first, closed by a + // bookmark when AllowWatchBookmarks is set, as a watch-list client does. + SendInitialEvents bool + AllowWatchBookmarks bool +} + +// Watch streams the changes of the entries matching the selector. +func (p *Projector) Watch(selector labels.Selector, opts WatchOptions) (watch.Interface, error) { + if selector == nil { + selector = labels.Everything() + } + w := &watcher{projector: p, selector: selector, ch: make(chan watch.Event, watchBuffer)} + + p.lock.Lock() + defer p.lock.Unlock() + + switch { + case opts.SendInitialEvents || opts.ResourceVersion == "0": + for _, entry := range p.sortedEntries() { + w.deliver(event{rv: p.rv, typ: watch.Added, obj: entry}) + } + if opts.SendInitialEvents && opts.AllowWatchBookmarks { + w.deliver(event{rv: p.rv, typ: watch.Bookmark, obj: &adminv1alpha1.APIIdentity{ + TypeMeta: metav1.TypeMeta{APIVersion: adminv1alpha1.SchemeGroupVersion.String(), Kind: "APIIdentity"}, + ObjectMeta: metav1.ObjectMeta{ResourceVersion: strconv.FormatUint(p.rv, 10), Annotations: map[string]string{metav1.InitialEventsAnnotationKey: "true"}}, + }}) + } + case opts.ResourceVersion == "": + // from now on + default: + from, err := strconv.ParseUint(opts.ResourceVersion, 10, 64) + if err != nil { + return nil, apierrors.NewBadRequest("invalid resourceVersion " + opts.ResourceVersion) + } + if from > p.rv || (from < p.rv && (len(p.history) == 0 || p.history[0].rv > from+1)) { + // too old, or unknown: the client relists + w.ch <- watch.Event{Type: watch.Error, Object: &apierrors.NewResourceExpired("too old resource version: " + opts.ResourceVersion).ErrStatus} + close(w.ch) + w.closed = true + return w, nil + } + for _, ev := range p.history { + if ev.rv > from { + w.deliver(ev) + } + } + } + p.watchers[w] = struct{}{} + return w, nil +} + +func (p *Projector) sortedEntries() []*adminv1alpha1.APIIdentity { + entries := make([]*adminv1alpha1.APIIdentity, 0, len(p.entries)) + for _, entry := range p.entries { + entries = append(entries, entry) + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name }) + return entries +} + +func (p *Projector) removeWatcher(w *watcher) { + p.lock.Lock() + defer p.lock.Unlock() + if _, ok := p.watchers[w]; ok { + delete(p.watchers, w) + w.close() + } +} + +// watchBuffer is how many events a watcher may fall behind before it is +// closed, which makes the client relist. +const watchBuffer = 1000 + +type watcher struct { + projector *Projector + selector labels.Selector + ch chan watch.Event + closed bool // guarded by the projector's lock +} + +// deliver queues the event for the watcher. It reports false when the +// watcher could not keep up and was closed. Called with the lock held. +func (w *watcher) deliver(ev event) bool { + if w.closed { + return false + } + if ev.typ != watch.Bookmark && !w.selector.Matches(labels.Set(ev.obj.Labels)) { + return true + } + select { + case w.ch <- watch.Event{Type: ev.typ, Object: ev.obj.DeepCopy()}: + return true + default: + w.close() + return false + } +} + +// close ends the stream. Called with the lock held. +func (w *watcher) close() { + if !w.closed { + w.closed = true + close(w.ch) + } +} + +func (w *watcher) Stop() { + w.projector.removeWatcher(w) +} + +func (w *watcher) ResultChan() <-chan watch.Event { + return w.ch +} diff --git a/pkg/apiidentity/projector_test.go b/pkg/apiidentity/projector_test.go new file mode 100644 index 00000000000..e356ad79353 --- /dev/null +++ b/pkg/apiidentity/projector_test.go @@ -0,0 +1,305 @@ +/* +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 ( + "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/labels" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/watch" + "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" + "github.com/kcp-dev/sdk/apis/third_party/conditions/util/conditions" +) + +const ( + hashA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + hashB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +) + +var epoch = time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + +// export is an APIExport holding the identity, created at epoch plus the +// given number of minutes. +func export(cluster, name, hash string, createdMinutes int) *apisv1alpha2.APIExport { + return &apisv1alpha2.APIExport{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + UID: types.UID("uid-" + cluster + "-" + name), + Annotations: map[string]string{logicalcluster.AnnotationKey: cluster}, + CreationTimestamp: metav1.NewTime(epoch.Add(time.Duration(createdMinutes) * time.Minute)), + }, + Status: apisv1alpha2.APIExportStatus{IdentityHash: hash}, + } +} + +func ref(cluster, name string) adminv1alpha1.ExportReference { + return adminv1alpha1.ExportReference{Cluster: cluster, Name: name, UID: "uid-" + cluster + "-" + name} +} + +func TestProject(t *testing.T) { + t.Parallel() + + t.Run("no exports, no entry", func(t *testing.T) { + t.Parallel() + require.Nil(t, Project(hashA, nil, IsCoreExport)) + }) + + t.Run("the oldest export owns, the others hold, in age order", func(t *testing.T) { + t.Parallel() + entry := Project(hashA, []*apisv1alpha2.APIExport{ + export("ws-c", "wildwest", hashA, 5), + export("ws-a", "wildwest", hashA, 1), + export("ws-b", "wildwest", hashA, 3), + }, IsCoreExport) + require.Equal(t, hashA, entry.Name) + require.Equal(t, ref("ws-a", "wildwest"), entry.Status.Owner) + require.Equal(t, []adminv1alpha1.Holder{ + {ExportReference: ref("ws-b", "wildwest"), Since: metav1.NewTime(epoch.Add(3 * time.Minute))}, + {ExportReference: ref("ws-c", "wildwest"), Since: metav1.NewTime(epoch.Add(5 * time.Minute))}, + }, entry.Status.Holders) + require.True(t, conditions.IsTrue(entry, adminv1alpha1.APIIdentityShared)) + require.False(t, entry.Status.Core) + require.Equal(t, adminv1alpha1.Labels(ref("ws-a", "wildwest"), false), entry.Labels) + require.Equal(t, epoch.Add(time.Minute), entry.CreationTimestamp.Time) + }) + + t.Run("same age: cluster, then name, decides", func(t *testing.T) { + t.Parallel() + entry := Project(hashA, []*apisv1alpha2.APIExport{ + export("ws-b", "a", hashA, 1), + export("ws-a", "z", hashA, 1), + export("ws-a", "b", hashA, 1), + }, IsCoreExport) + require.Equal(t, ref("ws-a", "b"), entry.Status.Owner) + }) + + t.Run("a sole holder is not shared", func(t *testing.T) { + t.Parallel() + entry := Project(hashA, []*apisv1alpha2.APIExport{export("ws-a", "wildwest", hashA, 1)}, IsCoreExport) + require.Empty(t, entry.Status.Holders) + require.Nil(t, conditions.Get(entry, adminv1alpha1.APIIdentityShared)) + }) + + t.Run("the core exports of root are core", func(t *testing.T) { + t.Parallel() + entry := Project(hashA, []*apisv1alpha2.APIExport{export("root", "tenancy.kcp.io", hashA, 1)}, IsCoreExport) + require.True(t, entry.Status.Core) + require.Equal(t, "true", entry.Labels[adminv1alpha1.APIIdentityCoreLabel]) + require.False(t, Project(hashA, []*apisv1alpha2.APIExport{export("org", "tenancy.kcp.io", hashA, 1)}, IsCoreExport).Status.Core) + }) +} + +// recorder collects the events a handler gets. +type recorder struct { + events []string +} + +func (r *recorder) OnAdd(obj interface{}, _ bool) { r.record("add", obj) } +func (r *recorder) OnUpdate(_, obj interface{}) { r.record("update", obj) } +func (r *recorder) OnDelete(obj interface{}) { r.record("delete", obj) } +func (r *recorder) record(typ string, obj interface{}) { + r.events = append(r.events, typ+" "+obj.(*adminv1alpha1.APIIdentity).Name[:1]+" owner="+obj.(*adminv1alpha1.APIIdentity).Status.Owner.Cluster) +} + +func TestProjectorFollowsExports(t *testing.T) { + t.Parallel() + p := NewProjector(IsCoreExport) + rec := &recorder{} + p.AddEventHandler(rec) + + t.Log("an export without identity is not an entry") + p.OnAdd(export("ws-a", "wildwest", "", 1), true) + require.Empty(t, p.List(nil)) + + t.Log("the identity appears with the first export holding it") + p.OnUpdate(export("ws-a", "wildwest", "", 1), export("ws-a", "wildwest", hashA, 1)) + entry, ok := p.Get(hashA) + require.True(t, ok) + require.Equal(t, ref("ws-a", "wildwest"), entry.Status.Owner) + require.Equal(t, "1", entry.ResourceVersion) + + t.Log("a second export with the same hash becomes a holder") + p.OnAdd(export("ws-b", "wildwest", hashA, 2), false) + entry, _ = p.Get(hashA) + require.Len(t, entry.Status.Holders, 1) + require.Equal(t, "2", entry.ResourceVersion) + + t.Log("re-delivering the same export changes nothing") + p.OnUpdate(export("ws-b", "wildwest", hashA, 2), export("ws-b", "wildwest", hashA, 2)) + entry, _ = p.Get(hashA) + require.Equal(t, "2", entry.ResourceVersion) + require.Equal(t, "2", p.ResourceVersion()) + + t.Log("deleting the owner passes the identity to the holder") + p.OnDelete(cache.DeletedFinalStateUnknown{Key: "ws-a|wildwest", Obj: export("ws-a", "wildwest", hashA, 1)}) + entry, _ = p.Get(hashA) + require.Equal(t, ref("ws-b", "wildwest"), entry.Status.Owner) + require.Empty(t, entry.Status.Holders) + + t.Log("deleting the last export removes the entry") + p.OnDelete(export("ws-b", "wildwest", hashA, 2)) + _, ok = p.Get(hashA) + require.False(t, ok) + + require.Equal(t, []string{"add a owner=ws-a", "update a owner=ws-a", "update a owner=ws-b", "delete a owner=ws-b"}, rec.events) +} + +func TestProjectorListAndSelectors(t *testing.T) { + t.Parallel() + p := NewProjector(IsCoreExport) + p.OnAdd(export("root", "tenancy.kcp.io", hashA, 1), true) + p.OnAdd(export("org", "widgets", hashB, 1), true) + + names := func(entries []*adminv1alpha1.APIIdentity) []string { + out := make([]string, 0, len(entries)) + for _, e := range entries { + out = append(out, e.Name) + } + return out + } + require.Equal(t, []string{hashA, hashB}, names(p.List(nil))) + require.Equal(t, []string{hashA}, names(p.List(labels.SelectorFromSet(labels.Set{adminv1alpha1.APIIdentityCoreLabel: "true"})))) + require.Equal(t, []string{hashB}, names(p.List(labels.SelectorFromSet(labels.Set{adminv1alpha1.APIIdentityOwnerLabel: "widgets"})))) + require.Equal(t, []string{hashA}, names(p.List(labels.SelectorFromSet(labels.Set{adminv1alpha1.APIIdentityOwnerClusterLabel: "root"})))) + + t.Log("a late handler gets the current entries as initial adds") + rec := &recorder{} + p.AddEventHandler(rec) + require.ElementsMatch(t, []string{"add a owner=root", "add b owner=org"}, rec.events) +} + +func next(t *testing.T, w watch.Interface) watch.Event { + t.Helper() + select { + case ev, ok := <-w.ResultChan(): + require.True(t, ok, "the watch closed") + return ev + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for a watch event") + return watch.Event{} + } +} + +func TestProjectorWatch(t *testing.T) { + t.Parallel() + + t.Run("from now delivers only changes", func(t *testing.T) { + t.Parallel() + p := NewProjector(IsCoreExport) + p.OnAdd(export("ws-a", "wildwest", hashA, 1), true) + w, err := p.Watch(nil, WatchOptions{}) + require.NoError(t, err) + defer w.Stop() + + p.OnAdd(export("ws-b", "wildwest", hashA, 2), false) + ev := next(t, w) + require.Equal(t, watch.Modified, ev.Type) + require.Len(t, ev.Object.(*adminv1alpha1.APIIdentity).Status.Holders, 1) + }) + + t.Run("watch-list sends the entries, a bookmark, then changes", func(t *testing.T) { + t.Parallel() + p := NewProjector(IsCoreExport) + p.OnAdd(export("ws-a", "wildwest", hashA, 1), true) + w, err := p.Watch(nil, WatchOptions{SendInitialEvents: true, AllowWatchBookmarks: true}) + require.NoError(t, err) + defer w.Stop() + + ev := next(t, w) + require.Equal(t, watch.Added, ev.Type) + ev = next(t, w) + require.Equal(t, watch.Bookmark, ev.Type) + require.Equal(t, "true", ev.Object.(*adminv1alpha1.APIIdentity).Annotations[metav1.InitialEventsAnnotationKey]) + require.Equal(t, "1", ev.Object.(*adminv1alpha1.APIIdentity).ResourceVersion) + + p.OnDelete(export("ws-a", "wildwest", hashA, 1)) + require.Equal(t, watch.Deleted, next(t, w).Type) + }) + + t.Run("resuming from a version replays what followed", func(t *testing.T) { + t.Parallel() + p := NewProjector(IsCoreExport) + p.OnAdd(export("ws-a", "wildwest", hashA, 1), true) + rv := p.ResourceVersion() + p.OnAdd(export("org", "widgets", hashB, 1), true) + + w, err := p.Watch(nil, WatchOptions{ResourceVersion: rv}) + require.NoError(t, err) + defer w.Stop() + ev := next(t, w) + require.Equal(t, watch.Added, ev.Type) + require.Equal(t, hashB, ev.Object.(*adminv1alpha1.APIIdentity).Name) + }) + + t.Run("a version from the future is expired", func(t *testing.T) { + t.Parallel() + p := NewProjector(IsCoreExport) + w, err := p.Watch(nil, WatchOptions{ResourceVersion: "42"}) + require.NoError(t, err) + ev := next(t, w) + require.Equal(t, watch.Error, ev.Type) + require.True(t, apierrors.IsResourceExpired(apierrors.FromObject(ev.Object))) + _, ok := <-w.ResultChan() + require.False(t, ok, "the stream ends after the error") + }) + + t.Run("a version older than the history is expired", func(t *testing.T) { + t.Parallel() + p := NewProjector(IsCoreExport) + for range historyLength + 5 { + p.OnAdd(export("ws-a", "wildwest", hashA, 1), true) + p.OnDelete(export("ws-a", "wildwest", hashA, 1)) + } + w, err := p.Watch(nil, WatchOptions{ResourceVersion: "1"}) + require.NoError(t, err) + require.Equal(t, watch.Error, next(t, w).Type) + }) + + t.Run("a selector filters the stream", func(t *testing.T) { + t.Parallel() + p := NewProjector(IsCoreExport) + w, err := p.Watch(labels.SelectorFromSet(labels.Set{adminv1alpha1.APIIdentityCoreLabel: "true"}), WatchOptions{}) + require.NoError(t, err) + defer w.Stop() + + p.OnAdd(export("org", "widgets", hashB, 1), false) + p.OnAdd(export("root", "tenancy.kcp.io", hashA, 1), false) + ev := next(t, w) + require.Equal(t, hashA, ev.Object.(*adminv1alpha1.APIIdentity).Name) + }) + + t.Run("stop ends the stream", func(t *testing.T) { + t.Parallel() + p := NewProjector(IsCoreExport) + w, err := p.Watch(nil, WatchOptions{}) + require.NoError(t, err) + w.Stop() + w.Stop() + _, ok := <-w.ResultChan() + require.False(t, ok) + }) +} diff --git a/pkg/cache/client/shard/shard.go b/pkg/cache/client/shard/shard.go index f8d8003a09f..b9d9a860fee 100644 --- a/pkg/cache/client/shard/shard.go +++ b/pkg/cache/client/shard/shard.go @@ -29,12 +29,6 @@ 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 54d21e19527..31585a1e048 100644 --- a/pkg/cache/server/bootstrap/bootstrap.go +++ b/pkg/cache/server/bootstrap/bootstrap.go @@ -48,7 +48,6 @@ 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 deleted file mode 100644 index fa1023d9e13..00000000000 --- a/pkg/reconciler/admin/apiidentity/controller.go +++ /dev/null @@ -1,359 +0,0 @@ -/* -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 entry is removed 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" - - // 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. -func NewController( - cacheKcpClusterClient kcpclientset.ClusterInterface, - apiIdentityInformer adminv1alpha1informers.APIIdentityClusterInformer, - apiExportInformer, globalAPIExportInformer apisv1alpha2informers.APIExportClusterInformer, - isCore func(*apisv1alpha2.APIExport) bool, - missingGracePeriod time.Duration, -) (*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, - gracePeriod: missingGracePeriod, - } - - _, _ = 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 - gracePeriod time.Duration -} - -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.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))) - }) -} - -// 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 -// 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) - - 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.update(ctx, entry.Name, func(entry *adminv1alpha1.APIIdentity) { - conditions.MarkTrue(entry, adminv1alpha1.APIIdentityOwnerMissing) - }); err != nil { - return err - } - c.requeueAfter(entry.Name, c.gracePeriod) - return nil - } - if remaining := c.gracePeriod - 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, 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 { - 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.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) - }) -} - -// 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) < c.gracePeriod { - continue - } - if _, err := c.getGlobalAPIExport(logicalcluster.Name(holder.Cluster), holder.Name); apierrors.IsNotFound(err) { - gone = append(gone, holder.ExportReference) - } - } - return gone -} - -// 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) - } -} - -// 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 { - return err - } - desired := current.DeepCopy() - mutate(desired) - 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{}) - 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 deleted file mode 100644 index 2f2f718e326..00000000000 --- a/pkg/reconciler/admin/apiidentity/controller_test.go +++ /dev/null @@ -1,361 +0,0 @@ -/* -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" - grace = time.Minute -) - -// 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) 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() - 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 }, - gracePeriod: grace, - } - 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*grace + 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.APIIdentityOwnerMissing) - - 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)) - 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{grace}, f.requeued) - - // still within the grace period: wait for the rest of it - 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*grace/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(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)) - 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(grace) - - 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(grace) - - 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 TestGoneOwnerWithoutHoldersIsRemoved(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(grace) - - 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) { - 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(grace) - - // 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)) -} - -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(-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/admin/apiidentity/registry.go b/pkg/reconciler/admin/apiidentity/registry.go deleted file mode 100644 index 45b6c22f688..00000000000 --- a/pkg/reconciler/admin/apiidentity/registry.go +++ /dev/null @@ -1,53 +0,0 @@ -/* -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) - Delete(ctx context.Context, name string, opts metav1.DeleteOptions) 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) -} - -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_controller.go b/pkg/reconciler/apis/apiexport/apiexport_controller.go index cd118d85ada..4e3c81c7df2 100644 --- a/pkg/reconciler/apis/apiexport/apiexport_controller.go +++ b/pkg/reconciler/apis/apiexport/apiexport_controller.go @@ -37,20 +37,15 @@ 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" @@ -66,12 +61,9 @@ 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, @@ -147,17 +139,6 @@ 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{}), } @@ -215,36 +196,9 @@ 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 @@ -277,13 +231,6 @@ 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 69027c71bd9..53bb0948301 100644 --- a/pkg/reconciler/apis/apiexport/apiexport_controller_test.go +++ b/pkg/reconciler/apis/apiexport/apiexport_controller_test.go @@ -28,6 +28,7 @@ import ( corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/util/workqueue" "github.com/kcp-dev/logicalcluster/v3" apisv1alpha1 "github.com/kcp-dev/sdk/apis/apis/v1alpha1" @@ -186,14 +187,8 @@ 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, + queue: workqueue.NewTypedRateLimitingQueue(workqueue.DefaultTypedControllerRateLimiter[string]()), 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 986c1cf7c11..b8bf8f6acd0 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(), client, factory.Admin().V1alpha1().APIIdentities(), + c, err := NewController(client, factory.Apis().V1alpha2().APIExports(), &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_identity.go b/pkg/reconciler/apis/apiexport/apiexport_reconcile_identity.go index 77b5b5d4073..669c1f14edc 100644 --- a/pkg/reconciler/apis/apiexport/apiexport_reconcile_identity.go +++ b/pkg/reconciler/apis/apiexport/apiexport_reconcile_identity.go @@ -19,37 +19,24 @@ 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. +// reconcileIdentity assigns the export's identity: the hash of the secret's +// identity key. Two exports whose secrets carry the same key have the same +// identity, which is how an identity is shared; who owns it and who holds it +// is derived from the exports themselves (see pkg/apiidentity), not +// recorded anywhere. // -// status.identityHash is set once the identity is recorded; an identity -// that cannot be recorded is retried, with IdentityValid=False meanwhile. +// Once set, status.identityHash is immutable: a secret edited to another +// key fails verification instead of changing the identity. 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) @@ -63,114 +50,13 @@ func (c *controller) reconcileIdentity(ctx context.Context, apiExport *apisv1alp 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, - 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}, - }) - 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) - } - } - 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 { - if err := c.setAPIIdentityOwner(ctx, entry.Name, me); err != nil { - return c.identityClaimFailed(apiExport, err) - } - } - 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) + return nil } 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 deleted file mode 100644 index 8f2f3c32cfb..00000000000 --- a/pkg/reconciler/apis/apiexport/apiexport_reconcile_identity_test.go +++ /dev/null @@ -1,181 +0,0 @@ -/* -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("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 346b0bbd919..2779c7fd404 100644 --- a/pkg/server/controllers.go +++ b/pkg/server/controllers.go @@ -59,8 +59,6 @@ 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" @@ -73,7 +71,6 @@ 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" @@ -116,7 +113,6 @@ 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" @@ -1348,13 +1344,9 @@ 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, @@ -1375,7 +1367,6 @@ 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 @@ -1387,43 +1378,6 @@ 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, s.Options.Extra.APIIdentityMissingGracePeriod) - 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/options/options.go b/pkg/server/options/options.go index 787ef465811..2a68ac90d9c 100644 --- a/pkg/server/options/options.go +++ b/pkg/server/options/options.go @@ -38,7 +38,6 @@ 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" ) @@ -86,12 +85,8 @@ type ExtraOptions struct { LogicalClusterAdminKubeconfig string ExternalLogicalClusterAdminKubeconfig string ConversionCELTransformationTimeout time.Duration - // 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 + 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. @@ -146,8 +141,6 @@ func NewOptions(rootDir string) *Options { LogicalClusterTotalObjectLimit: 0, LogicalClusterObjectCountScanInterval: 60 * time.Second, - APIIdentityMissingGracePeriod: apiidentity.DefaultMissingGracePeriod, - BatteriesIncluded: sets.List[string](batteries.Defaults), ServiceAccountCache: saCache, }, @@ -224,8 +217,6 @@ 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/pkg/server/server.go b/pkg/server/server.go index 88fc8385628..f1fcca6ccd2 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -344,9 +344,6 @@ 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") { @@ -529,10 +526,6 @@ 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/builder/apiidentities.go b/pkg/virtual/admin/builder/apiidentities.go index 9f91e5a5c90..f02c82ab96d 100644 --- a/pkg/virtual/admin/builder/apiidentities.go +++ b/pkg/virtual/admin/builder/apiidentities.go @@ -18,17 +18,19 @@ package builder import ( "context" - "fmt" + "errors" "sync" + apierrors "k8s.io/apimachinery/pkg/api/errors" 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/fields" "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" + "k8s.io/utils/ptr" kcpdynamic "github.com/kcp-dev/client-go/dynamic" adminv1alpha1 "github.com/kcp-dev/sdk/apis/admin/v1alpha1" @@ -36,28 +38,25 @@ import ( "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" + "github.com/kcp-dev/kcp/pkg/apiidentity" ) // 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. +// view: GET/LIST/WATCH computed by the projector from the APIExports +// replicated to the cache server. Nothing is stored and nothing is written: +// the view is read-only. func provideAPIIdentitiesRestStorage( mainConfig genericapiserver.CompletedConfig, - cacheDynamicClusterClient kcpdynamic.ClusterInterface, + projector *apiidentity.Projector, ) (apidefinition.APIDefinition, error) { ctx, cancelFn := context.WithCancel(context.Background()) + // the view is computed; the storage never reaches for a client. clientFunc := forwardingregistry.DynamicClusterClientFunc(func(_ context.Context) (kcpdynamic.ClusterInterface, error) { - return cacheDynamicClusterClient, nil + return nil, errors.New("the identity view is computed, it has no backing client") }) - restProvider, err := forwardingregistry.ProvideReadOnlyRestStorage(ctx, clientFunc, withRegistryView(), nil) + restProvider, err := forwardingregistry.ProvideReadOnlyRestStorage(ctx, clientFunc, projectedView(projector), nil) if err != nil { cancelFn() return nil, err @@ -75,85 +74,76 @@ func provideAPIIdentitiesRestStorage( }, 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) -} +var apiIdentitiesResource = schema.GroupResource{Group: adminv1alpha1.SchemeGroupVersion.Group, Resource: "apiidentities"} -// 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 { +// projectedView replaces the reads of the storage with the projector's view. +func projectedView(projector *apiidentity.Projector) 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) + storage.GetterFunc = func(_ context.Context, name string, _ *metav1.GetOptions) (runtime.Object, error) { + entry, ok := projector.Get(name) if !ok { - return nil, fmt.Errorf("expected *unstructured.Unstructured from the registry, got %T", result) + return nil, apierrors.NewNotFound(apiIdentitiesResource, name) } - stripCacheBookkeeping(obj) - return obj, nil + return toUnstructured(entry) } - 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]) + storage.ListerFunc = func(_ context.Context, options *metainternalversion.ListOptions) (runtime.Object, error) { + list := &unstructured.UnstructuredList{} + list.SetAPIVersion(adminv1alpha1.SchemeGroupVersion.String()) + list.SetKind("APIIdentityList") + list.SetResourceVersion(projector.ResourceVersion()) + for _, entry := range projector.List(options.LabelSelector) { + if !matchesFields(options.FieldSelector, entry) { + continue + } + obj, err := toUnstructured(entry) + if err != nil { + return nil, err + } + list.Items = append(list.Items, *obj) } return list, nil } - delegateWatch := storage.WatcherFunc storage.WatcherFunc = func(ctx context.Context, options *metainternalversion.ListOptions) (watch.Interface, error) { - w, err := delegateWatch(registryContext(ctx), options) + w, err := projector.Watch(options.LabelSelector, apiidentity.WatchOptions{ + ResourceVersion: options.ResourceVersion, + SendInitialEvents: ptr.Deref(options.SendInitialEvents, false), + AllowWatchBookmarks: options.AllowWatchBookmarks, + }) if err != nil { return nil, err } - return newStrippingWatch(ctx, w), nil + return newConvertingWatch(ctx, w, options.FieldSelector), 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 { +// matchesFields applies the one field selector the view supports, +// metadata.name. +func matchesFields(selector fields.Selector, entry *adminv1alpha1.APIIdentity) bool { + return selector == nil || selector.Empty() || selector.Matches(fields.Set{"metadata.name": entry.Name}) +} + +func toUnstructured(entry *adminv1alpha1.APIIdentity) (*unstructured.Unstructured, error) { + content, err := runtime.DefaultUnstructuredConverter.ToUnstructured(entry) + if err != nil { + return nil, err + } + return &unstructured.Unstructured{Object: content}, nil +} + +// convertingWatch forwards the projector's typed events as unstructured +// objects, which is what the storage serves, dropping those a field +// selector excludes. Bookmarks and errors pass through. +type convertingWatch struct { delegate watch.Interface resultChan chan watch.Event stopOnce sync.Once } -func newStrippingWatch(ctx context.Context, delegate watch.Interface) *strippingWatch { - w := &strippingWatch{ +func newConvertingWatch(ctx context.Context, delegate watch.Interface, selector fields.Selector) *convertingWatch { + w := &convertingWatch{ delegate: delegate, resultChan: make(chan watch.Event, 100), // Matches outgoingBufSize=100 in k8s.io/apiserver/pkg/storage/etcd3/watcher.go. } @@ -166,8 +156,16 @@ func newStrippingWatch(ctx context.Context, delegate watch.Interface) *stripping if !ok { return } - if obj, ok := event.Object.(*unstructured.Unstructured); ok { - stripCacheBookkeeping(obj) + if entry, ok := event.Object.(*adminv1alpha1.APIIdentity); ok { + if event.Type != watch.Bookmark && !matchesFields(selector, entry) { + continue + } + obj, err := toUnstructured(entry) + if err != nil { + event = watch.Event{Type: watch.Error, Object: &apierrors.NewInternalError(err).ErrStatus} + } else { + event.Object = obj + } } select { case w.resultChan <- event: @@ -184,10 +182,10 @@ func newStrippingWatch(ctx context.Context, delegate watch.Interface) *stripping return w } -func (w *strippingWatch) Stop() { +func (w *convertingWatch) Stop() { w.stopOnce.Do(func() { w.delegate.Stop() }) } -func (w *strippingWatch) ResultChan() <-chan watch.Event { +func (w *convertingWatch) ResultChan() <-chan watch.Event { return w.resultChan } diff --git a/pkg/virtual/admin/builder/build.go b/pkg/virtual/admin/builder/build.go index 8f732ed0382..788d429a6e5 100644 --- a/pkg/virtual/admin/builder/build.go +++ b/pkg/virtual/admin/builder/build.go @@ -36,6 +36,7 @@ import ( dynamiccontext "github.com/kcp-dev/virtual-workspace-framework/pkg/dynamic/context" "github.com/kcp-dev/virtual-workspace-framework/pkg/rootapiserver" + "github.com/kcp-dev/kcp/pkg/apiidentity" "github.com/kcp-dev/kcp/pkg/authorization" "github.com/kcp-dev/kcp/pkg/virtual/admin" adminauthorizer "github.com/kcp-dev/kcp/pkg/virtual/admin/authorizer" @@ -58,6 +59,7 @@ const apiDomainKey = dynamiccontext.APIDomainKey(admin.VirtualWorkspaceName) func BuildVirtualWorkspace( rootPathPrefix string, cacheDynamicClusterClient kcpdynamic.ClusterInterface, + identities *apiidentity.Projector, ) ([]rootapiserver.NamedVirtualWorkspace, error) { if !strings.HasSuffix(rootPathPrefix, "/") { rootPathPrefix += "/" @@ -87,6 +89,7 @@ func BuildVirtualWorkspace( provider := &adminAPIDefinitionSetProvider{ config: mainConfig, cacheDynamicClusterClient: cacheDynamicClusterClient, + identities: identities, } if err := mainConfig.AddPostStartHook(admin.VirtualWorkspaceName, func(hookContext genericapiserver.PostStartHookContext) error { close(readyCh) @@ -146,6 +149,7 @@ func digestURL(urlPath, rootPathPrefix string) (cluster genericapirequest.Cluste type adminAPIDefinitionSetProvider struct { config genericapiserver.CompletedConfig cacheDynamicClusterClient kcpdynamic.ClusterInterface + identities *apiidentity.Projector lock sync.Mutex cached apidefinition.APIDefinitionSet @@ -166,7 +170,7 @@ func (p *adminAPIDefinitionSetProvider) GetAPIDefinitionSet(_ context.Context, k if err != nil { return nil, false, err } - apiIdentitiesDef, err := provideAPIIdentitiesRestStorage(p.config, p.cacheDynamicClusterClient) + apiIdentitiesDef, err := provideAPIIdentitiesRestStorage(p.config, p.identities) if err != nil { shardsDef.TearDown() return nil, false, err diff --git a/pkg/virtual/admin/builder/build_test.go b/pkg/virtual/admin/builder/build_test.go index 3f13a49684c..170babd5e0c 100644 --- a/pkg/virtual/admin/builder/build_test.go +++ b/pkg/virtual/admin/builder/build_test.go @@ -24,16 +24,20 @@ import ( "github.com/stretchr/testify/require" apierrors "k8s.io/apimachinery/pkg/api/errors" + 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/fields" + "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/watch" - genericapirequest "k8s.io/apiserver/pkg/endpoints/request" "github.com/kcp-dev/logicalcluster/v3" + adminv1alpha1 "github.com/kcp-dev/sdk/apis/admin/v1alpha1" + apisv1alpha2 "github.com/kcp-dev/sdk/apis/apis/v1alpha2" corev1alpha1 "github.com/kcp-dev/sdk/apis/core/v1alpha1" + "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/apiidentity" ) func TestDigestURL(t *testing.T) { @@ -396,43 +400,47 @@ func TestMutableFieldUpdate(t *testing.T) { } } -func TestRegistryContextTargetsTheRegistryScope(t *testing.T) { +func TestProjectedViewServesTheProjector(t *testing.T) { t.Parallel() - ctx := registryContext(context.Background()) - - cluster, err := genericapirequest.ValidClusterFrom(ctx) + const hash = "abc" + projector := apiidentity.NewProjector(apiidentity.IsCoreExport) + projector.OnAdd(&apisv1alpha2.APIExport{ + ObjectMeta: metav1.ObjectMeta{Name: "widgets", Annotations: map[string]string{logicalcluster.AnnotationKey: "org"}}, + Status: apisv1alpha2.APIExportStatus{IdentityHash: hash}, + }, true) + storage := &forwardingregistry.StoreFuncs{} + projectedView(projector).Decorate(apiIdentitiesResource, storage) + ctx := context.Background() + + t.Log("get serves the entry as unstructured, unknown names are not found") + obj, err := storage.GetterFunc(ctx, hash, nil) require.NoError(t, err) - require.Equal(t, configshard.SystemShardCluster, cluster.Name) - require.False(t, cluster.Wildcard) - - require.Equal(t, shard.Registry, cacheclient.ShardFromContext(ctx)) -} + entry := obj.(*unstructured.Unstructured) + require.Equal(t, "APIIdentity", entry.GetKind()) + require.Equal(t, "widgets", entry.GetLabels()[adminv1alpha1.APIIdentityOwnerLabel]) + _, err = storage.GetterFunc(ctx, "nope", nil) + require.True(t, apierrors.IsNotFound(err)) + + t.Log("list honours label and name selectors") + obj, err = storage.ListerFunc(ctx, &metainternalversion.ListOptions{LabelSelector: labels.SelectorFromSet(labels.Set{adminv1alpha1.APIIdentityCoreLabel: "false"})}) + require.NoError(t, err) + list := obj.(*unstructured.UnstructuredList) + require.Equal(t, "APIIdentityList", list.GetKind()) + require.Len(t, list.Items, 1) + require.Equal(t, projector.ResourceVersion(), list.GetResourceVersion()) + obj, err = storage.ListerFunc(ctx, &metainternalversion.ListOptions{FieldSelector: fields.OneTermEqualSelector("metadata.name", "other")}) + require.NoError(t, err) + require.Empty(t, obj.(*unstructured.UnstructuredList).Items) -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", + t.Log("watch delivers changes as unstructured") + w, err := storage.WatcherFunc(ctx, &metainternalversion.ListOptions{}) + require.NoError(t, err) + defer w.Stop() + projector.OnDelete(&apisv1alpha2.APIExport{ + ObjectMeta: metav1.ObjectMeta{Name: "widgets", Annotations: map[string]string{logicalcluster.AnnotationKey: "org"}}, + Status: apisv1alpha2.APIExportStatus{IdentityHash: hash}, }) - 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) + event := <-w.ResultChan() + require.Equal(t, watch.Deleted, event.Type) + require.Equal(t, hash, event.Object.(*unstructured.Unstructured).GetName()) } diff --git a/pkg/virtual/admin/builder/forwarding.go b/pkg/virtual/admin/builder/forwarding.go index 323a8f18eda..f81042346ad 100644 --- a/pkg/virtual/admin/builder/forwarding.go +++ b/pkg/virtual/admin/builder/forwarding.go @@ -52,6 +52,7 @@ 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: @@ -638,3 +639,17 @@ func (w *fixupWatch) Stop() { func (w *fixupWatch) ResultChan() <-chan watch.Event { return w.resultChan } + +// 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) +} diff --git a/pkg/virtual/admin/options/options.go b/pkg/virtual/admin/options/options.go index b0c68df3325..ac55209a601 100644 --- a/pkg/virtual/admin/options/options.go +++ b/pkg/virtual/admin/options/options.go @@ -24,8 +24,10 @@ import ( "k8s.io/client-go/rest" kcpdynamic "github.com/kcp-dev/client-go/dynamic" + kcpinformers "github.com/kcp-dev/sdk/client/informers/externalversions" "github.com/kcp-dev/virtual-workspace-framework/pkg/rootapiserver" + "github.com/kcp-dev/kcp/pkg/apiidentity" "github.com/kcp-dev/kcp/pkg/virtual/admin" "github.com/kcp-dev/kcp/pkg/virtual/admin/builder" ) @@ -47,6 +49,7 @@ func (o *Admin) NewAdmin( rootPathPrefix string, _ *rest.Config, cacheConfig *rest.Config, + cachedKcpInformers kcpinformers.SharedInformerFactory, ) ([]rootapiserver.NamedVirtualWorkspace, error) { // We assume the cacheConfig already has the cache-related roundtrippers applied. cacheDynamicClusterClient, err := kcpdynamic.NewForConfig(cacheConfig) @@ -54,8 +57,14 @@ func (o *Admin) NewAdmin( return nil, err } + // the identity view follows the APIExports of every shard, replicated + // to the cache server. + identities := apiidentity.NewProjector(apiidentity.IsCoreExport) + _, _ = cachedKcpInformers.Apis().V1alpha2().APIExports().Informer().AddEventHandler(identities) + return builder.BuildVirtualWorkspace( path.Join(rootPathPrefix, admin.VirtualWorkspaceName), cacheDynamicClusterClient, + identities, ) } diff --git a/pkg/virtual/options/options.go b/pkg/virtual/options/options.go index 7d1a74c8e23..ac6212ccefe 100644 --- a/pkg/virtual/options/options.go +++ b/pkg/virtual/options/options.go @@ -121,6 +121,7 @@ func (o *Options) NewVirtualWorkspaces( rootPathPrefix, config, cacheConfig, + cachedKcpInformers, ) if err != nil { return nil, err 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 119a7b563f7..2beb490b1f6 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 @@ -23,12 +23,10 @@ import ( "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. +// APIIdentity is the view of one API identity: which APIExport owns it and +// which other APIExports hold it. It is computed from the APIExports of the +// installation and not stored anywhere; there is one per identity, named +// after the identity value, served read-only through the Admin workspace. // // +crd // +genclient @@ -82,7 +80,7 @@ func (r ExportReference) Equal(o ExportReference) bool { type Holder struct { ExportReference `json:",inline"` - // since is when the holder was recorded. + // since is when the holder was created. // // +optional Since metav1.Time `json:"since,omitempty"` @@ -92,18 +90,15 @@ type Holder struct { // 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. An entry whose owner is gone and that has no holder left - // is removed. + // oldest one holding it. When it is deleted the oldest holder succeeds + // it; an identity no APIExport holds any more has no entry. // // +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. + // identity: those whose identity secret carries the same key, oldest + // first. // // +optional // +listType=map @@ -147,11 +142,6 @@ func Labels(owner ExportReference, core bool) map[string]string { const ( // APIIdentityShared is True while holders is non-empty. APIIdentityShared conditionsv1alpha1.ConditionType = "Shared" - - // 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" ) func (in *APIIdentity) SetConditions(c conditionsv1alpha1.Conditions) { 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 aaeeeca88e9..6cc6c6638fd 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,9 +37,6 @@ 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 index 8836f17a93d..33fa8909294 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 @@ -28,12 +28,10 @@ import ( // 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. +// APIIdentity is the view of one API identity: which APIExport owns it and +// which other APIExports hold it. It is computed from the APIExports of the +// installation and not stored anywhere; there is one per identity, named +// after the identity value, served read-only through the Admin workspace. 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 15a47ce410f..a60faf3bb58 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 @@ -29,15 +29,12 @@ import ( // 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. An entry whose owner is gone and that has no holder left - // is removed. + // oldest one holding it. When it is deleted the oldest holder succeeds + // it; an identity no APIExport holds any more has no entry. 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. + // identity: those whose identity secret carries the same key, oldest + // first. 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. 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 index c48482dcfbf..6b7017405a6 100644 --- 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 @@ -28,7 +28,7 @@ import ( // 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 is when the holder was created. Since *v1.Time `json:"since,omitempty"` } 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 eba980cfd98..e99007fef91 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. Entries are written by kcp only.", + Description: "APIIdentity is the view of one API identity: which APIExport owns it and which other APIExports hold it. It is computed from the APIExports of the installation and not stored anywhere; there is one per identity, named after the identity value, served read-only through the Admin workspace.", 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. An entry whose owner is gone and that has no holder left is removed.", + Description: "owner is the APIExport that holds the identity authoritatively: the oldest one holding it. When it is deleted the oldest holder succeeds it; an identity no APIExport holds any more has no entry.", Default: map[string]interface{}{}, Ref: ref(v1alpha1.ExportReference{}.OpenAPIModelName()), }, @@ -350,7 +350,7 @@ func schema_sdk_apis_admin_v1alpha1_APIIdentityStatus(ref common.ReferenceCallba }, }, 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.", + Description: "holders lists the APIExports, other than the owner, that serve the identity: those whose identity secret carries the same key, oldest first.", Type: []string{"array"}, Items: &spec.SchemaOrArray{ Schema: &spec.Schema{ @@ -461,7 +461,7 @@ func schema_sdk_apis_admin_v1alpha1_Holder(ref common.ReferenceCallback) common. }, "since": { SchemaProps: spec.SchemaProps{ - Description: "since is when the holder was recorded.", + Description: "since is when the holder was created.", Ref: ref(v1.Time{}.OpenAPIModelName()), }, }, diff --git a/test/e2e/apibinding/apiexport_identity_registry_test.go b/test/e2e/apibinding/apiexport_identity_registry_test.go index e9343e6c8e6..e64f31729ec 100644 --- a/test/e2e/apibinding/apiexport_identity_registry_test.go +++ b/test/e2e/apibinding/apiexport_identity_registry_test.go @@ -45,26 +45,22 @@ 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/test/e2e/framework" ) -// TestAPIExportIdentityRegistry exercises the identity registry: the first +// TestAPIExportIdentityRegistry exercises the identity view: 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 +// key shares the identity and shows as a holder, the oldest holder // 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 last export is deleted, and the next export with the key brings // the identity anew. func TestAPIExportIdentityRegistry(t *testing.T) { t.Parallel() framework.Suite(t, "control-plane") - // 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())) + 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) @@ -138,7 +134,6 @@ func TestAPIExportIdentityRegistry(t *testing.T) { return check(entry) }, timeout, 250*time.Millisecond) } - 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{}) @@ -183,12 +178,12 @@ func TestAPIExportIdentityRegistry(t *testing.T) { 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) { + waitForEntry(identity, wait.ForeverTestTimeout, 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.Get(entry, adminv1alpha1.APIIdentityOwnerMissing) == nil, - fmt.Sprintf("holders=%v ownerMissing=%v", entry.Status.Holders, conditions.Get(entry, adminv1alpha1.APIIdentityOwnerMissing)) + return len(entry.Status.Holders) == 0 && conditions.Get(entry, adminv1alpha1.APIIdentityShared) == nil, + fmt.Sprintf("holders=%v shared=%v", entry.Status.Holders, conditions.Get(entry, adminv1alpha1.APIIdentityShared)) }) holder, err = getExport(holderPath) require.NoError(t, err) @@ -201,7 +196,7 @@ func TestAPIExportIdentityRegistry(t *testing.T) { 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) + }, wait.ForeverTestTimeout, 250*time.Millisecond) t.Logf("A newcomer with the key registers the identity anew") createWithKey(newcomerPath) From 57050182e19c9de79527825d06619c687ea9cb6c Mon Sep 17 00:00:00 2001 From: Mangirdas Judeikis Date: Mon, 26 Oct 2026 14:39:39 +0200 Subject: [PATCH 7/9] lint --- pkg/apiidentity/projector_test.go | 8 ++++---- pkg/virtual/admin/builder/build_test.go | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pkg/apiidentity/projector_test.go b/pkg/apiidentity/projector_test.go index e356ad79353..81a5ad31797 100644 --- a/pkg/apiidentity/projector_test.go +++ b/pkg/apiidentity/projector_test.go @@ -213,7 +213,7 @@ func TestProjectorWatch(t *testing.T) { p.OnAdd(export("ws-a", "wildwest", hashA, 1), true) w, err := p.Watch(nil, WatchOptions{}) require.NoError(t, err) - defer w.Stop() + t.Cleanup(w.Stop) p.OnAdd(export("ws-b", "wildwest", hashA, 2), false) ev := next(t, w) @@ -227,7 +227,7 @@ func TestProjectorWatch(t *testing.T) { p.OnAdd(export("ws-a", "wildwest", hashA, 1), true) w, err := p.Watch(nil, WatchOptions{SendInitialEvents: true, AllowWatchBookmarks: true}) require.NoError(t, err) - defer w.Stop() + t.Cleanup(w.Stop) ev := next(t, w) require.Equal(t, watch.Added, ev.Type) @@ -249,7 +249,7 @@ func TestProjectorWatch(t *testing.T) { w, err := p.Watch(nil, WatchOptions{ResourceVersion: rv}) require.NoError(t, err) - defer w.Stop() + t.Cleanup(w.Stop) ev := next(t, w) require.Equal(t, watch.Added, ev.Type) require.Equal(t, hashB, ev.Object.(*adminv1alpha1.APIIdentity).Name) @@ -284,7 +284,7 @@ func TestProjectorWatch(t *testing.T) { p := NewProjector(IsCoreExport) w, err := p.Watch(labels.SelectorFromSet(labels.Set{adminv1alpha1.APIIdentityCoreLabel: "true"}), WatchOptions{}) require.NoError(t, err) - defer w.Stop() + t.Cleanup(w.Stop) p.OnAdd(export("org", "widgets", hashB, 1), false) p.OnAdd(export("root", "tenancy.kcp.io", hashA, 1), false) diff --git a/pkg/virtual/admin/builder/build_test.go b/pkg/virtual/admin/builder/build_test.go index 170babd5e0c..88296298268 100644 --- a/pkg/virtual/admin/builder/build_test.go +++ b/pkg/virtual/admin/builder/build_test.go @@ -435,7 +435,7 @@ func TestProjectedViewServesTheProjector(t *testing.T) { t.Log("watch delivers changes as unstructured") w, err := storage.WatcherFunc(ctx, &metainternalversion.ListOptions{}) require.NoError(t, err) - defer w.Stop() + t.Cleanup(w.Stop) projector.OnDelete(&apisv1alpha2.APIExport{ ObjectMeta: metav1.ObjectMeta{Name: "widgets", Annotations: map[string]string{logicalcluster.AnnotationKey: "org"}}, Status: apisv1alpha2.APIExportStatus{IdentityHash: hash}, From a05554561e6ef7dc3cc9d60d7d1f7eeec6b6c8ba Mon Sep 17 00:00:00 2001 From: Mangirdas Judeikis Date: Mon, 26 Oct 2026 15:51:04 +0200 Subject: [PATCH 8/9] Fix racy test --- test/e2e/apibinding/apiexport_identity_registry_test.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/e2e/apibinding/apiexport_identity_registry_test.go b/test/e2e/apibinding/apiexport_identity_registry_test.go index e64f31729ec..eae7ec050ea 100644 --- a/test/e2e/apibinding/apiexport_identity_registry_test.go +++ b/test/e2e/apibinding/apiexport_identity_registry_test.go @@ -166,6 +166,11 @@ func TestAPIExportIdentityRegistry(t *testing.T) { t.Logf("Copy the owner's key into %q: the holder shares the identity and is recorded", holderPath) copyKey(ownerPath, holderPath) + // The owner is the oldest export by creationTimestamp, which has second + // precision; an export created in the same second as the owner ties and + // the tie is broken by cluster name. Let the second pass so that the + // holder is strictly younger. + time.Sleep(time.Until(owner.CreationTimestamp.Add(time.Second))) createWithKey(holderPath) holder := waitForIdentity(holderPath) require.Equal(t, identity, holder.Status.IdentityHash, "same key, same identity") From e369b2cd25f3d7f383afec9f3a804a530bb7c6f0 Mon Sep 17 00:00:00 2001 From: Mangirdas Judeikis Date: Mon, 26 Oct 2026 17:53:04 +0200 Subject: [PATCH 9/9] Add readiness checks and few nits --- .../apiexport/apiexport_controller_test.go | 2 -- .../apis/apiexport/apiexport_reconcile.go | 10 ++++------ .../apiexport/apiexport_reconcile_identity.go | 18 ++++++++++-------- pkg/virtual/admin/builder/build.go | 9 ++++++++- pkg/virtual/admin/options/options.go | 6 +++++- .../apis/admin/v1alpha1/types_apiidentity.go | 4 +++- 6 files changed, 30 insertions(+), 19 deletions(-) diff --git a/pkg/reconciler/apis/apiexport/apiexport_controller_test.go b/pkg/reconciler/apis/apiexport/apiexport_controller_test.go index 53bb0948301..1e3d829b548 100644 --- a/pkg/reconciler/apis/apiexport/apiexport_controller_test.go +++ b/pkg/reconciler/apis/apiexport/apiexport_controller_test.go @@ -103,7 +103,6 @@ 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, @@ -112,7 +111,6 @@ 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, diff --git a/pkg/reconciler/apis/apiexport/apiexport_reconcile.go b/pkg/reconciler/apis/apiexport/apiexport_reconcile.go index 10f5d7523c6..ce837f620d4 100644 --- a/pkg/reconciler/apis/apiexport/apiexport_reconcile.go +++ b/pkg/reconciler/apis/apiexport/apiexport_reconcile.go @@ -89,12 +89,10 @@ func (c *controller) reconcile(ctx context.Context, apiExport *apisv1alpha2.APIE return nil } - // 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 - } + // Ref exists - make sure it's valid. A failure is reported on the + // IdentityValid condition and is not retried: it is a wrong or missing + // secret, and the export is reconciled again when that secret changes. + c.reconcileIdentity(ctx, apiExport) // Ensure the APIExportEndpointSlice exists if _, ok := apiExport.Annotations[apisv1alpha2.APIExportEndpointSliceSkipAnnotation]; !ok { diff --git a/pkg/reconciler/apis/apiexport/apiexport_reconcile_identity.go b/pkg/reconciler/apis/apiexport/apiexport_reconcile_identity.go index 669c1f14edc..c1ca1d58ef5 100644 --- a/pkg/reconciler/apis/apiexport/apiexport_reconcile_identity.go +++ b/pkg/reconciler/apis/apiexport/apiexport_reconcile_identity.go @@ -35,28 +35,30 @@ import ( // recorded anywhere. // // Once set, status.identityHash is immutable: a secret edited to another -// key fails verification instead of changing the identity. -func (c *controller) reconcileIdentity(ctx context.Context, apiExport *apisv1alpha2.APIExport) error { +// key fails verification instead of changing the identity. Failures are +// reported on the IdentityValid condition. +func (c *controller) reconcileIdentity(ctx context.Context, apiExport *apisv1alpha2.APIExport) { 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) + c.identityVerificationFailed(apiExport, err) + return } hash, err := identity.IdentityHash(secret) if err != nil { - return c.identityVerificationFailed(apiExport, err) + c.identityVerificationFailed(apiExport, err) + return } 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)) + c.identityVerificationFailed(apiExport, fmt.Errorf("hash mismatch: identity secret hash %q must match status.identityHash %q", hash, apiExport.Status.IdentityHash)) + return } apiExport.Status.IdentityHash = hash conditions.MarkTrue(apiExport, apisv1alpha2.APIExportIdentityValid) - return nil } -func (c *controller) identityVerificationFailed(apiExport *apisv1alpha2.APIExport, err error) error { +func (c *controller) identityVerificationFailed(apiExport *apisv1alpha2.APIExport, err error) { conditions.MarkFalse(apiExport, apisv1alpha2.APIExportIdentityValid, apisv1alpha2.IdentityVerificationFailedReason, conditionsv1alpha1.ConditionSeverityError, "%v", err) - return err } diff --git a/pkg/virtual/admin/builder/build.go b/pkg/virtual/admin/builder/build.go index 788d429a6e5..e61428b2681 100644 --- a/pkg/virtual/admin/builder/build.go +++ b/pkg/virtual/admin/builder/build.go @@ -56,10 +56,14 @@ const apiDomainKey = dynamiccontext.APIDomainKey(admin.VirtualWorkspaceName) // /services/admin/apis/core.kcp.io/v1alpha1/shards (plain clients, e.g. kubectl via `ws use :admin`) // /services/admin/apis/admin.kcp.io/v1alpha1/apiidentities // /services/admin/clusters//apis/... (cluster-aware clients; the segment is accepted and ignored) +// +// identitiesSynced reports whether the identity view has seen every +// APIExport the cache server holds; the workspace is not ready before. func BuildVirtualWorkspace( rootPathPrefix string, cacheDynamicClusterClient kcpdynamic.ClusterInterface, identities *apiidentity.Projector, + identitiesSynced func() bool, ) ([]rootapiserver.NamedVirtualWorkspace, error) { if !strings.HasSuffix(rootPathPrefix, "/") { rootPathPrefix += "/" @@ -80,10 +84,13 @@ func BuildVirtualWorkspace( ReadyChecker: framework.ReadyFunc(func() error { select { case <-readyCh: - return nil default: return errors.New("admin virtual workspace is not started") } + if !identitiesSynced() { + return errors.New("the identity view has not synced yet") + } + return nil }), BootstrapAPISetManagement: func(mainConfig genericapiserver.CompletedConfig) (apidefinition.APIDefinitionSetGetter, error) { provider := &adminAPIDefinitionSetProvider{ diff --git a/pkg/virtual/admin/options/options.go b/pkg/virtual/admin/options/options.go index ac55209a601..612de41a17c 100644 --- a/pkg/virtual/admin/options/options.go +++ b/pkg/virtual/admin/options/options.go @@ -60,11 +60,15 @@ func (o *Admin) NewAdmin( // the identity view follows the APIExports of every shard, replicated // to the cache server. identities := apiidentity.NewProjector(apiidentity.IsCoreExport) - _, _ = cachedKcpInformers.Apis().V1alpha2().APIExports().Informer().AddEventHandler(identities) + registration, err := cachedKcpInformers.Apis().V1alpha2().APIExports().Informer().AddEventHandler(identities) + if err != nil { + return nil, err + } return builder.BuildVirtualWorkspace( path.Join(rootPathPrefix, admin.VirtualWorkspaceName), cacheDynamicClusterClient, identities, + registration.HasSynced, ) } 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 2beb490b1f6..301e59ced11 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 @@ -17,6 +17,8 @@ limitations under the License. package v1alpha1 import ( + "strconv" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" conditionsv1alpha1 "github.com/kcp-dev/sdk/apis/third_party/conditions/apis/conditions/v1alpha1" @@ -135,7 +137,7 @@ 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], + APIIdentityCoreLabel: strconv.FormatBool(core), } }