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
4 changes: 2 additions & 2 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ updates:
# Major updates are not grouped, so each opens its own PR

- package-ecosystem: "gomod"
directory: "/authz"
directory: "/proxy"
schedule:
interval: "monthly"
day: "monday"
Expand All @@ -120,7 +120,7 @@ updates:
- "/api"
- "/mcp"
- "/operator"
- "/authz"
- "/proxy"
# The Node major is pinned in ui/.node-version (CI fails when the image
# disagrees); move both together on purpose.
ignore:
Expand Down
26 changes: 13 additions & 13 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -108,23 +108,23 @@ jobs:
export KUBEBUILDER_ASSETS
go test -tags envtest ./internal/controller/ -race -count=1 -run Envtest -v

# The apps-authz sidecar (authz/): the same Go gates as the operator, with
# The apps-proxy sidecar (proxy/): the same Go gates as the operator, with
# its own coverage floor over internal/ (main.go is env wiring).
authz:
name: Authz sidecar
proxy:
name: Proxy sidecar
runs-on: ubuntu-latest
defaults:
run:
working-directory: authz
working-directory: proxy
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: authz/go.mod
cache-dependency-path: authz/go.sum
go-version-file: proxy/go.mod
cache-dependency-path: proxy/go.sum

- name: go mod tidy is a no-op
run: go mod tidy -diff
Expand All @@ -139,7 +139,7 @@ jobs:
uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0
with:
version: v2.14.0
working-directory: authz
working-directory: proxy

- name: go test
run: go test ./... -race -count=1 -coverprofile="$RUNNER_TEMP/cover.out"
Expand All @@ -152,7 +152,7 @@ jobs:
total=$(go tool cover -func="$RUNNER_TEMP/internal.out" | awk '/^total:/ { sub("%", "", $3); print $3 }')
echo "internal/ coverage: ${total}% (floor ${FLOOR}%)"
awk -v t="$total" -v f="$FLOOR" 'BEGIN { exit !(t >= f) }' || {
echo "::error::authz internal/ coverage ${total}% is below the ${FLOOR}% floor"; exit 1; }
echo "::error::proxy internal/ coverage ${total}% is below the ${FLOOR}% floor"; exit 1; }

