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
6 changes: 5 additions & 1 deletion .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,11 @@
"pooler",
"finalizer",
"superfences",
"tolerations"
"tolerations",
"portforward",
"livez",
"requestheader",
"subjectaccessreviews"
],
"ignorePaths": [
".git/**",
Expand Down
44 changes: 44 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,50 @@ jobs:
test -n "$TOKEN_SECRET"
kubectl -n coder get secret "$TOKEN_SECRET"

# Delegated authentication/authorization of the aggregated API server:
# RBAC via kube-apiserver for a non-admin identity, and direct requests to
# port 6443 from another pod (anonymous, forged front-proxy headers, and a
# ServiceAccount token without RBAC) are rejected. MCP is not served in --app=all.
- name: Verify aggregated API authentication and authorization
if: env.E2E_FULL == 'true'
env:
PROBE_IMAGE: curlimages/curl:8.16.0@sha256:463eaf6072688fe96ac64fa623fe73e1dbe25d8ad6c34404a669ad3ce1f104b6
run: |
set -euo pipefail
kubectl -n coder create serviceaccount e2e-reader
kubectl -n coder create role e2e-template-reader --verb=get,list --resource=codertemplates.aggregation.coder.com
kubectl -n coder create rolebinding e2e-template-reader --role=e2e-template-reader --serviceaccount=coder:e2e-reader
reader=(--as=system:serviceaccount:coder:e2e-reader)
kubectl "${reader[@]}" -n coder get codertemplates.aggregation.coder.com
expect_forbidden() {
local out
if out=$("$@" 2>&1); then
echo "expected Forbidden, command succeeded: $*" >&2
return 1
fi
grep -q Forbidden <<<"$out" || { echo "expected Forbidden, got: $out" >&2; return 1; }
}
expect_forbidden kubectl "${reader[@]}" -n default get codertemplates.aggregation.coder.com
expect_forbidden kubectl "${reader[@]}" -n coder get coderworkspaces.aggregation.coder.com
expect_forbidden kubectl "${reader[@]}" -n coder delete codertemplates.aggregation.coder.com coder.e2e-authz-probe --dry-run=server

kubectl -n default run e2e-authn-probe --image="$PROBE_IMAGE" --restart=Never --command -- sleep 600
kubectl -n default wait --for=condition=Ready pod/e2e-authn-probe --timeout=180s
probe() { kubectl -n default exec e2e-authn-probe -- sh -c "$1"; }
api=https://coder-k8s-apiserver.coder-system.svc
list="$api/apis/aggregation.coder.com/v1alpha1/namespaces/coder/codertemplates"
code() { probe "curl -sk -o /dev/null -w '%{http_code}' $1"; }
test "$(code "$list")" = 401
test "$(code "-H 'X-Remote-User: kubernetes-admin' -H 'X-Remote-Group: system:masters' $list")" = 401
test "$(code "-H \"Authorization: Bearer \$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)\" $list")" = 403
test "$(code "$api/healthz")" = 200
pod_ip=$(kubectl -n coder-system get pod -l app=coder-k8s -o jsonpath='{.items[0].status.podIP}')
if probe "curl -s --max-time 5 http://$pod_ip:8090/healthz"; then
echo "MCP must not listen on the pod network" >&2
exit 1
fi
kubectl -n default delete pod e2e-authn-probe --wait=false

