Skip to content

Commit 06d52f8

Browse files
committed
address offline review
Signed-off-by: Mangirdas Judeikis <mangirdas@judeikis.lt>
1 parent d265a38 commit 06d52f8

2 files changed

Lines changed: 92 additions & 32 deletions

File tree

docs/proposals/v2-extended.md

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -122,16 +122,22 @@ distribution, which wires its own implementation against the same interface.
122122
Kubernetes (workspaces, in the contrib/kcp issuer).
123123
* Mint credentials: ServiceAccount + RBAC scoped to exactly the exported APIs (+
124124
declared related resources) within that boundary + kubeconfig. This fixes v1's
125-
cluster-admin-ish `kube-binder` ClusterRole (roadmap #303: reduced footprint).
125+
cluster-admin-ish `kube-binder` ClusterRole (roadmap #303: reduced footprint). On a
126+
plain-Kubernetes provider "scoped to the exported APIs" is an explicit Role enumerating
127+
those resources; on the kcp/CRD-less flavor the tenancy boundary *is* the workspace, so
128+
scoping is the workspace grant itself (everything in it is exported by construction)
129+
rather than a per-resource enumeration — same `Issuer` interface, two scoping mechanisms
130+
matching the core's two schema sources.
126131
* **Credential mechanism: long-lived SA token** (v1 behavior, secret-based
127132
ServiceAccount token). Trade-off accepted deliberately: zero rotation friction and no
128133
konnector-side refresh machinery, at the cost of security posture — and noting
129134
upstream Kubernetes is steering away from secret-based SA tokens, so this is
130135
revisitable without API change (the bundle's Secret is replaceable; a bounded-token +
131136
reissue mode can be added later behind the same interface). Revocation = delete the
132137
`Grant` → issuer deletes the SA/token.
133-
* Records issuance in **`Grant`** (`catalog.kube-bind.io`): "identity X was issued
134-
credentials Y for export Z". The anchor for revocation, audit, and the reaper.
138+
* Records issuance in **`Grant`** (`iam.kube-bind.io` — an issuance/identity record, not
139+
catalog presentation): "identity X was issued credentials Y for export Z". The anchor
140+
for revocation, audit, and the reaper.
135141

136142
### 3. Gateway (HTTP API)
137143

@@ -159,6 +165,14 @@ envelope (`{ bundle: [...] }`). There is no other handshake state: no request ob
159165
poll, no phases to wait on. `curl` bind + pickup piped to `kubectl apply -f -` is a
160166
complete client.
161167

168+
The single-use, short-TTL property applies to the **pickup URL**, not to the credential
169+
inside it: the bundle's Secret carries the long-lived SA token minted by the issuer
170+
(§2), so a bundle that has been picked up once stays valid and re-appliable. That is what
171+
makes `-o yaml > binding.yaml` committed to git a real GitOps artifact — the pickup is
172+
consumed once, the token it delivered keeps working until its `Grant` is revoked.
173+
Re-running `bind` mints a fresh `Grant`/token and a new pickup; it does not invalidate a
174+
previously committed bundle unless that `Grant` is explicitly revoked.
175+
162176
### 4. Auth (pluggable)
163177

164178
* `Authenticator` interface: `Routes()` (mounted under `/api/auth/…`) +
@@ -175,7 +189,10 @@ complete client.
175189
### 5. Reaper (provider-side, optional)
176190

177191
The core leaves dead-consumer GC explicitly to this layer, keyed off the per-Connection
178-
`Lease` the konnector maintains:
192+
`Lease` the konnector maintains. **This component is blocked on that core primitive:** the
193+
konnector's provider-side `Lease` is specified in the core proposal but is new (no v1
194+
equivalent), so the reaper ships only once the konnector actually maintains the Lease;
195+
until then dead-consumer GC is manual.
179196

180197
* Lease expired beyond TTL → mark the issuance stale → (configurably) revoke
181198
credentials, then delete kube-bind-created namespaces and synced objects.
@@ -238,15 +255,16 @@ remains separate, providing its own issuer implementation behind the same interf
238255

239256
* **Packaging**: one `kube-bind-backend` binary; gateway/issuer/reaper/apply are module
240257
flags, boundaries kept as Go packages.
241-
* **Issuance anchor**: `Grant` in `catalog.kube-bind.io` — the typed record of
258+
* **Issuance anchor**: `Grant` in `iam.kube-bind.io` — the typed record of
242259
"identity X was issued credentials Y for export Z"; anchor for revocation, audit,
243-
reaper.
260+
reaper. Kept out of `catalog.kube-bind.io` so that group stays purely presentation+defaults.
244261
* **Credentials**: long-lived secret-based SA token (v1 behavior) — zero rotation
245262
friction accepted over security posture; revocation via `Grant` deletion; bounded
246263
tokens addable later behind the same issuer interface without API change.
247264
* **Catalog vocabulary**: `Export` + `Collection`.
248-
* **Bundle delivery**: one-time pickup URL, 5-minute TTL, single use; bundle never
249-
stored at rest in the gateway.
265+
* **Bundle delivery**: one-time pickup URL, 5-minute TTL, single use — the TTL/single-use
266+
applies to the *pickup URL*, not the long-lived SA token inside, so a picked-up bundle
267+
stays re-appliable (GitOps-safe). The bundle is never stored at rest in the gateway.
250268
* **kcp**: stays a separate distribution (`contrib/kcp`) providing its own issuer
251269
implementation; the in-tree backend issuer is plain Kubernetes only.
252270
* **UI reach**: browser-apply path **kept** (roadmap #406) — gateway `/api/apply`

docs/proposals/v2-slim-core.md

Lines changed: 66 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -206,11 +206,12 @@ spec:
206206
matchLabels:
207207
mangodb.io/managed: "true"
208208
status:
209-
conditions: [] # Connected, Synced, Conflicts
209+
conditions: [] # Connected, Synced, Conflicts, PermissionDenied
210210
boundAPIs: # per-API observed state
211211
- name: mangodbs.mangodb.io
212212
crdHash: sha256:… # applied schema version
213-
conflicts: [] # objects skipped due to foreign ownership
213+
conflictCount: 0 # objects skipped due to foreign ownership;
214+
# per-object detail lives on each object's own condition
214215
```
215216

216217
```yaml
@@ -284,18 +285,32 @@ order-independent and level-triggered**. A binding referencing a not-yet-existin
284285
`Connection`, a `Connection` referencing a not-yet-existing Secret, a binding listing a
285286
not-yet-exported CRD — none of these are errors, all are `Pending` conditions that
286287
resolve when the missing piece arrives. No phase gating, no request/response objects, no
287-
controller that must answer before the user may apply the next thing. (Equivalently:
288-
`kubectl delete -f mangodb-binding.yaml` is the complete, ordered-don't-care unbind.)
288+
controller that must answer before the user may apply the next thing.
289+
290+
Unbind has one ordering constraint that apply does not. `kubectl delete -f
291+
mangodb-binding.yaml` deletes the `Connection`, bindings, and synced objects in whatever
292+
order the server processes them, but every synced consumer object carries a finalizer
293+
whose release requires the konnector to reach the provider through the *very* `Connection`
294+
being deleted. The konnector therefore drains object finalizers before a referenced
295+
`Connection`/binding is allowed to finalize: one that still has synced objects bound to it
296+
stays in `Terminating` with a `DrainingObjects` condition until those objects' provider
297+
copies are gone, then releases. "Delete the whole file" converges — it does not deadlock —
298+
but the `Connection` is the *last* object to disappear, not the first.
289299

290300
### Schema sources: CRD and OpenAPI
291301

292302
The konnector must install a working CRD on the consumer for every bound API. Two ways
293303
to obtain it, selected by `Connection.spec.schema.source` (`Auto` probes CRD first,
294304
falls back to OpenAPI):
295305

296-
* **`CRD`** — read the CRD from the provider's apiextensions API and apply it (minus
297-
webhook conversion, status). Highest fidelity; requires the provider to *have* CRDs
298-
and the credentials to read them.
306+
* **`CRD`** — read the CRD from the provider's apiextensions API and apply it on the
307+
consumer with mechanical adjustments: never copy provider-side `conversion.strategy:
308+
Webhook` or its caBundle (such CRDs are refused — per-API condition + skip, see the
309+
sync table), add an owner-ref to the `Connection`, and inject UX-only printer
310+
columns/categories. To keep a multi-version CRD installable on the consumer *without* a
311+
conversion webhook, install only the provider's **storage/served version** rather than
312+
every historical version. Highest fidelity; requires the provider to *have* CRDs and
313+
the credentials to read them.
299314
* **`OpenAPI`** — for CRD-less providers (kcp and kcp-like systems, aggregated APIs):
300315
synthesize a CRD from what every Kubernetes-shaped API server already serves:
301316
* **discovery** (`/apis/<group>/<version>`) → plural/singular/kind/shortNames,
@@ -324,27 +339,39 @@ name on the provider. `status.exportedAPIs` is computed per source: labeled CRDs
324339
| Spec | consumer → provider, server-side apply with a dedicated field manager. |
325340
| Status | provider → consumer, status subresource update. |
326341
| Namespaces | If the consumer namespace doesn't exist on the provider, the konnector creates it (annotated as kube-bind-created; only kube-bind-created namespaces are cleaned up on unbind). |
327-
| Deletion | Finalizer on consumer object (as today); consumer deletion propagates to provider, finalizer released when provider copy is gone. Provider-side deletion of a synced object is treated as drift and re-created (consumer is source of truth for spec). |
342+
| Deletion | Finalizer on consumer object (as today); consumer deletion propagates to provider, finalizer released only when the provider copy is fully gone (a provider-side finalizer holds the consumer object in `Terminating` until it clears). `kube-bind.io/deletion-policy: Orphan` on a consumer object releases the finalizer without deleting the provider copy. Provider-side deletion of a synced object is treated as drift and re-created **unless** the provider copy has a non-zero deletionTimestamp — then the konnector waits for it to finalize rather than racing a re-create. Consumer is source of truth for spec. |
328343
| Schema | Per `Connection.spec.schema`: konnector obtains schemas via `source: CRD` (read apiextensions) or `OpenAPI` (synthesize from discovery + `/openapi/v3` — CRD-less providers like kcp), pulls per `pullPolicy: All` or `Bound`, applies on the consumer (owner-ref to the `Connection`). `updatePolicy: Always` keeps following provider schema changes; `Once` pins. CRDs with `strategy: Webhook` are refused (per-API condition + skip). |
329344
| Discovery | `Connection.status.exportedAPIs`: labeled CRDs (`source: CRD`) or discovery minus built-ins (`source: OpenAPI`, where the logical-cluster boundary is the export boundary) — core-level discovery with zero provider CRDs. |
330-
| Related resources | Selected Secrets/ConfigMaps sync in the declared direction, same identity rules, scoped like their binding. |
345+
| Related resources | Selected Secrets/ConfigMaps sync in the declared direction, same identity rules, scoped like their binding. They are owned by the **binding** (not by individual instances): an object is synced while it matches the selector and is garbage-collected when it stops matching or the binding is removed. The same ownership markers and `conflictPolicy` apply — a related object already owned by another binding/consumer is a conflict, never silently overwritten. |
346+
| RBAC | The supplied credentials' RBAC *is* the authorization model; partial RBAC is an expected steady state, not a failure. Each forbidden operation surfaces as a typed `PermissionDenied` condition naming the verb+resource refused (per-API on the binding, per-object on the instance); the konnector keeps syncing everything it *is* allowed to and never fails a whole binding because one resource or namespace is out of reach. |
331347
| Informers | One shared dynamic informer per GVR per connection. Cluster-wide informers for `ClusterBinding`; namespace-scoped informers where only namespaced `Binding`s exist. |
332348

333349
### Conflict handling (the new hard part)
334350

335351
Identity mapping means two writers can legitimately collide. Core rules:
336352

337-
1. Every object the konnector writes carries ownership markers (annotation pair:
338-
binding UID + source cluster UID — same idea as today's
339-
`kube-bind.io/consumer-uid` / `provider-uid` annotations, kept).
340-
2. Before first write to a target object that already exists **without** our markers:
341-
* `conflictPolicy: Fail` (default) — do not touch it. Record it in
342-
`status.boundResources[].conflicts`, set `Conflicts` condition, emit an Event,
343-
keep syncing everything else.
344-
* `conflictPolicy: Adopt` — take ownership (stamp markers, SSA force-apply).
345-
3. Two `Binding`s (or two consumers against one provider target) claiming the same
346-
object: second writer sees foreign markers → always a conflict regardless of policy.
347-
First-writer-wins, deterministically.
353+
1. Every object the konnector writes carries ownership markers: the **binding UID** plus
354+
the **source cluster UID**. The source cluster UID is the identity the `Connection`
355+
pins in its status the first time it resolves its credentials — *not* something read
356+
from a fixed object like the `kube-system` namespace, which does not exist on
357+
CRD-less/logical-cluster providers (kcp). This keeps the marker well-defined under both
358+
schema sources. (Same idea as today's `kube-bind.io/consumer-uid` / `provider-uid`
359+
annotations, kept.)
360+
2. Before first write, the konnector classifies the existing target object three ways:
361+
* **No markers** (a foreign, un-owned object): `conflictPolicy: Fail` (default) does
362+
not touch it; `conflictPolicy: Adopt` stamps markers and SSA force-applies.
363+
* **Our markers, our current binding + object UID**: ours — normal SSA update.
364+
* **Our cluster's markers but a stale/foreign binding-or-object UID**, *or* **another
365+
cluster's markers**: always a conflict, regardless of `conflictPolicy`. `Adopt`
366+
never steals an object another binding/consumer owns; first-writer-wins,
367+
deterministically.
368+
3. Conflicts are recorded **on the conflicting object itself** as a typed condition that
369+
distinguishes the two cases an operator remediates differently — `ForeignObjectExists`
370+
(no markers; rename or switch to `Adopt`) vs. `OwnedByAnother` (claimed by a different
371+
binding/consumer; pick another name). The binding does **not** inline an unbounded list
372+
of object names: its status carries only a `Conflicts` condition and a count
373+
(`boundAPIs[].conflictCount`), plus an Event. The konnector keeps syncing everything
374+
else.
348375
4. Field-level conflicts within an owned object are resolved by SSA with
349376
`force=true` for our field manager on our sync direction only (spec fields upstream,
350377
status fields downstream) — same as today, but now stated as the contract.
@@ -369,7 +396,11 @@ moved into the konnector or out of core:
369396
logical clusters (kcp workspaces, a cluster fleet) — see
370397
[Engine](#engine-built-on-multicluster-runtime).
371398
* It watches `Connection`, `ClusterBinding`, and `Binding` objects, and only the Secrets
372-
that `Connection`s reference (fixes the v1 watch-all-secrets issue).
399+
that `Connection`s reference (fixes the v1 watch-all-secrets issue). A `Connection`'s
400+
`kubeconfigSecretRef` must resolve to the konnector's own designated namespace (default
401+
`kube-bind`): `Connection` is cluster-scoped, so letting it name a Secret in any
402+
namespace would let anyone who can create a `Connection` read any Secret the konnector's
403+
ServiceAccount can. Cross-namespace refs are rejected with `SecretValid=False`.
373404
* One `Connection` = one provider client/informer context; all bindings referencing it
374405
share that context.
375406
* The provider cluster needs: reachable API server + RBAC for the supplied credentials.
@@ -555,9 +586,20 @@ Rules:
555586
`syncMode` field name is reserved per-API but not implemented in alpha.
556587
* **Related resources**: `secrets` + `configmaps` only; `labelSelector` + named
557588
selectors only. JSONPath reference-following selectors do not enter core.
558-
* **Conflicts**: `conflictPolicy: Fail | Adopt`; `Adopt` never touches objects carrying
559-
another binding's/consumer's markers — cross-binding collisions are always conflicts,
560-
first-writer-wins.
589+
* **Conflicts**: `conflictPolicy: Fail | Adopt` with three-way classification (no markers
590+
/ ours / foreign-or-stale). `Adopt` only takes *un-owned* objects and never steals one
591+
carrying another binding's/consumer's markers — cross-binding collisions are always
592+
conflicts, first-writer-wins. Per-object detail lives on the conflicting object's own
593+
condition (`ForeignObjectExists` vs `OwnedByAnother`); the binding holds only a
594+
`Conflicts` condition + count. The marker's source cluster UID is the
595+
`Connection`-pinned identity, so conflicts stay well-defined on CRD-less providers too.
596+
* **Cluster identity**: each `Connection` pins the resolved provider (and local) cluster
597+
UID in its status on first connect and is immutable thereafter; a Secret later pointing
598+
at a *different* cluster is rejected (`Connected=False`) rather than silently re-homing
599+
synced objects.
600+
* **Deletion**: consumer deletion propagates to the provider and the binding/`Connection`
601+
drains object finalizers before finalizing (`DrainingObjects`); `deletion-policy:
602+
Orphan` opts an object out of provider-side deletion.
561603
* **Provider namespaces**: konnector creates missing namespaces iff RBAC allows
562604
(annotated kube-bind-created, cleaned up on unbind); otherwise condition + wait.
563605
* **Heartbeat**: a plain `coordination.k8s.io/Lease` per Connection, maintained by the

0 commit comments

Comments
 (0)