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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,22 @@ Once set, no Apply API caller — Control Center, curl, CI — needs to know or

Three checks at `ork validate` time: required on a namespaced CRD with `idp.enabled: true`; rejected on a cluster-scoped one (`namespaced: false`) — nothing to resolve into; rejected when templated and the CRD's informer is pinned to one fixed namespace (`allowedNamespaces` with exactly one entry, or the legacy `namespace:` field) — a CR resolved outside that one namespace would exist but never be reconciled, silently. No equivalent checks exist for `idp.name` — there's no cluster-scoped/pinned-namespace-style conflict for a name to run into.

### `idp.fields.path` — nested spec paths

`idp.fields` entries now support a `path:` field mapping a flat field name to a nested dot-notation path in the CRD `spec`. Callers submit flat fields; the gateway maps them to nested locations.

```yaml
idp:
fields:
cpu:
path: app.resources.cpu
label: "CPU Request"
```

`ork validate` ensures paths are unique, formatted correctly, and warns on nested paths (schema existence validation coming later).

→ [Nested fields with `path` reference](./documentation/reference/schema/02-katalog/21-idp-nested-spec.md)

### `POST /api/v1/apply` response: `pollUrl` replaces `resourceVersion`

A successful apply now returns `pollUrl` — the exact `GET /api/v1/resources/{kind}/{namespace}/{name}` path for the CR just applied — instead of `resourceVersion`, which nothing consumed. Callers can `jq -r '.pollUrl'` straight into a poll loop instead of hand-assembling the path from `kind`/`namespace`/`name`. Cluster-scoped CRDs get an empty namespace segment (`/api/v1/resources/AppRequest//payments-api`), matching the existing `GET`/`DELETE` path convention.
Expand Down
241 changes: 241 additions & 0 deletions documentation/concepts/idp/02-target-mode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
# Target Mode

The Apply API accepts a simplified request format where callers submit a `target` and flat fields instead of a full Kubernetes CR. The gateway builds the CR from the IDP configuration.

---

## Why target mode exists

Every self-service caller — a browser form, a CI pipeline, a Slack bot — has the same problem: they want to describe what they need, not construct a Kubernetes object. A developer knows the repository and the image tag; they shouldn't need to know `apiVersion`, `kind`, `metadata`, or the difference between `spec` and `labels`.

Target mode hides Kubernetes behind the IDP contract. The platform team defines the fields. The gateway handles the rest.

---

## How it works

The Katalog declares `idp.target` and `idp.fields`:

```yaml
idp:
enabled: true
target: app
name: '{{ repoSlug .repository }}'
namespace: '{{ teamName }}-{{ environment }}'
fields:
repository:
label: "Repository"
type: string
required: true
image:
label: "Container Image"
type: string
required: true
```

Callers submit `target` + fields:

```json
POST /api/v1/apply
{
"target": "app",
"repository": "myorg/payments-api",
"image": "ghcr.io/myorg/app:v1.0.0",
"team": "team-payments",
"environment": "staging"
}
```

The gateway:

1. Looks up the CRD by `target`
2. Routes fields to `spec`, `metadata.labels`, or `metadata.annotations` based on `idp.fields` and `idp.additionalFields`
3. Resolves `idp.name` and `idp.namespace`
4. Applies the full CR via SSA

The caller never sees the CR.

---

## Two modes, one API

| Mode | Request format | When to use |
|------|----------------|-------------|
| **Target mode** | `{"target": "...", fields...}` | Self-service callers who don't know Kubernetes |
| **Full CR mode** | `{"apiVersion": "...", "kind": "...", ...}` | Advanced callers, existing clients, `kubectl` compatibility |

```bash
# Target mode — submit fields
curl -X POST /api/v1/apply \
-d '{"target":"app","repository":"myorg/app","image":"..."}'

# Full CR mode — submit a complete CR
curl -X POST /api/v1/apply \
-d '{"apiVersion":"platform.myorg.io/v1","kind":"App",...}'
```