# The driver creates the CoderTemplate itself (kubectl apply of config/e2e/codertemplate.yaml) after its setup checks.
- name: Template and workspace lifecycle (apply, re-apply, watch, rename, delete, recreate)
if: env.E2E_FULL == 'true'
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ Run from repository root.
- **Workspace lifecycle E2E driver tests (offline, stubbed tools):** `bash ./hack/e2e-workspace-lifecycle_test.sh` (also run by `make test-scripts`)
- **Lint (workflows):** `go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.10`
- **Development run (controller mode):** `GOFLAGS=-mod=vendor go run . --app=controller` (requires Kubernetes config via your env, e.g. `KUBECONFIG`)
- **Development run (aggregated API mode):** `GOFLAGS=-mod=vendor go run . --app=aggregated-apiserver`
- **Development run (aggregated API mode):** `GOFLAGS=-mod=vendor go run . --app=aggregated-apiserver` (needs `KUBECONFIG` or `~/.kube/config` with a cluster that can serve TokenReview/SubjectAccessReview and `kube-system/extension-apiserver-authentication`; the server does not start without it)
- **Vendor consistency:** `make verify-vendor`
- **Manifest generation:** `make manifests` (or `bash ./hack/update-manifests.sh`)
- **Code generation:** `make codegen` (or `bash ./hack/update-codegen.sh`)
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@ Pick what runs with `--app`:

| `--app` | Runs |
| --- | --- |
| `all` (default) | Everything in one process |
| `all` (default) | Operator and aggregated API server in one process |
| `controller` | Operator only |
| `aggregated-apiserver` | Aggregated API server only |
| `mcp-http` | MCP server only |
| `mcp-http` | MCP server only (never part of `all`; needs `--mcp-token-file`) |

## Quick start

Expand Down
13 changes: 12 additions & 1 deletion app_dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ func run(args []string) error {
coderSessionToken string
coderNamespace string
coderRequestTimeout time.Duration
mcpTokenFile string
)
fs.StringVar(&appMode, "app", "all", "Application mode (all, controller, aggregated-apiserver, mcp-http)")
fs.StringVar(
Expand All @@ -62,6 +63,12 @@ func run(args []string) error {
30*time.Second,
"Timeout for Coder SDK API requests",
)
fs.StringVar(
&mcpTokenFile,
"mcp-token-file",
"",
"Path to a file holding the bearer token that every MCP HTTP request must present (required for --app=mcp-http)",
)
if err := fs.Parse(args); err != nil {
return err
}
Expand All @@ -83,6 +90,10 @@ func run(args []string) error {
}
}

if mcpTokenFile != "" && appMode != "mcp-http" {
return fmt.Errorf("--mcp-token-file is only used with --app=mcp-http; --app=%s does not run the MCP server", appMode)
}

switch appMode {
case "all":
return runAllApp(setupSignalHandler(), coderRequestTimeout)
Expand All @@ -97,7 +108,7 @@ func run(args []string) error {
}
return runAggregatedAPIServerApp(setupSignalHandler(), opts)
case "mcp-http":
return runMCPHTTPApp(setupSignalHandler())
return runMCPHTTPApp(setupSignalHandler(), mcpTokenFile)
default:
return fmt.Errorf("assertion failed: unsupported --app value %q; must be one of: %s", appMode, supportedAppModes)
}
Expand Down
2 changes: 0 additions & 2 deletions config/default/controller-mode-patch.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,3 @@ spec:
ports:
- containerPort: 6443
$patch: delete
- containerPort: 8090
$patch: delete
2 changes: 0 additions & 2 deletions deploy/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,6 @@ spec:
name: health
- containerPort: 6443
name: https
- containerPort: 8090
name: mcp
livenessProbe:
httpGet:
path: /healthz
Expand Down
13 changes: 0 additions & 13 deletions deploy/mcp-service.yaml

This file was deleted.

15 changes: 8 additions & 7 deletions docs/explanation/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,33 +4,31 @@

| `--app` | Runs |
| --- | --- |
| `all` (default) | Controller, aggregated API server, and MCP server in one process |
| `all` (default) | Controller and aggregated API server in one process |
| `controller` | Controller-runtime manager and reconcilers |
| `aggregated-apiserver` | Aggregated API server (`aggregation.coder.com/v1alpha1`) |
| `mcp-http` | MCP HTTP server |
| `mcp-http` | MCP HTTP server (only in this mode; requires `--mcp-token-file`) |

## All-in-one mode

