# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## What This Is

`ilum.api` is a FastAPI REST service that exposes the CLI's `ReleaseManager` over HTTP. It sits between the Ilum UI and the Kubernetes cluster, enabling the frontend to manage modules, values, and operations without shelling out to the CLI. Deployed as a sidecar in the `ilum-core` pod behind Nginx.

## Commands

```bash
# Install with API extras
pip install -e ".[dev]"

# Run the API server
ilum-api                                                      # defaults: 0.0.0.0:8080
ILUM_API_HOST=127.0.0.1 ILUM_API_PORT=9000 ilum-api          # custom bind
uvicorn ilum.api.app:create_app --factory --reload             # dev mode with reload

# Tests
pytest tests/unit/test_api/ -v                                 # all API tests
pytest tests/unit/test_api/test_modules.py -v                  # single file
pytest tests/unit/test_api/test_modules.py::TestListModules -v # single class
pytest tests/unit/test_api/ -v --cov=ilum.api --cov-report=term-missing

# Full project checks (from ilum-cli/)
ruff format --check src tests && ruff check src tests && mypy src && pytest tests/unit -v
```

## Architecture

### Core Design: Thin HTTP Wrapper

The API contains **zero business logic**. Every endpoint delegates to the same `ReleaseManager`, `ModuleResolver`, `HelmClient`, and `KubeClient` that the CLI uses. The API layer only handles: HTTP routing, auth, request/response serialization, and async operation tracking.

### Request Flow

```
Client -> Nginx (cookie validation) -> FastAPI
    |-- auth.py: require_auth (Nginx cookie OR API key)
    |-- deps.py: get_manager() -> ReleaseManager singleton
    |-- router handler
    |   |-- reads:  direct call -> ReleaseManager -> HelmClient/KubeClient -> response
    |   |-- writes: plan in-process -> build K8s Job -> create Job -> 202 + operation ID
    +-- errors.py: IlumError -> JSONResponse with HTTP status
```

### Helm Operations via Kubernetes Jobs

All mutating `helm upgrade` operations (module enable, disable, config update, values update) are offloaded to Kubernetes Jobs. This solves the problem of the API pod restarting mid-operation (since `helm upgrade` can restart the pod that launched it).

**Why Jobs**: The API runs as a sidecar in `ilum-core`. Any `helm upgrade` can restart that pod, killing in-memory state and the daemon thread running the operation. Jobs survive pod restarts.

**Flow for mutating endpoints**:

1. Router validates input and runs the concurrency guard (`check_no_active_helm_operation`)
2. Planning stays in-process: `plan_enable()` / `plan_disable()` / `plan_upgrade()` (read-only)
3. CRD installation stays in-process (quick, idempotent)
4. `build_helm_job()` constructs a `V1Job` with the `helm upgrade` command
5. `mgr.k8s.create_job()` creates the Job in the namespace
6. Returns `202 Accepted` with operation ID immediately
7. Client polls `GET /operations/{id}` — the endpoint refreshes status from the K8s Job
8. On API restart, `recover_operations()` scans for existing Jobs and imports them into the store

**Key module**: `job_runner.py` — contains `build_helm_job()`, `get_job_status()`, `recover_operations()`, `find_active_helm_job()`, `check_no_active_helm_operation()`.

**Concurrency**: Only one helm operation at a time (checked both in-memory and via K8s Job labels). Returns `409` if another operation is active. Restart operations (pod deletion only) are exempt.

**Job properties**:
- Name: `ilum-helm-{op_id}`
- Labels: `app.kubernetes.io/managed-by=ilum-api`, `ilum.cloud/operation`, `ilum.cloud/operation-id`, `ilum.cloud/release`
- `backoffLimit: 0` (no retries), `activeDeadlineSeconds: 900` (15min timeout), `ttlSecondsAfterFinished: 86400` (24h auto-cleanup)
- `--atomic` flag ensures auto-rollback on failure
- Uses the same container image (`ILUM_API_IMAGE`) and service account (`ILUM_SERVICE_ACCOUNT`) as the API pod

**What stays in-thread**: Module restart (pod deletion only, no helm upgrade).

### The OperationStore

`OperationStore` (`operations.py`) is an in-memory `OrderedDict` with FIFO eviction at 100 entries. Key methods:

- `create()` / `get()` / `list_all()` — CRUD for operations
- `import_operation()` — used by recovery to import Job-backed operations on startup
- `has_active_helm_operation()` — used by the concurrency guard
- `run_in_thread()` — still used for non-helm operations (restart)

### Startup Sequence