Both modes produce the same result. The gateway detects which format you're using based on the presence of `target` or `apiVersion`+`kind`.

---

## The schema contract

Callers discover available targets and fields through the schema API:

```bash
# List all available targets
curl -X GET /api/v1/schema \
-H "Authorization: Bearer $TOKEN"

# Get fields for a specific target
curl -X GET /api/v1/schema?target=app \
-H "Authorization: Bearer $TOKEN"
```

The schema API returns a flat list of fields:

```json
{
"target": "app",
"title": "Application",
"fields": {
"repository": {
"label": "Repository",
"type": "string",
"required": true
},
"image": {
"label": "Container Image",
"type": "string",
"required": true
}
}
}
```

Callers don't need to know about `spec`, `labels`, or `annotations` — they just see fields.

---

## `idp.target` — the caller-facing identifier

`idp.target` decouples the caller-facing identifier from the Kubernetes `kind`.

```yaml
idp:
enabled: true
target: app # callers use this, not "App" or "apprequests"
```

If omitted, defaults to the lowercased `kind` (e.g., `kind: App` → `target: app`).

`ork validate` ensures targets are unique across the Katalog.

---

## `idp.name` and `idp.namespace`

Target mode resolves `idp.name` and `idp.namespace` server-side, so callers don't need to know them:

```yaml
idp:
enabled: true
name: '{{ repoSlug .repository }}' # → "payments-api"
namespace: '{{ teamName }}-{{ environment }}' # → "team-payments-staging"
```

Callers never supply `metadata.name` or `metadata.namespace` in target mode.

When `idp.name` is not declared, the caller must supply a name. When `idp.namespace` is not declared on a namespaced CRD, the gateway rejects the request — self-service creation has no way to know where the CR belongs.

---

## Nested fields with `path`

Fields can map to nested locations in the CRD `spec` using `path`:

```yaml
idp:
fields:
repository:
path: app.repository
label: "Repository"
cpu:
path: app.resources.cpu
label: "CPU Request"
```

Callers submit flat field names:

```json
{
"target": "app",
"repository": "myorg/app",
"cpu": "500m"
}
```

The gateway maps to:

```yaml
spec:
app:
repository: myorg/app
resources:
cpu: 500m
```