In `all` mode, `internal/app/allapp` creates one controller-runtime manager with one shared cache. It registers the reconcilers, then starts the aggregated API server and MCP server as non-leader runnables. One process, one cache, coordinated startup.
In `all` mode, `internal/app/allapp` creates one controller-runtime manager with one shared cache. It registers the reconcilers, then starts the aggregated API server as a non-leader runnable. The MCP server is not part of `all` mode because it acts with the operator's authority; run it on purpose with `--app=mcp-http`. One process, one cache, coordinated startup.

```mermaid
graph TD
entry["coder-k8s (--app=all)"] --> mgr["controller-runtime manager"]
mgr --> ctrl["Controller reconcilers"]
mgr --> agg["Aggregated API server runnable"]
mgr --> mcp["MCP HTTP runnable"]

ctrl --> crds["coder.com/v1alpha1 CRDs"]
agg --> api["aggregation.coder.com/v1alpha1"]
mcp --> tools["MCP tools over /mcp"]
```

## Components

| | Controller | Aggregated API server | MCP server |
| --- | --- | --- | --- |
| **Code** | `internal/app/controllerapp/`, `internal/controller/` | `internal/app/apiserverapp/`, `internal/aggregated/storage/`, `internal/aggregated/coder/` | `internal/app/mcpapp/` |
| **Listens on** | `:8081` (`/healthz`, `/readyz`) | `:6443` HTTPS (default) | `:8090` (`/mcp`, `/healthz`, `/readyz`) |
| **Listens on** | `:8081` (`/healthz`, `/readyz`) | `:6443` HTTPS (default) | `127.0.0.1:8090` only (`/mcp` needs the bearer token; `/healthz`, `/readyz` do not) |
| **Resources** | `CoderControlPlane`, `CoderProvisioner`, `CoderWorkspaceProxy` | `coderworkspaces`, `codertemplates` | Tools for control planes, templates, workspaces, events, pod logs, and run state |

### Controller
Expand All @@ -41,6 +39,10 @@ Uses controller-runtime with leader election. For each `CoderControlPlane`, it c

Storage is backed by the Coder SDK, not memory or etcd: each request becomes a Coder API call. See [Aggregated API behavior](../reference/aggregated-api-behavior.md) for the consequences.

The Coder calls use the control plane's operator credentials, so the server checks every Kubernetes caller first. It uses delegated authentication (front-proxy client certificates from kube-apiserver, TokenReview for bearer tokens) and delegated authorization (SubjectAccessReview), and fails closed when those checks are unavailable. Only exact `/healthz`, `/livez`, and `/readyz` answer anonymous callers. See [How callers are checked](../how-to/deploy-aggregated-apiserver.md#how-callers-are-checked).

Kubernetes users are not mapped to Coder users. Kubernetes RBAC on `aggregation.coder.com` in a namespace therefore grants owner-equivalent access in the Coder deployment of the control plane that serves that namespace.

How it finds its Coder backend:

- **`all` mode:** `ControlPlaneClientProvider` discovers eligible `CoderControlPlane` resources and reads their operator token Secrets dynamically.
Expand All @@ -64,4 +66,3 @@ graph TD
| `config/rbac/` | ServiceAccount, `manager-role`, and bindings (including auth-delegator) |
| `deploy/deployment.yaml` | The `coder-k8s` Deployment (defaults to `--app=all`) |
| `deploy/apiserver-service.yaml`, `deploy/apiserver-apiservice.yaml` | Expose the aggregated API |
| `deploy/mcp-service.yaml` | MCP Service on port `8090` |
30 changes: 30 additions & 0 deletions docs/how-to/deploy-aggregated-apiserver.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ kubectl apply -f config/rbac/
kubectl apply -f deploy/apiserver-service.yaml -f deploy/apiserver-apiservice.yaml
```

`config/rbac/` includes two bindings the aggregated API server needs to check callers: `auth-delegator-binding.yaml` (create TokenReviews and SubjectAccessReviews) and `authentication-reader-binding.yaml` (read `kube-system/extension-apiserver-authentication`; the default `manager-role` also grants cluster-wide ConfigMap reads). Both name the `coder-k8s` ServiceAccount in `coder-system`; edit them if you install elsewhere. The server fails closed without these permissions: without read access to that ConfigMap it does not start, and without permission to create SubjectAccessReviews it answers every request with an error (members of `system:masters` excepted).

## 2. Deploy

### Option A: all-in-one (recommended)
Expand Down Expand Up @@ -67,6 +69,34 @@ kubectl -n coder-system patch deployment coder-k8s --type=strategic -p '{
}'
```