`startup.py:auto_connect()` runs on boot:
1. Build `ReleaseManager` from env vars or auto-detection
2. `set_manager(mgr)` stores the singleton
3. `recover_operations()` scans for existing K8s Jobs and imports their state
4. Ensure Helm repo is configured (skip for bundled charts)
5. Auto-recover stuck releases via rollback

### Dependency Injection

Module-level singleton pattern — **not** FastAPI's DI container for the manager itself:
- `startup.py:auto_connect()` builds `ReleaseManager` from env vars (or scans for releases)
- `deps.py:set_manager(mgr)` stores the singleton
- `deps.py:get_manager()` is a FastAPI `Depends()` that yields it
- `OperationStore` uses the same pattern via `get_operation_store()`

### Auth Strategy

Triple auth in `auth.py` — any mechanism is sufficient:
- **Nginx cookie**: Header `X-Ilum-Authenticated: true` (production, set by Nginx after session validation)
- **Bearer token**: `Authorization: Bearer <JWT>` header — validated locally with PyJWT if `ILUM_JWT_PUBLIC_KEY` is configured, otherwise forwarded to ilum-core's `/authenticate/set-permission-cookies` endpoint. Results are cached in-memory (LRU, 1000 entries, configurable TTL).
- **API key**: Header `X-API-Key` compared against `ILUM_API_KEY` env var (direct/programmatic access)
- `/health` is unauthenticated (Kubernetes probes)

### Error Mapping

`errors.py` maps `IlumError` subclasses to HTTP status codes via `_ERROR_STATUS_MAP`. The global exception handler walks the MRO to find the most specific match. Key mappings: `ModuleError->400`, `ReleaseNotFoundError->404`, `HelmError->502`, `ClusterConnectionError->503`.

## Key Conventions

- **All endpoints prefixed** with `/api/v1`
- **Pydantic v2 models** in `models.py` for all request/response schemas — add new models there, not inline
- **Router auth**: `status.router` has no auth dependency; all other routers get `require_auth` added at include-time in `app.py`
- **Pod info is best-effort**: Module detail endpoint wraps pod queries in try/except — pod status is informational
- **Environment-based config**: `ILUM_RELEASE_NAME`, `ILUM_NAMESPACE`, `ILUM_KUBE_CONTEXT`, `ILUM_API_KEY`, `ILUM_API_HOST`, `ILUM_API_PORT`, `ILUM_CHART_REF`, `ILUM_API_IMAGE`, `ILUM_SERVICE_ACCOUNT`, `ILUM_HELM_REPO_URL`, `ILUM_CORE_URL`, `ILUM_JWT_PUBLIC_KEY`, `ILUM_JWT_ISSUER_URI`, `ILUM_JWT_AUDIENCES`, `ILUM_BEARER_CACHE_TTL`

## Testing Patterns

All tests mock the core layer — no real cluster or Helm calls:

- **`conftest.py`** provides `mock_api_manager` (mocked `ReleaseManager` with `ReleasePlan` return values), `mock_api_helm`, `mock_api_k8s` (with Job method defaults), `api_client` (with auth header and `ILUM_API_IMAGE`/`ILUM_SERVICE_ACCOUNT` env vars), and `unauthed_client`
- **App creation**: `auto_connect()` is patched during `create_app()` to skip real startup
- **Singletons reset**: `_manager` and `_store` are reset between tests to avoid cross-test leakage
- **Auth fixtures**: `api_client` sends `X-API-Key`; `unauthed_client` sends no auth headers
- **Job tests**: Mutating endpoints assert `mgr.k8s.create_job` was called; cancel tests assert `mgr.k8s.delete_job` was called; concurrency tests verify `409` when an operation is already active

## Adding a New Endpoint

1. Add Pydantic models to `models.py`
2. Create or extend a router in `routers/` — use `get_manager` dependency for `ReleaseManager` access
3. For mutations that run `helm upgrade`:
   - Add the concurrency guard: `check_no_active_helm_operation(store, mgr.k8s, namespace)`
   - Plan in-process: call `mgr.plan_*()` to get a `ReleasePlan`
   - Build and create a Job: `build_helm_job(...)` then `mgr.k8s.create_job()`
   - Set `op.job_name` and `op.status = "running"`
   - Return `202` with `status="running"`
4. For mutations that do NOT run `helm upgrade` (e.g. restart): use `store.run_in_thread()` as before
5. Register router in `app.py:create_app()` with appropriate auth dependency
6. Add tests in `tests/unit/test_api/` using the existing fixtures
