From 0a5f5e0b0ead4ff9b3eaa695da5ad9378350d740 Mon Sep 17 00:00:00 2001 From: Johnny Bouder Date: Thu, 22 Oct 2026 10:04:29 -0400 Subject: [PATCH] refactor!: rename the authz sidecar to proxy Since the gateway took over sign-in and access.groups (nebari-operator v0.2.0 SecurityPolicy, #168), the sidecar authorizes nothing: it proxies requests, records activity for scale to zero and gives image Apps the single front port 8080. The name "authz" made readers think the pod checks access. Rename it before 0.2.0, which already renames the images (#169), and before the Quay repositories are created (#85). The directory, Go module, image, chart values, operator flags, container name, port name and sidecar env vars move from authz to proxy. Ports and behaviour are unchanged. The sidecar still refuses to start when AUTHZ_GROUPS (or another setting of the removed enforcing mode) is set, under its old name on purpose, and now also when it gets its own settings under the old AUTHZ_ names, so an operator from before the rename fails closed instead of starting an App open. AUTHZ_PASSTHROUGH, which nothing read any more, is no longer mentioned. BREAKING CHANGE: the sidecar image is now ghcr.io/nebari-dev/apps-pack/proxy (quay.io/nebari/apps-pack-proxy) and the chart's authz.* values are now proxy.*; the schema rejects the old key. The sidecar container is renamed from authz to proxy, so every pod that runs it rolls once. Upgrade the chart and images together. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ap1Tqx1QLHRGVVSXdxeduM --- .github/dependabot.yml | 4 +- .github/workflows/ci.yaml | 26 ++++----- .github/workflows/images.yaml | 12 ++-- .github/workflows/release.yaml | 2 +- AGENTS.md | 14 ++--- CONTRIBUTING.md | 6 +- README.md | 2 +- api/src/nebari_apps_api/k8s.py | 6 +- api/src/nebari_apps_api/main.py | 2 +- api/src/nebari_apps_api/models.py | 4 +- api/tests/test_k8s_resilience.py | 6 +- api/tests/test_pod_health.py | 6 +- authz/go.mod | 5 -- charts/nebari-apps/README.md | 8 +-- .../crds/apps.nebari.dev_apps.yaml | 8 +-- charts/nebari-apps/templates/_helpers.tpl | 6 +- .../templates/operator-deployment.yaml | 6 +- charts/nebari-apps/values.schema.json | 4 +- charts/nebari-apps/values.yaml | 10 ++-- dev/Makefile | 18 +++--- docs/src/content/docs/api-reference.md | 2 +- docs/src/content/docs/app-crd-reference.md | 8 +-- docs/src/content/docs/architecture.md | 14 ++--- docs/src/content/docs/ci-cd.md | 2 +- docs/src/content/docs/configuration.md | 6 +- docs/src/content/docs/getting-started.md | 6 +- docs/src/content/docs/launching-apps.md | 2 +- docs/src/content/docs/local-development.md | 2 +- docs/src/content/docs/mcp.md | 2 +- docs/src/content/docs/runbook.md | 4 +- docs/src/content/docs/troubleshooting.md | 19 ++++--- docs/src/content/docs/upgrading.md | 28 +++++++++- examples/web-terminal/pixi.toml | 2 +- mcp/src/nebari_apps_mcp/server.py | 4 +- operator/api/v1alpha1/app_types.go | 8 +-- operator/cmd/main.go | 32 +++++------ .../internal/controller/app_controller.go | 24 ++++---- .../controller/app_controller_test.go | 4 +- operator/internal/controller/envtest_test.go | 4 +- operator/internal/controller/idle.go | 6 +- operator/internal/controller/idle_test.go | 6 +- operator/internal/controller/image_test.go | 14 ++--- .../controller/{authz.go => proxy.go} | 56 +++++++++---------- .../{authz_test.go => proxy_test.go} | 42 +++++++------- operator/internal/controller/secrets_test.go | 2 +- operator/internal/controller/workload.go | 12 ++-- {authz => proxy}/.dockerignore | 0 {authz => proxy}/.golangci.yml | 0 {authz => proxy}/Dockerfile | 6 +- proxy/go.mod | 5 ++ {authz => proxy}/go.sum | 0 {authz => proxy}/internal/gate/activity.go | 0 {authz => proxy}/internal/gate/config.go | 39 ++++++++----- {authz => proxy}/internal/gate/gate_test.go | 27 ++++++--- {authz => proxy}/internal/gate/proxy.go | 0 {authz => proxy}/internal/gate/run.go | 0 {authz => proxy}/main.go | 17 +++--- skill/new-nebari-app/references/manifest.md | 2 +- ui/src/lib/crd.gen.ts | 2 +- 59 files changed, 309 insertions(+), 255 deletions(-) delete mode 100644 authz/go.mod rename operator/internal/controller/{authz.go => proxy.go} (76%) rename operator/internal/controller/{authz_test.go => proxy_test.go} (89%) rename {authz => proxy}/.dockerignore (100%) rename {authz => proxy}/.golangci.yml (100%) rename {authz => proxy}/Dockerfile (84%) create mode 100644 proxy/go.mod rename {authz => proxy}/go.sum (100%) rename {authz => proxy}/internal/gate/activity.go (100%) rename {authz => proxy}/internal/gate/config.go (58%) rename {authz => proxy}/internal/gate/gate_test.go (89%) rename {authz => proxy}/internal/gate/proxy.go (100%) rename {authz => proxy}/internal/gate/run.go (100%) rename {authz => proxy}/main.go (62%) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d75eef0..e738490 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -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" @@ -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: diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c2a9fee..d9ec5b4 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -108,14 +108,14 @@ 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: @@ -123,8 +123,8 @@ jobs: - 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 @@ -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" @@ -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 }}) @@ -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" diff --git a/.github/workflows/images.yaml b/.github/workflows/images.yaml index 71fbd42..02f0bf1 100644 --- a/.github/workflows/images.yaml +++ b/.github/workflows/images.yaml @@ -19,7 +19,7 @@ on: branches: [main] paths: - "operator/**" - - "authz/**" + - "proxy/**" - "api/**" - "ui/**" - "mcp/**" @@ -28,7 +28,7 @@ on: pull_request: paths: - "operator/**" - - "authz/**" + - "proxy/**" - "api/**" - "ui/**" - "mcp/**" @@ -53,8 +53,8 @@ jobs: include: - image: operator context: operator - - image: authz - context: authz + - image: proxy + context: proxy - image: api context: api - image: ui @@ -89,8 +89,8 @@ jobs: include: - image: operator context: operator - - image: authz - context: authz + - image: proxy + context: proxy - image: api context: api - image: ui diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index d8cb68b..9736603 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index cbbc049..fa450e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`) | @@ -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) @@ -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 @@ -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 @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index efe90bc..48d2112 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 @@ -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). | @@ -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. diff --git a/README.md b/README.md index 6072df9..df4522a 100644 --- a/README.md +++ b/README.md @@ -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/` and `quay.io/nebari/apps-pack-`. The UI signs in with keycloak-js and the API validates its diff --git a/api/src/nebari_apps_api/k8s.py b/api/src/nebari_apps_api/k8s.py index 73b564f..debdd17 100644 --- a/api/src/nebari_apps_api/k8s.py +++ b/api/src/nebari_apps_api/k8s.py @@ -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 @@ -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, @@ -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 diff --git a/api/src/nebari_apps_api/main.py b/api/src/nebari_apps_api/main.py index d704ed2..1d4e1e3 100644 --- a/api/src/nebari_apps_api/main.py +++ b/api/src/nebari_apps_api/main.py @@ -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) diff --git a/api/src/nebari_apps_api/models.py b/api/src/nebari_apps_api/models.py index e75b392..37e426f 100644 --- a/api/src/nebari_apps_api/models.py +++ b/api/src/nebari_apps_api/models.py @@ -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 @@ -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. diff --git a/api/tests/test_k8s_resilience.py b/api/tests/test_k8s_resilience.py index fc47509..e4bc3ec 100644 --- a/api/tests/test_k8s_resilience.py +++ b/api/tests/test_k8s_resilience.py @@ -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): diff --git a/api/tests/test_pod_health.py b/api/tests/test_pod_health.py index 6bdf9ee..dd0afea 100644 --- a/api/tests/test_pod_health.py +++ b/api/tests/test_pod_health.py @@ -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}, ], }, } @@ -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"} diff --git a/authz/go.mod b/authz/go.mod deleted file mode 100644 index e1684d6..0000000 --- a/authz/go.mod +++ /dev/null @@ -1,5 +0,0 @@ -module github.com/nebari-dev/nebari-apps-pack/authz - -go 1.26.1 - -toolchain go1.27.3 diff --git a/charts/nebari-apps/README.md b/charts/nebari-apps/README.md index 36eea06..5ddbf7a 100644 --- a/charts/nebari-apps/README.md +++ b/charts/nebari-apps/README.md @@ -46,10 +46,6 @@ wrong type fails the install. | apps.networkPolicy.enabled | bool | `false` | Render a NetworkPolicy for launched app pods: ingress only from the gateway namespace on the app port (and from the operator on the sidecar's health port, for the idle check), egress only to DNS and to the internet minus `blockedCIDRs`, so user code cannot reach cluster-internal services. git/pixi sources still clone and install from the internet. Needs a CNI that enforces NetworkPolicy. | | apps.networkPolicy.gatewayNamespace | string | `"envoy-gateway-system"` | Namespace of the Envoy gateway pods allowed to reach app pods. | | appsDomain | string | `""` | Domain apps are exposed under: `https://.`. Empty defaults to `apps.`, e.g. `my-app.apps.example.ai`. | -| authz.image.pullPolicy | string | `"IfNotPresent"` | Image pull policy. | -| authz.image.repository | string | `"ghcr.io/nebari-dev/apps-pack/authz"` | apps-authz image repository. | -| authz.image.tag | string | `""` | Image tag. Empty defaults to the chart's appVersion. | -| authz.resources | object | `{"limits":{"cpu":"100m","memory":"64Mi"},"requests":{"cpu":"10m","memory":"32Mi"}}` | Sidecar resources, per app pod that runs it (on top of the app container's own). | | clusterDomain | string | `""` | Required. The cluster's base domain; there is no sensible default. | | fullnameOverride | string | `""` | Override the full resource-name prefix. | | gateway | string | `"public"` | Shared Gateway that app NebariApps attach to: `public` or `internal`. | @@ -76,6 +72,10 @@ wrong type fails the install. | operator.leaderElection | bool | `true` | Elect a leader so only one replica reconciles at a time. | | operator.replicaCount | int | `1` | Operator replicas (one leads when `leaderElection` is on). | | operator.resources | object | `{"limits":{"cpu":"500m","memory":"256Mi"},"requests":{"cpu":"500m","memory":"256Mi"}}` | Container resources. Requests equal limits, so the pod is Guaranteed. | +| proxy.image.pullPolicy | string | `"IfNotPresent"` | Image pull policy. | +| proxy.image.repository | string | `"ghcr.io/nebari-dev/apps-pack/proxy"` | apps-proxy image repository. | +| proxy.image.tag | string | `""` | Image tag. Empty defaults to the chart's appVersion. | +| proxy.resources | object | `{"limits":{"cpu":"100m","memory":"64Mi"},"requests":{"cpu":"10m","memory":"32Mi"}}` | Sidecar resources, per app pod that runs it (on top of the app container's own). | | pythonImage | string | `"ghcr.io/prefix-dev/pixi:0.68.1-noble"` | Image running Python/pixi apps (`runtime.pixiTask`). Must provide the `pixi` binary and run as a non-root user. | | staticImage | string | `"nginxinc/nginx-unprivileged:1.27-alpine"` | Image serving static app content. Must listen on 8080 as a non-root user; the operator mounts its nginx server config over /etc/nginx/conf.d. | | telemetry.enabled | bool | `false` | Render ServiceMonitors for the operator and apps-api (needs the Prometheus Operator CRDs) and the `prometheus.io/*` scrape annotations on the operator's metrics Service. | diff --git a/charts/nebari-apps/crds/apps.nebari.dev_apps.yaml b/charts/nebari-apps/crds/apps.nebari.dev_apps.yaml index 1e59e02..f8d11eb 100644 --- a/charts/nebari-apps/crds/apps.nebari.dev_apps.yaml +++ b/charts/nebari-apps/crds/apps.nebari.dev_apps.yaml @@ -336,7 +336,7 @@ spec: environment from the app source and executes `pixi run `. Not allowed with source type image, which runs its own server. The task must start a server on $PORT: 8080, or 8081 when the app can - idle (the authz sidecar takes 8080). + idle (the proxy sidecar takes 8080). maxLength: 64 type: string replicas: @@ -456,7 +456,7 @@ spec: description: |- ImageSource runs a prebuilt container image as the app container, with the same non-root, capability-free, resource-limited pod spec as other Apps and - the apps-authz sidecar in front of it. + the apps-proxy sidecar in front of it. properties: args: description: Args replaces the image's CMD (exec form, no @@ -477,7 +477,7 @@ spec: port: description: |- Port the image's server listens on (on 0.0.0.0 or 127.0.0.1; also - passed as $PORT). The apps-authz sidecar always fronts image Apps on + passed as $PORT). The apps-proxy sidecar always fronts image Apps on 8080 and serves its probes on 8082, so the app may use neither. format: int32 maximum: 65535 @@ -485,7 +485,7 @@ spec: type: integer x-kubernetes-validations: - message: 'source.image.port may not be 8080 or 8082: the - apps-authz sidecar uses them' + apps-proxy sidecar uses them' rule: self != 8080 && self != 8082 reference: description: |- diff --git a/charts/nebari-apps/templates/_helpers.tpl b/charts/nebari-apps/templates/_helpers.tpl index 5dee417..90f0d7d 100644 --- a/charts/nebari-apps/templates/_helpers.tpl +++ b/charts/nebari-apps/templates/_helpers.tpl @@ -72,8 +72,8 @@ Operator image reference {{- end }} {{/* -apps-authz sidecar image reference (passed to the operator) +apps-proxy sidecar image reference (passed to the operator) */}} -{{- define "nebari-apps.authzImage" -}} -{{- printf "%s:%s" .Values.authz.image.repository (.Values.authz.image.tag | default .Chart.AppVersion) }} +{{- define "nebari-apps.proxyImage" -}} +{{- printf "%s:%s" .Values.proxy.image.repository (.Values.proxy.image.tag | default .Chart.AppVersion) }} {{- end }} diff --git a/charts/nebari-apps/templates/operator-deployment.yaml b/charts/nebari-apps/templates/operator-deployment.yaml index 5cda46f..0b36dab 100644 --- a/charts/nebari-apps/templates/operator-deployment.yaml +++ b/charts/nebari-apps/templates/operator-deployment.yaml @@ -32,9 +32,9 @@ spec: - --static-image={{ .Values.staticImage }} - --git-image={{ .Values.gitImage }} - --python-image={{ .Values.pythonImage }} - - --authz-image={{ include "nebari-apps.authzImage" . }} - - --authz-image-pull-policy={{ .Values.authz.image.pullPolicy }} - - {{ printf "--authz-resources=%s" (toJson .Values.authz.resources) | quote }} + - --proxy-image={{ include "nebari-apps.proxyImage" . }} + - --proxy-image-pull-policy={{ .Values.proxy.image.pullPolicy }} + - {{ printf "--proxy-resources=%s" (toJson .Values.proxy.resources) | quote }} - {{ printf "--default-static-resources=%s" (toJson .Values.apps.defaultResources.static) | quote }} - {{ printf "--default-python-resources=%s" (toJson .Values.apps.defaultResources.python) | quote }} {{- if not .Values.tls.enabled }} diff --git a/charts/nebari-apps/values.schema.json b/charts/nebari-apps/values.schema.json index 53af824..ccfa56d 100644 --- a/charts/nebari-apps/values.schema.json +++ b/charts/nebari-apps/values.schema.json @@ -1088,9 +1088,9 @@ "type": "string", "minLength": 1 }, - "authz": { + "proxy": { "type": "object", - "description": "The apps-authz sidecar in front of app pods that can idle or run an image (activity tracking and the front port; access.groups are enforced at the gateway).", + "description": "The apps-proxy sidecar in front of app pods that can idle or run an image (activity tracking and the front port; access.groups are enforced at the gateway).", "properties": { "image": { "type": "object", diff --git a/charts/nebari-apps/values.yaml b/charts/nebari-apps/values.yaml index 7ad633a..19653b0 100644 --- a/charts/nebari-apps/values.yaml +++ b/charts/nebari-apps/values.yaml @@ -38,7 +38,7 @@ apps: # operator's activator until the next request has started it again. An App # sets `runtime.idleTimeoutMinutes` to override the default (and can opt in # while it is 0) or `runtime.keepAlive: true` to opt out. Apps that can idle - # run the apps-authz sidecar, which records their activity. + # run the apps-proxy sidecar, which records their activity. idle: # -- Cluster default idle timeout in minutes; `0` keeps apps running # unless they set `runtime.idleTimeoutMinutes`. Otherwise at least 5. @@ -386,14 +386,14 @@ gitImage: alpine/git:v2.47.2 # `pixi` binary and run as a non-root user. pythonImage: ghcr.io/prefix-dev/pixi:0.68.1-noble -# The apps-authz sidecar. Apps that can idle and image Apps get it in front of +# The apps-proxy sidecar. Apps that can idle and image Apps get it in front of # the app container: it proxies every request and records activity for the # idle check. It checks no tokens; `access.groups` are enforced at the gateway # by nebari-operator. -authz: +proxy: image: - # -- apps-authz image repository. - repository: ghcr.io/nebari-dev/apps-pack/authz + # -- apps-proxy image repository. + repository: ghcr.io/nebari-dev/apps-pack/proxy # -- Image tag. Empty defaults to the chart's appVersion. tag: "" # -- Image pull policy. diff --git a/dev/Makefile b/dev/Makefile index c525319..7408327 100644 --- a/dev/Makefile +++ b/dev/Makefile @@ -51,7 +51,7 @@ IMG := apps-operator:dev API_IMG := apps-api:dev UI_IMG := apps-ui:dev MCP_IMG := apps-mcp:dev -AUTHZ_IMG := apps-authz:dev +PROXY_IMG := apps-proxy:dev # In-cluster Keycloak from the nebari-operator dev scripts: apps-api and # apps-mcp fetch the realm's keys from it when auth is on. KEYCLOAK_INTERNAL_URL := http://keycloak-keycloakx-http.keycloak.svc.cluster.local:8080 @@ -99,7 +99,7 @@ endef export ENVOY_NODEPORT_PROXY .PHONY: up up-git redeploy deploy images operator-image api-image ui-image \ - mcp-image authz-image cluster dns check-dns down \ + mcp-image proxy-image cluster dns check-dns down \ _cluster-create _clone-operator _kind-create _services \ _gateway-nodeport _keycloak-setup _operator _namespaces @@ -205,11 +205,11 @@ mcp-image: docker build -t $(MCP_IMG) ../mcp kind load docker-image $(MCP_IMG) --name $(CLUSTER_NAME) -authz-image: - docker build -t $(AUTHZ_IMG) ../authz - kind load docker-image $(AUTHZ_IMG) --name $(CLUSTER_NAME) +proxy-image: + docker build -t $(PROXY_IMG) ../proxy + kind load docker-image $(PROXY_IMG) --name $(CLUSTER_NAME) -images: operator-image api-image ui-image mcp-image authz-image +images: operator-image api-image ui-image mcp-image proxy-image # -------------------------------------------------------------------------- # deploy - install/upgrade the nebari-apps chart with the dev images @@ -234,9 +234,9 @@ deploy: --set mcp.image.repository=apps-mcp \ --set mcp.image.tag=dev \ --set mcp.image.pullPolicy=Never \ - --set authz.image.repository=apps-authz \ - --set authz.image.tag=dev \ - --set authz.image.pullPolicy=Never \ + --set proxy.image.repository=apps-proxy \ + --set proxy.image.tag=dev \ + --set proxy.image.pullPolicy=Never \ --set keycloak.internalUrl=$(KEYCLOAK_INTERNAL_URL) \ --wait --timeout 3m diff --git a/docs/src/content/docs/api-reference.md b/docs/src/content/docs/api-reference.md index 85f824c..8495f33 100644 --- a/docs/src/content/docs/api-reference.md +++ b/docs/src/content/docs/api-reference.md @@ -79,7 +79,7 @@ create. | `GET /apps/{ns}/{name}/status` | Phase, URL, replicas, conditions, message. | | `GET /apps/{ns}/{name}/logs?lines=200&container=` | Recent pod logs (`lines` 1–5000, default 200). | | `GET /apps/{ns}/{name}/events` | Kubernetes events for the app's resources. | -| `GET /apps/{ns}/{name}/metrics` | The app's live pods: instantaneous CPU (millicores, `"12m"`) and memory (Mi, `"34Mi"`) usage from `metrics.k8s.io`, joined with the `app` container's health from the pod. `{available, pods:[{name, cpu, memory, restarts, lastTermination, startedAt, limits, requests}]}`. `available:false` when the cluster has no metrics server: `cpu`/`memory` are then `""`, the rest still comes back. `restarts` counts the `app` container only (not the authz sidecar); `lastTermination` is `{reason, exitCode, finishedAt}` of its previous run, or `null` (`reason` is a Kubernetes identifier such as `OOMKilled`, never the container's termination message); `startedAt` is when it started running, `null` while it is not; `limits` and `requests` are `{cpu, memory}` in the same units, `""` when unset. | +| `GET /apps/{ns}/{name}/metrics` | The app's live pods: instantaneous CPU (millicores, `"12m"`) and memory (Mi, `"34Mi"`) usage from `metrics.k8s.io`, joined with the `app` container's health from the pod. `{available, pods:[{name, cpu, memory, restarts, lastTermination, startedAt, limits, requests}]}`. `available:false` when the cluster has no metrics server: `cpu`/`memory` are then `""`, the rest still comes back. `restarts` counts the `app` container only (not the proxy sidecar); `lastTermination` is `{reason, exitCode, finishedAt}` of its previous run, or `null` (`reason` is a Kubernetes identifier such as `OOMKilled`, never the container's termination message); `startedAt` is when it started running, `null` while it is not; `limits` and `requests` are `{cpu, memory}` in the same units, `""` when unset. | | `GET /analytics/summary?namespace=` | Over the apps the caller may see: totals and breakdowns by phase / source type / namespace, plus replica readiness. | | `GET /analytics/metrics` | Resource usage of the apps the caller may see, aggregated per app and per namespace, plus restart counts. `{usageAvailable, apps:[{namespace, name, cpu, memory, restarts}], byNamespace:[…]}`. Restart counts always populate (pod status); CPU/memory require a metrics server. | diff --git a/docs/src/content/docs/app-crd-reference.md b/docs/src/content/docs/app-crd-reference.md index 341403c..6480b73 100644 --- a/docs/src/content/docs/app-crd-reference.md +++ b/docs/src/content/docs/app-crd-reference.md @@ -115,7 +115,7 @@ image App is refused. | Field | Type | Required | Description | |---|---|---|---| | `reference` | string | Yes | The image, with a **tag or a digest**: `ghcr.io/org/app:1.2.3`, `ghcr.io/org/app@sha256:<64 hex>`, or both. An implicit `:latest` is refused. Docker Hub short names are expanded the way the container runtime does (`nginx:1.27` is `docker.io/library/nginx:1.27`; `index.docker.io` is `docker.io`), and the pod runs that normalized reference, so the kubelet pulls exactly what was checked. Max 512 chars. See [allowlist matching](#image-allowlist-matching). | -| `port` | int | Yes | The port the image's server listens on (on `0.0.0.0` or `127.0.0.1`), `1024`–`65535`, not `8080` or `8082`: the `authz` sidecar always fronts image Apps on 8080 and serves its probes on 8082. Also passed to the container as `$PORT`. | +| `port` | int | Yes | The port the image's server listens on (on `0.0.0.0` or `127.0.0.1`), `1024`–`65535`, not `8080` or `8082`: the `proxy` sidecar always fronts image Apps on 8080 and serves its probes on 8082. Also passed to the container as `$PORT`. | | `command` | []string | No | Replaces the image's `ENTRYPOINT` (exec form, no shell), at most 64 entries of 4096 chars. | | `args` | []string | No | Replaces the image's `CMD` (exec form, no shell), same bounds. | | `runAsUser` | int64 | No | A non-zero uid to run as. Set it for an image whose `USER` is a name rather than a number, which the kubelet cannot verify as non-root. `0` is refused. | @@ -220,8 +220,8 @@ For each `App` the operator creates (and owns, via `ownerReferences`): |---|---|---| | ConfigMap | `app--content` | Inline source files (inline apps only). | | ConfigMap | `app--server` | The nginx server config with security headers (static apps only). | -| Deployment | `app-` | The app workload (hardened: non-root, no privilege escalation, seccomp `RuntimeDefault`). The `app` container comes first; an App that can idle or that runs an image also gets the `authz` sidecar on 8080, with the app on 8081 (an image app on its own `source.image.port`). The sidecar proxies every request and records activity; it checks no tokens (`access.groups` are enforced at the gateway). Scaled to zero while the App is `Idle`. | -| Service | `app-` | ClusterIP on port 8080 (the app, or the authz sidecar in front of it). | +| Deployment | `app-` | The app workload (hardened: non-root, no privilege escalation, seccomp `RuntimeDefault`). The `app` container comes first; an App that can idle or that runs an image also gets the `proxy` sidecar on 8080, with the app on 8081 (an image app on its own `source.image.port`). The sidecar proxies every request and records activity; it checks no tokens (`access.groups` are enforced at the gateway). Scaled to zero while the App is `Idle`. | +| Service | `app-` | ClusterIP on port 8080 (the app, or the proxy sidecar in front of it). | | NebariApp | `app-` | Routing + TLS + auth + (opt-in) landing-page tile, reconciled by the nebari-operator (contract pinned to `%NEBARI_OPERATOR_VERSION%`). While the App is `Idle` or waking, `spec.service` points at the activator Service in the pack's namespace. | Deleting the `App` cascades through all of them, and through the App's Secret @@ -259,7 +259,7 @@ otherwise let anyone who can write an App read any Secret in its namespace throu An App **can idle** when its idle timeout (`runtime.idleTimeoutMinutes`, else the chart's `apps.idle.timeoutMinutes`) is above zero, `runtime.keepAlive` is not `true` and -`runtime.replicas` is not `0`. Such an App runs the `authz` sidecar, which records when the app +`runtime.replicas` is not `0`. Such an App runs the `proxy` sidecar, which records when the app last served a request; a request still open (a WebSocket, a stream) counts until it ends. Requests the gateway refuses (sign-in, or `403` outside a restricted App's groups) never reach the pod, so they do not count. The operator asks every ready pod's diff --git a/docs/src/content/docs/architecture.md b/docs/src/content/docs/architecture.md index 3ed535b..dc619cb 100644 --- a/docs/src/content/docs/architecture.md +++ b/docs/src/content/docs/architecture.md @@ -34,7 +34,7 @@ For each `App`, the operator runs an ordered, idempotent pipeline: `RuntimeDefault`): nginx serving the app's content, with git sources cloned by a non-root init container. Static apps run an operator-written nginx config (a `-server` ConfigMap mounted over `/etc/nginx/conf.d`) that adds security headers. - An App that can idle also gets the `authz` sidecar (next section), which records + An App that can idle also gets the `proxy` sidecar (next section), which records activity; it checks no tokens. An `image` App runs the given image as the `app` container instead, always behind the sidecar, which keeps 8080 as the one port the Service and NetworkPolicy target while the image listens on its own port. Before the Deployment is @@ -54,7 +54,7 @@ drift (edit or delete a child and it is restored). An App that goes its idle timeout without a request is scaled to zero and wakes on the next request ([details](/app-crd-reference/#scale-to-zero)). The pieces: -- **Activity.** The `authz` sidecar counts requests that reach the app and serves the time of +- **Activity.** The `proxy` sidecar counts requests that reach the app and serves the time of the last one on its health port (`GET :8082/activity`). It proxies every request: sign-in and `access.groups` were already enforced at the gateway, so a request that reaches the pod is one the App lets in, and public and restricted Apps are measured the same way. The operator polls @@ -70,7 +70,7 @@ request ([details](/app-crd-reference/#scale-to-zero)). The pieces: App's Service when a pod is ready. Visitors see the starting page, never a gateway error. ```text -request ─▶ gateway ─▶ NebariApp ─┬─ running: app- Service ─▶ authz sidecar ─▶ app +request ─▶ gateway ─▶ NebariApp ─┬─ running: app- Service ─▶ proxy sidecar ─▶ app └─ idle: -activator ─▶ operator: page + wake annotation ``` @@ -129,10 +129,10 @@ The split between the gateway and the pod: |---|---| | Sign-in (Keycloak SSO) | Gateway (`SecurityPolicy`) | | `access.groups` (`403` for non-members) | Gateway (`SecurityPolicy` group rule, nebari-operator `%NEBARI_OPERATOR_VERSION%`+) | -| Activity for [scale to zero](#scale-to-zero) | apps-authz sidecar, in Apps that can idle | -| One front port (8080) for an `image` App | apps-authz sidecar, in every `image` App | +| Activity for [scale to zero](#scale-to-zero) | apps-proxy sidecar, in Apps that can idle | +| One front port (8080) for an `image` App | apps-proxy sidecar, in every `image` App | -The **apps-authz** sidecar checks no tokens and gets none: the gateway does not forward the +The **apps-proxy** sidecar checks no tokens and gets none: the gateway does not forward the access token to app pods. A restricted App that neither idles nor runs an image has no sidecar. Because the gateway is the only check, keep app pods unreachable except through it: turn on `apps.networkPolicy.enabled`, which admits traffic to app pods only from the gateway's @@ -148,7 +148,7 @@ credentials go in the App's own `Secret` instead: `app--secrets` in the App's namespace. The operator injects each one into the `app` container as an environment variable of the same name (`valueFrom.secretKeyRef`), so the kubelet reads the value; nothing copies it into the spec, the Deployment or a status. - Init containers and the authz sidecar do not get them. + Init containers and the proxy sidecar do not get them. - **apps-api writes the values.** A launch or edit that carries `secrets` (UI form, REST API, MCP `launch_app`/`update_app`, `PATCH /apps/{ns}/{name}/secrets`) writes them to the Secret, creating it with an owner reference to the App so Kubernetes deletes it with the diff --git a/docs/src/content/docs/ci-cd.md b/docs/src/content/docs/ci-cd.md index 8ec3ac6..8dd736c 100644 --- a/docs/src/content/docs/ci-cd.md +++ b/docs/src/content/docs/ci-cd.md @@ -29,7 +29,7 @@ Every action is pinned to a commit SHA, and every workflow starts from ## Images -`images.yaml` builds the five images (`operator`, `authz`, `api`, `ui`, `mcp`). Pull requests build each image once, on `linux/amd64`, without +`images.yaml` builds the five images (`operator`, `proxy`, `api`, `ui`, `mcp`). Pull requests build each image once, on `linux/amd64`, without pushing. Every merge to `main` builds them for `linux/amd64` and `linux/arm64` through the org's reusable `pack-build-image` workflow, pushes `ghcr.io/nebari-dev/apps-pack/` and `quay.io/nebari/apps-pack-`, tagged `sha-` and `latest`, and signs diff --git a/docs/src/content/docs/configuration.md b/docs/src/content/docs/configuration.md index 43871d7..d1bb652 100644 --- a/docs/src/content/docs/configuration.md +++ b/docs/src/content/docs/configuration.md @@ -29,14 +29,14 @@ only `clusterDomain` and `keycloak.url` need changing. | `apps.image.allowedRegistries` | `[]` (image apps off) | Registries, optionally narrowed to a repository prefix, that `source.type: image` Apps may pull from. See [Image registries](#image-registries). | | `apps.image.pullSecrets` | `[]` | Names of `kubernetes.io/dockerconfigjson` Secrets image App pods pull with (private registries). Each must exist in every App namespace. | | `apps.limitRange.enabled` | `false` | Render a `LimitRange` (`default`, `defaultRequest`, `max`) into every `api.allowedNamespaces` namespace. The namespaces must exist. | -| `apps.idle.timeoutMinutes` | `0` | Cluster default for [scale to zero](/app-crd-reference/#scale-to-zero): an App that goes this many minutes without a request is scaled to zero (phase `Idle`) and wakes on the next request to its URL. `0` keeps apps running unless they set `runtime.idleTimeoutMinutes`; otherwise `5`–`43200`. Apps opt out with `runtime.keepAlive: true`. Apps that can idle run the authz sidecar (`authz.resources`) in passthrough mode, which records their activity. | +| `apps.idle.timeoutMinutes` | `0` | Cluster default for [scale to zero](/app-crd-reference/#scale-to-zero): an App that goes this many minutes without a request is scaled to zero (phase `Idle`) and wakes on the next request to its URL. `0` keeps apps running unless they set `runtime.idleTimeoutMinutes`; otherwise `5`–`43200`. Apps opt out with `runtime.keepAlive: true`. Apps that can idle run the proxy sidecar (`proxy.resources`), which records their activity. | | `apps.networkPolicy.enabled` | `false` | Render a `NetworkPolicy` for launched app pods in every `api.allowedNamespaces` namespace: ingress only from `gatewayNamespace` on port 8080, plus from the operator pods on port 8082 (the sidecar's health port, which the idle check reads); egress only to DNS and to the internet minus `blockedCIDRs` (private, CGNAT and link-local ranges by default), so app code cannot reach cluster-internal services. Needs a CNI that enforces NetworkPolicy. | | `apps.landingPage.enabled` | `false` | Cluster default for registering launched apps as tiles on the Nebari landing page. Off: an app appears only if it sets `spec.landingPage.enabled: true`. On: every app gets a tile unless it sets `enabled: false`. Apps are reachable at their URL and listed in the Apps UI either way. | | `staticImage` | `nginxinc/nginx-unprivileged:1.27-alpine` | Serves static app content; must listen on 8080 as non-root. The operator mounts its nginx server config (security headers, `try_files`) over `/etc/nginx/conf.d`, so a replacement image should be nginx-based or ignore that directory. | | `gitImage` | `alpine/git:v2.47.2` | Init-container image that clones `git` sources. | | `pythonImage` | `ghcr.io/prefix-dev/pixi:0.68.1-noble` | Runs Python/pixi apps (`runtime.pixiTask`); must provide `pixi` and run as non-root. | -| `authz.image.repository` / `tag` / `pullPolicy` | `ghcr.io/nebari-dev/apps-pack/authz` / appVersion / `IfNotPresent` | The sidecar the operator adds to apps that can idle and to image apps: it takes port 8080, proxies every request to the app and records when the app last served one. It checks no tokens; `access.groups` are enforced at the gateway (see [Access control](/launching-apps/#access-control)). | -| `authz.resources` | `10m`/`32Mi` requests, `100m`/`64Mi` limits | The sidecar's requests/limits, on top of the app container's, in every pod that runs it. | +| `proxy.image.repository` / `tag` / `pullPolicy` | `ghcr.io/nebari-dev/apps-pack/proxy` / appVersion / `IfNotPresent` | The sidecar the operator adds to apps that can idle and to image apps: it takes port 8080, proxies every request to the app and records when the app last served one. It checks no tokens; `access.groups` are enforced at the gateway (see [Access control](/launching-apps/#access-control)). | +| `proxy.resources` | `10m`/`32Mi` requests, `100m`/`64Mi` limits | The sidecar's requests/limits, on top of the app container's, in every pod that runs it. | ### Image registries diff --git a/docs/src/content/docs/getting-started.md b/docs/src/content/docs/getting-started.md index 29a65fe..dcfe8d6 100644 --- a/docs/src/content/docs/getting-started.md +++ b/docs/src/content/docs/getting-started.md @@ -48,7 +48,7 @@ helm install nebari-apps charts/nebari-apps \ --set keycloak.url=https://keycloak.example.ai/auth \ --set operator.image.tag=latest --set api.image.tag=latest \ --set ui.image.tag=latest --set mcp.image.tag=latest \ - --set authz.image.tag=latest + --set proxy.image.tag=latest # The nebari-operator only processes namespaces that opt in: kubectl label namespace nebari-apps nebari.dev/managed=true @@ -71,7 +71,7 @@ with the same values (edit the two hostnames and `targetRevision`). kubectl apply -n argocd -f examples/argocd-application.yaml ``` -This deploys four components. A fifth image, **apps-authz**, is not a Deployment: the +This deploys four components. A fifth image, **apps-proxy**, is not a Deployment: the operator adds it as a sidecar to Apps that can idle or that run an image. | Component | Where it ends up | @@ -198,7 +198,7 @@ or pilot. request after idling gets a "starting" page until a pod is ready: seconds for a static app, up to minutes for a Python app, which reinstalls its pixi environment. Any request to an idle App's URL wakes it, including bots on public apps (the gateway still keeps users outside a - restricted App's groups out). Apps that can idle carry the authz sidecar, and Python apps there listen on + restricted App's groups out). Apps that can idle carry the proxy sidecar, and Python apps there listen on 8081. Idle detection counts HTTP requests through the sidecar only, so an app that works in the background with no visitors needs `runtime.keepAlive: true`. See [Scale to zero](/app-crd-reference/#scale-to-zero). diff --git a/docs/src/content/docs/launching-apps.md b/docs/src/content/docs/launching-apps.md index 68019ab..3638227 100644 --- a/docs/src/content/docs/launching-apps.md +++ b/docs/src/content/docs/launching-apps.md @@ -145,7 +145,7 @@ The contract: `pixi install` (with `--locked` when a `pixi.lock` is present) and then `pixi run `. - The task must start a server listening on **0.0.0.0:$PORT**; `PORT` and `HOME=/app` - are injected. `PORT` is 8080, or 8081 when the authz sidecar takes 8080 (the app can + are injected. `PORT` is 8080, or 8081 when the proxy sidecar takes 8080 (the app can [idle](#idle-apps)), so read it rather than hard-coding a port. pixi task strings support `$PORT` but not `${PORT:-8080}`; run `PORT=8080 pixi run start` locally. Routing, TLS, and SSO are identical to static apps. diff --git a/docs/src/content/docs/local-development.md b/docs/src/content/docs/local-development.md index df3fa26..016ded0 100644 --- a/docs/src/content/docs/local-development.md +++ b/docs/src/content/docs/local-development.md @@ -39,7 +39,7 @@ open http://docs-site.apps.nebari.test # the example app `admin` / `nebari-admin`), and the nebari-operator itself. Pins the gateway Service to fixed NodePorts and creates the `apps` namespace labeled `nebari.dev/managed=true`. 3. **`images`** — builds `apps-operator:dev`, `apps-api:dev`, `apps-ui:dev`, - `apps-mcp:dev` and `apps-authz:dev` and loads them into the kind cluster. + `apps-mcp:dev` and `apps-proxy:dev` and loads them into the kind cluster. 4. **`deploy`** — installs the chart with `clusterDomain=nebari.test` and **`tls.enabled=false`** (plain HTTP — no certificate warnings locally). 5. Applies the inline example App and waits for `Running`. diff --git a/docs/src/content/docs/mcp.md b/docs/src/content/docs/mcp.md index 2f853cb..0cae2ef 100644 --- a/docs/src/content/docs/mcp.md +++ b/docs/src/content/docs/mcp.md @@ -146,7 +146,7 @@ their paths mirror the REST API (`/api/v1/apps/{namespace}/{name}/…`): | `nebari-apps://apps` | The apps you own or manage (all apps for admins); inline sources list file `paths` only. | `list_apps` | | `nebari-apps://apps/{namespace}/{name}` | One app's full spec and status, with your `role`; inline files fenced. | `get_app` | | `nebari-apps://apps/{namespace}/{name}/status` | Phase, URL, replicas, conditions, message. | `get_app_status` | -| `nebari-apps://apps/{namespace}/{name}/logs{?lines,container}` | Recent pod logs, fenced (last 64 KiB). `lines` is 1–5000 (default 200); `container` defaults to `app`, and `git-clone`, `copy-source` or `authz` read the init containers or the sidecar, e.g. `…/logs?lines=500&container=git-clone`. | `get_app_logs` | +| `nebari-apps://apps/{namespace}/{name}/logs{?lines,container}` | Recent pod logs, fenced (last 64 KiB). `lines` is 1–5000 (default 200); `container` defaults to `app`, and `git-clone`, `copy-source` or `proxy` read the init containers or the sidecar, e.g. `…/logs?lines=500&container=git-clone`. | `get_app_logs` | | `nebari-apps://apps/{namespace}/{name}/events` | Kubernetes events for the app and its workload, newest first, fenced as one JSON document (capped at 32 KiB). | — | `resources/list` returns the two fixed resources plus one `nebari-apps://apps/{namespace}/{name}` diff --git a/docs/src/content/docs/runbook.md b/docs/src/content/docs/runbook.md index 4d38cd9..6be7a74 100644 --- a/docs/src/content/docs/runbook.md +++ b/docs/src/content/docs/runbook.md @@ -92,7 +92,7 @@ kubectl -n nebari-operator-system logs deploy/nebari-operator-controller-manager ## Group access `access.groups` are enforced at the gateway, by the group rule nebari-operator adds to each -restricted App's SecurityPolicy (`%NEBARI_OPERATOR_VERSION%` or later). The apps-authz sidecar +restricted App's SecurityPolicy (`%NEBARI_OPERATOR_VERSION%` or later). The apps-proxy sidecar in app pods checks nothing. ```bash @@ -127,7 +127,7 @@ kubectl -n nebari-apps logs deploy/nebari-apps-operator --since=30m | grep -E 'i ``` - **An App never idles.** Check that it can idle at all: `keepAlive` unset, `replicas` above 0, - a timeout above 0, and the pod has the `authz` container. Then look for `idle check failed` + a timeout above 0, and the pod has the `proxy` container. Then look for `idle check failed` in the operator log: the operator could not read the sidecar's activity (port 8082 on the pod IP) and kept the App running; `LAST ACTIVE` stays at the last request it did see. A NetworkPolicy that blocks the operator's namespace from port 8082 is the usual cause; the diff --git a/docs/src/content/docs/troubleshooting.md b/docs/src/content/docs/troubleshooting.md index 1146b9f..e55e113 100644 --- a/docs/src/content/docs/troubleshooting.md +++ b/docs/src/content/docs/troubleshooting.md @@ -62,7 +62,7 @@ kubectl -n logs deploy/app- -c app # the app itself `pixi.lock` makes the install `--locked` and faster. - **Python app is `Running` but never ready.** The task must listen on `0.0.0.0:$PORT` (the pod gets `PORT=8080`). A server bound to `127.0.0.1` fails the readiness probe. An App - that can idle gets `PORT=8081`, because the authz sidecar takes 8080: a task that + that can idle gets `PORT=8081`, because the proxy sidecar takes 8080: a task that hard-codes 8080 then collides with the sidecar. - **A restricted app returns `403` to someone who should get in.** The gateway refused them: their access token's `groups` claim holds none of the App's `access.groups`. Entries are @@ -83,12 +83,17 @@ kubectl -n logs deploy/app- -c app # the app itself create it. `GroupsEmpty` means every entry names no group (such as `/`), and `GroupsClaimConflict` that a mapper on the app's Keycloak client writes the `groups` claim in another form; the gateway denies everyone until it is fixed. -- **An app pod's `authz` container exits with "AUTHZ_GROUPS is set".** The authz image is - newer than the apps-operator, which still asks the sidecar to check groups. The sidecar - refuses rather than run the App open: upgrade the operator to the same release as the - image (the chart ships them together). -- **An image app stays `Failed` with "--authz-image".** Image apps always run the sidecar: - keep the chart's `authz.image`. +- **An app pod's sidecar exits with "AUTHZ_GROUPS is set" or "AUTHZ_UPSTREAM is set".** The + proxy image is newer than the apps-operator, which still configures the sidecar under its + old name (apps-authz) or asks it to check groups. The sidecar refuses rather than run the + App open: upgrade the operator to the same release as the image (the chart ships them + together). Such an operator names the container `authz`, so read it with + `kubectl logs -c authz`. +- **An app pod's `proxy` container exits with "AUTHZ_UPSTREAM is required".** The other way + round: the operator is current but `proxy.image` points at an old apps-authz image (a + mirror that was not updated, say). Point it at the apps-proxy image of the same release. +- **An image app stays `Failed` with "--proxy-image".** Image apps always run the sidecar: + keep the chart's `proxy.image`. - **`CreateContainerConfigError` with `secret "app--secrets" not found` or `couldn't find key`.** The App lists `runtime.secretKeys` that its Secret does not hold. Set the missing values (UI **Secrets**, `PATCH /apps/{ns}/{name}/secrets`), or, for an App diff --git a/docs/src/content/docs/upgrading.md b/docs/src/content/docs/upgrading.md index 6a9dbf9..28bc5df 100644 --- a/docs/src/content/docs/upgrading.md +++ b/docs/src/content/docs/upgrading.md @@ -84,10 +84,34 @@ Status fields in the backup are ignored; the operator recomputes them. ## Version notes +### Upgrading to the proxy sidecar + +**Breaking:** the sidecar in front of Apps that can idle and image Apps is renamed from +apps-authz to apps-proxy. It has checked no tokens or groups since +[gateway group enforcement](#upgrading-to-gateway-group-enforcement); it only proxies requests, +records activity for scale to zero and gives image Apps the front port 8080. Its ports and +behaviour are unchanged. + +- **The image and the chart keys are renamed.** The image is now + `ghcr.io/nebari-dev/apps-pack/proxy` (`quay.io/nebari/apps-pack-proxy`), and the chart's + `authz.*` values are now `proxy.*` (`proxy.image.repository`, `proxy.image.tag`, + `proxy.image.pullPolicy`, `proxy.resources`). If you override them, for example to mirror + the images, move your settings to the new keys. The chart's schema rejects the old `authz` + key, so a values file that still sets it fails the install instead of being ignored. +- **Pods with the sidecar roll once.** The sidecar container is now named `proxy` (and its + probe port `proxy-health`), so every App that can idle or runs an image gets a new pod once. +- **Read its logs with `-c proxy`.** `kubectl logs -c authz` becomes `kubectl logs -c proxy`; + the same goes for the API's and the MCP server's `container` parameter. +- **Upgrade the chart and the images together.** The operator now sets `PROXY_*` variables on + the sidecar. A proxy image refuses to start when it is given the old `AUTHZ_*` ones (or + `AUTHZ_GROUPS`), and an authz image does not start without them, so a mismatched operator + and image leave the App down rather than open. + ### Upgrading to the shorter image names The images dropped their `apps-` prefix: `ghcr.io/nebari-dev/apps-pack/apps-operator` is now -`ghcr.io/nebari-dev/apps-pack/operator`, and the same for `authz`, `api`, `ui` and `mcp` +`ghcr.io/nebari-dev/apps-pack/operator`, and the same for `authz` (now +[`proxy`](#upgrading-to-the-proxy-sidecar)), `api`, `ui` and `mcp` (on Quay, `quay.io/nebari/apps-pack-operator` and so on). The chart's defaults follow, so nothing changes unless you set `*.image.repository` yourself, for example to mirror the images: point those values at the new names. Images built before the rename stay at the old @@ -259,7 +283,7 @@ UI and the apps-authz sidecar (`AUTHZ_USERS`, `AUTHZ_USERS_CLAIM`). the apps-authz sidecar and returns `403` to everyone else, so check who your existing Apps list. Python tasks of restricted Apps must listen on `$PORT` (8081 for them). The operator needs the chart's `keycloak.url` (or `keycloak.internalUrl`), or such Apps fail - validation; `authz.image` ships with the chart. + validation; the sidecar image (`proxy.image`) ships with the chart. - **App pods get default requests and limits** (`apps.defaultResources`) and at most 10 replicas. - **Auth is stricter.** Tokens must come from the pack's own Keycloak clients, and diff --git a/examples/web-terminal/pixi.toml b/examples/web-terminal/pixi.toml index 9e4dec7..3a8f94e 100644 --- a/examples/web-terminal/pixi.toml +++ b/examples/web-terminal/pixi.toml @@ -43,7 +43,7 @@ nss_wrapper = "*" [tasks] # The platform runs `pixi run start` and expects a server on 0.0.0.0:$PORT: -# 8080, or 8081 when the cluster idles apps (the authz sidecar takes 8080). Locally: +# 8080, or 8081 when the cluster idles apps (the proxy sidecar takes 8080). Locally: # PORT=8080 pixi run start # -W makes the terminal writable (ttyd defaults to read-only). terminal.sh # runs per browser session: identity setup, then an interactive bash that diff --git a/mcp/src/nebari_apps_mcp/server.py b/mcp/src/nebari_apps_mcp/server.py index 981c593..9ceea61 100644 --- a/mcp/src/nebari_apps_mcp/server.py +++ b/mcp/src/nebari_apps_mcp/server.py @@ -636,7 +636,7 @@ async def get_app_metrics(ctx: Context, namespace: str, name: str) -> Any: restarts, lastTermination, startedAt, limits, requests}]}. cpu (millicores, "12m") and memory (Mi, "34Mi") are instantaneous usage from metrics.k8s.io; available=false means the cluster has no metrics server and they are "". The rest comes from - the pod and is always filled, all about the `app` container (the authz sidecar is left out): + the pod and is always filled, all about the `app` container (the proxy sidecar is left out): restarts (its restart count), lastTermination ({reason, exitCode, finishedAt} of its previous run or null; reason is a Kubernetes identifier such as OOMKilled or Error, never the container's own message), startedAt (when it started running, null while it is not), and @@ -777,7 +777,7 @@ async def app_status_resource(namespace: str, name: str, ctx: Context) -> Any: f"{SCHEME}://apps/{{namespace}}/{{name}}/logs{{?lines,container}}", name="app-logs", description="Recent pod logs (fenced untrusted content, last 64 KiB). Query: lines (1-5000, default 200) " - "and container (default app; git-clone or copy-source for the init containers, authz for the sidecar).", + "and container (default app; git-clone or copy-source for the init containers, proxy for the sidecar).", mime_type=JSON, annotations=READ_ONLY_RESOURCE, ) diff --git a/operator/api/v1alpha1/app_types.go b/operator/api/v1alpha1/app_types.go index 23a22a3..2eb9603 100644 --- a/operator/api/v1alpha1/app_types.go +++ b/operator/api/v1alpha1/app_types.go @@ -54,7 +54,7 @@ type PVCSource struct { // ImageSource runs a prebuilt container image as the app container, with the // same non-root, capability-free, resource-limited pod spec as other Apps and -// the apps-authz sidecar in front of it. +// the apps-proxy sidecar in front of it. type ImageSource struct { // Reference is the image to run, with a tag or a digest: // ghcr.io/org/app:1.2.3 or ghcr.io/org/app@sha256:<64 hex>. Docker Hub @@ -68,11 +68,11 @@ type ImageSource struct { Reference string `json:"reference"` // Port the image's server listens on (on 0.0.0.0 or 127.0.0.1; also - // passed as $PORT). The apps-authz sidecar always fronts image Apps on + // passed as $PORT). The apps-proxy sidecar always fronts image Apps on // 8080 and serves its probes on 8082, so the app may use neither. // +kubebuilder:validation:Minimum=1024 // +kubebuilder:validation:Maximum=65535 - // +kubebuilder:validation:XValidation:rule="self != 8080 && self != 8082",message="source.image.port may not be 8080 or 8082: the apps-authz sidecar uses them" + // +kubebuilder:validation:XValidation:rule="self != 8080 && self != 8082",message="source.image.port may not be 8080 or 8082: the apps-proxy sidecar uses them" Port int32 `json:"port"` // Command replaces the image's ENTRYPOINT (exec form, no shell). @@ -175,7 +175,7 @@ type AppRuntime struct { // environment from the app source and executes `pixi run `. // Not allowed with source type image, which runs its own server. // The task must start a server on $PORT: 8080, or 8081 when the app can - // idle (the authz sidecar takes 8080). + // idle (the proxy sidecar takes 8080). // +kubebuilder:validation:MaxLength=64 // +optional PixiTask string `json:"pixiTask,omitempty"` diff --git a/operator/cmd/main.go b/operator/cmd/main.go index 78ab51d..3b01c6e 100644 --- a/operator/cmd/main.go +++ b/operator/cmd/main.go @@ -52,11 +52,11 @@ func main() { "Image used by init containers to fetch git sources.") flag.StringVar(&cfg.PythonImage, "python-image", envOr("PYTHON_IMAGE", "ghcr.io/prefix-dev/pixi:0.68.1-noble"), "Image running Python/pixi apps (runtime.pixiTask); must provide pixi and run as non-root.") - flag.StringVar(&cfg.AuthzImage, "authz-image", envOr("AUTHZ_IMAGE", ""), - "apps-authz sidecar image, added to Apps that can idle and to image Apps (required for those Apps).") - var authzPullPolicy string - flag.StringVar(&authzPullPolicy, "authz-image-pull-policy", envOr("AUTHZ_IMAGE_PULL_POLICY", ""), - "Pull policy for the authz sidecar image (Always|IfNotPresent|Never; empty: the Kubernetes default).") + flag.StringVar(&cfg.ProxyImage, "proxy-image", envOr("PROXY_IMAGE", ""), + "apps-proxy sidecar image, added to Apps that can idle and to image Apps (required for those Apps).") + var proxyPullPolicy string + flag.StringVar(&proxyPullPolicy, "proxy-image-pull-policy", envOr("PROXY_IMAGE_PULL_POLICY", ""), + "Pull policy for the proxy sidecar image (Always|IfNotPresent|Never; empty: the Kubernetes default).") flag.BoolVar(&cfg.TLSDisabled, "tls-disabled", envOr("TLS_DISABLED", "") == "true", "Serve apps over plain HTTP (no certificates, no HTTPS listeners).") flag.BoolVar(&cfg.LandingPage, "landing-page", envOr("LANDING_PAGE", "") == "true", @@ -80,13 +80,13 @@ func main() { "Comma-separated names of Secrets (in each App namespace) that image Apps pull with.") opts := zap.Options{Development: false} - var staticResources, pythonResources, authzResources string + var staticResources, pythonResources, proxyResources string flag.StringVar(&staticResources, "default-static-resources", envOr("DEFAULT_STATIC_RESOURCES", ""), `Requests/limits (JSON, e.g. {"requests":{"cpu":"50m"},"limits":{"memory":"256Mi"}}) for static app pods that leave them unset.`) flag.StringVar(&pythonResources, "default-python-resources", envOr("DEFAULT_PYTHON_RESOURCES", ""), "Requests/limits (JSON) for Python/pixi app pods that leave them unset.") - flag.StringVar(&authzResources, "authz-resources", envOr("AUTHZ_RESOURCES", ""), - "Requests/limits (JSON) for the authz sidecar.") + flag.StringVar(&proxyResources, "proxy-resources", envOr("PROXY_RESOURCES", ""), + "Requests/limits (JSON) for the proxy sidecar.") opts.BindFlags(flag.CommandLine) flag.Parse() @@ -102,15 +102,15 @@ func main() { os.Exit(1) } - if cfg.AuthzResources, err = controller.ParseResources(authzResources, controller.DefaultAuthzResources); err != nil { - setupLog.Error(err, "invalid --authz-resources") + if cfg.ProxyResources, err = controller.ParseResources(proxyResources, controller.DefaultProxyResources); err != nil { + setupLog.Error(err, "invalid --proxy-resources") os.Exit(1) } - switch p := corev1.PullPolicy(authzPullPolicy); p { + switch p := corev1.PullPolicy(proxyPullPolicy); p { case "", corev1.PullAlways, corev1.PullIfNotPresent, corev1.PullNever: - cfg.AuthzImagePullPolicy = p + cfg.ProxyImagePullPolicy = p default: - setupLog.Error(nil, "invalid --authz-image-pull-policy", "value", authzPullPolicy) + setupLog.Error(nil, "invalid --proxy-image-pull-policy", "value", proxyPullPolicy) os.Exit(1) } @@ -129,8 +129,8 @@ func main() { os.Exit(1) } cfg.IdleTimeout = time.Duration(idleMinutes) * time.Minute - if cfg.IdleTimeout > 0 && (cfg.ActivatorService == "" || cfg.ActivatorNamespace == "" || cfg.AuthzImage == "") { - setupLog.Error(nil, "--idle-timeout-minutes needs --activator-service, --activator-namespace and --authz-image") + if cfg.IdleTimeout > 0 && (cfg.ActivatorService == "" || cfg.ActivatorNamespace == "" || cfg.ProxyImage == "") { + setupLog.Error(nil, "--idle-timeout-minutes needs --activator-service, --activator-namespace and --proxy-image") os.Exit(1) } @@ -185,7 +185,7 @@ func main() { setupLog.Info("starting manager", "appsDomain", cfg.AppsDomain, "gateway", cfg.Gateway, - "authzImage", cfg.AuthzImage, + "proxyImage", cfg.ProxyImage, "idleTimeout", cfg.IdleTimeout.String(), "activatorService", cfg.ActivatorNamespace+"/"+cfg.ActivatorService, "imageRegistries", cfg.ImageRegistries) if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { diff --git a/operator/internal/controller/app_controller.go b/operator/internal/controller/app_controller.go index 5bba19f..d1466f1 100644 --- a/operator/internal/controller/app_controller.go +++ b/operator/internal/controller/app_controller.go @@ -58,21 +58,21 @@ type OperatorConfig struct { // limit an App leaves unset (static apps vs runtime.pixiTask apps). StaticResources corev1.ResourceRequirements PythonResources corev1.ResourceRequirements - // AuthzImage is the apps-authz sidecar added in front of Apps that can - // idle and of image Apps (authz.go). - AuthzImage string - // AuthzImagePullPolicy is the sidecar's pull policy (empty: the + // ProxyImage is the apps-proxy sidecar added in front of Apps that can + // idle and of image Apps (proxy.go). + ProxyImage string + // ProxyImagePullPolicy is the sidecar's pull policy (empty: the // Kubernetes default). - AuthzImagePullPolicy corev1.PullPolicy - // AuthzResources are the sidecar's requests/limits. - AuthzResources corev1.ResourceRequirements + ProxyImagePullPolicy corev1.PullPolicy + // ProxyResources are the sidecar's requests/limits. + ProxyResources corev1.ResourceRequirements // IdleTimeout is the cluster default for scaling an App to zero after // it goes this long without a request (0: Apps never idle unless they // set runtime.idleTimeoutMinutes). See idle.go. IdleTimeout time.Duration // ActivatorService and ActivatorNamespace name the Service in front of // the operator's activator (activator.go), which idle Apps route to. - // Idling needs both, and AuthzImage. + // Idling needs both, and ProxyImage. ActivatorService string ActivatorNamespace string // ImageRegistries is the normalized allowlist for source type image @@ -330,7 +330,7 @@ func (r *AppReconciler) validate(ctx context.Context, app *appsv1alpha1.App) err if app.Spec.Access.Subdomain == "" { return fmt.Errorf("access.subdomain is required") } - if err := r.Config.checkAuthz(app); err != nil { + if err := r.Config.checkProxy(app); err != nil { return err } return r.checkSubdomain(ctx, app) @@ -347,9 +347,9 @@ func (c OperatorConfig) validateImageSource(app *appsv1alpha1.App) error { if app.Spec.Runtime.PixiTask != "" { return fmt.Errorf("runtime.pixiTask cannot be combined with source type image: the image runs its own server") } - if src.Port < 1024 || src.Port > 65535 || src.Port == AppPort || src.Port == authzHealthPort { - return fmt.Errorf("source.image.port %d must be 1024-65535 and not %d or %d (the authz sidecar's ports)", - src.Port, AppPort, authzHealthPort) + if src.Port < 1024 || src.Port > 65535 || src.Port == AppPort || src.Port == proxyHealthPort { + return fmt.Errorf("source.image.port %d must be 1024-65535 and not %d or %d (the proxy sidecar's ports)", + src.Port, AppPort, proxyHealthPort) } if src.RunAsUser != nil && *src.RunAsUser < 1 { return fmt.Errorf("source.image.runAsUser must be a non-zero uid: image apps never run as root") diff --git a/operator/internal/controller/app_controller_test.go b/operator/internal/controller/app_controller_test.go index 91d4950..3514fc3 100644 --- a/operator/internal/controller/app_controller_test.go +++ b/operator/internal/controller/app_controller_test.go @@ -80,8 +80,8 @@ func newReconciler(t *testing.T, objs ...client.Object) (*AppReconciler, client. PythonImage: "ghcr.io/prefix-dev/pixi:0.68.1-noble", StaticResources: DefaultStaticResources, PythonResources: DefaultPythonResources, - AuthzImage: testAuthzImage, - AuthzResources: DefaultAuthzResources, + ProxyImage: testProxyImage, + ProxyResources: DefaultProxyResources, }, } return r, c diff --git a/operator/internal/controller/envtest_test.go b/operator/internal/controller/envtest_test.go index 994addc..357ca43 100644 --- a/operator/internal/controller/envtest_test.go +++ b/operator/internal/controller/envtest_test.go @@ -80,8 +80,8 @@ func startEnv(t *testing.T) client.Client { PythonImage: "ghcr.io/prefix-dev/pixi:0.68.1-noble", StaticResources: DefaultStaticResources, PythonResources: DefaultPythonResources, - AuthzImage: testAuthzImage, - AuthzResources: DefaultAuthzResources, + ProxyImage: testProxyImage, + ProxyResources: DefaultProxyResources, }, } if err := r.SetupWithManager(mgr); err != nil { diff --git a/operator/internal/controller/idle.go b/operator/internal/controller/idle.go index 135df6b..a857be8 100644 --- a/operator/internal/controller/idle.go +++ b/operator/internal/controller/idle.go @@ -24,7 +24,7 @@ import ( appsv1alpha1 "github.com/nebari-dev/nebari-apps-pack/operator/api/v1alpha1" ) -// Scale to zero. An App that can idle (idleTimeout > 0) runs the apps-authz +// Scale to zero. An App that can idle (idleTimeout > 0) runs the apps-proxy // sidecar, which records when the app last served a request (GET /activity // on its health port). The operator polls it, keeps the answer in // status.lastActivityTime, and once the App has gone a whole idle timeout @@ -56,7 +56,7 @@ var ReferenceGrantGVK = schema.GroupVersionKind{ // idlingAvailable reports whether this operator can scale Apps to zero: it // needs the sidecar image (activity) and the activator Service (wake-up). func (c OperatorConfig) idlingAvailable() bool { - return c.AuthzImage != "" && c.ActivatorService != "" && c.ActivatorNamespace != "" + return c.ProxyImage != "" && c.ActivatorService != "" && c.ActivatorNamespace != "" } // idleTimeout is how long an App may go without a request before it is @@ -230,7 +230,7 @@ func podReady(pod *corev1.Pod) bool { } func (r *AppReconciler) fetchActivity(ctx context.Context, podIP string) (time.Time, error) { - url := "http://" + net.JoinHostPort(podIP, strconv.Itoa(authzHealthPort)) + "/activity" + url := "http://" + net.JoinHostPort(podIP, strconv.Itoa(proxyHealthPort)) + "/activity" if r.ActivityURL != nil { url = r.ActivityURL(podIP) } diff --git a/operator/internal/controller/idle_test.go b/operator/internal/controller/idle_test.go index 386bb0b..1e04d1b 100644 --- a/operator/internal/controller/idle_test.go +++ b/operator/internal/controller/idle_test.go @@ -124,7 +124,7 @@ func deploymentReplicas(t *testing.T, c client.Client, app *appsv1alpha1.App) in } func TestIdleTimeoutPolicy(t *testing.T) { - on := OperatorConfig{AuthzImage: testAuthzImage, ActivatorService: activatorSvc, ActivatorNamespace: activatorNS, + on := OperatorConfig{ProxyImage: testProxyImage, ActivatorService: activatorSvc, ActivatorNamespace: activatorNS, IdleTimeout: 30 * time.Minute} app := inlineApp("team-a") if got := on.idleTimeout(app); got != 30*time.Minute { @@ -168,8 +168,8 @@ func TestIdleAppGetsPassthroughSidecar(t *testing.T) { t.Fatal(err) } pod := d.Spec.Template.Spec - if len(pod.Containers) != 2 || pod.Containers[0].Name != "app" || pod.Containers[1].Name != "authz" { - t.Fatalf("containers = %v, want app then authz", names(pod.Containers)) + if len(pod.Containers) != 2 || pod.Containers[0].Name != "app" || pod.Containers[1].Name != "proxy" { + t.Fatalf("containers = %v, want app then proxy", names(pod.Containers)) } env := map[string]string{} for _, e := range pod.Containers[1].Env { diff --git a/operator/internal/controller/image_test.go b/operator/internal/controller/image_test.go index f285bdf..34f555a 100644 --- a/operator/internal/controller/image_test.go +++ b/operator/internal/controller/image_test.go @@ -77,8 +77,8 @@ func TestReconcileImageApp(t *testing.T) { if len(pod.Volumes) != 0 || len(pod.InitContainers) != 0 { t.Errorf("image pods need no volumes or init containers: %+v %+v", pod.Volumes, pod.InitContainers) } - if names(pod.Containers)[0] != "app" || len(pod.Containers) != 2 || pod.Containers[1].Name != authzContainer { - t.Fatalf("containers = %v, want app then the authz sidecar", names(pod.Containers)) + if names(pod.Containers)[0] != "app" || len(pod.Containers) != 2 || pod.Containers[1].Name != proxyContainer { + t.Fatalf("containers = %v, want app then the proxy sidecar", names(pod.Containers)) } main := pod.Containers[0] if main.Image != "ghcr.io/org/dash:1.2.3" || main.ImagePullPolicy != corev1.PullAlways { @@ -113,7 +113,7 @@ func TestReconcileImageApp(t *testing.T) { t.Errorf("readiness probe should go through the sidecar: %+v", main.ReadinessProbe) } side := envOf(pod.Containers[1]) - if side["AUTHZ_UPSTREAM"] != "http://127.0.0.1:8501" || len(side) != 3 { + if side["PROXY_UPSTREAM"] != "http://127.0.0.1:8501" || len(side) != 3 { t.Errorf("sidecar env = %v", side) } if pod.Containers[1].Ports[0].Name != "http" || pod.Containers[1].Ports[0].ContainerPort != AppPort { @@ -141,7 +141,7 @@ func TestImageAppRestrictedAndDigest(t *testing.T) { // The sidecar still fronts the image's port; the gateway enforces the // groups, so it gets no group settings. side := envOf(pod.Containers[1]) - if side["AUTHZ_UPSTREAM"] != "http://127.0.0.1:8501" || side["AUTHZ_GROUPS"] != "" { + if side["PROXY_UPSTREAM"] != "http://127.0.0.1:8501" || side["AUTHZ_GROUPS"] != "" { t.Errorf("restricted image app sidecar env = %v", side) } } @@ -279,13 +279,13 @@ func TestImagePullFailure(t *testing.T) { } } -func TestImageAppNeedsAuthzImage(t *testing.T) { +func TestImageAppNeedsProxyImage(t *testing.T) { app := imageApp("apps", "ghcr.io/org/dash:1") r, c, _ := imageReconciler(t, managedNamespace("apps"), app) - r.Config.AuthzImage = "" + r.Config.ProxyImage = "" reconcile(t, r, app) if got := getApp(t, c, app.Name, app.Namespace); got.Status.Phase != appsv1alpha1.AppPhaseFailed || - !strings.Contains(got.Status.Message, "--authz-image") { + !strings.Contains(got.Status.Message, "--proxy-image") { t.Errorf("phase %s message %q", got.Status.Phase, got.Status.Message) } } diff --git a/operator/internal/controller/authz.go b/operator/internal/controller/proxy.go similarity index 76% rename from operator/internal/controller/authz.go rename to operator/internal/controller/proxy.go index 83fd277..226c28b 100644 --- a/operator/internal/controller/authz.go +++ b/operator/internal/controller/proxy.go @@ -12,7 +12,7 @@ import ( appsv1alpha1 "github.com/nebari-dev/nebari-apps-pack/operator/api/v1alpha1" ) -// The apps-authz sidecar sits in front of the app container of Apps that can +// The apps-proxy sidecar sits in front of the app container of Apps that can // idle (it records request activity for the idle check, idle.go) and of image // Apps (it gives the image's own port the pack's one front port, AppPort). It // takes AppPort and proxies every request to the app container on @@ -22,17 +22,17 @@ import ( const ( // AppInternalPort is where the app container listens behind the sidecar. AppInternalPort = 8081 - // authzHealthPort serves the sidecar's token-free probe endpoints. - authzHealthPort = 8082 + // proxyHealthPort serves the sidecar's token-free probe endpoints. + proxyHealthPort = 8082 - authzContainer = "authz" - authzHealthName = "authz-health" + proxyContainer = "proxy" + proxyHealthName = "proxy-health" appInternalName = "app-http" - authzUID = int64(65532) + proxyUID = int64(65532) ) -// DefaultAuthzResources bound the sidecar when the operator is given none. -var DefaultAuthzResources = corev1.ResourceRequirements{ +// DefaultProxyResources bound the sidecar when the operator is given none. +var DefaultProxyResources = corev1.ResourceRequirements{ Requests: corev1.ResourceList{ corev1.ResourceCPU: resource.MustParse("10m"), corev1.ResourceMemory: resource.MustParse("32Mi"), @@ -50,7 +50,7 @@ func restricted(app *appsv1alpha1.App) bool { return !a.Public && len(a.Groups) > 0 } -// hasSidecar reports whether the App's pod runs the apps-authz sidecar: Apps +// hasSidecar reports whether the App's pod runs the apps-proxy sidecar: Apps // that can idle (it records activity for the idle check) and image Apps (it // gives their own port the pack's one front port, AppPort). func (c OperatorConfig) hasSidecar(app *appsv1alpha1.App) bool { @@ -73,13 +73,13 @@ func (c OperatorConfig) appContainerPort(app *appsv1alpha1.App) int32 { return AppPort } -// checkAuthz reports why an App cannot run as specified, so it fails +// checkProxy reports why an App cannot run as specified, so it fails // validation instead: an image App needs the sidecar, and every access.groups // entry must name a group (nebari-operator denies every request when all // entries normalize to nothing, such as "/"). -func (c OperatorConfig) checkAuthz(app *appsv1alpha1.App) error { - if isImage(app) && c.AuthzImage == "" { - return fmt.Errorf("image sources need the authz sidecar, but the operator has no --authz-image (chart authz.image)") +func (c OperatorConfig) checkProxy(app *appsv1alpha1.App) error { + if isImage(app) && c.ProxyImage == "" { + return fmt.Errorf("image sources need the proxy sidecar, but the operator has no --proxy-image (chart proxy.image)") } for _, g := range app.Spec.Access.Groups { if groupPath(g) == "" { @@ -106,13 +106,13 @@ func groupPath(g string) string { return "/" + strings.Join(segments, "/") } -// addAuthzSidecar moves the app container to appContainerPort (AppInternalPort, +// addProxySidecar moves the app container to appContainerPort (AppInternalPort, // or an image App's own port), points its // probes at the sidecar's /upstreamz (which dials the app on loopback, so an // app listening only on 127.0.0.1 still passes) and appends the sidecar, // which takes the "http" port the Service targets. The sidecar checks no // tokens: it proxies every request and records activity. -func (r *AppReconciler) addAuthzSidecar(app *appsv1alpha1.App, pod *corev1.PodSpec) { +func (r *AppReconciler) addProxySidecar(app *appsv1alpha1.App, pod *corev1.PodSpec) { main := &pod.Containers[0] upstreamPort := r.Config.appContainerPort(app) main.Ports = []corev1.ContainerPort{{ @@ -121,10 +121,10 @@ func (r *AppReconciler) addAuthzSidecar(app *appsv1alpha1.App, pod *corev1.PodSp Protocol: corev1.ProtocolTCP, }} // By number, not name: a probe's named port must belong to the probed - // container, and authz-health is the sidecar's. The pod shares one + // container, and proxy-health is the sidecar's. The pod shares one // network namespace, so the kubelet reaches the sidecar on this port. upstreamz := corev1.ProbeHandler{ - HTTPGet: &corev1.HTTPGetAction{Path: "/upstreamz", Port: intstr.FromInt32(authzHealthPort)}, + HTTPGet: &corev1.HTTPGetAction{Path: "/upstreamz", Port: intstr.FromInt32(proxyHealthPort)}, } for _, p := range []*corev1.Probe{main.StartupProbe, main.ReadinessProbe, main.LivenessProbe} { if p != nil { @@ -133,30 +133,30 @@ func (r *AppReconciler) addAuthzSidecar(app *appsv1alpha1.App, pod *corev1.PodSp } healthGet := func(path string) corev1.ProbeHandler { - return corev1.ProbeHandler{HTTPGet: &corev1.HTTPGetAction{Path: path, Port: intstr.FromString(authzHealthName)}} + return corev1.ProbeHandler{HTTPGet: &corev1.HTTPGetAction{Path: path, Port: intstr.FromString(proxyHealthName)}} } env := []corev1.EnvVar{ - {Name: "AUTHZ_LISTEN", Value: fmt.Sprintf(":%d", AppPort)}, - {Name: "AUTHZ_HEALTH_LISTEN", Value: fmt.Sprintf(":%d", authzHealthPort)}, - {Name: "AUTHZ_UPSTREAM", Value: fmt.Sprintf("http://127.0.0.1:%d", upstreamPort)}, + {Name: "PROXY_LISTEN", Value: fmt.Sprintf(":%d", AppPort)}, + {Name: "PROXY_HEALTH_LISTEN", Value: fmt.Sprintf(":%d", proxyHealthPort)}, + {Name: "PROXY_UPSTREAM", Value: fmt.Sprintf("http://127.0.0.1:%d", upstreamPort)}, } pod.Containers = append(pod.Containers, corev1.Container{ - Name: authzContainer, - Image: r.Config.AuthzImage, - ImagePullPolicy: r.Config.AuthzImagePullPolicy, + Name: proxyContainer, + Image: r.Config.ProxyImage, + ImagePullPolicy: r.Config.ProxyImagePullPolicy, Ports: []corev1.ContainerPort{ {Name: "http", ContainerPort: AppPort, Protocol: corev1.ProtocolTCP}, - {Name: authzHealthName, ContainerPort: authzHealthPort, Protocol: corev1.ProtocolTCP}, + {Name: proxyHealthName, ContainerPort: proxyHealthPort, Protocol: corev1.ProtocolTCP}, }, Env: env, - Resources: *r.Config.AuthzResources.DeepCopy(), + Resources: *r.Config.ProxyResources.DeepCopy(), ReadinessProbe: &corev1.Probe{ProbeHandler: healthGet("/readyz"), PeriodSeconds: 5}, LivenessProbe: &corev1.Probe{ProbeHandler: healthGet("/healthz"), PeriodSeconds: 10}, SecurityContext: &corev1.SecurityContext{ RunAsNonRoot: ptr.To(true), - RunAsUser: ptr.To(authzUID), - RunAsGroup: ptr.To(authzUID), + RunAsUser: ptr.To(proxyUID), + RunAsGroup: ptr.To(proxyUID), AllowPrivilegeEscalation: ptr.To(false), ReadOnlyRootFilesystem: ptr.To(true), Capabilities: &corev1.Capabilities{Drop: []corev1.Capability{"ALL"}}, diff --git a/operator/internal/controller/authz_test.go b/operator/internal/controller/proxy_test.go similarity index 89% rename from operator/internal/controller/authz_test.go rename to operator/internal/controller/proxy_test.go index 4fcdf44..1e16a6a 100644 --- a/operator/internal/controller/authz_test.go +++ b/operator/internal/controller/proxy_test.go @@ -14,7 +14,7 @@ import ( appsv1alpha1 "github.com/nebari-dev/nebari-apps-pack/operator/api/v1alpha1" ) -const testAuthzImage = "ghcr.io/nebari-dev/apps-pack/authz:test" +const testProxyImage = "ghcr.io/nebari-dev/apps-pack/proxy:test" func restrictedApp(app *appsv1alpha1.App, groups []string) *appsv1alpha1.App { app.Spec.Access.Public = false @@ -59,19 +59,19 @@ func deployed(t *testing.T, app *appsv1alpha1.App) (*appsv1.Deployment, *corev1. // settings, even for an App that lists access.groups (the gateway enforces // them). var passthroughEnv = map[string]string{ - "AUTHZ_LISTEN": ":8080", - "AUTHZ_HEALTH_LISTEN": ":8082", - "AUTHZ_UPSTREAM": "http://127.0.0.1:8081", + "PROXY_LISTEN": ":8080", + "PROXY_HEALTH_LISTEN": ":8082", + "PROXY_UPSTREAM": "http://127.0.0.1:8081", } -func TestAuthzSidecarStaticApp(t *testing.T) { +func TestProxySidecarStaticApp(t *testing.T) { app := restrictedApp(inlineApp("apps"), []string{"analysts", "admin"}) app.Name = "web-terminal" d, svc, cm, r := deployed(t, app) pod := d.Spec.Template.Spec - if len(pod.Containers) != 2 || pod.Containers[0].Name != "app" || pod.Containers[1].Name != "authz" { - t.Fatalf("containers = %+v, want app then authz", names(pod.Containers)) + if len(pod.Containers) != 2 || pod.Containers[0].Name != "app" || pod.Containers[1].Name != "proxy" { + t.Fatalf("containers = %+v, want app then proxy", names(pod.Containers)) } main, side := pod.Containers[0], pod.Containers[1] if d.Spec.Template.Annotations["kubectl.kubernetes.io/default-container"] != "app" { @@ -82,7 +82,7 @@ func TestAuthzSidecarStaticApp(t *testing.T) { t.Errorf("app ports = %+v, want only %d under another name", main.Ports, AppInternalPort) } for _, p := range []*corev1.Probe{main.ReadinessProbe, main.LivenessProbe} { - if p == nil || p.HTTPGet == nil || p.HTTPGet.Path != "/upstreamz" || p.HTTPGet.Port.IntVal != authzHealthPort { + if p == nil || p.HTTPGet == nil || p.HTTPGet.Path != "/upstreamz" || p.HTTPGet.Port.IntVal != proxyHealthPort { t.Errorf("app probe = %+v, want the sidecar's /upstreamz", p) } } @@ -104,10 +104,10 @@ func TestAuthzSidecarStaticApp(t *testing.T) { if got := envOf(side); !reflect.DeepEqual(got, passthroughEnv) { t.Errorf("sidecar env =\n%v\nwant\n%v", got, passthroughEnv) } - if side.Image != testAuthzImage { + if side.Image != testProxyImage { t.Errorf("sidecar image = %q", side.Image) } - if !reflect.DeepEqual(side.Resources, DefaultAuthzResources) { + if !reflect.DeepEqual(side.Resources, DefaultProxyResources) { t.Errorf("sidecar resources = %+v", side.Resources) } sc := side.SecurityContext @@ -123,7 +123,7 @@ func TestAuthzSidecarStaticApp(t *testing.T) { } } -func TestAuthzSidecarPixiApp(t *testing.T) { +func TestProxySidecarPixiApp(t *testing.T) { app := restrictedApp(pixiApp("apps"), []string{"analysts"}) d, _, _, _ := deployed(t, app) pod := d.Spec.Template.Spec @@ -135,7 +135,7 @@ func TestAuthzSidecarPixiApp(t *testing.T) { t.Errorf("PORT = %q, want 8081", envOf(main)["PORT"]) } for _, p := range []*corev1.Probe{main.StartupProbe, main.ReadinessProbe, main.LivenessProbe} { - if p == nil || p.HTTPGet == nil || p.HTTPGet.Path != "/upstreamz" || p.HTTPGet.Port.IntVal != authzHealthPort || p.TCPSocket != nil { + if p == nil || p.HTTPGet == nil || p.HTTPGet.Path != "/upstreamz" || p.HTTPGet.Port.IntVal != proxyHealthPort || p.TCPSocket != nil { t.Errorf("app probe = %+v, want the sidecar's /upstreamz", p) } } @@ -148,7 +148,7 @@ func TestAuthzSidecarPixiApp(t *testing.T) { } // Apps that neither idle nor run an image get exactly the pod they got before -// the sidecar existed, whatever the authz settings, restricted ones included: +// the sidecar existed, whatever the proxy settings, restricted ones included: // the gateway enforces access.groups. func TestAppsThatNeitherIdleNorRunAnImageHaveNoSidecar(t *testing.T) { publicWithLists := inlineApp("team-a") @@ -166,20 +166,20 @@ func TestAppsThatNeitherIdleNorRunAnImageHaveNoSidecar(t *testing.T) { } { t.Run(name, func(t *testing.T) { r, _ := newReconciler(t) - withAuthz, err := r.buildDeployment(app) + withProxy, err := r.buildDeployment(app) if err != nil { t.Fatal(err) } bare := *r - bare.Config.AuthzImage = "" + bare.Config.ProxyImage = "" without, err := bare.buildDeployment(app) if err != nil { t.Fatal(err) } - if !reflect.DeepEqual(withAuthz, without) { - t.Error("the authz settings changed the App's Deployment") + if !reflect.DeepEqual(withProxy, without) { + t.Error("the proxy settings changed the App's Deployment") } - pod := withAuthz.Spec.Template.Spec + pod := withProxy.Spec.Template.Spec if len(pod.Containers) != 1 || pod.Containers[0].Ports[0].Name != "http" || pod.Containers[0].Ports[0].ContainerPort != AppPort { t.Errorf("pod = %+v", pod.Containers) } @@ -197,7 +197,7 @@ func TestAppsThatNeitherIdleNorRunAnImageHaveNoSidecar(t *testing.T) { } } -func TestAuthzMisconfigurationFailsValidation(t *testing.T) { +func TestProxyMisconfigurationFailsValidation(t *testing.T) { cases := map[string]struct { app *appsv1alpha1.App edit func(*OperatorConfig) @@ -205,8 +205,8 @@ func TestAuthzMisconfigurationFailsValidation(t *testing.T) { }{ "image without sidecar image": {imageApp("team-a", "nginx:1.27"), func(c *OperatorConfig) { c.ImageRegistries = []string{"docker.io/library"} - c.AuthzImage = "" - }, "--authz-image"}, + c.ProxyImage = "" + }, "--proxy-image"}, "blank group": {restrictedApp(inlineApp("team-a"), []string{" "}), func(*OperatorConfig) {}, "must name a group"}, "root group": {restrictedApp(inlineApp("team-a"), []string{"analysts", "/"}), func(*OperatorConfig) {}, "must name a group"}, } diff --git a/operator/internal/controller/secrets_test.go b/operator/internal/controller/secrets_test.go index 747a63d..c09964d 100644 --- a/operator/internal/controller/secrets_test.go +++ b/operator/internal/controller/secrets_test.go @@ -39,7 +39,7 @@ func TestSecretKeysInjectFromTheAppsOwnSecret(t *testing.T) { app := tc.app app.Spec.Runtime.Env = []corev1.EnvVar{{Name: "MODE", Value: "prod"}} app.Spec.Runtime.SecretKeys = []string{"API_KEY", "DATABASE_URL"} - // Can idle, so the pod also runs the authz sidecar. + // Can idle, so the pod also runs the proxy sidecar. r, _ := newReconciler(t) r.Config.IdleTimeout = 30 * time.Minute r.Config.ActivatorService, r.Config.ActivatorNamespace = activatorSvc, activatorNS diff --git a/operator/internal/controller/workload.go b/operator/internal/controller/workload.go index f2ad5c4..37e5764 100644 --- a/operator/internal/controller/workload.go +++ b/operator/internal/controller/workload.go @@ -13,9 +13,9 @@ import ( const ( // AppPort is the port the Service and the chart's NetworkPolicy target. - // The app container listens on it, unless the App runs the authz sidecar + // The app container listens on it, unless the App runs the proxy sidecar // (it can idle, or runs an image): then the sidecar takes it and the app - // moves to AppInternalPort (authz.go). Sign-in and access.groups are + // moves to AppInternalPort (proxy.go). Sign-in and access.groups are // enforced at the gateway by the NebariApp SecurityPolicy, so apps serve // plain HTTP here. AppPort = 8080 @@ -75,7 +75,7 @@ func (r *AppReconciler) buildDeployment(app *appsv1alpha1.App) (*appsv1.Deployme annotations[appsv1alpha1.SecretsVersionAnnotation] = v } if r.Config.hasSidecar(app) { - r.addAuthzSidecar(app, &podSpec) + r.addProxySidecar(app, &podSpec) // kubectl logs/exec pick the app, not the sidecar. annotations["kubectl.kubernetes.io/default-container"] = "app" } @@ -232,7 +232,7 @@ const ( // buildPixiPodSpec runs the app as a Python/pixi service: source is assembled // into a writable emptyDir, then the main container installs the pixi // environment and executes `pixi run `. The task must listen on $PORT -// (AppPort, or AppInternalPort behind the authz sidecar). +// (AppPort, or AppInternalPort behind the proxy sidecar). func (r *AppReconciler) buildPixiPodSpec(app *appsv1alpha1.App, annotations map[string]string) (corev1.PodSpec, error) { containerSecurity := &corev1.SecurityContext{ AllowPrivilegeEscalation: ptr.To(false), @@ -350,7 +350,7 @@ func (r *AppReconciler) buildPixiPodSpec(app *appsv1alpha1.App, annotations map[ // allowlist; the pod runs the normalized reference, so the kubelet pulls // exactly what was checked. The pod keeps the invariants of the other // sources (non-root, no capabilities, no ServiceAccount token, resource -// limits) and always gets the apps-authz sidecar (hasSidecar), which takes +// limits) and always gets the apps-proxy sidecar (hasSidecar), which takes // AppPort and proxies to the image's own port; that keeps one front port for // the Service and the chart's NetworkPolicy whatever port the image uses. func (r *AppReconciler) buildImagePodSpec(app *appsv1alpha1.App) (corev1.PodSpec, error) { @@ -397,7 +397,7 @@ func (r *AppReconciler) buildImagePodSpec(app *appsv1alpha1.App) (corev1.PodSpec }}, Env: env, Resources: r.Config.appResources(app), - // The sidecar points these at its /upstreamz (addAuthzSidecar). + // The sidecar points these at its /upstreamz (addProxySidecar). StartupProbe: &corev1.Probe{ProbeHandler: tcpProbe, PeriodSeconds: 5, FailureThreshold: 60}, ReadinessProbe: &corev1.Probe{ProbeHandler: tcpProbe, PeriodSeconds: 5}, LivenessProbe: &corev1.Probe{ProbeHandler: tcpProbe, PeriodSeconds: 10}, diff --git a/authz/.dockerignore b/proxy/.dockerignore similarity index 100% rename from authz/.dockerignore rename to proxy/.dockerignore diff --git a/authz/.golangci.yml b/proxy/.golangci.yml similarity index 100% rename from authz/.golangci.yml rename to proxy/.golangci.yml diff --git a/authz/Dockerfile b/proxy/Dockerfile similarity index 84% rename from authz/Dockerfile rename to proxy/Dockerfile index d350ea7..bd766e9 100644 --- a/authz/Dockerfile +++ b/proxy/Dockerfile @@ -13,12 +13,12 @@ COPY main.go ./ COPY internal/ internal/ RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ - go build -trimpath -ldflags="-s -w" -o apps-authz . + go build -trimpath -ldflags="-s -w" -o apps-proxy . FROM gcr.io/distroless/static:nonroot@sha256:e2e927ec666bae08560abb3c55d0659eceabb657f56b6782ab500a9fc7f555e3 WORKDIR / -COPY --from=builder /workspace/apps-authz . +COPY --from=builder /workspace/apps-proxy . USER 65532:65532 EXPOSE 8080 8082 -ENTRYPOINT ["/apps-authz"] +ENTRYPOINT ["/apps-proxy"] diff --git a/proxy/go.mod b/proxy/go.mod new file mode 100644 index 0000000..09b7155 --- /dev/null +++ b/proxy/go.mod @@ -0,0 +1,5 @@ +module github.com/nebari-dev/nebari-apps-pack/proxy + +go 1.26.1 + +toolchain go1.27.3 diff --git a/authz/go.sum b/proxy/go.sum similarity index 100% rename from authz/go.sum rename to proxy/go.sum diff --git a/authz/internal/gate/activity.go b/proxy/internal/gate/activity.go similarity index 100% rename from authz/internal/gate/activity.go rename to proxy/internal/gate/activity.go diff --git a/authz/internal/gate/config.go b/proxy/internal/gate/config.go similarity index 58% rename from authz/internal/gate/config.go rename to proxy/internal/gate/config.go index ee0e2f7..be669f6 100644 --- a/authz/internal/gate/config.go +++ b/proxy/internal/gate/config.go @@ -1,4 +1,4 @@ -// Package gate is the apps-authz sidecar: a reverse proxy in front of an app +// Package gate is the apps-proxy sidecar: a reverse proxy in front of an app // container that records when the app last served a request (for the // operator's idle check) and gives an image App's own port the pack's one // front port. It checks no tokens: sign-in and access.groups are enforced at @@ -16,42 +16,49 @@ import ( // writes it into the sidecar's container spec. type Config struct { // Listen is the proxy's address; the Service and the NetworkPolicy - // target it (AUTHZ_LISTEN, default :8080). + // target it (PROXY_LISTEN, default :8080). Listen string // HealthListen serves /healthz, /readyz, /upstreamz and /activity, on // its own port so the app's own paths are never shadowed - // (AUTHZ_HEALTH_LISTEN, default :8082). + // (PROXY_HEALTH_LISTEN, default :8082). HealthListen string // Upstream is the app container, e.g. http://127.0.0.1:8081 - // (AUTHZ_UPSTREAM). + // (PROXY_UPSTREAM). Upstream *url.URL } // removedEnforcement are the variables of the enforcing mode this sidecar // no longer has. An operator older than the sidecar may still set them for // a restricted App; FromEnv refuses to start then, so the App stays down -// instead of running open to every signed-in user. +// instead of running open to every signed-in user. They keep the sidecar's +// old AUTHZ_ prefix on purpose: that is the name such an operator sets, so +// renaming them would let its restricted Apps start open. var removedEnforcement = []string{"AUTHZ_GROUPS", "AUTHZ_ISSUERS", "AUTHZ_JWKS_URL", "AUTHZ_CLIENT_ID"} +// renamedSettings are this sidecar's own settings under its old name +// (apps-authz). An operator from before the rename sets them instead of the +// PROXY_ ones; FromEnv refuses to start then too, so a mismatched operator +// and image fail closed instead of running on defaults nobody asked for. +var renamedSettings = []string{"AUTHZ_LISTEN", "AUTHZ_HEALTH_LISTEN", "AUTHZ_UPSTREAM"} + // FromEnv builds a Config from getenv (os.Getenv in main), reporting every -// missing or invalid variable at once. AUTHZ_PASSTHROUGH, which earlier -// operators set on Apps that only idle, is ignored: every request passes. +// missing or invalid variable at once. func FromEnv(getenv func(string) string) (Config, error) { cfg := Config{ - Listen: orDefault(getenv("AUTHZ_LISTEN"), ":8080"), - HealthListen: orDefault(getenv("AUTHZ_HEALTH_LISTEN"), ":8082"), + Listen: orDefault(getenv("PROXY_LISTEN"), ":8080"), + HealthListen: orDefault(getenv("PROXY_HEALTH_LISTEN"), ":8082"), } var errs []error - if raw := strings.TrimSpace(getenv("AUTHZ_UPSTREAM")); raw == "" { - errs = append(errs, errors.New("AUTHZ_UPSTREAM is required (e.g. http://127.0.0.1:8081)")) + if raw := strings.TrimSpace(getenv("PROXY_UPSTREAM")); raw == "" { + errs = append(errs, errors.New("PROXY_UPSTREAM is required (e.g. http://127.0.0.1:8081)")) } else if u, err := url.Parse(raw); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { - errs = append(errs, fmt.Errorf("AUTHZ_UPSTREAM %q must be an http(s) URL with a host", raw)) + errs = append(errs, fmt.Errorf("PROXY_UPSTREAM %q must be an http(s) URL with a host", raw)) } else { cfg.Upstream = u } if cfg.Listen == cfg.HealthListen { - errs = append(errs, errors.New("AUTHZ_LISTEN and AUTHZ_HEALTH_LISTEN must differ")) + errs = append(errs, errors.New("PROXY_LISTEN and PROXY_HEALTH_LISTEN must differ")) } for _, k := range removedEnforcement { if strings.TrimSpace(getenv(k)) != "" { @@ -59,6 +66,12 @@ func FromEnv(getenv func(string) string) (Config, error) { "access.groups are enforced at the gateway, so upgrade the apps-operator to match this image", k)) } } + for _, k := range renamedSettings { + if strings.TrimSpace(getenv(k)) != "" { + errs = append(errs, fmt.Errorf("%s is set, but this sidecar (apps-proxy, formerly apps-authz) reads PROXY_%s: "+ + "upgrade the apps-operator to match this image", k, strings.TrimPrefix(k, "AUTHZ_"))) + } + } return cfg, errors.Join(errs...) } diff --git a/authz/internal/gate/gate_test.go b/proxy/internal/gate/gate_test.go similarity index 89% rename from authz/internal/gate/gate_test.go rename to proxy/internal/gate/gate_test.go index dd9afe4..28dcc7f 100644 --- a/authz/internal/gate/gate_test.go +++ b/proxy/internal/gate/gate_test.go @@ -194,7 +194,7 @@ func TestHealth(t *testing.T) { func env(m map[string]string) func(string) string { return func(k string) string { return m[k] } } func TestFromEnv(t *testing.T) { - full := map[string]string{"AUTHZ_UPSTREAM": "http://127.0.0.1:8081"} + full := map[string]string{"PROXY_UPSTREAM": "http://127.0.0.1:8081"} cfg, err := FromEnv(env(full)) if err != nil { t.Fatal(err) @@ -202,24 +202,33 @@ func TestFromEnv(t *testing.T) { if cfg.Listen != ":8080" || cfg.HealthListen != ":8082" || cfg.Upstream.String() != "http://127.0.0.1:8081" { t.Errorf("defaults: %+v", cfg) } - // Earlier operators set AUTHZ_PASSTHROUGH on Apps that only idle; it is - // ignored now that every request passes. - if _, err := FromEnv(env(map[string]string{"AUTHZ_UPSTREAM": "http://127.0.0.1:8081", "AUTHZ_PASSTHROUGH": "true"})); err != nil { - t.Errorf("AUTHZ_PASSTHROUGH: %v", err) + // An operator from before the rename sets only the old names (and, for + // Apps that only idle, AUTHZ_PASSTHROUGH): the sidecar must not start. + old := map[string]string{ + "AUTHZ_LISTEN": ":8080", "AUTHZ_HEALTH_LISTEN": ":8082", + "AUTHZ_UPSTREAM": "http://127.0.0.1:8081", "AUTHZ_PASSTHROUGH": "true", + } + if _, err := FromEnv(env(old)); err == nil || !strings.Contains(err.Error(), "AUTHZ_UPSTREAM is set") || + !strings.Contains(err.Error(), "PROXY_UPSTREAM is required") { + t.Errorf("an operator from before the rename: %v", err) } cases := map[string]struct { edit map[string]string want string }{ - "no upstream": {map[string]string{"AUTHZ_UPSTREAM": ""}, "AUTHZ_UPSTREAM is required"}, - "bad upstream": {map[string]string{"AUTHZ_UPSTREAM": "127.0.0.1:8081"}, "AUTHZ_UPSTREAM"}, - "same listen ports": {map[string]string{"AUTHZ_HEALTH_LISTEN": ":8080"}, "must differ"}, + "no upstream": {map[string]string{"PROXY_UPSTREAM": ""}, "PROXY_UPSTREAM is required"}, + "bad upstream": {map[string]string{"PROXY_UPSTREAM": "127.0.0.1:8081"}, "PROXY_UPSTREAM"}, + "same listen ports": {map[string]string{"PROXY_HEALTH_LISTEN": ":8080"}, "must differ"}, // An older operator restricting an App: refuse, never run it open. "groups": {map[string]string{"AUTHZ_GROUPS": "analysts"}, "AUTHZ_GROUPS is set"}, "issuers": {map[string]string{"AUTHZ_ISSUERS": "https://kc/realms/nebari"}, "AUTHZ_ISSUERS is set"}, "jwks": {map[string]string{"AUTHZ_JWKS_URL": "https://kc/certs"}, "AUTHZ_JWKS_URL is set"}, "client id": {map[string]string{"AUTHZ_CLIENT_ID": "apps-app-x"}, "AUTHZ_CLIENT_ID is set"}, + // The sidecar's own settings under the old name fail closed too. + "old upstream": {map[string]string{"AUTHZ_UPSTREAM": "http://127.0.0.1:8081"}, "AUTHZ_UPSTREAM is set"}, + "old listen": {map[string]string{"AUTHZ_LISTEN": ":8080"}, "reads PROXY_LISTEN"}, + "old health": {map[string]string{"AUTHZ_HEALTH_LISTEN": ":8082"}, "AUTHZ_HEALTH_LISTEN is set"}, } for name, tc := range cases { t.Run(name, func(t *testing.T) { @@ -230,7 +239,7 @@ func TestFromEnv(t *testing.T) { } }) } - if _, err := FromEnv(env(map[string]string{"AUTHZ_GROUPS": "a", "AUTHZ_HEALTH_LISTEN": ":8080"})); err == nil || strings.Count(err.Error(), "\n") < 2 { + if _, err := FromEnv(env(map[string]string{"AUTHZ_GROUPS": "a", "PROXY_HEALTH_LISTEN": ":8080"})); err == nil || strings.Count(err.Error(), "\n") < 2 { t.Errorf("every problem should be reported at once: %v", err) } } diff --git a/authz/internal/gate/proxy.go b/proxy/internal/gate/proxy.go similarity index 100% rename from authz/internal/gate/proxy.go rename to proxy/internal/gate/proxy.go diff --git a/authz/internal/gate/run.go b/proxy/internal/gate/run.go similarity index 100% rename from authz/internal/gate/run.go rename to proxy/internal/gate/run.go diff --git a/authz/main.go b/proxy/main.go similarity index 62% rename from authz/main.go rename to proxy/main.go index 6ce5b25..00f2013 100644 --- a/authz/main.go +++ b/proxy/main.go @@ -1,4 +1,4 @@ -// Command apps-authz is the sidecar the apps-operator puts in front of the +// Command apps-proxy is the sidecar the apps-operator puts in front of the // app container of Apps that can idle and of image Apps. It proxies every // request to the app and reports, on the health port's /activity, when the // app last served one, which the operator reads to scale idle Apps to zero. @@ -6,13 +6,16 @@ // (nebari-operator v0.2.0+). It is configured only through environment // variables: // -// AUTHZ_LISTEN proxy address (default :8080) -// AUTHZ_HEALTH_LISTEN /healthz, /readyz, /upstreamz, /activity (default :8082) -// AUTHZ_UPSTREAM the app container, e.g. http://127.0.0.1:8081 +// PROXY_LISTEN proxy address (default :8080) +// PROXY_HEALTH_LISTEN /healthz, /readyz, /upstreamz, /activity (default :8082) +// PROXY_UPSTREAM the app container, e.g. http://127.0.0.1:8081 // // It refuses to start when AUTHZ_GROUPS (or another setting of the enforcing // mode it no longer has) is set, so an App an older operator meant to restrict -// never runs open. +// never runs open. Those names keep the old AUTHZ_ prefix (the sidecar was +// apps-authz) because that is what such an operator sets. It also refuses its +// own settings under the old names (AUTHZ_UPSTREAM and the rest), so an +// operator from before the rename fails closed instead of starting open. package main import ( @@ -23,14 +26,14 @@ import ( "os/signal" "syscall" - "github.com/nebari-dev/nebari-apps-pack/authz/internal/gate" + "github.com/nebari-dev/nebari-apps-pack/proxy/internal/gate" ) func main() { log := slog.New(slog.NewJSONHandler(os.Stdout, nil)) cfg, err := gate.FromEnv(os.Getenv) if err != nil { - fmt.Fprintf(os.Stderr, "apps-authz: invalid configuration:\n%v\n", err) + fmt.Fprintf(os.Stderr, "apps-proxy: invalid configuration:\n%v\n", err) os.Exit(2) } ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) diff --git a/skill/new-nebari-app/references/manifest.md b/skill/new-nebari-app/references/manifest.md index 08f2a92..1a9c174 100644 --- a/skill/new-nebari-app/references/manifest.md +++ b/skill/new-nebari-app/references/manifest.md @@ -143,7 +143,7 @@ content files and the manifest. copied into a writable workspace, `pixi install` resolves the environment (use a committed `pixi.lock` for reproducible, faster starts), and `pixi run ` launches the app. The task must start a server on **0.0.0.0:$PORT** — `PORT` is injected (8080, or -8081 when idling puts the authz sidecar on 8080), so use +8081 when idling puts the proxy sidecar on 8080), so use `--port $PORT` and run `PORT=8080 pixi run start` locally (pixi tasks do not support `${PORT:-8080}`). No Dockerfile needed. diff --git a/ui/src/lib/crd.gen.ts b/ui/src/lib/crd.gen.ts index 479424a..d9c90a8 100644 --- a/ui/src/lib/crd.gen.ts +++ b/ui/src/lib/crd.gen.ts @@ -62,7 +62,7 @@ export type AppRuntime = { export type AppSource = { /** GitSource references app content in a git repository. */ git?: GitSource; - /** ImageSource runs a prebuilt container image as the app container, with the same non-root, capability-free, resource-limited pod spec as other Apps and the apps-authz sidecar in front of it. */ + /** ImageSource runs a prebuilt container image as the app container, with the same non-root, capability-free, resource-limited pod spec as other Apps and the apps-proxy sidecar in front of it. */ image?: { /** Args replaces the image's CMD (exec form, no shell). */ args?: string[];