Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion cmd/sharded-test-server/frontproxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,6 @@ func startFrontProxy(
"--bind-address="+hostIP,
fmt.Sprintf("--mapping-file=%s", filepath.Join(workDirPath, ".kcp-front-proxy", "mapping.yaml")),
fmt.Sprintf("--root-directory=%s", filepath.Join(workDirPath, ".kcp-front-proxy")),
fmt.Sprintf("--root-kubeconfig=%s", filepath.Join(workDirPath, ".kcp", "root.kubeconfig")),
fmt.Sprintf("--shards-kubeconfig=%s", filepath.Join(workDirPath, ".kcp-front-proxy", "shards.kubeconfig")),
fmt.Sprintf("--shard-peer-kubeconfig=%s", filepath.Join(workDirPath, ".kcp-front-proxy", "peers.kubeconfig")),
fmt.Sprintf("--client-ca-file=%s", filepath.Join(workDirPath, ".kcp", "client-ca.crt")),
Expand Down
10 changes: 6 additions & 4 deletions docs/content/concepts/sharding/shards.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,10 +129,12 @@ Access requires membership in the `system:kcp:admin` group; the serving
path does not depend on the root shard. To reach the view through a
front-proxy, add a `/services/` path mapping; any shard can be the backend.

The front-proxy itself discovers shards through the Admin workspace: the
kubeconfigs given via `--shard-peer-kubeconfig` (falling back to the
`--root-kubeconfig` server) are seeds, used to bootstrap discovery and as a
last-resort fallback only. Every shard observed through the view joins the
The front-proxy itself discovers shards through the Admin workspace, and
reads the identities of the core APIs from the `apiidentities` registry
there, so it starts and runs without the root shard: the kubeconfigs given
via `--shard-peer-kubeconfig` (falling back to the `--root-kubeconfig`
server) are seeds, used to bootstrap discovery and as a last-resort fallback
only. Every shard observed through the view joins the
failover peer set at runtime and decommissioned shards leave it; requests are
distributed across the discovered shards, not the seeds. With short-lived
shards the discovery channel therefore keeps working as long as any currently
Expand Down
7 changes: 7 additions & 0 deletions docs/content/setup/production/metrics.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,10 @@ scrape_configs:
- targets:
- cache.kcp.svc:6443
```

## Scraping the front-proxy

The front-proxy's own `/metrics` is authorized by group: members of
`system:kcp:metrics-reader`, `system:kcp:admin` or `system:masters` may read
it. Give the scraper identity the `system:kcp:metrics-reader` group, for
example as a client certificate organization or an OIDC group claim.
14 changes: 1 addition & 13 deletions pkg/apiidentity/projector.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ import (
"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"
Expand All @@ -48,20 +47,9 @@ import (
// 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)
return bootstrap.IsCoreExport(logicalcluster.From(export), 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.
Expand Down
57 changes: 33 additions & 24 deletions pkg/proxy/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,17 +44,20 @@ type completedConfig struct {
}

type ExtraConfig struct {
// resolveIdentities is to be called on server start until it succeeds. It injects the kcp
// resource identities into the rest.Config used by the client. Only after it succeeds,
// the clients can wildcard-list/watch most kcp resources.
ResolveIdentities func(ctx context.Context) error
// CoreIdentityHandler keeps the identities resolved by ResolveIdentities
// in sync with the APIExports in the root workspace, e.g. across an
// APIExport identity rotation. Register it on an informer of those
// APIExports.
// CoreIdentityHandler feeds the identities of the core APIs, which the
// clients built from ShardsConfig inject into their wildcard requests,
// from the APIIdentity registry that the Admin workspace of the peers
// serves. Register it on an informer of those entries.
CoreIdentityHandler cache.ResourceEventHandler
RootShardConfig *rest.Config
ShardsConfig *rest.Config
// CoreIdentitiesReady fails while the identity of a core API is still
// unknown. It is a readiness check: requests needing an identity fail
// until it arrives, nothing blocks startup on it.
CoreIdentitiesReady func() error
// RootShardConfig points at the root shard and is set only when
// --root-kubeconfig is given. Service account authentication still needs
// it.
RootShardConfig *rest.Config
ShardsConfig *rest.Config
// PeersConfig points at the Admin workspace (/services/admin) of the
// peer shards with round-robin and per-request failover, and is the only
// source for Shard discovery. When no peers are configured, the root
Expand Down Expand Up @@ -89,17 +92,20 @@ func NewConfig(ctx context.Context, opts *proxyoptions.Options) (*Config, error)
Options: opts,
}

// get root API identities
//nolint:staticcheck // the deprecated root kubeconfig remains the source for identity resolution until identities are resolvable without the root shard.
nonIdentityRootConfig, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(&clientcmd.ClientConfigLoadingRules{ExplicitPath: c.Options.RootKubeconfig}, nil).ClientConfig()
if err != nil {
return nil, fmt.Errorf("failed to load root kubeconfig: %w", err)
}

// The identities of the core APIs come from the APIIdentity registry,
// read through the Admin workspace of the peers; the root shard is not
// asked for them.
var kcpShardIdentityRoundTripper func(rt http.RoundTripper) http.RoundTripper
kcpShardIdentityRoundTripper, c.ResolveIdentities, c.CoreIdentityHandler = bootstrap.NewWildcardIdentitiesWrappingRoundTripper(bootstrap.KcpRootGroupExportNames, bootstrap.KcpRootGroupResourceExportNames, nonIdentityRootConfig, nil)
c.RootShardConfig = rest.CopyConfig(nonIdentityRootConfig)
c.RootShardConfig.Wrap(kcpShardIdentityRoundTripper)
kcpShardIdentityRoundTripper, c.CoreIdentityHandler, c.CoreIdentitiesReady = bootstrap.NewCoreWildcardIdentities(bootstrap.KcpRootGroupExportNames, bootstrap.KcpRootGroupResourceExportNames)

if c.Options.RootKubeconfig != "" {
rootConfig, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(&clientcmd.ClientConfigLoadingRules{ExplicitPath: c.Options.RootKubeconfig}, nil).ClientConfig()
if err != nil {
return nil, fmt.Errorf("failed to load root kubeconfig: %w", err)
}
c.RootShardConfig = rest.CopyConfig(rootConfig)
c.RootShardConfig.Wrap(kcpShardIdentityRoundTripper)
}

var loopbackClientConfig *rest.Config
if err := c.Options.SecureServing.ApplyTo(&c.ServingInfo, &loopbackClientConfig); err != nil {
Expand All @@ -109,6 +115,7 @@ func NewConfig(ctx context.Context, opts *proxyoptions.Options) (*Config, error)
return nil, err
}

var err error
c.ShardsConfig, err = clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
&clientcmd.ClientConfigLoadingRules{ExplicitPath: c.Options.ShardsKubeconfig},
// We override the Server here so that the user doesn't have to specify unused server value
Expand All @@ -119,12 +126,14 @@ func NewConfig(ctx context.Context, opts *proxyoptions.Options) (*Config, error)
}
c.ShardsConfig.Wrap(kcpShardIdentityRoundTripper)

// Shard discovery always goes through the Admin workspace of the peers;
// the root workspace is no longer read for it. Without configured peers,
// the root kubeconfig's server is the seed peer.
// Shard discovery and the core identities always go through the Admin
// workspace of the peers; the root shard is never asked. Without
// configured peers, the root kubeconfig's server is the seed peer.
peerKubeconfigs := c.Options.ShardsPeerKubeconfigs
if len(peerKubeconfigs) == 0 {
//nolint:staticcheck // the deprecated root kubeconfig doubles as the default seed peer for deployments that predate --shard-peer-kubeconfig.
if c.Options.RootKubeconfig == "" {
return nil, fmt.Errorf("--shard-peer-kubeconfig is required")
}
peerKubeconfigs = []string{c.Options.RootKubeconfig}
}
c.PeersConfig, c.Peers, err = NewPeersConfig(peerKubeconfigs)
Expand Down
58 changes: 58 additions & 0 deletions pkg/proxy/metrics_authorizer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/*
Copyright 2026 The kcp Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package proxy

import (
"context"
"slices"

"k8s.io/apiserver/pkg/authorization/authorizer"

bootstrappolicy "github.com/kcp-dev/kcp/pkg/authorization/bootstrap"
)

// metricsReaderGroups may read the front-proxy's /metrics. The proxy has
// no RBAC of its own and must not depend on the root shard, so access is
// decided from the caller's groups: system:kcp:admin, and
// system:kcp:metrics-reader, the name of the ClusterRole that authorizes
// scraping on the shards, used as a group here. system:masters is not
// listed: the proxy drops it on ingress (see --authentication-drop-groups),
// so it never reaches this authorizer.
var metricsReaderGroups = []string{
bootstrappolicy.SystemKcpAdminGroup,
bootstrappolicy.SystemKcpMetricsReader,
}

type metricsGroupAuthorizer struct{}

func newMetricsGroupAuthorizer() authorizer.Authorizer {
return metricsGroupAuthorizer{}
}

// Authorize allows members of metricsReaderGroups and has no opinion on
// everybody else, so that a further authorizer in a union can still allow.
func (metricsGroupAuthorizer) Authorize(_ context.Context, attr authorizer.Attributes) (authorizer.Decision, string, error) {
if attr.GetUser() == nil {
return authorizer.DecisionNoOpinion, "", nil
}
for _, group := range attr.GetUser().GetGroups() {
if slices.Contains(metricsReaderGroups, group) {
return authorizer.DecisionAllow, "user is a member of the " + group + " group", nil
}
}
return authorizer.DecisionNoOpinion, "", nil
}
58 changes: 58 additions & 0 deletions pkg/proxy/metrics_authorizer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/*
Copyright 2026 The kcp Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package proxy

import (
"context"
"testing"

"github.com/stretchr/testify/require"

"k8s.io/apiserver/pkg/authentication/user"
"k8s.io/apiserver/pkg/authorization/authorizer"

bootstrappolicy "github.com/kcp-dev/kcp/pkg/authorization/bootstrap"
)

func TestMetricsGroupAuthorizer(t *testing.T) {
t.Parallel()

for _, tc := range []struct {
name string
groups []string
want authorizer.Decision
}{
{name: "kcp admin may scrape", groups: []string{bootstrappolicy.SystemKcpAdminGroup, user.AllAuthenticated}, want: authorizer.DecisionAllow},
{name: "system:masters gets no opinion", groups: []string{bootstrappolicy.SystemMastersGroup}, want: authorizer.DecisionNoOpinion},
{name: "the metrics-reader group may scrape", groups: []string{bootstrappolicy.SystemKcpMetricsReader}, want: authorizer.DecisionAllow},
// no opinion, not deny: a further authorizer in the union (the :root
// SubjectAccessReview while --root-kubeconfig is given) may still allow.
{name: "an ordinary user gets no opinion", groups: []string{user.AllAuthenticated}, want: authorizer.DecisionNoOpinion},
{name: "no groups gets no opinion", groups: nil, want: authorizer.DecisionNoOpinion},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, _, err := newMetricsGroupAuthorizer().Authorize(context.Background(), authorizer.AttributesRecord{
User: &user.DefaultInfo{Name: "scraper", Groups: tc.groups},
Verb: "get",
Path: "/metrics",
})
require.NoError(t, err)
require.Equal(t, tc.want, got)
})
}
}
9 changes: 9 additions & 0 deletions pkg/proxy/options/authentication.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,12 @@ func (c *Authentication) tokenAuthEnabled() bool {
return c.BuiltInOptions.TokenFile != nil && c.BuiltInOptions.TokenFile.TokenFile != ""
}

// ServiceAccountAuthEnabled reports whether service account tokens are
// accepted, which needs a client to the root shard.
func (c *Authentication) ServiceAccountAuthEnabled() bool {
return c.serviceAccountAuthEnabled()
}

func (c *Authentication) serviceAccountAuthEnabled() bool {
return c.BuiltInOptions.ServiceAccounts != nil && len(c.BuiltInOptions.ServiceAccounts.KeyFiles) != 0
}
Expand Down Expand Up @@ -129,6 +135,9 @@ func (c *Authentication) ApplyTo(ctx context.Context, authenticationInfo *generi

// Set for service account auth, if enabled
if c.serviceAccountAuthEnabled() {
if rootShardConfig == nil {
return fmt.Errorf("service account authentication needs --root-kubeconfig")
}
authenticationInfo.APIAudiences = c.BuiltInOptions.APIAudiences
if len(c.BuiltInOptions.ServiceAccounts.Issuers) != 0 && len(c.BuiltInOptions.APIAudiences) == 0 {
authenticationInfo.APIAudiences = c.BuiltInOptions.ServiceAccounts.Issuers
Expand Down
18 changes: 11 additions & 7 deletions pkg/proxy/options/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,8 @@ type Options struct {
RootDirectory string
// RootKubeconfig points at the root shard.
//
// Deprecated: shard discovery uses ShardsPeerKubeconfigs (the Admin
// workspace of the peer shards) instead. The root kubeconfig is still
// required for APIExport identity resolution and will be removed once
// identities are resolvable without the root shard.
// TODO: This is going to be removed once service account
// authentication no longer requires the root shard.
RootKubeconfig string
ShardsKubeconfig string
ShardsPeerKubeconfigs []string
Expand Down Expand Up @@ -65,12 +63,12 @@ func (o *Options) AddFlags(fss *cliflag.NamedFlagSets) {
fs := fss.FlagSet("proxy")
fs.StringVar(&o.MappingFile, "mapping-file", o.MappingFile, "Config file mapping paths to backends")
fs.StringVar(&o.RootDirectory, "root-directory", o.RootDirectory, "Root directory.")
fs.StringVar(&o.RootKubeconfig, "root-kubeconfig", o.RootKubeconfig, "The path to the kubeconfig of the root shard.")
if err := fs.MarkDeprecated("root-kubeconfig", "use --shard-peer-kubeconfig for shard discovery; --root-kubeconfig is still required for APIExport identity resolution and will be removed once identities are resolvable without the root shard."); err != nil {
fs.StringVar(&o.RootKubeconfig, "root-kubeconfig", o.RootKubeconfig, "The path to the kubeconfig of the root shard. Only needed for service account authentication; without --shard-peer-kubeconfig its server also acts as the seed peer.")
if err := fs.MarkDeprecated("root-kubeconfig", "use --shard-peer-kubeconfig; the root shard is needed neither for shard discovery nor for the identities of the core APIs any more. It is still required for service account authentication."); err != nil {
panic(err)
}
fs.StringVar(&o.ShardsKubeconfig, "shards-kubeconfig", o.ShardsKubeconfig, "The path to the kubeconfig used for communication with all shards. The server name if provided is replaced with a shard's hostname.")
fs.StringSliceVar(&o.ShardsPeerKubeconfigs, "shard-peer-kubeconfig", o.ShardsPeerKubeconfigs, "Repeatable path to a kubeconfig whose named clusters are seed peer shards; all files' clusters are merged into one seed list. The proxy discovers Shards through the Admin workspace (/services/admin) of the peers - requests are distributed round-robin with failover - instead of watching the root shard. If unset, the --root-kubeconfig server acts as the only seed peer. The seeds are used for bootstrapping and as a last-resort fallback only: once shards are discovered, requests go to the discovered shards, which join and leave the peer set at runtime, so the files do not have to track short-lived shards.")
fs.StringSliceVar(&o.ShardsPeerKubeconfigs, "shard-peer-kubeconfig", o.ShardsPeerKubeconfigs, "Repeatable path to a kubeconfig whose named clusters are seed peer shards; all files' clusters are merged into one seed list. The proxy discovers Shards and the identities of the core APIs through the Admin workspace (/services/admin) of the peers - requests are distributed round-robin with failover - and never talks to the root shard for them. If unset, the --root-kubeconfig server acts as the only seed peer. The seeds are used for bootstrapping and as a last-resort fallback only: once shards are discovered, requests go to the discovered shards, which join and leave the peer set at runtime, so the files do not have to track short-lived shards.")
fs.StringVar(&o.ProfilerAddress, "profiler-address", "", "[Address]:port to bind the profiler to")
fs.StringSliceVar(&o.CorsAllowedOriginList, "cors-allowed-origins", o.CorsAllowedOriginList, "List of allowed origins for CORS, comma separated. An allowed origin can be a regular expression to support subdomain matching. If this list is empty CORS will not be enabled.")
}
Expand Down Expand Up @@ -103,6 +101,12 @@ func (o *Options) Validate() []error {
if len(o.ShardsKubeconfig) == 0 {
errs = append(errs, fmt.Errorf("--shards-kubeconfig is required"))
}
if len(o.ShardsPeerKubeconfigs) == 0 && len(o.RootKubeconfig) == 0 {
errs = append(errs, fmt.Errorf("--shard-peer-kubeconfig is required"))
}
if o.Authentication.ServiceAccountAuthEnabled() && len(o.RootKubeconfig) == 0 {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will get removed in follow-up pr

errs = append(errs, fmt.Errorf("--root-kubeconfig is required for service account authentication"))
}

errs = append(errs, o.SecureServing.Validate()...)
errs = append(errs, o.Authentication.Validate()...)
Expand Down
Loading
Loading