python:
name: Python (${{ matrix.package }})
Expand Down Expand Up @@ -454,12 +454,12 @@ jobs:
check api/Dockerfile python "$(cat api/.python-version)"
check mcp/Dockerfile python "$(cat mcp/.python-version)"
check operator/Dockerfile golang "$(sed -nE 's/^toolchain go(.+)/\1/p' operator/go.mod)"
check authz/Dockerfile golang "$(sed -nE 's/^toolchain go(.+)/\1/p' authz/go.mod)"
# One Go toolchain for the repo: authz/go.mod follows operator/go.mod.
if [ "$(grep '^toolchain' authz/go.mod)" != "$(grep '^toolchain' operator/go.mod)" ]; then
echo "::error file=authz/go.mod::toolchain differs from operator/go.mod"; fail=1
check proxy/Dockerfile golang "$(sed -nE 's/^toolchain go(.+)/\1/p' proxy/go.mod)"
# One Go toolchain for the repo: proxy/go.mod follows operator/go.mod.
if [ "$(grep '^toolchain' proxy/go.mod)" != "$(grep '^toolchain' operator/go.mod)" ]; then
echo "::error file=proxy/go.mod::toolchain differs from operator/go.mod"; fail=1
fi
if grep -nE '^FROM ' ui/Dockerfile api/Dockerfile mcp/Dockerfile operator/Dockerfile authz/Dockerfile | grep -v '@sha256:'; then
if grep -nE '^FROM ' ui/Dockerfile api/Dockerfile mcp/Dockerfile operator/Dockerfile proxy/Dockerfile | grep -v '@sha256:'; then
echo "::error::base images above are not pinned by digest"; fail=1
fi
exit "$fail"
Expand Down
12 changes: 6 additions & 6 deletions .github/workflows/images.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ on:
branches: [main]
paths:
- "operator/**"
- "authz/**"
- "proxy/**"
- "api/**"
- "ui/**"
- "mcp/**"
Expand All @@ -28,7 +28,7 @@ on:
pull_request:
paths:
- "operator/**"
- "authz/**"
- "proxy/**"
- "api/**"
- "ui/**"
- "mcp/**"
Expand All @@ -53,8 +53,8 @@ jobs:
include:
- image: operator
context: operator
- image: authz
context: authz
- image: proxy
context: proxy
- image: api
context: api
- image: ui
Expand Down Expand Up @@ -89,8 +89,8 @@ jobs:
include:
- image: operator
context: operator
- image: authz
context: authz
- image: proxy
context: proxy
- image: api
context: api
- image: ui
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ jobs:
chart-name: nebari-apps
tag-paths: |
operator.image.tag
authz.image.tag
proxy.image.tag
api.image.tag
ui.image.tag
mcp.image.tag
Expand Down
14 changes: 7 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ Keycloak auth. See [`docs/src/content/docs/architecture.md`](docs/src/content/do

| Path | What | Key files |
|---|---|---|
| `operator/` | Go operator (controller-runtime) | `api/v1alpha1/app_types.go` (the CRD), `internal/controller/app_controller.go` (reconcile pipeline), `internal/controller/workload.go` (pod spec), `internal/controller/authz.go` (the authz sidecar for Apps that can idle or run an image), `internal/controller/idle.go` (scale to zero: activity polling, the Idle phase, the activator ReferenceGrant), `internal/controller/activator.go` (the page idle Apps serve while they wake), `internal/controller/sources.go` (source volumes and init containers), `internal/controller/imageref.go` (image references and the registry allowlist for `source.type: image`), `internal/controller/routing.go` (NebariApp) |
| `authz/` | Go sidecar in front of Apps that can idle and image Apps: proxies every request and reports activity for the idle check. It checks no tokens: `access.groups` are enforced at the gateway (NebariApp `auth.groups`), and it refuses to start if given group settings | `main.go` (env config), `internal/gate/config.go` (env), `internal/gate/proxy.go` (proxy + probes), `internal/gate/activity.go` (`/activity`) |
| `operator/` | Go operator (controller-runtime) | `api/v1alpha1/app_types.go` (the CRD), `internal/controller/app_controller.go` (reconcile pipeline), `internal/controller/workload.go` (pod spec), `internal/controller/proxy.go` (the proxy sidecar for Apps that can idle or run an image), `internal/controller/idle.go` (scale to zero: activity polling, the Idle phase, the activator ReferenceGrant), `internal/controller/activator.go` (the page idle Apps serve while they wake), `internal/controller/sources.go` (source volumes and init containers), `internal/controller/imageref.go` (image references and the registry allowlist for `source.type: image`), `internal/controller/routing.go` (NebariApp) |
| `proxy/` | Go sidecar in front of Apps that can idle and image Apps: proxies every request and reports activity for the idle check. It checks no tokens: `access.groups` are enforced at the gateway (NebariApp `auth.groups`), and it refuses to start if given group settings | `main.go` (env config), `internal/gate/config.go` (env), `internal/gate/proxy.go` (proxy + probes), `internal/gate/activity.go` (`/activity`) |
| `api/` | FastAPI REST API over App CRs | `src/nebari_apps_api/main.py` (routes), `auth.py` (JWT + owner/admin checks), `models.py` (request/response models), `cr.py` (model ↔ CR), `images.py` (image allowlist, mirrors `imageref.go`), `k8s.py` (store) |
| `mcp/` | FastMCP server, agent tools, resources and prompts at `/mcp` | `src/nebari_apps_mcp/server.py` (tools, `nebari-apps://` resources, prompts), `auth.py` (MCP OAuth: protected resource metadata, JWT verification), `untrusted.py` (fencing app output) |
| `ui/` | React + Vite UI on the Nebari design registry | `src/pages/`, `src/components/` (`app-form/`: state, sections), `src/hooks/use-app-actions.ts`, `src/lib/api.ts`, `src/lib/types.ts` (over the generated `crd.gen.ts`) |
Expand All @@ -34,7 +34,7 @@ Run the checks for every component you touched; CI runs all of them
([quality gates](CONTRIBUTING.md#quality-gates) has the full table).

```bash
# operator/ (authz/ is the same)
# operator/ (proxy/ is the same)
go build ./... && go vet ./... && go test ./... -race && golangci-lint run

# api/ (mcp/ is the same, plus `uv pip install --no-deps -e ../api` before pytest)
Expand Down Expand Up @@ -70,11 +70,11 @@ helm lint charts/nebari-apps --set clusterDomain=nebari.example.com
`docs/src/content/docs/configuration.md`.
- **The gateway enforces `access.groups`, not the pod.** A restricted App's NebariApp carries
`auth.groups` with `enforceAtGateway: true`, and nebari-operator's SecurityPolicy returns
403 to non-members. The authz sidecar is passthrough only (activity for idling, the front
403 to non-members. The proxy sidecar is passthrough only (activity for idling, the front
port for image Apps); never add token or group checks back to it, and never set
`forwardAccessToken` (app pods do not get the user's token). Groups are full Keycloak paths
matched exactly (`finance` == `/finance`; `/finance` does not admit `/finance/analysts`),
normalized like nebari-operator's `groups.NormalizePath` in `authz.go` (`groupPath`),
normalized like nebari-operator's `groups.NormalizePath` in `proxy.go` (`groupPath`),
`api/src/nebari_apps_api/groups.py` and `ui/src/lib/access.ts` (`groupPath`); change them
together. apps-api matches `api.adminGroups` and `access.managers.groups` the same way.
- **Every per-App API route authorizes through `authorized_app`** (owner, a manager listed
Expand All @@ -84,7 +84,7 @@ helm lint charts/nebari-apps --set clusterDomain=nebari.example.com
- **App pods are untrusted user code.** Keep them non-root, capability-free, without a
ServiceAccount token, and with resource limits. User input reaches init containers through
environment variables, never shell interpolation. The `app` container stays first and
keeps its name (logs default to it); Apps that can idle and image Apps add the `authz`
keeps its name (logs default to it); Apps that can idle and image Apps add the `proxy`
sidecar after it. The operator reads idle Apps' activity from the pod, so treat that answer as
untrusted too (bounded, clamped, and a failed check never idles the App). MCP tools and resources return app-controlled text
(logs, files, events) only through the fences in `mcp/src/nebari_apps_mcp/untrusted.py`, and
Expand All @@ -109,7 +109,7 @@ helm lint charts/nebari-apps --set clusterDomain=nebari.example.com
check compares them with the chart's `version`.
- **Runtime versions are pinned once:** `ui/.node-version`, `api/.python-version`,
`mcp/.python-version`, `docs/.bun-version`, and the `toolchain` line in `operator/go.mod`
(`authz/go.mod` carries the same line).
(`proxy/go.mod` carries the same line).
Change the pin and the matching Dockerfile `FROM` (digest-pinned) together.
- **Workflows:** every `uses:` is pinned to a full commit SHA with a `# vX.Y.Z` comment;
each workflow starts at `permissions: contents: read`. actionlint and zizmor run in the
Expand Down
6 changes: 3 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ The repository layout is described in [AGENTS.md](AGENTS.md#repository-map).
CI runs these on every pull request. Run the ones for the components you touched before
pushing.

CI is one job per component in `.github/workflows/ci.yaml` (Operator, Authz sidecar, Python
CI is one job per component in `.github/workflows/ci.yaml` (Operator, Proxy sidecar, Python
per package, UI, Helm chart, Repo invariants), plus `.github/workflows/images.yaml`, which builds each
image. The checks that are scripts live in `.github/scripts/` and run locally from the repo
root, e.g. `python3 .github/scripts/check_versions.py` or
Expand All @@ -48,7 +48,7 @@ chart; see the `chart` job for the arguments).
| Component | Directory | Checks |
|---|---|---|
| Operator (Go) | `operator/` | `go vet ./...`, `go build ./...`, `go test ./... -race` (CI also enforces an `internal/` coverage floor, in `.github/workflows/ci.yaml`), `golangci-lint run` (config in `operator/.golangci.yml`), and the envtest suite (below) |
| Authz sidecar (Go) | `authz/` | `go mod tidy -diff`, `go vet ./...`, `go build ./...`, `golangci-lint run` (config in `authz/.golangci.yml`), `go test ./... -race` with an `internal/` coverage floor (in `.github/workflows/ci.yaml`) |
| Proxy sidecar (Go) | `proxy/` | `go mod tidy -diff`, `go vet ./...`, `go build ./...`, `golangci-lint run` (config in `proxy/.golangci.yml`), `go test ./... -race` with an `internal/` coverage floor (in `.github/workflows/ci.yaml`) |
| CRD / deepcopy | `operator/` | Regenerate with `controller-gen` v0.21.0 (commands below) and commit the result; CI fails on drift. |
| apps-api | `api/` | `uv lock --check`, `uv sync --frozen --extra dev`, `uv run --frozen --no-sync python -m pytest -q --cov` (fails under `fail_under` in `pyproject.toml`), then `ruff check .`, `ruff format --check .` and `mypy` (strict) through `uv run --frozen --no-sync` |
| apps-mcp | `mcp/` | Same as apps-api, plus `uv pip install --no-deps -e ../api` before the tests (they drive the real API in process). |
Expand All @@ -60,7 +60,7 @@ chart; see the `chart` job for the arguments).

Runtime versions are pinned once and read everywhere: `ui/.node-version`, `api/.python-version`
and `mcp/.python-version` (CI's setup-node and uv read them), `docs/.bun-version`, and the
`toolchain` line in `operator/go.mod` (which `authz/go.mod` repeats). Base images are pinned
`toolchain` line in `operator/go.mod` (which `proxy/go.mod` repeats). Base images are pinned
by digest. When you change a runtime version, change the pin file and the matching Dockerfile
`FROM` in the same PR; the Runtime pins check in CI fails when they disagree.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ start again on the next request
| apps-api | REST API for CRUD, status, logs, events, analytics and zip/`.html` upload; writes `App` resources (and each App's write-only Secret) for the UI and the MCP server. | `api` |
| apps-ui | The apps list as the home page (with a Needs attention banner, a Mine filter and Open links), app detail with logs and events, the launch form, and a Settings page (the UI and API versions, your groups, the cluster's settings, and how to connect an agent). | `ui` |
| apps-mcp | MCP server at `/mcp` that lets coding agents launch and manage apps (tools), attach an app's spec, status, logs and events as context (resources), and follow the triage, launch and access-review workflows (prompts), with standard MCP OAuth sign-in through Keycloak. | `mcp` |
| apps-authz | Sidecar in the pods of Apps that can idle (records when the app last served a request) and of image Apps (gives them the one front port). It checks no tokens: `access.groups` are enforced at the gateway. | `authz` |
| apps-proxy | Sidecar in the pods of Apps that can idle (records when the app last served a request) and of image Apps (gives them the one front port). It checks no tokens: `access.groups` are enforced at the gateway. | `proxy` |

Images are published to `ghcr.io/nebari-dev/apps-pack/<image>` and
`quay.io/nebari/apps-pack-<image>`. The UI signs in with keycloak-js and the API validates its
Expand Down
6 changes: 3 additions & 3 deletions api/src/nebari_apps_api/k8s.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
MANAGED_LABEL = "nebari.dev/managed"
APP_LABEL = "apps.nebari.dev/app"
# The operator always names the app's own container "app" (and puts it
# first); restricted apps add an "authz" sidecar next to it.
# first); restricted apps add an "proxy" sidecar next to it.
APP_CONTAINER = "app"
# Seconds a readiness check may wait on a dependency.
READY_TIMEOUT = 3
Expand Down Expand Up @@ -188,7 +188,7 @@ def pod_logs(self, namespace: str, app_name: str, lines: int, container: str | N
resp: Any = self._core.read_namespaced_pod_log(
pod_name,
namespace,
# Restricted apps run an authz sidecar too, and the apiserver
# Restricted apps run an proxy sidecar too, and the apiserver
# needs a container name for multi-container pods: default to
# the app's own container.
container=container or APP_CONTAINER,
Expand Down Expand Up @@ -317,7 +317,7 @@ def pod_health(self, namespace: str, app_name: str) -> list[dict[str, Any]]:
"""Health and resources of the App's live pods, from pod status and spec.

Needs no metrics server: one pod list call, by the App's label. Each
entry carries the `app` container's restart count (the authz
entry carries the `app` container's restart count (the proxy
sidecar's are left out), how its previous run ended
(`lastTermination`: reason, exit code and finish time; never the
termination message, which the container writes), when it started
Expand Down
2 changes: 1 addition & 1 deletion api/src/nebari_apps_api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -617,7 +617,7 @@ def _check_image(source: Any, pixi_task: str, allowlist: list[str]) -> None:
if source.image.port in IMAGE_RESERVED_PORTS:
raise HTTPException(
422,
f"source.image.port may not be {' or '.join(map(str, IMAGE_RESERVED_PORTS))}: the apps-authz sidecar uses them",
f"source.image.port may not be {' or '.join(map(str, IMAGE_RESERVED_PORTS))}: the apps-proxy sidecar uses them",
)
try:
check_image(source.image.reference, allowlist)
Expand Down
4 changes: 2 additions & 2 deletions api/src/nebari_apps_api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ class PVCSource(BaseModel):
subPath: str = ""


# Ports the apps-authz sidecar takes in every image App's pod (its listener
# Ports the apps-proxy sidecar takes in every image App's pod (its listener
# and its probe port), so the image may not listen on them.
IMAGE_RESERVED_PORTS = (8080, 8082)
IMAGE_PORT_MIN = 1024
Expand Down Expand Up @@ -299,7 +299,7 @@ class PodMetric(BaseModel):
name: str
cpu: str = "" # e.g. "12m" (millicores); "" without a metrics server
memory: str = "" # e.g. "34Mi"; "" without a metrics server
# Restarts of the `app` container (the authz sidecar's are not counted).
# Restarts of the `app` container (the proxy sidecar's are not counted).
restarts: int = 0
lastTermination: PodTermination | None = None
# When the `app` container last started running; None while it is not running.
Expand Down
6 changes: 3 additions & 3 deletions api/tests/test_k8s_resilience.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,12 +92,12 @@ def test_every_call_has_a_timeout(kube):


def test_pod_logs_default_to_the_app_container(kube):
# Restricted apps have an authz sidecar; the apiserver refuses a log
# Restricted apps have an proxy sidecar; the apiserver refuses a log
# request for a multi-container pod without a container name.
kube.pod_logs("apps", "demo", 10, None)
assert kube._core.read_namespaced_pod_log.call_args.kwargs["container"] == "app"
kube.pod_logs("apps", "demo", 10, "authz")
assert kube._core.read_namespaced_pod_log.call_args.kwargs["container"] == "authz"
kube.pod_logs("apps", "demo", 10, "proxy")
assert kube._core.read_namespaced_pod_log.call_args.kwargs["container"] == "proxy"


def test_lists_page_through_continue_tokens(kube):
Expand Down
6 changes: 3 additions & 3 deletions api/tests/test_pod_health.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,14 @@ def make_pod(name: str, *, phase: str = "Running", app: dict[str, Any] | None =
"requests": {"cpu": "250m", "memory": "268435456"},
},
},
{"name": "authz", "resources": {"limits": {"cpu": "100m", "memory": "64Mi"}}},
{"name": "proxy", "resources": {"limits": {"cpu": "100m", "memory": "64Mi"}}},
]
},
"status": {
"phase": phase,
"containerStatuses": [
app_status,
{"name": "authz", "image": "authz", "imageID": "", "ready": True, "restartCount": 9},
{"name": "proxy", "image": "proxy", "imageID": "", "ready": True, "restartCount": 9},
],
},
}
Expand Down Expand Up @@ -86,7 +86,7 @@ def test_oom_killed_pod_counts_only_the_app_container(kube):
}
serve(kube, make_pod("app-demo-1", app=oom))
[pod] = kube.pod_health("apps", "demo")
# The authz sidecar's 9 restarts are not the app's.
# The proxy sidecar's 9 restarts are not the app's.
assert pod["restarts"] == 3
assert pod["lastTermination"] == {"reason": "OOMKilled", "exitCode": 137, "finishedAt": "2026-07-15T09:58:00Z"}

Expand Down
Loading
Loading