→ [Nested fields with `path` reference](../../reference/schema/02-katalog/20-idp#idpfieldspath)

---

## Response: `pollUrl` and `payload`

A successful target-mode apply returns:

```json
{
"accepted": true,
"name": "payments-api",
"namespace": "team-payments-staging",
"kind": "AppRequest",
"apiVersion": "platform.myorg.io/v1",
"pollUrl": "/api/v1/resources/AppRequest/team-payments-staging/payments-api?field=status.phase",
"payload": {
"phase": "",
"serviceURL": "https://payments-api.staging.myorg.io",
"nextSteps": "Waiting for resources to be provisioned..."
}
}
```

- **`pollUrl`** — where to GET the resource (configurable via `idp.config.response.poll`)
- **`payload`** — the platform team's curated view (`idp.config.response.payload`)

At apply time, `.status` is not yet available. Callers should poll `pollUrl` to see status updates.

→ [`idp.config.response` reference](../../reference/schema/02-katalog/20-idp.md#idpconfigresponse)

---

## Try it

```bash
ork init --pack use-cases/idp
```

Follow the README — it walks through target mode from schema discovery to apply to polling.

---

## See also

→ [`idp.target` schema reference](../../reference/schema/02-katalog/20-idp#idptarget)

→ [`idp.fields` schema reference](../../reference/schema/02-katalog/20-idp#idpfieldsname)

→ [`idp.namespace` reference](../../reference/schema/02-katalog/20-idp#idpnamespace)

→ [Apply API reference](../../reference/schema/02-katalog/17-katalog-applyapi.md)

→ [Additional Fields](01-additional-fields.md)
4 changes: 3 additions & 1 deletion documentation/concepts/idp/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,8 @@ The gateway Apply API is the uniform interface across all of those:
| `GET /api/v1/resources/{kind}/{ns}/{name}` | Read CR state and status |
| `GET /api/v1/resources/{kind}/{ns}` | List all CRs of a kind |
| `DELETE /api/v1/resources/{kind}/{ns}/{name}` | Delete a CR |
| `GET /api/v1/schema/{kind}` | Discover the CRD's spec schema and field hints |
| `GET /api/v1/schema` | Discover the CRD's spec schema as fieldss |
| `GET /api/v1/raw-schema` | Discover the CRD's raw spec schema and field hints |

Every enforcement rule — admission, namespace protection, deletion protection — is the same regardless of delivery path. There is nothing to reconfigure per caller.

Expand Down Expand Up @@ -147,5 +148,6 @@ The pack runs three delivery paths against one `AppRequest` CRD — browser form

## Where to go next
→ [Additional Fields](01-additional-fields.md)
→ [Target Mode](02-target-mode.md)

→ [Apply API reference](../../reference/schema/02-katalog/17-katalog-applyapi.md)
42 changes: 42 additions & 0 deletions documentation/reference/schema/02-katalog/20-idp.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,51 @@ fields:
| `anyOf` | At least one condition must pass for this field to be shown (OR). When both `when` and `anyOf` are declared, both blocks must pass. |
| `required` | When `true`, marks the field as mandatory — enforced both client-side (the browser shows an asterisk and blocks submission while empty) and server-side: an implicit `exists` rule with `action: deny` is synthesized automatically at load time, so every caller of the Apply API is covered, not just the Control Center form. No matching `validation.rules` entry needs to be hand-written. Has no effect on fields currently hidden by a `when:` or `anyOf:` condition. |
| `disabled` | Non-empty string — field is rendered greyed-out with this message. Useful for platform-managed fields that should be visible but not editable. |
| `path` | — | Dot-notation path mapping the field to a nested location in the CRD `spec`. When set, the field value is written to `spec.<path>` instead of `spec.<name>`. See [`path` — nested spec paths](#idpfieldspath) below. |

---
`order` isn't just cosmetic form layout. When multiple `required`/`type: enum` fields fail validation at once, only the first violation is reported as the headline denial reason — and synthesized rules are evaluated in the same order `order` puts the fields in, so the field a developer sees *first* on the form is also the one whose error they see first if several are wrong simultaneously. Two fields on the same CRD sharing a non-zero `order` value is a load-time error (`ork validate`) for exactly this reason — `0`/unset is the only value any number of fields may share, since it means "no preference," not a real position.

## `path` — nested spec paths

By default, `idp.fields` maps field names directly to top-level `spec` paths:

```yaml
fields:
repository:
label: "Repository"
# → spec.repository
```

Use `path` to map a field to a nested location:

```yaml
fields:
repository:
path: app.repository
label: "Repository"
# → spec.app.repository

cpu:
path: app.resources.cpu
label: "CPU Request"
# → spec.app.resources.cpu
```

Callers submit flat field names — they don't need to know the nesting structure. The gateway maps the field to the correct location in the CRD.

```json
POST /api/v1/apply
{
"target": "app",
"repository": "myorg/app",
"cpu": "500m"
}
```

→ [Full `path` reference](21-idp-nested-spec.md)


## `idp.name`

`metadata.name` exists on every CR regardless of scope, so `idp.name` doesn't care whether the CRD is namespaced or cluster-scoped — it applies uniformly either way. It's optional, not required, though: most CRDs still want the caller to choose a name, since multiple concurrent instances of the same underlying app are normal (PR previews, ephemeral environments), and a name is the only thing distinguishing them. Set `idp.name` only when instances are 1:1 with some other identity the caller already supplies, and a redeploy is meant to update that same CR in place rather than create a new one — a stable environment where only the image tag changes between deploys:
Expand Down
Loading
Loading