Skip to content
Closed
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
6 changes: 0 additions & 6 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,3 @@ pki/
# Self-signed serving certs a VW generates when none are supplied.
apiserver.local.config/
/marketplace/bin/

# The web UI's build output and dependencies. Running vitest from the repo
# root also leaves a node_modules/ cache there.
node_modules/
ui/dist/
*.tsbuildinfo
20 changes: 4 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ platform built out of two of them.

**kcp Platform** is [tenancy/](tenancy/) and [marketplace/](marketplace/)
together — tenants, projects and memberships, plus a catalog of offerings
to enable in them — served under the `*.platform.kcp.io` API groups, with
[ui/](ui/) as its console. The other three components are standalone
virtual workspaces that stand on their own.
to enable in them — served under the `*.platform.kcp.io` API groups. Its
web console lives in [kcp hub](https://github.com/olamilekan000/kcp-hub),
which runs these components as one platform. The other three components
are standalone virtual workspaces that stand on their own.

| Component | What it serves | Image |
|---|---|---|
Expand Down Expand Up @@ -68,19 +69,6 @@ nothing after it has to be told where kcp is.
The standalone binaries (`tenancyctl`, `scar-to-kubeconfig`) remain the
same code under their own names.

## One UI

[ui/](ui/) is a web console over exactly the same surfaces: sign in with
the same identity provider, see your tenants and projects, browse the
catalog and enable from it, and look at what a workspace serves. It is
static files talking to the front-proxy with the user's own token — no
backend of ours, nothing privileged, and so it can never show more than
kcp would answer for that person.

`make tilt-up` starts it with the rest of the stack, on
http://localhost:5173; `cd ui && npm install && npm run dev` runs it
against a stack that is already up.

## Building a producer

[producer-quickstart/](producer-quickstart/) is a producer ready to copy: a Go type, the
Expand Down
41 changes: 4 additions & 37 deletions Tiltfile
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ if os.getenv('KCP_PORTAL_ENABLED') == None and not os.path.exists(KCP_DIR + '/..
os.putenv('KCP_PORTAL_ENABLED', 'false')

# Dex: an in-cluster OIDC issuer shared by kcp and the virtual workspaces,
# so the console and `contribctl login` work against this environment. One
# so `contribctl login` works against this environment. One
# issuer URL serves both sides: the host reaches it through the :8443
# ingress port-forward, the kcp pods through their gateway hostAliases.
# Disable with DEX_ENABLED=false.
Expand Down Expand Up @@ -147,8 +147,8 @@ if KCP_STATIC:
print('kcp portal → http://localhost:8080 (no-oidc, static image %s)' % portal_image)
else:
# Built from source: kcp's hand-written CRs, which carry no OIDC block.
# Rather than let the stack come up with a console nobody can sign in
# to, say so.
# Rather than let the stack come up with an issuer nobody can sign in
# with, say so.
if DEX_ENABLED:
fail('KCP_STATIC=false runs kcp from the hand-written CRs in the checkout, which have no OIDC issuer; ' +
'run with DEX_ENABLED=false, or use the default KCP_STATIC=true.')
Expand Down Expand Up @@ -596,8 +596,7 @@ if enabled('MCP'):
print(' publish it to the catalog: tilt trigger mcp-offering')

# The dex user who runs the platform: init binds it to the platform-admin
# role in the tenancy store and the marketplace catalog, which is what
# makes the console's Admin page appear for it.
# role in the tenancy store and the marketplace catalog, and nothing else.
PLATFORM_ADMIN = 'admin@platform.example.com'
PLATFORM_ADMIN_INIT_ARGS = ' - --platform-admin-user=%s\n' % PLATFORM_ADMIN

Expand Down Expand Up @@ -665,38 +664,6 @@ if enabled('MARKETPLACE'):
print('marketplace VW → %s/services/marketplace' % FRONT_PROXY_URL)
print(' example offerings: tilt trigger marketplace-examples')

# The web UI, served by vite in dev mode. It talks only to the
# front-proxy — the dev server proxies /services and /clusters there — so
# the browser stays on one origin and kcp needs no CORS configuration.
# UI_ENABLED=false skips it.
if enabled('UI'):
# Dependencies are a resource of their own so the dev server is not
# restarted by an install, and an install re-runs only when the
# manifests change. `npm ci` when there is a lockfile, because a dev
# environment that drifts from it is a class of bug nobody enjoys.
local_resource('ui-deps',
cmd='cd ui && if [ -f package-lock.json ]; then npm ci --silent; else npm install --silent; fi',
deps=['ui/package.json', 'ui/package-lock.json'],
labels=['ui'],
allow_parallel=True,
)

# Vite watches the sources itself, so Tilt starts it and stays out of
# the way: no deps here, or every keystroke would restart the server.
local_resource('ui',
serve_cmd='npm run dev --silent',
serve_dir='ui',
resource_deps=['ui-deps'] + (['dex'] if DEX_ENABLED else []),
labels=['ui'],
links=['http://localhost:5173'],
readiness_probe=probe(
period_secs=5,
http_get=http_get_action(port=5173, path='/'),
),
allow_parallel=True,
)
print('UI → http://localhost:5173 (sign in with the dex users above)')

# One patch carries every mapping: the front-proxy matches the longest
# prefix, so these win over the default /services/ route to the shards.
if VW_PATH_MAPPINGS:
Expand Down
13 changes: 4 additions & 9 deletions hack/tilt/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,7 @@ the `oidc` block (`issuerURL`,
`clientID: kcp`, `usernameClaim: email`, `caFileRef: dex-tls/ca.crt`) and
`dex.kcp.localhost` among the pods' hostAliases. That is what puts an OIDC
authenticator on the shards and the front-proxy; without it every dex token
is `invalid bearer token` at `:8443` and the console reports that the
servers did not accept the sign-in. If kcp's entry point grows an `oidc`
is `invalid bearer token` at `:8443`. If kcp's entry point grows an `oidc`
parameter, this collapses back to an include.

### The dex certificate, and why it matters
Expand All @@ -72,8 +71,8 @@ directions that are easy to mistake for component failures:

- a browser will not talk to a self-signed issuer until someone has visited
it once and accepted the warning, and that acceptance is **per
certificate** — so a new one makes the console's Sign in button fail with
nothing but `Failed to fetch` in the console;
certificate** — so a new one makes signing in from a browser fail until
the issuer has been visited and accepted again;
- dex reads its certificate once, at **boot**, so a new one in the secret
leaves dex serving the old one while every OIDC validator has mounted the
new CA. They then cannot fetch dex's discovery document at all, their
Expand All @@ -86,9 +85,6 @@ longer changes the certificate — only losing the cluster does. And the
certificate's fingerprint is an annotation on the dex pod template, so a
certificate that really is new rolls dex with it.

If the console's Sign in does fail, it now says so in those terms and links
the issuer to accept once, rather than printing the URL as decoration.

Knobs, all optional:

| Variable | Effect |
Expand Down Expand Up @@ -219,8 +215,7 @@ a token names the same user everywhere. Four users, all with the password
`admin` is the platform administrator: the tenancy and marketplace init
steps run with `--platform-admin-user=admin@platform.example.com`, which
binds it to a role that may manage Tenants in `root:tenancy:store` and
Producers in `root:marketplace:catalog`, and nothing else. It is the only
user the console shows the Admin page to.
Producers in `root:marketplace:catalog`, and nothing else.

The identity is the **plain email, with no groups**: the kcp-operator only
speaks the legacy `--oidc-*` claim mapping, so the CEL claim mapping used
Expand Down
2 changes: 1 addition & 1 deletion hack/tilt/dex-pki.sh
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
#
# - a browser will not talk to a self-signed issuer until someone has
# visited it once and accepted the warning, and that acceptance is per
# certificate — so a new one silently breaks signing in to the console;
# certificate — so a new one silently breaks signing in from a browser;
# - dex reads its certificate once at boot, so a new one means every OIDC
# validator has to be rolled with it or they answer 401 to everything.
#
Expand Down
26 changes: 3 additions & 23 deletions hack/tilt/dex.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -67,23 +67,6 @@ data:
https: 0.0.0.0:5556
tlsCert: /etc/dex/tls/tls.crt
tlsKey: /etc/dex/tls/tls.key
# The browser fetches discovery and exchanges the code from the UI's
# own origin, which is a different one than dex — so dex has to say
# those origins may read its answers. Without this the sign-in fails
# at the first request, before anything reaches kcp.
#
# A few ports, because vite moves up one when the last is taken.
allowedOrigins:
- http://localhost:5173
- http://localhost:5174
- http://localhost:5175
# The token exchange itself needs none of these — form-encoded POSTs
# are CORS-safelisted and never preflight — but anything that adds a
# header later would be refused with a bare 403 at the preflight,
# which is a confusing way to find out.
allowedHeaders:
- Content-Type
- Authorization
oauth2:
# The password grant needs a nominated connector and a public
# client; PKCE (contribctl tenancy login without --username) works
Expand All @@ -94,14 +77,11 @@ data:
- id: kcp
public: true
name: kcp
# The CLI's loopback listener, and the UI's dev server. Both are
# public clients doing authorization-code + PKCE: neither has a
# secret to keep, which is what PKCE is for.
# The CLI's loopback listener: a public client doing
# authorization-code + PKCE, with no secret to keep, which is what
# PKCE is for.
redirectURIs:
- 'http://localhost:8000/callback'
- 'http://localhost:5173/callback'
- 'http://localhost:5174/callback'
- 'http://localhost:5175/callback'
enablePasswordDB: true
# All hashes are bcrypt("password").
staticPasswords:
Expand Down
5 changes: 5 additions & 0 deletions marketplace/config/apiexport/apiexport-publish.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
# - read apiexports, because an annotated export IS an offering: this is
# how one is noticed, read, and noticed again when un-annotated;
# - read their endpoint slices, to follow the exports that are published;
# - read apiresourceschemas, so the catalog can show what an offering
# serves - the schema a consumer gets by enabling it - before they do;
# - read the LogicalCluster, so the workspace's canonical path can be
# matched against the Producer record that names it (that match is how
# an external producer's workspace is recognized);
Expand All @@ -32,6 +34,9 @@ spec:
- group: apis.kcp.io
resource: apiexportendpointslices
verbs: [get, list, watch]
- group: apis.kcp.io
resource: apiresourceschemas
verbs: [get, list, watch]
- group: core.kcp.io
resource: logicalclusters
verbs: [get, list, watch]
Expand Down
52 changes: 52 additions & 0 deletions marketplace/pkg/directory/directory.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,25 @@ type Offering struct {
// content is mirrored separately and joined at read time, so an edit
// to the instructions never has to re-reconcile the export.
InstructionsRef string
// Resources are the export's spec.resources. Like instructions, the
// schemas they name are mirrored separately and joined at read time.
Resources []ResourceRef
}

// ResourceRef is one resource an export serves and the APIResourceSchema,
// in the export's own cluster, that defines it.
type ResourceRef struct {
Group string
Resource string
Schema string
}

// Schema is the directory's view of an APIResourceSchema: what the catalog
// shows of it.
type Schema struct {
Kind string
Scope string
Versions []marketplacev1alpha1.OfferingResourceVersion
}

// Directory is safe for concurrent use.
Expand All @@ -75,6 +94,8 @@ type Directory struct {
// instructions holds the content of ConfigMaps offerings point at,
// keyed like everything else by the cluster they live in.
instructions map[Key]map[string]string
// schemas holds the APIResourceSchemas offerings reference.
schemas map[Key]Schema
}

// New returns an empty directory.
Expand All @@ -83,6 +104,7 @@ func New() *Directory {
producers: map[Key]Producer{},
offerings: map[Key]Offering{},
instructions: map[Key]map[string]string{},
schemas: map[Key]Schema{},
}
}

Expand Down Expand Up @@ -149,6 +171,20 @@ func (d *Directory) RemoveInstructions(key Key) {
delete(d.instructions, key)
}

// UpsertSchema records one APIResourceSchema. As with instructions, which
// offerings use it is decided at read time by name.
func (d *Directory) UpsertSchema(key Key, s Schema) {
d.mu.Lock()
defer d.mu.Unlock()
d.schemas[key] = s
}

func (d *Directory) RemoveSchema(key Key) {
d.mu.Lock()
defer d.mu.Unlock()
delete(d.schemas, key)
}

// ForgetCluster drops everything mirrored from one logical cluster — a
// workspace that unbound its export leaves nothing behind.
func (d *Directory) ForgetCluster(cluster string) {
Expand All @@ -169,6 +205,11 @@ func (d *Directory) ForgetCluster(cluster string) {
delete(d.instructions, key)
}
}
for key := range d.schemas {
if key.Cluster == cluster {
delete(d.schemas, key)
}
}
}

// Entry is one catalog listing: a published export joined with the
Expand All @@ -184,6 +225,9 @@ type Entry struct {
// Instructions is the referenced ConfigMap's content, or nil if the
// offering names none or it has not been seen.
Instructions map[string]string
// Resources are the offering's resources joined with their schemas;
// one whose schema has not been seen carries no versions.
Resources []marketplacev1alpha1.OfferingResource
}

// Name is the entry's stable listing name, "<producer>.<export>".
Expand Down Expand Up @@ -232,6 +276,14 @@ func (d *Directory) Catalog() []Entry {
if offering.InstructionsRef != "" {
entry.Instructions = d.instructions[Key{Cluster: key.Cluster, Name: offering.InstructionsRef}]
}
// So do the schemas.
for _, ref := range offering.Resources {
r := marketplacev1alpha1.OfferingResource{Group: ref.Group, Resource: ref.Resource, Schema: ref.Schema}
if s, seen := d.schemas[Key{Cluster: key.Cluster, Name: ref.Schema}]; seen {
r.Kind, r.Scope, r.Versions = s.Kind, s.Scope, s.Versions
}
entry.Resources = append(entry.Resources, r)
}
out = append(out, entry)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name() < out[j].Name() })
Expand Down
66 changes: 66 additions & 0 deletions marketplace/pkg/directory/directory_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/*
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 directory

import (
"testing"

"k8s.io/apimachinery/pkg/runtime"

marketplacev1alpha1 "github.com/kcp-dev/contrib-virtual-workspaces/marketplace/sdk/apis/marketplace/v1alpha1"
)

func TestCatalogJoinsSchemasFromTheOfferingsOwnCluster(t *testing.T) {
d := New()
d.UpsertProducer(Key{Cluster: "catalog", Name: "acme"}, Producer{WorkspacePath: "root:acme", WorkspaceCluster: "c-acme"})
d.UpsertOffering(Key{Cluster: "c-acme", Name: "widgets.acme.example"}, Offering{
Export: "widgets.acme.example",
Resources: []ResourceRef{
{Group: "acme.example", Resource: "widgets", Schema: "v1.widgets.acme.example"},
{Group: "acme.example", Resource: "gizmos", Schema: "v1.gizmos.acme.example"},
},
})
schema := runtime.RawExtension{Raw: []byte(`{"type":"object"}`)}
d.UpsertSchema(Key{Cluster: "c-acme", Name: "v1.widgets.acme.example"}, Schema{
Kind: "Widget",
Scope: "Namespaced",
Versions: []marketplacev1alpha1.OfferingResourceVersion{{Name: "v1", Served: true, Storage: true, Schema: schema}},
})
// A schema of the same name in another workspace is not this offering's.
d.UpsertSchema(Key{Cluster: "c-other", Name: "v1.gizmos.acme.example"}, Schema{Kind: "Gizmo"})

entries := d.Catalog()
if len(entries) != 1 {
t.Fatalf("catalog = %+v, want one entry", entries)
}
got := entries[0].Resources
if len(got) != 2 {
t.Fatalf("resources = %+v, want both the export serves", got)
}
if got[0].Kind != "Widget" || got[0].Scope != "Namespaced" || len(got[0].Versions) != 1 ||
string(got[0].Versions[0].Schema.Raw) != `{"type":"object"}` {
t.Errorf("widgets = %+v, want its schema joined in", got[0])
}
if got[1].Resource != "gizmos" || got[1].Kind != "" || got[1].Versions != nil {
t.Errorf("gizmos = %+v, want it listed without a schema it has not seen", got[1])
}

d.RemoveSchema(Key{Cluster: "c-acme", Name: "v1.widgets.acme.example"})
if r := d.Catalog()[0].Resources[0]; r.Versions != nil {
t.Errorf("widgets = %+v after its schema went away, want no versions", r)
}
}
Loading
Loading