Skip to content

Commit bf1c424

Browse files
mjudeikisclaude
andauthored
feat(infra): template images as schema defaults + kro-acceptance e2e/CI (#369)
* feat(infra): sandbox-runner images as schema defaults (drop ${kedge.*} tokens) The sandbox-runner template was the lone outlier using ${kedge.*} env-substitution tokens for its container images (Phase 2a) — every other template declares configurable images as spec.schema fields with sane defaults and hardcodes fixed sidecar images. That outlier broke in practice: an unset KEDGE_SANDBOX_RUNNER_IMAGE substituted to "" and kro authored a Deployment/Job with `image: ""` (Required value). Make sandbox-runner follow the same convention as simple-webapp / database / redis / application: - spec.runnerImage is a schema field with a sane default (ghcr.io/faroshq/kedge-sandbox-runner:dev); the runner Deployment uses ${schema.spec.runnerImage}. Overridable per instance. - the control-token Job image is hardcoded bitnami/kubectl, like every other template's control job (tokenGeneratorImage field dropped). Remove all the now-dead image-token machinery: - kro backend: drop the sandbox image tokens (only the exposure Gateway tokens remain). - infra chart: drop KEDGE_SANDBOX_*_IMAGE env, the install guard, and the sandboxRunner values block; operator passthrough. - App Studio: sandboxRunnerValues supplies only {projectRef}; drop the image env helpers, chart guard/env/values, README rows. Document the convention so this doesn't recur: - new providers/infrastructure/docs/template-conventions.md (when to use a schema default vs a hardcoded literal vs a ${kedge.*} token, with the failure modes that motivated it). - AGENTS.md §9 guardrail pointing at it. Also: dev postgres images default to Google's Docker Hub mirror (mirror.gcr.io/library/postgres:16-alpine) — Docker Hub has been dropping pulls with "unexpected EOF". Preview routing is unchanged (it was already schema-based on main). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(infra): e2e that every template's RGD is accepted by kro + CI gate Unit tests only check buildRGD's output; they miss whether kro actually ACCEPTS the authored graph — which is exactly the class of bug the sandbox-runner template hit (an integer field fed a string, an includeWhen that wasn't a standalone CEL expression). Add an e2e that closes that gap for every seeded template. - providers/infrastructure/backend/kro/e2e_test.go (build tag `e2e`): for each install/templates/*.yaml, build the real RGD via buildRGD and assert kro reports GraphAccepted=True. Reuses the package's decodeTemplate/buildRGD/testTokens. Skips when KUBECONFIG is unset, so it never runs in the unit pass. - make e2e-infrastructure: stands up a throwaway kind cluster with the Gateway API CRDs (templates declare HTTPRoute/ReferenceGrant) and STANDALONE kro (multicluster.enabled=false — no kcp needed; graph validation is independent of the watch source), then runs the test. Split into -up / -run / -down for reuse. - .github/workflows/infrastructure-templates-e2e.yaml: runs it on PRs touching the templates, the kro backend, or the provider apis. Validated end to end: a fresh kind + standalone kro accepts all five templates (application, database, redis-cache, sandbox-runner, simple-webapp). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(infra): e2e also creates an instance of each template The template e2e proved only that kro ACCEPTS each authored RGD (GraphAccepted=True). Extend it to also CREATE a sample instance per template and assert kro reconciles it WITHOUT an apply error. This is the exact failure that motivated the schema-default image convention ("apply results contain errors: ... image: Required value"): RGD acceptance alone never exercised it, because the empty-image fault only surfaces when kro applies a child Deployment. The test now catches it. Per template: build an instance from sampleValues when present, else a minimal valid spec (e2eMinimalSpecs); retry Create until the fresh CRD is servable; poll status conditions and fail on any apply-error marker (apply results contain errors / is invalid / Required value / failed to apply / admission webhook). Readiness is out of scope — images need not pull in CI; apply validates the spec. Validated end-to-end on a fresh standalone kro kind cluster (ok 20.7s, all five templates). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * ci(infra): reflect instance-creation coverage in template e2e workflow Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(infra): verify kro creates each template's child objects The e2e proved kro accepted the RGD and the instance reported no apply error, but never checked the runtime cluster for the objects kro is supposed to create. Add that: per template, after the instance reconciles, List every child the RGD declares (matched by kro's kro.run/instance-id + node-id labels) and assert the always-on ones exist; resources gated by includeWhen (e.g. the preview HTTPRoute) are verified only when present. This caught a real gap: application's HTTPRoute references spec.expose.fqdn, a field the platform stamps onto the instance ("do NOT set" in the sample) — with no controller in the standalone-kro e2e, the route could never render. The test now stands in for the platform via e2ePlatformStamped (supplies expose.fqdn + credentialsSecretName), so the HTTPRoute is created and verified. Instance names are made per-run-unique (a runID nonce; the 16-hex form satisfies sandbox-runner's name pattern) so re-running against a reused cluster can't collide with a prior run's not-yet-GC'd child objects. Validated on a fresh standalone kro cluster: all five templates create their declared objects (ok 27.6s) — application now incl. HTTPRoute×2. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs: name the provider-isolation rule (no cross-provider backend access) Providers must not reach into another provider's backend layer (its runtime/target clusters and credentials, databases, internal Services, controllers, kro RGDs). All cross-provider interaction goes through the owning provider's published API: its kcp APIExport resources (control plane) and virtual-workspace subresources (data plane), invoked as the tenant user and routed by binding — never by a hardcoded backend URL. The rule was already practiced everywhere (the App Studio -> infrastructure runtime-decoupling is the worked example) but never named. Make it explicit and link the docs that embody it to one canonical statement: - docs/providers.md: new "Provider isolation (the cross-provider boundary)" section + a Security bullet. - docs/provider-connectivity-contract.md: new Contract 3 (provider-to- provider) alongside the existing platform-facing contracts 1 and 2, with a conformance table and checklist item. - AGENTS.md: a guardrail bullet so new work follows it by default. - Reinforcement notes (linked, not duplicated) in infrastructure-, kuery-, mcp-architecture, app-studio-runtime-decoupling, app-studio-sandbox-runtime, and the infrastructure/app-studio READMEs. Docs only; no code or behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5efe47c commit bf1c424

28 files changed

Lines changed: 896 additions & 192 deletions
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
name: Infrastructure Template E2E
2+
3+
# Every seeded infrastructure Template must author a kro ResourceGraphDefinition
4+
# that kro ACCEPTS, and a sample instance of it must reconcile WITHOUT an apply
5+
# error. Unit tests only check buildRGD's output; this stands up a real
6+
# (standalone) kro on kind and, per template, asserts GraphAccepted=True and then
7+
# creates a sample instance that kro applies cleanly — catching the failures unit
8+
# tests miss (string-vs-int fields, non-standalone includeWhen expressions, an
9+
# image that resolved to "" surfacing as "Required value" only at apply time).
10+
# See providers/infrastructure/backend/kro/e2e_test.go.
11+
on:
12+
pull_request:
13+
branches: [main]
14+
paths:
15+
- 'providers/infrastructure/install/templates/**'
16+
- 'providers/infrastructure/backend/kro/**'
17+
- 'providers/infrastructure/apis/**'
18+
- '.github/workflows/infrastructure-templates-e2e.yaml'
19+
- 'Makefile'
20+
push:
21+
branches: [main]
22+
paths:
23+
- 'providers/infrastructure/install/templates/**'
24+
- 'providers/infrastructure/backend/kro/**'
25+
- 'providers/infrastructure/apis/**'
26+
- '.github/workflows/infrastructure-templates-e2e.yaml'
27+
workflow_dispatch:
28+
29+
permissions:
30+
contents: read
31+
packages: read
32+
33+
jobs:
34+
template-e2e:
35+
name: Templates accepted + instances apply
36+
runs-on: ubuntu-latest
37+
timeout-minutes: 25
38+
steps:
39+
- uses: actions/checkout@v4
40+
41+
- uses: actions/setup-go@v5
42+
with:
43+
go-version: v1.26.1
44+
45+
- name: Install kind
46+
uses: helm/kind-action@v1
47+
with:
48+
install_only: true
49+
50+
- name: Install Helm
51+
uses: azure/setup-helm@v3
52+
with:
53+
token: ${{ secrets.GITHUB_TOKEN }}
54+
55+
# The kro fork chart + image live in ghcr under this org; logging in lets
56+
# helm/kind pull them whether they are public or org-private.
57+
- name: Log in to GHCR for the kro chart
58+
run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io -u ${{ github.actor }} --password-stdin
59+
60+
- name: Bring up kro + run the template e2e
61+
run: make e2e-infrastructure
62+
63+
- name: Tear down the e2e cluster
64+
if: always()
65+
run: make e2e-infrastructure-down

AGENTS.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,26 @@ whole point.
390390
build/test and `go.work` awareness; they are not in the root `./...`.
391391
- Before merging: `make verify` is the full gate
392392
(boilerplate + codegen + vet + lint + build + test).
393+
- **Infrastructure templates declare configurable inputs (container images,
394+
versions, sizes) as `spec.schema` fields with sane defaults** — never via
395+
`${kedge.*}` env-substitution tokens. Fixed sidecar images (e.g. the
396+
control-token `kubectl` job) are hardcoded literals. `${kedge.*}` tokens are
397+
reserved for genuinely platform-global values with no universal default (only
398+
the exposure Gateway parent today). A missing env must never be able to
399+
produce an empty/invalid field. See
400+
[`providers/infrastructure/docs/template-conventions.md`](providers/infrastructure/docs/template-conventions.md).
401+
- **Providers are isolated; never reach into another provider's backend.** A
402+
provider's backend layer (its runtime/target clusters and their
403+
credentials, databases, internal Services, controllers, kro RGDs) is
404+
private to it. A provider must not hold a second credential into another
405+
provider's cluster/DB/service, call its internal endpoints directly, or
406+
hardcode its backend topology. Cross-provider access goes **only** through
407+
the other provider's published `APIExport` resources + virtual-workspace
408+
subresources, invoked **as the tenant user** and routed by binding (not by
409+
a backend URL). This is what makes BYO compute work and bounds blast
410+
radius. See [`docs/providers.md` §"Provider isolation"](docs/providers.md#provider-isolation-the-cross-provider-boundary)
411+
and contract 3 in
412+
[`docs/provider-connectivity-contract.md`](docs/provider-connectivity-contract.md).
393413

394414
---
395415

Makefile

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -678,7 +678,9 @@ KUERY_RUNTIME_KUBECONFIG ?= $(KCP_DATA_DIR)/kuery-runtime.kubeconfig
678678
# …) diverges from SQLite and passing on SQLite has shipped real query bugs.
679679
# kuery-db-up starts a throwaway container matching KUERY_DEV_DATABASE_URL.
680680
KUERY_POSTGRES_CONTAINER ?= kedge-kuery-postgres
681-
KUERY_POSTGRES_IMAGE ?= postgres:16-alpine
681+
# Pulled from Google's Docker Hub mirror (same official image, more reliable
682+
# pulls — Docker Hub has been dropping them with "unexpected EOF").
683+
KUERY_POSTGRES_IMAGE ?= mirror.gcr.io/library/postgres:16-alpine
682684
KUERY_POSTGRES_PORT ?= 55433
683685
KUERY_POSTGRES_DATA_DIR ?= $(KCP_DATA_DIR)/kuery-postgres
684686
KUERY_POSTGRES_USER ?= kuery
@@ -862,7 +864,7 @@ APP_STUDIO_SANDBOX_RUNNER_IMAGE ?= $(SANDBOX_RUNNER_IMAGE)
862864
APP_STUDIO_SANDBOX_TOKEN_GENERATOR_IMAGE ?= $(SANDBOX_TOKEN_GENERATOR_IMAGE)
863865
APP_STUDIO_DEV_DATABASE_URL ?= postgres://appstudio:appstudio@localhost:55432/appstudio?sslmode=disable
864866
APP_STUDIO_POSTGRES_CONTAINER ?= kedge-app-studio-postgres
865-
APP_STUDIO_POSTGRES_IMAGE ?= postgres:16-alpine
867+
APP_STUDIO_POSTGRES_IMAGE ?= mirror.gcr.io/library/postgres:16-alpine
866868
APP_STUDIO_POSTGRES_PORT ?= 55432
867869
APP_STUDIO_POSTGRES_DATA_DIR ?= $(KCP_DATA_DIR)/app-studio-postgres
868870
APP_STUDIO_POSTGRES_USER ?= appstudio
@@ -1322,6 +1324,14 @@ KRO_IMAGE_TAG ?= v0.0.1-mc.7
13221324
KRO_NAMESPACE ?= kro-system
13231325
KRO_SEED_DIR ?= providers/infrastructure/examples/rgds
13241326

1327+
# --- Infrastructure template e2e (RGD acceptance against a real kro) ---
1328+
# A throwaway kind cluster running STANDALONE kro (no kcp) — enough to validate
1329+
# that every seeded Template authors a kro graph kro accepts. See
1330+
# providers/infrastructure/backend/kro/e2e_test.go.
1331+
E2E_KRO_KIND_NAME ?= kedge-kro-e2e
1332+
E2E_KRO_KUBECONFIG ?= $(CURDIR)/.kedge-kro-e2e.kubeconfig
1333+
GATEWAY_API_VERSION ?= v1.2.1
1334+
13251335
## Bring up the management kro cluster + install kro (fork w/ multicluster) +
13261336
## register the cluster as a self-member + seed RGDs. Idempotent: re-running
13271337
## just helm-upgrades the chart and re-applies the RGDs.
@@ -1428,6 +1438,33 @@ dev-kro-down: ## Delete the kedge-kro kind cluster + kubeconfig file
14281438
-kind delete cluster --name $(KRO_KIND_NAME)
14291439
-rm -f $(KRO_KIND_KUBECONFIG)
14301440

1441+
e2e-infrastructure-up: ## Bring up a throwaway kind cluster with Gateway API CRDs + standalone kro for the template e2e
1442+
@command -v kind >/dev/null || { echo "kind not found; install: brew install kind"; exit 1; }
1443+
@command -v helm >/dev/null || { echo "helm not found; install: brew install helm"; exit 1; }
1444+
@if ! kind get clusters | grep -qx "$(E2E_KRO_KIND_NAME)"; then \
1445+
echo ">>> creating kind cluster $(E2E_KRO_KIND_NAME)"; \
1446+
kind create cluster --name $(E2E_KRO_KIND_NAME) --kubeconfig $(E2E_KRO_KUBECONFIG); \
1447+
else \
1448+
kind get kubeconfig --name $(E2E_KRO_KIND_NAME) > $(E2E_KRO_KUBECONFIG); \
1449+
fi
1450+
@echo ">>> installing Gateway API CRDs ($(GATEWAY_API_VERSION)) — templates declare HTTPRoute/ReferenceGrant"
1451+
KUBECONFIG=$(E2E_KRO_KUBECONFIG) kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/$(GATEWAY_API_VERSION)/standard-install.yaml
1452+
@echo ">>> installing standalone kro (no kcp) into $(KRO_NAMESPACE)"
1453+
KUBECONFIG=$(E2E_KRO_KUBECONFIG) helm upgrade --install kro $(KRO_CHART) \
1454+
--version $(KRO_CHART_VERSION) -n $(KRO_NAMESPACE) --create-namespace \
1455+
--set image.repository=$(KRO_IMAGE_REPO) --set image.tag=$(KRO_IMAGE_TAG) \
1456+
--set multicluster.enabled=false --wait --timeout 5m
1457+
1458+
e2e-infrastructure-down: ## Delete the infra-template e2e kind cluster + kubeconfig
1459+
-kind delete cluster --name $(E2E_KRO_KIND_NAME)
1460+
-rm -f $(E2E_KRO_KUBECONFIG)
1461+
1462+
e2e-infrastructure-run: ## Run the infra template RGD-acceptance e2e against $(E2E_KRO_KUBECONFIG)
1463+
cd providers/infrastructure && KUBECONFIG=$(E2E_KRO_KUBECONFIG) \
1464+
go test -tags e2e -count=1 -timeout 15m ./backend/kro/ -run TestE2E -v
1465+
1466+
e2e-infrastructure: e2e-infrastructure-up e2e-infrastructure-run ## Bring up kro + run the infra template e2e (then `make e2e-infrastructure-down`)
1467+
14311468
# --- In-cluster variants (no separate kind cluster) ---
14321469
# Used by Tiltfile.cluster, which already manages a kind cluster for kcp.
14331470
# Installing kro INTO that same cluster lets kro reach kcp via in-cluster

docs/app-studio-runtime-decoupling.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,14 @@ App Studio keeps the **product** concerns: the Project capability contract, the
6060

6161
## 2. Design principles
6262

63+
> This work is the reference implementation of the platform-wide
64+
> [provider-isolation rule](./providers.md#provider-isolation-the-cross-provider-boundary)
65+
> (contract 3 in [`provider-connectivity-contract.md`](./provider-connectivity-contract.md)):
66+
> App Studio's `APP_STUDIO_RUNTIME_KUBECONFIG` is a direct credential into
67+
> another provider's backend — exactly what the rule forbids — and this
68+
> design replaces it with calls to the infrastructure provider's published
69+
> subresources.
70+
6371
1. **The runtime cluster has exactly one owner: the infrastructure provider.** Whoever materializes a workload also serves its data plane. No other component holds a runtime credential.
6472
2. **Data-plane verbs are subresources on the workload instance.** `sandboxrunners/{name}/log` is to a `SandboxRunner` what `pods/log` is to a Pod. This keeps the model k8s-native and routable by binding.
6573
3. **Routing is binding-driven, never URL-driven.** App Studio never resolves a provider backend URL. It calls a subresource on a resource it is already bound to; kcp routes to the provider that exports it. This is the BYO mechanism.

docs/app-studio-sandbox-runtime.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,10 @@ is garbage-collected by the kro template when the `SandboxRunner` instance is
5050
deleted, and the preview `ReferenceGrant` is materialized by that template too.
5151
See [`app-studio-runtime-decoupling.md`](./app-studio-runtime-decoupling.md) for
5252
the full design (including BYO compute, where a workspace can be backed by a
53-
different infrastructure provider / runtime cluster).
53+
different infrastructure provider / runtime cluster). This is the platform
54+
[provider-isolation rule](./providers.md#provider-isolation-the-cross-provider-boundary):
55+
App Studio never reaches into the infrastructure provider's backend — only its
56+
published `SandboxRunner` CR and subresources.
5457

5558
## Capability Boundary
5659

docs/infrastructure-architecture.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ Same user, same workspace, two different namespaces. The instance is real, the p
4242
3. **Backends implement a narrow interface, not a data model.** Each backend is responsible for taking a `Template` and serving instances of the corresponding per-template CRD. *How* it does that — RGDs, terraform modules, cloud SDK calls — is its own business.
4343
4. **kcp is the source of truth for identity.** Tenant = workspace logical-cluster name, embedded in the URL. No headers. No hashing.
4444
5. **The provider binary owns the platform layer + the backends it ships with.** Today that's one backend (kro). The binary stays small; complexity lives in backends behind the interface.
45+
6. **The backend layer is private to this provider; other providers reach it only through published APIs.** The runtime/target clusters a backend drives, the credentials to them, the kro RGDs, the internal Services — none of it is reachable by another provider. A consumer (e.g. App Studio) interacts with infrastructure-owned workloads **only** through the `Template`/instance CRs (control plane) and their VW subresources (data plane: `sandboxrunners/{name}/log`, `…/proxy/…`), as the tenant user. No other component holds a credential into the runtime cluster. This is the platform-wide [provider-isolation rule](./providers.md#provider-isolation-the-cross-provider-boundary); the worked example is [`app-studio-runtime-decoupling.md`](./app-studio-runtime-decoupling.md).
4546

4647
## 3. Architecture
4748

docs/kuery-provider-architecture.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,12 @@ cluster-name prefix (`{tenantCluster}/…`) before forwarding to the engine. One
116116
isolation enforced at the single choke point. `KEDGE_DEV_ALLOW_TENANT_QUERY` mirrors the
117117
infrastructure provider's dev escape hatch.
118118

119+
Kuery's relationship to the **edge providers** also follows the platform
120+
[provider-isolation rule](./providers.md#provider-isolation-the-cross-provider-boundary):
121+
it consumes `Edge` CRs (a tenant-scoped permission claim) and reaches the clusters
122+
through the hub's **edges-proxy** as the caller — it never holds a credential into an
123+
edge provider's backend.
124+
119125
### MCP tools (the primary consumer)
120126

121127
The provider serves `/mcp` + `/mcp/sse` (proxied at `/services/providers/kuery/mcp`) and

docs/mcp-architecture.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,11 @@ Two consequences:
172172
[`providers/infrastructure/tenant/client.go`](https://github.com/faroshq/kedge/blob/main/providers/infrastructure/tenant/client.go): the tenant client is
173173
built per-(tenant, caller) from the request token; the provider's own
174174
credentials are never used for tenant work.
175+
- **Federation routes to published endpoints, not backends.** The aggregator
176+
reaches each provider through its registered `BackendURL`/`/mcp` surface
177+
with the caller's identity forwarded — never into the provider's runtime
178+
cluster, DB, or internal Services. This is the cross-provider half of the
179+
platform [provider-isolation rule](./providers.md#provider-isolation-the-cross-provider-boundary).
175180

176181
## Adding a new integration
177182

docs/provider-connectivity-contract.md

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
11
# Provider connectivity contract — how providers connect to the platform
22

3-
This doc pins down the two contracts every provider should follow for *how it
4-
reaches data*: the **UI data path** (contract 1) and the **API access path**
5-
(contract 2). It explains the hub plumbing that enforces them, how the portal
6-
authenticates, which providers conform today, and where the deliberate
7-
exceptions are.
3+
This doc pins down the contracts every provider should follow for *how it
4+
reaches data*: the **UI data path** (contract 1), the **platform API access
5+
path** (contract 2), and the **provider-to-provider path** (contract 3). It
6+
explains the hub plumbing that enforces them, how the portal authenticates,
7+
which providers conform today, and where the deliberate exceptions are.
8+
9+
Contracts 1 and 2 are about a provider reaching the **platform** (kcp / the
10+
GraphQL gateway). Contract 3 is about a provider reaching **another
11+
provider** — and the rule there is that it does so *only* through that
12+
provider's published API, never into its backend. See
13+
[`providers.md` §"Provider isolation"](./providers.md#provider-isolation-the-cross-provider-boundary)
14+
for the principle in full; this doc gives the concrete access mechanics.
815

916
It is the companion to [`providers.md`](./providers.md) (the provider plane
1017
overview), [`provider-scoping.md`](./provider-scoping.md) (Global/Org/Personal
@@ -14,11 +21,14 @@ scoping), and [`security.md`](./security.md) (auth setup).
1421

1522
## Restore-from-reboot summary
1623

17-
- There are **two** legitimate data paths, not one. UI data flows through the
24+
- There are **three** legitimate data paths. UI data flows through the
1825
hub's **GraphQL gateway** (contract 1); backend/controller code reaches kcp
1926
either as a **non-privileged provider ServiceAccount via an
2027
APIExportEndpointSlice** *or* as the **caller using their forwarded bearer
21-
token**, scoped to the tenant workspace (contract 2).
28+
token**, scoped to the tenant workspace (contract 2); and one provider
29+
reaches **another** provider only through that provider's **published
30+
APIExport resources + VW subresources, as the caller** — never into its
31+
backend (contract 3).
2232
- The hub exposes providers through **two different proxies** with **different
2333
token handling**: the UI proxy forwards **no token**, the backend proxy
2434
forwards the caller's `Authorization` header **as-is**.
@@ -58,6 +68,27 @@ Both 2a and 2b are admin-free. New providers should pick one (or use 2a for
5868
controllers and 2b for request-driven endpoints, like `code` and
5969
`infrastructure` do) and never construct a kcp-admin / root client.
6070

71+
**Contract 3 — provider-to-provider path.** When provider A needs something
72+
provider B owns, A goes through **B's published API**, not B's backend:
73+
74+
- **B's `APIExport` resources** — A binds B's `APIExport` (an `APIBinding`
75+
in the tenant workspace) and reads/writes B's CRs over the normal
76+
`/clusters/...` path. This is the control-plane channel (spec/status).
77+
- **B's VW subresources** on those resources (e.g.
78+
`sandboxrunners/{name}/log`, `…/proxy/{path}`) — for data-plane verbs
79+
(streams, proxies, shells) that aren't plain CRUD. B serves them against
80+
*its* backend; A never sees it.
81+
82+
Both are invoked **as the caller** (forwarded bearer token, scoped to the
83+
workspace — same identity as 2b) and **routed by binding, never by a
84+
backend URL**. A must never hold a credential into B's runtime cluster /
85+
database / internal Service, call B's internal endpoints directly, or encode
86+
B's backend topology in its own config. The owning provider is the single
87+
holder of its backend credential. This is the access mechanics behind
88+
[`providers.md` §"Provider isolation"](./providers.md#provider-isolation-the-cross-provider-boundary);
89+
the canonical example is the App Studio → infrastructure data-plane
90+
decoupling ([`app-studio-runtime-decoupling.md`](./app-studio-runtime-decoupling.md)).
91+
6192
---
6293

6394
## The hub plumbing (and what it does with the token)
@@ -215,6 +246,22 @@ keeps only the CA, and authenticates with the **caller's** bearer token against
215246

216247
---
217248

249+
## Contract 3 conformance — provider-to-provider via published API only
250+
251+
| Interaction | Mechanism | Verdict |
252+
|----------|-----------|---------|
253+
| `app-studio``infrastructure` (sandbox data plane) | **Target:** `SandboxRunner` CR (control plane) + `sandboxrunners/{name}/{log,proxy,sync,restart}` VW subresources (data plane), called as the tenant user | ✅ Conforms (after runtime-decoupling) |
254+
| `app-studio` → runtime cluster (legacy) | held `APP_STUDIO_RUNTIME_KUBECONFIG`, a direct second credential into infrastructure's runtime cluster | ❌ The violation being removed — see [`app-studio-runtime-decoupling.md`](./app-studio-runtime-decoupling.md) |
255+
| `kuery``Edge`s | reads bound `Edge` CRs / engages clusters through the hub's edges-proxy as the caller, never the edge provider's backend | ✅ Conforms |
256+
257+
The shape that conforms: address a **bound resource** (or a subresource on
258+
it), authenticate as the **caller**, and let the binding decide which
259+
provider's backend answers. The shape that doesn't: a kubeconfig / DSN / URL
260+
in provider A's config that points straight at provider B's cluster, DB, or
261+
internal Service.
262+
263+
---
264+
218265
## Known divergences (and why)
219266

220267
1. **Built-ins are privileged by construction.** `mcp`, `kubernetesedges`, and
@@ -249,6 +296,10 @@ keeps only the CA, and authenticates with the **caller's** bearer token against
249296
drop the provider's own credential.
250297
- [ ] Never trust inbound `X-Kedge-*` headers in the provider — the backend
251298
proxy strips and re-injects them; treat them as hub-asserted only.
299+
- [ ] To reach **another provider**, bind its `APIExport` and call its CRs /
300+
VW subresources as the caller (contract 3). Never hold a credential
301+
into another provider's backend (runtime cluster, DB, internal
302+
Service) or hardcode its backend URL.
252303

253304
---
254305

0 commit comments

Comments
 (0)