## How callers are checked

The aggregated API server authenticates and authorizes every request with the Kubernetes API:

| Caller | Authentication | Authorization |
| --- | --- | --- |
| `kubectl` and other clients through kube-apiserver (the normal path) | kube-apiserver's front-proxy client certificate, verified against `requestheader-client-ca-file` from `kube-system/extension-apiserver-authentication`; the user comes from the `X-Remote-*` headers | SubjectAccessReview for that user, verb, resource, and namespace |
| Direct requests to port `6443` with a bearer token | TokenReview | SubjectAccessReview |
| Direct requests with a client certificate signed by the cluster client CA | Certificate subject | SubjectAccessReview |
| Anything else | None | Rejected with `401`, except exact `/healthz`, `/livez`, `/readyz` |

`X-Remote-*` headers are trusted only on connections that present a valid front-proxy client certificate. Access to the resources is controlled with Kubernetes RBAC on `aggregation.coder.com` (`codertemplates`, `coderworkspaces`), but read the warning below before you grant it.

!!! warning "RBAC on these resources is owner access in Coder"
Treat Kubernetes RBAC on `codertemplates` and `coderworkspaces` as owner-equivalent inside Coder. Grant it only to subjects you would trust as Coder owners, and prefer namespaced Roles over ClusterRoles.

The server does not map Kubernetes users to Coder users. After Kubernetes RBAC allows a request, the server calls Coder with the control plane's operator token, which has owner rights in Coder. Each request uses only the control plane that serves the request's namespace (in standalone mode, the server is pinned to `--coder-namespace`).

So a subject allowed to create, update, patch, or delete `codertemplates` or `coderworkspaces` in a namespace acts in that Coder deployment as an owner, and `get`, `list`, or `watch` shows that deployment's templates (including their source files) and workspaces as an owner sees them. A ClusterRole binding grants this for every namespace that has a control plane.

Kept Kubernetes defaults, so you know what to expect:

- Members of `system:masters` are authorized without a SubjectAccessReview, as in kube-apiserver.
- TokenReview results are cached for 10 seconds, allowed SubjectAccessReview results for 10 seconds, and denied results for 10 seconds. A permission change can take effect only after the matching cache entry expires.
- The server reads `extension-apiserver-authentication` at startup and keeps watching it. If the ConfigMap is deleted later, the server keeps trusting the CA it last loaded (retained trust) until it restarts or sees a new one. If the ConfigMap or its request-header CA is missing at startup, front-proxy requests fail with `401`.

The server uses the same Kubernetes API for all of these checks: `KUBECONFIG` if set (exactly one file, never a fallback), otherwise the in-cluster ServiceAccount, otherwise `~/.kube/config`. With none, it does not start.

## 3. Verify

```bash
Expand Down
4 changes: 2 additions & 2 deletions docs/how-to/deploy-controller.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,10 @@ kubectl get codercontrolplanes -A

## Want everything instead?

Skip the `kubectl patch` step to keep `--app=all`, and apply the extra Services:
Skip the `kubectl patch` step to keep `--app=all` (operator plus aggregated API server), and register the aggregated API:

```bash
kubectl apply -f deploy/apiserver-service.yaml -f deploy/apiserver-apiservice.yaml -f deploy/mcp-service.yaml
kubectl apply -f deploy/apiserver-service.yaml -f deploy/apiserver-apiservice.yaml
```

## Connect an external PostgreSQL database
Expand Down
Loading
Loading