api: a fresh install gets one admin, so enforcing tokens cannot brick it

Enforcing API tokens (f4482fd5) had a sharp edge: on a brand-new config dir
there is no token, so every /v1 call is refused, AND no user, so nobody can
log in to create one. Nothing in the codebase auto-creates that first user —
grepped for it rather than assumed.

The app now creates a single `admin` account at startup when auth.json has
no users, with a random password printed once and must_change_password set,
and logs plainly when there are users but no tokens. Idempotent: verified it
never touches an existing user or rewrites its hash.

The guide gains "You never configure a pod — CoderAI does": a table of what
the orchestrator sets on a rented pod by itself (the per-pool cra- token so
a pod is never open, TLS from this install's CA, seed models, engine args
including tensor-parallel and LoRA modules, admission limits, the volume
mount, idle teardown and the reaper) and the rule that follows — no SSH into
a pod, no client pointed at a pod URL; throughput comes from max_pods and
scale_up_inflight_per_pod, not from hammering one pod.

Also corrects the guide's first-run text, which promised credentials the
code did not actually create until now.
Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
parent f4482fd5
......@@ -740,6 +740,49 @@ app.include_router(admin_router, tags=["Admin"])
# Registering them here gives one image that is both the orchestrator and its GUI.
# Guarded: a missing templates dir or config dir must not take the API down with
# it, which is the same contract the front has.
def _bootstrap_first_admin(sm, cfg_dir):
"""Give a brand-new install one admin account, printed once.
Enforcing tokens (above) would otherwise lock out a fresh config dir
completely: no token means every /v1 call is refused, and no user means
nobody can log in to create one. Nothing auto-creates that first user, so
this does — with a random password, marked must-change — and says so in the
log. Idempotent: it never touches an existing user.
"""
import secrets
try:
data = sm._load_auth_data()
except Exception:
return
if data.get("users"):
if not data.get("tokens"):
print("[api] no API tokens configured — /v1 requests will be refused; "
"create one in Admin -> Tokens", flush=True)
return
password = secrets.token_urlsafe(18)
if not sm.create_user("admin", password, role="admin"):
return
# Force the change on first login: a password that was printed to a
# container log is not a password.
def _mark(auth_data):
for u in auth_data.get("users", []):
if u.get("username") == "admin":
u["must_change_password"] = True
return True, True
return False, False
try:
sm.update_auth_data(_mark)
except Exception:
pass
print("\n" + "=" * 72, flush=True)
print("[api] FIRST RUN: created the admin account for this install", flush=True)
print(f"[api] username: admin", flush=True)
print(f"[api] password: {password}", flush=True)
print("[api] change it at /admin/change-password; this is printed once.", flush=True)
print(f"[api] stored in {cfg_dir}/auth.json", flush=True)
print("=" * 72 + "\n", flush=True)
def _ensure_session_manager():
"""Make sure API tokens are actually ENFORCED under a bare uvicorn start.
......@@ -763,6 +806,7 @@ def _ensure_session_manager():
or legacy_style_config_dir()
_ar.init_session_manager(cfg_dir)
print(f"[api] API tokens enforced from {cfg_dir}", flush=True)
_bootstrap_first_admin(_ar.session_manager, cfg_dir)
except Exception as exc:
print(f"[api] WARNING: could not initialise the session manager ({exc}); "
f"set CODERAI_API_TOKEN to refuse unauthenticated /v1 requests",
......
......@@ -66,9 +66,18 @@ curl -fsS http://127.0.0.1:8000/health # same — the vLLM-convention alias
curl -fsS http://127.0.0.1:8000/v1/models # [] until you add models
```
Then open `http://127.0.0.1:8000/admin`. It redirects to `/login`; the first
credentials are created on first run and printed in the container log
(`docker logs coderai`), and you are asked to change the password.
Then open `http://127.0.0.1:8000/admin`. It redirects to `/login`. On a brand-new
config directory the app creates one admin account at startup with a random
password and prints it **once** — so read the log before you lose it:
```bash
docker logs coderai | grep -A4 'FIRST RUN'
```
You are forced to change it on first login: a password that was printed to a
container log is not a password. Then create an API token (Admin → Tokens)
before anything calls `/v1/*`, because with no token every API request is
refused — correctly.
### Behind nginx, under a sub-path
......@@ -246,6 +255,34 @@ because a volume lives in one region and a pod elsewhere cannot attach it.
## 4. A network volume, and one recipe per kind of model
### You never configure a pod — CoderAI does
This is the part worth being explicit about, because it is the easiest thing to
get wrong: **you do not set anything up on a pod, and you do not send requests
to one.** A pod is disposable. CoderAI rents it, configures it, routes to it and
destroys it. Everything you set is on the *model*, here, in `models.json` or the
model page.
When CoderAI provisions a pod it sets, by itself:
| on the pod | from where |
|---|---|
| its **bearer token** — a fresh `cra-…` per pool, so a pod is never open on a public proxy URL | generated unless you set `api_key`, or opt out with `allow_open_pod` |
| **TLS**, when the path is direct TCP — a certificate from this install's own CA, which only this install verifies | automatic for CoderAI images |
| which **models** to serve, and where to get the weights | `served_model`, `source`, `hf_repo`, `volume_path` |
| the **engine** and its launch arguments, including `--tensor-parallel-size`, `--enable-lora` and the LoRA modules | `engine`, `gpu_count`, the adapters on the model |
| its **admission limits** | `pod_max_parallel_requests`, `pod_queue_max_size` |
| the **volume** mount | `network_volume_id`, `volume_mount_path` |
| **teardown** — idle pods destroyed, and a reaper that kills tagged pods no live pool is tracking, so a crash cannot leave one billing | `idle_timeout_s`, automatic |
So: no SSH into a pod, no editing config on a pod, no pointing a client at a pod
URL. If you find yourself wanting to drive a pod directly to get throughput, the
answer is `max_pods` and `scale_up_inflight_per_pod` instead — client-side
concurrency against one pod produces 429s, not speed.
The one thing to keep in your own hands is the **orchestrator's** front door:
its API tokens (Admin → Tokens) and what your reverse proxy exposes.
### Put the weights on a volume — this is the single biggest lever
**The weights are the cost, not the GPU.** A pod with no network volume
......
......@@ -243,3 +243,47 @@ def test_the_gui_emits_prefixed_urls_behind_a_sub_path_proxy():
assert 'action="/coderai/login"' in html, "the login form would post outside the prefix"
assert "/coderai/static/admin/" in html, "assets would 404 through the proxy"
assert re.search(r"ROOT_PATH\s*=\s*['\"]/coderai", html), "JS fetches would miss the prefix"
def test_a_fresh_install_gets_one_admin_and_is_not_locked_out():
"""Enforcing tokens would otherwise brick a brand-new config dir: no token
means every /v1 call is refused and no user means nobody can log in to make
one. Nothing else auto-creates that first user."""
import json, pathlib, tempfile
from codai.admin.auth import SessionManager
from codai.api.app import _bootstrap_first_admin
d = tempfile.mkdtemp()
sm = SessionManager(pathlib.Path(d))
_bootstrap_first_admin(sm, d)
users = json.load(open(pathlib.Path(d) / "auth.json"))["users"]
assert [u["username"] for u in users] == ["admin"]
assert users[0]["role"] == "admin"
# A password printed to a container log is not a password.
assert users[0]["must_change_password"] is True
def test_the_bootstrap_never_touches_an_existing_user():
import json, pathlib, tempfile
from codai.admin.auth import SessionManager
from codai.api.app import _bootstrap_first_admin
d = tempfile.mkdtemp()
sm = SessionManager(pathlib.Path(d))
_bootstrap_first_admin(sm, d)
before = json.load(open(pathlib.Path(d) / "auth.json"))["users"]
_bootstrap_first_admin(sm, d)
after = json.load(open(pathlib.Path(d) / "auth.json"))["users"]
assert len(after) == 1
assert before[0]["password_hash"] == after[0]["password_hash"]
def test_coderai_locks_every_pod_it_rents():
"""The operator never sets a pod's token: the pool generates one unless the
model explicitly opts out, so a pod is never open on a public proxy URL."""
src = (ROOT / "codai/api/runpod_worker.py").read_text()
assert 'self.api_key = "cra-" + secrets.token_urlsafe(32)' in src
assert 'allow_open_pod' in src
i = src.index('self.api_key = "cra-"')
guard = src[max(0, i - 200):i]
assert "allow_open_pod" in guard, "generation must be the default, not opt-in"
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment