diff --git a/.env.example b/.env.example index f915d43..b81337c 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,12 @@ GSHARE_SESSION_URL_SCHEME=https # so without this the console cannot reach sessions. Leave unset to disable relaying. # The host must match the operator's SESSION_DOMAIN / GSHARE_SESSION_DOMAIN. # GSHARE_SESSION_INGRESS=10.0.0.10:30080 +# How many proxies append to X-Forwarded-For before a request reaches the API. The client +# address recorded in login audit rows and used for the per-IP login rate limit is read that +# many entries from the right of the header (a client can prepend values, not remove them). +# 1 = the Compose frontend (nginx) or ingress-nginx alone; 2 = a load balancer that forwards +# the header in front of it. +GSHARE_TRUSTED_PROXY_HOPS=1 # ── Billing knobs ── GSHARE_CONNECTION_TOKEN_TTL_SEC=300 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..3360328 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,81 @@ +# Publishes the documentation site to GitHub Pages. +# +# The built site is never committed: this workflow builds it from `docs/` and `website/` and hands +# the result straight to Pages, so there is no gh-pages branch to keep in sync. +# +# Before the first run, an administrator has to set Settings → Pages → Source to "GitHub Actions" +# on this repository. The site is then served at https://boanlab.github.io/gshare/ as a project +# site, which is per-repository and does not occupy the organization's single boanlab.github.io. +name: Docs site + +on: + push: + branches: [main] + paths: + - 'docs/**' + - 'website/**' + - '.github/workflows/docs.yml' + # Lets an administrator republish without a code change, and lets a fork build a preview. + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +# One deployment at a time; a newer push waits rather than racing the one in flight. +concurrency: + group: pages + cancel-in-progress: false + +env: + SITE_URL: https://boanlab.github.io + SITE_BASE_URL: /gshare/ + +jobs: + build: + runs-on: ubuntu-latest + defaults: + run: + working-directory: website + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + cache-dependency-path: website/package-lock.json + + - name: Install + run: npm ci + + # A fork sits at its own owner and repository name, so it builds against its own Pages URL. + # Nothing here needs editing to preview a fork. + - name: Resolve the site address + run: | + if [ "$GITHUB_REPOSITORY" != "boanlab/gshare" ]; then + owner="${GITHUB_REPOSITORY%%/*}" + name="${GITHUB_REPOSITORY##*/}" + echo "SITE_URL=https://${owner}.github.io" >> "$GITHUB_ENV" + echo "SITE_BASE_URL=/${name}/" >> "$GITHUB_ENV" + fi + + - name: Build + run: npm run build + + - uses: actions/configure-pages@v5 + + - uses: actions/upload-pages-artifact@v3 + with: + path: website/build + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index baf044d..a868c2e 100644 --- a/.gitignore +++ b/.gitignore @@ -71,6 +71,9 @@ hack/storage-csi-key.pub test/e2e/ux/out/ test/e2e/ux/out-*/ +# Beta-test findings and their run logs: the same kind of point-in-time artefact. +beta-report/ + # ── Local assistant/agent tooling: workstation config, not project source. ── .claude/ .agents/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1251f5b..fb99e1e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,15 +11,17 @@ vulnerabilities follow [SECURITY.md](./SECURITY.md) instead of the public issue - **Bugs and features** — open an issue first for anything beyond a small fix. It saves you from building something that conflicts with work already in flight. - **Scope** — GShare is interactive-session only. There is deliberately no API key, CLI, - SDK, or batch-job surface, and MIG partitioning is out of scope. Proposals that move - those boundaries are welcome, but argue the case in an issue first. + SDK, or batch-job surface, and a user-selectable MIG session mode is out of scope + (`mode=mig` is rejected); MIG exists only as an admin-operated per-card pool that + fractional requests may land on. Proposals that move those boundaries are welcome, but + argue the case in an issue first. ## Development environment | Component | Runtime | Directory | |---|---|---| | `gshare-api`, `gshare-worker` | Python 3.12 + FastAPI | `backend/` | -| `gshare-operator` | Go 1.22 + controller-runtime | `operator/` | +| `gshare-operator` | Go 1.25 + controller-runtime | `operator/` | | Console | Node 20 + Vite + React | `frontend/` | You need `docker`. For deployment work you also need `kubectl` and `helm` v3. diff --git a/README.md b/README.md index bc7f174..c8f6dbe 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ sitting on it. | Path | Contents | |---|---| | `backend/` | `gshare-api` (FastAPI control plane) and `gshare-worker` (billing, queue, and reaper batch). Python 3.12 | -| `operator/` | `gshare-operator`, the per-cluster controller that reconciles `GShareSession` resources. Go 1.22 + controller-runtime | +| `operator/` | `gshare-operator`, the per-cluster controller that reconciles `GShareSession` resources. Go 1.25 + controller-runtime | | `frontend/` | The console: a React + TypeScript single-page app with live updates over SSE | | `charts/gshare/` | The Helm chart for the whole platform | | `deploy/` | Values overlays (`values/`), security baselines, monitoring, supply-chain policy, secret examples | @@ -91,7 +91,7 @@ The custom resource is the source of truth for desired session state. The API de | Namespace | Purpose | Pod Security Admission | |---|---|---| | `gshare-system` | Control plane: api, worker, operator, Postgres, Redis, ingress | `baseline` | -| `gshare-sessions` | Tenant session pods | **`restricted` (enforce)** | +| `gshare-sessions` | Tenant session pods | **`baseline` (enforce), `restricted` (audit/warn)** — session pods keep the `restricted` shape; only a policy-granted privileged session relaxes it | | `gshare-infra` | Privileged DaemonSets | `privileged` | ### Reference production configuration @@ -125,7 +125,8 @@ memberships. The top bar has an admin-mode toggle that switches between the two **Credits** start at zero for a new user. They are allocated down the hierarchy — super-admin to organization to group to user — and no level can hand out more than it -received. Monthly refills are supported and are use-it-or-lose-it. A session is always +received. Monthly refills are supported: at the start of each month a wallet is topped up to its +monthly grant (a balance already above the grant is left alone). A session is always billed to the requester's own personal wallet; charging someone else's wallet or a group wallet is rejected. **Credits are a GPU-only concept**: CPU-class sessions and storage volumes are free, and are governed by resource-policy quotas instead. diff --git a/backend/README.md b/backend/README.md index 0895b4f..24185da 100644 --- a/backend/README.md +++ b/backend/README.md @@ -33,7 +33,9 @@ app/ domain/ scheduler, credit_engine, session/volume/budget services, connection_token cluster/ handoff, crd (GShareSession apply), status_sync — apply only, no workload calls db/ base (engine and session), models - workers/ runner plus billing, budget_rollup, credit_refill, token_expiry, queue_ticker + workers/ runner plus billing, budget_rollup, credit_refill, token_expiry, queue_ticker, + grace_enforcer, audit_retention, webhook_dispatcher, pool_rebalancer, + node_liveness, session_liveness alembic/ env.py and versions/ tests/ pytest against in-memory SQLite; real-GPU e2e lives in /test/e2e ``` @@ -45,12 +47,16 @@ just the API by hand: ```bash pip install -e ".[dev]" -cp ../.env.example .env # set GSHARE_BOOTSTRAP_ADMIN_* and INTERNAL_JWT_PRIVATE_KEY +cp ../.env.example .env # set GSHARE_BOOTSTRAP_ADMIN_* and GSHARE_INTERNAL_JWT_PRIVATE_KEY docker compose up -d postgres redis # datastores only alembic upgrade head # apply the schema +alembic check # models vs schema: must report no new operations uvicorn app.main:app --reload --port 8080 # docs at http://localhost:8080/api/v1/docs -python -m app.workers.runner # billing, budget rollup, credit refill, token expiry, queue ticker +python -m app.workers.runner # billing, budget rollup, credit refill, token expiry, queue ticker, and the other loops in workers/runner.py ``` +`downgrade` walks back one revision at a time, but **not all the way to base**: `0051_drop_boards` +refuses to run backwards, because the notice and inquiry tables were dropped with their data by +decision. Restore from a backup instead of downgrading past that revision. Port 8080 matches what Compose and the frontend proxy expect. diff --git a/backend/alembic/versions/0062_volume_placement.py b/backend/alembic/versions/0062_volume_placement.py new file mode 100644 index 0000000..317801c --- /dev/null +++ b/backend/alembic/versions/0062_volume_placement.py @@ -0,0 +1,39 @@ +"""Record where a volume's data lives. + +A volume's PVC is created lazily by whichever cluster's operator first runs a session that mounts +it, and until now nothing wrote that down: the administrator's volume list could not say which +storage server a volume sits on, and with several pools the answer was reconstructed from mount +history. The operator already reports every PVC each sync tick; this adds the columns that report +fills — the provisioning cluster, the StorageClass the claim names (the pool is the pair), and +when it was first seen. Existing rows fill in on the next tick; volumes with no PVC yet stay NULL. + +Revision ID: 0062_volume_placement +Revises: 0061_org_name_unique_live +""" +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = "0062_volume_placement" +down_revision = "0061_org_name_unique_live" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("storage_volume", sa.Column("cluster_id", sa.String(), nullable=True)) + op.add_column("storage_volume", sa.Column("storage_class", sa.String(), nullable=True)) + op.add_column("storage_volume", sa.Column("provisioned_at", sa.DateTime(timezone=True), nullable=True)) + op.create_foreign_key( + "fk_storage_volume_cluster", "storage_volume", "cluster", ["cluster_id"], ["id"], ondelete="SET NULL", + ) + op.create_index("ix_storage_volume_cluster_id", "storage_volume", ["cluster_id"]) + + +def downgrade() -> None: + op.drop_index("ix_storage_volume_cluster_id", table_name="storage_volume") + op.drop_constraint("fk_storage_volume_cluster", "storage_volume", type_="foreignkey") + op.drop_column("storage_volume", "provisioned_at") + op.drop_column("storage_volume", "storage_class") + op.drop_column("storage_volume", "cluster_id") diff --git a/backend/alembic/versions/0063_schema_alignment.py b/backend/alembic/versions/0063_schema_alignment.py new file mode 100644 index 0000000..e95a922 --- /dev/null +++ b/backend/alembic/versions/0063_schema_alignment.py @@ -0,0 +1,102 @@ +"""Align the live schema with the models so `alembic check` is clean. + +Revision ID: 0063_schema_alignment +Revises: 0062_volume_placement + +Every item here is a name, a type or a nullability that the hand-written migrations left +different from what the models declare; none changes what the tables hold: + +- two CHECK constraints carried the naming convention twice + (ck_credit_wallet_ck_credit_wallet_wallet_sigma, ck_gpu_device_ck_gpu_device_no_overcommit); +- the project -> group rename kept the old index names (ix_*_project_id); +- image_build.build_args was created as JSON, the model says JSONB; +- five tables were created with nullable created_at/updated_at (TimestampMixin is NOT NULL). + +Postgres only: the SQLite test harness builds the schema from the models directly. +""" +from __future__ import annotations + +import sqlalchemy as sa + +from alembic import op + +revision = "0063_schema_alignment" +down_revision = "0062_volume_placement" +branch_labels = None +depends_on = None + +_CHECKS = { + # table: (name on disk, name in the models) + "credit_wallet": ("ck_credit_wallet_ck_credit_wallet_wallet_sigma", "ck_credit_wallet_wallet_sigma"), + "gpu_device": ("ck_gpu_device_ck_gpu_device_no_overcommit", "ck_gpu_device_no_overcommit"), +} +_INDEXES = { + # old name: new name + "ix_project_org_id": "ix_group_org_id", + "ix_image_build_project_id": "ix_image_build_group_id", + "ix_membership_project_id": "ix_membership_group_id", + "ix_session_project_id": "ix_session_group_id", +} +_TIMESTAMPED = ("node_pool", "node_pool_grant", "resource_request", "session_event", "system_setting") + + +def _is_postgres() -> bool: + return op.get_bind().dialect.name == "postgresql" + + +def _rename_constraint(table: str, old: str, new: str) -> None: + # Guarded: a database whose schema came from a different path may already carry the new + # name, and the rename must not fail the whole upgrade over it. + op.execute(sa.text(f""" + DO $$ + BEGIN + IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = '{old}') + AND NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = '{new}') THEN + EXECUTE 'ALTER TABLE "{table}" RENAME CONSTRAINT {old} TO {new}'; + END IF; + END $$; + """)) + + +def _rename_index(old: str, new: str) -> None: + op.execute(sa.text(f""" + DO $$ + BEGIN + IF EXISTS (SELECT 1 FROM pg_class WHERE relname = '{old}' AND relkind = 'i') + AND NOT EXISTS (SELECT 1 FROM pg_class WHERE relname = '{new}' AND relkind = 'i') THEN + EXECUTE 'ALTER INDEX {old} RENAME TO {new}'; + END IF; + END $$; + """)) + + +def upgrade() -> None: + if not _is_postgres(): + return + for table, (old, new) in _CHECKS.items(): + _rename_constraint(table, old, new) + for old, new in _INDEXES.items(): + _rename_index(old, new) + op.execute(sa.text( + "ALTER TABLE image_build ALTER COLUMN build_args TYPE JSONB USING build_args::jsonb" + )) + for table in _TIMESTAMPED: + for col in ("created_at", "updated_at"): + # Backfill first: a NULL timestamp on an old row would otherwise fail SET NOT NULL. + op.execute(sa.text(f'UPDATE "{table}" SET {col} = now() WHERE {col} IS NULL')) + op.alter_column(table, col, existing_type=sa.DateTime(timezone=True), nullable=False) + + +def downgrade() -> None: + if not _is_postgres(): + return + for table in _TIMESTAMPED: + for col in ("created_at", "updated_at"): + op.alter_column(table, col, existing_type=sa.DateTime(timezone=True), nullable=True) + op.execute(sa.text( + "ALTER TABLE image_build ALTER COLUMN build_args TYPE JSON USING build_args::json" + )) + for old, new in _INDEXES.items(): + _rename_index(new, old) + for table, (old, new) in _CHECKS.items(): + _rename_constraint(table, new, old) diff --git a/backend/app/api/credits_router.py b/backend/app/api/credits_router.py index ca49bb2..ecab97b 100644 --- a/backend/app/api/credits_router.py +++ b/backend/app/api/credits_router.py @@ -643,6 +643,19 @@ async def allocate( } +def _credit_grant_increase(db: AsyncSession, wallet: CreditWallet, new_amount: Decimal) -> None: + """Raise the balance to the new grant and put the difference on the ledger. + + The immediate credit used to move ``balance`` with no CreditTransaction, so the transaction + history no longer summed to the wallet — the one invariant the ledger exists to keep.""" + delta = new_amount - wallet.balance + wallet.balance = new_amount + db.add(_make_txn( + wallet, "adjust", delta, ref="monthly_grant", + key=f"grant:{wallet.id}:{ids.new('transaction')}", + )) + + async def _grant_scope( db: AsyncSession, principal: Principal, wallet: CreditWallet ) -> tuple[CreditWallet | None, list[str]]: @@ -711,8 +724,8 @@ async def set_monthly_grant( """Set a child wallet's monthly automatic refill, as the administrator one level up. The siblings' grants must sum within the parent's grant; 0 disables refills for that wallet. The - refill itself — resetting balance to grant at the start of each month, use-it-or-lose-it — is - performed by the credit_refill worker.""" + refill itself — topping the balance up to the grant at the start of each month, never taking + a surplus away — is performed by the credit_refill worker.""" wallet = await _lock_wallet(db, wallet_id) parent, sibling_ids = await _grant_scope(db, principal, wallet) new_amount = body.amount @@ -732,7 +745,7 @@ async def set_monthly_grant( # An increase is credited immediately so it is usable at once; a decrease takes effect at the # next monthly refill. The system wallet only holds the ceiling and needs no balance. if wallet.owner_type != "system" and new_amount > wallet.balance: - wallet.balance = new_amount + _credit_grant_increase(db, wallet, new_amount) wallet.version = wallet.version + 1 await AuditService(db).record( actor=principal.user_id, action="credit.set_monthly_grant", @@ -881,7 +894,7 @@ async def bulk_monthly_grant( for w in wallets: w.monthly_grant = body.amount if body.amount > w.balance: - w.balance = body.amount + _credit_grant_increase(db, w, body.amount) w.version = w.version + 1 await AuditService(db).record( actor=principal.user_id, action="credit.bulk_monthly_grant", target=parent.id, diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 54504bf..da3af86 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -12,6 +12,10 @@ # The only paths reachable while must_change_password is set: read your own profile and change the # password. Every other endpoint is blocked. _PWCHANGE_ALLOWED = ("/auth/change-password", "/auth/me") +# The only routes that may read the bearer token from the query string: the SSE streams, which a +# browser EventSource opens without custom headers. Everywhere else a token in the URL is a token +# in access logs, Referer headers and browser history, so it is refused. +_QUERY_TOKEN_SUFFIXES = ("/events",) async def get_current_principal( @@ -26,10 +30,11 @@ async def get_current_principal( """Verify the bearer JWT and resolve the Principal (global_role ∪ Membership). The canonical form is the ``Authorization: Bearer `` header, but a browser ``EventSource`` - (SSE) cannot set custom headers, so ``?access_token=`` is accepted as well. With neither, + (SSE) cannot set custom headers, so ``?access_token=`` is accepted on the event-stream + routes only. With neither, the response is 401 unauthenticated rather than FastAPI's default 422. """ token = authorization - if token is None and access_token: + if token is None and access_token and request.url.path.endswith(_QUERY_TOKEN_SUFFIXES): token = f"Bearer {access_token}" if token is None: raise Unauthenticated("missing credentials") @@ -39,6 +44,9 @@ async def get_current_principal( if claims.get("must_change_password") and not request.url.path.endswith(_PWCHANGE_ALLOWED): raise PasswordChangeRequired("password change required") principal = await resolve_principal(db, claims) + # The row's flag, not only the claim: a forced reset lands after the token was issued. + if principal.must_change_password and not request.url.path.endswith(_PWCHANGE_ALLOWED): + raise PasswordChangeRequired("password change required") # resolve_principal's SELECT auto-begins a transaction on the session. This dependency is # read-only, so rolling back immediately closes it and lets the endpoint's unit of work (`async # with db.begin`) start a fresh transaction without colliding — SQLAlchemy 2.0 autobegin. @@ -69,7 +77,7 @@ async def get_sse_principal( session; they authenticate through this dependency and hold no connection afterwards. """ token = authorization - if token is None and access_token: + if token is None and access_token and request.url.path.endswith(_QUERY_TOKEN_SUFFIXES): token = f"Bearer {access_token}" if token is None: raise Unauthenticated("missing credentials") @@ -79,6 +87,9 @@ async def get_sse_principal( async with get_sessionmaker()() as db: principal = await resolve_principal(db, claims) await db.rollback() + # The row's flag, not only the claim: a forced reset lands after the token was issued. + if principal.must_change_password and not request.url.path.endswith(_PWCHANGE_ALLOWED): + raise PasswordChangeRequired("password change required") return principal diff --git a/backend/app/api/groups_router.py b/backend/app/api/groups_router.py index a535574..fd9bf9d 100644 --- a/backend/app/api/groups_router.py +++ b/backend/app/api/groups_router.py @@ -843,6 +843,7 @@ async def add_membership( user = await db.get(User, body.user_id) if user is None or user.deleted_at is not None: raise NotFound("user", {"user_id": body.user_id}) + await _assert_user_enrollable(db, principal, body.user_id) # (user, project) is unique: 409 when they are already a member. dup = await db.scalar( @@ -997,6 +998,42 @@ def _page(pagination: Pagination, total: int) -> dict[str, int]: } +async def _assert_user_enrollable(db: AsyncSession, principal: Principal, user_id: str) -> None: + """A non-super administrator may only enroll a user who already shares a tenant with them, or + who belongs to no group yet. + + Membership is authority: whoever administers the group can reset the member's password, + suspend them, soft-delete them and move their credits. Enrolling an arbitrary user id (which + /users/resolve hands out for any email) into one's own group therefore used to hand a + group_admin of one organization those powers over a member of another. Moving a user between + organizations stays a super_admin task. + """ + if "super_admin" in principal.global_roles: + return + target_groups = set( + (await db.scalars( + select(Membership.group_id).where( + Membership.user_id == user_id, Membership.group_id.is_not(None) + ) + )).all() + ) + if not target_groups: + return # unaffiliated (freshly created / approved) user: nothing to take over + reachable = {gid for gid, r in principal.memberships.items() if r in ("org_admin", "group_admin")} + if principal.org_admin_orgs: + reachable |= set( + (await db.scalars( + select(Project.id).where( + Project.org_id.in_(list(principal.org_admin_orgs)), Project.deleted_at.is_(None) + ) + )).all() + ) + if not (reachable & target_groups): + raise Forbidden( + "not permitted: the user belongs to another organization", {"user_id": user_id} + ) + + async def _load_project(db: AsyncSession, group_id: str) -> Project: project = await db.get(Project, group_id) if project is None or project.deleted_at is not None: diff --git a/backend/app/api/images_router.py b/backend/app/api/images_router.py index cf9cc5b..d864e74 100644 --- a/backend/app/api/images_router.py +++ b/backend/app/api/images_router.py @@ -171,6 +171,11 @@ async def list_images( catalogue passes no filter and sees both public and private images. """ base = select(Image) + # Only the catalogue administrators (image.create) see every row. Everyone else is limited to + # the shared catalogue plus their own private images, whatever filter they pass — a private + # image built by another user (its registry path included) is not theirs to list. + if not rbac_allows(principal, "image.create"): + base = base.where(or_(Image.public.is_(True), Image.owner_user_id == principal.user_id)) if kind is not None: if kind not in _IMAGE_KINDS: raise _Validation("invalid kind", {"kind": kind}) @@ -271,10 +276,14 @@ async def get_image( principal: Principal = Depends(get_current_principal), db: AsyncSession = Depends(get_db), ): - """Image detail. any authenticated.""" + """Image detail. any authenticated, for the shared catalogue and one's own images; another + user's private image answers 404 so its existence is not confirmed either.""" img = await db.get(Image, image_id) if img is None: raise NotFound("image", {"image_id": image_id}) + if not img.public and img.owner_user_id != principal.user_id \ + and not rbac_allows(principal, "image.create"): + raise NotFound("image", {"image_id": image_id}) return _serialize_image(img) diff --git a/backend/app/api/infra_router.py b/backend/app/api/infra_router.py index 24e3028..dfc4423 100644 --- a/backend/app/api/infra_router.py +++ b/backend/app/api/infra_router.py @@ -1350,6 +1350,16 @@ async def metrics_cluster( vol_alloc = int(await db.scalar( select(func.coalesce(func.sum(StorageVolume.quota_gb), 0)).where(StorageVolume.deleted_at.is_(None)) ) or 0) + # Per-pool allocation: every live volume whose PVC landed on the pool's (cluster, StorageClass), + # the same key the volume list resolves a pool by. Volumes whose PVC has not been created yet + # carry no placement and are reported apart, so the per-pool figures and the total still meet. + placed_rows = (await db.execute( + select(StorageVolume.cluster_id, StorageVolume.storage_class, func.coalesce(func.sum(StorageVolume.quota_gb), 0)) + .where(StorageVolume.deleted_at.is_(None), StorageVolume.cluster_id.is_not(None)) + .group_by(StorageVolume.cluster_id, StorageVolume.storage_class) + )).all() + used_by_key = {(cid, sc): int(gb or 0) for cid, sc, gb in placed_rows} + unplaced_gb = vol_alloc - sum(used_by_key.values()) pool_cluster_names = dict( (await db.execute(select(Cluster.id, Cluster.name))).all() ) if pools else {} @@ -1364,6 +1374,11 @@ async def metrics_cluster( "source": bound_source or "unknown", }, "node_count": len(pools), + # Fleet-wide figures for the tile's header: capacity summed over the pools in view (a + # picture of the whole, not the placement bound — `disk_gb.total` stays the largest pool, + # which is what one volume can actually get) and the quota not yet pinned to any pool. + "capacity_gb": sum(pool_capacity_gb(p)[0] or 0 for p in pools), + "unplaced_gb": max(0, unplaced_gb), # A pool serving more than the cluster in view: the figure is not this cluster's own. "shared": any(p.cluster_id != cluster_id for p in pools) if cluster_id else False, "pools": [ @@ -1378,6 +1393,7 @@ async def metrics_cluster( "capacity_gb": pool_capacity_gb(p)[0], "capacity_source": pool_capacity_gb(p)[1], "capacity_reported_at": p.capacity_reported_at, + "used_gb": used_by_key.get((p.cluster_id, p.storage_class), 0), } for p in pools ], diff --git a/backend/app/api/policies_router.py b/backend/app/api/policies_router.py index 4fe4daf..21d73e8 100644 --- a/backend/app/api/policies_router.py +++ b/backend/app/api/policies_router.py @@ -323,6 +323,11 @@ async def create_resource_request( raise _Unprocessable("targets must be positive") if not body.note.strip(): raise _Unprocessable("note is required") + # The group named on a request is the caller's own: it lands in that group's audit trail and + # notifications, so a member must not be able to file under a group they are not in. + if body.group_id and "super_admin" not in principal.global_roles \ + and body.group_id not in principal.memberships: + raise Forbidden("not permitted: not a member of that group") r = ResourceRequest( id=ids.new("resourcerequest"), user_id=principal.user_id, group_id=body.group_id, cpu=body.cpu, mem_gb=body.mem_gb, storage_gb=body.storage_gb, diff --git a/backend/app/api/queue_router.py b/backend/app/api/queue_router.py index bffaa7f..5fcf58f 100644 --- a/backend/app/api/queue_router.py +++ b/backend/app/api/queue_router.py @@ -16,7 +16,7 @@ from app.api.deps import Pagination, get_current_principal from app.api.schemas.session import QueueList, QueueMineList from app.auth.rbac import Principal -from app.core.errors import DomainError, NotFound +from app.core.errors import DomainError, Forbidden, NotFound from app.core.redis import get_redis from app.db.base import get_db from app.db.models import Offering, QueueEntry, Session, SessionEvent, User @@ -24,6 +24,7 @@ from app.domain.audit_service import AuditService from app.domain.credit_engine import CreditEngine from app.domain.session_events import record_session_event +from app.domain.tenancy import managed_owner_filter, session_is_managed router = APIRouter(prefix="/queue", tags=["queue"]) @@ -111,6 +112,19 @@ async def list_queue( principal.require(action="queue.read") ranked = await _ranked(db) + # ``queue.read`` only says the caller administers a group somewhere; the entries they may see + # are those whose session OWNER they manage (managed_owner_filter — the same predicate the + # session monitor and dashboard use). Without this an administrator of one organization read + # every other tenant's waiting sessions, owners and requests. + managed = managed_owner_filter(principal, "managed") + if managed is not None and ranked: + visible = set((await db.scalars( + select(Session.id).where( + Session.id.in_([e.session_id for e, _ in ranked]), managed + ) + )).all()) + ranked = [(e, sc) for e, sc in ranked if e.session_id in visible] + if cluster_id is not None: # A queue entry has no cluster of its own; it inherits the one from its session. Without # this the queue was the one panel with no cluster filter at all. @@ -199,9 +213,13 @@ async def cancel_queue_entry( if session is None: raise NotFound("session not found") - # Owner or group_admin+. + # Owner or group_admin+ over the session's tenant. The group check alone let a session created + # without a group (group_id=None) be cancelled by any group_admin of any organization, so the + # owner's tenancy decides as well. if session.owner_user_id != principal.user_id: principal.require(action="queue.update", group_id=session.group_id) + if not await session_is_managed(db, principal, session.id): + raise Forbidden("not permitted: queue.update") # Already assigned/scheduled -> cannot cancel from the queue (409). if session.status not in ("pending", "preparing"): @@ -255,6 +273,9 @@ async def update_priority( if session.group_id is not None: principal.require(action="queue.update", group_id=session.group_id) + # An ungrouped session is not nobody's: the owner's tenancy must be the caller's. + if not await session_is_managed(db, principal, session.id): + raise Forbidden("not permitted: queue.update") entry.priority = body.priority await db.flush() diff --git a/backend/app/api/schemas/internal.py b/backend/app/api/schemas/internal.py index fc1cedd..3d84aa3 100644 --- a/backend/app/api/schemas/internal.py +++ b/backend/app/api/schemas/internal.py @@ -13,13 +13,16 @@ class OperatorStatusEvent(BaseModel): node_name: str | None = None # k8s node the pod landed on (pod.spec.nodeName) yield_state: str | None = None # "Yielded" if operator did an in-place yield (not cold) pod_ref: str | None = None # "namespace/name" - used_mem_mb: int | None = None # measured occupancy (inventory reconciliation) + used_mem_mb: int | None = None # reported, unused: device occupancy comes from the + # inventory callback, never from a session's status message: str | None = None trace_id: str | None = None # W3C traceparent trace-id # Heartbeat facts (phase=heartbeat, also carried on phase changes when the pod exists). restart_count: int | None = None # kubelet container restartCount generation: int | None = None # CR generation the report describes container_state: str | None = None # Running|Waiting:|Terminated: + cluster_id: str | None = None # the reporting operator's cluster; cross-checked against + # its token (None from operators older than this field) ts: datetime # event time (UTC) @@ -83,7 +86,7 @@ class OperatorAuditEvent(BaseModel): actor: str # "operator:clu_" action: str # node.cordon|node.drain|pod.delete|session.force_terminate target: str # "gpu-node-3" | "gshare-sessions/ses-..-pod" - result: str # ok|failed + result: str # ok | failed (operators emit only "ok" today) detail: dict | None = None trace_id: str | None = None ts: datetime @@ -97,6 +100,7 @@ class OperatorVolumeObserved(BaseModel): capacity_gb: int = 0 # the claim's current size used_bytes: int | None = None # kubelet volume stats; None when nothing has it mounted mounted: bool = False # some pod currently mounts it + storage_class: str | None = None # the claim's StorageClass — with the cluster, the pool class OperatorSessionDisk(BaseModel): diff --git a/backend/app/api/schemas/metrics.py b/backend/app/api/schemas/metrics.py index 6f004e5..456ce82 100644 --- a/backend/app/api/schemas/metrics.py +++ b/backend/app/api/schemas/metrics.py @@ -133,6 +133,8 @@ class ClusterStoragePool(BaseModel): capacity_gb: int | None = None capacity_source: str | None = None # csi | manual capacity_reported_at: datetime | None = None + # Provisioned quota of the live volumes placed on this pool (GB) — allocation, not bytes. + used_gb: int = 0 class ClusterStorageDisk(BaseModel): @@ -144,6 +146,11 @@ class ClusterStorageDisk(BaseModel): class ClusterStorage(BaseModel): disk_gb: ClusterStorageDisk node_count: int = 0 + # Capacity summed over the pools listed (GB): the fleet picture. `disk_gb.total` is the + # placement bound (largest pool) and is what one volume can actually be created on. + capacity_gb: int = 0 + # Quota of live volumes whose PVC has not been created yet, so they sit on no pool. + unplaced_gb: int = 0 # True under a cluster filter: the pool is fleet-wide, not this cluster's own. shared: bool = False # The registered pools, each with the cluster it belongs to. diff --git a/backend/app/api/schemas/volume.py b/backend/app/api/schemas/volume.py index 7e1de17..585ab8e 100644 --- a/backend/app/api/schemas/volume.py +++ b/backend/app/api/schemas/volume.py @@ -29,6 +29,14 @@ class VolumeRead(ORMModel): owner_id: str | None = None owner_name: str | None = None # display name of the creator, for shared/admin listings shared_count: int = 0 # users granted access besides the owner (rw/ro) + # Where the data lives, once a session has mounted the volume and its PVC exists: the + # provisioning cluster and StorageClass, resolved to the registered storage pool when one + # matches. All None for a volume nothing has mounted yet. + cluster_id: str | None = None + cluster_name: str | None = None + storage_class: str | None = None + pool_id: str | None = None + pool_name: str | None = None # Sessions currently mounting this volume — filled on the single-volume read only # (response_model strips undeclared keys, so the field must exist here). active_mounts: list[dict] | None = None diff --git a/backend/app/api/sessions_router.py b/backend/app/api/sessions_router.py index e398499..e070a57 100644 --- a/backend/app/api/sessions_router.py +++ b/backend/app/api/sessions_router.py @@ -454,11 +454,47 @@ async def sessions_monitor_events( parameterized route captures ``/sessions/events`` (session_id="events") and returns 404. """ principal.require(action="session.monitor") + # ``session.monitor`` is a rank check; the stream itself is fleet-wide (session:events:*), so + # every event is gated on the same owner predicate the monitoring list uses. Otherwise a + # group_admin of one organization watched every other tenant's session ids and phase changes. return EventSourceResponse( - _sse_events([_QUEUE_CHANNEL], [_MONITOR_PATTERN], request) + _sse_events([_QUEUE_CHANNEL], [_MONITOR_PATTERN], request, + allow=_monitor_event_filter(principal)) ) +def _monitor_event_filter(principal: Principal): + """Per-event tenant gate for the monitor stream: None for a super_admin (nothing to hide), + otherwise a coroutine deciding by the event's session owner, memoised per session id since a + session never changes owner.""" + if managed_owner_filter(principal, "managed") is None: + return None + decided: dict[str, bool] = {} + + async def _allow(msg: dict) -> bool: + channel = msg.get("channel") + if isinstance(channel, bytes): + channel = channel.decode() + sid: str | None = None + if isinstance(channel, str) and channel.startswith("session:events:"): + sid = channel.removeprefix("session:events:") + elif channel == _QUEUE_CHANNEL: + try: + sid = (json.loads(msg.get("data") or "{}") or {}).get("session_id") + except (ValueError, TypeError): + sid = None + if not sid: + return False + if sid not in decided: + if len(decided) > 4096: + decided.clear() + async with get_sessionmaker()() as db: + decided[sid] = await session_is_managed(db, principal, sid) + return decided[sid] + + return _allow + + @router.get("/sessions/gpu-availability") async def gpu_availability( fleet: bool = Query(default=False), @@ -985,12 +1021,13 @@ async def restore_checkpoint( async def _sse_events(channels: list[str] | None, patterns: list[str] | None, - request: Request): + request: Request, allow=None): """Generic SSE generator over Redis pub/sub with periodic heartbeat. Subscribes to the given channel(s)/pattern(s), yields each published message as an SSE event, and emits a heartbeat every _HEARTBEAT_SEC seconds. Terminates cleanly on client disconnect or - cancellation, always unsubscribing/closing the pubsub. + cancellation, always unsubscribing/closing the pubsub. ``allow(msg)`` — when given — is + awaited per message and a False verdict drops the event (tenant scoping of a shared pattern). """ r = get_redis() pubsub = r.pubsub() @@ -1020,6 +1057,8 @@ async def _sse_events(channels: list[str] | None, patterns: list[str] | None, data = msg.get("data") if not isinstance(data, str): continue + if allow is not None and not await allow(msg): + continue event_type = "message" payload = data try: diff --git a/backend/app/api/storage_pools_router.py b/backend/app/api/storage_pools_router.py index d104b0a..04acb41 100644 --- a/backend/app/api/storage_pools_router.py +++ b/backend/app/api/storage_pools_router.py @@ -12,7 +12,7 @@ from fastapi import APIRouter, Depends, Query, Response, status from pydantic import BaseModel, Field -from sqlalchemy import select +from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_principal @@ -34,7 +34,11 @@ class _Validation(DomainError): class PoolCreate(BaseModel): - name: str = Field(min_length=1, max_length=80) + # The display name is the linked node's hostname whenever a node is given — a storage server + # is known by its hostname everywhere else in the console, and a second server must not be + # told apart by a nickname someone typed. A name is only required for a pool with no node + # (an external appliance the cluster never sees as a node). + name: str | None = Field(default=None, min_length=1, max_length=80) cluster_id: str # the cluster whose storage server this is storage_class: str = Field(min_length=1, max_length=200) node_id: str | None = None @@ -124,6 +128,28 @@ async def list_pools( return {"data": [await _view(db, p, names) for p in pools]} +async def _release_retired_key(db: AsyncSession, cluster_id: str, storage_class: str) -> None: + """Make (cluster, StorageClass) registrable again after the pool holding it was deregistered. + + Deregistering soft-deletes the row, and the uniqueness of the key ignores nothing — so a class + registered, removed and registered again hit the constraint and surfaced as a 500. The retired + row has nothing left to say (its shares were cleared, the audit log keeps the history), so it + is purged here rather than kept beside its replacement. + """ + retired = (await db.scalars( + select(StoragePool).where( + StoragePool.cluster_id == cluster_id, + StoragePool.storage_class == storage_class, + StoragePool.deleted_at.is_not(None), + ) + )).all() + for row in retired: + await db.execute(delete(StoragePoolShare).where(StoragePoolShare.pool_id == row.id)) + await db.delete(row) + if retired: + await db.flush() + + @router.post("", status_code=status.HTTP_201_CREATED) async def create_pool( body: PoolCreate, @@ -157,8 +183,12 @@ async def create_pool( raise _Validation("that node is in another cluster", {"node_id": body.node_id, "cluster_id": node.cluster_id}) hostname = node.hostname + name = hostname or (body.name or "").strip() + if not name: + raise _Validation("a pool without a node needs a name", {"name": body.name}) + await _release_retired_key(db, body.cluster_id, body.storage_class.strip()) pool = StoragePool( - id=ids.new("storage_pool"), name=body.name.strip(), cluster_id=body.cluster_id, + id=ids.new("storage_pool"), name=name, cluster_id=body.cluster_id, node_id=body.node_id, node_hostname=hostname, storage_class=body.storage_class.strip(), share_scope=body.share_scope, manual_capacity_gb=body.manual_capacity_gb, capacity_source="manual" if body.manual_capacity_gb else None, @@ -186,12 +216,23 @@ async def update_pool( async with db.begin(): pool = await _load(db, pool_id) changes: dict[str, Any] = {} - if body.name is not None and body.name.strip() != pool.name: - changes["name"] = {"from": pool.name, "to": body.name.strip()} - pool.name = body.name.strip() if body.storage_class is not None and body.storage_class.strip() != pool.storage_class: # The class is the join key for the operator's capacity report; a changed class means # the stored measurement describes something else. + taken = (await db.scalars( + select(StoragePool).where( + StoragePool.cluster_id == pool.cluster_id, + StoragePool.storage_class == body.storage_class.strip(), + StoragePool.deleted_at.is_(None), + StoragePool.id != pool.id, + ) + )).first() + if taken is not None: + raise AlreadyExists( + "a pool for this cluster and storage class already exists", + {"pool_id": taken.id, "storage_class": body.storage_class.strip()}, + ) + await _release_retired_key(db, pool.cluster_id, body.storage_class.strip()) changes["storage_class"] = {"from": pool.storage_class, "to": body.storage_class.strip()} pool.storage_class = body.storage_class.strip() pool.capacity_bytes = None @@ -207,6 +248,11 @@ async def update_pool( changes["node_id"] = {"from": pool.node_id, "to": body.node_id or None} pool.node_id = body.node_id or None pool.node_hostname = node.hostname if node is not None else None + # Name: the node's hostname while a node is linked; otherwise whatever was sent. + wanted = pool.node_hostname or (body.name.strip() if body.name is not None else pool.name) + if wanted and wanted != pool.name: + changes["name"] = {"from": pool.name, "to": wanted} + pool.name = wanted if body.manual_capacity_gb is not None and body.manual_capacity_gb != pool.manual_capacity_gb: changes["manual_capacity_gb"] = {"from": pool.manual_capacity_gb, "to": body.manual_capacity_gb} pool.manual_capacity_gb = body.manual_capacity_gb or None diff --git a/backend/app/api/users_router.py b/backend/app/api/users_router.py index c0f6b87..adbecf7 100644 --- a/backend/app/api/users_router.py +++ b/backend/app/api/users_router.py @@ -196,6 +196,34 @@ async def _audit_login( await db.commit() + +def _client_ip(request: Request) -> str: + """The client address for rate limiting and the login audit trail. + + X-Forwarded-For is read from the right, TRUSTED_PROXY_HOPS entries in: the proxies in front + of the API append to the header, so the rightmost entries are theirs and everything further + left is whatever the client sent. Reading the FIRST entry let a client pick its own bucket + (a fresh address per attempt defeats the per-IP limit). Without the header, the socket peer. + """ + hops = [h.strip() for h in (request.headers.get("x-forwarded-for") or "").split(",")] + hops = [h for h in hops if h] + if hops: + trusted = max(1, int(getattr(settings, "TRUSTED_PROXY_HOPS", 1) or 1)) + return hops[-trusted] if len(hops) >= trusted else hops[0] + return request.client.host if request.client else "unknown" + + +# A hash to verify against when the account does not exist, so a miss costs the same time as a +# wrong password and the response time does not say which of the two it was. +_TIMING_PAD_HASH: str | None = None + + +async def _pad_password_check(password: str) -> None: + global _TIMING_PAD_HASH + if _TIMING_PAD_HASH is None: + _TIMING_PAD_HASH = await hash_password_async(secrets.token_urlsafe(16)) + await verify_password_async(password or "", _TIMING_PAD_HASH) + @router.post("/auth/login") async def auth_login(body: _LoginRequest, request: Request, db: AsyncSession = Depends(get_db)): """Email+password local login (gated by AUTH_ALLOW_LOCAL_PASSWORD). @@ -209,9 +237,7 @@ async def auth_login(body: _LoginRequest, request: Request, db: AsyncSession = D # Brute-force / thundering-herd guard. Per-email first (targeted stuffing), then per-client-IP # (broad sweeps). The deployment sits behind ingress, so the first X-Forwarded-For hop is the # client; fall back to the socket peer. - client_ip = (request.headers.get("x-forwarded-for") or "").split(",")[0].strip() or ( - request.client.host if request.client else "unknown" - ) + client_ip = _client_ip(request) if email: await check_rate(f"login:email:{email}", limit=10, window_sec=300) await check_rate(f"login:ip:{client_ip}", limit=30, window_sec=300) @@ -221,6 +247,7 @@ async def auth_login(body: _LoginRequest, request: Request, db: AsyncSession = D await db.execute(select(User).where(func.lower(User.email) == email)) ).scalar_one_or_none() if user is None: + await _pad_password_check(body.password or "") await _audit_login(db, actor=email or "unknown", ok=False, ip=client_ip, email=email, reason="unknown_account") raise Unauthenticated("invalid credentials") @@ -230,12 +257,6 @@ async def auth_login(body: _LoginRequest, request: Request, db: AsyncSession = D await _audit_login(db, actor=user.id, ok=False, ip=client_ip, email=email, reason="account_disabled") raise Unauthenticated("invalid credentials") - # A self-registered account waiting for approval gets a message it can act on: the - # credentials are right, the account simply is not open yet. - if user.status == "pending": - await _audit_login(db, actor=user.id, ok=False, ip=client_ip, email=email, - reason="account_pending") - raise _SignupPending("account awaiting administrator approval") # Password check. An account with no hash cannot be authenticated at all: letting it through # on any password turned "credential not set yet" into "no credential required", which is a # login bypass for every bootstrap or legacy row. Such an account needs an administrator to @@ -248,6 +269,13 @@ async def auth_login(body: _LoginRequest, request: Request, db: AsyncSession = D await _audit_login(db, actor=user.id, ok=False, ip=client_ip, email=email, reason="bad_password") raise Unauthenticated("invalid credentials") + # A self-registered account waiting for approval gets a message it can act on — but only + # once the password has matched. Answering before the check told anyone with the address + # that a sign-up is pending there. + if user.status == "pending": + await _audit_login(db, actor=user.id, ok=False, ip=client_ip, email=email, + reason="account_pending") + raise _SignupPending("account awaiting administrator approval") await _audit_login(db, actor=user.id, ok=True, ip=client_ip, email=email) return _issue_token(user) @@ -296,9 +324,7 @@ async def auth_signup(body: _SignupRequest, request: Request, db: AsyncSession = """ from app.api.system_router import signup_policy - client_ip = (request.headers.get("x-forwarded-for") or "").split(",")[0].strip() or ( - request.client.host if request.client else "unknown" - ) + client_ip = _client_ip(request) await check_rate(f"signup:ip:{client_ip}", limit=5, window_sec=3600) mode, domains = await signup_policy(db) @@ -733,6 +759,10 @@ async def _mk_password() -> tuple[str, str]: password_hash=pw_hash, must_change_password=True, ) db.add(user) + # The user row has to land before its membership: without a relationship() to order them, + # SQLAlchemy's unit of work flushes mappers in class-name order (Membership before User), + # and Postgres then rejects the membership on fk_membership_user_id_user. + await db.flush() db.add(CreditWallet( id=ids.new("wallet"), owner_type="user", owner_id=user.id, balance=Decimal("0"), reserved=Decimal("0"), @@ -1177,6 +1207,13 @@ async def set_user_department( ) ) ).all() + # An org_admin may move people WITHIN their organization or place an unaffiliated user; a user + # who belongs to another organization is not theirs to pull in. Enrolling them here made the + # caller their administrator (suspend, soft-delete, password reset), a cross-tenant takeover. + if not is_super and existing and not any(m.group_id in managed_group_ids for m in existing): + raise Forbidden( + "not permitted: the user belongs to another organization", {"user_id": user_id} + ) removable = [ m for m in existing if m.group_id != target_gid and (is_super or m.group_id in managed_group_ids) diff --git a/backend/app/api/volumes_router.py b/backend/app/api/volumes_router.py index 6f58e50..bbcdee9 100644 --- a/backend/app/api/volumes_router.py +++ b/backend/app/api/volumes_router.py @@ -28,9 +28,11 @@ ) from app.db.base import get_db from app.db.models import ( + Cluster, Project, Session, StorageFolder, + StoragePool, StorageVolume, User, VolumeMount, @@ -282,6 +284,7 @@ def _owner_key(v: StorageVolume) -> str | None: (await db.execute(select(Project.id, Project.name).where(Project.id.in_(group_owner_ids)))).all() ) owner_names.update(gnames) + placement = await _placement_of(db, vols) return [ VolumeRead.model_validate(v).model_copy( update={ @@ -289,12 +292,38 @@ def _owner_key(v: StorageVolume) -> str | None: "owner_id": _owner_key(v), "owner_name": owner_names.get(_owner_key(v)), "shared_count": int(shared_counts.get(v.id, 0)), + **placement.get(v.id, {}), } ) for v in vols ] +async def _placement_of(db: AsyncSession, vols: list[StorageVolume]) -> dict[str, dict]: + """Name where each volume's data lives: its cluster, and the registered pool for + (cluster, StorageClass) when there is one. One query per kind, not per volume.""" + placed = [v for v in vols if v.cluster_id] + if not placed: + return {} + cluster_ids = {v.cluster_id for v in placed} + cluster_names = dict( + (await db.execute(select(Cluster.id, Cluster.name).where(Cluster.id.in_(cluster_ids)))).all() + ) + pools = (await db.execute( + select(StoragePool.id, StoragePool.name, StoragePool.cluster_id, StoragePool.storage_class) + .where(StoragePool.cluster_id.in_(cluster_ids), StoragePool.deleted_at.is_(None)) + )).all() + pool_by_key = {(cid, sc): (pid, pname) for pid, pname, cid, sc in pools} + out: dict[str, dict] = {} + for v in placed: + pid, pname = pool_by_key.get((v.cluster_id, v.storage_class), (None, None)) + out[v.id] = { + "cluster_id": v.cluster_id, "cluster_name": cluster_names.get(v.cluster_id), + "storage_class": v.storage_class, "pool_id": pid, "pool_name": pname, + } + return out + + @router.get("/quota-usage") async def storage_quota_usage( scope: str = Query(...), @@ -305,7 +334,19 @@ async def storage_quota_usage( """Per-scope storage limit, usage, and headroom, for the limit warning on the new-volume form. Reads the same (scope, scope_id) policy and volume quota sum that _assert_storage_quota uses at - creation time. has_limit=false means unlimited: no policy, or a limit of 0.""" + creation time. has_limit=false means unlimited: no policy, or a limit of 0. + + The scope must be one the caller could create a volume in or belongs to: their own user scope, + a group they are a member of, or the global scope. Another tenant's limit and provisioned total + are not theirs to read.""" + if "super_admin" not in principal.global_roles: + allowed = ( + scope == "global" + or (scope == "user" and scope_id == principal.user_id) + or (scope == "group" and scope_id in principal.memberships) + ) + if not allowed: + raise Forbidden("not permitted: storage quota of another scope") limit, allocated = await _storage_usage(db, scope, scope_id) cap, pool_alloc = await _physical_storage(db) physical = {"physical_remaining_gb": max(0, cap - pool_alloc)} if cap is not None else {} @@ -472,9 +513,11 @@ async def get_volume( Session.status.notin_(("terminated", "error")), ) )).all() + placement = await _placement_of(db, [vol]) return VolumeRead.model_validate(vol).model_copy( update={ "role": role, "owner_id": owner_key, "owner_name": owner_name, + **placement.get(vol.id, {}), "active_mounts": [ {"session_id": sid, "name": nm, "status": st, "mount_path": mp, "mode": md, "owner_user_id": ou, "owner_name": on} @@ -608,6 +651,28 @@ async def list_folders( } +async def _assert_volume_write(db: AsyncSession, principal: Principal, vol: StorageVolume) -> None: + """Authorize changing a volume's contents metadata (folders): read access is not enough. + + Owner, group member, super_admin, or a grantee holding rw/owner; a read-only grantee and a + plain reader of a global volume may look but not add.""" + if "super_admin" in principal.global_roles: + return + if vol.scope == "user" and vol.scope_id == principal.user_id: + return + if vol.scope == "group" and vol.scope_id in principal.memberships: + return + role = await db.scalar( + select(VolumePermission.role).where( + VolumePermission.volume_id == vol.id, + VolumePermission.user_id == principal.user_id, + ) + ) + if role in ("owner", "rw"): + return + raise Forbidden("not permitted: read-only access to this volume") + + @router.post("/{volume_id}/folders", status_code=status.HTTP_201_CREATED) async def create_folder( volume_id: str, @@ -617,7 +682,7 @@ async def create_folder( ): """Create a folder (absolute path) under a volume.""" vol = await _load_volume(db, volume_id) - await _assert_volume_access(db, principal, vol) + await _assert_volume_write(db, principal, vol) path = body.get("path") if not path or not isinstance(path, str) or not path.startswith("/"): raise _ValidationFailed("path must be an absolute string") diff --git a/backend/app/auth/bootstrap.py b/backend/app/auth/bootstrap.py index 08aa14b..2fc43f4 100644 --- a/backend/app/auth/bootstrap.py +++ b/backend/app/auth/bootstrap.py @@ -377,4 +377,4 @@ async def seed_bootstrap_admin() -> None: if not user.password_hash: user.password_hash = hash_password(password) user.must_change_password = True - log.info("bootstrap admin password seeded: %s", email) + log.info("bootstrap admin account seeded with an initial password: %s", email) diff --git a/backend/app/auth/rbac.py b/backend/app/auth/rbac.py index d2f0adf..a9b4d22 100644 --- a/backend/app/auth/rbac.py +++ b/backend/app/auth/rbac.py @@ -137,6 +137,8 @@ class Principal: memberships: dict[str, str] = field(default_factory=dict) # {group_id: role} email: str | None = None org_admin_orgs: set[str] = field(default_factory=set) # ids of the organizations this principal administers, used to scope list queries + # Read from the row, so an administrator's forced reset reaches tokens issued before it. + must_change_password: bool = False def require(self, action: str, group_id: str | None = None) -> None: """Guard: raise Forbidden (403) unless the principal may perform ``action``.""" @@ -192,8 +194,9 @@ def rbac_allows(principal: Principal, action: str, group_id: str | None = None) # Per-process Principal cache: resolve_principal runs on EVERY authenticated request (2-3 # SELECTs), which at thousands of users is pure overhead. Memberships change rarely; 30s of -# staleness is acceptable (revocation via must_change_password rides in the JWT claims and is -# unaffected). Keyed by user id + the claim fields that shape the Principal. +# staleness is acceptable: the account-state gate, the global roles and must_change_password are +# read from the row inside this same resolution, so a suspension, a demotion or a forced reset +# lags by at most the TTL. Keyed by user id + the claim fields that shape the Principal. _PRINCIPAL_CACHE: dict[str, tuple[float, Principal]] = {} _PRINCIPAL_TTL_SEC = 30.0 _PRINCIPAL_CACHE_MAX = 8192 @@ -274,14 +277,14 @@ def _expired(m: Membership) -> bool: if cur is None or _RANK.get(cur, -1) < _RANK["org_admin"]: memberships[pid] = "org_admin" - # Prefer the token's global_roles list; an older token carrying a single global_role is promoted - # to a list. - roles_claim = claims.get("global_roles") - if isinstance(roles_claim, list): - global_roles = {r for r in roles_claim if r in GLOBAL_ROLES} - else: - single = claims.get("global_role") - global_roles = {single} if single in GLOBAL_ROLES else set() + # Global roles come from the row, never from the token. A bearer token lives 24h and there is + # no revocation list, so a demoted super_admin would otherwise keep the role until the token + # expired; the row is re-read here (30s principal cache) exactly like the account-state gate + # above. The claim is still issued for older clients that display it, but it decides nothing. + db_roles = list(user_row.global_roles or []) + if not db_roles and user_row.global_role in GLOBAL_ROLES: + db_roles = [user_row.global_role] # a row from before global_roles existed + global_roles = {r for r in db_roles if r in GLOBAL_ROLES} return Principal( user_id=claims["sub"], @@ -290,4 +293,5 @@ def _expired(m: Membership) -> bool: memberships=memberships, email=claims.get("email"), org_admin_orgs=org_admin_orgs, + must_change_password=bool(user_row.must_change_password), ) diff --git a/backend/app/cluster/crd.py b/backend/app/cluster/crd.py index 74ca8a0..a2cba20 100644 --- a/backend/app/cluster/crd.py +++ b/backend/app/cluster/crd.py @@ -87,6 +87,28 @@ class MissingClusterCredential(DomainError): } +# Every spec key the desired state may carry. A re-apply of an existing custom resource is a JSON +# merge-patch, and a merge-patch only touches the keys it names — so a key the new desired state +# no longer carries has to be sent as null explicitly, or the value from the previous run stays +# on the object (a borrowed card, a pinned UUID, an excluded node, a paused flag). +_MERGE_PATCH_CLEARABLE = tuple(_CRD_KEY_MAP.values()) + ("migProfile", "connect", "volumes") +MERGE_PATCH_CONTENT_TYPE = "application/merge-patch+json" + + +def _as_merge_patch(body: dict[str, Any]) -> dict[str, Any]: + """The body for re-applying an existing object: the same desired state, with every clearable + spec key present (null when absent) and the lifetime-cap annotation nulled when no cap is + resolved, so the merge removes what the previous apply set.""" + spec = dict(body.get("spec") or {}) + for key in _MERGE_PATCH_CLEARABLE: + spec.setdefault(key, None) + metadata = dict(body.get("metadata") or {}) + annotations = dict(metadata.get("annotations") or {}) + annotations.setdefault(f"{GROUP}/max-runtime-sec", None) + metadata["annotations"] = annotations + return {**body, "metadata": metadata, "spec": spec} + + def _cr_name(session_id: str) -> str: """Convert a session id to a Kubernetes object name (RFC 1123). @@ -465,11 +487,11 @@ async def restamp_reaper( self, cluster_id: str, session_id: str, owner: str | None, group_id: str | None, resource_class: str | None = None, ) -> None: - """Merge-patch a live CR's idle/max-runtime annotations from the CURRENT policy. + """JSON-patch a live CR's idle/max-runtime annotations from the CURRENT policy. Called on policy writes so a policy edit reaches RUNNING sessions immediately (not only - on the next resume). A dict body makes the client send merge-patch; a null value removes - the max-runtime annotation when the cap is lifted.""" + on the next resume). The body is a JSON-patch list (a dict would be sent as a strategic + merge patch, which custom resources reject); "0" stands for "no cap" when it is lifted.""" from app.core.config import settings # lazy: keep module import-light spec_like = {"owner": owner, "group_id": group_id, "resource_class": resource_class} @@ -503,12 +525,14 @@ async def restamp_reaper( async def apply( self, cluster_id: str, spec: dict[str, Any], traceparent: str | None = None ) -> None: - """Apply (create/patch) the GShareSession CR to the target cluster. + """Apply (create, or merge-patch on 409) the GShareSession CR to the target cluster. Loads the cluster's bootstrap kubeconfig/SA (from secret ref), builds an async - CustomObjectsApi, and server-side-applies gshare.io/v1 gsharesessions. CR apply ONLY. - Idempotent: server-side apply (force=True, our field manager) creates or patches the same - named object, so repeated handoffs converge to the desired spec. + CustomObjectsApi and creates the gshare.io/v1 gsharesession; when the object already + exists (409) it is JSON merge-patched with the full desired state, absent keys sent as + null, so repeated handoffs converge to the desired spec. CR apply ONLY — no workload API. + Server-side apply is not used: it needs the apply-patch content type, which the custom + objects client does not select for a dict body. """ cluster = await self._load_cluster(cluster_id) # Resolve the idle timeout and lifetime cap through the policy hierarchy (user, group, @@ -522,9 +546,9 @@ async def apply( body = self.build_object(spec, traceparent) name = body["metadata"]["name"] async with await self._client_factory(cluster) as api: - # Idempotent create-or-patch: create when new, merge-patch on a 409. Server-side apply - # is not used because it requires the apply content type, which merge patch rejects with - # 422. + # Idempotent create-or-patch: create when new, merge-patch on a 409. The content type + # is forced: kubernetes_asyncio sends a dict body as a strategic merge patch, which + # custom resources do not support (415), so without it the 409 path never succeeded. try: await api.create_namespaced_custom_object( group=GROUP, version=VERSION, namespace=SESSION_NAMESPACE, plural=PLURAL, @@ -534,7 +558,8 @@ async def apply( if getattr(exc, "status", None) == 409: # AlreadyExists: update the existing object await api.patch_namespaced_custom_object( group=GROUP, version=VERSION, namespace=SESSION_NAMESPACE, plural=PLURAL, - name=name, body=body, + name=name, body=_as_merge_patch(body), + _content_type=MERGE_PATCH_CONTENT_TYPE, ) else: raise diff --git a/backend/app/cluster/status_sync.py b/backend/app/cluster/status_sync.py index 52944a0..9c37a4a 100644 --- a/backend/app/cluster/status_sync.py +++ b/backend/app/cluster/status_sync.py @@ -105,14 +105,18 @@ async def on_status(self, session_id: str, ev: OperatorStatusEvent) -> None: # a deleted pod's exit lands after its successor is up). Acting on it would settle the # bill and release the NEW reservation out from under a session that continues. if phase in ("paused", "terminated", "error") and sess.status == "running": - stale = sess.started_at is not None and _aware(ev.ts) < _aware(sess.started_at) - # Generation beats timestamps: the operator may reconcile the stop's generation - # AFTER the resume was committed (its report is then newer than the run start), - # so compare against the generation the resume produced. - if not stale and ev.generation: - marker = await get_redis().get(f"resume-gen:{sess.id}") - if marker is not None and int(ev.generation) < int(marker): - stale = True + # The CR generation is the primary test: both numbers come from the same + # apiserver, so it holds however far apart the operator's clock and ours are. + # The timestamp is only the fallback for a report that cannot be placed by + # generation (an older operator, or a resume whose patch returned none) — it + # compares two machines' clocks, so an operator running behind used to have its + # legitimate Paused/Terminated discarded for the whole skew window after a + # resume, with nothing to recover it. + marker = await get_redis().get(f"resume-gen:{sess.id}") if ev.generation else None + if marker is not None: + stale = int(ev.generation) < int(marker) + else: + stale = sess.started_at is not None and _aware(ev.ts) < _aware(sess.started_at) if stale: log.info("stale %s report for running session %s ignored (event %s gen %s < run start %s)", phase, sess.id, ev.ts, ev.generation, sess.started_at) @@ -260,6 +264,17 @@ async def _on_running(self, sess: Session, ev: OperatorStatusEvent) -> None: now = self._event_ts(ev) was_running = sess.status == "running" + if sess.status in ("terminating", "terminated", "error"): + # A settled (or settling) session must not come back. Its hold was released and its + # settle key is spent, so a resurrected row would bill against no reservation and + # never settle again. A late `running` for a finished session is a stale report, not + # a transition — and it must be refused BEFORE the ledger is touched: binding the + # card here left a live Allocation (and used_* on the device) that nothing would + # ever release, since the session was already over. + log.info("ignoring running report for finished session=%s status=%s", + sess.id, sess.status) + return + # Idempotency: a single live Allocation per session (partial UNIQUE WHERE ended_at IS NULL). # If one already exists, this is a duplicate `running` (operator restart) -> no-op for the # ledger, but we still converge interim session fields. @@ -283,13 +298,6 @@ async def _on_running(self, sess: Session, ev: OperatorStatusEvent) -> None: sess.node_hostname = ev.node_name if sess.started_at is None: sess.started_at = now - if sess.status in ("terminated", "error"): - # A settled session must not come back. Its hold was released and its settle key is - # spent, so a resurrected row would bill against no reservation and never settle - # again. A late `running` for a finished session is a stale report, not a transition. - log.info("ignoring running report for finished session=%s status=%s", - sess.id, sess.status) - return if sess.status != "running": # preparing/pending -> running (allowed by the lifecycle SM). sess.status = "running" diff --git a/backend/app/cluster/volume_sync.py b/backend/app/cluster/volume_sync.py index 2f8fb89..cf6aa72 100644 --- a/backend/app/cluster/volume_sync.py +++ b/backend/app/cluster/volume_sync.py @@ -89,14 +89,35 @@ async def sync(self, report: OperatorVolumeSync) -> VolumeSyncResponse: if deleted_at.tzinfo is None: deleted_at = deleted_at.replace(tzinfo=UTC) d.reclaim = self.now - deleted_at >= grace - elif obs.used_bytes is not None: - self._apply_usage(row, obs) + else: + self._apply_placement(row, obs, report.cluster_id) + if obs.used_bytes is not None: + self._apply_usage(row, obs) out.append(d) await self.db.commit() if orphans: log.warning("volume_sync: %d PVC(s) without a ledger row; left untouched", orphans) return VolumeSyncResponse(volumes=out, orphans=orphans) + def _apply_placement(self, row: StorageVolume, obs: OperatorVolumeObserved, cluster_id: str | None) -> None: + """Pin the volume to the cluster (and StorageClass) whose operator reports its PVC. + + Written once, on the first report: the PVC exists on exactly one cluster and the data is + there. A report of the same PVC name from another cluster afterwards is the double- + provisioning bug the placement rule exists to prevent, so it is logged, not adopted. + """ + if not cluster_id: + return + if row.cluster_id is None: + row.cluster_id = cluster_id + row.storage_class = obs.storage_class or None + row.provisioned_at = self.now + elif row.cluster_id != cluster_id: + log.warning("volume_sync: %s reported from %s but its data lives on %s; ignoring", + row.id, cluster_id, row.cluster_id) + elif row.storage_class is None and obs.storage_class: + row.storage_class = obs.storage_class + # Scratch-disk pre-warning knobs: warn at 80% of the ephemeral-storage limit (kubelet evicts # at 100%), keep the gauge reading for 15 min (refreshed every ~5-min sync tick), and re-warn # a given session at most once per 6 h. diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 6ca2f10..4df455b 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -37,6 +37,12 @@ class Settings(BaseSettings): # external-secrets, and an empty value fails fast at startup. # Generate with: openssl rand -hex 32. hack/gen-secrets.sh writes it into .env or a Secret. USER_JWT_SECRET: str = "" + # How many proxies in front of the API append to X-Forwarded-For. The client address used by + # the login and sign-up rate limiters is taken that many hops from the RIGHT of the header, + # because a client can prepend anything it likes but cannot remove what a trusted proxy + # appends. 1 = one proxy (ingress-nginx, or the compose console); 2 = a load balancer that + # forwards the header in front of the ingress. + TRUSTED_PROXY_HOPS: int = 1 # Bootstrap administrator: an email and initial password that guarantee a super_admin account at # startup, from GSHARE_BOOTSTRAP_ADMIN_EMAIL and GSHARE_BOOTSTRAP_ADMIN_PASSWORD. The display # name is edited from the console. diff --git a/backend/app/db/models.py b/backend/app/db/models.py index fbfa678..9816c7b 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -52,8 +52,11 @@ class SoftDeleteMixin: # ── Identity / tenancy ── class User(Base, TimestampMixin, SoftDeleteMixin): __tablename__ = "user" + # The live schema carries the UNIQUE as a named constraint plus a plain index (0001); + # declared the same way here so `alembic check` sees no difference. + __table_args__ = (UniqueConstraint("email", name="uq_user_email"),) id: Mapped[str] = mapped_column(String, primary_key=True) # usr_ULID - email: Mapped[str] = mapped_column(String, unique=True, index=True) + email: Mapped[str] = mapped_column(String, index=True) name: Mapped[str] = mapped_column(String) status: Mapped[str] = mapped_column(String, default="active") # global_roles is the source of truth and may hold several roles. global_role is the primary one @@ -496,6 +499,8 @@ class GpuDevice(Base, TimestampMixin): # lend_state: Mapped[str] = mapped_column(String, default="", server_default=text("''")) __table_args__ = ( + # One alias per card within a cluster (0052); NULL aliases stay free of it. + Index("uq_gpu_device_cluster_alias", "cluster_id", "alias", unique=True), # Final overcommit defense. CheckConstraint( "used_mem_mb <= total_mem_mb AND used_cores <= total_cores", name="no_overcommit" @@ -655,6 +660,15 @@ class StorageVolume(Base, TimestampMixin, SoftDeleteMixin): # Owner's lock: no NEW session may mount the volume while set (existing mounts keep running), # so a shared volume can be drained and deleted without a race against fresh mounts. mount_locked: Mapped[bool] = mapped_column(Boolean, default=False, server_default=text("false")) + # Where the data physically lives, learned from the operator's first report of the PVC: the + # cluster whose operator provisioned it and the StorageClass the claim names. Together they + # identify the storage pool (cluster, class). NULL until a session has mounted the volume — + # the PVC is created lazily — and never moved afterwards: the data is on that server. + cluster_id: Mapped[str | None] = mapped_column( + ForeignKey("cluster.id", ondelete="SET NULL"), default=None, index=True, + ) + storage_class: Mapped[str | None] = mapped_column(String, default=None) + provisioned_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None) # Partial unique index including the name, excluding soft-deleted rows, so one scope can hold # several volumes as long as their names differ. __table_args__ = ( @@ -781,6 +795,8 @@ class SystemSetting(Base, TimestampMixin): class AuditLog(Base, TimestampMixin): """Hash-chained audit log; this plane is the only writer.""" __tablename__ = "audit_log" + # The list and export endpoints order by time (0031). + __table_args__ = (Index("ix_audit_log_created_at", "created_at"),) id: Mapped[str] = mapped_column(String, primary_key=True) # aud_ULID actor: Mapped[str] = mapped_column(String, index=True) action: Mapped[str] = mapped_column(String, index=True) diff --git a/backend/app/domain/credit_engine.py b/backend/app/domain/credit_engine.py index a5cbb68..cbb8acb 100644 --- a/backend/app/domain/credit_engine.py +++ b/backend/app/domain/credit_engine.py @@ -99,8 +99,11 @@ async def _keyed_atomic(self): async with self._atomic(): yield except IntegrityError as exc: + # Postgres names the constraint (credit_transaction_idempotency_key_key); SQLite names + # the column (credit_transaction.idempotency_key). Both carry the column name, and no + # other table has one — any other unique violation is a real failure. detail = str(exc.orig or exc).lower() - if "idempotency" not in detail and "uq_" not in detail and "unique" not in detail: + if "idempotency_key" not in detail: raise async def hold(self, wallet_id: str, amount: Decimal, key: str) -> None: @@ -144,6 +147,12 @@ async def consume(self, session, minute_bucket: int, now: datetime) -> None: async with self._keyed_atomic(): if await self._txn_exists(key): return # per-minute dedupe (idempotent retry / operator restart) + # A settled session is terminal (terminated, or an error row whose stranded hold was + # refunded) and its hold is already released. A late tick that lands after settle — + # a delayed billing batch, a status callback replayed out of order — must not reopen + # billing: the charge would have no settle left to reconcile it. + if await self._txn_exists(f"settle:{session.id}"): + return wallet = await self.db.get( CreditWallet, session.billing_wallet_id, with_for_update=True ) diff --git a/backend/app/domain/session_service.py b/backend/app/domain/session_service.py index 4259036..10c41d7 100644 --- a/backend/app/domain/session_service.py +++ b/backend/app/domain/session_service.py @@ -236,6 +236,14 @@ async def stop(self, session_id: str, *, reason: str = "user_stopped"): is_yield = sess.pause_mode == "yield" # Close our read tx so credit.consume owns a clean per-wallet FOR UPDATE tx. await self.db.commit() + # The operator is told to tear the pod down (or yield its VRAM) FIRST, while the ledger + # still holds the card. Releasing the reservation before this patch was applied meant a + # failed handoff (cluster unreachable, custom resource gone) left a row that said paused + # over a pod that kept computing — for free, on a card the ledger now offered to the next + # session. If the patch lands and the steps below fail instead, the operator's Paused + # report converges the row (status_sync treats it as an operator-driven pause). + await self.handoff.set_paused(sess, True) + await self.db.commit() # set_paused's cluster lookup autobegan a transaction # Event-correction: charge the remaining (unbilled) running time up to now. await self.credit.consume(sess, _minute_bucket(now), now) # Return GPU capacity (DB) — operator releases the physical pod via spec.paused below. @@ -272,9 +280,8 @@ async def stop(self, session_id: str, *, reason: str = "user_stopped"): ) await publish_session_event(sess.id, {"phase": "paused", "status": "paused"}) await self.db.commit() - # The operator tears the pod down and returns the physical GPU, reporting Paused rather than - # terminated. The custom resource stays. - await self.handoff.set_paused(sess, True) + # The custom resource stays (spec.paused=true was applied above); the operator reports + # Paused rather than terminated once the pod is gone. return sess async def demote(self, session_id: str): diff --git a/backend/app/internal/audit_router.py b/backend/app/internal/audit_router.py index 9d23ed0..ccfd346 100644 --- a/backend/app/internal/audit_router.py +++ b/backend/app/internal/audit_router.py @@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.api.schemas.internal import OperatorAuditEvent -from app.auth.internal_jwt import require_internal_jwt +from app.auth.internal_jwt import operator_cluster, require_internal_jwt, require_operator_cluster from app.db.base import get_db from app.domain.audit_service import AuditService @@ -19,9 +19,12 @@ @router.post("/internal/audit/operator", status_code=status.HTTP_202_ACCEPTED) async def audit_operator( ev: OperatorAuditEvent, - _claims: dict = Depends(require_internal_jwt), # aud=gshare-internal + claims: dict = Depends(require_internal_jwt), # aud=gshare-internal db: AsyncSession = Depends(get_db), ): + # The actor is the token, not the payload: a cluster-scoped operator may not sign the audit + # trail as another cluster's operator. + require_operator_cluster(claims, operator_cluster({"sub": ev.actor}), what="audit actor") await AuditService(db).record_operator_action(ev) # _append only flushes; without an explicit commit the row rolls back when the request # session closes — a 202 with nothing persisted. diff --git a/backend/app/internal/imagebuild_status_router.py b/backend/app/internal/imagebuild_status_router.py index 432d27c..7b065eb 100644 --- a/backend/app/internal/imagebuild_status_router.py +++ b/backend/app/internal/imagebuild_status_router.py @@ -12,7 +12,7 @@ from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession -from app.auth.internal_jwt import require_internal_jwt +from app.auth.internal_jwt import require_internal_jwt, require_operator_cluster from app.core import ids from app.db.base import get_db from app.db.models import Image, ImageBuild @@ -36,12 +36,16 @@ class BuildStatusEvent(BaseModel): async def report_build_status( build_id: str, ev: BuildStatusEvent, - _claims: dict = Depends(require_internal_jwt), # aud=gshare-internal + claims: dict = Depends(require_internal_jwt), # aud=gshare-internal db: AsyncSession = Depends(get_db), ): build = await db.get(ImageBuild, build_id, with_for_update=True) if build is None or ev.phase not in _PHASES: return {"accepted": False} + # Only the operator of the cluster running the kaniko Job may finish this build: a + # neighbouring cluster's token could otherwise mark it succeeded with an image ref of its + # choosing, which becomes a private Image row the owner then runs sessions from. + require_operator_cluster(claims, build.cluster_id, what="image build") if build.status in _TERMINAL: # terminal never regresses (retried callbacks) return {"accepted": True, "status": build.status} diff --git a/backend/app/internal/inventory_router.py b/backend/app/internal/inventory_router.py index 1fbfdc6..0cbffe2 100644 --- a/backend/app/internal/inventory_router.py +++ b/backend/app/internal/inventory_router.py @@ -8,7 +8,7 @@ from __future__ import annotations from fastapi import APIRouter, Body, Depends, status -from sqlalchemy import select +from sqlalchemy import or_, select from sqlalchemy.ext.asyncio import AsyncSession from app.api.schemas.internal import ( @@ -141,32 +141,47 @@ async def report_drift( @router.post("/internal/nodes/health-events", status_code=status.HTTP_202_ACCEPTED) async def node_health_event( ev: OperatorNodeHealthEvent, - _claims: dict = Depends(require_internal_jwt), + claims: dict = Depends(require_internal_jwt), db: AsyncSession = Depends(get_db), ): """Record a NodeHealthEvent and, on a cordon action, mark GpuNode.status=cordoned. The operator already cordoned the K8s node; the ledger reflects it so the console/scheduler stop placing new sessions there. + + The operator's HealthReconciler addresses the node by its Kubernetes NAME (the hostname): it + has no ledger id. The row is therefore resolved by (token cluster, hostname) — with the ledger + id still accepted for older callers — and the event carries the row's id, which the FK needs. + Looking the hostname up as an id never matched: the cordon never reached the ledger and the + event insert violated the FK on Postgres. """ + require_operator_cluster(claims, ev.cluster_id, what="health event") + cluster_id = operator_cluster(claims) or ev.cluster_id async with db.begin(): + node_q = select(GpuNode).where( + or_(GpuNode.hostname == ev.node_id, GpuNode.id == ev.node_id) + ) + if cluster_id is not None: + node_q = node_q.where(GpuNode.cluster_id == cluster_id) + node = (await db.execute(node_q.limit(1))).scalar_one_or_none() + hostname = ev.node_id + if node is None: + # No dangling FK row for a node the ledger does not know; the signal is still logged. + log.warning("node health event for unknown node=%s cluster=%s kind=%s action=%s", + ev.node_id, cluster_id, ev.kind, ev.action) + return {"accepted": True} db.add( NodeHealthEvent( id=ev.id or ids.new("healthevent"), - node_id=ev.node_id, + node_id=node.id, kind=ev.kind, severity=ev.severity, action=ev.action, ) ) - hostname = ev.node_id + hostname = node.hostname if (ev.action or "").lower() == "cordon": - node = ( - await db.execute(select(GpuNode).where(GpuNode.id == ev.node_id)) - ).scalar_one_or_none() - if node is not None: - node.status = "cordoned" - hostname = node.hostname + node.status = "cordoned" # Warning, critical, and cordon events notify the system administrators. if (ev.severity or "").lower() in ("warn", "warning", "critical") or (ev.action or "").lower() == "cordon": from app.domain.notification_service import NotificationService diff --git a/backend/app/internal/status_router.py b/backend/app/internal/status_router.py index de8e15a..4973096 100644 --- a/backend/app/internal/status_router.py +++ b/backend/app/internal/status_router.py @@ -23,6 +23,10 @@ async def report_status( claims: dict = Depends(require_internal_jwt), # aud=gshare-internal db: AsyncSession = Depends(get_db), ): + # The payload names the reporting cluster; a token that says otherwise is refused rather than + # believed, exactly as the inventory callback does. Older operators send no cluster_id, which + # the guard tolerates (the session check below still binds the report). + require_operator_cluster(claims, ev.cluster_id, what="status report") # Only the operator of the session's own cluster may speak for it. The operator addresses the # session by its CR name, so the lookup has to be the same one StatusSync uses; an unknown # session falls through to StatusSync, which already treats it as a no-op. diff --git a/backend/app/internal/volumes_router.py b/backend/app/internal/volumes_router.py index 57a7a0c..4856fb0 100644 --- a/backend/app/internal/volumes_router.py +++ b/backend/app/internal/volumes_router.py @@ -10,7 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.api.schemas.internal import OperatorVolumeSync, VolumeSyncResponse -from app.auth.internal_jwt import require_internal_jwt +from app.auth.internal_jwt import operator_cluster, require_internal_jwt, require_operator_cluster from app.cluster.volume_sync import VolumeSync from app.db.base import get_db @@ -20,7 +20,13 @@ @router.post("/internal/volumes/sync", response_model=VolumeSyncResponse) async def sync_volumes( report: OperatorVolumeSync, - _claims: dict = Depends(require_internal_jwt), # aud=gshare-internal + claims: dict = Depends(require_internal_jwt), # aud=gshare-internal db: AsyncSession = Depends(get_db), ) -> VolumeSyncResponse: + # The report's cluster pins volume placement and pool capacity, so it has to be the token's + # cluster: no attached cluster may claim a neighbour's PVCs or rewrite its pool figures. An + # older operator that sends no cluster lands on the token's. + require_operator_cluster(claims, report.cluster_id, what="volume report") + if report.cluster_id is None: + report.cluster_id = operator_cluster(claims) return await VolumeSync(db).sync(report) diff --git a/backend/app/workers/credit_refill.py b/backend/app/workers/credit_refill.py index e9c27db..25dc386 100644 --- a/backend/app/workers/credit_refill.py +++ b/backend/app/workers/credit_refill.py @@ -1,8 +1,10 @@ -"""credit_refill — the monthly automatic credit refill, use-it-or-lose-it. +"""credit_refill — the monthly automatic credit refill (top-up to the grant). -Invoked hourly but acts **once a month**, made idempotent by a Redis month marker set with SET NX. -When it runs, every wallet with monthly_grant > 0 has its balance reset to the grant, so last -month's unused credits expire. To preserve active holds the balance is set to max(grant, reserved). +Invoked hourly but acts **once a month**, made idempotent by a Redis month marker set with SET NX +plus a per-wallet ledger key. When it runs, every wallet with monthly_grant > 0 is topped UP to +its grant: a balance already above the grant is left alone (purchased, allocated and welcome +credit are never reclaimed), so this is not use-it-or-lose-it. To preserve active holds the +target is max(grant, reserved). The hierarchy ceiling — the siblings' grants summing within the parent's — is enforced when a grant is set, so resetting each wallet independently is sufficient here. """ @@ -50,7 +52,19 @@ async def run() -> None: if not await redis.set(marker, "1", nx=True, ex=40 * 24 * 3600): return log.info("credit_refill: monthly reset start (%s)", tag) + try: + reset = await _sweep(tag) + except BaseException: + # The marker was claimed before the sweep ran. Keeping it after a failure meant the + # wallets the sweep had not reached went without their refill for the whole month — no + # later tick could get past the NX claim. Release it so the next hourly tick retries; + # the per-wallet refill key keeps the retry from crediting anyone twice. + await redis.delete(marker) + raise + log.info("credit_refill: reset %d wallet(s) for %s", reset, tag) + +async def _sweep(tag: str) -> int: sessionmaker = get_sessionmaker() reset = 0 # Batched: one transaction locking every wallet would block concurrent holds for the whole @@ -72,7 +86,23 @@ async def run() -> None: if not wallets: break last_id = wallets[-1].id + # Wallets this month's sweep already credited (a replay after the month marker + # was lost, or a retry after a crash mid-sweep). Inserting their key again raised + # a unique violation that aborted the whole batch instead of skipping the wallet. + done = set( + ( + await db.scalars( + select(CreditTransaction.wallet_id).where( + CreditTransaction.idempotency_key.in_( + [f"refill:{w.id}:{tag}" for w in wallets] + ) + ) + ) + ).all() + ) for w in wallets: + if w.id in done: + continue # Top the wallet UP to the monthly grant; never down. The reset also erased # credit that was bought, allocated by a parent or granted as welcome credit — # money the grant never provided and has no business reclaiming. @@ -94,4 +124,4 @@ async def run() -> None: ) ) reset += 1 - log.info("credit_refill: reset %d wallet(s) for %s", reset, tag) + return reset diff --git a/backend/tests/beta_support.py b/backend/tests/beta_support.py new file mode 100644 index 0000000..1323f8b --- /dev/null +++ b/backend/tests/beta_support.py @@ -0,0 +1,128 @@ +"""Shared helpers for the beta-test suites (test_beta_*). + +Every referenced row (cluster, node, offering, image, user) is created for real, so the same +tests run unchanged on Postgres, where the foreign keys are enforced, and on the SQLite fixture. +""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from decimal import Decimal + +from sqlalchemy import select + +from app.core import ids +from app.db.models import ( + Cluster, + CreditTransaction, + CreditWallet, + GpuDevice, + GpuNode, + Image, + Offering, + Session, + User, +) + +RESERVED_ONLY = ("hold", "refund", "settle") + + +@dataclass +class Refs: + cluster_id: str + node_id: str + offering_id: str + image_id: str + user_id: str + + +async def make_refs(db, *, rate: str = "60", gpu_mem_mb: int = 16000, + total_mem_mb: int = 16000, devices: int = 0) -> Refs: + """Create one cluster/node/offering/image/user (and ``devices`` ready fractional cards).""" + cluster = Cluster(id=ids.new("cluster"), name=ids.new("cluster"), api_server="https://k", + runtime="k8s", kubeconfig_secret_ref="ref", status="ready") + node = GpuNode(id=ids.new("node"), cluster_id=cluster.id, hostname=ids.new("node"), + status="ready", cpu=64, mem=256, disk=1000) + offering = Offering(id=ids.new("offering"), name="A100-frac", resource_class="gpu", + gpu_model="A100", gpu_mem_mb=gpu_mem_mb, gpu_cores=100, + credit_per_hour=Decimal(rate)) + image = Image(id=ids.new("image"), name="pytorch", registry=ids.new("image")) + user = User(id=ids.new("user"), email=f"{ids.new('user')}@t.local", name="u") + async with db.begin(): + db.add_all([cluster, node, offering, image, user]) + await db.flush() # no relationship() on the models: order the FK parents explicitly + for _ in range(devices): + db.add(GpuDevice(id=ids.new("device"), node_id=node.id, cluster_id=cluster.id, + model="A100", gpu_uuid=ids.new("device"), total_mem_mb=total_mem_mb, + status="ready", mode="fractional")) + return Refs(cluster.id, node.id, offering.id, image.id, user.id) + + +async def make_user(db) -> str: + u = User(id=ids.new("user"), email=f"{ids.new('user')}@t.local", name="u") + async with db.begin(): + db.add(u) + return u.id + + +async def make_wallet(db, balance: str, reserved: str = "0", *, owner_type: str = "user", + owner_id: str | None = None, grant: str = "0") -> str: + """Create a wallet and return its id (a plain string survives rollbacks and expiry).""" + if owner_id is None: + owner_id = await make_user(db) if owner_type == "user" else ids.new(owner_type) + w = CreditWallet( + id=ids.new("wallet"), owner_type=owner_type, owner_id=owner_id, + balance=Decimal(balance), reserved=Decimal(reserved), monthly_grant=Decimal(grant), + ) + async with db.begin(): + db.add(w) + return w.id + + +async def make_gpu_session(db, refs: Refs, wallet_id: str, rate: str, started: datetime | None, + *, status: str = "running", gpu_mem_mb: int = 10000, + gpu_cores: int = 100, total_mem_mb: int = 20000, + owner_id: str | None = None) -> Session: + sess = Session( + id=ids.new("session"), owner_user_id=owner_id or refs.user_id, + cluster_id=refs.cluster_id, offering_id=refs.offering_id, image_id=refs.image_id, + resource_class="gpu", mode="fractional", gpu_mem_mb=gpu_mem_mb, gpu_cores=gpu_cores, + device_total_mem_mb=total_mem_mb, billing_wallet_id=wallet_id, status=status, + credit_per_hour_snapshot=Decimal(rate), started_at=started, + ) + async with db.begin(): + db.add(sess) + return sess + + +async def wallet_state(db, wallet_id: str) -> tuple[Decimal, Decimal]: + row = (await db.execute( + select(CreditWallet.balance, CreditWallet.reserved).where(CreditWallet.id == wallet_id) + )).one() + await db.commit() # close the autobegun read so the next begin() is clean + return row.balance, row.reserved + + +async def ledger_rows(db, wallet_id: str, ref: str | None = None) -> list[CreditTransaction]: + stmt = select(CreditTransaction).where(CreditTransaction.wallet_id == wallet_id) + if ref is not None: + stmt = stmt.where(CreditTransaction.ref == ref) + rows = list((await db.scalars(stmt.order_by(CreditTransaction.created_at, + CreditTransaction.id))).all()) + await db.commit() + return rows + + +async def check_ledger(db, wallet_id: str, initial_balance: Decimal) -> None: + """Ledger sum == balance delta; balance_after snapshot == balance; 0 <= reserved <= balance. + + hold/refund/settle rows move ``reserved`` only, so they are excluded from the balance sum. + """ + db.expunge_all() + balance, reserved = await wallet_state(db, wallet_id) + rows = await ledger_rows(db, wallet_id) + moved = sum((r.amount for r in rows if r.type not in RESERVED_ONLY), Decimal("0")) + assert balance == initial_balance + moved, (balance, initial_balance, moved, rows) + if rows: + assert rows[-1].balance_after == balance, (rows[-1].type, rows[-1].balance_after, balance) + assert Decimal("0") <= reserved <= balance, (balance, reserved) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 1356f4a..6398afa 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -19,8 +19,13 @@ # here. os.environ.setdefault("GSHARE_USER_JWT_SECRET", "test-secret-not-for-prod") +import subprocess +import sys +from pathlib import Path + import pytest import pytest_asyncio +from sqlalchemy import text from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.compiler import compiles @@ -28,6 +33,14 @@ from app.db.base import Base +# Opt-in Postgres backend (hypothesis H-7: the SQLite fixture cannot exercise FOR UPDATE, CHECK +# constraints as migrated, enforced foreign keys, or real concurrency). Set +# ``GSHARE_TEST_DATABASE_URL`` to an asyncpg URL and the ``db`` fixture binds to that database +# instead: the schema is created ONCE per session with ``alembic upgrade head`` (the production +# path, not create_all) and every table is truncated after each test. Unset, nothing below +# changes the SQLite behaviour. +PG_TEST_URL = os.environ.get("GSHARE_TEST_DATABASE_URL") + # SQLite has no JSONB; render it as plain JSON (text-backed) so create_all works off the same # models the production Postgres schema uses. This is a test-only dialect shim. @@ -36,14 +49,42 @@ def _compile_jsonb_sqlite(type_, compiler, **kw): # noqa: ANN001, ANN202 return "JSON" -@pytest_asyncio.fixture -async def db() -> AsyncSession: - """AsyncSession bound to an in-memory SQLite. +@pytest.fixture(scope="session") +def pg_schema() -> str | None: + """Migrate the opt-in Postgres test database to head once per session (``alembic upgrade + head`` in a subprocess so env.py's own asyncio.run stays out of the test loop), and start + from empty tables. Returns the URL, or None when the SQLite default is in force.""" + if not PG_TEST_URL: + return None + backend_dir = Path(__file__).resolve().parents[1] + env = {**os.environ, "GSHARE_DATABASE_URL": PG_TEST_URL} + subprocess.run( + [sys.executable, "-m", "alembic", "upgrade", "head"], + cwd=backend_dir, env=env, check=True, capture_output=True, + ) + return PG_TEST_URL - A single connection-pooled in-memory engine; schema created via run_sync(create_all). Yields one - session per test (rolled back / disposed at teardown). Postgres partial-UNIQUE indexes carry a - ``postgresql_where`` that SQLite simply ignores, which is fine for these logic tests. - """ + +async def _truncate_all(engine) -> None: + names = ", ".join(f'"{t.name}"' for t in reversed(Base.metadata.sorted_tables)) + async with engine.begin() as conn: + await conn.execute(text(f"TRUNCATE TABLE {names} CASCADE")) + + +@pytest_asyncio.fixture +async def db_engine(pg_schema): + """The engine behind ``db``. On Postgres this is a real pool, so a test may open several + independent sessions on it (concurrency tests); on SQLite it is the single shared in-memory + connection.""" + if pg_schema: + engine = create_async_engine(pg_schema, pool_size=20, max_overflow=20) + await _truncate_all(engine) + try: + yield engine + finally: + await _truncate_all(engine) + await engine.dispose() + return # StaticPool shares one connection across every session, so the in-memory database survives # between them and the connection stays consistent after an exception inside begin(). With the # default pool the async adapter can be left in a broken state. @@ -54,13 +95,27 @@ async def db() -> AsyncSession: ) async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) - sessionmaker = async_sessionmaker(engine, expire_on_commit=False) + try: + yield engine + finally: + await engine.dispose() + + +@pytest_asyncio.fixture +async def db(db_engine) -> AsyncSession: + """AsyncSession bound to an in-memory SQLite (default) or the opt-in Postgres (``db_engine``). + + SQLite: a single connection-pooled in-memory engine; schema created via run_sync(create_all). + Yields one session per test (rolled back / disposed at teardown). Postgres partial-UNIQUE + indexes carry a ``postgresql_where`` that SQLite simply ignores, which is fine for these logic + tests. + """ + sessionmaker = async_sessionmaker(db_engine, expire_on_commit=False) session = sessionmaker() try: yield session finally: await session.close() - await engine.dispose() class FakeHandoff: diff --git a/backend/tests/fkseed.py b/backend/tests/fkseed.py new file mode 100644 index 0000000..901f099 --- /dev/null +++ b/backend/tests/fkseed.py @@ -0,0 +1,280 @@ +"""Fixture seeding that satisfies the schema's foreign keys. + +SQLite does not enforce foreign keys, so a fixture could happily insert a Session whose +``owner_user_id`` points at a user that was never created. Postgres does enforce them, and the +same fixtures then fail with ``ForeignKeyViolationError``. Two distinct problems show up: + +1. **Missing parents.** An id was minted with ``ids.new(...)`` and nothing was inserted behind it. +2. **Insert order.** These models deliberately carry no ``relationship()``, so SQLAlchemy's unit of + work has no dependency graph to sort by: a single ``add_all([device, node, cluster])`` is + flushed in exactly that order and the child INSERT runs first. + +``seed(db, rows)`` fixes both: it orders the INSERTs by table dependency and creates a minimal +parent row for every foreign key that would otherwise dangle (recursively — a placeholder group +gets a placeholder organization). Only rows handed to it are inspected, so production code paths +under test keep hitting the real constraints. + +Use the explicit ``*_row`` factories when a test needs a parent with particular contents; use +``seed`` for everything else. +""" +from __future__ import annotations + +from datetime import UTC, datetime +from decimal import Decimal +from typing import Any + +from sqlalchemy import inspect, select + +from app.core import ids +from app.db import models +from app.db.base import Base + +__all__ = [ + "seed", + "add_ordered", + "cluster_row", + "user_row", + "org_row", + "group_row", + "wallet_row", + "node_row", +] + + +# ── explicit factories ──────────────────────────────────────────────────────────────────────── +def cluster_row(cluster_id: str | None = None, **kw: Any) -> models.Cluster: + cluster_id = cluster_id or ids.new("cluster") + base = dict( + id=cluster_id, name=f"c-{cluster_id}", api_server="https://k8s.test", runtime="k8s", + kubeconfig_secret_ref=f"secret-{cluster_id}", status="ready", + ) + base.update(kw) + return models.Cluster(**base) + + +def user_row(user_id: str | None = None, **kw: Any) -> models.User: + user_id = user_id or ids.new("user") + base = dict(id=user_id, email=f"{user_id}@fixture.local", name=user_id) + base.update(kw) + return models.User(**base) + + +def org_row(org_id: str | None = None, **kw: Any) -> models.Organization: + org_id = org_id or ids.new("org") + base = dict(id=org_id, name=f"org-{org_id}") + base.update(kw) + return models.Organization(**base) + + +def group_row(group_id: str | None = None, org_id: str | None = None, + **kw: Any) -> models.Project: + group_id = group_id or ids.new("group") + base = dict(id=group_id, org_id=org_id or ids.new("org"), name=f"g-{group_id}") + base.update(kw) + return models.Project(**base) + + +def wallet_row(wallet_id: str | None = None, *, owner_type: str = "user", + owner_id: str | None = None, **kw: Any) -> models.CreditWallet: + wallet_id = wallet_id or ids.new("wallet") + base = dict(id=wallet_id, owner_type=owner_type, owner_id=owner_id or ids.new("user")) + base.update(kw) + return models.CreditWallet(**base) + + +def node_row(node_id: str | None = None, cluster_id: str | None = None, + **kw: Any) -> models.GpuNode: + node_id = node_id or ids.new("node") + base = dict( + id=node_id, cluster_id=cluster_id or ids.new("cluster"), hostname=f"h-{node_id}", + status="ready", + ) + base.update(kw) + return models.GpuNode(**base) + + +# ── placeholder parents, one builder per referenced table ───────────────────────────────────── +def _offering(i: str) -> models.Offering: + # Deliberately minimal: a placeholder must not add a constraint the fixture never asked for + # (a gpu_model here would make the scheduler's model filter reject the fixture's own cards). + return models.Offering(id=i, name=f"off-{i}", resource_class="gpu") + + +def _image(i: str) -> models.Image: + return models.Image(id=i, name=f"img-{i}") + + +def _device(i: str) -> models.GpuDevice: + return models.GpuDevice(id=i, node_id=ids.new("node"), cluster_id=ids.new("cluster"), + model="A100", gpu_uuid=f"GPU-{i}", total_mem_mb=16000, + status="ready", mode="fractional") + + +def _session(i: str) -> models.Session: + return models.Session(id=i, owner_user_id=ids.new("user"), cluster_id=ids.new("cluster"), + offering_id=ids.new("offering"), image_id=ids.new("image"), + resource_class="gpu", mode="fractional", status="running") + + +def _volume(i: str) -> models.StorageVolume: + return models.StorageVolume(id=i, scope="user", scope_id=ids.new("user"), type="home", + access_mode="RWO") + + +def _pool(i: str) -> models.NodePool: + return models.NodePool(id=i, cluster_id=ids.new("cluster"), name=f"pool-{i}", kind="shared") + + +def _storage_pool(i: str) -> models.StoragePool: + return models.StoragePool(id=i, cluster_id=ids.new("cluster"), name=f"sp-{i}") + + +def _budget(i: str) -> models.Budget: + return models.Budget(id=i, scope="user", scope_id=ids.new("user"), period="monthly", + period_start=datetime.now(UTC), limit_credit=Decimal("0")) + + +def _webhook(i: str) -> models.WebhookSubscription: + return models.WebhookSubscription(id=i, scope="global", url="https://hook.test", events=[]) + + +_BUILDERS = { + "user": user_row, + "organization": org_row, + "group": group_row, + "cluster": cluster_row, + "gpu_node": node_row, + "credit_wallet": wallet_row, + "offering": _offering, + "image": _image, + "gpu_device": _device, + "session": _session, + "storage_volume": _volume, + "node_pool": _pool, + "storage_pool": _storage_pool, + "budget": _budget, + "webhook_subscription": _webhook, +} + +_TABLE_ORDER = {t.name: n for n, t in enumerate(Base.metadata.sorted_tables)} + + +def _fk_refs(obj: Any) -> list[tuple[str, str]]: + """(parent table, parent id) for every non-null foreign key value carried by ``obj``.""" + table = inspect(type(obj)).local_table + out: list[tuple[str, str]] = [] + for col in table.columns: + for fk in col.foreign_keys: + value = getattr(obj, col.name, None) + if value is not None: + out.append((fk.column.table.name, value)) + return out + + +def _pk_of(obj: Any) -> tuple[str, str]: + table = inspect(type(obj)).local_table + return table.name, getattr(obj, next(iter(table.primary_key.columns)).name) + + +def _fk_values(obj: Any) -> dict[str, Any]: + """{column name: value} for the non-null foreign keys ``obj`` carries.""" + table = inspect(type(obj)).local_table + return { + c.name: getattr(obj, c.name, None) + for c in table.columns + if c.foreign_keys and getattr(obj, c.name, None) is not None + } + + +def _inherit(obj: Any, ctx: dict[str, Any]) -> None: + """Give a placeholder the same parents as the row that asked for it. + + A placeholder gpu_node conjured for a GpuDevice must sit in the device's cluster, not in a + cluster of its own, or a scheduler query joining the two stops matching. + """ + table = inspect(type(obj)).local_table + for col in table.columns: + if col.foreign_keys and ctx.get(col.name) is not None: + setattr(obj, col.name, ctx[col.name]) + + +async def _fill_parents(db, rows: list[Any]) -> list[Any]: + """Return placeholder parents for the foreign keys ``rows`` reference but nobody provides.""" + have: set[tuple[str, str]] = {_pk_of(r) for r in rows} + # Rows the caller already put in the session (pending or already persistent) count as provided: + # conjuring a placeholder for one of those would collide on its primary key. + for known in list(db.new) + list(db.identity_map.values()): + try: + have.add(_pk_of(known)) + except Exception: # not a mapped row we can key + pass + made: list[Any] = [] + pending = list(rows) + asked: set[tuple[str, str]] = set() + while pending: + requests: dict[tuple[str, str], dict[str, Any]] = {} + for obj in pending: + ctx = _fk_values(obj) + for ref in _fk_refs(obj): + if ref not in have and ref not in asked: + requests.setdefault(ref, ctx) + if not requests: + break + asked |= set(requests) + by_table: dict[str, set[str]] = {} + for table_name, value in requests: + by_table.setdefault(table_name, set()).add(value) + present: set[tuple[str, str]] = set() + for table_name, values in by_table.items(): + table = Base.metadata.tables[table_name] + pk = next(iter(table.primary_key.columns)) + for found in (await db.scalars(select(pk).where(pk.in_(values)))).all(): + present.add((table_name, found)) + pending = [] + for (table_name, value), ctx in sorted(requests.items()): + have.add((table_name, value)) + builder = _BUILDERS.get(table_name) + if (table_name, value) in present or builder is None: + continue # already in the database, or no placeholder shape is known: + obj = builder(value) # let the real constraint speak instead of inventing a row + _inherit(obj, ctx) + made.append(obj) + pending.append(obj) + return made + + +def _ordered(rows: list[Any]) -> list[list[Any]]: + """Group rows per table, in the schema's topological table order.""" + by_table: dict[str, list[Any]] = {} + for obj in rows: + by_table.setdefault(inspect(type(obj)).local_table.name, []).append(obj) + return [by_table[name] for name in sorted(by_table, key=lambda n: _TABLE_ORDER[n])] + + +async def add_ordered(db, rows) -> None: + """Insert ``rows`` in dependency order inside the caller's transaction — flush, no commit. + + The flushing counterpart of :func:`seed`, for fixtures that deliberately stay in the + transaction the test goes on to use. + """ + rows = [r for r in rows if r is not None] + made = await _fill_parents(db, rows) + for wave in _ordered(made + rows): + db.add_all(wave) + await db.flush() + + +async def seed(db, rows) -> None: + """Insert ``rows`` (plus any missing foreign-key parents) and commit. + + Replaces ``async with db.begin(): db.add_all(rows)`` in fixtures whose rows reference ids + that were never inserted, or that are not already in parent-before-child order. + """ + rows = [r for r in rows if r is not None] + made = await _fill_parents(db, rows) + if db.in_transaction(): + await db.commit() # close the transaction the lookups above autobegan + async with db.begin(): + for wave in _ordered(made + rows): + db.add_all(wave) + await db.flush() diff --git a/backend/tests/test_audit_result_filter.py b/backend/tests/test_audit_result_filter.py index 35bc038..b69bafa 100644 --- a/backend/tests/test_audit_result_filter.py +++ b/backend/tests/test_audit_result_filter.py @@ -10,6 +10,7 @@ from app.core.errors import Unauthenticated from app.core.passwords import hash_password from app.db.models import User +from tests.fkseed import seed ROOT = Principal(user_id="usr_root", global_role="super_admin", global_roles={"super_admin"}) @@ -21,9 +22,8 @@ class _Req: @pytest.mark.asyncio async def test_result_filter_narrows_to_one_outcome(db): - db.add(User(id=ids.new("user"), email="ce-user01@example.edu", name="u", - password_hash=hash_password("right-pass-1"), status="active")) - await db.commit() + await seed(db, [User(id=ids.new("user"), email="ce-user01@example.edu", name="u", + password_hash=hash_password("right-pass-1"), status="active")]) with pytest.raises(Unauthenticated): await auth_login(_LoginRequest(email="ce-user01@example.edu", password="wrong-pass-1"), _Req(), db) await auth_login(_LoginRequest(email="ce-user01@example.edu", password="right-pass-1"), _Req(), db) diff --git a/backend/tests/test_auth_token.py b/backend/tests/test_auth_token.py index 91b724a..c03973f 100644 --- a/backend/tests/test_auth_token.py +++ b/backend/tests/test_auth_token.py @@ -14,6 +14,7 @@ from app.auth.jwt_auth import verify_jwt from app.core.config import settings from app.core.errors import Unauthenticated +from tests.fkseed import seed def _make_token(**claims) -> str: @@ -83,8 +84,7 @@ async def test_must_change_password_allows_change_password_path(db): from app.db.models import User # The principal resolver now verifies the account row exists and is not suspended/deleted. - async with db.begin(): - db.add(User(id="usr_x", email="x@example.com", name="x", status="active")) + await seed(db, [User(id="usr_x", email="x@example.com", name="x", status="active")]) tok = _make_token(must_change_password=True) req = SimpleNamespace(url=SimpleNamespace(path="/api/v1/auth/change-password")) @@ -105,8 +105,7 @@ async def test_suspended_user_is_rejected_on_login_and_token(db, fake_redis): from app.core.errors import Forbidden, Unauthenticated from app.db.models import User - async with db.begin(): - db.add(User(id="usr_susp", email="susp@example.com", name="s", status="suspended")) + await seed(db, [User(id="usr_susp", email="susp@example.com", name="s", status="suspended")]) fake_req = SimpleNamespace(headers={}, client=None) with pytest.raises(Unauthenticated): diff --git a/backend/tests/test_availability_matches_admission.py b/backend/tests/test_availability_matches_admission.py index c6de889..7c88bcd 100644 --- a/backend/tests/test_availability_matches_admission.py +++ b/backend/tests/test_availability_matches_admission.py @@ -13,6 +13,7 @@ from app.core import ids from app.db.models import Cluster, GpuDevice, GpuNode from app.domain.placement import placeable_device_clauses +from tests.fkseed import seed async def _fleet(db): @@ -34,8 +35,7 @@ def dev(uuid, node, **kw): dev("GPU-faulted", ready, status="unhealthy"), # administrator marked it faulted dev("GPU-cordoned-node", cordoned), # node takes no new work ] - async with db.begin(): - db.add_all([cluster, ready, cordoned, *devices]) + await seed(db, [cluster, ready, cordoned, *devices]) return devices diff --git a/backend/tests/test_beta_contract2_status_payload.py b/backend/tests/test_beta_contract2_status_payload.py new file mode 100644 index 0000000..e50d1ea --- /dev/null +++ b/backend/tests/test_beta_contract2_status_payload.py @@ -0,0 +1,151 @@ +"""Beta follow-up: the status callback payload contract (GS-C08, GS-C12, GS-C13). + +The operator sends more than the control plane used to read. What arrives must either be used +or be documented as unused, and what both sides document about a field must say the same thing. +""" +from __future__ import annotations + +import re +from datetime import UTC, datetime +from pathlib import Path + +import pytest + +from app.api.schemas.internal import OperatorStatusEvent +from app.core import ids +from app.core.errors import Forbidden +from app.db.models import Session +from app.internal import status_router +from tests.fkseed import seed + +pytestmark = pytest.mark.asyncio + +REPO = Path(__file__).resolve().parents[2] +CLUSTER = "clu_a" + + +async def _seed(db, **kw) -> Session: + fields = dict( + id=ids.new("session"), owner_user_id=ids.new("user"), cluster_id=CLUSTER, + offering_id="off_t", image_id="img_t", resource_class="cpu", status="preparing", + ) + fields.update(kw) + sess = Session(**fields) + await seed(db, [sess]) + return sess + + +def _ev(**kw) -> OperatorStatusEvent: + base = dict(phase="preparing", ts=datetime.now(UTC)) + base.update(kw) + return OperatorStatusEvent(**base) + + +# ── GS-C08: cluster_id is read, not discarded ── + + +async def test_the_status_schema_keeps_the_reported_cluster_id(db): + """The operator stamps cluster_id on every status event; pydantic used to drop it.""" + ev = OperatorStatusEvent(phase="running", ts=datetime.now(UTC), cluster_id=CLUSTER) + assert ev.cluster_id == CLUSTER + + +async def test_a_status_report_naming_another_cluster_is_refused(db): + """Same guard the inventory callback has: the token says who is reporting, and a payload + that names a different cluster is refused rather than believed.""" + sess = await _seed(db) + await db.commit() + with pytest.raises(Forbidden): + await status_router.report_status( + sess.id, _ev(cluster_id="clu_b"), {"sub": f"operator:{CLUSTER}"}, db) + db.expunge_all() + assert (await db.get(Session, sess.id)).status == "preparing" + + +async def test_a_status_report_from_the_owning_cluster_is_applied(db): + sess = await _seed(db) + await db.commit() + out = await status_router.report_status( + sess.id, _ev(cluster_id=CLUSTER), {"sub": f"operator:{CLUSTER}"}, db) + assert out == {"accepted": True} + db.expunge_all() + assert (await db.get(Session, sess.id)).status == "preparing" + + +async def test_an_older_operator_without_a_cluster_id_still_passes(db): + sess = await _seed(db) + await db.commit() + await status_router.report_status(sess.id, _ev(), {"sub": f"operator:{CLUSTER}"}, db) + db.expunge_all() + assert (await db.get(Session, sess.id)).status == "preparing" + + +async def test_used_mem_mb_is_documented_as_unused(db): + """It is accepted for wire compatibility and nothing reads it: the device ledger is fed by + the inventory callback. The comment must not claim a reconciliation that does not exist.""" + src = (REPO / "backend/app/api/schemas/internal.py").read_text() + line = next(ln for ln in src.splitlines() if ln.strip().startswith("used_mem_mb: int | None")) + assert "inventory reconciliation" not in line + assert "unused" in line.lower() + status_sync = (REPO / "backend/app/cluster/status_sync.py").read_text() + assert "ev.used_mem_mb" not in status_sync + + +# ── GS-C12: restart_count 0 is a fact, None is silence ── + + +async def test_restart_count_zero_is_a_heartbeat_and_none_is_not(db): + """The backend already tells them apart; the operator now actually sends the 0.""" + reported = await _seed(db) + silent = await _seed(db) + from app.cluster.status_sync import StatusSync + + await StatusSync(db).on_status(reported.id, _ev(phase="running", restart_count=0)) + await StatusSync(db).on_status(silent.id, _ev(phase="running")) + db.expunge_all() + assert (await db.get(Session, reported.id)).last_reported_at is not None + assert (await db.get(Session, silent.id)).last_reported_at is None + + +async def test_the_operator_sends_restart_count_when_it_knows_it(): + """A pointer (not a plain int with omitempty): nil when there is no pod to read it from, + and an explicit 0 when the kubelet says zero restarts.""" + src = (REPO / "operator/internal/sot/client.go").read_text() + line = next(ln for ln in src.splitlines() if "json:\"restart_count" in ln) + assert "*int" in line, line + ctrl = (REPO / "operator/internal/controller/gsharesession_controller.go").read_text() + assert "RestartCount: restartCount" in ctrl or "RestartCount: restartCount" in ctrl + + +# ── GS-C13: both sides must document the same enum ── + + +def _go_enum(path: Path, field: str) -> set[str]: + line = next(ln for ln in path.read_text().splitlines() + if re.match(rf"^\s*{field}\s+string\s+`json:", ln)) + tail = line.split("//", 1)[1].split("(", 1)[0] + return {tok.strip() for tok in tail.split("|") if tok.strip()} + + +def _py_enum(path: Path, field: str) -> set[str]: + line = next(ln for ln in path.read_text().splitlines() + if re.match(rf"^\s*{field}: str\s+#", ln)) + tail = line.split("#", 1)[1].split("(", 1)[0] + return {tok.strip() for tok in tail.split("|") if tok.strip()} + + +async def test_audit_result_enum_is_documented_identically_on_both_sides(): + go = _go_enum(REPO / "operator/internal/sot/client.go", "Result") + py = _py_enum(REPO / "backend/app/api/schemas/internal.py", "result") + assert go == py, f"operator documents {sorted(go)}, backend documents {sorted(py)}" + + +async def test_the_operator_only_ever_reports_ok(): + """What the comment promises has to match what the code emits.""" + emitted = set() + for src in (REPO / "operator/internal").rglob("*.go"): + if src.name.endswith("_test.go"): + continue + emitted |= set(re.findall(r'Result:\s*"([a-z_]+)"', src.read_text())) + assert emitted == {"ok"}, emitted + assert "ok" in _go_enum(REPO / "operator/internal/sot/client.go", "Result") diff --git a/backend/tests/test_beta_contract_internal_routes.py b/backend/tests/test_beta_contract_internal_routes.py new file mode 100644 index 0000000..778f203 --- /dev/null +++ b/backend/tests/test_beta_contract_internal_routes.py @@ -0,0 +1,140 @@ +"""Beta: every internal operator callback is bound to the token's cluster, and the node health +contract matches what the operator actually sends (a hostname, not a ledger id).""" +from __future__ import annotations + +from datetime import UTC, datetime + +import pytest +from sqlalchemy import select + +from app.api.schemas.internal import ( + OperatorAuditEvent, + OperatorNodeHealthEvent, + OperatorVolumeSync, +) +from app.core import ids +from app.core.errors import Forbidden +from app.db.models import Cluster, GpuNode, ImageBuild, NodeHealthEvent +from app.internal import audit_router, imagebuild_status_router, inventory_router, volumes_router +from tests.fkseed import seed, user_row + +pytestmark = pytest.mark.asyncio + + +async def _node(db, cluster_id: str, hostname: str) -> GpuNode: + node = GpuNode(id=ids.new("node"), cluster_id=cluster_id, hostname=hostname, status="ready") + await seed(db, [ + Cluster(id=cluster_id, name=cluster_id, api_server="https://x", runtime="containerd", + kubeconfig_secret_ref="kc"), + node, + ]) + return node + + +def _health(node_id: str, **kw) -> OperatorNodeHealthEvent: + base = dict(node_id=node_id, kind="xid", severity="critical", action="cordon") + base.update(kw) + return OperatorNodeHealthEvent(**base) + + +# ── node health: the operator reports the Kubernetes node NAME ── + + +async def test_health_event_addressed_by_hostname_cordons_the_ledger_node(db): + """The operator's HealthReconciler sends NodeID = node.Name (the hostname). The ledger row is + keyed by its own id, so the callback has to resolve (token cluster, hostname); looking the + hostname up as an id never matched, the cordon never reached the ledger, and the FK on the + event row failed on Postgres.""" + node = await _node(db, "clu_a", "gpu-a") + await inventory_router.node_health_event(_health("gpu-a"), {"sub": "operator:clu_a"}, db) + db.expunge_all() + row = await db.get(GpuNode, node.id) + assert row.status == "cordoned" + ev = (await db.scalars(select(NodeHealthEvent))).all() + assert len(ev) == 1 and ev[0].node_id == node.id # FK-valid ledger id, not the hostname + + +async def test_health_event_from_another_clusters_operator_is_refused(db): + node = await _node(db, "clu_b", "gpu-b") + with pytest.raises(Forbidden): + await inventory_router.node_health_event( + _health("gpu-b", cluster_id="clu_b"), {"sub": "operator:clu_a"}, db) + await db.rollback() + db.expunge_all() + assert (await db.get(GpuNode, node.id)).status == "ready" + assert (await db.scalars(select(NodeHealthEvent))).all() == [] + + +async def test_health_event_cannot_cordon_a_same_named_node_on_another_cluster(db): + """Two clusters may both have a node called gpu-a; the token decides which one is meant.""" + mine = await _node(db, "clu_a", "gpu-a") + other = GpuNode(id=ids.new("node"), cluster_id="clu_b", hostname="gpu-a", status="ready") + await seed(db, [ + Cluster(id="clu_b", name="b", api_server="https://y", runtime="containerd", + kubeconfig_secret_ref="kc"), + other, + ]) + await inventory_router.node_health_event(_health("gpu-a"), {"sub": "operator:clu_a"}, db) + db.expunge_all() + assert (await db.get(GpuNode, mine.id)).status == "cordoned" + assert (await db.get(GpuNode, other.id)).status == "ready" + + +async def test_health_event_for_an_unknown_node_is_accepted_without_a_dangling_row(db): + await _node(db, "clu_a", "gpu-a") + out = await inventory_router.node_health_event(_health("ghost"), {"sub": "operator:clu_a"}, db) + assert out == {"accepted": True} + db.expunge_all() + assert (await db.scalars(select(NodeHealthEvent))).all() == [] + + +# ── audit: the actor is the token, not the payload ── + + +async def test_audit_actor_cannot_name_another_cluster(db): + ev = OperatorAuditEvent(actor="operator:clu_b", action="node.cordon", target="gpu-b", + result="ok", ts=datetime.now(UTC)) + with pytest.raises(Forbidden): + await audit_router.audit_operator(ev, {"sub": "operator:clu_a"}, db) + + +# ── volumes: the report's cluster must be the token's ── + + +async def test_volume_sync_from_another_cluster_is_refused(db, monkeypatch): + seen: list[str | None] = [] + + async def _sync(self, report): + seen.append(report.cluster_id) + from app.api.schemas.internal import VolumeSyncResponse + return VolumeSyncResponse(volumes=[], orphans=0) + + monkeypatch.setattr(volumes_router.VolumeSync, "sync", _sync) + with pytest.raises(Forbidden): + await volumes_router.sync_volumes(OperatorVolumeSync(volumes=[], cluster_id="clu_b"), + {"sub": "operator:clu_a"}, db) + assert seen == [] + # A report without a cluster (older operator) lands on the token's cluster. + await volumes_router.sync_volumes(OperatorVolumeSync(volumes=[]), {"sub": "operator:clu_a"}, db) + assert seen == ["clu_a"] + + +# ── image builds: the build's cluster must be the token's ── + + +async def test_image_build_status_from_another_cluster_is_refused(db): + build = ImageBuild(id=ids.new("build"), group_id="grp_1", owner_user_id="usr_1", name="b", + source="dockerfile", status="running", cluster_id="clu_b") + # The handler notifies the build's owner, and notification.user_id is a real foreign key. + await seed(db, [user_row("usr_1"), build]) + bid = build.id + ev = imagebuild_status_router.BuildStatusEvent(phase="succeeded", image_ref="evil/registry:1") + with pytest.raises(Forbidden): + await imagebuild_status_router.report_build_status(bid, ev, {"sub": "operator:clu_a"}, db) + await db.rollback() + db.expunge_all() + row = await db.get(ImageBuild, bid) + assert row.status == "running" and row.image_id is None + await db.commit() + out = await imagebuild_status_router.report_build_status(bid, ev, {"sub": "operator:clu_b"}, db) + assert out["status"] == "succeeded" diff --git a/backend/tests/test_beta_credits_scenarios.py b/backend/tests/test_beta_credits_scenarios.py new file mode 100644 index 0000000..fea59c9 --- /dev/null +++ b/backend/tests/test_beta_credits_scenarios.py @@ -0,0 +1,604 @@ +"""Beta-test scenarios for the credit engine (hold -> consume -> settle/refund). + +Every scenario finishes with ``check_ledger``: the wallet's balance must equal its starting +balance plus the sum of every balance-moving ledger row (hold/refund/settle move ``reserved`` +only), the last row's ``balance_after`` must match the wallet, and 0 <= reserved <= balance. +Runs on the in-memory SQLite ``db`` fixture and, with ``GSHARE_TEST_DATABASE_URL`` set, on +Postgres. +""" +from __future__ import annotations + +import time +from datetime import UTC, datetime, timedelta +from decimal import Decimal + +import pytest +from sqlalchemy import select, update +from sqlalchemy.exc import IntegrityError + +from app.core import ids +from app.core.errors import Forbidden, InsufficientCredit +from app.db.models import ( + CreditTransaction, + CreditWallet, + Membership, + Organization, + Project, + Session, +) +from app.domain.credit_engine import CreditEngine +from tests.beta_support import ( + check_ledger, + ledger_rows, + make_gpu_session, + make_refs, + make_user, + make_wallet, + wallet_state, +) +from tests.fkseed import seed + +pytestmark = pytest.mark.asyncio + + +# ── 1 balance exactly equal to the hold ────────────────────────────────────────────────────── +async def test_balance_equal_to_hold_runs_the_full_hour_then_settles_to_zero(db, fake_redis): + """A wallet funded to exactly one hour must run that hour, be exhausted only at its end, and + settle to 0/0 with no refund and no negative balance.""" + refs = await make_refs(db) + wallet = await make_wallet(db, "60", owner_id=refs.user_id) + engine = CreditEngine(db) + started = datetime.now(UTC) - timedelta(hours=1) + sess = await make_gpu_session(db, refs, wallet, "60", started) + + await engine.hold(wallet, Decimal("60"), key=f"hold:{sess.id}") + assert await wallet_state(db, wallet) == (Decimal("60.00"), Decimal("60.00")) + + for minute in (1, 30, 59): + await engine.consume(sess, minute, started + timedelta(minutes=minute)) + balance, reserved = await wallet_state(db, wallet) + assert balance == Decimal(60 - minute) and reserved == balance + # Own reservation covers the rest of the hour: not exhausted yet. + assert await fake_redis.get(f"grace:{sess.id}") is None, minute + + await engine.consume(sess, 60, started + timedelta(minutes=60)) + assert await wallet_state(db, wallet) == (Decimal("0.00"), Decimal("0.00")) + assert await fake_redis.get(f"grace:{sess.id}") is not None # exhausted exactly at the end + + await engine.settle(sess, key=f"settle:{sess.id}", final_consume=False) + rows = await ledger_rows(db, wallet, ref=sess.id) + assert [r.type for r in rows if r.type == "refund"] == [] # nothing left to refund + assert sum(-r.amount for r in rows if r.type == "consume") == Decimal("60.00") + await check_ledger(db, wallet, Decimal("60")) + + +# ── 2 balance zero ───────────────────────────────────────────────────────────────────────── +async def test_zero_balance_rejects_hold_and_consume_is_a_clean_no_charge(db, fake_redis): + refs = await make_refs(db) + wallet = await make_wallet(db, "0", owner_id=refs.user_id) + engine = CreditEngine(db) + started = datetime.now(UTC) - timedelta(minutes=5) + sess = await make_gpu_session(db, refs, wallet, "60", started) + await engine.consume(sess, 5, started + timedelta(minutes=5)) + # Nothing to debit: no consume row, no negative balance, but grace is armed. + assert await ledger_rows(db, wallet, ref=sess.id) == [] + assert await fake_redis.get(f"grace:{sess.id}") is not None + await engine.settle(sess, key=f"settle:{sess.id}") + rows = await ledger_rows(db, wallet, ref=sess.id) + assert [r.type for r in rows] == ["settle"] and rows[0].amount == Decimal("0") + await check_ledger(db, wallet, Decimal("0")) + + # Last, because a rejected hold leaves the aiosqlite connection awaiting rollback (see + # test_credit_engine): even one cent is refused on an empty wallet. + with pytest.raises(InsufficientCredit) as exc: + await engine.hold(wallet, Decimal("0.01"), key="hold:ses_zero") + assert exc.value.http == 402 + if db.bind.dialect.name == "postgresql": + await db.rollback() + assert len(await ledger_rows(db, wallet)) == len(rows) # no row left behind + assert await wallet_state(db, wallet) == (Decimal("0.00"), Decimal("0.00")) + + +# ── 3 sub-cent amounts: rounding direction and zero drift ───────────────────────────────── +async def test_hold_rounds_half_up_to_the_cent(db): + wallet = await make_wallet(db, "100") + engine = CreditEngine(db) + await engine.hold(wallet, Decimal("10.005"), key="hold:ses_a") + assert (await wallet_state(db, wallet))[1] == Decimal("10.01") # half-up, never truncated + await engine.hold(wallet, Decimal("10.004"), key="hold:ses_b") + assert (await wallet_state(db, wallet))[1] == Decimal("20.01") + rows = await ledger_rows(db, wallet) + assert [r.amount for r in rows] == [Decimal("10.01"), Decimal("10.00")] + await check_ledger(db, wallet, Decimal("100")) + + +async def test_running_total_differencing_has_no_per_minute_rounding_drift(db): + """rate 1.00/h at 1/3 occupancy owes 0.00556/min: rounding each minute on its own would bill + 0.01 x 60 = 0.60 for the hour; differencing must bill round(1/3) = 0.33.""" + refs = await make_refs(db) + wallet = await make_wallet(db, "10", owner_id=refs.user_id) + engine = CreditEngine(db) + started = datetime.now(UTC) - timedelta(hours=1) + # occupancy = max(5000/15000, 0/100) = 1/3 + sess = await make_gpu_session(db, refs, wallet, "1.00", started, gpu_mem_mb=5000, + gpu_cores=0, total_mem_mb=15000) + for minute in range(1, 61): + await engine.consume(sess, minute, started + timedelta(minutes=minute)) + balance, _ = await wallet_state(db, wallet) + assert Decimal("10") - balance == Decimal("0.33") + rows = await ledger_rows(db, wallet, ref=sess.id) + assert all(r.amount <= Decimal("-0.01") for r in rows) # only non-zero debits are recorded + assert sum(-r.amount for r in rows) == Decimal("0.33") + await check_ledger(db, wallet, Decimal("10")) + + +async def test_sub_cent_rate_charges_nothing_until_a_cent_is_owed(db): + refs = await make_refs(db) + wallet = await make_wallet(db, "10", owner_id=refs.user_id) + engine = CreditEngine(db) + started = datetime.now(UTC) - timedelta(minutes=10) + sess = await make_gpu_session(db, refs, wallet, "0.10", started) # 0.00167/min + await engine.consume(sess, 1, started + timedelta(minutes=1)) # owed 0.00 -> nothing + assert await ledger_rows(db, wallet, ref=sess.id) == [] + await engine.consume(sess, 3, started + timedelta(minutes=3)) # owed 0.005 -> 0.01 + assert (await wallet_state(db, wallet))[0] == Decimal("9.99") + await check_ledger(db, wallet, Decimal("10")) + + +# ── 4 pause / resume cycles ─────────────────────────────────────────────────────────────── +async def _backdate_rows(db, session_id: str, at: datetime) -> None: + """Move this session's ledger rows to the simulated clock: in production the rows are + written at the moment of the charge, which is what the run-scoped sum relies on.""" + async with db.begin(): + await db.execute( + update(CreditTransaction) + .where(CreditTransaction.ref == session_id, CreditTransaction.created_at > at) + .values(created_at=at) + ) + + +async def test_pause_resume_cycles_bill_only_the_running_intervals(db): + """Run 30 min, pause (true-up), resume with a rebased started_at, run 20 min, pause, resume, + run 10 min, terminate from paused. Total billed must be 60 min = 60 credits; the paused gaps + must not be billed; the hold refunds exactly hold - consumed.""" + refs = await make_refs(db) + wallet = await make_wallet(db, "200", owner_id=refs.user_id) + engine = CreditEngine(db) + t0 = datetime.now(UTC) - timedelta(hours=6) + sess = await make_gpu_session(db, refs, wallet, "60", t0) + await engine.hold(wallet, Decimal("60"), key=f"hold:{sess.id}") + + bucket = 1000 + await engine.consume(sess, bucket, t0 + timedelta(minutes=20)) + bucket += 1 + await engine.consume(sess, bucket, t0 + timedelta(minutes=30)) # pause true-up: 30 + await _backdate_rows(db, sess.id, t0 + timedelta(minutes=30)) + assert (await wallet_state(db, wallet))[0] == Decimal("170.00") + + resumed_at = t0 + timedelta(hours=2) # a two-hour paused gap on the session's timeline + for run_minutes, expected_balance in ((20, Decimal("150.00")), (10, Decimal("140.00"))): + async with db.begin(): + row = await db.get(Session, sess.id) + row.started_at = resumed_at + db.expunge_all() + sess = await db.get(Session, sess.id) + await db.commit() + bucket += 1 + await engine.consume(sess, bucket, resumed_at + timedelta(minutes=run_minutes // 2)) + bucket += 1 + await engine.consume(sess, bucket, resumed_at + timedelta(minutes=run_minutes)) + await _backdate_rows(db, sess.id, resumed_at + timedelta(minutes=run_minutes)) + balance, reserved = await wallet_state(db, wallet) + assert balance == expected_balance, (run_minutes, balance) + assert reserved == max(Decimal("0"), Decimal("60") - (Decimal("200") - balance)) + resumed_at += timedelta(hours=2) + + # Terminate from paused: no final consume (the clock kept running while nothing ran). + await engine.settle(sess, key=f"settle:{sess.id}", final_consume=False) + balance, reserved = await wallet_state(db, wallet) + assert (balance, reserved) == (Decimal("140.00"), Decimal("0.00")) + rows = await ledger_rows(db, wallet, ref=sess.id) + assert sum(-r.amount for r in rows if r.type == "consume") == Decimal("60.00") + assert [r.amount for r in rows if r.type == "refund"] == [] # hold 60 fully consumed + await check_ledger(db, wallet, Decimal("200")) + + +async def test_terminate_from_paused_refunds_the_unused_slice_of_the_hold(db): + refs = await make_refs(db) + wallet = await make_wallet(db, "200", owner_id=refs.user_id) + engine = CreditEngine(db) + started = datetime.now(UTC) - timedelta(minutes=15) + sess = await make_gpu_session(db, refs, wallet, "60", started, status="paused") + await engine.hold(wallet, Decimal("60"), key=f"hold:{sess.id}") + await engine.consume(sess, 7, started + timedelta(minutes=15)) # pause true-up: 15 + await engine.settle(sess, key=f"settle:{sess.id}", final_consume=False) + balance, reserved = await wallet_state(db, wallet) + assert (balance, reserved) == (Decimal("185.00"), Decimal("0.00")) + rows = await ledger_rows(db, wallet, ref=sess.id) + assert [r.amount for r in rows if r.type == "refund"] == [Decimal("45.00")] + await check_ledger(db, wallet, Decimal("200")) + + +# ── 5 settle twice / out of order ───────────────────────────────────────────────────────── +async def test_settle_twice_is_idempotent_and_refunds_once(db): + refs = await make_refs(db) + wallet = await make_wallet(db, "100", owner_id=refs.user_id) + engine = CreditEngine(db) + started = datetime.now(UTC) - timedelta(minutes=30) + sess = await make_gpu_session(db, refs, wallet, "60", started) + await engine.hold(wallet, Decimal("60"), key=f"hold:{sess.id}") + await engine.consume(sess, 1, started + timedelta(minutes=30)) # 30 charged + await engine.settle(sess, key=f"settle:{sess.id}") + first = await wallet_state(db, wallet) + assert first[1] == Decimal("0.00") + # Re-take a hold that must NOT be released again by a duplicate settle. + await engine.hold(wallet, Decimal("10"), key="hold:other") + await engine.settle(sess, key=f"settle:{sess.id}") + await engine.settle(sess, key=f"settle:{sess.id}", final_consume=False) + balance, reserved = await wallet_state(db, wallet) + assert balance == first[0] and reserved == Decimal("10.00") + rows = await ledger_rows(db, wallet, ref=sess.id) + assert len([r for r in rows if r.type == "settle"]) == 1 + assert len([r for r in rows if r.type == "refund"]) == 1 + await check_ledger(db, wallet, Decimal("100")) + + +async def test_consume_after_settle_does_not_charge_a_settled_session(db): + """A late per-minute tick that lands after settle must not reopen billing: the session is + terminal, its hold is refunded, and a charge now would move balance with no way to settle + it again (settle:{ses} is already spent).""" + refs = await make_refs(db) + wallet = await make_wallet(db, "100", owner_id=refs.user_id) + engine = CreditEngine(db) + started = datetime.now(UTC) - timedelta(minutes=30) + sess = await make_gpu_session(db, refs, wallet, "60", started) + await engine.hold(wallet, Decimal("60"), key=f"hold:{sess.id}") + await engine.consume(sess, 1, started + timedelta(minutes=30)) + await engine.settle(sess, key=f"settle:{sess.id}") + settled = await wallet_state(db, wallet) + + await engine.consume(sess, 2, started + timedelta(minutes=45)) # out-of-order late tick + assert await wallet_state(db, wallet) == settled + rows = await ledger_rows(db, wallet, ref=sess.id) + assert rows[-1].type == "settle" # the settle marker stays the last word + await check_ledger(db, wallet, Decimal("100")) + + +async def test_settle_before_the_last_tick_bills_the_partial_minute_once(db): + """settle(final_consume=True) closes the final partial minute; a tick for the same minute that + arrives afterwards must find nothing left to charge.""" + refs = await make_refs(db) + wallet = await make_wallet(db, "100", owner_id=refs.user_id) + engine = CreditEngine(db) + # started 30m30s ago: settle owes ceil -> 31 minutes = 31.00 + started = datetime.now(UTC) - timedelta(minutes=30, seconds=30) + sess = await make_gpu_session(db, refs, wallet, "60", started) + await engine.hold(wallet, Decimal("60"), key=f"hold:{sess.id}") + await engine.consume(sess, 1, started + timedelta(minutes=30)) # 30 + await engine.settle(sess, key=f"settle:{sess.id}", final_consume=True) + balance, reserved = await wallet_state(db, wallet) + assert balance == Decimal("69.00") and reserved == Decimal("0.00") + rows = await ledger_rows(db, wallet, ref=sess.id) + assert [r.amount for r in rows if r.type == "refund"] == [Decimal("29.00")] + await check_ledger(db, wallet, Decimal("100")) + + +# ── 6 exhaustion -> grace -> pause -> top-up -> auto-resume attempt ─────────────────────── +async def test_exhaustion_grace_pause_and_topup_resume_flow(db, fake_redis, monkeypatch): + from app.cluster.handoff import Handoff + from app.domain import session_service as ss_mod + from app.workers import grace_enforcer + + refs = await make_refs(db) + wallet = await make_wallet(db, "5", owner_id=refs.user_id) + engine = CreditEngine(db) + started = datetime.now(UTC) - timedelta(hours=1) + sess = await make_gpu_session(db, refs, wallet, "300", started) + sid = sess.id + + # Exhaustion: 300 owed, 5 available -> charged down to 0, grace armed. + await engine.consume(sess, 1, started + timedelta(hours=1)) + assert await wallet_state(db, wallet) == (Decimal("0.00"), Decimal("0.00")) + assert await fake_redis.get(f"grace:{sid}") is not None + + paused_calls: list[tuple[str, bool]] = [] + + async def _fake_set_paused(self, s, paused, *, graceful_demote=None): + paused_calls.append((s.id, paused)) + return 0 + + monkeypatch.setattr(Handoff, "set_paused", _fake_set_paused) + monkeypatch.setattr(grace_enforcer, "get_sessionmaker", lambda: (lambda: db)) + + # Inside the window: only a warning, the session keeps running. + await grace_enforcer.run() + db.expunge_all() + assert (await db.get(Session, sid)).status == "running" + await db.commit() + assert await fake_redis.get(f"grace-warned:{sid}") is not None + assert paused_calls == [] + + # Window expired and still short -> graceful pause (credit_exhausted), hold kept. + await fake_redis.set(f"grace:{sid}", str(time.time() - ss_mod.GRACE_PERIOD_SEC - 1)) + await grace_enforcer.run() + db.expunge_all() + row = await db.get(Session, sid) + assert (row.status, row.status_reason) == ("paused", "credit_exhausted") + await db.commit() + assert paused_calls == [(sid, True)] + assert await fake_redis.get(f"grace:{sid}") is None + assert await fake_redis.get(f"credit-paused:{sid}") is not None + # Still broke: the resume pass leaves it paused and keeps the marker. + started_calls: list[str] = [] + + async def _fake_start(self, session_id): + started_calls.append(session_id) + + monkeypatch.setattr(ss_mod.SessionService, "start", _fake_start) + await grace_enforcer.run() + assert started_calls == [] + assert await fake_redis.get(f"credit-paused:{sid}") is not None + + # Top up -> the next tick auto-resumes and drops the marker. + async with db.begin(): + w = await db.get(CreditWallet, wallet, with_for_update=True) + w.balance += Decimal("50") + db.add(CreditTransaction( + id=ids.new("transaction"), wallet_id=w.id, type="topup", amount=Decimal("50"), + balance_after=w.balance, idempotency_key="topup:beta", ref=None, + )) + await grace_enforcer.run() + assert started_calls == [sid] + assert await fake_redis.get(f"credit-paused:{sid}") is None + await check_ledger(db, wallet, Decimal("5")) + + +# ── 7 monthly refill ─────────────────────────────────────────────────────────────────────── +def _refill_ready(db, monkeypatch): + from app.workers import credit_refill + + monkeypatch.setattr(credit_refill, "get_sessionmaker", lambda: (lambda: db)) + return credit_refill + + +async def test_monthly_refill_tops_up_to_grant_keeps_surplus_and_respects_reserved( + db, fake_redis, monkeypatch +): + refill = _refill_ready(db, monkeypatch) + low = await make_wallet(db, "30", grant="100") + high = await make_wallet(db, "150", grant="100") # surplus is kept (top-up, not reset) + held = await make_wallet(db, "50", reserved="50", grant="40") # reserved above grant: untouched + held_low = await make_wallet(db, "20", reserved="20", grant="100") + none = await make_wallet(db, "5", grant="0") # 0 disables refills + + await refill.run() + db.expunge_all() + assert await wallet_state(db, low) == (Decimal("100.00"), Decimal("0.00")) + assert await wallet_state(db, high) == (Decimal("150.00"), Decimal("0.00")) + assert await wallet_state(db, held) == (Decimal("50.00"), Decimal("50.00")) + assert await wallet_state(db, held_low) == (Decimal("100.00"), Decimal("20.00")) + assert await wallet_state(db, none) == (Decimal("5.00"), Decimal("0.00")) + for w, initial in ((low, "30"), (high, "150"), (held, "50"), (held_low, "20"), (none, "5")): + await check_ledger(db, w, Decimal(initial)) + rows = await ledger_rows(db, low) + assert [(r.type, r.amount, r.ref) for r in rows] == [("adjust", Decimal("70.00"), "monthly_refill")] + + # Same month again: the Redis month marker makes the second tick a no-op. + await refill.run() + db.expunge_all() + assert await wallet_state(db, low) == (Decimal("100.00"), Decimal("0.00")) + + +async def test_monthly_refill_replay_after_marker_loss_never_credits_twice( + db, fake_redis, monkeypatch +): + """Redis lost the month marker (restart without persistence) after a sweep that already + credited wallet A. The replay must not credit A again, must not raise, and must still finish + the sweep for wallets the earlier run had not reached.""" + refill = _refill_ready(db, monkeypatch) + a = await make_wallet(db, "30", grant="100") + b = await make_wallet(db, "10", grant="100") + await refill.run() + db.expunge_all() + assert (await wallet_state(db, a))[0] == Decimal("100.00") + + # A spends 40; then the marker is lost and the worker re-runs. + async with db.begin(): + row = await db.get(CreditWallet, a, with_for_update=True) + row.balance -= Decimal("40") + db.add(CreditTransaction( + id=ids.new("transaction"), wallet_id=a, type="consume", amount=Decimal("-40"), + balance_after=row.balance, idempotency_key="consume:beta:1", ref="ses", + )) + keys = [k async for k in fake_redis.scan_iter(match="credit_refill:done:*")] + assert keys + await fake_redis.delete(*keys) + + await refill.run() # must not raise, must not double-credit + db.expunge_all() + assert (await wallet_state(db, a))[0] == Decimal("60.00") # no second refill this month + assert (await wallet_state(db, b))[0] == Decimal("100.00") + assert len([r for r in await ledger_rows(db, a) if r.type == "adjust"]) == 1 + await check_ledger(db, a, Decimal("30")) + await check_ledger(db, b, Decimal("10")) + + +async def test_monthly_refill_failed_sweep_releases_the_month_marker(db, fake_redis, monkeypatch): + """A sweep that crashes half-way must not leave the month marker claimed: the hourly tick has + to retry, or the unreached wallets go without their refill for the whole month.""" + from app.workers import credit_refill + + await make_wallet(db, "1", grant="100") + calls = {"n": 0} + + def _flaky_sessionmaker(): + calls["n"] += 1 + if calls["n"] == 2: # the schedule read succeeds; the sweep session blows up + raise RuntimeError("db gone") + return lambda: db + + monkeypatch.setattr(credit_refill, "get_sessionmaker", _flaky_sessionmaker) + with pytest.raises(RuntimeError): + await credit_refill.run() + keys = [k async for k in fake_redis.scan_iter(match="credit_refill:done:*")] + assert keys == [], "month marker must be released after a failed sweep" + + +# ── 8 hierarchical allocation / monthly grant ceilings ──────────────────────────────────── +async def _org_tree(db): + org = Organization(id=ids.new("org"), name=ids.new("org")) + prj = Project(id=ids.new("group"), org_id=org.id, name="p") + prj2 = Project(id=ids.new("group"), org_id=org.id, name="p2") + await seed(db, [org, prj, prj2]) + return org.id, prj.id, prj2.id + + +async def test_allocate_cannot_hand_out_more_than_the_parent_received(db): + from app.api.credits_router import allocate + from app.api.schemas.credit import AllocateBody + from app.auth.rbac import Principal + + org, prj, _ = await _org_tree(db) + org_w = await make_wallet(db, "100", reserved="30", owner_type="org", owner_id=org) + prj_w = await make_wallet(db, "0", owner_type="group", owner_id=prj) + admin = Principal(user_id=await make_user(db), org_admin_orgs={org}) + + # available = 100 - 30 = 70: 70.01 is refused, 70 is granted. + with pytest.raises(InsufficientCredit): + await allocate(AllocateBody(from_wallet_id=org_w, to_wallet_id=prj_w, + amount=Decimal("70.01")), principal=admin, idem="a1", db=db) + await db.rollback() + await allocate(AllocateBody(from_wallet_id=org_w, to_wallet_id=prj_w, + amount=Decimal("70")), principal=admin, idem="a2", db=db) + assert await wallet_state(db, org_w) == (Decimal("30.00"), Decimal("30.00")) + assert await wallet_state(db, prj_w) == (Decimal("70.00"), Decimal("0.00")) + # Replay with the same key moves nothing. + await allocate(AllocateBody(from_wallet_id=org_w, to_wallet_id=prj_w, + amount=Decimal("70")), principal=admin, idem="a2", db=db) + assert await wallet_state(db, prj_w) == (Decimal("70.00"), Decimal("0.00")) + await check_ledger(db, org_w, Decimal("100")) + await check_ledger(db, prj_w, Decimal("0")) + + +async def test_monthly_grant_siblings_cannot_exceed_the_parent_grant(db): + from app.api.credits_router import set_monthly_grant + from app.api.schemas.credit import MonthlyGrantBody + from app.auth.rbac import Principal + from app.core.errors import DomainError + + org, prj, prj2 = await _org_tree(db) + await make_wallet(db, "0", owner_type="org", owner_id=org, grant="100") + w1 = await make_wallet(db, "0", owner_type="group", owner_id=prj, grant="60") + w2 = await make_wallet(db, "0", owner_type="group", owner_id=prj2) + admin = Principal(user_id=await make_user(db), org_admin_orgs={org}) + + with pytest.raises(DomainError) as exc: + await set_monthly_grant(w2, MonthlyGrantBody(amount=Decimal("40.01")), + principal=admin, db=db) + assert exc.value.code == "validation_failed" + await db.rollback() + await set_monthly_grant(w2, MonthlyGrantBody(amount=Decimal("40")), principal=admin, db=db) + db.expunge_all() + assert (await db.get(CreditWallet, w2)).monthly_grant == Decimal("40.00") + assert (await db.get(CreditWallet, w1)).monthly_grant == Decimal("60.00") + + +async def test_monthly_grant_increase_credits_balance_through_the_ledger(db): + """The immediate credit on a grant increase moves balance, so it must be a ledger row: + otherwise the transaction history no longer explains the wallet balance.""" + from app.api.credits_router import bulk_monthly_grant, set_monthly_grant + from app.api.schemas.credit import BulkMonthlyGrantBody, MonthlyGrantBody + from app.auth.rbac import Principal + + org, prj, _ = await _org_tree(db) + await make_wallet(db, "0", owner_type="org", owner_id=org, grant="1000") + prj_w = await make_wallet(db, "10", owner_type="group", owner_id=prj, grant="0") + admin = Principal(user_id=await make_user(db), org_admin_orgs={org}) + + await set_monthly_grant(prj_w, MonthlyGrantBody(amount=Decimal("100")), + principal=admin, db=db) + assert (await wallet_state(db, prj_w))[0] == Decimal("100.00") + await check_ledger(db, prj_w, Decimal("10")) # +90 must be on the ledger + + # A decrease credits nothing and writes nothing. + await set_monthly_grant(prj_w, MonthlyGrantBody(amount=Decimal("50")), + principal=admin, db=db) + assert (await wallet_state(db, prj_w))[0] == Decimal("100.00") + assert len(await ledger_rows(db, prj_w)) == 1 + + # Bulk grant on member wallets: same rule. + users = [await make_user(db) for _ in range(2)] + member_ws = [await make_wallet(db, "5", owner_id=u) for u in users] + await seed(db, [ + Membership(id=ids.new("membership"), user_id=u, group_id=prj, role="member") + for u in users + ]) + gadmin = Principal(user_id=await make_user(db), memberships={prj: "group_admin"}) + await bulk_monthly_grant(BulkMonthlyGrantBody(group_id=prj, amount=Decimal("25")), + principal=gadmin, db=db) + for w in member_ws: + assert (await wallet_state(db, w))[0] == Decimal("25.00") + await check_ledger(db, w, Decimal("5")) + + +# ── 9 billing another user's or a group wallet ──────────────────────────────────────────── +async def test_session_cannot_bill_another_users_or_a_group_wallet(db, fake_handoff): + from app.api.schemas.session import SessionCreate + from app.auth.rbac import Principal + from app.domain.scheduler import SchedulerService + + refs = await make_refs(db, devices=1) + _org, prj, _ = await _org_tree(db) + my_w = await make_wallet(db, "1000", owner_id=refs.user_id) + other_w = await make_wallet(db, "1000") + group_w = await make_wallet(db, "1000", owner_type="group", owner_id=prj) + principal = Principal(user_id=refs.user_id, memberships={prj: "group_admin"}) + svc = SchedulerService(db) + svc.handoff = fake_handoff + + def _req(wallet_id: str) -> SessionCreate: + return SessionCreate(offering_id=refs.offering_id, image_id=refs.image_id, + resource_class="gpu", cluster_id=refs.cluster_id, group_id=prj, + gpu_mem_mb=8000, gpu_cores=50, mode="fractional", + billing_wallet_id=wallet_id) + + for n, foreign in enumerate((other_w, group_w)): + with pytest.raises(Forbidden): + await svc.create_session(_req(foreign), principal, idem=f"foreign-{n}") + await db.rollback() + db.expunge_all() + assert await wallet_state(db, foreign) == (Decimal("1000.00"), Decimal("0.00")) + assert await ledger_rows(db, foreign) == [] + assert fake_handoff.last_spec is None + + # Own wallet: admitted, hold taken on MY wallet only. + out = await svc.create_session(_req(my_w), principal, idem="mine") + assert fake_handoff.last_spec is not None + db.expunge_all() + assert (await db.get(Session, out.id)).billing_wallet_id == my_w + await db.commit() + balance, reserved = await wallet_state(db, my_w) + assert reserved > Decimal("0") and balance == Decimal("1000.00") + assert await wallet_state(db, other_w) == (Decimal("1000.00"), Decimal("0.00")) + assert await wallet_state(db, group_w) == (Decimal("1000.00"), Decimal("0.00")) + await check_ledger(db, my_w, Decimal("1000")) + + +# ── 10 sigma constraint is enforced by the database, not only by the engine ─────────────── +async def test_wallet_sigma_constraint_rejects_reserved_above_balance(db): + if db.bind.dialect.name != "postgresql": + pytest.skip("asserts the migrated Postgres schema; SQLite create_all is not the contract") + wallet = await make_wallet(db, "10") + with pytest.raises(IntegrityError): + async with db.begin(): + row = await db.get(CreditWallet, wallet, with_for_update=True) + row.reserved = Decimal("10.01") + await db.rollback() + with pytest.raises(IntegrityError): + async with db.begin(): + row = await db.get(CreditWallet, wallet, with_for_update=True) + row.balance = Decimal("-0.01") + await db.rollback() + assert ( + await db.scalar(select(CreditWallet.balance).where(CreditWallet.id == wallet)) + ) == Decimal("10.00") diff --git a/backend/tests/test_beta_lead_hypotheses.py b/backend/tests/test_beta_lead_hypotheses.py new file mode 100644 index 0000000..f15831e --- /dev/null +++ b/backend/tests/test_beta_lead_hypotheses.py @@ -0,0 +1,246 @@ +"""Regression tests for the pre-review hypotheses H-1..H-6 (beta-test pass). + +Each test pins one behaviour the review suspected was wrong; every one of them failed on the +code as found and passes with the fix, so a later change that reopens the hole is caught here. +""" +from __future__ import annotations + +import time +from contextlib import asynccontextmanager + +import pytest +from jose import jwt +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from starlette.requests import Request + +from app.api.users_router import _LoginRequest, auth_login +from app.auth.rbac import _resolve_principal_uncached +from app.core import ids +from app.core.config import settings +from app.core.errors import DomainError, Unauthenticated +from app.core.passwords import hash_password +from app.db.models import AuditLog, Cluster, User +from tests.fkseed import seed + + +def _request(path: str = "/api/v1/auth/login", *, xff: str | None = None, + query: str = "", peer: str = "9.9.9.9") -> Request: + headers = [(b"host", b"gshare.test")] + if xff is not None: + headers.append((b"x-forwarded-for", xff.encode())) + return Request({ + "type": "http", "method": "GET", "path": path, "raw_path": path.encode(), + "root_path": "", "scheme": "http", "server": ("gshare.test", 80), + "headers": headers, "client": (peer, 1234), "query_string": query.encode(), + }) + + +def _user_token(uid: str, email: str, **extra) -> str: + now = int(time.time()) + claims = { + "sub": uid, "aud": "gshare-user", "email": email, + "global_role": None, "global_roles": [], + "must_change_password": False, "iat": now, "exp": now + 60, + } + claims.update(extra) + return jwt.encode(claims, settings.USER_JWT_SECRET, algorithm="HS256") + + +# ── H-1: a demoted super_admin keeps the role for the life of the token ───────────────── + +@pytest.mark.asyncio +async def test_h1_global_roles_come_from_the_database_not_the_token(db): + uid = ids.new("user") + await seed(db, [ + User(id=uid, email="h1@example.com", name="H1", status="active", + global_role="super_admin", global_roles=["super_admin"]), + ]) + claims = {"sub": uid, "global_roles": ["super_admin"], "global_role": "super_admin"} + p = await _resolve_principal_uncached(db, claims) + assert "super_admin" in p.global_roles + await db.rollback() # the resolver's SELECT autobegan a transaction + # Demotion in the database while the 24h token is still valid. + async with db.begin(): + row = await db.get(User, uid) + row.global_roles = [] + row.global_role = None + p = await _resolve_principal_uncached(db, claims) + assert p.global_roles == set() and p.global_role is None + + +# ── H-2: the rate limiter trusts whatever the client puts first in X-Forwarded-For ──────── + +@pytest.mark.asyncio +async def test_h2_client_ip_is_the_hop_the_trusted_proxy_appended(db, monkeypatch): + from app.api.users_router import _client_ip + + monkeypatch.setattr(settings, "TRUSTED_PROXY_HOPS", 1, raising=False) + # The proxy appends the real peer last; anything before it is client-controlled. + assert _client_ip(_request(xff="6.6.6.6, 203.0.113.7")) == "203.0.113.7" + assert _client_ip(_request(xff="203.0.113.7")) == "203.0.113.7" + assert _client_ip(_request()) == "9.9.9.9" + monkeypatch.setattr(settings, "TRUSTED_PROXY_HOPS", 2, raising=False) + assert _client_ip(_request(xff="6.6.6.6, 203.0.113.7, 10.0.0.2")) == "203.0.113.7" + # Fewer hops than configured: the leftmost is the best available, never a spoofed extra. + assert _client_ip(_request(xff="203.0.113.7")) == "203.0.113.7" + + +@pytest.mark.asyncio +async def test_h2_login_audit_records_the_trusted_hop(db, monkeypatch): + monkeypatch.setattr(settings, "TRUSTED_PROXY_HOPS", 1, raising=False) + with pytest.raises(Unauthenticated): + await auth_login(_LoginRequest(email="ghost@example.com", password="x-y-z-1"), + _request(xff="6.6.6.6, 203.0.113.7"), db) + row = (await db.execute(select(AuditLog).where(AuditLog.action == "auth.login"))).scalars().first() + assert row is not None + assert "203.0.113.7" in str(row.detail) + assert "6.6.6.6" not in str(row.detail) + + +# ── H-3: "account pending" is disclosed before the password is checked ──────────────────── + +@pytest.mark.asyncio +async def test_h3_pending_is_only_revealed_to_the_right_password(db): + user = User(id=ids.new("user"), email="h3@example.com", name="H3", status="pending", + password_hash=hash_password("correct-horse")) + await seed(db, [user]) + with pytest.raises(Unauthenticated): + await auth_login(_LoginRequest(email=user.email, password="wrong-one"), _request(), db) + with pytest.raises(DomainError) as exc: + await auth_login(_LoginRequest(email=user.email, password="correct-horse"), _request(), db) + assert exc.value.code == "account_pending" + + +# ── H-4: ?access_token= is accepted on every endpoint, not only the SSE streams ─────────── + +@pytest.mark.asyncio +async def test_h4_query_token_is_limited_to_event_streams(db): + from app.api.deps import get_current_principal + + uid = ids.new("user") + await seed(db, [User(id=uid, email="h4@example.com", name="H4", status="active")]) + tok = _user_token(uid, "h4@example.com") + # An EventSource cannot set headers: the stream endpoints keep the query fallback. + p = await get_current_principal(_request("/api/v1/notifications/events"), None, tok, db) + assert p.user_id == uid + p = await get_current_principal(_request("/api/v1/sessions/ses_x/events"), None, tok, db) + assert p.user_id == uid + # Everything else must carry the bearer header; a token in the URL is a token in every log. + with pytest.raises(Unauthenticated): + await get_current_principal(_request("/api/v1/sessions"), None, tok, db) + with pytest.raises(Unauthenticated): + await get_current_principal(_request("/api/v1/audit-logs/export"), None, tok, db) + # The header still works everywhere. + p = await get_current_principal(_request("/api/v1/sessions"), f"Bearer {tok}", None, db) + assert p.user_id == uid + + +# ── H-5: the 409 merge-patch leaves cleared fields on the custom resource ───────────────── + +class _Conflict(Exception): + status = 409 + + +class _FakeApi: + def __init__(self): + self.patches: list[dict] = [] + + async def create_namespaced_custom_object(self, **kw): + raise _Conflict() + + async def patch_namespaced_custom_object(self, **kw): + self.patches.append(kw["body"]) + self.content_types = getattr(self, "content_types", []) + [kw.get("_content_type")] + return kw["body"] + + +@pytest.mark.asyncio +async def test_h5_reapply_clears_fields_the_new_desired_state_no_longer_carries(db, monkeypatch): + from app.cluster.crd import GShareSessionCRD + + await seed(db, [ + Cluster(id="clu_h5", name="h5", api_server="https://h5:6443", runtime="k8s", + kubeconfig_secret_ref="sec"), + ]) + api = _FakeApi() + + @asynccontextmanager + async def _factory(_cluster): + yield api + + async def _fake_factory(cluster): + return _factory(cluster) + + crd = GShareSessionCRD(db, client_factory=_fake_factory) + + async def _none(spec_like): + return None + + monkeypatch.setattr(crd, "_resolve_idle_timeout_sec", _none) + monkeypatch.setattr(crd, "_resolve_max_runtime_sec", _none) + # A spec that borrowed a card and excluded a node last time carries neither now. + spec = {"session_id": "ses_h5", "cluster_id": "clu_h5", "resource_class": "gpu", + "mode": "fractional", "owner": "usr_h5", "image": "img", "cpu": 2, "mem_gb": 4, + "gpu_mem_mb": 4096, "gpu_cores": 25} + await crd.apply("clu_h5", spec) + assert len(api.patches) == 1 + # Forced merge-patch: the client's default for a dict body is a strategic merge patch, which + # the API server refuses for custom resources with 415. + assert api.content_types == ["application/merge-patch+json"] + patched = api.patches[0]["spec"] + # Present fields are carried; fields the desired state dropped are explicitly nulled so a + # JSON merge-patch removes them instead of leaving last run's values behind. + assert patched["gpuMemMb"] == 4096 and patched["mode"] == "fractional" + for stale in ("borrowedGpuUuid", "borrowedNode", "pinnedGpuUuid", "excludedNodes", + "migProfile", "paused", "fullCard", "volumes"): + assert stale in patched and patched[stale] is None, stale + # A lifted lifetime cap must also disappear from the annotations. + ann = api.patches[0]["metadata"]["annotations"] + assert "gshare.io/max-runtime-sec" in ann and ann["gshare.io/max-runtime-sec"] is None + + +# ── H-6: any unique violation is mistaken for an idempotent replay ──────────────────────── + +@pytest.mark.asyncio +async def test_h6_only_the_idempotency_key_collision_is_swallowed(db): + from app.domain.credit_engine import CreditEngine + + engine = CreditEngine(db) + + def _err(msg: str) -> IntegrityError: + return IntegrityError("INSERT", {}, Exception(msg)) + + # The replay: swallowed, by design. + async with engine._keyed_atomic(): + raise _err('duplicate key value violates unique constraint ' + '"credit_transaction_idempotency_key_key"') + async with engine._keyed_atomic(): + raise _err("UNIQUE constraint failed: credit_transaction.idempotency_key") + # Any other unique violation is a real failure and must surface. + with pytest.raises(IntegrityError): + async with engine._keyed_atomic(): + raise _err('duplicate key value violates unique constraint "uq_storage_pool_cluster_class"') + with pytest.raises(IntegrityError): + async with engine._keyed_atomic(): + raise _err("UNIQUE constraint failed: allocation.session_id") + + +# ── GS-008: a forced password reset does not reach a token issued before it ────────────── + +@pytest.mark.asyncio +async def test_gs008_forced_password_change_is_read_from_the_database(db): + from app.api.deps import get_current_principal + from app.core.errors import PasswordChangeRequired + + uid = ids.new("user") + await seed(db, [ + User(id=uid, email="g8@example.com", name="G8", status="active", + must_change_password=True), + ]) + # The token predates the administrator's reset, so its claim still says False. + tok = _user_token(uid, "g8@example.com", must_change_password=False) + with pytest.raises(PasswordChangeRequired): + await get_current_principal(_request("/api/v1/sessions"), f"Bearer {tok}", None, db) + p = await get_current_principal(_request("/api/v1/auth/me"), f"Bearer {tok}", None, db) + assert p.user_id == uid diff --git a/backend/tests/test_beta_pg_concurrency.py b/backend/tests/test_beta_pg_concurrency.py new file mode 100644 index 0000000..383ff65 --- /dev/null +++ b/backend/tests/test_beta_pg_concurrency.py @@ -0,0 +1,318 @@ +"""Concurrency tests on the opt-in Postgres backend (hypothesis H-7). + +Each actor runs on its OWN AsyncSession from ``db_engine``'s pool, so the FOR UPDATE serialization, +the UNIQUE idempotency keys and the partial unique index on live allocations are exercised by real +concurrent transactions. Everything here skips automatically without ``GSHARE_TEST_DATABASE_URL``, +except the Redis-only races at the end, which run on fakeredis everywhere. +""" +from __future__ import annotations + +import asyncio +import os +from datetime import UTC, datetime, timedelta +from decimal import Decimal + +import pytest +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import async_sessionmaker + +from app.core import ids +from app.core.errors import InsufficientCredit +from app.db.models import ( + Allocation, + GpuDevice, + QueueEntry, + Session, +) +from app.domain.credit_engine import CreditEngine +from tests.beta_support import ( + check_ledger, + ledger_rows, + make_gpu_session, + make_refs, + make_wallet, + wallet_state, +) +from tests.fkseed import seed + +pytestmark = pytest.mark.asyncio +requires_pg = pytest.mark.skipif( + not os.environ.get("GSHARE_TEST_DATABASE_URL"), + reason="needs the opt-in Postgres backend (GSHARE_TEST_DATABASE_URL)", +) + + +@pytest.fixture +def maker(db_engine): + return async_sessionmaker(db_engine, expire_on_commit=False) + + +async def _on_own_session(maker, fn): + async with maker() as s: + return await fn(s) + + +async def _gather(maker, fns): + return await asyncio.gather( + *(_on_own_session(maker, fn) for fn in fns), return_exceptions=True + ) + + +# ── credit engine ────────────────────────────────────────────────────────────────────────── +@requires_pg +async def test_concurrent_holds_never_exceed_the_balance(db, maker): + wallet = await make_wallet(db, "100") + + def _hold(i): + async def run(s): + await CreditEngine(s).hold(wallet, Decimal("30"), key=f"hold:ses_{i}") + return "ok" + return run + + results = await _gather(maker, [_hold(i) for i in range(10)]) + ok = [r for r in results if r == "ok"] + refused = [r for r in results if isinstance(r, InsufficientCredit)] + other = [r for r in results if r != "ok" and not isinstance(r, InsufficientCredit)] + assert other == [], other + assert len(ok) == 3 and len(refused) == 7 # 3 x 30 = 90 fits, the fourth does not + balance, reserved = await wallet_state(db, wallet) + assert (balance, reserved) == (Decimal("100.00"), Decimal("90.00")) + assert len(await ledger_rows(db, wallet)) == 3 + await check_ledger(db, wallet, Decimal("100")) + + +@requires_pg +async def test_concurrent_holds_with_one_key_apply_once(db, maker): + """The same idempotency key raced by several callers: one reservation, one row (H-6).""" + wallet = await make_wallet(db, "100") + + async def run(s): + await CreditEngine(s).hold(wallet, Decimal("30"), key="hold:ses_same") + return "ok" + + results = await _gather(maker, [run] * 6) + assert all(r == "ok" for r in results), results + assert (await wallet_state(db, wallet))[1] == Decimal("30.00") + assert len(await ledger_rows(db, wallet)) == 1 + + +@requires_pg +async def test_concurrent_consume_of_one_bucket_charges_once(db, maker): + refs = await make_refs(db) + wallet = await make_wallet(db, "100", owner_id=refs.user_id) + started = datetime.now(UTC) - timedelta(minutes=30) + sess = await make_gpu_session(db, refs, wallet, "60", started) + sid = sess.id + + async def run(s): + row = await s.get(Session, sid) + await s.commit() + await CreditEngine(s).consume(row, 7, started + timedelta(minutes=30)) + return "ok" + + results = await _gather(maker, [run] * 6) + assert all(r == "ok" for r in results), results + assert (await wallet_state(db, wallet))[0] == Decimal("70.00") + assert len(await ledger_rows(db, wallet, ref=sid)) == 1 + await check_ledger(db, wallet, Decimal("100")) + + +@requires_pg +async def test_concurrent_settle_of_one_session_refunds_once_and_spares_the_neighbour(db, maker): + """Two sessions hold on one wallet. Six concurrent settles of A must produce exactly one + refund of A's leftover and leave B's reservation untouched.""" + refs = await make_refs(db) + wallet = await make_wallet(db, "1000", owner_id=refs.user_id) + started = datetime.now(UTC) - timedelta(minutes=30) + a = await make_gpu_session(db, refs, wallet, "60", started) + b = await make_gpu_session(db, refs, wallet, "60", started) + engine = CreditEngine(db) + await engine.hold(wallet, Decimal("100"), key=f"hold:{a.id}") + await engine.hold(wallet, Decimal("80"), key=f"hold:{b.id}") + await engine.consume(a, 1, started + timedelta(minutes=30)) # A owes 30 + aid = a.id + + async def run(s): + row = await s.get(Session, aid) + await s.commit() + await CreditEngine(s).settle(row, key=f"settle:{aid}", final_consume=False) + return "ok" + + results = await _gather(maker, [run] * 6) + assert all(r == "ok" for r in results), results + balance, reserved = await wallet_state(db, wallet) + assert (balance, reserved) == (Decimal("970.00"), Decimal("80.00")) + rows = await ledger_rows(db, wallet, ref=aid) + assert [r.amount for r in rows if r.type == "refund"] == [Decimal("70.00")] + assert len([r for r in rows if r.type == "settle"]) == 1 + await check_ledger(db, wallet, Decimal("1000")) + + +# ── GPU admission ────────────────────────────────────────────────────────────────────────── +async def _pending_gpu_sessions(db, refs, wallet, n, *, mem=8000, cores=50): + out = [] + for _ in range(n): + s = await make_gpu_session(db, refs, wallet, "60", None, status="pending", + gpu_mem_mb=mem, gpu_cores=cores, total_mem_mb=None) + out.append(s.id) + return out + + +async def _live_allocs(db): + rows = (await db.execute( + select(Allocation.session_id, Allocation.device_id, Allocation.gpu_mem_mb) + .where(Allocation.ended_at.is_(None)) + )).all() + await db.commit() + return rows + + +@requires_pg +async def test_concurrent_admission_on_one_card_never_overcommits(db, maker): + """Four 8000 MB requests race for one 16000 MB card: exactly two are placed and the card's + used counter equals the sum of its live allocations.""" + from app.domain.scheduler import SchedulerService + + refs = await make_refs(db, devices=1) + wallet = await make_wallet(db, "1000", owner_id=refs.user_id) + sids = await _pending_gpu_sessions(db, refs, wallet, 4) + + def _reserve(sid): + async def run(s): + svc = SchedulerService(s) + async with s.begin(): + sess = await s.get(Session, sid) + req = svc._req_from_entry(None, sess) + return await svc.reserve_slice(sess, req) + return run + + results = await _gather(maker, [_reserve(sid) for sid in sids]) + errors = [r for r in results if isinstance(r, BaseException)] + assert errors == [], errors + assert sorted(results) == [False, False, True, True] + allocs = await _live_allocs(db) + assert len(allocs) == 2 and sum(a.gpu_mem_mb for a in allocs) == 16000 + dev = (await db.execute(select(GpuDevice.used_mem_mb, GpuDevice.total_mem_mb))).one() + await db.commit() + assert dev.used_mem_mb == 16000 <= dev.total_mem_mb + + +@requires_pg +async def test_concurrent_dequeue_admits_the_head_exactly_once(db, maker, monkeypatch): + """Two queue tickers racing on the same head must not reserve the session twice: the loser + is stopped by the FOR UPDATE on the card plus the live-allocation unique index.""" + from app.domain import queue_ranking, scheduler + from tests.conftest import FakeHandoff + + refs = await make_refs(db, devices=1) + wallet = await make_wallet(db, "1000", owner_id=refs.user_id) + (sid,) = await _pending_gpu_sessions(db, refs, wallet, 1) + await seed(db, [QueueEntry(id=ids.new("queue"), session_id=sid, session_req={}, priority=0)]) + + barrier = asyncio.Barrier(2) + real_head = queue_ranking.head + + async def head_then_wait(s): + entry = await real_head(s) + await barrier.wait() # both tickers have read the same head before either reserves + return entry + + monkeypatch.setattr(scheduler.queue_ranking, "head", head_then_wait) + + async def run(s): + svc = scheduler.SchedulerService(s) + svc.handoff = FakeHandoff() + return await svc.reschedule_from_queue() + + results = await _gather(maker, [run, run]) + admitted = [r for r in results if r == "admitted"] + assert len(admitted) == 1, results + allocs = await _live_allocs(db) + assert len(allocs) == 1 and allocs[0].session_id == sid + dev = (await db.execute(select(GpuDevice.used_mem_mb))).scalar_one() + await db.commit() + assert dev == 8000 + left = await db.scalar(select(func.count()).select_from(QueueEntry)) + await db.commit() + assert left == 0 + + +# ── workers ──────────────────────────────────────────────────────────────────────────────── +@requires_pg +async def test_two_billing_ticks_at_once_charge_each_session_once(db, maker, monkeypatch): + from app.workers import billing_worker + + refs = await make_refs(db) + wallet = await make_wallet(db, "1000", owner_id=refs.user_id) + started = datetime.now(UTC) - timedelta(minutes=10) + sids = [(await make_gpu_session(db, refs, wallet, "60", started)).id for _ in range(3)] + monkeypatch.setattr(billing_worker, "get_sessionmaker", lambda: maker) + + await asyncio.gather(billing_worker.run(), billing_worker.run(), billing_worker.run()) + for sid in sids: + rows = await ledger_rows(db, wallet, ref=sid) + assert len(rows) == 1 and rows[0].type == "consume", rows + # 3 sessions x ceil(10 min) x 60/h = 30 in total, once. + assert (await wallet_state(db, wallet))[0] == Decimal("970.00") + await check_ledger(db, wallet, Decimal("1000")) + + +@requires_pg +async def test_session_create_with_one_idempotency_key_yields_one_session(db, maker, fake_handoff): + """Redis SET NX idempotency: two API workers racing on the same Idempotency-Key must return + the same session and create one row (the loser polls for the winner's id).""" + from app.api.schemas.session import SessionCreate + from app.auth.rbac import Principal + from app.domain.scheduler import SchedulerService + + refs = await make_refs(db, devices=1) + wallet = await make_wallet(db, "1000", owner_id=refs.user_id) + principal = Principal(user_id=refs.user_id) + + async def run(s): + svc = SchedulerService(s) + svc.handoff = fake_handoff + req = SessionCreate(offering_id=refs.offering_id, image_id=refs.image_id, + resource_class="gpu", cluster_id=refs.cluster_id, gpu_mem_mb=8000, + gpu_cores=50, mode="fractional", billing_wallet_id=wallet) + return (await svc.create_session(req, principal, idem="same-key")).id + + results = await _gather(maker, [run, run, run]) + errors = [r for r in results if isinstance(r, BaseException)] + assert errors == [], errors + assert len(set(results)) == 1 + n = await db.scalar(select(func.count()).select_from(Session)) + await db.commit() + assert n == 1 + assert (await wallet_state(db, wallet))[1] > Decimal("0") # one hold + assert len(await ledger_rows(db, wallet)) == 1 + + +# ── Redis-only races (fakeredis; run everywhere) ────────────────────────────────────────── +async def test_set_nx_race_has_exactly_one_winner(fake_redis): + results = await asyncio.gather(*(fake_redis.set("idem:beta", str(i), nx=True, ex=60) + for i in range(25))) + assert sum(1 for r in results if r) == 1 + + +async def test_runner_lock_lets_one_replica_run_a_job(fake_redis, monkeypatch): + """Two worker replicas ticking the same job under the Redis owner-token lock: the job runs + once per tick and the lock is released by its owner.""" + from app.workers import runner + + ran: list[int] = [] + + async def job(): + ran.append(1) + await asyncio.sleep(0.05) + + async def stop_after_first_tick(_interval): + raise asyncio.CancelledError + + monkeypatch.setattr(runner.asyncio, "sleep", stop_after_first_tick) + outcomes = await asyncio.gather( + runner.loop(job, 60, "beta_job"), runner.loop(job, 60, "beta_job"), + return_exceptions=True, + ) + assert all(isinstance(o, asyncio.CancelledError) for o in outcomes) + assert ran == [1] diff --git a/backend/tests/test_beta_rbac_auth.py b/backend/tests/test_beta_rbac_auth.py new file mode 100644 index 0000000..89b389c --- /dev/null +++ b/backend/tests/test_beta_rbac_auth.py @@ -0,0 +1,231 @@ +"""Beta authorization sweep (B-authz): token audience/algorithm mixing, account-state gates, the +must_change_password fence, and the operator token's cluster binding. + +These are the checks that were verified to HOLD during the sweep; they are kept as a regression +net so a later refactor of jwt_auth / rbac / internal_jwt cannot silently reopen them. +""" +from __future__ import annotations + +import time +from datetime import UTC, datetime, timedelta + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from jose import jwt + +from app.auth import internal_jwt as ij +from app.auth import rbac +from app.auth.jwt_auth import verify_jwt +from app.auth.rbac import Principal, rbac_allows, resolve_principal +from app.core import ids +from app.core.config import settings +from app.core.errors import Forbidden, Unauthenticated +from app.db.models import Membership, Organization, Project, User +from app.internal.connect_verify_router import _make_cookie, _verify_cookie +from tests.fkseed import add_ordered + +pytestmark = pytest.mark.asyncio + + +def _user_token(**claims) -> str: + now = int(time.time()) + payload = {"sub": "usr_x", "aud": "gshare-user", "iat": now, "exp": now + 60} + payload.update(claims) + return jwt.encode(payload, settings.USER_JWT_SECRET, algorithm="HS256") + + +# ── token audience / algorithm mixing ── + + +async def test_connect_cookie_is_not_a_user_bearer(): + """The gshare_session cookie is signed with the same secret as user tokens; it carries no + gshare-user audience, so it must never authenticate an API call.""" + cookie = _make_cookie("ses_1", "usr_victim") + with pytest.raises(Unauthenticated): + await verify_jwt(f"Bearer {cookie}") + + +async def test_user_bearer_is_not_a_connect_cookie(): + """The reverse direction: a user token presented as the connect cookie yields no session id, + so forward-auth falls through to the single-use cnx token.""" + assert _verify_cookie(_user_token()) is None + assert _verify_cookie("garbage") is None + assert _verify_cookie(None) is None + + assert _verify_cookie(_make_cookie("ses_1", "usr_a")) == "ses_1" + + +async def test_internal_token_is_not_a_user_bearer(monkeypatch): + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + pem = key.private_bytes( + serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption() + ).decode() + monkeypatch.setattr(settings, "INTERNAL_JWT_PRIVATE_KEY", pem) + tok = ij.sign_internal_jwt("operator:clu_A") + with pytest.raises(Unauthenticated): + await verify_jwt(f"Bearer {tok}") + + +async def test_user_bearer_is_not_an_internal_token(monkeypatch): + """HS256 user token against the RS256-only internal verifier (also covers the classic + alg-confusion attack: an HS256 token signed with the public key is refused because HS256 is + not in the allowed algorithm list).""" + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + pem = key.private_bytes( + serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption() + ).decode() + monkeypatch.setattr(settings, "INTERNAL_JWT_PRIVATE_KEY", pem) + jwks = await ij.load_internal_jwks() + + async def _fake_jwks(): + return jwks + + monkeypatch.setattr(ij, "_internal_jwks", _fake_jwks) + + good = ij.sign_internal_jwt("operator:clu_A") + assert (await ij.require_internal_jwt(f"Bearer {good}"))["sub"] == "operator:clu_A" + + with pytest.raises(Unauthenticated): + await ij.require_internal_jwt(f"Bearer {_user_token(aud='gshare-internal')}") + + pub_pem = key.public_key().public_bytes( + serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo + ).decode() + now = int(time.time()) + + forged = _hs256_by_hand( + {"alg": "HS256", "typ": "JWT", "kid": settings.INTERNAL_JWT_KID}, + {"sub": "operator:clu_A", "aud": "gshare-internal", "iat": now, "exp": now + 60}, + pub_pem.encode(), + ) + with pytest.raises(Unauthenticated): + await ij.require_internal_jwt(f"Bearer {forged}") + + +def _hs256_by_hand(header: dict, claims: dict, secret: bytes) -> str: + import base64 + import hashlib + import hmac + import json + + def b64(b: bytes) -> str: + return base64.urlsafe_b64encode(b).rstrip(b"=").decode() + + signing = f"{b64(json.dumps(header).encode())}.{b64(json.dumps(claims).encode())}" + sig = hmac.new(secret, signing.encode(), hashlib.sha256).digest() + return f"{signing}.{b64(sig)}" + + +# ── operator token cluster binding ── + + +async def test_operator_token_cluster_binding(): + a = {"sub": "operator:clu_A"} + ij.require_operator_cluster(a, "clu_A", what="session") + ij.require_operator_cluster(a, None, what="session") + ij.require_operator_cluster({"sub": "legacy"}, "clu_B", what="session") + with pytest.raises(Forbidden): + ij.require_operator_cluster(a, "clu_B", what="session") + + +# ── account state gates ── + + +async def _seed_user(db, *, status="active", deleted=False, memberships=()): + uid = ids.new("user") + await add_ordered(db, [ + User(id=uid, email=f"{uid}@x", name=uid, status=status, + deleted_at=datetime.now(UTC) if deleted else None), + *(Membership(id=ids.new("membership"), user_id=uid, group_id=gid, role=role, + expires_at=exp) for gid, role, exp in memberships), + ]) + return uid + + +async def test_expired_guest_membership_is_dropped(db): + db.add_all([Organization(id="org_G", name="G"), Project(id="grp_G", org_id="org_G", name="g")]) + past = datetime.now(UTC) - timedelta(minutes=1) + future = datetime.now(UTC) + timedelta(days=1) + expired = await _seed_user(db, memberships=[("grp_G", "guest", past)]) + live = await _seed_user(db, memberships=[("grp_G", "guest", future)]) + rbac._PRINCIPAL_CACHE.clear() + p_exp = await resolve_principal(db, {"sub": expired}) + p_live = await resolve_principal(db, {"sub": live}) + assert p_exp.memberships == {} + assert p_live.memberships == {"grp_G": "guest"} + + for p in (p_exp, p_live): + assert not rbac_allows(p, "session.create", "grp_G") + assert not rbac_allows(p, "group.read", "grp_G") + assert not rbac_allows(p, "volume.create", "grp_G") + + +async def test_suspended_or_deleted_user_with_valid_token_is_refused(db): + rbac._PRINCIPAL_CACHE.clear() + suspended = await _seed_user(db, status="suspended") + deleted = await _seed_user(db, deleted=True) + with pytest.raises(Forbidden): + await resolve_principal(db, {"sub": suspended}) + with pytest.raises(Forbidden): + await resolve_principal(db, {"sub": deleted}) + with pytest.raises(Forbidden): + await resolve_principal(db, {"sub": "usr_does_not_exist"}) + + +async def test_suspension_lockout_bounded_by_principal_cache(db, monkeypatch): + """The 30 s principal cache is the documented window between suspension and lock-out; make the + bound explicit so nobody grows it without noticing.""" + assert rbac._PRINCIPAL_TTL_SEC <= 30.0 + rbac._PRINCIPAL_CACHE.clear() + uid = await _seed_user(db) + await resolve_principal(db, {"sub": uid}) + (await db.get(User, uid)).status = "suspended" + await db.flush() + + assert (await resolve_principal(db, {"sub": uid})).user_id == uid + + monkeypatch.setattr(rbac, "_PRINCIPAL_TTL_SEC", 0.0) + with pytest.raises(Forbidden): + await resolve_principal(db, {"sub": uid}) + + +# ── the must_change_password fence ── + + +async def test_password_change_fence_blocks_everything_else(): + from types import SimpleNamespace + + from app.api.deps import _PWCHANGE_ALLOWED, get_current_principal + from app.core.errors import PasswordChangeRequired + + assert set(_PWCHANGE_ALLOWED) == {"/auth/change-password", "/auth/me"} + token = _user_token(must_change_password=True) + for path in ("/api/v1/sessions", "/api/v1/notifications", "/api/v1/users/resolve", + "/api/v1/credits/wallets/me", "/api/v1/auth/me/../sessions"): + req = SimpleNamespace(url=SimpleNamespace(path=path), state=SimpleNamespace()) + with pytest.raises(PasswordChangeRequired): + await get_current_principal(req, authorization=f"Bearer {token}", access_token=None, db=None) + + req = SimpleNamespace(url=SimpleNamespace(path="/api/v1/sessions"), state=SimpleNamespace()) + with pytest.raises(Unauthenticated): + await get_current_principal(req, authorization=None, access_token=None, db=None) + + +# ── scoped rank checks ── + + +async def test_scoped_rank_checks_are_per_group(): + ga = Principal(user_id="u", memberships={"grp_A": "group_admin"}) + assert rbac_allows(ga, "membership.create", "grp_A") + assert not rbac_allows(ga, "membership.create", "grp_B") + assert not rbac_allows(ga, "org.read") + assert not rbac_allows(ga, "cluster.read") + member = Principal(user_id="u", memberships={"grp_A": "member"}) + for action in ("user.read", "audit.read", "queue.read", "budget.read", "membership.read", + "session.monitor", "session.force_terminate", "volume.create"): + assert not rbac_allows(member, action), action + assert not rbac_allows(member, action, "grp_A"), action + nobody = Principal(user_id="u") + assert not rbac_allows(nobody, "session.create") + assert not rbac_allows(nobody, "no.such.action") diff --git a/backend/tests/test_beta_state_callbacks.py b/backend/tests/test_beta_state_callbacks.py new file mode 100644 index 0000000..d6caf81 --- /dev/null +++ b/backend/tests/test_beta_state_callbacks.py @@ -0,0 +1,339 @@ +"""Beta: abnormal operator callbacks through the status path. + +Duplicate, out-of-order, late and cross-cluster reports must never move money or GPU capacity a +second time, and must never bring a finished session back. Everything here drives StatusSync (and +the status router for the token guard) exactly as the operator would. +""" +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from decimal import Decimal + +import pytest +from sqlalchemy import func, select + +from app.api.schemas.internal import OperatorStatusEvent +from app.cluster.status_sync import StatusSync +from app.core import ids +from app.core.errors import Forbidden +from app.db.models import ( + Allocation, + CreditTransaction, + CreditWallet, + GpuDevice, + GpuNode, + Notification, + Session, + SessionEvent, +) +from app.internal import status_router +from tests.fkseed import seed + +pytestmark = pytest.mark.asyncio + +CLUSTER = "clu_a" +GPU = "GPU-aaaa-0001" + + +async def _seed(db, *, status: str = "preparing", wallet: bool = False, **kw): + """One ready card and one fractional session on it (1000 MB / 10 cores).""" + node = GpuNode(id=ids.new("node"), cluster_id=CLUSTER, hostname="gpu-a", status="ready") + dev = GpuDevice(id=ids.new("device"), node_id=node.id, cluster_id=CLUSTER, model="X", + gpu_uuid=GPU, total_mem_mb=16000, total_cores=100) + fields = dict( + id=ids.new("session"), owner_user_id=ids.new("user"), cluster_id=CLUSTER, + offering_id="off_t", image_id="img_t", resource_class="gpu", mode="fractional", + status=status, gpu_mem_mb=1000, gpu_cores=10, device_total_mem_mb=16000, + credit_per_hour_snapshot=Decimal("0"), + ) + objs = [node, dev] + if wallet: + w = CreditWallet(id=ids.new("wallet"), owner_type="user", owner_id=fields["owner_user_id"], + balance=Decimal("100"), reserved=Decimal("0")) + objs.append(w) + fields["billing_wallet_id"] = w.id + fields["credit_per_hour_snapshot"] = Decimal("6") + fields.update(kw) + sess = Session(**fields) + objs.append(sess) + await seed(db, objs) + return sess, dev + + +def _ev(phase: str, **kw) -> OperatorStatusEvent: + base = dict(phase=phase, ts=datetime.now(UTC), bound_gpu_uuid=GPU, node_name="gpu-a", + pod_ref="gshare-sessions/ses-x") + base.update(kw) + return OperatorStatusEvent(**base) + + +async def _live_allocs(db, sid: str) -> int: + return await db.scalar( + select(func.count(Allocation.id)).where(Allocation.session_id == sid, Allocation.ended_at.is_(None)) + ) + + +async def _events(db, sid: str, kind: str) -> int: + return await db.scalar( + select(func.count(SessionEvent.id)).where(SessionEvent.session_id == sid, SessionEvent.kind == kind) + ) + + +async def _notifs(db, uid: str, typ: str) -> int: + return await db.scalar( + select(func.count(Notification.id)).where(Notification.user_id == uid, Notification.type == typ) + ) + + +async def _settles(db, sid: str) -> int: + return await db.scalar( + select(func.count(CreditTransaction.id)).where(CreditTransaction.idempotency_key == f"settle:{sid}") + ) + + +# ── duplicates ── + + +async def test_duplicate_running_binds_the_card_once(db): + sess, dev = await _seed(db) + sync = StatusSync(db) + await sync.on_status(sess.id, _ev("running")) + await sync.on_status(sess.id, _ev("running")) + db.expunge_all() + row = await db.get(Session, sess.id) + card = await db.get(GpuDevice, dev.id) + assert row.status == "running" and row.bound_gpu_uuid == GPU and row.node_hostname == "gpu-a" + assert await _live_allocs(db, sess.id) == 1 + assert (card.used_mem_mb, card.used_cores) == (1000, 10) + assert await _events(db, sess.id, "running") == 1 + assert await _notifs(db, row.owner_user_id, "session_running") == 1 + + +async def test_duplicate_terminated_settles_once(db): + sess, dev = await _seed(db, wallet=True) + sync = StatusSync(db) + await sync.on_status(sess.id, _ev("running")) + await sync.on_status(sess.id, _ev("terminated", ts=datetime.now(UTC) + timedelta(minutes=2))) + await sync.on_status(sess.id, _ev("terminated", ts=datetime.now(UTC) + timedelta(minutes=3))) + db.expunge_all() + row = await db.get(Session, sess.id) + card = await db.get(GpuDevice, dev.id) + wallet = await db.get(CreditWallet, row.billing_wallet_id) + assert row.status == "terminated" + assert await _live_allocs(db, sess.id) == 0 + assert (card.used_mem_mb, card.used_cores) == (0, 0) + assert await _settles(db, sess.id) == 1 + assert wallet.reserved == Decimal("0.00") + assert await _events(db, sess.id, "terminated") == 1 + assert await _notifs(db, row.owner_user_id, "session_terminated") == 1 + + +async def test_error_after_terminated_is_a_no_op(db): + sess, _dev = await _seed(db, wallet=True) + sync = StatusSync(db) + await sync.on_status(sess.id, _ev("running")) + await sync.on_status(sess.id, _ev("terminated")) + db.expunge_all() + ended = (await db.get(Session, sess.id)).terminated_at + await db.commit() + await sync.on_status(sess.id, _ev("error", message="Evicted: node lost")) + db.expunge_all() + row = await db.get(Session, sess.id) + assert row.status == "terminated" and row.terminated_at == ended + assert row.status_reason is None # a repeat cannot rewrite a settled row's cause + assert await _settles(db, sess.id) == 1 + assert await _events(db, sess.id, "error") == 0 + assert await _notifs(db, row.owner_user_id, "session_error") == 0 + + +# ── late / out-of-order ── + + +@pytest.mark.parametrize("finished", ["terminated", "error", "terminating"]) +async def test_late_running_report_never_rebinds_a_finished_session(db, finished): + """A `running` that lands after the session ended (a retried callback, an operator restart + observing a pod the finalizer has not yet removed) must not reserve the card again: the + session will never release it, so the capacity is gone until someone edits the ledger.""" + sess, dev = await _seed(db, status=finished, terminated_at=datetime.now(UTC)) + await StatusSync(db).on_status(sess.id, _ev("running")) + db.expunge_all() + row = await db.get(Session, sess.id) + card = await db.get(GpuDevice, dev.id) + assert row.status == finished + assert await _live_allocs(db, sess.id) == 0 + assert (card.used_mem_mb, card.used_cores) == (0, 0) + assert await _events(db, sess.id, "running") == 0 + + +@pytest.mark.parametrize("phase", ["terminated", "error"]) +async def test_terminal_report_from_an_older_generation_after_a_resume_is_ignored(db, phase): + """The operator reconciled the stop's generation after the resume committed: its report is + newer than the run start, so only the generation tells it apart.""" + from app.core.redis import get_redis + + sess, dev = await _seed(db, status="running", started_at=datetime.now(UTC) - timedelta(seconds=5)) + async with db.begin(): + db.add(Allocation(id=ids.new("allocation"), session_id=sess.id, device_id=dev.id, + gpu_uuid=GPU, gpu_mem_mb=1000, gpu_cores=10, status="bound")) + dev.used_mem_mb, dev.used_cores = 1000, 10 + await get_redis().set(f"resume-gen:{sess.id}", "9") + await StatusSync(db).on_status(sess.id, _ev(phase, generation=8, message="max-runtime-exceeded")) + db.expunge_all() + row = await db.get(Session, sess.id) + card = await db.get(GpuDevice, dev.id) + assert row.status == "running" and row.status_reason is None + assert await _live_allocs(db, sess.id) == 1 + assert (card.used_mem_mb, card.used_cores) == (1000, 10) + + +async def test_paused_with_a_reason_from_an_older_generation_is_still_ignored(db): + from app.core.redis import get_redis + + sess, _dev = await _seed(db, status="running", started_at=datetime.now(UTC) - timedelta(minutes=30)) + await get_redis().set(f"resume-gen:{sess.id}", "9") + await StatusSync(db).on_status(sess.id, _ev("paused", generation=8, message="idle-reaped")) + db.expunge_all() + assert (await db.get(Session, sess.id)).status == "running" + + +async def test_preparing_for_a_running_session_keeps_it_running(db): + """A pod being replaced (DeletionTimestamp set) is reported Preparing, not terminal.""" + sess, _dev = await _seed(db, status="running", started_at=datetime.now(UTC)) + await StatusSync(db).on_status(sess.id, _ev("preparing", pod_ref="gshare-sessions/ses-y")) + db.expunge_all() + row = await db.get(Session, sess.id) + assert row.status == "running" and row.pod_ref == "gshare-sessions/ses-y" + + +async def test_heartbeat_never_resurrects_a_finished_session(db): + sess, _dev = await _seed(db, status="terminated", terminated_at=datetime.now(UTC)) + await StatusSync(db).on_status(sess.id, _ev("heartbeat", restart_count=0, container_state="Running")) + db.expunge_all() + row = await db.get(Session, sess.id) + assert row.status == "terminated" and await _live_allocs(db, sess.id) == 0 + + +async def test_terminated_from_paused_skips_the_final_consume(db, monkeypatch): + """A paused session was trued up at stop(); a final consume from started_at would bill the + whole paused gap.""" + sess, _dev = await _seed(db, status="paused", wallet=True, + started_at=datetime.now(UTC) - timedelta(hours=3)) + sync = StatusSync(db) + seen: list[bool] = [] + + async def _settle(session, key, *, final_consume=True): + seen.append(final_consume) + + monkeypatch.setattr(sync.credit, "settle", _settle) + await sync.on_status(sess.id, _ev("terminated")) + assert seen == [False] + db.expunge_all() + assert (await db.get(Session, sess.id)).status == "terminated" + + +async def test_terminated_for_a_queued_session_refunds_the_hold(db): + """A pending (queued) session has a hold and no card; the operator has nothing to tear down, + but a CR could still be deleted by hand. The hold must come back.""" + from app.domain.credit_engine import CreditEngine + + sess, _dev = await _seed(db, status="pending", wallet=True) + await CreditEngine(db).hold(sess.billing_wallet_id, Decimal("10"), key=f"hold:{sess.id}") + await db.commit() + await StatusSync(db).on_status(sess.id, _ev("terminated", bound_gpu_uuid=None, node_name=None)) + db.expunge_all() + row = await db.get(Session, sess.id) + wallet = await db.get(CreditWallet, row.billing_wallet_id) + assert row.status == "terminated" + assert wallet.reserved == Decimal("0.00") and wallet.balance == Decimal("100.00") + + +# ── addressing and the cluster guard ── + + +async def test_callback_for_an_unknown_session_is_accepted_and_changes_nothing(db): + before = await db.scalar(select(func.count(Session.id))) + await db.commit() # the router's guard opens its own transaction + out = await status_router.report_status( + "ses-01doesnotexist", _ev("running"), {"sub": "operator:clu_zzz"}, db) + assert out == {"accepted": True} + assert await db.scalar(select(func.count(Session.id))) == before + assert await db.scalar(select(func.count(Allocation.id))) == 0 + + +@pytest.mark.parametrize("phase", ["running", "heartbeat", "paused", "error"]) +async def test_every_phase_is_refused_from_another_clusters_token(db, phase): + sess, dev = await _seed(db, status="running", started_at=datetime.now(UTC) - timedelta(minutes=5)) + cr_name = sess.id.lower().replace("_", "-") + with pytest.raises(Forbidden): + await status_router.report_status(cr_name, _ev(phase, restart_count=1, message="idle-reaped"), + {"sub": "operator:clu_b"}, db) + db.expunge_all() + row = await db.get(Session, sess.id) + assert row.status == "running" and row.last_reported_at is None + assert await _live_allocs(db, sess.id) == 0 + + +async def test_unscoped_token_still_passes_the_guard(db): + """A token without an operator: subject (image builder, older deployments) is not cluster + scoped and is let through — documented, so it stays a conscious choice.""" + sess, _dev = await _seed(db, status="pending") + await status_router.report_status(sess.id, _ev("preparing"), {"sub": "image-builder"}, db) + db.expunge_all() + assert (await db.get(Session, sess.id)).status == "preparing" + + +# ── GS-C09: the stale test must not hang on two different wall clocks ── + + +async def test_a_current_generation_pause_survives_an_operator_clock_that_lags(db): + """The operator's node clock is 2 minutes behind the API's. Its Paused report describes the + generation the resume produced, so it is about THIS run and must be applied — comparing the + two clocks made the control plane drop it as an echo and leave the pod's card reserved.""" + from app.core.redis import get_redis + + sess, dev = await _seed(db, status="running", started_at=datetime.now(UTC)) + async with db.begin(): + db.add(Allocation(id=ids.new("allocation"), session_id=sess.id, device_id=dev.id, + gpu_uuid=GPU, gpu_mem_mb=1000, gpu_cores=10, status="bound")) + dev.used_mem_mb, dev.used_cores = 1000, 10 + await get_redis().set(f"resume-gen:{sess.id}", "9") + await StatusSync(db).on_status( + sess.id, _ev("paused", generation=9, message="idle-reaped", + ts=datetime.now(UTC) - timedelta(minutes=2))) + db.expunge_all() + row = await db.get(Session, sess.id) + card = await db.get(GpuDevice, dev.id) + assert row.status == "paused" and row.status_reason == "idle" + assert await _live_allocs(db, sess.id) == 0 + assert (card.used_mem_mb, card.used_cores) == (0, 0) + + +async def test_a_newer_generation_terminated_survives_an_operator_clock_that_lags(db): + from app.core.redis import get_redis + + sess, _dev = await _seed(db, status="running", started_at=datetime.now(UTC)) + await get_redis().set(f"resume-gen:{sess.id}", "9") + await StatusSync(db).on_status( + sess.id, _ev("terminated", generation=10, ts=datetime.now(UTC) - timedelta(minutes=2))) + db.expunge_all() + assert (await db.get(Session, sess.id)).status == "terminated" + + +async def test_without_a_generation_the_run_start_still_rejects_the_echo(db): + """Older operators report no generation: the run-start timestamp stays the only test, so the + echo of a pause a resume overtook is still discarded.""" + sess, _dev = await _seed(db, status="running", started_at=datetime.now(UTC)) + await StatusSync(db).on_status( + sess.id, _ev("paused", message="idle-reaped", ts=datetime.now(UTC) - timedelta(minutes=2))) + db.expunge_all() + assert (await db.get(Session, sess.id)).status == "running" + + +async def test_a_generation_without_a_resume_marker_still_falls_back_to_the_run_start(db): + """No marker means no resume this session can be echoing (or a lost one); with nothing to + compare the generation against, the timestamp remains the guard.""" + sess, _dev = await _seed(db, status="running", started_at=datetime.now(UTC)) + await StatusSync(db).on_status( + sess.id, _ev("terminated", generation=4, ts=datetime.now(UTC) - timedelta(minutes=2))) + db.expunge_all() + assert (await db.get(Session, sess.id)).status == "running" diff --git a/backend/tests/test_beta_state_stop_handoff.py b/backend/tests/test_beta_state_stop_handoff.py new file mode 100644 index 0000000..5c4456a --- /dev/null +++ b/backend/tests/test_beta_state_stop_handoff.py @@ -0,0 +1,79 @@ +"""Beta: a pause must not release the GPU reservation while the pod is still running. + +stop() used to commit `paused` and release the Allocation BEFORE asking the operator to tear the +pod down. When that handoff failed (cluster unreachable, CR gone) the row said paused, the ledger +said the card was free, and the pod kept computing on it for free — and the next admission could +be placed onto the same card on top of it. +""" +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from decimal import Decimal + +import pytest +from sqlalchemy import select + +from app.core import ids +from app.db.models import Allocation, GpuDevice, GpuNode, Session +from app.domain.session_service import SessionService +from tests.fkseed import seed + +pytestmark = pytest.mark.asyncio + + +async def _running(db): + node = GpuNode(id=ids.new("node"), cluster_id="clu_a", hostname="gpu-a", status="ready") + dev = GpuDevice(id=ids.new("device"), node_id=node.id, cluster_id="clu_a", model="X", + gpu_uuid="GPU-1", total_mem_mb=16000, total_cores=100, + used_mem_mb=1000, used_cores=10) + sess = Session(id=ids.new("session"), owner_user_id=ids.new("user"), cluster_id="clu_a", + offering_id="off_t", image_id="img_t", resource_class="gpu", mode="fractional", + status="running", gpu_mem_mb=1000, gpu_cores=10, + credit_per_hour_snapshot=Decimal("0"), + started_at=datetime.now(UTC) - timedelta(minutes=10)) + alloc = Allocation(id=ids.new("allocation"), session_id=sess.id, device_id=dev.id, + gpu_uuid="GPU-1", gpu_mem_mb=1000, gpu_cores=10, status="bound") + await seed(db, [node, dev, sess, alloc]) + return sess, dev + + +async def test_failed_pause_handoff_keeps_the_session_running_and_the_card_reserved(db): + sess, dev = await _running(db) + svc = SessionService(db) + + async def _boom(sess_, paused, **kw): + raise RuntimeError("cluster unreachable") + + svc.handoff.set_paused = _boom + with pytest.raises(RuntimeError): + await svc.stop(sess.id) + await db.rollback() + db.expunge_all() + row = await db.get(Session, sess.id) + card = await db.get(GpuDevice, dev.id) + live = await db.scalar(select(Allocation).where(Allocation.session_id == sess.id, Allocation.ended_at.is_(None))) + assert row.status == "running" + assert live is not None + assert (card.used_mem_mb, card.used_cores) == (1000, 10) + + +async def test_successful_pause_releases_after_the_handoff(db): + sess, dev = await _running(db) + svc = SessionService(db) + order: list[str] = [] + + async def _ok(sess_, paused, **kw): + # The ledger must still hold the card when the operator is told to pause. + live = await db.scalar(select(Allocation).where(Allocation.session_id == sess_.id, Allocation.ended_at.is_(None))) + order.append("handoff:" + ("held" if live is not None else "released")) + return 3 + + svc.handoff.set_paused = _ok + await svc.stop(sess.id) + db.expunge_all() + row = await db.get(Session, sess.id) + card = await db.get(GpuDevice, dev.id) + live = await db.scalar(select(Allocation).where(Allocation.session_id == sess.id, Allocation.ended_at.is_(None))) + assert order == ["handoff:held"] + assert row.status == "paused" and row.status_reason == "user_stopped" + assert live is None and (card.used_mem_mb, card.used_cores) == (0, 0) diff --git a/backend/tests/test_budget_delete.py b/backend/tests/test_budget_delete.py index 6cd1ab7..c7763f9 100644 --- a/backend/tests/test_budget_delete.py +++ b/backend/tests/test_budget_delete.py @@ -10,6 +10,7 @@ from app.api.schemas.budget import BudgetCreate from app.auth.rbac import Principal from app.db.models import Budget, BudgetAlert, Project +from tests.fkseed import seed def _root() -> Principal: @@ -18,8 +19,7 @@ def _root() -> Principal: @pytest.mark.asyncio async def test_delete_budget_removes_alert_children(db): - db.add(Project(id="g1", org_id="o1", name="g")) - await db.commit() + await seed(db, [Project(id="g1", org_id="o1", name="g")]) out = await create_budget( BudgetCreate(scope="group", scope_id="g1", period_start=datetime(2026, 8, 1, tzinfo=UTC), diff --git a/backend/tests/test_budget_rollup.py b/backend/tests/test_budget_rollup.py index 446411b..7c459f6 100644 --- a/backend/tests/test_budget_rollup.py +++ b/backend/tests/test_budget_rollup.py @@ -17,6 +17,7 @@ ) from app.db.models import Session as SessionRow from app.workers.budget_rollup import _period_window, _scope_group_ids, _spent_in_period +from tests.fkseed import seed async def _seed(db): @@ -49,8 +50,7 @@ async def _seed(db): amount=Decimal("-50"), balance_after=Decimal("42"), ref=outside.id, idempotency_key="c2"), ] - async with db.begin(): - db.add_all([org, grp, other, wallet, sess, outside, *txns]) + await seed(db, [org, grp, other, wallet, sess, outside, *txns]) return org, grp @@ -60,8 +60,7 @@ async def test_spend_rolls_up_by_session_scope(db): budget = Budget(id=ids.new("budget"), scope="group", scope_id=grp.id, period_start=datetime(2020, 1, 1, tzinfo=UTC), period="monthly", limit_credit=Decimal("100"), spent_credit=Decimal("0"), action="alert") - async with db.begin(): - db.add(budget) + await seed(db, [budget]) start, end = _period_window(budget, "Asia/Seoul") group_ids = await _scope_group_ids(db, budget) @@ -76,8 +75,7 @@ async def test_org_budget_covers_all_its_groups(db): budget = Budget(id=ids.new("budget"), scope="org", scope_id=org.id, period_start=datetime(2020, 1, 1, tzinfo=UTC), period="monthly", limit_credit=Decimal("100"), spent_credit=Decimal("0"), action="alert") - async with db.begin(): - db.add(budget) + await seed(db, [budget]) group_ids = await _scope_group_ids(db, budget) assert group_ids == [grp.id] diff --git a/backend/tests/test_credit_engine.py b/backend/tests/test_credit_engine.py index 5bd8a6e..725b2bd 100644 --- a/backend/tests/test_credit_engine.py +++ b/backend/tests/test_credit_engine.py @@ -17,6 +17,7 @@ from app.core.errors import InsufficientCredit from app.db.models import CreditTransaction, CreditWallet, Session from app.domain.credit_engine import CreditEngine +from tests.fkseed import seed async def _make_wallet(db, balance: str, reserved: str = "0") -> CreditWallet: @@ -27,8 +28,7 @@ async def _make_wallet(db, balance: str, reserved: str = "0") -> CreditWallet: balance=Decimal(balance), reserved=Decimal(reserved), ) - async with db.begin(): - db.add(wallet) + await seed(db, [wallet]) return wallet @@ -143,8 +143,7 @@ async def test_consume_idempotent(db): credit_per_hour_snapshot=Decimal("60"), started_at=started, ) - async with db.begin(): - db.add(sess) + await seed(db, [sess]) engine = CreditEngine(db) now = started + timedelta(hours=1) @@ -196,8 +195,7 @@ async def test_consume_exhaustion_clamps_to_zero_and_arms_grace(db): billing_wallet_id=wallet.id, status="running", credit_per_hour_snapshot=Decimal("5000"), started_at=started, ) - async with db.begin(): - db.add(sess) + await seed(db, [sess]) await CreditEngine(db).consume(sess, minute_bucket=99, now=started + timedelta(hours=1)) @@ -227,8 +225,7 @@ async def test_resume_requires_solvent_wallet(db): billing_wallet_id=wallet.id, status="paused", credit_per_hour_snapshot=Decimal("200"), started_at=datetime.now(UTC), ) - async with db.begin(): - db.add(sess) + await seed(db, [sess]) with pytest.raises(InsufficientCredit): await SessionService(db).start(sess.id) @@ -258,8 +255,7 @@ async def test_settle_paused_session_does_not_bill_the_paused_gap(db): billing_wallet_id=wallet.id, status="paused", credit_per_hour_snapshot=Decimal("300"), started_at=started, ) - async with db.begin(): - db.add(sess) + await seed(db, [sess]) # final_consume=False (terminate-from-paused): nothing further is billed. await CreditEngine(db).settle(sess, key=f"settle:{sess.id}", final_consume=False) @@ -279,8 +275,7 @@ async def test_settle_final_consume_clamps_at_balance(db): billing_wallet_id=wallet.id, status="running", credit_per_hour_snapshot=Decimal("300"), started_at=started, ) - async with db.begin(): - db.add(sess) + await seed(db, [sess]) await CreditEngine(db).settle(sess, key=f"settle:{sess.id}", final_consume=True) after = await _reload(db, wallet.id) diff --git a/backend/tests/test_credit_resume_release.py b/backend/tests/test_credit_resume_release.py index e60ab03..c8db4f0 100644 --- a/backend/tests/test_credit_resume_release.py +++ b/backend/tests/test_credit_resume_release.py @@ -18,6 +18,7 @@ from app.core import ids from app.db.models import CreditTransaction, CreditWallet from app.domain.credit_engine import CreditEngine +from tests.fkseed import seed pytestmark = pytest.mark.asyncio @@ -37,10 +38,11 @@ async def test_sum_consumed_interval_scoping(db): t0 = datetime(2026, 1, 1, 0, 0, tzinfo=UTC) # interval 1 charge t2 = datetime(2026, 1, 1, 2, 0, tzinfo=UTC) # interval 2 charge (after resume) resume = datetime(2026, 1, 1, 1, 0, tzinfo=UTC) # started_at re-based here - async with db.begin(): - db.add(wallet) - db.add(_consume_row(wallet.id, "sesX", "-100", t0, "c1")) - db.add(_consume_row(wallet.id, "sesX", "-60", t2, "c2")) + await seed(db, [ + wallet, + _consume_row(wallet.id, "sesX", "-100", t0, "c1"), + _consume_row(wallet.id, "sesX", "-60", t2, "c2"), + ]) engine = CreditEngine(db) sess = SimpleNamespace(id="sesX", billing_wallet_id=wallet.id, started_at=resume) @@ -53,8 +55,7 @@ async def test_settle_releases_only_own_hold(db): """Two sessions hold on one wallet; settling A frees only A's slice, leaving B's hold intact.""" wallet = CreditWallet(id=ids.new("wallet"), owner_type="user", owner_id=ids.new("user"), balance=Decimal("1000"), reserved=Decimal("0")) - async with db.begin(): - db.add(wallet) + await seed(db, [wallet]) engine = CreditEngine(db) await engine.hold(wallet.id, Decimal("100"), key="hold:sesA") diff --git a/backend/tests/test_credits_bulk.py b/backend/tests/test_credits_bulk.py index 2f84d3b..17281cd 100644 --- a/backend/tests/test_credits_bulk.py +++ b/backend/tests/test_credits_bulk.py @@ -12,6 +12,7 @@ from app.core import ids from app.core.errors import InsufficientCredit from app.db.models import CreditTransaction, CreditWallet, Membership, Organization, Project, User +from tests.fkseed import seed async def _cohort(db, n=3, group_balance="1000", group_grant="0"): @@ -32,8 +33,7 @@ async def _cohort(db, n=3, group_balance="1000", group_grant="0"): balance=Decimal("0"), reserved=Decimal("0"), )) members.append(Membership(id=ids.new("membership"), user_id=uid, group_id=group.id, role="member")) - async with db.begin(): - db.add_all([org, group, gw, *user_rows, *wallets, *members]) + await seed(db, [org, group, gw, *user_rows, *wallets, *members]) return group, gw, users, wallets @@ -115,8 +115,7 @@ async def test_membership_grant_credit_actually_moves_money(db): user = User(id=ids.new("user"), email="s@u.ac.kr", name="S") uw = CreditWallet(id=ids.new("wallet"), owner_type="user", owner_id=user.id, balance=Decimal("0"), reserved=Decimal("0")) - async with db.begin(): - db.add_all([org, group, gw, user, uw]) + await seed(db, [org, group, gw, user, uw]) await add_membership( group.id, MembershipCreate(user_id=user.id, role="member", grant_credit="40"), diff --git a/backend/tests/test_credits_topup_visibility.py b/backend/tests/test_credits_topup_visibility.py index bffeaa0..768f574 100644 --- a/backend/tests/test_credits_topup_visibility.py +++ b/backend/tests/test_credits_topup_visibility.py @@ -11,6 +11,7 @@ from app.auth.rbac import Principal from app.core import ids from app.db.models import CreditWallet, TopupRequest +from tests.fkseed import seed @pytest.mark.asyncio @@ -20,8 +21,7 @@ async def test_member_sees_only_their_own_topup_requests(db): w2 = CreditWallet(id=ids.new("wallet"), owner_type="user", owner_id=other, balance=Decimal("0"), reserved=Decimal("0")) r1 = TopupRequest(id=ids.new("topup"), wallet_id=w1.id, requester_id=me, amount=Decimal("10"), status="pending") r2 = TopupRequest(id=ids.new("topup"), wallet_id=w2.id, requester_id=other, amount=Decimal("20"), status="pending") - async with db.begin(): - db.add_all([w1, w2, r1, r2]) + await seed(db, [w1, w2, r1, r2]) mine = await list_topup_requests(page=Pagination(page=1, size=50), status_filter=None, wallet_id=None, principal=Principal(user_id=me), db=db) diff --git a/backend/tests/test_dashboard_scope.py b/backend/tests/test_dashboard_scope.py index 773393a..4dd9919 100644 --- a/backend/tests/test_dashboard_scope.py +++ b/backend/tests/test_dashboard_scope.py @@ -9,6 +9,7 @@ from app.core import ids from app.db.models import Membership, Organization, Project from app.db.models import Session as SessionRow +from tests.fkseed import seed def _session(owner: str) -> SessionRow: @@ -29,18 +30,17 @@ async def world(db): org_a, org_b = ids.new("org"), ids.new("org") g_a1, g_a2, g_b = ids.new("group"), ids.new("group"), ids.new("group") admin_a1, member_a1, member_a2, member_b, org_admin_a = (ids.new("user") for _ in range(5)) - async with db.begin(): - db.add_all([ - Organization(id=org_a, name="A"), Organization(id=org_b, name="B"), - Project(id=g_a1, org_id=org_a, name="a1"), Project(id=g_a2, org_id=org_a, name="a2"), - Project(id=g_b, org_id=org_b, name="b"), - Membership(id=ids.new("membership"), user_id=admin_a1, group_id=g_a1, role="group_admin"), - Membership(id=ids.new("membership"), user_id=member_a1, group_id=g_a1, role="member"), - Membership(id=ids.new("membership"), user_id=member_a2, group_id=g_a2, role="member"), - Membership(id=ids.new("membership"), user_id=member_b, group_id=g_b, role="member"), - _session(admin_a1), _session(member_a1), _session(member_a2), _session(member_b), - _session(org_admin_a), - ]) + await seed(db, [ + Organization(id=org_a, name="A"), Organization(id=org_b, name="B"), + Project(id=g_a1, org_id=org_a, name="a1"), Project(id=g_a2, org_id=org_a, name="a2"), + Project(id=g_b, org_id=org_b, name="b"), + Membership(id=ids.new("membership"), user_id=admin_a1, group_id=g_a1, role="group_admin"), + Membership(id=ids.new("membership"), user_id=member_a1, group_id=g_a1, role="member"), + Membership(id=ids.new("membership"), user_id=member_a2, group_id=g_a2, role="member"), + Membership(id=ids.new("membership"), user_id=member_b, group_id=g_b, role="member"), + _session(admin_a1), _session(member_a1), _session(member_a2), _session(member_b), + _session(org_admin_a), + ]) return {"g_a1": g_a1, "org_a": org_a, "admin_a1": admin_a1, "org_admin_a": org_admin_a, "member_a1": member_a1} diff --git a/backend/tests/test_dashboard_storage.py b/backend/tests/test_dashboard_storage.py index 465e12c..cf7e236 100644 --- a/backend/tests/test_dashboard_storage.py +++ b/backend/tests/test_dashboard_storage.py @@ -10,6 +10,7 @@ from app.auth.rbac import Principal from app.core import ids from app.db.models import ResourcePolicy, StorageVolume +from tests.fkseed import seed OWNER = "usr_stor01" P = Principal(user_id=OWNER, global_role="member", global_roles={"member"}) @@ -32,9 +33,8 @@ async def test_storage_counts_only_the_callers_live_volumes(db): await db.commit() from datetime import UTC, datetime gone.deleted_at = datetime.now(UTC) - db.add(ResourcePolicy(id=ids.new("policy"), scope="global", scope_id="*", - limits={"volume_gb": 200})) - await db.commit() + await seed(db, [ResourcePolicy(id=ids.new("policy"), scope="global", scope_id="*", + limits={"volume_gb": 200})]) out = await dashboard_summary(scope="mine", principal=P, db=db) assert out["storage"] == { diff --git a/backend/tests/test_device_health.py b/backend/tests/test_device_health.py index a0901e1..3495998 100644 --- a/backend/tests/test_device_health.py +++ b/backend/tests/test_device_health.py @@ -6,6 +6,7 @@ from app.core import ids from app.db.models import Allocation, GpuDevice, GpuNode, Session from app.domain import device_health +from tests.fkseed import seed @pytest.mark.asyncio @@ -20,13 +21,12 @@ def sess(status="running"): offering_id="off_t", image_id="img_t", resource_class="gpu", mode="fractional", status=status, gpu_mem_mb=100, gpu_cores=10) on_dev, on_other, ended_before = sess(), sess(), sess("terminated") - async with db.begin(): - db.add_all([node, dev, other, on_dev, on_other, ended_before]) - db.add_all([ - Allocation(id=ids.new("allocation"), session_id=on_dev.id, device_id=dev.id, status="bound"), - Allocation(id=ids.new("allocation"), session_id=on_other.id, device_id=other.id, status="bound"), - Allocation(id=ids.new("allocation"), session_id=ended_before.id, device_id=dev.id, status="bound"), - ]) + await seed(db, [ + node, dev, other, on_dev, on_other, ended_before, + Allocation(id=ids.new("allocation"), session_id=on_dev.id, device_id=dev.id, status="bound"), + Allocation(id=ids.new("allocation"), session_id=on_other.id, device_id=other.id, status="bound"), + Allocation(id=ids.new("allocation"), session_id=ended_before.id, device_id=dev.id, status="bound"), + ]) calls: list[tuple[str, str]] = [] class _Svc: diff --git a/backend/tests/test_device_health_sticky.py b/backend/tests/test_device_health_sticky.py index 2053103..e0ce89d 100644 --- a/backend/tests/test_device_health_sticky.py +++ b/backend/tests/test_device_health_sticky.py @@ -9,6 +9,7 @@ from app.cluster.inventory_sync import InventorySync from app.core import ids from app.db.models import Cluster, GpuDevice, GpuNode +from tests.fkseed import seed def _report(cluster_id: str, status: str) -> OperatorGpuDeviceUpsert: @@ -26,8 +27,7 @@ async def test_inventory_never_resurrects_a_faulted_card(db): node = GpuNode(id=ids.new("node"), hostname="n1", cluster_id=cluster.id, status="ready") dev = GpuDevice(id="GPU-x", node_id=node.id, cluster_id=cluster.id, model="A100", gpu_uuid="GPU-x", total_mem_mb=16000, status="unhealthy", mode="fractional") - async with db.begin(): - db.add_all([cluster, node, dev]) + await seed(db, [cluster, node, dev]) sync = InventorySync(db) # the card is physically fine and says so; the fault was a decision, so it must stand diff --git a/backend/tests/test_drain_parking.py b/backend/tests/test_drain_parking.py index 3b898c6..a9ed130 100644 --- a/backend/tests/test_drain_parking.py +++ b/backend/tests/test_drain_parking.py @@ -8,6 +8,7 @@ from app.core.errors import InsufficientCredit, NoCapacity from app.db.models import QueueEntry, Session from app.domain import queue_ranking, scheduler +from tests.fkseed import seed def _paused(**kw) -> Session: @@ -23,8 +24,7 @@ def _paused(**kw) -> Session: @pytest.mark.asyncio async def test_enqueue_resume_is_idempotent_and_invisible_to_the_pending_head(db): sess = _paused() - async with db.begin(): - db.add(sess) + await seed(db, [sess]) async with db.begin(): await scheduler.enqueue_resume(db, sess.id) await scheduler.enqueue_resume(db, sess.id) @@ -38,8 +38,8 @@ async def test_enqueue_resume_is_idempotent_and_invisible_to_the_pending_head(db @pytest.mark.asyncio async def test_parked_session_resumes_when_start_succeeds(db, monkeypatch): sess = _paused() + await seed(db, [sess]) async with db.begin(): - db.add(sess) await scheduler.enqueue_resume(db, sess.id) started: list[str] = [] @@ -64,8 +64,8 @@ async def start(self, sid): ]) async def test_parked_session_outcomes(db, monkeypatch, exc, outcome, keeps_entry): sess = _paused() + await seed(db, [sess]) async with db.begin(): - db.add(sess) await scheduler.enqueue_resume(db, sess.id) class _Svc: diff --git a/backend/tests/test_excluded_nodes.py b/backend/tests/test_excluded_nodes.py index 61ed8ad..6cc89d7 100644 --- a/backend/tests/test_excluded_nodes.py +++ b/backend/tests/test_excluded_nodes.py @@ -7,6 +7,7 @@ from app.cluster.handoff import Handoff from app.core import ids from app.db.models import GpuNode, Session +from tests.fkseed import seed def _cpu_session() -> Session: @@ -27,11 +28,10 @@ def test_spec_carries_the_exclusion_in_camel_case(): @pytest.mark.asyncio async def test_excluded_nodes_are_the_cordoned_and_offline_ones_of_that_cluster(db): - async with db.begin(): - db.add_all([ - GpuNode(id=ids.new("node"), cluster_id="clu_t", hostname="ok", status="ready"), - GpuNode(id=ids.new("node"), cluster_id="clu_t", hostname="draining", status="cordoned"), - GpuNode(id=ids.new("node"), cluster_id="clu_t", hostname="dead", status="offline"), - GpuNode(id=ids.new("node"), cluster_id="clu_other", hostname="elsewhere", status="cordoned"), - ]) + await seed(db, [ + GpuNode(id=ids.new("node"), cluster_id="clu_t", hostname="ok", status="ready"), + GpuNode(id=ids.new("node"), cluster_id="clu_t", hostname="draining", status="cordoned"), + GpuNode(id=ids.new("node"), cluster_id="clu_t", hostname="dead", status="offline"), + GpuNode(id=ids.new("node"), cluster_id="clu_other", hostname="elsewhere", status="cordoned"), + ]) assert await Handoff(db).excluded_nodes(_cpu_session()) == ["dead", "draining"] diff --git a/backend/tests/test_global_volume.py b/backend/tests/test_global_volume.py index 3c04600..eac6851 100644 --- a/backend/tests/test_global_volume.py +++ b/backend/tests/test_global_volume.py @@ -8,6 +8,7 @@ from app.api.volumes_router import _implicit_group_role, create_volume, list_volumes from app.auth.rbac import Principal from app.core.errors import Forbidden +from tests.fkseed import seed, user_row def _super() -> Principal: @@ -18,6 +19,11 @@ def _member() -> Principal: return Principal(user_id="usr_m", memberships={"grp_x": "member"}) +async def _principals(db) -> None: + """create_volume stamps volume_permission.user_id, so the callers need user rows.""" + await seed(db, [user_row("usr_admin"), user_row("usr_m")]) + + def _body(**kw) -> VolumeCreate: base = dict(scope="global", scope_id="anything", type="dataset", name="imagenet-mini", access_mode="ROX", quota_gb=50) @@ -27,6 +33,7 @@ def _body(**kw) -> VolumeCreate: @pytest.mark.asyncio async def test_super_admin_creates_a_shared_volume_everyone_can_read(db): + await _principals(db) vol = await create_volume(_body(), _super(), db) assert vol.scope == "global" and vol.scope_id == "global" and vol.access_mode == "ROX" # a plain member sees it in their own list, read-only @@ -39,6 +46,7 @@ async def test_super_admin_creates_a_shared_volume_everyone_can_read(db): @pytest.mark.asyncio async def test_rwx_shared_volume_is_writable_by_members(db): + await _principals(db) vol = await create_volume(_body(access_mode="RWX", name="scratch-all"), _super(), db) assert _implicit_group_role(_member(), vol) == "rw" diff --git a/backend/tests/test_gpu_alias.py b/backend/tests/test_gpu_alias.py index c69e567..025caf5 100644 --- a/backend/tests/test_gpu_alias.py +++ b/backend/tests/test_gpu_alias.py @@ -9,6 +9,7 @@ from app.core import ids from app.core.errors import DomainError, Forbidden from app.db.models import Cluster, GpuDevice, GpuNode +from tests.fkseed import seed def _super() -> Principal: @@ -21,9 +22,10 @@ async def _cards(db) -> tuple[GpuDevice, GpuDevice]: mode="fractional", status="ready", total_mem_mb=24564, used_mem_mb=0, total_cores=100, used_cores=0) b = GpuDevice(id=ids.new("device"), cluster_id="clu_t", node_id=node.id, gpu_uuid="GPU-b", model="RTX 4090", mode="fractional", status="ready", total_mem_mb=24564, used_mem_mb=0, total_cores=100, used_cores=0) - async with db.begin(): - db.add_all([Cluster(id="clu_t", name="t", api_server="https://k", runtime="containerd", - kubeconfig_secret_ref="s"), node, a, b]) + await seed(db, [ + Cluster(id="clu_t", name="t", api_server="https://k", runtime="containerd", + kubeconfig_secret_ref="s"), node, a, b, + ]) return a, b diff --git a/backend/tests/test_gpu_lending.py b/backend/tests/test_gpu_lending.py index 5dff063..f0d6e97 100644 --- a/backend/tests/test_gpu_lending.py +++ b/backend/tests/test_gpu_lending.py @@ -21,6 +21,7 @@ from app.db.models import Allocation, GpuDevice, Image, Offering, Session from app.domain.scheduler import SchedulerService from app.domain.session_service import SessionService +from tests.fkseed import cluster_row, seed, user_row CLUSTER = "clu_lend" @@ -48,8 +49,7 @@ async def _setup_yielded_card(db, *, lend_state: str = "yielded"): cluster_mode="single", resource_class="gpu", mode="exclusive", offering_id="off_x", image_id="img_x", preemptible=True, status="pending", ) - async with db.begin(): - db.add_all([dev, owner, owner_alloc, borrower]) + await seed(db, [dev, owner, owner_alloc, borrower]) return dev, owner, borrower @@ -147,9 +147,8 @@ async def test_fractional_multi_borrow_packs_yielded_card(db): dev_id = dev.id async def place(mem, cores): - async with db.begin(): - b = _frac_borrower(mem, cores) - db.add(b) + b = _frac_borrower(mem, cores) + await seed(db, [b]) async with db.begin(): return await sched.reserve_spot_slice(b, _req_frac(mem, cores)) @@ -254,22 +253,22 @@ async def _capture(sess, paused, *, graceful_demote=None): @pytest.mark.asyncio async def test_preemptible_session_priced_at_spot_discount(db): """A preemptible exclusive session snapshots the normal rate multiplied by SPOT_DISCOUNT.""" - cluster_id = ids.new("cluster") + cluster = cluster_row() offering = Offering( id=ids.new("offering"), name="excl", resource_class="gpu", gpu_model="A100", gpu_mem_mb=81920, gpu_cores=100, credit_per_hour=Decimal("400"), ) image = Image(id=ids.new("image"), name="pytorch") - async with db.begin(): - db.add_all([offering, image]) + spot_user, normal_user = user_row(), user_row() + await seed(db, [offering, image, cluster, spot_user, normal_user]) svc = SchedulerService(db) base = dict(offering_id=offering.id, image_id=image.id, resource_class="gpu", - cluster_id=cluster_id, mode="exclusive") + cluster_id=cluster.id, mode="exclusive") spot = await svc._persist_pending( - SessionCreate(**base, preemptible=True), Principal(user_id=ids.new("user"))) + SessionCreate(**base, preemptible=True), Principal(user_id=spot_user.id)) normal = await svc._persist_pending( - SessionCreate(**base, preemptible=False), Principal(user_id=ids.new("user"))) + SessionCreate(**base, preemptible=False), Principal(user_id=normal_user.id)) assert normal.credit_per_hour_snapshot == Decimal("400") assert normal.preemptible is False diff --git a/backend/tests/test_host_headroom.py b/backend/tests/test_host_headroom.py index aeb9989..7cf352b 100644 --- a/backend/tests/test_host_headroom.py +++ b/backend/tests/test_host_headroom.py @@ -13,27 +13,28 @@ from app.core import ids from app.db.models import Allocation, CreditWallet, GpuDevice, GpuNode, Image, Offering, Project from app.domain.scheduler import SchedulerService +from tests.fkseed import seed, user_row MODEL = "NVIDIA RTX PRO 5000 Blackwell" async def _user(db): """A user with their own personal wallet (billing must target the requester's wallet).""" - user_id = ids.new("user") + user = user_row() wallet = CreditWallet( - id=ids.new("wallet"), owner_type="user", owner_id=user_id, + id=ids.new("wallet"), owner_type="user", owner_id=user.id, balance=Decimal("1000"), reserved=Decimal("0"), ) - async with db.begin(): - db.add(wallet) - return user_id, wallet + await seed(db, [user, wallet]) + return user.id, wallet async def _fleet(db, *nodes): """Build a cluster of (mem_gb, cpu) nodes, one fractional 48G card each. Returns the ids.""" org_id = ids.new("org") group = Project(id=ids.new("group"), org_id=org_id, name="p") - user_id = ids.new("user") + user = user_row() + user_id = user.id wallet = CreditWallet( id=ids.new("wallet"), owner_type="user", owner_id=user_id, balance=Decimal("1000"), reserved=Decimal("0"), @@ -45,7 +46,7 @@ async def _fleet(db, *nodes): cpu=4, mem_gb=8, disk_gb=50, ) image = Image(id=ids.new("image"), name="pytorch") - rows = [group, wallet, offering, image] + rows = [user, group, wallet, offering, image] devs = [] for i, (mem, cpu) in enumerate(nodes): node = GpuNode(id=ids.new("node"), cluster_id=cluster_id, hostname=f"n{i}", @@ -56,8 +57,7 @@ async def _fleet(db, *nodes): ) rows += [node, dev] devs.append(dev.id) - async with db.begin(): - db.add_all(rows) + await seed(db, rows) return cluster_id, offering, image, group, wallet, user_id, devs @@ -147,8 +147,7 @@ async def test_cpu_session_needs_a_cpu_node(db, fake_handoff): cpu_off = _Offering(id=ids.new("offering"), name="cpu", resource_class="cpu", credit_per_hour=Decimal("0"), cpu=2, mem_gb=4, disk_gb=10) img = _Image(id=ids.new("image"), name="ubuntu") - async with db.begin(): - db.add_all([cpu_off, img]) + await seed(db, [cpu_off, img]) svc = SchedulerService(db) svc.handoff = fake_handoff req = SessionCreate(offering_id=cpu_off.id, image_id=img.id, resource_class="cpu", diff --git a/backend/tests/test_image_builds.py b/backend/tests/test_image_builds.py index d08ded8..ffbba9d 100644 --- a/backend/tests/test_image_builds.py +++ b/backend/tests/test_image_builds.py @@ -10,6 +10,7 @@ from app.core import ids from app.db.models import Cluster, Image, ImageBuild, Membership, Organization, Project, User from app.internal.imagebuild_status_router import BuildStatusEvent, report_build_status +from tests.fkseed import seed def _p(uid, *, super_admin=False, memberships=None): @@ -27,9 +28,8 @@ async def _seed(db): user = User(id=ids.new("user"), email="b@x.kr", name="B") clu = Cluster(id=ids.new("cluster"), name="c1", role="primary", api_server="https://x", runtime="containerd", status="ready", kubeconfig_secret_ref="sec/kc") - db.add_all([org, grp, user, clu, + await seed(db, [org, grp, user, clu, Membership(id=ids.new("membership"), user_id=user.id, group_id=grp.id, role="member")]) - await db.commit() return grp, user, clu @@ -64,13 +64,13 @@ async def test_member_build_lifecycle(db, no_handoff): # operator: running -> succeeded (Image row minted once, private, owned) await report_build_status(out["id"], BuildStatusEvent(phase="running", log_tail="step 1/2"), - _claims={}, db=db) + claims={}, db=db) await report_build_status( out["id"], BuildStatusEvent(phase="succeeded", image_ref=out["image_ref"], log_tail="done"), - _claims={}, db=db) + claims={}, db=db) await report_build_status( # retried callback must not regress or duplicate out["id"], BuildStatusEvent(phase="succeeded", image_ref=out["image_ref"]), - _claims={}, db=db) + claims={}, db=db) build = await db.get(ImageBuild, out["id"]) assert build.status == "succeeded" and build.started_at and build.finished_at img = await db.get(Image, build.image_id) diff --git a/backend/tests/test_image_import.py b/backend/tests/test_image_import.py index 8cf0625..207846a 100644 --- a/backend/tests/test_image_import.py +++ b/backend/tests/test_image_import.py @@ -15,6 +15,7 @@ from app.core import ids from app.core.errors import DomainError, Forbidden, NotImplementedFeature from app.db.models import Image, User +from tests.fkseed import seed REF = "pytorch/pytorch:2.4.0-cuda12.4-cudnn9-runtime" @@ -37,8 +38,7 @@ def _body(source: str = REF, **kw) -> ImageImport: async def _user(db, email: str) -> str: u = User(id=ids.new("user"), email=email, name=email) - db.add(u) - await db.commit() + await seed(db, [u]) return u.id @@ -56,9 +56,8 @@ async def test_member_import_is_forbidden(db): async def test_member_is_refused_even_for_an_existing_shared_ref(db): """Idempotent hand-back of the shared row was a member convenience; it went with the path.""" uid = await _user(db, "m2@x.kr") - db.add(Image(id=ids.new("image"), name="Shared", registry=REF, kind="container", tags={}, - import_status="ready", public=True, owner_user_id=None)) - await db.commit() + await seed(db, [Image(id=ids.new("image"), name="Shared", registry=REF, kind="container", tags={}, + import_status="ready", public=True, owner_user_id=None)]) with pytest.raises(Forbidden): await import_image(_body(), principal=_p(uid), db=db) diff --git a/backend/tests/test_internal_cluster_binding.py b/backend/tests/test_internal_cluster_binding.py index 696dce4..1cd339b 100644 --- a/backend/tests/test_internal_cluster_binding.py +++ b/backend/tests/test_internal_cluster_binding.py @@ -19,6 +19,7 @@ from app.core.errors import Forbidden from app.db.models import Session as SessionModel from app.internal import inventory_router, status_router +from tests.fkseed import seed def test_subject_parsing(): @@ -39,8 +40,7 @@ async def _session_on(db, cluster_id: str) -> str: sess = SessionModel(id=ids.new("session"), owner_user_id="usr_1", cluster_id=cluster_id, offering_id="off_1", image_id="img_1", resource_class="gpu", mode="fractional", status="running") - async with db.begin(): - db.add(sess) + await seed(db, [sess]) return sess.id @@ -97,8 +97,7 @@ async def test_the_guard_does_not_hold_a_transaction_open(db): sess = SessionModel(id=ids.new("session"), owner_user_id="usr_1", cluster_id="clu_a", offering_id="off_1", image_id="img_1", resource_class="gpu", mode="fractional", status="pending") - async with db.begin(): - db.add(sess) + await seed(db, [sess]) ev = OperatorStatusEvent(phase="preparing", ts="2026-09-09T00:00:00Z") await status_router.report_status(sess.id.lower().replace("_", "-"), ev, {"sub": "operator:clu_a"}, db) diff --git a/backend/tests/test_ledger_grouping.py b/backend/tests/test_ledger_grouping.py index 27a2726..516048e 100644 --- a/backend/tests/test_ledger_grouping.py +++ b/backend/tests/test_ledger_grouping.py @@ -12,6 +12,7 @@ from app.core import ids from app.db.models import CreditTransaction, CreditWallet, Image, Offering from app.db.models import Session as SessionRow +from tests.fkseed import seed @pytest.mark.asyncio @@ -38,8 +39,7 @@ async def test_consume_rows_fold_per_session(db): amount=Decimal("-1.67"), balance_after=Decimal("1000") - Decimal("1.67") * (i + 1), ref=sess.id, idempotency_key=f"c{i}", created_at=base + timedelta(minutes=i + 1), )) - async with db.begin(): - db.add_all([wallet, offering, image, sess, *rows]) + await seed(db, [wallet, offering, image, sess, *rows]) out = await _grouped_transactions(db, wallet.id, Pagination(page=1, size=50)) assert len(out) == 2, [r.type for r in out] # one rollup + the topup @@ -80,8 +80,7 @@ async def test_two_sessions_stay_separate(db): balance_after=Decimal("99"), ref=sess.id, idempotency_key=f"c{n}-{i}", created_at=base + timedelta(minutes=i), )) - async with db.begin(): - db.add_all([wallet, offering, image, *sessions, *txns]) + await seed(db, [wallet, offering, image, *sessions, *txns]) out = await _grouped_transactions(db, wallet.id, Pagination(page=1, size=50)) assert len(out) == 2 @@ -116,8 +115,7 @@ async def test_settle_marker_folds_into_the_refund_row(db): balance_after=Decimal("95"), ref=sess.id, idempotency_key="s", created_at=base + timedelta(minutes=2)), ] - async with db.begin(): - db.add_all([wallet, offering, image, sess, *txns]) + await seed(db, [wallet, offering, image, sess, *txns]) out = await _grouped_transactions(db, wallet.id, Pagination(page=1, size=50)) assert "settle" not in {r.type for r in out} # no standalone zero-amount line @@ -147,8 +145,7 @@ async def test_closed_streams_are_not_live(db): created_at=base + timedelta(minutes=i)) for i in range(2) ] - async with db.begin(): - db.add_all([wallet, offering, image, sess, *rows]) + await seed(db, [wallet, offering, image, sess, *rows]) out = await _grouped_transactions(db, wallet.id, Pagination(page=1, size=50)) assert {r.type: r.live for r in out} == {"consume": False} diff --git a/backend/tests/test_login_audit.py b/backend/tests/test_login_audit.py index 639aa4f..6c88a98 100644 --- a/backend/tests/test_login_audit.py +++ b/backend/tests/test_login_audit.py @@ -8,6 +8,7 @@ from app.core import ids from app.core.errors import Unauthenticated from app.db.models import AuditLog, User +from tests.fkseed import seed class _Req: @@ -24,12 +25,12 @@ async def _rows(db) -> list[AuditLog]: @pytest.mark.asyncio async def test_unknown_account_is_recorded_as_failed(db): with pytest.raises(Unauthenticated): - await auth_login(_LoginRequest(email="nobody@dankook.ac.kr", password="whatever1"), + await auth_login(_LoginRequest(email="nobody@example.com", password="whatever1"), _Req(), db) rows = await _rows(db) assert len(rows) == 1 assert rows[0].result == "failed" - assert rows[0].actor == "nobody@dankook.ac.kr" + assert rows[0].actor == "nobody@example.com" assert rows[0].detail["reason"] == "unknown_account" assert rows[0].detail["ip"] == "10.0.0.9" @@ -37,10 +38,9 @@ async def test_unknown_account_is_recorded_as_failed(db): @pytest.mark.asyncio async def test_wrong_password_and_then_success_are_both_recorded(db): from app.core.passwords import hash_password - user = User(id=ids.new("user"), email="ce-user01@dankook.ac.kr", name="u", + user = User(id=ids.new("user"), email="ce-user01@example.com", name="u", password_hash=hash_password("correct-horse"), status="active") - async with db.begin(): - db.add(user) + await seed(db, [user]) with pytest.raises(Unauthenticated): await auth_login(_LoginRequest(email=user.email, password="wrong-one"), _Req(), db) @@ -55,10 +55,9 @@ async def test_wrong_password_and_then_success_are_both_recorded(db): @pytest.mark.asyncio async def test_a_suspended_account_is_recorded_as_failed(db): from app.core.passwords import hash_password - user = User(id=ids.new("user"), email="gone@dankook.ac.kr", name="g", + user = User(id=ids.new("user"), email="gone@example.com", name="g", password_hash=hash_password("correct-horse"), status="suspended") - async with db.begin(): - db.add(user) + await seed(db, [user]) with pytest.raises(Unauthenticated): await auth_login(_LoginRequest(email=user.email, password="correct-horse"), _Req(), db) rows = await _rows(db) diff --git a/backend/tests/test_mixed_fleet.py b/backend/tests/test_mixed_fleet.py index 4a543e0..ced7a05 100644 --- a/backend/tests/test_mixed_fleet.py +++ b/backend/tests/test_mixed_fleet.py @@ -15,13 +15,15 @@ from app.core import ids from app.db.models import Allocation, CreditWallet, GpuDevice, Image, Offering, Project from app.domain.scheduler import SchedulerService +from tests.fkseed import seed, user_row @pytest.mark.asyncio async def test_reservation_respects_offering_model(db, fake_handoff): org_id = ids.new("org") group = Project(id=ids.new("group"), org_id=org_id, name="p") - user_id = ids.new("user") + user = user_row() + user_id = user.id wallet = CreditWallet( id=ids.new("wallet"), owner_type="user", owner_id=user_id, balance=Decimal("1000"), reserved=Decimal("0"), @@ -44,8 +46,7 @@ async def test_reservation_respects_offering_model(db, fake_handoff): model="NVIDIA RTX PRO 6000 Blackwell", gpu_uuid="GPU-pro6000", total_mem_mb=98304, status="ready", mode="fractional", ) - async with db.begin(): - db.add_all([group, wallet, off_4090, image, dev_4090, dev_pro]) + await seed(db, [user, group, wallet, off_4090, image, dev_4090, dev_pro]) svc = SchedulerService(db) svc.handoff = fake_handoff diff --git a/backend/tests/test_node_decommission.py b/backend/tests/test_node_decommission.py index b7b9da2..4269828 100644 --- a/backend/tests/test_node_decommission.py +++ b/backend/tests/test_node_decommission.py @@ -16,6 +16,7 @@ from app.core.errors import DomainError from app.db.models import Cluster, GpuDevice, GpuNode from app.internal.inventory_router import decommissioning_nodes, node_decommissioned +from tests.fkseed import seed CLU = "clu_t" @@ -27,16 +28,15 @@ def _super() -> Principal: async def _node(db, status: str = "offline") -> GpuNode: node = GpuNode(id=ids.new("node"), cluster_id=CLU, hostname="gpu3", status=status, cpu=32, mem=128, disk=500) - async with db.begin(): - db.add_all([ - Cluster(id=CLU, name="local", api_server="https://k8s", runtime="containerd", - kubeconfig_secret_ref="secret"), - node, - GpuDevice(id=ids.new("device"), cluster_id=CLU, node_id=node.id, - gpu_uuid=ids.new("device"), model="RTX PRO 5000", mode="fractional", - status="ready", total_mem_mb=49152, used_mem_mb=0, - total_cores=100, used_cores=0), - ]) + await seed(db, [ + Cluster(id=CLU, name="local", api_server="https://k8s", runtime="containerd", + kubeconfig_secret_ref="secret"), + node, + GpuDevice(id=ids.new("device"), cluster_id=CLU, node_id=node.id, + gpu_uuid=ids.new("device"), model="RTX PRO 5000", mode="fractional", + status="ready", total_mem_mb=49152, used_mem_mb=0, + total_cores=100, used_cores=0), + ]) return node diff --git a/backend/tests/test_node_delete.py b/backend/tests/test_node_delete.py index b4ecfc6..6a0f82d 100644 --- a/backend/tests/test_node_delete.py +++ b/backend/tests/test_node_delete.py @@ -11,6 +11,7 @@ from app.core.errors import DomainError from app.db.models import Allocation, GpuDevice, GpuNode from app.db.models import Session as SessionRow +from tests.fkseed import seed async def _node_with_card(db, hostname="gpu-x"): @@ -19,8 +20,7 @@ async def _node_with_card(db, hostname="gpu-x"): dev = GpuDevice(id=ids.new("device"), node_id=node.id, cluster_id=node.cluster_id, model="RTX PRO 6000", gpu_uuid=ids.new("dev"), total_mem_mb=97887, total_cores=100) - async with db.begin(): - db.add_all([node, dev]) + await seed(db, [node, dev]) return node, dev @@ -42,10 +42,11 @@ async def test_delete_node_refuses_while_an_allocation_is_live(db): offering_id=ids.new("offering"), image_id=ids.new("image"), resource_class="gpu", mode="fractional", status="running", gpu_mem_mb=4096, gpu_cores=10, ) - async with db.begin(): - db.add(sess) - db.add(Allocation(id=ids.new("allocation"), session_id=sess.id, device_id=dev.id, - gpu_uuid=dev.gpu_uuid, gpu_mem_mb=4096, gpu_cores=10, status="bound")) + await seed(db, [ + sess, + Allocation(id=ids.new("allocation"), session_id=sess.id, device_id=dev.id, + gpu_uuid=dev.gpu_uuid, gpu_mem_mb=4096, gpu_cores=10, status="bound"), + ]) with pytest.raises(DomainError) as err: await delete_node(node.id, principal=_Principal(), db=db) @@ -67,8 +68,7 @@ async def test_delete_node_detaches_history_and_clears_the_cards(db): alloc = Allocation(id=ids.new("allocation"), session_id=sess.id, device_id=dev.id, gpu_uuid=dev.gpu_uuid, gpu_mem_mb=4096, gpu_cores=10, status="released", ended_at=datetime.now(UTC)) - async with db.begin(): - db.add_all([sess, alloc]) + await seed(db, [sess, alloc]) await delete_node(node.id, principal=_Principal(), db=db) diff --git a/backend/tests/test_node_drain.py b/backend/tests/test_node_drain.py index 7643ffa..7653552 100644 --- a/backend/tests/test_node_drain.py +++ b/backend/tests/test_node_drain.py @@ -21,6 +21,7 @@ User, ) from app.db.models import Session as SessionRow +from tests.fkseed import seed def _admin() -> Principal: @@ -47,8 +48,7 @@ async def _seed(db): billing_wallet_id=wallet.id, credit_per_hour_snapshot=Decimal("60")) alloc = Allocation(id=ids.new("allocation"), session_id=sess.id, device_id=dev.id, gpu_mem_mb=4000, gpu_cores=25, status="bound") - async with db.begin(): - db.add_all([clu, node, dev, owner, wallet, off, img, sess, alloc]) + await seed(db, [clu, node, dev, owner, wallet, off, img, sess, alloc]) return node, dev, sess diff --git a/backend/tests/test_node_liveness.py b/backend/tests/test_node_liveness.py index 3c948cf..14b5496 100644 --- a/backend/tests/test_node_liveness.py +++ b/backend/tests/test_node_liveness.py @@ -13,6 +13,7 @@ from app.db.models import Cluster, GpuNode, Notification, User from app.db.models import Session as SessionRow from app.workers import node_liveness +from tests.fkseed import seed async def _node(db, *, status, seen_delta_sec, cluster_id=None, hostname=None): @@ -21,8 +22,7 @@ async def _node(db, *, status, seen_delta_sec, cluster_id=None, hostname=None): hostname=hostname or ids.new("node"), status=status, last_seen_at=datetime.now(UTC) - timedelta(seconds=seen_delta_sec), ) - async with db.begin(): - db.add(n) + await seed(db, [n]) return n @@ -33,8 +33,7 @@ def _naive(dt): async def _root(db) -> User: root = User(id=ids.new("user"), email=f"{ids.new('user')}@t", name="root", global_role="super_admin") - async with db.begin(): - db.add(root) + await seed(db, [root]) return root @@ -58,8 +57,9 @@ async def test_stale_node_goes_offline_and_cordon_is_left_alone(db, monkeypatch) async def test_not_ready_report_marks_offline_at_once_and_ready_report_restores(db): root = await _root(db) cid = ids.new("cluster") - async with db.begin(): - db.add(Cluster(id=cid, name="c", api_server="https://t:6443", runtime="k8s", kubeconfig_secret_ref="sec")) + await seed(db, [ + Cluster(id=cid, name="c", api_server="https://t:6443", runtime="k8s", kubeconfig_secret_ref="sec"), + ]) node = await _node(db, status="ready", seen_delta_sec=1, cluster_id=cid, hostname="gpu-x") seen_before = node.last_seen_at @@ -84,8 +84,9 @@ async def test_not_ready_report_marks_offline_at_once_and_ready_report_restores( @pytest.mark.asyncio async def test_old_operator_without_the_field_still_heartbeats(db): cid = ids.new("cluster") - async with db.begin(): - db.add(Cluster(id=cid, name="c", api_server="https://t:6443", runtime="k8s", kubeconfig_secret_ref="sec")) + await seed(db, [ + Cluster(id=cid, name="c", api_server="https://t:6443", runtime="k8s", kubeconfig_secret_ref="sec"), + ]) node = await _node(db, status="ready", seen_delta_sec=120, cluster_id=cid, hostname="gpu-y") seen_before = node.last_seen_at await InventorySync(db).upsert_node(OperatorNodeUpsert(node_id="gpu-y"), cid) @@ -108,8 +109,7 @@ async def test_sessions_on_an_offline_node_are_terminated(db, monkeypatch): offering_id="off_t", image_id="img_t", resource_class="gpu", mode="fractional", status="paused", node_hostname="gpu-dead", gpu_mem_mb=1024, gpu_cores=10, ) - async with db.begin(): - db.add_all([running, paused]) + await seed(db, [running, paused]) calls: list[tuple[str, str]] = [] diff --git a/backend/tests/test_node_pool_api.py b/backend/tests/test_node_pool_api.py index 2266cc2..e40bdc3 100644 --- a/backend/tests/test_node_pool_api.py +++ b/backend/tests/test_node_pool_api.py @@ -41,6 +41,7 @@ Project, ) from app.db.models import Session as SessionRow +from tests.fkseed import seed pytestmark = pytest.mark.asyncio @@ -77,11 +78,10 @@ async def _world(db) -> World: id="dev-1", node_id=w.node.id, cluster_id=w.cluster.id, model="A100", gpu_uuid="GPU-1", total_mem_mb=16000, status="ready", mode="fractional", ) - async with db.begin(): - db.add_all([ - w.cluster, w.cluster2, w.org_a, w.org_b, w.g1, w.gb, w.pool, w.other, w.grant_a, - w.node, w.free_node, w.dev, - ]) + await seed(db, [ + w.cluster, w.cluster2, w.org_a, w.org_b, w.g1, w.gb, w.pool, w.other, w.grant_a, + w.node, w.free_node, w.dev, + ]) w.su = Principal(user_id="su", global_role="super_admin", global_roles={"super_admin"}) w.admin_a = Principal( user_id="adm-a", memberships={w.g1.id: "org_admin"}, org_admin_orgs={w.org_a.id} @@ -261,8 +261,7 @@ async def test_set_node_pool_and_node_reads(db): w = await _world(db) # cross-cluster pool → validation error foreign = NodePool(id=ids.new("pool"), cluster_id=w.cluster2.id, name="F", kind="dedicated") - db.add(foreign) - await db.commit() + await seed(db, [foreign]) with pytest.raises(_Validation): await set_node_pool(w.free_node.id, NodePoolSet(pool_id=foreign.id), principal=w.su, db=db) with pytest.raises(NotFound): @@ -301,8 +300,7 @@ async def test_delete_pool_refused_under_live_sessions_then_clears_nodes(db): id=ids.new("allocation"), session_id=sess.id, device_id=w.dev.id, gpu_uuid="GPU-1", gpu_mem_mb=8000, gpu_cores=50, status="bound", ) - db.add_all([offering, image, sess, alloc]) - await db.commit() + await seed(db, [offering, image, sess, alloc]) with pytest.raises(_PoolInUse) as ei: await delete_node_pool(w.pool.id, principal=w.su, db=db) @@ -311,8 +309,7 @@ async def test_delete_pool_refused_under_live_sessions_then_clears_nodes(db): await delete_node_pool(w.pool.id, principal=w.admin_a, db=db) sess.status = "terminated" - db.add(sess) - await db.commit() + await seed(db, [sess]) await delete_node_pool(w.pool.id, principal=w.su, db=db) assert await db.get(NodePool, w.pool.id) is None assert await db.get(NodePoolGrant, w.grant_a.id) is None diff --git a/backend/tests/test_node_pools.py b/backend/tests/test_node_pools.py index cd06eaa..cbf20e2 100644 --- a/backend/tests/test_node_pools.py +++ b/backend/tests/test_node_pools.py @@ -31,6 +31,7 @@ from app.db.models import Session as SessionRow from app.domain.node_pools import assert_may_grant, resolve_pool_access from app.domain.scheduler import SchedulerService +from tests.fkseed import seed pytestmark = pytest.mark.asyncio @@ -77,8 +78,7 @@ async def _fleet(db, *, shared_nodes: int = 1) -> Fleet: ) rows += [node, dev] (f.pool_devs if in_pool else f.shared_devs).append(dev.id) - async with db.begin(): - db.add_all(rows) + await seed(db, rows) return f @@ -102,8 +102,8 @@ def _req(f: Fleet, group: Project, mem_mb: int = 8000) -> SessionCreate: async def _reserve(db, f, user_id, group, mem_mb=8000) -> str | None: """reserve_slice for one session; returns the device it landed on (None = no fit).""" sess = _session(f, user_id, group, mem_mb) + await seed(db, [sess]) async with db.begin(): - db.add(sess) ok = await SchedulerService(db).reserve_slice(sess, _req(f, group, mem_mb)) if not ok: return None @@ -114,16 +114,22 @@ async def _reserve(db, f, user_id, group, mem_mb=8000) -> str | None: async def _fill(db, dev_id: str) -> None: - """Occupy a card completely (used = total).""" + """Occupy a card completely (used = total) on behalf of some other tenant's session.""" + dev = await db.get(GpuDevice, dev_id) + resident = SessionRow( + id=ids.new("session"), owner_user_id=ids.new("user"), cluster_id=dev.cluster_id, + offering_id=ids.new("offering"), image_id=ids.new("image"), resource_class="gpu", + mode="fractional", status="running", + ) + await seed(db, [resident, Allocation( + id=ids.new("allocation"), session_id=resident.id, device_id=dev.id, + gpu_uuid=dev.gpu_uuid, gpu_mem_mb=dev.total_mem_mb, gpu_cores=dev.total_cores, + status="bound", + )]) async with db.begin(): dev = await db.get(GpuDevice, dev_id) dev.used_mem_mb = dev.total_mem_mb dev.used_cores = dev.total_cores - db.add(Allocation( - id=ids.new("allocation"), session_id=ids.new("session"), device_id=dev.id, - gpu_uuid=dev.gpu_uuid, gpu_mem_mb=dev.total_mem_mb, gpu_cores=dev.total_cores, - status="bound", - )) # 1 @@ -148,10 +154,11 @@ async def test_org_a_prefers_pool_then_falls_back_to_shared(db): # 3 async def test_shared_pool_policy_blocks_fallback(db): f = await _fleet(db) - async with db.begin(): - db.add(ResourcePolicy( - id=ids.new("policy"), scope="org", scope_id=f.org_a.id, limits={"shared_pool": False} - )) + await seed(db, [ + ResourcePolicy( + id=ids.new("policy"), scope="org", scope_id=f.org_a.id, limits={"shared_pool": False} + ), + ]) await _fill(db, f.pool_devs[0]) await _fill(db, f.pool_devs[1]) assert await _reserve(db, f, f.user_a, f.g1) is None @@ -162,10 +169,11 @@ async def test_shared_pool_policy_blocks_fallback(db): # 4 async def test_group_sub_grant_tier_order(db): f = await _fleet(db) - async with db.begin(): - db.add(NodePoolGrant( - id=ids.new("pool_grant"), pool_id=f.pool.id, scope="group", scope_id=f.g1.id - )) + await seed(db, [ + NodePoolGrant( + id=ids.new("pool_grant"), pool_id=f.pool.id, scope="group", scope_id=f.g1.id + ), + ]) a1 = await resolve_pool_access(db, cluster_id=f.cluster_id, user_id=f.user_a, group_id=f.g1.id) assert a1.tier_of(f.pool.id) == 0 and a1.tier_of(None) == 1 assert a1.pools[0] == {"id": f.pool.id, "name": "P", "kind": "dedicated", "tier": "group"} @@ -201,11 +209,10 @@ async def test_unserviceable_when_only_matching_cards_are_dedicated(db): # 7 async def test_dashboard_regions_exclude_dedicated_vram_for_org_b(db): f = await _fleet(db) - async with db.begin(): - db.add_all([ - Membership(id=ids.new("membership"), user_id=f.user_b, group_id=f.gb.id, role="member"), - Membership(id=ids.new("membership"), user_id=f.user_a, group_id=f.g1.id, role="member"), - ]) + await seed(db, [ + Membership(id=ids.new("membership"), user_id=f.user_b, group_id=f.gb.id, role="member"), + Membership(id=ids.new("membership"), user_id=f.user_a, group_id=f.g1.id, role="member"), + ]) out_b = await dashboard_summary(principal=Principal(user_id=f.user_b), db=db) assert [r["total_mb"] for r in out_b["regions"]] == [CARD_MB] assert out_b["pools"] == [{"id": None, "name": "shared", "kind": "shared", "tier": "shared"}] @@ -250,17 +257,17 @@ async def _resident_on(db, f, dev_id: str, user_id: str, group: Project) -> Sess resource_class="gpu", mode="exclusive", pause_mode="yield", priority=0, status="running", ) + dev = await db.get(GpuDevice, dev_id) + await seed(db, [resident, Allocation( + id=ids.new("allocation"), session_id=resident.id, device_id=dev.id, + gpu_uuid=dev.gpu_uuid, gpu_mem_mb=dev.total_mem_mb, gpu_cores=dev.total_cores, + status="bound", kind="resident", + )]) async with db.begin(): dev = await db.get(GpuDevice, dev_id) dev.mode = "exclusive" dev.used_mem_mb = dev.total_mem_mb dev.used_cores = dev.total_cores - db.add(resident) - db.add(Allocation( - id=ids.new("allocation"), session_id=resident.id, device_id=dev.id, - gpu_uuid=dev.gpu_uuid, gpu_mem_mb=dev.total_mem_mb, gpu_cores=dev.total_cores, - status="bound", kind="resident", - )) return resident @@ -288,8 +295,7 @@ async def _fake_stop(self, session_id, reason=None, **_): svc = SchedulerService(db) pre_b = _preemptor(f, f.user_b, f.gb) - async with db.begin(): - db.add(pre_b) + await seed(db, [pre_b]) req = SessionCreate( offering_id=f.offering.id, image_id=f.image.id, resource_class="gpu", cluster_id=f.cluster_id, group_id=f.gb.id, mode="exclusive", @@ -300,8 +306,7 @@ async def _fake_stop(self, session_id, reason=None, **_): assert fresh.status == "running" pre_a = _preemptor(f, f.user_a2, f.g2) - async with db.begin(): - db.add(pre_a) + await seed(db, [pre_a]) req_a = req.model_copy(update={"group_id": f.g2.id}) assert await svc._preempt_lower_priority(pre_a, req_a) is True assert stopped == [(resident.id, "preempted")] @@ -314,11 +319,10 @@ async def test_wizard_availability_hides_dedicated_models(db): from app.api.sessions_router import gpu_availability f = await _fleet(db) - async with db.begin(): - db.add_all([ - Membership(id=ids.new("membership"), user_id=f.user_b, group_id=f.gb.id, role="member"), - Membership(id=ids.new("membership"), user_id=f.user_a, group_id=f.g1.id, role="member"), - ]) + await seed(db, [ + Membership(id=ids.new("membership"), user_id=f.user_b, group_id=f.gb.id, role="member"), + Membership(id=ids.new("membership"), user_id=f.user_a, group_id=f.g1.id, role="member"), + ]) out_b = await gpu_availability( principal=Principal(user_id=f.user_b, memberships={f.gb.id: "member"}), db=db) assert [m["gpu_model"] for m in out_b["data"]] == ["SHARED-CARD"] or all( diff --git a/backend/tests/test_offline_node_serviceability.py b/backend/tests/test_offline_node_serviceability.py index 0de22db..c50c652 100644 --- a/backend/tests/test_offline_node_serviceability.py +++ b/backend/tests/test_offline_node_serviceability.py @@ -13,6 +13,7 @@ from app.core import ids from app.db.models import GpuDevice, GpuNode, Offering, Session from app.domain.scheduler import SchedulerService, Unserviceable +from tests.fkseed import seed def _device(node_id: str, model: str) -> GpuDevice: @@ -27,8 +28,7 @@ async def _fixture(db, node_status: str) -> tuple[Session, SessionCreate]: node = GpuNode(id=ids.new("node"), cluster_id="clu_t", hostname="gpu3", status=node_status) offering = Offering(id=ids.new("offering"), name="PRO 5000", resource_class="gpu", gpu_model="NVIDIA RTX PRO 5000 Blackwell", gpu_mem_mb=49152) - async with db.begin(): - db.add_all([node, offering, _device(node.id, offering.gpu_model)]) + await seed(db, [node, offering, _device(node.id, offering.gpu_model)]) sess = Session( id=ids.new("session"), owner_user_id="usr_t", cluster_id="clu_t", offering_id=offering.id, image_id="img_t", resource_class="gpu", mode="fractional", status="pending", diff --git a/backend/tests/test_org_counts.py b/backend/tests/test_org_counts.py index 8aebf5b..d143bd5 100644 --- a/backend/tests/test_org_counts.py +++ b/backend/tests/test_org_counts.py @@ -11,6 +11,7 @@ from app.auth.rbac import Principal from app.core import ids from app.db.models import Membership, Organization, Project, User +from tests.fkseed import seed def _admin() -> Principal: @@ -23,10 +24,9 @@ async def _seed(db): alive = User(id=ids.new("user"), email="alive@x.kr", name="Alive") ghost = User(id=ids.new("user"), email="ghost@x.kr", name="Ghost", status="suspended", deleted_at=datetime.now(UTC)) - db.add_all([org, grp, alive, ghost, + await seed(db, [org, grp, alive, ghost, Membership(id=ids.new("membership"), user_id=alive.id, group_id=grp.id, role="member"), Membership(id=ids.new("membership"), user_id=ghost.id, group_id=grp.id, role="member")]) - await db.commit() return org, grp diff --git a/backend/tests/test_per_card_mode.py b/backend/tests/test_per_card_mode.py index 77278a8..e13753a 100644 --- a/backend/tests/test_per_card_mode.py +++ b/backend/tests/test_per_card_mode.py @@ -24,12 +24,14 @@ from app.db.models import Session as SessionRow from app.domain.pool import maybe_apply_drained_mode from app.domain.scheduler import SchedulerService +from tests.fkseed import seed, user_row def _seed(db_objs, *, mode="fractional", mode_state="ready"): org_id = ids.new("org") group = Project(id=ids.new("group"), org_id=org_id, name="p") - user_id = ids.new("user") + user = user_row() + user_id = user.id wallet = CreditWallet( id=ids.new("wallet"), owner_type="user", owner_id=user_id, balance=Decimal("1000"), reserved=Decimal("0"), @@ -45,7 +47,7 @@ def _seed(db_objs, *, mode="fractional", mode_state="ready"): model="A100", gpu_uuid=f"GPU-{ids.new('device')}", total_mem_mb=16000, status="ready", mode=mode, mode_state=mode_state, ) - db_objs.extend([group, wallet, offering, image, device]) + db_objs.extend([user, group, wallet, offering, image, device]) return group, user_id, wallet, cluster_id, offering, image, device @@ -54,8 +56,7 @@ async def test_fractional_spec_carries_ledger_pin(db, fake_handoff, monkeypatch) monkeypatch.setattr(settings, "PER_CARD_MODE", True) objs: list = [] group, user_id, wallet, cluster_id, offering, image, dev = _seed(objs) - async with db.begin(): - db.add_all(objs) + await seed(db, objs) svc = SchedulerService(db) svc.handoff = fake_handoff @@ -107,8 +108,7 @@ async def test_inventory_report_does_not_flip_policy_mode(db): id="GPU-x", node_id=node.id, cluster_id=cluster.id, model="A100", gpu_uuid="GPU-x", total_mem_mb=16000, status="ready", mode="fractional", ) - async with db.begin(): - db.add_all([cluster, node, dev]) + await seed(db, [cluster, node, dev]) sync = InventorySync(db) ev = OperatorGpuDeviceUpsert( @@ -138,8 +138,7 @@ async def test_draining_card_accepts_no_placement_and_applies_when_empty(db, fak objs, mode="fractional", mode_state="draining", ) dev.desired_mode = "exclusive" - async with db.begin(): - db.add_all(objs) + await seed(db, objs) svc = SchedulerService(db) svc.handoff = fake_handoff @@ -167,8 +166,7 @@ async def test_set_device_mode_endpoint(db): id="GPU-y", node_id=node.id, cluster_id=cluster.id, model="A100", gpu_uuid="GPU-y", total_mem_mb=16000, status="ready", mode="fractional", ) - async with db.begin(): - db.add_all([cluster, node, dev]) + await seed(db, [cluster, node, dev]) admin = Principal(user_id=ids.new("user"), global_roles=["super_admin"]) out = await set_gpu_device_mode("GPU-y", GpuDeviceModeSet(desired_mode="exclusive"), diff --git a/backend/tests/test_pilot_fixes.py b/backend/tests/test_pilot_fixes.py index 8f82cf7..589059a 100644 --- a/backend/tests/test_pilot_fixes.py +++ b/backend/tests/test_pilot_fixes.py @@ -31,13 +31,15 @@ from app.db.models import Session as SessionRow from app.domain.scheduler import SchedulerService from app.domain.session_service import SessionService +from tests.fkseed import add_ordered, seed, user_row def _seed(db_objs): """Common catalogue seed: org/group, funded wallet, offering, image, one fractional device.""" org_id = ids.new("org") group = Project(id=ids.new("group"), org_id=org_id, name="p") - user_id = ids.new("user") + user = user_row() + user_id = user.id wallet = CreditWallet( id=ids.new("wallet"), owner_type="user", owner_id=user_id, balance=Decimal("1000"), reserved=Decimal("0"), @@ -53,11 +55,11 @@ def _seed(db_objs): model="A100", gpu_uuid=ids.new("device"), total_mem_mb=16000, status="ready", mode="fractional", ) - db_objs.extend([group, wallet, offering, image, device]) + db_objs.extend([user, group, wallet, offering, image, device]) return group, user_id, wallet, cluster_id, offering, image, device -def _occupy_fully(db, dev, offering, image, cluster_id): +async def _occupy_fully(db, dev, offering, image, cluster_id): """Fill the card with a real running session + reserved Allocation so the scheduler's drift reconciliation (which recomputes used_* from allocations) keeps it full.""" resident = SessionRow( @@ -71,7 +73,7 @@ def _occupy_fully(db, dev, offering, image, cluster_id): ) dev.used_mem_mb = dev.total_mem_mb dev.used_cores = 100 - db.add_all([resident, alloc]) + await add_ordered(db, [resident, alloc]) def _gpu_req(offering, image, cluster_id, group, wallet, *, mode, mem, cores): @@ -88,8 +90,7 @@ async def test_exclusive_without_matching_pool_is_unserviceable(db, fake_handoff `unserviceable` — not a queue entry that could never be dequeued.""" objs: list = [] group, user_id, wallet, cluster_id, offering, image, _dev = _seed(objs) - async with db.begin(): - db.add_all(objs) + await seed(db, objs) svc = SchedulerService(db) svc.handoff = fake_handoff @@ -116,8 +117,8 @@ async def test_full_fractional_pool_still_queues(db, fake_handoff): objs: list = [] group, user_id, wallet, cluster_id, offering, image, dev = _seed(objs) async with db.begin(): - db.add_all(objs) - _occupy_fully(db, dev, offering, image, cluster_id) + await add_ordered(db, objs) + await _occupy_fully(db, dev, offering, image, cluster_id) svc = SchedulerService(db) svc.handoff = fake_handoff @@ -136,8 +137,8 @@ async def test_stop_on_pending_session_cancels(db, fake_handoff): objs: list = [] group, user_id, wallet, cluster_id, offering, image, dev = _seed(objs) async with db.begin(): - db.add_all(objs) - _occupy_fully(db, dev, offering, image, cluster_id) + await add_ordered(db, objs) + await _occupy_fully(db, dev, offering, image, cluster_id) svc = SchedulerService(db) svc.handoff = fake_handoff @@ -161,8 +162,7 @@ async def test_stop_on_pending_session_cancels(db, fake_handoff): async def test_image_registry_patch_and_delete(db): admin = Principal(user_id=ids.new("user"), global_roles=["super_admin"]) img = Image(id=ids.new("image"), name="temp", registry="a/b:1") - async with db.begin(): - db.add(img) + await seed(db, [img]) image_id = img.id out = await update_image(image_id, ImageUpdate(registry="a/b:2"), principal=admin, db=db) @@ -180,14 +180,12 @@ async def test_image_delete_refused_with_session_history(db): offering = Offering( id=ids.new("offering"), name="cpu", resource_class="cpu", credit_per_hour=Decimal("0"), ) - async with db.begin(): - db.add_all([img, offering]) + await seed(db, [img, offering]) sess = SessionRow( id=ids.new("session"), owner_user_id=ids.new("user"), cluster_id=ids.new("cluster"), offering_id=offering.id, image_id=img.id, resource_class="cpu", status="terminated", ) - async with db.begin(): - db.add(sess) + await seed(db, [sess]) with pytest.raises(DomainError) as excinfo: await delete_image(img.id, principal=admin, db=db) diff --git a/backend/tests/test_policy_resolution.py b/backend/tests/test_policy_resolution.py index 52ca953..7b47809 100644 --- a/backend/tests/test_policy_resolution.py +++ b/backend/tests/test_policy_resolution.py @@ -20,6 +20,7 @@ ) from app.domain.policy import resolve_effective_policy from app.domain.scheduler import SchedulerService +from tests.fkseed import seed, user_row @pytest.mark.asyncio @@ -29,24 +30,23 @@ async def test_per_field_merge_user_overrides_only_what_it_sets(db): org = Organization(id=ids.new("org"), name="o") group = Project(id=ids.new("group"), org_id=org.id, name="g") user_id = ids.new("user") - async with db.begin(): - db.add_all([ - org, group, - ResourcePolicy( - id=ids.new("policy"), scope="user", scope_id=user_id, - max_concurrent=1, limits={}, - ), - ResourcePolicy( - id=ids.new("policy"), scope="group", scope_id=group.id, - max_concurrent=4, max_runtime=600, idle_timeout=900, - limits={"gpu_mem_mb": 50000}, - ), - ResourcePolicy( - id=ids.new("policy"), scope="global", scope_id="*", - max_concurrent=3, max_queued=5, max_runtime=1440, idle_timeout=3600, - limits={"gpu_mem_mb": 98304, "cpu": 64}, - ), - ]) + await seed(db, [ + org, group, + ResourcePolicy( + id=ids.new("policy"), scope="user", scope_id=user_id, + max_concurrent=1, limits={}, + ), + ResourcePolicy( + id=ids.new("policy"), scope="group", scope_id=group.id, + max_concurrent=4, max_runtime=600, idle_timeout=900, + limits={"gpu_mem_mb": 50000}, + ), + ResourcePolicy( + id=ids.new("policy"), scope="global", scope_id="*", + max_concurrent=3, max_queued=5, max_runtime=1440, idle_timeout=3600, + limits={"gpu_mem_mb": 98304, "cpu": 64}, + ), + ]) pol = await resolve_effective_policy(db, user_id, group.id) assert pol is not None @@ -82,7 +82,8 @@ async def test_max_concurrent_is_per_user_not_per_group(db, fake_handoff): model="A100", gpu_uuid=ids.new("device"), total_mem_mb=16000, status="ready", mode="fractional", ) - users = [ids.new("user"), ids.new("user")] + user_rows = [user_row(), user_row()] + users = [u.id for u in user_rows] wallets = { uid: CreditWallet( id=ids.new("wallet"), owner_type="user", owner_id=uid, @@ -93,8 +94,7 @@ async def test_max_concurrent_is_per_user_not_per_group(db, fake_handoff): policy = ResourcePolicy( id=ids.new("policy"), scope="group", scope_id=group.id, max_concurrent=1, limits={}, ) - async with db.begin(): - db.add_all([group, offering, image, device, policy, *wallets.values()]) + await seed(db, [*user_rows, group, offering, image, device, policy, *wallets.values()]) svc = SchedulerService(db) svc.handoff = fake_handoff diff --git a/backend/tests/test_pool_rebalancer.py b/backend/tests/test_pool_rebalancer.py index eafd0b5..176950b 100644 --- a/backend/tests/test_pool_rebalancer.py +++ b/backend/tests/test_pool_rebalancer.py @@ -8,6 +8,7 @@ from app.core import ids from app.db.models import Cluster, GpuDevice, GpuNode from app.workers import pool_rebalancer +from tests.fkseed import seed class FakeChangeCRD: @@ -42,8 +43,7 @@ async def _fleet(db, n=3): ) for i in range(n) ] - async with db.begin(): - db.add_all([cluster, node, *devs]) + await seed(db, [cluster, node, *devs]) return cluster, node, devs @@ -159,8 +159,7 @@ async def test_idle_card_metadata_transition_applies(db, monkeypatch): total_mem_mb=48935, used_mem_mb=0, total_cores=100, used_cores=0, status="ready", mode="fractional", desired_mode="exclusive", mode_state="draining", ) - async with db.begin(): - db.add(dev) + await seed(db, [dev]) monkeypatch.setattr(pool_rebalancer, "get_sessionmaker", lambda: (lambda: _NullCtx(db))) await pool_rebalancer.run() diff --git a/backend/tests/test_privileged_policy.py b/backend/tests/test_privileged_policy.py index aee12a1..4926c1e 100644 --- a/backend/tests/test_privileged_policy.py +++ b/backend/tests/test_privileged_policy.py @@ -8,18 +8,18 @@ from app.core import ids from app.db.models import ResourcePolicy from app.domain.policy import resolve_effective_policy +from tests.fkseed import seed @pytest.mark.asyncio async def test_grant_resolves_most_specific_first_and_defaults_off(db): - db.add_all([ + await seed(db, [ ResourcePolicy(id=ids.new("policy"), scope="global", scope_id="*", max_concurrent=3, limits={}), ResourcePolicy(id=ids.new("policy"), scope="user", scope_id="usr_root_ok", max_concurrent=2, limits={"allow_privileged": True}), ResourcePolicy(id=ids.new("policy"), scope="user", scope_id="usr_denied", max_concurrent=2, limits={"allow_privileged": False}), ]) - await db.commit() assert (await resolve_effective_policy(db, "usr_root_ok", None)).limits.get("allow_privileged") is True assert not (await resolve_effective_policy(db, "usr_denied", None)).limits.get("allow_privileged") assert not (await resolve_effective_policy(db, "usr_plain", None)).limits.get("allow_privileged") diff --git a/backend/tests/test_qa_regressions.py b/backend/tests/test_qa_regressions.py index 472bda9..fe7d43b 100644 --- a/backend/tests/test_qa_regressions.py +++ b/backend/tests/test_qa_regressions.py @@ -15,6 +15,7 @@ from app.auth.rbac import Principal from app.core.errors import Forbidden from app.core.validation import clean_text +from tests.fkseed import seed # ── BUG-001 / BUG-002: 이름 검증 ──────────────────────────────────────────────── @@ -85,12 +86,10 @@ async def test_session_may_spend_its_own_reservation(db): wallet = CreditWallet(id=ids.new("wallet"), owner_type="user", owner_id="usr_x", balance=Decimal("60"), reserved=Decimal("60")) - db.add(wallet) sess = SimpleNamespace(id="ses_x", billing_wallet_id=wallet.id, started_at=None) - db.add(CreditTransaction( + await seed(db, [wallet, CreditTransaction( id=ids.new("txn"), wallet_id=wallet.id, type="hold", amount=Decimal("60"), - balance_after=Decimal("60"), ref=sess.id, idempotency_key=f"hold:{sess.id}")) - await db.commit() + balance_after=Decimal("60"), ref=sess.id, idempotency_key=f"hold:{sess.id}")]) engine = CreditEngine(db) # The wallet-wide figure says nothing is left... @@ -111,13 +110,11 @@ async def test_a_second_session_does_not_get_the_first_ones_reservation(db): wallet = CreditWallet(id=ids.new("wallet"), owner_type="user", owner_id="usr_y", balance=Decimal("100"), reserved=Decimal("100")) - db.add(wallet) a = SimpleNamespace(id="ses_a", billing_wallet_id=wallet.id, started_at=None) b = SimpleNamespace(id="ses_b", billing_wallet_id=wallet.id, started_at=None) - db.add(CreditTransaction( + await seed(db, [wallet, CreditTransaction( id=ids.new("txn"), wallet_id=wallet.id, type="hold", amount=Decimal("100"), - balance_after=Decimal("100"), ref=a.id, idempotency_key=f"hold:{a.id}")) - await db.commit() + balance_after=Decimal("100"), ref=a.id, idempotency_key=f"hold:{a.id}")]) engine = CreditEngine(db) assert await engine.available_for(wallet, a) == Decimal("100") # its own hold @@ -150,7 +147,7 @@ async def test_admin_cannot_reach_a_user_of_another_organization(db): org_a, org_b = ids.new("org"), ids.new("org") grp_a, grp_b = ids.new("group"), ids.new("group") victim, outsider = ids.new("user"), ids.new("user") - db.add_all([ + await seed(db, [ Organization(id=org_a, name="A"), Organization(id=org_b, name="B"), Project(id=grp_a, org_id=org_a, name="ga"), Project(id=grp_b, org_id=org_b, name="gb"), User(id=victim, email="v@example.edu", name="v", status="active"), @@ -158,7 +155,6 @@ async def test_admin_cannot_reach_a_user_of_another_organization(db): Membership(id=ids.new("membership"), user_id=victim, group_id=grp_a, role="member"), Membership(id=ids.new("membership"), user_id=outsider, group_id=grp_b, role="org_admin"), ]) - await db.commit() attacker = Principal(user_id=outsider, global_role=None, global_roles=set(), memberships={grp_b: "org_admin"}, org_admin_orgs={org_b}) @@ -167,11 +163,10 @@ async def test_admin_cannot_reach_a_user_of_another_organization(db): # The same administrator still reaches their own organization's users. insider = ids.new("user") - db.add_all([ + await seed(db, [ User(id=insider, email="i@example.edu", name="i", status="active"), Membership(id=ids.new("membership"), user_id=insider, group_id=grp_b, role="member"), ]) - await db.commit() await _assert_target_in_scope(db, attacker, insider) # super_admin is global, and everyone reaches themselves. @@ -189,11 +184,10 @@ async def test_admin_cannot_enroll_a_user_into_another_organizations_group(db): org_a, org_b = ids.new("org"), ids.new("org") grp_a, grp_b = ids.new("group"), ids.new("group") - db.add_all([ + await seed(db, [ Organization(id=org_a, name="A"), Organization(id=org_b, name="B"), Project(id=grp_a, org_id=org_a, name="ga"), Project(id=grp_b, org_id=org_b, name="gb"), ]) - await db.commit() foreign = await db.get(Project, grp_a) own = await db.get(Project, grp_b) @@ -230,9 +224,8 @@ async def test_account_without_a_password_hash_cannot_log_in(db): from app.core.errors import Unauthenticated from app.db.models import User - db.add(User(id=ids.new("user"), email="nohash@example.edu", name="n", - password_hash=None, status="active")) - await db.commit() + await seed(db, [User(id=ids.new("user"), email="nohash@example.edu", name="n", + password_hash=None, status="active")]) class _Req: headers = {"x-forwarded-for": "10.0.0.1"} @@ -258,12 +251,11 @@ async def test_finished_session_is_not_resurrected_by_a_late_running_report(db): now = datetime.now(UTC) for finished in ("terminated", "error"): sid = ids.new("session") - db.add(SessionModel( + await seed(db, [SessionModel( id=sid, owner_user_id="usr_x", cluster_id="clu_1", offering_id="off_1", image_id="img_1", resource_class="gpu", mode="fractional", gpu_mem_mb=1024, gpu_cores=10, status=finished, terminated_at=now, - )) - await db.commit() + )]) ev = OperatorStatusEvent(phase="running", ts=now, pod_ref="ns/pod", node_name="node-1") await StatusSync(db).on_status(sid, ev) diff --git a/backend/tests/test_queue_ranking.py b/backend/tests/test_queue_ranking.py index 9963f2c..a30c67b 100644 --- a/backend/tests/test_queue_ranking.py +++ b/backend/tests/test_queue_ranking.py @@ -29,6 +29,7 @@ from app.domain import queue_ranking from app.domain.scheduler import SchedulerService from app.workers import queue_ticker +from tests.fkseed import add_ordered, seed, user_row def _entry(session_id: str, *, priority: int = 0, waited_min: float = 0.0) -> QueueEntry: @@ -60,8 +61,7 @@ async def test_rank_orders_desc_with_fifo_tiebreak(db): e_low = _entry("s-low", priority=0, waited_min=10) e_high = _entry("s-high", priority=5, waited_min=1) e_old = _entry("s-old", priority=0, waited_min=60) - async with db.begin(): - db.add_all([e_low, e_high, e_old]) + await seed(db, [e_low, e_high, e_old]) ranked = await queue_ranking.rank(db) assert [e.session_id for e, _ in ranked] == ["s-high", "s-old", "s-low"] @@ -69,7 +69,8 @@ async def test_rank_orders_desc_with_fifo_tiebreak(db): def _seed(db_objs): org_id = ids.new("org") group = Project(id=ids.new("group"), org_id=org_id, name="p") - user_id = ids.new("user") + user = user_row() + user_id = user.id wallet = CreditWallet( id=ids.new("wallet"), owner_type="user", owner_id=user_id, balance=Decimal("1000"), reserved=Decimal("0"), @@ -85,11 +86,11 @@ def _seed(db_objs): model="A100", gpu_uuid=ids.new("device"), total_mem_mb=16000, status="ready", mode="fractional", ) - db_objs.extend([group, wallet, offering, image, device]) + db_objs.extend([user, group, wallet, offering, image, device]) return group, user_id, wallet, cluster_id, offering, image, device -def _occupy_fully(db, dev, offering, image, cluster_id): +async def _occupy_fully(db, dev, offering, image, cluster_id): resident = SessionRow( id=ids.new("session"), owner_user_id=ids.new("user"), cluster_id=cluster_id, offering_id=offering.id, image_id=image.id, resource_class="gpu", @@ -101,7 +102,7 @@ def _occupy_fully(db, dev, offering, image, cluster_id): ) dev.used_mem_mb = dev.total_mem_mb dev.used_cores = 100 - db.add_all([resident, alloc]) + await add_ordered(db, [resident, alloc]) return resident, alloc @@ -111,8 +112,8 @@ async def test_enqueue_preserves_session_priority(db, fake_handoff): objs: list = [] group, user_id, wallet, cluster_id, offering, image, dev = _seed(objs) async with db.begin(): - db.add_all(objs) - _occupy_fully(db, dev, offering, image, cluster_id) + await add_ordered(db, objs) + await _occupy_fully(db, dev, offering, image, cluster_id) svc = SchedulerService(db) svc.handoff = fake_handoff @@ -138,8 +139,8 @@ async def test_ticker_admits_in_rank_order_when_capacity_returns(db, fake_handof objs: list = [] group, user_id, wallet, cluster_id, offering, image, dev = _seed(objs) async with db.begin(): - db.add_all(objs) - resident, alloc = _occupy_fully(db, dev, offering, image, cluster_id) + await add_ordered(db, objs) + resident, alloc = await _occupy_fully(db, dev, offering, image, cluster_id) svc = SchedulerService(db) svc.handoff = fake_handoff @@ -240,8 +241,7 @@ async def test_rank_penalizes_owner_with_active_session(db): enqueued_at=now - timedelta(minutes=10)) e_idle = QueueEntry(id=ids.new("queue"), session_id=q_idle.id, session_req={}, priority=0, enqueued_at=now - timedelta(minutes=10)) - async with db.begin(): - db.add_all([offering, image, running, q_busy, q_idle, e_busy, e_idle]) + await seed(db, [offering, image, running, q_busy, q_idle, e_busy, e_idle]) ranked = await queue_ranking.rank(db) assert [e.session_id for e, _ in ranked] == [q_idle.id, q_busy.id] diff --git a/backend/tests/test_reaper_policy_stamp.py b/backend/tests/test_reaper_policy_stamp.py index eeb1fe9..8e8859b 100644 --- a/backend/tests/test_reaper_policy_stamp.py +++ b/backend/tests/test_reaper_policy_stamp.py @@ -10,17 +10,19 @@ from app.cluster.crd import GShareSessionCRD from app.core import ids from app.db.models import ResourcePolicy +from tests.fkseed import seed @pytest.mark.asyncio async def test_cpu_sessions_use_cpu_session_windows(db): user_id = ids.new("user") - async with db.begin(): - db.add(ResourcePolicy( - id=ids.new("policy"), scope="user", scope_id=user_id, - max_concurrent=2, max_queued=2, max_runtime=60, idle_timeout=3600, - limits={"cpu_session_idle_timeout_sec": 0, "cpu_session_max_runtime_min": 0}, - )) + await seed(db, [ + ResourcePolicy( + id=ids.new("policy"), scope="user", scope_id=user_id, + max_concurrent=2, max_queued=2, max_runtime=60, idle_timeout=3600, + limits={"cpu_session_idle_timeout_sec": 0, "cpu_session_max_runtime_min": 0}, + ), + ]) crd = GShareSessionCRD(db=db) gpu_spec = {"owner": user_id, "group_id": None, "resource_class": "gpu"} diff --git a/backend/tests/test_rebind_on_resume.py b/backend/tests/test_rebind_on_resume.py index 585014c..c621128 100644 --- a/backend/tests/test_rebind_on_resume.py +++ b/backend/tests/test_rebind_on_resume.py @@ -7,6 +7,7 @@ from app.cluster.status_sync import StatusSync from app.core import ids from app.db.models import Session +from tests.fkseed import seed def _event(**kw): @@ -22,8 +23,7 @@ async def test_resumed_session_rebinds_to_the_card_it_actually_runs_on(db, monke offering_id="off_t", image_id="img_t", resource_class="gpu", mode="fractional", status="preparing", gpu_mem_mb=12288, gpu_cores=50, bound_gpu_uuid="GPU-old", node_hostname="retired-node", pod_ref="ses-old") - db.add(sess) - await db.commit() + await seed(db, [sess]) sync = StatusSync(db) # the ledger side is exercised elsewhere; here the question is only what the row records diff --git a/backend/tests/test_request_hierarchy.py b/backend/tests/test_request_hierarchy.py index 8282148..6b13769 100644 --- a/backend/tests/test_request_hierarchy.py +++ b/backend/tests/test_request_hierarchy.py @@ -19,6 +19,7 @@ from app.core import ids from app.core.errors import Forbidden, TopupRequestOrgOnly from app.db.models import CreditWallet, Organization, Project, User +from tests.fkseed import seed async def _tenant(db): @@ -30,8 +31,7 @@ async def _tenant(db): balance=Decimal("0"), reserved=Decimal("0")) oa = User(id=ids.new("user"), email="oa@t.local", name="oa") ga = User(id=ids.new("user"), email="ga@t.local", name="ga") - async with db.begin(): - db.add_all([org, prj, ow, gw, oa, ga]) + await seed(db, [org, prj, ow, gw, oa, ga]) return org, prj, ow, gw, oa.id, ga.id diff --git a/backend/tests/test_resource_requests.py b/backend/tests/test_resource_requests.py index 4c62f62..8fb2a83 100644 --- a/backend/tests/test_resource_requests.py +++ b/backend/tests/test_resource_requests.py @@ -20,6 +20,7 @@ from app.core.errors import Forbidden from app.db.models import Membership, Organization, Project, ResourcePolicy, User from app.domain.policy import resolve_effective_policy +from tests.fkseed import seed def _p(uid, *, super_admin=False, memberships=None): @@ -36,7 +37,7 @@ async def _seed(db): grp = Project(id=ids.new("project"), org_id=org.id, name="G") member = User(id=ids.new("user"), email="m@x.kr", name="M") admin = User(id=ids.new("user"), email="a@x.kr", name="A") - db.add_all([ + await seed(db, [ org, grp, member, admin, Membership(id=ids.new("membership"), user_id=member.id, group_id=grp.id, role="member"), Membership(id=ids.new("membership"), user_id=admin.id, group_id=grp.id, role="group_admin"), @@ -44,7 +45,6 @@ async def _seed(db): ResourcePolicy(id=ids.new("policy"), scope="group", scope_id=grp.id, max_concurrent=3, limits={"cpu": 16, "mem_gb": 64, "storage_gb": 200}), ]) - await db.commit() return grp, member, admin diff --git a/backend/tests/test_resume_restamp.py b/backend/tests/test_resume_restamp.py index b0e96a5..b331039 100644 --- a/backend/tests/test_resume_restamp.py +++ b/backend/tests/test_resume_restamp.py @@ -7,6 +7,7 @@ from app.cluster.crd import GShareSessionCRD from app.db.models import Cluster +from tests.fkseed import seed class _FakeApi: @@ -19,8 +20,9 @@ async def patch_namespaced_custom_object(self, **kw): @pytest.mark.asyncio async def test_resume_patch_is_class_aware_and_stamps_run_start(db, monkeypatch): - async with db.begin(): - db.add(Cluster(id="clu_t", name="t", api_server="https://t:6443", runtime="k8s", kubeconfig_secret_ref="sec")) + await seed(db, [ + Cluster(id="clu_t", name="t", api_server="https://t:6443", runtime="k8s", kubeconfig_secret_ref="sec"), + ]) api = _FakeApi() @asynccontextmanager diff --git a/backend/tests/test_scheduler_gate.py b/backend/tests/test_scheduler_gate.py index 8173187..9092844 100644 --- a/backend/tests/test_scheduler_gate.py +++ b/backend/tests/test_scheduler_gate.py @@ -28,6 +28,7 @@ ResourcePolicy, ) from app.domain.scheduler import SchedulerService +from tests.fkseed import seed, user_row @pytest.mark.asyncio @@ -41,7 +42,8 @@ async def test_gate_order_policy_then_budget_then_hold(db, fake_handoff): """ org_id = ids.new("org") group = Project(id=ids.new("group"), org_id=org_id, name="p") - user_id = ids.new("user") + owner = user_row() + user_id = owner.id # Personal billing wallet (owner = requester) with no available credit: a hold, if reached, # raises 402. wallet = CreditWallet( @@ -76,8 +78,7 @@ async def test_gate_order_policy_then_budget_then_hold(db, fake_handoff): id=ids.new("device"), node_id=ids.new("node"), cluster_id=cluster_id, model="A100", gpu_uuid=ids.new("device"), total_mem_mb=16000, status="ready", ) - async with db.begin(): - db.add_all([group, wallet, budget, offering, image, device]) + await seed(db, [owner, group, wallet, budget, offering, image, device]) svc = SchedulerService(db) svc.handoff = fake_handoff # never reached, but keep the live cluster out of the test @@ -120,7 +121,8 @@ async def test_hold_is_reached_when_budget_passes(db, fake_handoff): empty wallet surfaces InsufficientCredit. (Complements the order assertion above.) """ group = Project(id=ids.new("group"), org_id=ids.new("org"), name="p") - user_id = ids.new("user") + owner = user_row() + user_id = owner.id wallet = CreditWallet( id=ids.new("wallet"), owner_type="user", @@ -144,8 +146,7 @@ async def test_hold_is_reached_when_budget_passes(db, fake_handoff): id=ids.new("device"), node_id=ids.new("node"), cluster_id=cluster_id, model="A100", gpu_uuid=ids.new("device"), total_mem_mb=16000, status="ready", ) - async with db.begin(): - db.add_all([group, wallet, offering, image, device]) + await seed(db, [owner, group, wallet, offering, image, device]) svc = SchedulerService(db) svc.handoff = fake_handoff @@ -184,15 +185,15 @@ async def test_lossless_gate_eligible_when_offering_and_node_capable(db): id=ids.new("node"), cluster_id=cluster_id, hostname="n1", status="ready", lossless_capable=True, ) - async with db.begin(): - db.add_all([offering, image, node]) + owner = user_row() + await seed(db, [offering, image, node, owner]) svc = SchedulerService(db) req = SessionCreate( offering_id=offering.id, image_id=image.id, resource_class="gpu", cluster_id=cluster_id, mode="exclusive", ) - sess = await svc._persist_pending(req, Principal(user_id=ids.new("user"))) + sess = await svc._persist_pending(req, Principal(user_id=owner.id)) assert sess.lossless_pause is True @@ -211,15 +212,15 @@ async def test_lossless_gate_blocked_without_capable_node(db): id=ids.new("node"), cluster_id=cluster_id, hostname="n1", status="ready", lossless_capable=False, ) - async with db.begin(): - db.add_all([offering, image, node]) + owner = user_row() + await seed(db, [offering, image, node, owner]) svc = SchedulerService(db) req = SessionCreate( offering_id=offering.id, image_id=image.id, resource_class="gpu", cluster_id=cluster_id, mode="exclusive", ) - sess = await svc._persist_pending(req, Principal(user_id=ids.new("user"))) + sess = await svc._persist_pending(req, Principal(user_id=owner.id)) assert sess.lossless_pause is False @@ -355,8 +356,7 @@ async def test_resource_sum_uses_request_disk_override(db): pol = ResourcePolicy( id=ids.new("pol"), scope="user", scope_id=user_id, limits={"storage_gb": 100}, ) - async with db.begin(): - db.add_all([offering, pol]) + await seed(db, [offering, pol]) svc = SchedulerService(db) req = SessionCreate( @@ -381,8 +381,7 @@ async def test_resource_sum_falls_back_to_offering_when_no_override(db): pol = ResourcePolicy( id=ids.new("pol"), scope="user", scope_id=user_id, limits={"storage_gb": 100}, ) - async with db.begin(): - db.add_all([offering, pol]) + await seed(db, [offering, pol]) svc = SchedulerService(db) req = SessionCreate( @@ -400,8 +399,7 @@ async def test_max_runtime_resolved_and_stamped_seconds(db): gshare.io/max-runtime-sec.""" owner = ids.new("user") pol = ResourcePolicy(id=ids.new("pol"), scope="user", scope_id=owner, max_runtime=60) - async with db.begin(): - db.add(pol) + await seed(db, [pol]) crd = GShareSessionCRD(db=db) spec = {"session_id": "ses_test", "owner": owner, "group_id": None} max_sec = await crd._resolve_max_runtime_sec(spec) diff --git a/backend/tests/test_session_cost_and_queue_reason.py b/backend/tests/test_session_cost_and_queue_reason.py index a1e1d00..3585b9d 100644 --- a/backend/tests/test_session_cost_and_queue_reason.py +++ b/backend/tests/test_session_cost_and_queue_reason.py @@ -13,6 +13,7 @@ from app.auth.rbac import Principal from app.core import ids from app.db.models import CreditTransaction, CreditWallet, Session, SessionEvent +from tests.fkseed import seed, user_row OWNER = "usr_cost01" P = Principal(user_id=OWNER, global_role="member", global_roles={"member"}) @@ -35,15 +36,17 @@ async def _rows(db): async def test_cost_is_the_sum_of_the_consume_rows(db): wallet = CreditWallet(id=ids.new("wallet"), owner_type="user", owner_id=OWNER, balance=Decimal("100"), reserved=Decimal("0")) - db.add_all([wallet, _session("ses_paid", "terminated"), _session("ses_free", "terminated")]) + await seed(db, [user_row(OWNER), wallet, + _session("ses_paid", "terminated"), _session("ses_free", "terminated")]) # two billing ticks on one session, plus a hold and a refund that must NOT count as spend - for i, (typ, amt) in enumerate( - [("consume", "-3.50"), ("consume", "-1.25"), ("hold", "-10"), ("refund", "10")] - ): - db.add(CreditTransaction(id=ids.new("txn"), wallet_id=wallet.id, type=typ, - amount=Decimal(amt), balance_after=Decimal("0"), - ref="ses_paid", idempotency_key=f"k{i}")) - await db.commit() + await seed(db, [ + CreditTransaction(id=ids.new("txn"), wallet_id=wallet.id, type=typ, + amount=Decimal(amt), balance_after=Decimal("0"), + ref="ses_paid", idempotency_key=f"k{i}") + for i, (typ, amt) in enumerate( + [("consume", "-3.50"), ("consume", "-1.25"), ("hold", "-10"), ("refund", "10")] + ) + ]) rows = await _rows(db) assert rows["ses_paid"].credit_consumed == 4.75 @@ -53,7 +56,7 @@ async def test_cost_is_the_sum_of_the_consume_rows(db): @pytest.mark.asyncio async def test_a_waiting_session_reports_its_latest_queue_reason(db): - db.add_all([_session("ses_wait", "pending"), _session("ses_run", "running")]) + await seed(db, [_session("ses_wait", "pending"), _session("ses_run", "running")]) for i, (sid, kind, reason) in enumerate([ ("ses_wait", "queued", "no_gpu_capacity"), ("ses_wait", "queued", "host_headroom"), # the newer refusal wins @@ -72,12 +75,11 @@ async def test_the_queue_entry_carries_the_same_reason(db): """The queue screen and the dashboard read the reason from one place, so they agree.""" from app.api.queue_router import _decorate - db.add_all([ + await seed(db, [ _session("ses_q1", "pending"), SessionEvent(id="sev_q0", session_id="ses_q1", kind="queued", reason="no_gpu_capacity"), SessionEvent(id="sev_q1", session_id="ses_q1", kind="queued", reason="host_headroom"), ]) - await db.commit() out = await _decorate(db, [{"session_id": "ses_q1"}, {"session_id": "ses_none"}]) assert out[0]["reason"] == "host_headroom" diff --git a/backend/tests/test_session_liveness.py b/backend/tests/test_session_liveness.py index 07343d0..fe44b8b 100644 --- a/backend/tests/test_session_liveness.py +++ b/backend/tests/test_session_liveness.py @@ -11,6 +11,7 @@ from app.core.config import settings from app.db.models import GpuNode, Session from app.workers import session_liveness +from tests.fkseed import seed def _session(**kw) -> Session: @@ -32,8 +33,7 @@ def _ev(**kw) -> OperatorStatusEvent: @pytest.mark.asyncio async def test_heartbeat_stamps_the_session(db): sess = _session() - async with db.begin(): - db.add(sess) + await seed(db, [sess]) await StatusSync(db).on_status(sess.id, _ev(restart_count=1)) db.expunge_all() row = await db.get(Session, sess.id) @@ -43,8 +43,7 @@ async def test_heartbeat_stamps_the_session(db): @pytest.mark.asyncio async def test_crash_loop_ends_the_session(db, monkeypatch): sess = _session() - async with db.begin(): - db.add(sess) + await seed(db, [sess]) monkeypatch.setattr(settings, "SESSION_CRASH_LOOP_RESTARTS", 3) await StatusSync(db).on_status(sess.id, _ev(restart_count=2, container_state="Waiting:CrashLoopBackOff")) db.expunge_all() @@ -76,14 +75,13 @@ async def test_stale_session_is_settled_only_while_the_operator_is_alive(db, mon fresh = _session(cluster_id="clu_alive", last_reported_at=datetime.now(UTC)) never = _session(cluster_id="clu_alive") # pre-feature row: no stamp yet orphan = _session(cluster_id="clu_dead", last_reported_at=stale_at) - async with db.begin(): - db.add_all([ - lost, fresh, never, orphan, - GpuNode(id=ids.new("node"), cluster_id="clu_alive", hostname="n1", status="ready", - last_seen_at=datetime.now(UTC)), - GpuNode(id=ids.new("node"), cluster_id="clu_dead", hostname="n2", status="ready", - last_seen_at=stale_at), - ]) + await seed(db, [ + lost, fresh, never, orphan, + GpuNode(id=ids.new("node"), cluster_id="clu_alive", hostname="n1", status="ready", + last_seen_at=datetime.now(UTC)), + GpuNode(id=ids.new("node"), cluster_id="clu_dead", hostname="n2", status="ready", + last_seen_at=stale_at), + ]) await session_liveness.run() assert _Svc.calls == [(lost.id, "pod_lost")] @@ -92,8 +90,7 @@ async def test_stale_session_is_settled_only_while_the_operator_is_alive(db, mon async def test_stale_paused_report_after_a_resume_is_ignored(db): """Drain: stop() then start() within a second; the operator's Paused echo lands after the resume.""" sess = _session(started_at=datetime.now(UTC)) - async with db.begin(): - db.add(sess) + await seed(db, [sess]) await StatusSync(db).on_status(sess.id, _ev(phase="paused", ts=datetime.now(UTC) - timedelta(seconds=5), restart_count=None, container_state=None)) db.expunge_all() row = await db.get(Session, sess.id) @@ -104,8 +101,7 @@ async def test_stale_paused_report_after_a_resume_is_ignored(db): @pytest.mark.parametrize("phase", ["terminated", "error"]) async def test_stale_terminal_report_after_a_pod_replacement_is_ignored(db, phase): sess = _session(started_at=datetime.now(UTC)) - async with db.begin(): - db.add(sess) + await seed(db, [sess]) await StatusSync(db).on_status(sess.id, _ev(phase=phase, ts=datetime.now(UTC) - timedelta(seconds=5), restart_count=None, container_state=None)) db.expunge_all() assert (await db.get(Session, sess.id)).status == "running" @@ -117,8 +113,7 @@ async def test_running_report_never_creates_an_allocation_for_a_cpu_session(db): from app.db.models import Allocation sess = _session(resource_class="cpu", gpu_mem_mb=None, gpu_cores=None, cpu=2, mem_gb=4, status="preparing") - async with db.begin(): - db.add(sess) + await seed(db, [sess]) await StatusSync(db).on_status(sess.id, _ev(phase="running", restart_count=None, container_state=None)) assert await db.scalar(_select(Allocation).where(Allocation.session_id == sess.id)) is None @@ -129,8 +124,7 @@ async def test_paused_report_from_an_older_generation_is_ignored(db): older generation.""" from app.core.redis import get_redis sess = _session(started_at=datetime.now(UTC) - timedelta(seconds=5)) - async with db.begin(): - db.add(sess) + await seed(db, [sess]) await get_redis().set(f"resume-gen:{sess.id}", "7") await StatusSync(db).on_status(sess.id, _ev(phase="paused", ts=datetime.now(UTC), generation=6, restart_count=None, container_state=None)) db.expunge_all() @@ -141,8 +135,7 @@ async def test_paused_report_from_an_older_generation_is_ignored(db): async def test_paused_echo_without_a_reason_never_pauses_a_running_session(db): """Backend stop → resume within milliseconds: the operator's Paused (no reason) lands after.""" sess = _session(started_at=datetime.now(UTC) - timedelta(seconds=1)) - async with db.begin(): - db.add(sess) + await seed(db, [sess]) await StatusSync(db).on_status(sess.id, _ev(phase="paused", ts=datetime.now(UTC), restart_count=None, container_state=None, message=None)) db.expunge_all() row = await db.get(Session, sess.id) @@ -152,8 +145,7 @@ async def test_paused_echo_without_a_reason_never_pauses_a_running_session(db): @pytest.mark.asyncio async def test_reaper_pause_with_a_reason_still_pauses(db): sess = _session(started_at=datetime.now(UTC) - timedelta(minutes=30)) - async with db.begin(): - db.add(sess) + await seed(db, [sess]) await StatusSync(db).on_status(sess.id, _ev(phase="paused", ts=datetime.now(UTC), restart_count=None, container_state=None, message="idle-reaped")) db.expunge_all() row = await db.get(Session, sess.id) @@ -164,8 +156,7 @@ async def test_reaper_pause_with_a_reason_still_pauses(db): async def test_pause_ack_is_recorded_for_a_backend_pause(db): from app.core.redis import get_redis sess = _session(status="paused") - async with db.begin(): - db.add(sess) + await seed(db, [sess]) await StatusSync(db).on_status(sess.id, _ev(phase="paused", ts=datetime.now(UTC), restart_count=None, container_state=None)) assert await get_redis().get(f"pause-ack:{sess.id}") is not None @@ -173,8 +164,7 @@ async def test_pause_ack_is_recorded_for_a_backend_pause(db): @pytest.mark.asyncio async def test_heartbeat_follows_the_pod_to_its_new_node(db): sess = _session(node_hostname="cpu01") - async with db.begin(): - db.add(sess) + await seed(db, [sess]) await StatusSync(db).on_status(sess.id, _ev(node_name="cpu02")) db.expunge_all() assert (await db.get(Session, sess.id)).node_hostname == "cpu02" @@ -198,11 +188,10 @@ async def test_an_operator_outage_does_not_settle_the_sessions_it_stops_reportin # from the same moment, and the nodes have not been declared stale yet. died_at = now - timedelta(seconds=settings.SESSION_STALE_SEC + 10) quiet = _session(cluster_id="clu_blip", last_reported_at=died_at) - async with db.begin(): - db.add_all([ - quiet, - GpuNode(id=ids.new("node"), cluster_id="clu_blip", hostname="n3", status="ready", - last_seen_at=now - timedelta(seconds=settings.NODE_STALE_SEC - 30)), - ]) + await seed(db, [ + quiet, + GpuNode(id=ids.new("node"), cluster_id="clu_blip", hostname="n3", status="ready", + last_seen_at=now - timedelta(seconds=settings.NODE_STALE_SEC - 30)), + ]) await session_liveness.run() assert _Svc.calls == [] diff --git a/backend/tests/test_session_monitor_scope.py b/backend/tests/test_session_monitor_scope.py index a7b4900..24d4868 100644 --- a/backend/tests/test_session_monitor_scope.py +++ b/backend/tests/test_session_monitor_scope.py @@ -16,6 +16,7 @@ from app.core.errors import Forbidden from app.db.models import Membership, Organization, Project from app.db.models import Session as SessionRow +from tests.fkseed import seed pytestmark = pytest.mark.asyncio @@ -34,17 +35,16 @@ async def world(db): g_a, g_b = ids.new("group"), ids.new("group") gadmin_a, member_a, member_b, oadmin_a, oadmin_b = (ids.new("user") for _ in range(5)) s_a, s_b = _session(member_a, "a-run"), _session(member_b, "b-run") - async with db.begin(): - db.add_all([ - Organization(id=org_a, name="A"), Organization(id=org_b, name="B"), - Project(id=g_a, org_id=org_a, name="a"), Project(id=g_b, org_id=org_b, name="b"), - Membership(id=ids.new("membership"), user_id=gadmin_a, group_id=g_a, role="group_admin"), - Membership(id=ids.new("membership"), user_id=member_a, group_id=g_a, role="member"), - Membership(id=ids.new("membership"), user_id=member_b, group_id=g_b, role="member"), - Membership(id=ids.new("membership"), user_id=oadmin_a, group_id=g_a, role="member"), - Membership(id=ids.new("membership"), user_id=oadmin_b, group_id=g_b, role="member"), - s_a, s_b, - ]) + await seed(db, [ + Organization(id=org_a, name="A"), Organization(id=org_b, name="B"), + Project(id=g_a, org_id=org_a, name="a"), Project(id=g_b, org_id=org_b, name="b"), + Membership(id=ids.new("membership"), user_id=gadmin_a, group_id=g_a, role="group_admin"), + Membership(id=ids.new("membership"), user_id=member_a, group_id=g_a, role="member"), + Membership(id=ids.new("membership"), user_id=member_b, group_id=g_b, role="member"), + Membership(id=ids.new("membership"), user_id=oadmin_a, group_id=g_a, role="member"), + Membership(id=ids.new("membership"), user_id=oadmin_b, group_id=g_b, role="member"), + s_a, s_b, + ]) return { "org_a": org_a, "org_b": org_b, "g_a": g_a, "g_b": g_b, "gadmin_a": gadmin_a, "oadmin_a": oadmin_a, "oadmin_b": oadmin_b, "member_a": member_a, diff --git a/backend/tests/test_session_queued_reason_detail.py b/backend/tests/test_session_queued_reason_detail.py index 56a2b0a..d617150 100644 --- a/backend/tests/test_session_queued_reason_detail.py +++ b/backend/tests/test_session_queued_reason_detail.py @@ -14,6 +14,7 @@ from app.core import ids from app.db.models import Session as SessionModel from app.db.models import SessionEvent +from tests.fkseed import seed @pytest.mark.asyncio @@ -21,11 +22,8 @@ async def test_detail_reports_the_same_queued_reason_as_the_list(db): sess = SessionModel(id=ids.new("session"), owner_user_id="usr_q", cluster_id="clu_a", offering_id="off_1", image_id="img_1", resource_class="gpu", mode="fractional", status="pending") - async with db.begin(): - db.add(sess) - await db.flush() - db.add(SessionEvent(id=ids.new("session_event"), session_id=sess.id, kind="queued", - reason="no_vram")) + await seed(db, [sess, SessionEvent(id=ids.new("session_event"), session_id=sess.id, + kind="queued", reason="no_vram")]) p = Principal(user_id="usr_q") detail = await get_session(sess.id, p, db) assert detail.queued_reason == "no_vram" diff --git a/backend/tests/test_session_restart_state.py b/backend/tests/test_session_restart_state.py index e820457..d064590 100644 --- a/backend/tests/test_session_restart_state.py +++ b/backend/tests/test_session_restart_state.py @@ -8,6 +8,7 @@ from app.core.errors import InvalidStateTransition from app.db.models import Session as SessionModel from app.domain.session_service import SessionService +from tests.fkseed import seed @pytest.mark.parametrize("status", ["terminated", "pending", "preparing", "error", "terminating"]) @@ -16,7 +17,6 @@ async def test_restart_refuses_non_live_states(db, status): sess = SessionModel(id=ids.new("session"), owner_user_id="usr_1", cluster_id="clu_a", offering_id="off_1", image_id="img_1", resource_class="gpu", mode="fractional", status=status) - async with db.begin(): - db.add(sess) + await seed(db, [sess]) with pytest.raises(InvalidStateTransition): await SessionService(db).restart(sess.id) diff --git a/backend/tests/test_signup.py b/backend/tests/test_signup.py index 75c3eee..efeb070 100644 --- a/backend/tests/test_signup.py +++ b/backend/tests/test_signup.py @@ -16,6 +16,7 @@ from app.core import ids from app.core.errors import DomainError, Forbidden from app.db.models import Organization, Project, User +from tests.fkseed import seed class _Req: @@ -30,12 +31,11 @@ def _super() -> Principal: async def _group(db) -> Project: org = Organization(id=ids.new("org"), name="DKU") grp = Project(id=ids.new("project"), org_id=org.id, name="CE") - async with db.begin(): - db.add_all([org, grp]) + await seed(db, [org, grp]) return grp -def _body(email: str = "s1@dankook.ac.kr") -> _SignupRequest: +def _body(email: str = "s1@example.com") -> _SignupRequest: return _SignupRequest(email=email, name="Student", password="choose-my-own") @@ -50,7 +50,7 @@ async def test_closed_is_the_default_and_refuses(db): async def test_open_mode_creates_an_active_account_with_no_department(db): await set_signup_policy(SignupPolicyUpdate(mode="open"), _super(), db) assert (await auth_signup(_body(), _Req(), db))["status"] == "active" - tok = await auth_login(_LoginRequest(email="s1@dankook.ac.kr", password="choose-my-own"), + tok = await auth_login(_LoginRequest(email="s1@example.com", password="choose-my-own"), _Req(), db) assert tok["access_token"] @@ -58,7 +58,7 @@ async def test_open_mode_creates_an_active_account_with_no_department(db): from sqlalchemy import select as _select from app.db.models import Membership - user = (await db.execute(_select(User).where(User.email == "s1@dankook.ac.kr"))).scalar_one() + user = (await db.execute(_select(User).where(User.email == "s1@example.com"))).scalar_one() memberships = await db.scalar( _select(func.count()).select_from(Membership).where(Membership.user_id == user.id) ) @@ -72,20 +72,20 @@ async def test_approval_mode_parks_the_account_until_an_admin_opens_it(db): assert (await auth_signup(_body(), _Req(), db))["status"] == "pending" with pytest.raises(DomainError) as exc: - await auth_login(_LoginRequest(email="s1@dankook.ac.kr", password="choose-my-own"), + await auth_login(_LoginRequest(email="s1@example.com", password="choose-my-own"), _Req(), db) assert exc.value.code == "account_pending" # Approval assigns the department and activates in one step. from sqlalchemy import select - row = (await db.execute(select(User).where(User.email == "s1@dankook.ac.kr"))).scalar_one() + row = (await db.execute(select(User).where(User.email == "s1@example.com"))).scalar_one() out = await approve_user(row.id, UserApproveBody(group_id=grp.id), _super(), db) assert out["status"] == "active" from app.db.models import Membership m = (await db.execute(select(Membership).where(Membership.user_id == row.id))).scalar_one() assert m.group_id == grp.id and m.role == "member" - assert (await auth_login(_LoginRequest(email="s1@dankook.ac.kr", password="choose-my-own"), + assert (await auth_login(_LoginRequest(email="s1@example.com", password="choose-my-own"), _Req(), db))["access_token"] @@ -94,7 +94,7 @@ async def test_approval_without_a_department_is_allowed(db): await set_signup_policy(SignupPolicyUpdate(mode="approval"), _super(), db) await auth_signup(_body(), _Req(), db) from sqlalchemy import select - row = (await db.execute(select(User).where(User.email == "s1@dankook.ac.kr"))).scalar_one() + row = (await db.execute(select(User).where(User.email == "s1@example.com"))).scalar_one() assert (await approve_user(row.id, UserApproveBody(), _super(), db))["status"] == "active" @@ -103,7 +103,7 @@ async def test_an_account_that_is_not_pending_cannot_be_approved(db): await set_signup_policy(SignupPolicyUpdate(mode="open"), _super(), db) await auth_signup(_body(), _Req(), db) from sqlalchemy import select - row = (await db.execute(select(User).where(User.email == "s1@dankook.ac.kr"))).scalar_one() + row = (await db.execute(select(User).where(User.email == "s1@example.com"))).scalar_one() with pytest.raises(DomainError): await approve_user(row.id, UserApproveBody(), _super(), db) @@ -111,7 +111,7 @@ async def test_an_account_that_is_not_pending_cannot_be_approved(db): @pytest.mark.asyncio async def test_only_the_allowed_domains_may_register(db): await set_signup_policy( - SignupPolicyUpdate(mode="open", allowed_domains=["dankook.ac.kr"]), _super(), db) + SignupPolicyUpdate(mode="open", allowed_domains=["example.com"]), _super(), db) with pytest.raises(DomainError): await auth_signup(_body("outsider@gmail.com"), _Req(), db) assert (await auth_signup(_body(), _Req(), db))["status"] == "active" diff --git a/backend/tests/test_status_heartbeat_recovery.py b/backend/tests/test_status_heartbeat_recovery.py index 4a527c9..05d42f0 100644 --- a/backend/tests/test_status_heartbeat_recovery.py +++ b/backend/tests/test_status_heartbeat_recovery.py @@ -16,14 +16,14 @@ from app.cluster.status_sync import StatusSync from app.core import ids from app.db.models import Session as SessionModel +from tests.fkseed import seed async def _session(db, status: str) -> SessionModel: sess = SessionModel(id=ids.new("session"), owner_user_id="usr_1", cluster_id="clu_a", offering_id="off_1", image_id="img_1", resource_class="cpu", status=status) - async with db.begin(): - db.add(sess) + await seed(db, [sess]) return sess diff --git a/backend/tests/test_status_reason.py b/backend/tests/test_status_reason.py index cc0cee4..dec23e3 100644 --- a/backend/tests/test_status_reason.py +++ b/backend/tests/test_status_reason.py @@ -12,6 +12,7 @@ from app.core import ids from app.db.models import Allocation, GpuDevice, Image, Notification, Offering from app.db.models import Session as SessionRow +from tests.fkseed import seed def test_operator_message_mapping(): @@ -33,8 +34,7 @@ async def _running_session(db): offering_id=offering.id, image_id=image.id, resource_class="gpu", mode="fractional", status="running", gpu_mem_mb=4000, gpu_cores=25, ) - async with db.begin(): - db.add_all([offering, image, sess]) + await seed(db, [offering, image, sess]) return sess @@ -64,8 +64,8 @@ async def _card_with_live_alloc(db, sess, *, mem=6144, cores=13, used_mem=24576, id=ids.new("allocation"), session_id=sess.id, device_id=dev.id, gpu_uuid=dev.gpu_uuid, gpu_mem_mb=mem, gpu_cores=cores, status="bound", kind="resident", ) + await seed(db, [dev, alloc]) async with db.begin(): - db.add_all([dev, alloc]) sess = await db.get(SessionRow, sess.id) sess.bound_gpu_uuid = dev.gpu_uuid return dev, alloc diff --git a/backend/tests/test_storage_pool_bound.py b/backend/tests/test_storage_pool_bound.py index 30ae1f8..796cc47 100644 --- a/backend/tests/test_storage_pool_bound.py +++ b/backend/tests/test_storage_pool_bound.py @@ -14,15 +14,15 @@ from app.core import ids from app.core.config import settings from app.db.models import GpuNode, StorageVolume +from tests.fkseed import seed async def _servers(db, *disks: int) -> None: - async with db.begin(): - db.add_all([ - GpuNode(id=ids.new("node"), cluster_id="clu_a", hostname=f"store{i}", status="ready", - role="storage", disk=d) - for i, d in enumerate(disks) - ]) + await seed(db, [ + GpuNode(id=ids.new("node"), cluster_id="clu_a", hostname=f"store{i}", status="ready", + role="storage", disk=d) + for i, d in enumerate(disks) + ]) @pytest.mark.asyncio @@ -58,12 +58,11 @@ async def test_no_storage_server_means_no_gate(db): @pytest.mark.asyncio async def test_allocation_counts_every_live_volume(db): await _servers(db, 2000) - async with db.begin(): - db.add_all([ - StorageVolume(id=ids.new("volume"), scope="user", scope_id="usr_1", type="home", - access_mode="RWO", quota_gb=40, used_gb=0), - StorageVolume(id=ids.new("volume"), scope="user", scope_id="usr_2", type="home", - access_mode="RWO", quota_gb=60, used_gb=0), - ]) + await seed(db, [ + StorageVolume(id=ids.new("volume"), scope="user", scope_id="usr_1", type="home", + access_mode="RWO", quota_gb=40, used_gb=0), + StorageVolume(id=ids.new("volume"), scope="user", scope_id="usr_2", type="home", + access_mode="RWO", quota_gb=60, used_gb=0), + ]) _, allocated = await _physical_storage(db) assert allocated == 100 diff --git a/backend/tests/test_storage_pools.py b/backend/tests/test_storage_pools.py index 2ceec08..f4291e8 100644 --- a/backend/tests/test_storage_pools.py +++ b/backend/tests/test_storage_pools.py @@ -17,12 +17,14 @@ from app.core.config import settings from app.db.models import Cluster, GpuNode, StoragePool, StoragePoolShare from app.domain.storage_pools import GB, pool_bound_gb, pool_capacity_gb, usable_pools +from tests.fkseed import seed async def _cluster(db, cid: str) -> None: - async with db.begin(): - db.add(Cluster(id=cid, name=cid, role="primary", api_server="https://x", runtime="containerd", - status="connected", kubeconfig_secret_ref="")) + await seed(db, [ + Cluster(id=cid, name=cid, role="primary", api_server="https://x", runtime="containerd", + status="connected", kubeconfig_secret_ref=""), + ]) async def _pool(db, *, cluster_id: str, name: str, scope: str = "all", @@ -32,8 +34,7 @@ async def _pool(db, *, cluster_id: str, name: str, scope: str = "all", storage_class=storage_class, share_scope=scope, capacity_bytes=capacity_bytes, manual_capacity_gb=manual_gb, capacity_source="csi" if capacity_bytes else ("manual" if manual_gb else None)) - async with db.begin(): - db.add(p) + await seed(db, [p]) return p @@ -48,8 +49,9 @@ async def test_a_pool_serves_its_own_cluster_and_shared_ones(db): assert {p.id for p in await usable_pools(db, "clu_b")} == {everyone.id} await db.commit() # close the read transaction before the write below opens its own # Sharing A's restricted pool with B explicitly brings it into B's view. - async with db.begin(): - db.add(StoragePoolShare(id=ids.new("storage_pool_share"), pool_id=own.id, cluster_id="clu_b")) + await seed(db, [ + StoragePoolShare(id=ids.new("storage_pool_share"), pool_id=own.id, cluster_id="clu_b"), + ]) assert {p.id for p in await usable_pools(db, "clu_b")} == {own.id, everyone.id} @@ -74,9 +76,10 @@ async def test_a_measurement_outranks_a_typed_in_figure(db): @pytest.mark.asyncio async def test_the_configured_figure_beats_the_node_disk(db, monkeypatch): """Both are stand-ins, but one is a statement about the pool and the other is a system drive.""" - async with db.begin(): - db.add(GpuNode(id=ids.new("node"), cluster_id="clu_a", hostname="store", status="ready", - role="storage", disk=2062)) + await seed(db, [ + GpuNode(id=ids.new("node"), cluster_id="clu_a", hostname="store", status="ready", + role="storage", disk=2062), + ]) monkeypatch.setattr(settings, "STORAGE_POOL_CAPACITY_GB", 1424) assert await pool_bound_gb(db) == (1424, "manual") @@ -152,3 +155,133 @@ async def test_deregistering_a_cluster_retires_its_pools_and_shares(db): # Its share of someone else's pool goes too, and the fleet bound follows. assert {p.id for p in await usable_pools(db, "clu_stays")} == {living_id} assert (await pool_bound_gb(db))[0] == 500 + + +# ── the volume list names the pool a volume sits on ────────────────────────────────────── + +@pytest.mark.asyncio +async def test_volume_list_names_the_pool_its_data_lives_on(db): + from app.api.deps import Pagination + from app.api.volumes_router import list_volumes + from app.auth.rbac import Principal + from app.db.models import StorageVolume + await _cluster(db, "clu_p") + pool = await _pool(db, cluster_id="clu_p", name="nas-01 (ZFS tank)", storage_class="gshare-data") + placed = StorageVolume(id=ids.new("volume"), scope="user", scope_id="u_1", type="home", name="home", + access_mode="RWX", quota_gb=5, used_gb=0, cluster_id="clu_p", storage_class="gshare-data") + fresh = StorageVolume(id=ids.new("volume"), scope="user", scope_id="u_1", type="home", name="new", + access_mode="RWX", quota_gb=5, used_gb=0) + await seed(db, [placed, fresh]) + rows = await list_volumes(scope=None, scope_id=None, type=None, access_mode=None, all_scopes=True, + page=Pagination(1, 50), principal=Principal(user_id="root", global_roles={"super_admin"}), db=db) + by = {r.id: r for r in rows} + assert (by[placed.id].pool_id, by[placed.id].pool_name, by[placed.id].cluster_name) == (pool.id, "nas-01 (ZFS tank)", "clu_p") + assert by[fresh.id].pool_name is None and by[fresh.id].cluster_id is None + + +# ── a pool is known by its server's hostname ───────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_a_pool_with_a_node_is_named_after_the_hostname(db): + from app.api.storage_pools_router import PoolCreate, PoolPatch, create_pool, update_pool + from app.auth.rbac import Principal + from app.db.models import GpuNode + await _cluster(db, "clu_n") + node = GpuNode(id=ids.new("node"), cluster_id="clu_n", hostname="nas-01", status="ready") + await seed(db, [node]) + root = Principal(user_id="root", global_roles={"super_admin"}) + made = await create_pool(PoolCreate(name="nickname", cluster_id="clu_n", storage_class="gshare-data", node_id=node.id), + principal=root, db=db) + assert made["name"] == "nas-01" + await db.commit() # the handler's read-back leaves an autobegun transaction; close it like a request would + # a typed name never overrides the hostname while the node is linked + edited = await update_pool(made["id"], PoolPatch(name="other"), principal=root, db=db) + assert edited["name"] == "nas-01" + + +@pytest.mark.asyncio +async def test_a_pool_without_a_node_needs_a_typed_name(db): + from app.api.storage_pools_router import PoolCreate, create_pool + from app.auth.rbac import Principal + from app.core.errors import DomainError + await _cluster(db, "clu_x") + root = Principal(user_id="root", global_roles={"super_admin"}) + with pytest.raises(DomainError): + await create_pool(PoolCreate(cluster_id="clu_x", storage_class="gshare-data"), principal=root, db=db) + await db.rollback() + made = await create_pool(PoolCreate(name="appliance-1", cluster_id="clu_x", storage_class="gshare-data"), principal=root, db=db) + assert made["name"] == "appliance-1" + + +# ── the dashboard tile: fleet total on top, each pool's own allocation below ───────────── + +@pytest.mark.asyncio +async def test_dashboard_storage_reports_each_pools_allocation_and_the_fleet_total(db): + from app.api.infra_router import metrics_cluster + from app.auth.rbac import Principal + from app.db.models import StorageVolume + await _cluster(db, "clu_m") + a = await _pool(db, cluster_id="clu_m", name="nas-a", storage_class="gshare-data", manual_gb=1000) + b = await _pool(db, cluster_id="clu_m", name="nas-b", storage_class="gshare-nfs", manual_gb=3000) + async with db.begin(): + db.add_all([ + StorageVolume(id=ids.new("volume"), scope="user", scope_id="u", type="home", name="v1", access_mode="RWX", + quota_gb=100, used_gb=0, cluster_id="clu_m", storage_class="gshare-data"), + StorageVolume(id=ids.new("volume"), scope="user", scope_id="u", type="home", name="v2", access_mode="RWX", + quota_gb=50, used_gb=0, cluster_id="clu_m", storage_class="gshare-data"), + StorageVolume(id=ids.new("volume"), scope="user", scope_id="u", type="home", name="v3", access_mode="RWX", + quota_gb=700, used_gb=0, cluster_id="clu_m", storage_class="gshare-nfs"), + # PVC not created yet: on no pool, but still part of the fleet's provisioned quota. + StorageVolume(id=ids.new("volume"), scope="user", scope_id="u", type="home", name="v4", access_mode="RWX", + quota_gb=20, used_gb=0), + ]) + m = await metrics_cluster(region=None, cluster_id="clu_m", + principal=Principal(user_id="root", global_roles={"super_admin"}), db=db) + st = m["storage"] + used = {p["name"]: p["used_gb"] for p in st["pools"]} + assert used == {"nas-a": 150, "nas-b": 700} + assert st["capacity_gb"] == 4000 # the header meter: every pool added up + assert st["disk_gb"]["total"] == 3000 # the placement bound stays the largest pool + assert st["disk_gb"]["used"] == 870 and st["unplaced_gb"] == 20 + assert {p["name"]: p["capacity_gb"] for p in st["pools"]} == {"nas-a": 1000, "nas-b": 3000} + assert a.id != b.id + + +# ── a deregistered class can be registered again ───────────────────────────────────────── + +@pytest.mark.asyncio +async def test_a_class_can_be_registered_again_after_its_pool_was_removed(db): + from app.api.storage_pools_router import ( + PoolCreate, + PoolPatch, + create_pool, + delete_pool, + update_pool, + ) + from app.auth.rbac import Principal + from app.core.errors import AlreadyExists + await _cluster(db, "clu_r") + root = Principal(user_id="root", global_roles={"super_admin"}) + first = await create_pool(PoolCreate(name="nas-02", cluster_id="clu_r", storage_class="gshare-nfs", manual_capacity_gb=2000), + principal=root, db=db) + await db.commit() + await delete_pool(first["id"], principal=root, db=db) + await db.commit() + # the same key again: before, the soft-deleted row still held the unique constraint → 500 + again = await create_pool(PoolCreate(name="nas-02", cluster_id="clu_r", storage_class="gshare-nfs", manual_capacity_gb=2500), + principal=root, db=db) + await db.commit() + assert again["id"] != first["id"] and again["capacity_gb"] == 2500 + live = (await db.scalars(select(StoragePool).where(StoragePool.cluster_id == "clu_r"))).all() + assert [p.id for p in live] == [again["id"]] # the retired row is gone, not kept beside it + await db.commit() + # moving another pool onto a live class is still refused, and onto a retired one succeeds + other = await create_pool(PoolCreate(name="nas-03", cluster_id="clu_r", storage_class="gshare-data"), principal=root, db=db) + await db.commit() + with pytest.raises(AlreadyExists): + await update_pool(other["id"], PoolPatch(storage_class="gshare-nfs"), principal=root, db=db) + await db.rollback() + await delete_pool(again["id"], principal=root, db=db) + await db.commit() + moved = await update_pool(other["id"], PoolPatch(storage_class="gshare-nfs"), principal=root, db=db) + assert moved["storage_class"] == "gshare-nfs" diff --git a/backend/tests/test_tenant_isolation.py b/backend/tests/test_tenant_isolation.py index d62cacc..561526d 100644 --- a/backend/tests/test_tenant_isolation.py +++ b/backend/tests/test_tenant_isolation.py @@ -11,6 +11,7 @@ from decimal import Decimal import pytest +from sqlalchemy import select from app.api.budgets_router import list_budgets from app.api.deps import Pagination @@ -19,6 +20,7 @@ from app.auth.rbac import Principal from app.core.errors import Forbidden from app.db.models import Budget, CreditWallet, ResourcePolicy +from tests.fkseed import add_ordered, seed pytestmark = pytest.mark.asyncio @@ -38,7 +40,7 @@ def _super() -> Principal: # --------------------------------------------------------------------------- policies async def _seed_policies(db) -> None: - db.add_all([ + await seed(db, [ ResourcePolicy(id="pol_orgA", scope="org", scope_id="org_A", max_concurrent=1, limits={}), ResourcePolicy(id="pol_orgB", scope="org", scope_id="org_B", max_concurrent=1, limits={}), ResourcePolicy(id="pol_grpA", scope="group", scope_id="grp_A", max_concurrent=1, limits={}), @@ -185,7 +187,7 @@ async def test_dedicated_pool_cards_invisible_to_other_org_in_placement(db): cluster_id = "clu_pool" pool = NodePool(id="npl_A", cluster_id=cluster_id, name="A-only", kind="dedicated") - db.add_all([ + await add_ordered(db, [ Project(id="grp_A2", org_id="org_A", name="A2"), Project(id="grp_B2", org_id="org_B", name="B2"), pool, @@ -194,7 +196,6 @@ async def test_dedicated_pool_cards_invisible_to_other_org_in_placement(db): GpuDevice(id="dev_A", node_id="nod_A", cluster_id=cluster_id, model="A100", gpu_uuid="GPU-A", total_mem_mb=16000, status="ready", mode="fractional"), ]) - await db.flush() access_b = await resolve_pool_access(db, cluster_id=cluster_id, user_id="u_b", group_id="grp_B2") assert pool.id not in access_b.allowed() assert access_b.allowed() == {None} @@ -251,3 +252,330 @@ async def test_policy_shared_pool_limit_round_trips(db): created["id"], PolicyUpdate(limits={"shared_pool": False}), principal=_super(), db=db ) assert upd["limits"]["shared_pool"] is False + + +# --------------------------------------------------------------------------- queue (beta: GS-B01/02) +# +# ``queue.read``/``queue.update`` are rank checks ("are you a group_admin anywhere"); the queue +# handlers must still narrow to the sessions whose OWNER the caller manages, and a session created +# without a group must not fall through the per-group check as if nobody owned it. + +def _session_row(owner: str, group_id: str | None = None): + from app.core import ids + from app.db.models import Session as SessionRow + + return SessionRow( + id=ids.new("session"), owner_user_id=owner, group_id=group_id, cluster_id="clu_t", + offering_id="off_t", image_id="img_t", resource_class="gpu", mode="fractional", + status="pending", gpu_mem_mb=1024, gpu_cores=10, + ) + + +async def _seed_queue_world(db): + from app.core import ids + from app.db.models import Membership, Organization, Project, QueueEntry, User + + org_a, org_b = "org_QA", "org_QB" + grp_a, grp_b = "grp_QA", "grp_QB" + user_a, user_b = "u_qa", "u_qb" + s_a = _session_row(user_a, grp_a) + s_b_grouped = _session_row(user_b, grp_b) + s_b_ungrouped = _session_row(user_b, None) # the API accepts a session without a group + # committed, not just flushed: the monitor-SSE test reads this world through a second + # session, which on Postgres is a different connection. + await seed(db, [ + Organization(id=org_a, name="A"), Organization(id=org_b, name="B"), + Project(id=grp_a, org_id=org_a, name="a"), Project(id=grp_b, org_id=org_b, name="b"), + User(id=user_a, email="qa@x", name="qa"), User(id=user_b, email="qb@x", name="qb"), + Membership(id=ids.new("membership"), user_id=user_a, group_id=grp_a, role="member"), + Membership(id=ids.new("membership"), user_id=user_b, group_id=grp_b, role="member"), + s_a, s_b_grouped, s_b_ungrouped, + ]) + entries = {} + for s in (s_a, s_b_grouped, s_b_ungrouped): + e = QueueEntry(id=ids.new("queue"), session_id=s.id, session_req={"group_id": s.group_id}) + entries[s.id] = e + await seed(db, list(entries.values())) + return {"grp_a": grp_a, "grp_b": grp_b, "org_a": org_a, "s_a": s_a, + "s_b": s_b_grouped, "s_b_ungrouped": s_b_ungrouped, "entries": entries} + + +async def test_list_queue_group_admin_sees_only_managed_sessions(db): + from app.api.queue_router import list_queue + + w = await _seed_queue_world(db) + res = await list_queue(page=Pagination(1, 50), group_id=None, cluster_id=None, + principal=_group_admin(w["grp_a"]), db=db) + sids = {r["session_id"] for r in res["data"]} + assert sids == {w["s_a"].id}, sids + assert res["pagination"]["total"] == 1 + + +async def test_list_queue_org_admin_sees_only_own_org(db): + from app.api.queue_router import list_queue + + w = await _seed_queue_world(db) + p = Principal(user_id="u_oa", org_admin_orgs={w["org_a"]}, memberships={w["grp_a"]: "org_admin"}) + res = await list_queue(page=Pagination(1, 50), group_id=None, cluster_id=None, principal=p, db=db) + assert {r["session_id"] for r in res["data"]} == {w["s_a"].id} + + +async def test_list_queue_super_sees_all(db): + from app.api.queue_router import list_queue + + await _seed_queue_world(db) + res = await list_queue(page=Pagination(1, 50), group_id=None, cluster_id=None, + principal=_super(), db=db) + assert len(res["data"]) == 3 + + +async def test_cancel_queue_entry_cross_tenant_denied(db): + from app.api.queue_router import cancel_queue_entry + + w = await _seed_queue_world(db) + ga = _group_admin(w["grp_a"]) + # Grouped session of the other tenant: the per-group check already refuses it. + with pytest.raises(Forbidden): + await cancel_queue_entry(w["entries"][w["s_b"].id].id, principal=ga, db=db) + # Ungrouped session of the other tenant: previously slipped through (group_id=None check). + with pytest.raises(Forbidden): + await cancel_queue_entry(w["entries"][w["s_b_ungrouped"].id].id, principal=ga, db=db) + assert w["s_b_ungrouped"].status == "pending" + + +async def test_update_priority_cross_tenant_denied(db): + from app.api.queue_router import QueuePriorityPatch, update_priority + + w = await _seed_queue_world(db) + ga = _group_admin(w["grp_a"]) + with pytest.raises(Forbidden): + await update_priority(w["entries"][w["s_b_ungrouped"].id].id, QueuePriorityPatch(priority=5), + principal=ga, db=db) + assert w["entries"][w["s_b_ungrouped"].id].priority == 0 + # The administrator's own tenant still works. + out = await update_priority(w["entries"][w["s_a"].id].id, QueuePriorityPatch(priority=5), + principal=ga, db=db) + assert out["priority"] == 5 + + +# --------------------------------------------------------------------------- memberships (beta: GS-B04) +# +# Adding a user to a group hands the group's administrators authority over that user (password +# reset, suspension, soft delete, wallet grants). A non-super administrator must therefore only be +# able to enroll people who are already in their tenant, or who belong to no tenant yet. + +async def _seed_membership_world(db): + from app.core import ids + from app.db.models import Membership, Organization, Project, User + + await add_ordered(db, [ + Organization(id="org_MA", name="A"), Organization(id="org_MB", name="B"), + Project(id="grp_MA", org_id="org_MA", name="a"), Project(id="grp_MB", org_id="org_MB", name="b"), + User(id="u_victim_b", email="vb@x", name="vb"), + User(id="u_fresh", email="fresh@x", name="fresh"), + Membership(id=ids.new("membership"), user_id="u_victim_b", group_id="grp_MB", role="member"), + ]) + + +async def test_add_membership_cannot_pull_in_foreign_org_user(db): + from app.api.groups_router import MembershipCreate, add_membership + + await _seed_membership_world(db) + ga = _group_admin("grp_MA") + with pytest.raises(Forbidden): + await add_membership("grp_MA", MembershipCreate(user_id="u_victim_b", role="member"), + principal=ga, db=db) + # An unaffiliated user can still be enrolled by a group_admin. + out = await add_membership("grp_MA", MembershipCreate(user_id="u_fresh", role="member"), + principal=ga, db=db) + assert out["user_id"] == "u_fresh" + + +async def test_set_department_cannot_pull_in_foreign_org_user(db): + from app.api.users_router import UserDepartmentSet, set_user_department + from app.db.models import Membership + + await _seed_membership_world(db) + oa = Principal(user_id="u_oa", org_admin_orgs={"org_MA"}, memberships={"grp_MA": "org_admin"}) + with pytest.raises(Forbidden): + await set_user_department("u_victim_b", UserDepartmentSet(group_id="grp_MA"), principal=oa, db=db) + rows = (await db.scalars(select(Membership.group_id).where(Membership.user_id == "u_victim_b"))).all() + assert list(rows) == ["grp_MB"] + # A super_admin may move anyone; an unaffiliated user may be placed by the org_admin. + out = await set_user_department("u_fresh", UserDepartmentSet(group_id="grp_MA"), principal=oa, db=db) + assert out["department_changed"] is True + + +# --------------------------------------------------------------------------- images (beta: GS-B05) + +async def _seed_images(db): + from app.db.models import Image + + db.add_all([ + Image(id="img_pub", name="pub", registry="r/pub", kind="container", public=True), + Image(id="img_mine", name="mine", registry="r/mine", kind="container", public=False, + owner_user_id="u_me"), + Image(id="img_other", name="other", registry="r/other-secret", kind="container", public=False, + owner_user_id="u_other"), + Image(id="img_admin_private", name="retired", registry="r/retired", kind="container", + public=False), + ]) + await db.flush() + + +async def test_list_images_member_never_sees_other_users_private_images(db): + from app.api.images_router import list_images + + await _seed_images(db) + me = Principal(user_id="u_me", memberships={"grp_X": "member"}) + for public in (None, False, True): + res = await list_images(pagination=Pagination(1, 50), kind=None, q=None, tag=None, + public=public, mine=False, principal=me, db=db) + ids_ = {r["id"] for r in res["data"]} + assert "img_other" not in ids_, (public, ids_) + assert "img_admin_private" not in ids_, (public, ids_) + res = await list_images(pagination=Pagination(1, 50), kind=None, q=None, tag=None, + public=None, mine=False, principal=me, db=db) + assert {r["id"] for r in res["data"]} == {"img_pub", "img_mine"} + # super_admin catalogue view is unchanged. + res = await list_images(pagination=Pagination(1, 50), kind=None, q=None, tag=None, + public=None, mine=False, principal=_super(), db=db) + assert len(res["data"]) == 4 + + +async def test_get_image_other_users_private_image_denied(db): + from app.api.images_router import get_image + from app.core.errors import NotFound + + await _seed_images(db) + me = Principal(user_id="u_me", memberships={"grp_X": "member"}) + assert (await get_image("img_mine", principal=me, db=db))["id"] == "img_mine" + assert (await get_image("img_pub", principal=me, db=db))["id"] == "img_pub" + with pytest.raises((Forbidden, NotFound)): + await get_image("img_other", principal=me, db=db) + with pytest.raises((Forbidden, NotFound)): + await get_image("img_admin_private", principal=me, db=db) + assert (await get_image("img_other", principal=_super(), db=db))["id"] == "img_other" + + +# --------------------------------------------------------------------------- storage quota (beta: GS-B06) + +async def test_storage_quota_usage_cross_scope_denied(db): + from app.api.volumes_router import storage_quota_usage + from app.db.models import StorageVolume + + db.add_all([ + ResourcePolicy(id="pol_u_other", scope="user", scope_id="u_other", max_concurrent=1, + limits={"volume_gb": 500}), + StorageVolume(id="vol_other", scope="user", scope_id="u_other", type="workspace", + name="w", access_mode="RWX", owner_id="u_other", quota_gb=123, used_gb=0), + ]) + await db.flush() + me = Principal(user_id="u_me", memberships={"grp_A": "member"}) + # Own scope and a group the caller belongs to are fine. + assert "allocated_gb" in await storage_quota_usage(scope="user", scope_id="u_me", principal=me, db=db) + assert "allocated_gb" in await storage_quota_usage(scope="group", scope_id="grp_A", principal=me, db=db) + # Another user's / another group's quota and usage are not. + with pytest.raises(Forbidden): + await storage_quota_usage(scope="user", scope_id="u_other", principal=me, db=db) + with pytest.raises(Forbidden): + await storage_quota_usage(scope="group", scope_id="grp_B", principal=me, db=db) + # super_admin is unrestricted. + out = await storage_quota_usage(scope="user", scope_id="u_other", principal=_super(), db=db) + assert out["allocated_gb"] == 123 + + +# --------------------------------------------------------------------------- monitor SSE (beta: GS-B03) +# +# The admin monitor stream subscribes to session:events:* for the whole fleet; every event has to +# pass the same owner predicate as the monitoring list before it reaches a non-super administrator. + +class _FakeRequest: + def __init__(self, disconnect_after: int = 3): + self._n = disconnect_after + + async def is_disconnected(self) -> bool: + self._n -= 1 + return self._n < 0 + + +async def test_monitor_event_filter_hides_other_tenants_sessions(db, monkeypatch): + import json + + from sqlalchemy.ext.asyncio import async_sessionmaker + + from app.api import sessions_router as sr + + w = await _seed_queue_world(db) + monkeypatch.setattr(sr, "get_sessionmaker", lambda: async_sessionmaker(db.bind, expire_on_commit=False)) + + # super_admin: no gate at all. + assert sr._monitor_event_filter(_super()) is None + + allow = sr._monitor_event_filter(_group_admin(w["grp_a"])) + assert allow is not None + assert await allow({"channel": f"session:events:{w['s_a'].id}", "data": "{}"}) is True + assert await allow({"channel": f"session:events:{w['s_b'].id}", "data": "{}"}) is False + assert await allow({"channel": f"session:events:{w['s_b_ungrouped'].id}", "data": "{}"}) is False + assert await allow({"channel": "queue:events", "data": json.dumps({"kind": "enqueued", "session_id": w["s_a"].id})}) is True + assert await allow({"channel": "queue:events", "data": json.dumps({"kind": "enqueued", "session_id": w["s_b"].id})}) is False + assert await allow({"channel": "queue:events", "data": "not json"}) is False + + +async def test_sse_events_drops_events_the_gate_refuses(fake_redis, monkeypatch): + from app.api import sessions_router as sr + + monkeypatch.setattr(sr, "_HEARTBEAT_SEC", 0.05) + seen: list[str] = [] + + async def allow(msg: dict) -> bool: + seen.append(msg["channel"]) + return msg["channel"].endswith(":ok") + + gen = sr._sse_events(["queue:events"], ["session:events:*"], _FakeRequest(disconnect_after=6), allow=allow) + first = await anext(gen) # subscribes, then heartbeats + assert first["event"] == "heartbeat" + await fake_redis.publish("session:events:ok", '{"phase":"running"}') + await fake_redis.publish("session:events:hidden", '{"phase":"running"}') + await fake_redis.publish("queue:events", '{"kind":"enqueued","session_id":"x"}') + got = [] + async for ev in gen: + if ev["event"] != "heartbeat": + got.append(ev["data"]) + assert got == ['{"phase":"running"}'] + assert set(seen) == {"session:events:ok", "session:events:hidden", "queue:events"} + + +# --------------------------------------------------------------------------- lead follow-ups +async def test_resource_request_cannot_name_a_group_the_caller_is_not_in(db): + from app.api.policies_router import ResourceRequestCreate, create_resource_request + + me = Principal(user_id="u_me", memberships={"grp_A": "member"}) + with pytest.raises(Forbidden): + await create_resource_request( + ResourceRequestCreate(group_id="grp_B", cpu=8, note="please"), principal=me, db=db) + ok = await create_resource_request( + ResourceRequestCreate(group_id="grp_A", cpu=8, note="please"), principal=me, db=db) + assert ok["group_id"] == "grp_A" + + +async def test_read_only_grantee_cannot_create_folders(db): + from app.api.volumes_router import create_folder + from app.db.models import StorageVolume, User, VolumePermission + + await seed(db, [ + User(id="u_owner", email="o@example.com", name="o"), + User(id="u_ro", email="r@example.com", name="r"), + User(id="u_rw", email="w@example.com", name="w"), + StorageVolume(id="vol_f", scope="user", scope_id="u_owner", type="workspace", name="w", + access_mode="RWX", owner_id="u_owner", quota_gb=10, used_gb=0), + VolumePermission(id="vpm_ro", volume_id="vol_f", user_id="u_ro", role="ro"), + VolumePermission(id="vpm_rw", volume_id="vol_f", user_id="u_rw", role="rw"), + ]) + with pytest.raises(Forbidden): + await create_folder("vol_f", {"path": "/a"}, principal=Principal(user_id="u_ro"), db=db) + await db.rollback() + out = await create_folder("vol_f", {"path": "/b"}, principal=Principal(user_id="u_rw"), db=db) + assert out["path"] == "/b" + out = await create_folder("vol_f", {"path": "/c"}, principal=Principal(user_id="u_owner"), db=db) + assert out["path"] == "/c" diff --git a/backend/tests/test_upstream_bugfixes.py b/backend/tests/test_upstream_bugfixes.py index 279a095..5362480 100644 --- a/backend/tests/test_upstream_bugfixes.py +++ b/backend/tests/test_upstream_bugfixes.py @@ -8,6 +8,7 @@ from app.cluster.inventory_sync import align_offering_models from app.core import ids from app.db.models import AuditLog, GpuDevice, GpuNode, Offering +from tests.fkseed import cluster_row, seed def _offering(name, model, mem=98304) -> Offering: @@ -23,16 +24,14 @@ def _device(node_id, model) -> GpuDevice: @pytest.fixture async def node(db): n = GpuNode(id=ids.new("node"), cluster_id="clu_t", hostname="gpu-a", status="ready") - async with db.begin(): - db.add(n) + await seed(db, [cluster_row("clu_t"), n]) return n @pytest.mark.asyncio async def test_seeded_marketing_name_adopts_the_reported_sku(db, node): off = _offering("RTX PRO 6000", "NVIDIA RTX PRO 6000 Blackwell") - async with db.begin(): - db.add(off) + await seed(db, [off]) async with db.begin(): await align_offering_models(db, "NVIDIA RTX PRO 6000 Blackwell Max-Q Workstation Edition") db.expunge_all() @@ -49,8 +48,7 @@ async def test_seeded_marketing_name_adopts_the_reported_sku(db, node): ]) async def test_alignment_leaves_other_cases_alone(db, node, reported): off = _offering("RTX PRO 6000", "NVIDIA RTX PRO 6000 Blackwell") - async with db.begin(): - db.add(off) + await seed(db, [off]) async with db.begin(): await align_offering_models(db, reported) db.expunge_all() @@ -61,8 +59,7 @@ async def test_alignment_leaves_other_cases_alone(db, node, reported): async def test_alignment_never_guesses_between_two_candidates(db, node): a = _offering("A", "NVIDIA RTX PRO 6000") b = _offering("B", "NVIDIA RTX PRO 6000 Blackwell") - async with db.begin(): - db.add_all([a, b]) + await seed(db, [a, b]) async with db.begin(): await align_offering_models(db, "NVIDIA RTX PRO 6000 Blackwell Max-Q Workstation Edition") db.expunge_all() @@ -73,8 +70,7 @@ async def test_alignment_never_guesses_between_two_candidates(db, node): @pytest.mark.asyncio async def test_alignment_keeps_a_name_other_cards_still_report(db, node): off = _offering("RTX PRO 6000", "NVIDIA RTX PRO 6000 Blackwell") - async with db.begin(): - db.add_all([off, _device(node.id, "NVIDIA RTX PRO 6000 Blackwell")]) + await seed(db, [off, _device(node.id, "NVIDIA RTX PRO 6000 Blackwell")]) async with db.begin(): await align_offering_models(db, "NVIDIA RTX PRO 6000 Blackwell Max-Q Workstation Edition") db.expunge_all() @@ -84,10 +80,8 @@ async def test_alignment_keeps_a_name_other_cards_still_report(db, node): @pytest.mark.asyncio async def test_device_report_triggers_alignment(db, node, monkeypatch): from app.cluster.inventory_sync import InventorySync - from app.db.models import Cluster off = _offering("RTX PRO 6000", "NVIDIA RTX PRO 6000 Blackwell") - async with db.begin(): - db.add_all([off, Cluster(id="clu_t", name="t", api_server="https://t:6443", runtime="k8s", kubeconfig_secret_ref="s")]) + await seed(db, [off]) # the `node` fixture already put cluster clu_t on the ledger await InventorySync(db).upsert_device(OperatorGpuDeviceUpsert( node_id="gpu-a", uuid="GPU-1", model="NVIDIA RTX PRO 6000 Blackwell Max-Q Workstation Edition", total_mem_mb=97887, total_cores=100, mode="fractional", status="ready", node_ready=True, diff --git a/backend/tests/test_user_usage.py b/backend/tests/test_user_usage.py index feb04ff..aca2e9f 100644 --- a/backend/tests/test_user_usage.py +++ b/backend/tests/test_user_usage.py @@ -13,12 +13,13 @@ from app.auth.rbac import Principal from app.core import ids from app.db.models import Allocation, CreditWallet, Session, StorageVolume, User +from tests.fkseed import seed async def _seed(db): uid = ids.new("user") other = ids.new("user") - db.add_all([ + await seed(db, [ User(id=uid, email="u@t.local", name="U"), User(id=other, email="o@t.local", name="O"), CreditWallet(id=ids.new("wallet"), owner_type="user", owner_id=uid, @@ -39,7 +40,7 @@ async def _seed(db): offering_id=running.offering_id, image_id=running.image_id, resource_class="gpu", cpu=16, mem_gb=32, status="terminated", ) - db.add_all([ + await seed(db, [ running, paused, done, Allocation(id=ids.new("allocation"), session_id=running.id, gpu_uuid="GPU-A", gpu_mem_mb=8000, gpu_cores=50, status="bound"), @@ -50,7 +51,6 @@ async def _seed(db): StorageVolume(id=ids.new("volume"), scope="user", scope_id=other, type="home", access_mode="RWX", quota_gb=999, used_gb=1), # someone else's: out ]) - await db.commit() return uid, other diff --git a/backend/tests/test_users_bulk.py b/backend/tests/test_users_bulk.py index 178e1df..6b6c7e3 100644 --- a/backend/tests/test_users_bulk.py +++ b/backend/tests/test_users_bulk.py @@ -8,6 +8,7 @@ from app.auth.rbac import Principal from app.core import ids from app.db.models import CreditWallet, Membership, Organization, Project, User +from tests.fkseed import seed def _admin() -> Principal: @@ -17,8 +18,7 @@ def _admin() -> Principal: async def _group(db) -> Project: org = Organization(id=ids.new("org"), name="school") group = Project(id=ids.new("group"), org_id=org.id, name="cs500") - async with db.begin(): - db.add_all([org, group]) + await seed(db, [org, group]) return group @@ -26,8 +26,7 @@ async def _group(db) -> Project: async def test_bulk_create_partial_success(db): group = await _group(db) existing = User(id=ids.new("user"), email="old@u.ac.kr", name="Old") - async with db.begin(): - db.add(existing) + await seed(db, [existing]) body = BulkUserCreate(group_id=group.id, rows=[ BulkUserRow(email="a@u.ac.kr", name="A"), diff --git a/backend/tests/test_users_delete.py b/backend/tests/test_users_delete.py index 3e2a85b..353c412 100644 --- a/backend/tests/test_users_delete.py +++ b/backend/tests/test_users_delete.py @@ -12,6 +12,7 @@ from app.core.errors import DomainError from app.db.models import CreditWallet, Image, Offering, User from app.db.models import Session as SessionRow +from tests.fkseed import seed def _admin() -> Principal: @@ -33,8 +34,7 @@ async def _user_with_sessions(db, statuses): billing_wallet_id=wallet.id, credit_per_hour_snapshot=Decimal("60")) for st in statuses ] - async with db.begin(): - db.add_all([user, wallet, offering, image, *sessions]) + await seed(db, [user, wallet, offering, image, *sessions]) # A real hold (ledger row + reserved), so settle-on-delete has something to release. from app.domain.credit_engine import CreditEngine diff --git a/backend/tests/test_volume_access.py b/backend/tests/test_volume_access.py index a693e76..a8b4dae 100644 --- a/backend/tests/test_volume_access.py +++ b/backend/tests/test_volume_access.py @@ -17,6 +17,7 @@ from app.auth.rbac import Principal from app.core.errors import Forbidden from app.db.models import StorageFolder, StorageVolume, User, VolumePermission +from tests.fkseed import seed class _Page: @@ -28,8 +29,7 @@ class _Page: async def _seed(db): """Owner 'alice' personal volume + group 'eng' volume + a shared volume; outsider 'mallory'.""" - db.add_all( - [ + await seed(db, [ User(id="alice", email="alice@example.com", name="Alice"), User(id="mallory", email="mallory@example.com", name="Mallory"), User(id="bob", email="bob@example.com", name="Bob"), @@ -48,9 +48,7 @@ async def _seed(db): # bob shares vol_shared with mallory (explicit VolumePermission). VolumePermission(id="vpm1", volume_id="vol_shared", user_id="mallory", role="ro"), StorageFolder(id="fld1", volume_id="vol_alice", path="/data", size_bytes=0), - ] - ) - await db.commit() + ]) def _principal(user_id: str, *, super_admin: bool = False, memberships: dict | None = None) -> Principal: @@ -149,8 +147,7 @@ async def test_share_recipient_cannot_delete_but_can_leave(db): )).all() assert left == [] # …but revoking somebody ELSE still needs manage rights. - db.add(VolumePermission(id="vpm2", volume_id="vol_shared", user_id="mallory", role="ro")) - await db.commit() + await seed(db, [VolumePermission(id="vpm2", volume_id="vol_shared", user_id="mallory", role="ro")]) with pytest.raises(Forbidden): await revoke_permission("vol_shared", "mallory", principal=_principal("alice"), db=db) @@ -178,9 +175,8 @@ async def test_group_member_can_access_group_volume(db): assert eng.role == "rw" # RWX volume, plain member -> derived rw # A ROX group volume derives ro for a plain member. - db.add(StorageVolume(id="vol_eng_ro", scope="group", scope_id="eng", type="dataset", - access_mode="ROX", owner_id="eng", quota_gb=5)) - await db.commit() + await seed(db, [StorageVolume(id="vol_eng_ro", scope="group", scope_id="eng", type="dataset", + access_mode="ROX", owner_id="eng", quota_gb=5)]) rows = await list_volumes(scope=None, scope_id=None, type=None, access_mode=None, page=_Page(), principal=member, db=db) ro = next(v for v in rows if v.id == "vol_eng_ro") diff --git a/backend/tests/test_volume_cluster_placement.py b/backend/tests/test_volume_cluster_placement.py index e8fbff1..4ac816f 100644 --- a/backend/tests/test_volume_cluster_placement.py +++ b/backend/tests/test_volume_cluster_placement.py @@ -30,11 +30,9 @@ async def _volume_used_on(db, cluster_id: str) -> str: sess = SessionModel(id=ids.new("session"), owner_user_id="usr_1", cluster_id=cluster_id, offering_id="off_1", image_id="img_1", resource_class="gpu", mode="fractional", status="terminated") - db.add_all([vol, sess]) - await db.flush() - db.add(VolumeMount(id=ids.new("volume_mount"), volume_id=vol.id, session_id=sess.id, - mount_path="/home/coder", mode="rw")) - await db.commit() + await seed(db, [vol, sess, + VolumeMount(id=ids.new("volume_mount"), volume_id=vol.id, session_id=sess.id, + mount_path="/home/coder", mode="rw")]) return vol.id @@ -60,8 +58,7 @@ async def test_an_unused_volume_does_not_constrain_placement(db): """A volume nobody has mounted has no data anywhere yet; it may land on any cluster.""" vol = StorageVolume(id=ids.new("volume"), scope="user", scope_id="usr_1", type="home", access_mode="RWO", quota_gb=10, used_gb=0) - db.add(vol) - await db.commit() + await seed(db, [vol]) svc = SchedulerService(db) assert await svc._clusters_holding_volumes(_Req([vol.id])) is None @@ -83,10 +80,12 @@ async def test_a_session_without_volumes_is_unconstrained(db): from app.auth.rbac import Principal # noqa: E402 from app.core.errors import VolumeOnAnotherCluster # noqa: E402 from app.db.models import CreditWallet, GpuNode, Image, Offering, VolumePermission # noqa: E402 +from tests.fkseed import seed, user_row # noqa: E402 async def _cpu_fixture(db): - user_id = ids.new("user") + user = user_row() + user_id = user.id offering = Offering(id=ids.new("offering"), name="cpu", resource_class="cpu", credit_per_hour=Decimal("0"), cpu=2, mem_gb=4, disk_gb=10) image = Image(id=ids.new("image"), name="ubuntu") @@ -96,11 +95,9 @@ async def _cpu_fixture(db): access_mode="RWO", quota_gb=10, used_gb=0, owner_id=user_id) node = GpuNode(id=ids.new("node"), cluster_id="clu_a", hostname="cpu0", status="ready", role="cpu", cpu=8, mem=32, disk=200) - async with db.begin(): - db.add_all([offering, image, wallet, vol, node]) - await db.flush() - db.add(VolumePermission(id=ids.new("volume_permission"), volume_id=vol.id, - user_id=user_id, role="owner")) + await seed(db, [user, offering, image, wallet, vol, node, + VolumePermission(id=ids.new("volume_permission"), volume_id=vol.id, + user_id=user_id, role="owner")]) return user_id, offering, image, vol @@ -126,11 +123,9 @@ async def test_pinned_cluster_refuses_a_volume_whose_data_is_elsewhere(db, fake_ other = SessionModel(id=ids.new("session"), owner_user_id=user_id, cluster_id="clu_b", offering_id=offering.id, image_id=image.id, resource_class="cpu", status="terminated") - async with db.begin(): - db.add(other) - await db.flush() - db.add(VolumeMount(id=ids.new("volume_mount"), volume_id=vol.id, session_id=other.id, - mount_path="/home/coder", mode="rw")) + await seed(db, [other, + VolumeMount(id=ids.new("volume_mount"), volume_id=vol.id, session_id=other.id, + mount_path="/home/coder", mode="rw")]) svc = SchedulerService(db) svc.handoff = fake_handoff with pytest.raises(VolumeOnAnotherCluster): diff --git a/backend/tests/test_volume_safety.py b/backend/tests/test_volume_safety.py index a8cc4ff..af9f8e0 100644 --- a/backend/tests/test_volume_safety.py +++ b/backend/tests/test_volume_safety.py @@ -12,6 +12,7 @@ from app.core.errors import VolumeLocked, VolumeShrinkNotAllowed from app.db.models import Session, StorageVolume, VolumeMount from app.domain.scheduler import SchedulerService +from tests.fkseed import seed, user_row def _super() -> Principal: @@ -23,6 +24,9 @@ def _member() -> Principal: async def _shared(db) -> StorageVolume: + # The principals below own rows (volume_permission.user_id, session.owner_user_id), so they + # have to exist on the ledger. + await seed(db, [user_row("usr_admin"), user_row("usr_m"), user_row("usr_ga")]) body = VolumeCreate(scope="global", scope_id="global", type="dataset", name="ds", access_mode="ROX", quota_gb=50) vol = await create_volume(body, _super(), db) @@ -73,10 +77,12 @@ async def test_force_delete_terminates_mounting_sessions(db, monkeypatch): image_id="img_t", resource_class="cpu", mode="cpu", status="paused") ended = Session(id=ids.new("session"), owner_user_id="usr_m", cluster_id="clu_t", offering_id="off_t", image_id="img_t", resource_class="cpu", mode="cpu", status="terminated") - db.add_all([live, paused, ended]) - for s in (live, paused, ended): - db.add(VolumeMount(id=ids.new("mount"), session_id=s.id, volume_id=vol.id, mount_path="/mnt/ds", mode="ro")) - await db.commit() + await seed(db, [live, paused, ended]) + await seed(db, [ + VolumeMount(id=ids.new("mount"), session_id=s.id, volume_id=vol.id, mount_path="/mnt/ds", + mode="ro") + for s in (live, paused, ended) + ]) cut: list[tuple[str, bool, str]] = [] diff --git a/backend/tests/test_volume_spec.py b/backend/tests/test_volume_spec.py index 4072587..08469cf 100644 --- a/backend/tests/test_volume_spec.py +++ b/backend/tests/test_volume_spec.py @@ -7,6 +7,7 @@ from app.cluster.crd import GShareSessionCRD, _to_crd_spec from app.core import ids from app.db.models import StorageVolume +from tests.fkseed import seed def _spec_with(vols): @@ -41,8 +42,7 @@ async def test_enrich_volumes_reads_storage_volume(db): id=ids.new("volume"), scope="user", scope_id=ids.new("user"), type="scratch", name="d", access_mode="RWO", quota_gb=7, ) - async with db.begin(): - db.add(vol) + await seed(db, [vol]) crd = GShareSessionCRD(db) out = await crd._enrich_volumes([ {"volume_id": vol.id, "mount_path": "/data", "mode": "rw"}, diff --git a/backend/tests/test_volume_sync.py b/backend/tests/test_volume_sync.py index f1fcc3c..df14933 100644 --- a/backend/tests/test_volume_sync.py +++ b/backend/tests/test_volume_sync.py @@ -18,6 +18,7 @@ from app.core.config import settings from app.db.models import Image, Notification, Offering, StorageVolume from app.db.models import Session as SessionRow +from tests.fkseed import cluster_row, seed GIB = 1024**3 @@ -27,8 +28,7 @@ async def _volume(db, *, quota_gb=10, deleted_at=None): id=ids.new("volume"), scope="user", scope_id=ids.new("user"), type="home", name="home", access_mode="RWO", quota_gb=quota_gb, used_gb=0, deleted_at=deleted_at, ) - async with db.begin(): - db.add(vol) + await seed(db, [vol]) return vol @@ -122,8 +122,7 @@ async def _live_session(db, *, status="running"): offering_id=offering.id, image_id=image.id, resource_class="gpu", mode="fractional", status=status, gpu_mem_mb=4000, gpu_cores=25, disk_gb=20, ) - async with db.begin(): - db.add_all([offering, image, sess]) + await seed(db, [offering, image, sess]) return sess @@ -211,3 +210,42 @@ async def test_session_disk_report_is_scoped_to_the_reporting_cluster(db, fake_r )) assert await fake_redis.get(f"sess:diskuse:{sess.id}") == f"{17 * GIB}:{20 * GIB}" assert len(await _disk_warnings(db, sess)) == 1 + + +# ── placement: where the data lives is learned from the first report and never moved ─────── + +@pytest.mark.asyncio +async def test_first_report_pins_the_volume_to_its_cluster_and_class(db): + vol = await _volume(db) + # the reporting clusters are real rows: the volume's cluster_id is a foreign key + await seed(db, [cluster_row("clu_a"), cluster_row("clu_b")]) + await VolumeSync(db).sync(OperatorVolumeSync( + cluster_id="clu_a", volumes=[_obs(vol, capacity_gb=10, storage_class="gshare-data")], + )) + row = await db.get(StorageVolume, vol.id) + assert (row.cluster_id, row.storage_class) == ("clu_a", "gshare-data") + assert row.provisioned_at is not None + + +@pytest.mark.asyncio +async def test_a_report_from_another_cluster_does_not_move_the_volume(db): + vol = await _volume(db) + # the reporting clusters are real rows: the volume's cluster_id is a foreign key + await seed(db, [cluster_row("clu_a"), cluster_row("clu_b")]) + await VolumeSync(db).sync(OperatorVolumeSync(cluster_id="clu_a", volumes=[_obs(vol, capacity_gb=10, storage_class="gshare-data")])) + await VolumeSync(db).sync(OperatorVolumeSync(cluster_id="clu_b", volumes=[_obs(vol, capacity_gb=10, storage_class="other")])) + row = await db.get(StorageVolume, vol.id) + assert (row.cluster_id, row.storage_class) == ("clu_a", "gshare-data") + + +@pytest.mark.asyncio +async def test_an_old_operator_without_the_class_still_pins_the_cluster(db): + vol = await _volume(db) + # the reporting clusters are real rows: the volume's cluster_id is a foreign key + await seed(db, [cluster_row("clu_a"), cluster_row("clu_b")]) + await VolumeSync(db).sync(OperatorVolumeSync(cluster_id="clu_a", volumes=[_obs(vol, capacity_gb=10)])) + row = await db.get(StorageVolume, vol.id) + assert row.cluster_id == "clu_a" and row.storage_class is None + # the class arrives once the operator is upgraded + await VolumeSync(db).sync(OperatorVolumeSync(cluster_id="clu_a", volumes=[_obs(vol, capacity_gb=10, storage_class="gshare-data")])) + assert (await db.get(StorageVolume, vol.id)).storage_class == "gshare-data" diff --git a/backend/tests/test_wallet_spend_daily.py b/backend/tests/test_wallet_spend_daily.py index b8cdf81..1e97b09 100644 --- a/backend/tests/test_wallet_spend_daily.py +++ b/backend/tests/test_wallet_spend_daily.py @@ -11,14 +11,14 @@ from app.auth.rbac import Principal from app.core import ids from app.db.models import CreditTransaction, CreditWallet, User +from tests.fkseed import seed async def _wallet(db) -> tuple[str, str]: uid = ids.new("user") w = CreditWallet(id=ids.new("wallet"), owner_type="user", owner_id=uid, balance=Decimal("100"), reserved=Decimal("0")) - async with db.begin(): - db.add_all([User(id=uid, email=f"{uid}@t.local", name="u"), w]) + await seed(db, [User(id=uid, email=f"{uid}@t.local", name="u"), w]) return uid, w.id @@ -32,15 +32,14 @@ def _txn(wallet_id: str, type_: str, amount: str, at: datetime) -> CreditTransac @pytest.mark.asyncio async def test_sums_spend_by_day_and_ignores_non_spend(db): uid, wid = await _wallet(db) - async with db.begin(): - db.add_all([ - _txn(wid, "consume", "-1.5", datetime(2026, 8, 10, 3, 0, tzinfo=UTC)), - _txn(wid, "consume", "-0.5", datetime(2026, 8, 10, 22, 0, tzinfo=UTC)), - _txn(wid, "consume", "-0.25", datetime(2026, 8, 11, 1, 0, tzinfo=UTC)), - _txn(wid, "topup", "50", datetime(2026, 8, 10, 4, 0, tzinfo=UTC)), # not spend - _txn(wid, "hold", "-10", datetime(2026, 8, 10, 5, 0, tzinfo=UTC)), # not spend - _txn(wid, "consume", "-9", datetime(2026, 9, 1, 0, 0, tzinfo=UTC)), # out of range - ]) + await seed(db, [ + _txn(wid, "consume", "-1.5", datetime(2026, 8, 10, 3, 0, tzinfo=UTC)), + _txn(wid, "consume", "-0.5", datetime(2026, 8, 10, 22, 0, tzinfo=UTC)), + _txn(wid, "consume", "-0.25", datetime(2026, 8, 11, 1, 0, tzinfo=UTC)), + _txn(wid, "topup", "50", datetime(2026, 8, 10, 4, 0, tzinfo=UTC)), # not spend + _txn(wid, "hold", "-10", datetime(2026, 8, 10, 5, 0, tzinfo=UTC)), # not spend + _txn(wid, "consume", "-9", datetime(2026, 9, 1, 0, 0, tzinfo=UTC)), # out of range, + ]) out = await spend_daily(wid, frm=date(2026, 8, 1), to=date(2026, 8, 31), tz_offset_min=0, principal=Principal(user_id=uid, memberships={}), db=db) got = {r.date: r.amount for r in out} @@ -51,8 +50,7 @@ async def test_sums_spend_by_day_and_ignores_non_spend(db): async def test_buckets_follow_the_callers_timezone(db): uid, wid = await _wallet(db) # 23:30 UTC on the 10th is 08:30 KST on the 11th. - async with db.begin(): - db.add(_txn(wid, "consume", "-3", datetime(2026, 8, 10, 23, 30, tzinfo=UTC))) + await seed(db, [_txn(wid, "consume", "-3", datetime(2026, 8, 10, 23, 30, tzinfo=UTC))]) out = await spend_daily(wid, frm=date(2026, 8, 1), to=date(2026, 8, 31), tz_offset_min=540, principal=Principal(user_id=uid, memberships={}), db=db) assert {r.date: r.amount for r in out} == {"2026-08-11": 3.0} diff --git a/backend/tests/test_webhook_org_scope.py b/backend/tests/test_webhook_org_scope.py index f1809c4..ab21401 100644 --- a/backend/tests/test_webhook_org_scope.py +++ b/backend/tests/test_webhook_org_scope.py @@ -16,6 +16,7 @@ from app.auth.rbac import Principal from app.core.errors import Forbidden from app.db.models import WebhookSubscription +from tests.fkseed import seed pytestmark = pytest.mark.asyncio @@ -38,8 +39,7 @@ def _sub(sid: str, org_id: str | None) -> WebhookSubscription: async def _seed(db) -> None: - async with db.begin(): - db.add_all([_sub("wbh_A", "org_A"), _sub("wbh_B", "org_B"), _sub("wbh_G", None)]) + await seed(db, [_sub("wbh_A", "org_A"), _sub("wbh_B", "org_B"), _sub("wbh_G", None)]) async def test_list_scoped_to_own_org(db): diff --git a/backend/tests/test_welcome_credit.py b/backend/tests/test_welcome_credit.py index 3bb11c4..8d98576 100644 --- a/backend/tests/test_welcome_credit.py +++ b/backend/tests/test_welcome_credit.py @@ -9,6 +9,7 @@ from app.core import ids from app.db.models import CreditTransaction, CreditWallet, Organization, Project from app.domain.welcome_credit import grant_welcome_credit +from tests.fkseed import seed @pytest.mark.asyncio @@ -19,8 +20,7 @@ async def test_grant_once_and_idempotent(db): user_id = ids.new("user") wallet = CreditWallet(id=ids.new("wallet"), owner_type="user", owner_id=user_id, balance=Decimal("0"), reserved=Decimal("0")) - async with db.begin(): - db.add_all([org, grp, wallet]) + await seed(db, [org, grp, wallet]) async with db.begin(): granted = await grant_welcome_credit(db, user_id, grp) @@ -49,7 +49,6 @@ async def test_zero_setting_grants_nothing(db): user_id = ids.new("user") wallet = CreditWallet(id=ids.new("wallet"), owner_type="user", owner_id=user_id, balance=Decimal("0"), reserved=Decimal("0")) - async with db.begin(): - db.add_all([org, grp, wallet]) + await seed(db, [org, grp, wallet]) async with db.begin(): assert await grant_welcome_credit(db, user_id, grp) is None diff --git a/charts/gshare/templates/agent-daemonset.yaml b/charts/gshare/templates/agent-daemonset.yaml index c252e60..59ea035 100644 --- a/charts/gshare/templates/agent-daemonset.yaml +++ b/charts/gshare/templates/agent-daemonset.yaml @@ -75,7 +75,7 @@ spec: seccompProfile: { type: RuntimeDefault } containers: - name: agent - image: {{ .Values.images.agent.repository }}:{{ .Values.images.agent.tag | default .Chart.AppVersion }} + image: {{ include "gshare.image" (dict "img" .Values.images.agent "root" $) }} imagePullPolicy: {{ .Values.images.agent.pullPolicy | default "IfNotPresent" }} # No privileges of any kind: cgroup v2 files are world-readable, so a read-only bind of # the host hierarchy is all the agent needs. diff --git a/charts/gshare/templates/api-deployment.yaml b/charts/gshare/templates/api-deployment.yaml index 28d15d1..bcfdef3 100644 --- a/charts/gshare/templates/api-deployment.yaml +++ b/charts/gshare/templates/api-deployment.yaml @@ -49,13 +49,17 @@ spec: env: - name: GSHARE_DATABASE_URL valueFrom: { secretKeyRef: { name: {{ .Values.postgres.credentialsSecret }}, key: uri } } + # Same envelope as the api container, so the pod stays schedulable under a namespace + # LimitRange or ResourceQuota that demands limits on every container. + resources: + {{- toYaml .Values.api.resources | nindent 12 }} containers: - name: api image: {{ include "gshare.image" (dict "img" .Values.images.api "root" $) }} imagePullPolicy: {{ .Values.images.api.pullPolicy }} securityContext: {{- include "gshare.containerSecurityContext" . | nindent 12 }} - command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "{{ .Values.api.port }}", "--proxy-headers", "--forwarded-allow-ips=*"] + command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "{{ .Values.api.port }}", "--proxy-headers", "--forwarded-allow-ips={{ .Values.api.forwardedAllowIps }}"] ports: - { name: http, containerPort: {{ .Values.api.port }} } envFrom: diff --git a/charts/gshare/templates/configmap.yaml b/charts/gshare/templates/configmap.yaml index 8e4989f..d2a9d22 100644 --- a/charts/gshare/templates/configmap.yaml +++ b/charts/gshare/templates/configmap.yaml @@ -40,6 +40,9 @@ data: {{- /* not `default`: 0 is a legitimate value (reclaim on the next sync) and `default` would swallow it */}} GSHARE_VOLUME_RECLAIM_GRACE_HOURS: {{ ternary .Values.api.volumeReclaimGraceHours 24 (hasKey .Values.api "volumeReclaimGraceHours") | quote }} GSHARE_DB_MAX_OVERFLOW: {{ .Values.api.dbMaxOverflow | default 10 | quote }} + # Proxies that append to X-Forwarded-For in front of the api; the login rate limit and the + # login audit take the client address that many hops from the right of the header. + GSHARE_TRUSTED_PROXY_HOPS: {{ .Values.api.trustedProxyHops | default 1 | quote }} # JWKS used to verify the internal RS256 JWT. The api fetches it from itself when verifying an # operator callback. The default (api.gshare.internal) does not resolve inside the cluster, so it # must be overridden with the in-cluster service URL. diff --git a/charts/gshare/templates/data-tier.yaml b/charts/gshare/templates/data-tier.yaml index 6d58705..dac9f64 100644 --- a/charts/gshare/templates/data-tier.yaml +++ b/charts/gshare/templates/data-tier.yaml @@ -39,9 +39,23 @@ spec: {{- include "gshare.selectorLabels" (dict "component" "gshare-pg") | nindent 8 }} spec: {{- include "gshare.controlPlaneNodeSelector" . | nindent 6 }} + automountServiceAccountToken: false + # The official image ships a `postgres` user as uid/gid 999 and its entrypoint runs as that + # user without root when started as it; fsGroup lets it create PGDATA inside the claim. The + # root filesystem stays read-only: the socket directory and initdb's scratch space are the + # only paths it writes outside PGDATA. + securityContext: + runAsNonRoot: true + runAsUser: 999 + runAsGroup: 999 + fsGroup: 999 + seccompProfile: + type: RuntimeDefault containers: - name: postgres image: {{ .Values.postgres.image }} + securityContext: + {{- include "gshare.containerSecurityContext" . | nindent 12 }} {{- with .Values.postgres.resources }} resources: {{- toYaml . | nindent 12 }} @@ -59,9 +73,15 @@ spec: periodSeconds: 5 volumeMounts: - { name: data, mountPath: /var/lib/postgresql/data } + - { name: run, mountPath: /var/run/postgresql } + - { name: tmp, mountPath: /tmp } volumes: - name: data persistentVolumeClaim: { claimName: gshare-pg-data } + - name: run + emptyDir: {} + - name: tmp + emptyDir: {} --- apiVersion: v1 kind: Service @@ -98,9 +118,21 @@ spec: {{- include "gshare.selectorLabels" (dict "component" "gshare-redis") | nindent 8 }} spec: {{- include "gshare.controlPlaneNodeSelector" . | nindent 6 }} + automountServiceAccountToken: false + # The official image's `redis` user is uid/gid 999. Nothing here needs to persist (queue + # state and idempotency keys are rebuilt), so its working directory is scratch space. + securityContext: + runAsNonRoot: true + runAsUser: 999 + runAsGroup: 999 + fsGroup: 999 + seccompProfile: + type: RuntimeDefault containers: - name: redis image: {{ .Values.redis.image }} + securityContext: + {{- include "gshare.containerSecurityContext" . | nindent 12 }} {{- with .Values.redis.resources }} resources: {{- toYaml . | nindent 12 }} @@ -110,6 +142,11 @@ spec: readinessProbe: exec: { command: ["redis-cli", "ping"] } periodSeconds: 5 + volumeMounts: + - { name: data, mountPath: /data } + volumes: + - name: data + emptyDir: {} --- apiVersion: v1 kind: Service diff --git a/charts/gshare/templates/namespaces.yaml b/charts/gshare/templates/namespaces.yaml index afc4791..bfb367b 100644 --- a/charts/gshare/templates/namespaces.yaml +++ b/charts/gshare/templates/namespaces.yaml @@ -1,6 +1,8 @@ {{- if .Values.createNamespaces }} # Namespaces and their Pod Security Admission labels. -# gshare-sessions enforces `restricted`; gshare-infra is `privileged`, for privileged DaemonSets only. +# gshare-sessions enforces `baseline` and audits/warns at `restricted` (see the comment on that +# namespace: a policy-granted privileged session runs as root, which restricted rejects); +# gshare-infra is `privileged`, for the privileged DaemonSets only. apiVersion: v1 kind: Namespace metadata: diff --git a/charts/gshare/templates/networkpolicy.yaml b/charts/gshare/templates/networkpolicy.yaml new file mode 100644 index 0000000..e3f7a78 --- /dev/null +++ b/charts/gshare/templates/networkpolicy.yaml @@ -0,0 +1,100 @@ +{{- if .Values.networkPolicy.enabled }} +{{- $ns := .Values.global.namespaces.sessions }} +{{- $np := .Values.networkPolicy }} +# Session-namespace NetworkPolicies: deny everything, then allow the ingress controller in and DNS +# plus the declared storage subnets out. Standalone copies of these live in deploy/security/ for +# installs that do not use this chart; keeping them here is what gives them a deployment path. +# +# Off by default because the allowlists have to name this cluster's own addresses: turning them on +# with no storage CIDR leaves sessions able to resolve DNS and nothing else. +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: gshare-default-deny-all + namespace: {{ $ns }} + labels: + {{- include "gshare.labels" . | nindent 4 }} +spec: + podSelector: {} # every pod in the namespace + policyTypes: ["Ingress", "Egress"] +--- +# Ingress: the proxy that fronts the session apps, and nothing else. +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: gshare-session-ingress-allow + namespace: {{ $ns }} + labels: + {{- include "gshare.labels" . | nindent 4 }} +spec: + podSelector: + matchLabels: { gshare.io/workload: session } + policyTypes: ["Ingress"] + ingress: + - from: + - namespaceSelector: + matchLabels: { kubernetes.io/metadata.name: {{ $np.ingressNamespace }} } + ports: + {{- range $np.sessionPorts }} + - { protocol: TCP, port: {{ . }} } + {{- end }} +--- +# Egress: DNS always, plus the storage subnets when they are declared. A session never calls the +# control plane — the operator reports on its behalf — so nothing else is opened. +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: gshare-session-egress-allow + namespace: {{ $ns }} + labels: + {{- include "gshare.labels" . | nindent 4 }} +spec: + podSelector: + matchLabels: { gshare.io/workload: session } + policyTypes: ["Egress"] + egress: + - to: + - namespaceSelector: + matchLabels: { kubernetes.io/metadata.name: {{ $np.dnsNamespace }} } + podSelector: + matchLabels: { k8s-app: kube-dns } + ports: + - { protocol: UDP, port: 53 } + - { protocol: TCP, port: 53 } + {{- with $np.storage.cidrs }} + - to: + {{- range . }} + - ipBlock: { cidr: {{ . }} } + {{- end }} + ports: + {{- range $np.storage.ports }} + - { protocol: TCP, port: {{ . }} } + {{- end }} + {{- end }} +{{- with $np.cpuData.cidrs }} +--- +# CPU data-prep sessions additionally reach the dataset mirror or object store. +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: gshare-cpu-data-egress-allow + namespace: {{ $ns }} + labels: + {{- include "gshare.labels" $ | nindent 4 }} +spec: + podSelector: + matchLabels: + gshare.io/workload: session + gshare.io/resource-class: cpu + policyTypes: ["Egress"] + egress: + - to: + {{- range . }} + - ipBlock: { cidr: {{ . }} } + {{- end }} + ports: + {{- range $np.cpuData.ports }} + - { protocol: TCP, port: {{ . }} } + {{- end }} +{{- end }} +{{- end }} diff --git a/charts/gshare/templates/operator-deployment.yaml b/charts/gshare/templates/operator-deployment.yaml index 0352d48..64e515d 100644 --- a/charts/gshare/templates/operator-deployment.yaml +++ b/charts/gshare/templates/operator-deployment.yaml @@ -43,6 +43,10 @@ spec: - "--leader-elect" # Namespace the session children live in (--session-namespace, env SESSION_NAMESPACE). - "--session-namespace={{ .Values.global.namespaces.sessions }}" + # Namespace for the privileged node Jobs (checkpoint/restore, mig-agent, kaniko). The + # RBAC in operator-rbac.yaml confines Job and pod writes to this namespace, so it must + # follow global.namespaces.infra rather than the flag's built-in default. + - "--lossless-namespace={{ .Values.global.namespaces.infra }}" # Session ingress: one domain, path-based, on the console host at /proxy/{cr}, with # forward-auth for the connect token. session-domain is the console domain. The auth-url # has to be a fully qualified name (.svc.cluster.local); the nginx resolver cannot look diff --git a/charts/gshare/templates/operator-rbac.yaml b/charts/gshare/templates/operator-rbac.yaml index 8c31210..960f342 100644 --- a/charts/gshare/templates/operator-rbac.yaml +++ b/charts/gshare/templates/operator-rbac.yaml @@ -73,11 +73,8 @@ rules: resources: ["csistoragecapacities", "storageclasses"] verbs: ["get", "list", "watch"] - apiGroups: ["batch"] - resources: ["jobs"] # lossless pause: the privileged agent Job (checkpoint and restore) - verbs: ["get", "list", "watch", "create", "delete"] - - apiGroups: [""] - resources: ["pods"] # lossless pause: creating and managing restore pods in gshare-infra - verbs: ["get", "list", "watch", "create", "delete"] + resources: ["jobs"] # the manager's informer: checkpoint, mig-agent and kaniko Jobs + verbs: ["get", "list", "watch"] # creating and deleting them is confined to gshare-infra below - apiGroups: ["admissionregistration.k8s.io"] resources: ["validatingwebhookconfigurations"] # lend-guard: injecting its own caBundle verbs: ["get", "list", "watch", "update"] @@ -100,8 +97,10 @@ subjects: name: {{ .Values.serviceAccounts.operator }} namespace: {{ .Values.global.namespaces.system }} --- -# ── Console image builds: kaniko Jobs run in the infra namespace (Jobs/pods are covered by the -# cluster-scope rules above); this Role adds only the Dockerfile ConfigMaps and log reads. ── +# ── Privileged node work in the infra namespace: the lossless-pause checkpoint and restore Jobs, +# the mig-agent Jobs, and the kaniko build Jobs all run in gshare-infra, so the operator's right +# to create and delete Jobs and pods stops at that namespace. Reads stay cluster-scoped above +# because the manager's cache lists and watches those types cluster-wide. ── apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: @@ -110,6 +109,12 @@ metadata: labels: {{- include "gshare.labels" . | nindent 4 }} rules: + - apiGroups: ["batch"] + resources: ["jobs"] # checkpoint/restore, mig-agent and kaniko Jobs + verbs: ["get", "list", "watch", "create", "delete"] + - apiGroups: [""] + resources: ["pods"] # restore pods; a stuck Job's pod on cleanup + verbs: ["get", "list", "watch", "create", "delete"] - apiGroups: [""] resources: ["configmaps"] # per-build Dockerfile; deleted when the build finishes verbs: ["create", "get", "delete"] @@ -154,7 +159,7 @@ rules: verbs: ["create", "get", "list", "watch", "delete"] - apiGroups: [""] resources: ["secrets"] # the per-session ses--secret only - verbs: ["create", "get", "delete"] # no list, watch, or update, and no wildcard + verbs: ["create", "get", "list", "watch", "delete"] # list/watch: the Secret informer is confined to this namespace (operator cache options); no update, no wildcard - apiGroups: [""] resources: ["events"] # eviction cause recovery: a gracefully-evicted pod ends verbs: ["get", "list"] # Succeeded with no reason; the kubelet Event is the only evidence @@ -289,7 +294,7 @@ metadata: {{- include "gshare.labels" . | nindent 4 }} rules: - apiGroups: [""] - resources: ["pods", "services", "secrets", "persistentvolumeclaims"] + resources: ["pods", "services", "persistentvolumeclaims"] # no secrets: those are read only in the sessions namespace (Role above) verbs: ["get", "list", "watch"] - apiGroups: ["networking.k8s.io"] resources: ["ingresses"] diff --git a/charts/gshare/templates/operator-token.yaml b/charts/gshare/templates/operator-token.yaml index 80a7223..2491361 100644 --- a/charts/gshare/templates/operator-token.yaml +++ b/charts/gshare/templates/operator-token.yaml @@ -199,6 +199,9 @@ spec: valueFrom: { secretKeyRef: { name: gshare-jwt-signing, key: active_kid } } volumeMounts: - { name: mint, mountPath: /mint, readOnly: true } + resources: + requests: { cpu: 50m, memory: 64Mi } + limits: { cpu: 500m, memory: 256Mi } volumes: - name: mint configMap: @@ -252,6 +255,9 @@ spec: valueFrom: { secretKeyRef: { name: gshare-jwt-signing, key: active_kid } } volumeMounts: - { name: mint, mountPath: /mint, readOnly: true } + resources: + requests: { cpu: 50m, memory: 64Mi } + limits: { cpu: 500m, memory: 256Mi } volumes: - name: mint configMap: diff --git a/charts/gshare/templates/pg-backup-cronjob.yaml b/charts/gshare/templates/pg-backup-cronjob.yaml index 9c97283..57836d4 100644 --- a/charts/gshare/templates/pg-backup-cronjob.yaml +++ b/charts/gshare/templates/pg-backup-cronjob.yaml @@ -33,9 +33,24 @@ spec: spec: {{- include "gshare.controlPlaneNodeSelector" . | nindent 10 }} restartPolicy: OnFailure + automountServiceAccountToken: false + # Same identity as the database pod (the image's uid/gid 999); pg_dump writes only to + # the backup claim, so the root filesystem stays read-only. + securityContext: + runAsNonRoot: true + runAsUser: 999 + runAsGroup: 999 + fsGroup: 999 + seccompProfile: + type: RuntimeDefault containers: - name: pg-dump image: {{ .Values.postgres.image }} + securityContext: + {{- include "gshare.containerSecurityContext" . | nindent 16 }} + resources: + requests: { cpu: 100m, memory: 128Mi } + limits: { cpu: "1", memory: 512Mi } command: - sh - -c diff --git a/charts/gshare/values.yaml b/charts/gshare/values.yaml index d3f80ee..eb5926e 100644 --- a/charts/gshare/values.yaml +++ b/charts/gshare/values.yaml @@ -61,7 +61,7 @@ images: pullPolicy: IfNotPresent # Per-node metrics agent (see the `agent` section below). agent: - repository: docker.io/boanlab/gshare-agent + repository: gshare-agent tag: "latest" pullPolicy: IfNotPresent @@ -88,6 +88,18 @@ api: # (and the CSI driver the dataset). 0 reclaims on the next sync (about five minutes). volumeReclaimGraceHours: 24 dbMaxOverflow: 10 + # Which peers uvicorn trusts to set X-Forwarded-For / X-Forwarded-Proto (--forwarded-allow-ips). + # "*" trusts every peer, which is right when the only way to the pod is the ingress controller + # but lets any in-cluster client choose its own recorded address (login audit rows and the + # per-IP login rate limit key off it). Narrow it to the ingress controller's pod CIDR or IPs + # ("10.244.0.0/16" or "10.244.1.5,10.244.2.7") where the cluster lets you pin that down. + forwardedAllowIps: "*" + # How many proxies append to X-Forwarded-For before a request reaches the api + # (GSHARE_TRUSTED_PROXY_HOPS). The client address used for the login rate limit and the login + # audit rows is taken that many entries from the RIGHT of the header, because a client can + # prepend anything but cannot remove what a trusted proxy appends. 1 = ingress-nginx alone; + # 2 = a load balancer that forwards the header in front of the ingress. + trustedProxyHops: 1 resources: requests: { cpu: "250m", memory: 512Mi } limits: { cpu: "1", memory: 1Gi } @@ -200,6 +212,29 @@ frontend: # ── Per-node metrics agent ── # One pod per node sampling each session's cgroup counters, for a one-second live view on the # session page. Off by default: the Prometheus path already covers history, GPU and host metrics, +# ── NetworkPolicies for the session namespace ── +# Deny-all plus an ingress and egress allowlist for session pods (rendered by +# templates/networkpolicy.yaml; the same manifests stand alone in deploy/security/). +# +# Off by default: the allowlists name addresses that differ per cluster, and enabling them without +# a storage CIDR leaves sessions able to resolve DNS and reach nothing else. Fill the CIDRs in, +# then enable. +networkPolicy: + enabled: false + # Namespace of the ingress controller that fronts the session apps. + ingressNamespace: ingress-nginx + # Namespace running kube-dns / CoreDNS. + dnsNamespace: kube-system + # Ports the session containers listen on: code-server, JupyterLab, the web terminal. + sessionPorts: [8080, 8888, 7681] + storage: + cidrs: [] # e.g. ["10.20.0.0/16"] — the NFS / CephFS subnet + ports: [2049, 6789] + # Extra destinations for CPU data-prep sessions (dataset mirror, object store). No CIDR = no rule. + cpuData: + cidrs: [] + ports: [443] + # and this only earns its keep where people watch a job start. agent: enabled: false diff --git a/deploy/security/podsecurity-namespaces.yaml b/deploy/security/podsecurity-namespaces.yaml index 6dbe6c0..2798006 100644 --- a/deploy/security/podsecurity-namespaces.yaml +++ b/deploy/security/podsecurity-namespaces.yaml @@ -19,8 +19,13 @@ kind: Namespace metadata: name: gshare-sessions labels: - # Tenant sessions: restricted, enforced. Anything not running as non-root is denied at admission. - pod-security.kubernetes.io/enforce: restricted + # Tenant sessions: baseline enforced, restricted audited and warned — the same profile the + # chart sets (charts/gshare/templates/namespaces.yaml). Every ordinary session pod already + # has the restricted shape; the one relaxation is a policy-granted privileged session running + # as root, which restricted would reject outright. Enforcing restricted here while the chart + # enforces baseline meant whichever manifest was applied last decided, and applying this one + # last silently broke that feature. + pod-security.kubernetes.io/enforce: baseline pod-security.kubernetes.io/enforce-version: latest pod-security.kubernetes.io/audit: restricted pod-security.kubernetes.io/warn: restricted diff --git a/deploy/security/rbac-onboarding-scoped.yaml b/deploy/security/rbac-onboarding-scoped.yaml index 2a0aea5..c794d2e 100644 --- a/deploy/security/rbac-onboarding-scoped.yaml +++ b/deploy/security/rbac-onboarding-scoped.yaml @@ -1,8 +1,10 @@ # Onboarding RBAC for a remote target cluster: namespace-scoped least privilege, a read-only # ClusterRole, and an in-cluster service account. # -# No blanket access to secrets, no pods/exec, no long-lived token grants, and no wildcard verbs. There -# is no long-lived kubeconfig either — access rotates through 24-hour TokenRequests. +# No blanket access to secrets, no long-lived token grants, and no wildcard verbs. There is no +# long-lived kubeconfig either — access rotates through 24-hour TokenRequests. `pods/exec` is not +# granted to the controller: section (C) below defines a break-glass Role for it that is left +# deliberately unbound, so nothing can exec into a session until an administrator binds it by hand. # # Apply this to the cluster being onboarded, which the GShare control plane will reconcile remotely. # Bootstrapping then issues only a short-lived token: diff --git a/deploy/supplychain/kyverno-verify-images.yaml b/deploy/supplychain/kyverno-verify-images.yaml index 66e3f4b..9f570fe 100644 --- a/deploy/supplychain/kyverno-verify-images.yaml +++ b/deploy/supplychain/kyverno-verify-images.yaml @@ -62,3 +62,9 @@ spec: spec: containers: - image: "ghcr.io/gshare/* | registry.gshare.internal/* | registry.gshare.internal/prj_*" + # `=(...)`: checked when the field is present. Without these two an init or ephemeral + # container pulled the image from anywhere it liked while the rule still passed. + =(initContainers): + - image: "ghcr.io/gshare/* | registry.gshare.internal/* | registry.gshare.internal/prj_*" + =(ephemeralContainers): + - image: "ghcr.io/gshare/* | registry.gshare.internal/* | registry.gshare.internal/prj_*" diff --git a/docs/admin/dashboard.md b/docs/admin/dashboard.md index 1078e1f..377fe50 100644 --- a/docs/admin/dashboard.md +++ b/docs/admin/dashboard.md @@ -33,7 +33,7 @@ only: a tenant administrator manages people, not machines. | **GPU devices** | One tile per card: cluster tag, mode, VRAM occupancy, free cores | | **Node status** | Busy, ready, cordoned, offline, open health alerts | | **Host resources** | CPU, memory and disk across the fleet | -| **Storage server** | The registered [pool](./storage.md), its cluster, sharing, and provisioned quota against capacity | +| **Storage server** | Provisioned volume quota against the capacity of every registered [pool](./storage.md) added up, then one row per pool with its own allocation, cluster and sharing | | **Recent activity** | The latest audit entries, fleet-wide | The **cluster selector** in the top bar narrows every figure to one cluster. The storage panel diff --git a/docs/admin/groups.md b/docs/admin/groups.md index cb32ea6..f515c9c 100644 --- a/docs/admin/groups.md +++ b/docs/admin/groups.md @@ -43,7 +43,8 @@ policy. - monitor and force-terminate their members' sessions, - read the group's audit entries. -They cannot create groups, touch other groups, or change the catalogue and policies. +They cannot create groups, touch other groups, or change the catalogue; the only policies +they can set are their own group's and its members'. ## Deactivating and deleting diff --git a/docs/admin/images.md b/docs/admin/images.md index 3431641..4caa8a0 100644 --- a/docs/admin/images.md +++ b/docs/admin/images.md @@ -38,17 +38,19 @@ Two things decide whether it will actually run: image built for 12.4 does not make it work on a Blackwell card — it makes the failure happen later and less clearly. -## Building +## Builds -![Build](/img/screens/admin-image-build.png) +![Builds tab](/img/screens/admin-image-build.png) -**Build image** starts a console-driven build from an inline Dockerfile or a public git -repository, pushes the result to `api.buildRegistry`, and registers it automatically. +The **Builds** tab follows image builds run through the platform: +`queued → building → pushing → scanning → succeeded`, with the log of each. -The build runs asynchronously and the page follows it: -`queued → building → pushing → scanning → succeeded`. A failure keeps the log for you to read. +A build is started through the API (`POST /api/v1/image-builds`, from an inline Dockerfile or a +public git repository); the console has no form for it. It also needs the chart's +`operator.kanikoImage` to be set — without it the operator fails builds immediately. The result +is pushed to `api.buildRegistry` and registered automatically. -Members can also build their own images (up to 20 each); those are private to the owner and +Images a member imported or built themselves are private to that member (up to 20 each) and tagged **My image** in the wizard. ## Seeded images diff --git a/docs/admin/roles.md b/docs/admin/roles.md index 8ce50a8..0e5fbbb 100644 --- a/docs/admin/roles.md +++ b/docs/admin/roles.md @@ -26,7 +26,7 @@ organization sees nothing of another. A person can hold different roles in diffe | Session monitoring and audit log | — | group | organization | everything | | User and group management | — | group | organization | everything | | Credit allocation | request only | group → user | organization → group | top-up and everything | -| Organizations, offerings, policies, clusters, nodes, storage, images | — | — | — | ✅ | +| Organizations, offerings, the global policy, clusters, nodes, storage, images | — | — | — | ✅ | ## Two kinds of administrator @@ -45,7 +45,7 @@ side: the [GPU catalogue](./resources-offerings.md), [clusters](./clusters.md) a | "I need more credits" | The user's group_admin — [credit requests](./credits-requests.md) | | "I need a bigger quota" | Whoever owns the policy: group_admin for their group, super_admin for the global one — [resource requests](./resources-policies.md#quota-requests) | | "Someone is holding a GPU they are not using" | Any administrator in scope — [intervening](./monitoring-control.md), or fix the idle timeout | -| "A new person joined" | group_admin or org_admin — [adding users](./users-add.md) | +| "A new person joined" | org_admin — [adding users](./users-add.md); a group_admin can add an existing user to their group | | "A node needs maintenance" | super_admin — [draining](./nodes.md#draining) | | "The storage pool is full" | super_admin — [storage](./storage.md) | | "Sessions fail with *unserviceable*" | super_admin — the [offering](./resources-offerings.md) does not match the card | diff --git a/docs/admin/storage.md b/docs/admin/storage.md index d266d54..5d37310 100644 --- a/docs/admin/storage.md +++ b/docs/admin/storage.md @@ -9,13 +9,44 @@ Two things live here: the **pools** that back user volumes, and the **volumes** ## Storage pools +![Storage pools](/img/screens/admin-storage-pools.png) + A pool is a registered object, not something inferred from node roles: the cluster its server sits in, the StorageClass that provisions from it, and its sharing scope (**all** clusters, or -**selected** ones). Pools are registered through the API (`/api/v1/storage/pools`); the console -shows them on the dashboard's storage tile. +**selected** ones). The **Storage pools** tab lists them; the dashboard's storage tile and the +capacity gate that admits new volumes both read this list — a server that is not registered here +does not count. + +### Registering a server + +The order is: label the node, install the driver, register the pool. + +1. Label the storage node `gshare.io/role=storage` (the bootstrap script does this for + `STORAGE_NODE`), so it appears under [Nodes](./nodes.md) with the *Storage* role. +2. Install the CSI driver and give the operator its StorageClass + (`operator.volumeStorageClass`) — see [Operations → Storage](../operations/storage.md). +3. **Register pool**: + +![Register a pool](/img/screens/admin-storage-pool-new.png) + +| Field | Meaning | +|---|---| +| **Pool name** | The linked node's **hostname**, always — typed only for a server the cluster never sees as a node | +| **Cluster** | The cluster the server sits in. Fixed after registration | +| **StorageClass** | The class that provisions from it — the same name the operator was given | +| **Storage node** | Optional. The labelled node; its disk becomes the fallback capacity | +| **Stated capacity (GB)** | The real pool size, used until the driver publishes one | +| **Sharing** | *All clusters* — every cluster may place volumes here; *Selected* — tick the clusters allowed, besides this one | + +**Edit** changes everything but the cluster; **Delete** (typed confirmation) stops counting the +server — volumes already on it keep working through their StorageClass, nothing on the storage +is touched. + +![Removing a pool](/img/screens/admin-storage-pool-delete.png) + +### Where the capacity figure comes from -The dashboard's storage tile and the capacity gate that admits new volumes both read the -registered pools. Capacity comes, in order, from: +Each row shows the figure and its source, in order of preference: 1. **csi** — the driver's own `GetCapacity`, published as `CSIStorageCapacity` and read by the operator. The only automatic source. @@ -31,8 +62,11 @@ prerequisites, attaching a second cluster — is in ![All volumes](/img/screens/admin-volumes.png) -Every volume in the fleet, with owner, scope, type, access mode, usage against quota, and the -cluster it is pinned to. +Every volume in the fleet, with owner, scope, type, access mode, usage against quota, and +**where its data lives** — the storage pool (and cluster) whose server holds it. A volume no +session has mounted yet has no claim and therefore no place; it reads *not provisioned yet* until +the first mount creates it. With several storage servers this column is how you tell which one a +volume — and its user — depends on. | Action | Effect | |---|---| diff --git a/docs/admin/users-manage.md b/docs/admin/users-manage.md index 7be843d..b9bd12a 100644 --- a/docs/admin/users-manage.md +++ b/docs/admin/users-manage.md @@ -23,7 +23,7 @@ Sessions already running are unaffected until they end. **Roles are not set here.** Group and organization administrators are appointed from the [group](./groups.md#group-administrators) and [organization](./organizations.md#organization-administrators) pages, and a role can only be granted at or below your own, within your scope. Global roles -(super_admin) are a super_admin action. +(super_admin) are a super_admin action, done through the API — the console has no control for them. The dialog also shows the account's **usage** — sessions, GPU and host resources held, volumes, wallet — so you can see what deactivating or deleting would affect before you do it. diff --git a/docs/admin/users.md b/docs/admin/users.md index 2a3ab41..cdbe836 100644 --- a/docs/admin/users.md +++ b/docs/admin/users.md @@ -26,10 +26,10 @@ the group each belongs to. | **Created** | When the account was made | | **Actions** | Approve (pending accounts), edit, suspend/activate, delete | -Roles are **granted elsewhere** — the list only shows them: global roles under -[System](./system.md), organization administrators on the +Roles are **granted elsewhere** — the list only shows them: organization administrators on the [organization](./organizations.md#organization-administrators), group roles on the -[group](./groups.md#group-administrators). +[group](./groups.md#group-administrators). The global role (super_admin) has no console +control; it is set through the API (`PUT /api/v1/users/{id}/global-role`). ![Filters](/img/screens/admin-users-toolbar.png) diff --git a/docs/architecture.md b/docs/architecture.md index f60454f..2885a28 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -117,7 +117,7 @@ preemptive lending (lossless hand-off and resume), and hierarchical limit manage | Namespace | Purpose | |---|---| | `gshare-system` | Control plane: api, worker, operator, Postgres, Redis, ingress | -| `gshare-sessions` | Tenant session pods, under `restricted` Pod Security Admission | +| `gshare-sessions` | Tenant session pods; Pod Security Admission enforces `baseline` and audits/warns at `restricted` | | `gshare-infra` | Privileged DaemonSets: node-problem-detector, Spegel, image pre-puller | ## Per-node metrics agent (optional) diff --git a/docs/cluster-connect.md b/docs/cluster-connect.md index 00c1578..e0a67a1 100644 --- a/docs/cluster-connect.md +++ b/docs/cluster-connect.md @@ -95,6 +95,13 @@ comments in [`docker-compose.yml`](../docker-compose.yml)). The control plane's > only has to forward everything to the frontend. This handles a single external cluster, > and is left unset for an all-in-one Kubernetes install where the cluster ingress routes > `/proxy/` itself. +> +> **Client addresses behind the proxy.** The API records the client address in the login +> audit and rate-limits logins per address. It reads that address from `X-Forwarded-For`, +> counting `GSHARE_TRUSTED_PROXY_HOPS` entries from the right (default `1`: the Compose +> frontend or ingress-nginx alone). With a load balancer that forwards the header in front +> of that proxy, set it to `2`; otherwise every login would be attributed to the balancer's +> address. In the chart the same knob is `api.trustedProxyHops`. 1. **Enable internal callbacks** — generate the RS256 key, then start: @@ -183,7 +190,7 @@ rotate it for you. When the control plane itself runs on Kubernetes, external-secrets projects each kubeconfig under `GSHARE_CLUSTER_KUBECONFIG_DIR`. Deploy each additional cluster's operator with the chart, setting `operator.clusterId` to the registered id, plus -`operator.internalJwksUrl` and `operator.internalJwtSecret`. +`operator.controlPlaneUrl` and `operator.internalJwtSecret`. ## Security and limitations diff --git a/docs/getting-started.md b/docs/getting-started.md index 3050dc8..4baf9e4 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -182,9 +182,10 @@ an administrator you only edit and extend it, from the console or through The seeded rates are suggestions — adjust them in the console. - An **image** is the session container image. The GShare session images ship JupyterLab, a web terminal, and code-server. -- Users are not limited to the shared catalogue: from **내 이미지 / My images** any member can - build an image (an inline Dockerfile or a public git repository) or import a public registry - reference. Those rows are private to their owner, and one member may hold at most 20 of them. +- Users are not limited to the shared catalogue: a member can register images of their own + through the API (`POST /api/v1/images/import` for a public registry reference, + `POST /api/v1/image-builds` for a build). Those rows are private to their owner, tagged + **My image** in the wizard, and one member may hold at most 20 of them. The operator reports GPU inventory automatically, so nodes and devices (for example RTX 4090) appear on the infrastructure screens without further setup. diff --git a/docs/multi-cluster.md b/docs/multi-cluster.md index 9859483..976e423 100644 --- a/docs/multi-cluster.md +++ b/docs/multi-cluster.md @@ -163,8 +163,8 @@ when the attach is done. ### Registering a pool -A pool is a registered object, not something inferred from node roles. Register it with -`POST /api/v1/storage/pools` (there is no console screen for pools yet) with: +A pool is a registered object, not something inferred from node roles. Register it in the console +(**Resources → Volumes → Storage pools**) or with `POST /api/v1/storage/pools`, giving: - **cluster** — the one its storage server sits in; - **storage class** — the class that provisions from it, the same name the operator is given as diff --git a/docs/operations/observability.md b/docs/operations/observability.md index 0b415af..cb46692 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -49,8 +49,8 @@ the agent is the one-second live view and nothing depends on it. ## Health and alerts -- `/health` on the API reports database and Redis reachability. -- The operator posts node inventory once a minute; a node silent for `api.nodeStaleSec` goes +- `/healthz` on the API is a plain liveness probe; it does not check database or Redis reachability. +- The operator posts node inventory every 15 seconds; a node silent for `api.nodeStaleSec` goes **offline** and every super_admin is notified. Fatal Xid events from DCGM cordon the node. - The audit log records `access.denied` entries, de-duplicated per minute, so a misbehaving client is visible without log scraping. diff --git a/docs/operations/storage.md b/docs/operations/storage.md index 537b87a..5828283 100644 --- a/docs/operations/storage.md +++ b/docs/operations/storage.md @@ -28,9 +28,9 @@ node of the cluster can mount. ## Registering a pool -A pool is a registered object, not something inferred from node roles. There is no console -screen for it yet: register it with `POST /api/v1/storage/pools` (the dashboard's storage tile -then shows it): +A pool is a registered object, not something inferred from node roles. Register it in the console +under **Resources → Volumes → Storage pools** ([Administrator guide → Storage](../admin/storage.md#registering-a-server)) +or with `POST /api/v1/storage/pools`: | Field | Meaning | |---|---| diff --git a/docs/reference/api.md b/docs/reference/api.md index f1b8147..81db609 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -54,13 +54,13 @@ endpoints until they do. | `/queue` | Queue entries, cancel, priority | | `/credits`, `/budgets` | Wallets, allocations, requests, refills, ledger; organization budgets | | `/storage/volumes`, `/storage/pools` | Volumes, shares, quota, locks; storage pools | -| `/offerings`, `/presets`, `/images`, `/resource-policies` | The catalogue and policies, including quota requests | +| `/offerings`, `/resource-presets`, `/images`, `/resource-policies` | The catalogue and policies, including quota requests | | `/clusters`, `/nodes`, `/gpu-devices`, `/node-pools` | Infrastructure: registration, cordon/drain/delete, cards, pools | | `/monitoring`, `/dashboard`, `/metrics` | Metrics proxies and summaries | -| `/audit` | Audit log with filters and CSV export | +| `/audit-logs` | Audit log with filters and CSV export | | `/notifications`, `/webhooks` | The bell, and outbound webhooks per organization | | `/system` | Branding, sign-up policy, placement policy | -| `/health` | Liveness for probes | +| `/healthz` (at the root, not under `/api/v1`) | Liveness for probes | The **internal** plane (`/internal/...`, `/.well-known/gshare-internal-jwks.json`) is for operators only and is authenticated with the RS256 internal JWT, never a user token. diff --git a/docs/reference/glossary.md b/docs/reference/glossary.md index a400e89..3dcff98 100644 --- a/docs/reference/glossary.md +++ b/docs/reference/glossary.md @@ -45,7 +45,7 @@ later, keeping volumes and the hold. **Policy (resource policy)** — quotas: concurrency, VRAM, cores, host resources, volume quota, runtime cap, idle timeout. Resolves user → group → organization → global. -**Preset** — a compute shape plus a GPU fraction tier (XL ½ … SS 1/32) or exclusive. +**Preset** — a compute shape plus a GPU fraction tier (XL ½ … S 1/16) or exclusive. **Queue** — where a session waits when no card fits; admitted in priority order as capacity returns. diff --git a/docs/reference/helm-values.md b/docs/reference/helm-values.md index eafee27..aa4df31 100644 --- a/docs/reference/helm-values.md +++ b/docs/reference/helm-values.md @@ -16,7 +16,7 @@ knob). | Key | Purpose | |---|---| | `global.imageRegistry`, `global.imagePullSecrets` | Registry prefix and pull secrets for every image. | -| `global.namespaces` | `system` (control plane) and `sessions` (workloads). | +| `global.namespaces` | `system` (control plane), `sessions` (workloads) and `infra` (privileged node jobs). | | `global.domains`, `global.sessionUrlScheme` | Console and session domains; `https` or `http`. | | `controlPlane.enabled` | `false` on a cluster that only runs an operator (attached cluster). | | `controlPlane.nodeSelector` | Pin api, worker, frontend, data tier, and backup jobs to nodes. | @@ -28,7 +28,8 @@ knob). | `podDisruptionBudget.enabled` | minAvailable 1 for api, worker, frontend. | | `secrets.generate` | Let the chart create passwords and keys, or bring your own. | | `ingress.*`, `ingress.internalPlane` | Console ingress and the whitelisted internal plane attached operators call. | -| `serviceAccounts.*` | Names and annotations. | +| `serviceAccounts.*` | Service-account names for the controller (api/worker) and the operator. | +| `networkPolicy.*` | Deny-all plus ingress/egress allowlists for the session namespace. Off by default — see below. | ## `api` @@ -42,10 +43,12 @@ knob). | `seedSessionImages` | true | Seed the `boanlab/gshare-session` catalogue. | | `gpuPacking` | binpack | `binpack` or `spread` for fractional placement. | | `volumeReclaimGraceHours` | 24 | Keep a deleted volume's data this long. | +| `forwardedAllowIps` | `*` | Peers uvicorn trusts to set `X-Forwarded-*` (`--forwarded-allow-ips`); narrow to the ingress controller's addresses where possible. | +| `trustedProxyHops` | 1 | Proxies that append to `X-Forwarded-For` before the api (`GSHARE_TRUSTED_PROXY_HOPS`); `2` with a load balancer in front of the ingress. | ## `worker` -`replicas`, `resources`, `gracePeriodSec` (billing settle window), `yieldReservationTtlSec`. +`replicas`, `resources`, `gracePeriodSec` (grace window after credit exhaustion before a session is paused or yielded), `yieldReservationTtlSec`. ## `operator` @@ -61,6 +64,20 @@ knob). | `losslessAgentImage`, `migAgentImage`, `kanikoImage` | Optional sidecar and build images. | | `webhook.*`, `hamiYieldExtender` | The lend-guard admission webhook and HAMi yield extender. | +## `networkPolicy` + +Off by default (`networkPolicy.enabled: false`). Enabling it renders a deny-all policy for the +session namespace plus allowlists: ingress from `ingressNamespace` on `sessionPorts`, egress to +DNS in `dnsNamespace`, and egress to `storage.cidrs` on `storage.ports`. `cpuData.cidrs` adds a +second egress rule for CPU data-prep sessions only. + +The CIDRs are cluster-specific and start empty, which is why the whole block is off: enabling it +without a storage CIDR leaves sessions able to resolve DNS and reach nothing else. The same +manifests stand alone in `deploy/security/` for installs that do not use this chart. + +The policies select session pods by the `gshare.io/workload=session` label (CPU sessions also by +`gshare.io/resource-class=cpu`), which the operator stamps on every session pod. + ## `frontend`, `agent` `frontend.replicas`, `frontend.port`, `frontend.resources`; `agent.enabled`, `agent.intervalMs`, diff --git a/docs/user/data-mount.md b/docs/user/data-mount.md index 7077844..e135aaf 100644 --- a/docs/user/data-mount.md +++ b/docs/user/data-mount.md @@ -49,8 +49,9 @@ Click a volume in the list to expand it. ![Sessions holding the volume](/img/screens/data-mounts.png) -The panel lists the sessions that hold the volume **right now**, with their owner and mount -mode. It is also the answer when a delete is refused: a volume with an active mount cannot be +The panel first says **where the volume's data lives** — the storage server it was created on, +or *not provisioned yet* if nothing has mounted it — and then lists the sessions that hold the +volume **right now**, with their owner and mount mode. It is also the answer when a delete is refused: a volume with an active mount cannot be deleted, and this is where you find out which session to end first. ## Changing what a running session mounts diff --git a/docs/user/sessions-create.md b/docs/user/sessions-create.md index 9180cd0..4e5e11e 100644 --- a/docs/user/sessions-create.md +++ b/docs/user/sessions-create.md @@ -165,7 +165,7 @@ The tags on each tile tell you what you are getting: and `import torch` then fails. Pick a CUDA image unless you intend to install the toolkit yourself. -Images marked **My image** are ones you built or imported yourself; see +Images marked **My image** are ones registered for you alone rather than for everyone; see [Images](../admin/images.md) for how the catalogue is filled. --- diff --git a/frontend/openapi.json b/frontend/openapi.json index 09e0ae8..708e403 100644 --- a/frontend/openapi.json +++ b/frontend/openapi.json @@ -1 +1 @@ -{"openapi": "3.1.0", "info": {"title": "gShare API", "version": "v1"}, "paths": {"/api/v1/auth/me": {"get": {"tags": ["users"], "summary": "Auth Me", "description": "Return identity/role/memberships of the current principal.\n\nmemberships is returned as a list of {group_id, project_name, role} for the console's context\nswitcher and RBAC, converted from the internal {project_id: role} mapping with names resolved.", "operationId": "auth_me_api_v1_auth_me_get", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MeResponse"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/auth/login": {"post": {"tags": ["users"], "summary": "Auth Login", "description": "Email+password local login (gated by AUTH_ALLOW_LOCAL_PASSWORD).\n\nLooks the user up by email and issues an HS256 token signed with USER_JWT_SECRET, which\njwt_auth.verify_jwt verifies.", "operationId": "auth_login_api_v1_auth_login_post", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/_LoginRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/auth/signup": {"post": {"tags": ["users"], "summary": "Auth Signup", "description": "Self-service registration, governed by the sign-up policy in system settings.\n\n`closed` refuses outright; `open` creates a usable account; `approval` creates it as\n``pending``, which cannot sign in until an administrator approves it. Either way the account\nstarts with **no department** \u2014 an administrator assigns one at approval, or later. Until then\nthe account resolves to the global resource policy and has no group wallet to draw credits\nfrom, which is why the approval screen assigns a department in the same step.", "operationId": "auth_signup_api_v1_auth_signup_post", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/_SignupRequest"}}}, "required": true}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/users/{user_id}/approve": {"post": {"tags": ["users"], "summary": "Approve User", "description": "Approve a self-registered account: assign the department, activate, grant welcome credit.\n\nOne step rather than three, because a half-approved account (active, no department, no\ncredits) is a state nobody wants to find. super_admin, or an org_admin for a group of theirs.", "operationId": "approve_user_api_v1_users__user_id__approve_post", "parameters": [{"name": "user_id", "in": "path", "required": true, "schema": {"type": "string", "title": "User Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserApproveBody"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/auth/change-password": {"post": {"tags": ["users"], "summary": "Change Password", "description": "Change your own password. Unless must_change_password is set, the current password is\nverified. A fresh token is issued afterwards.", "operationId": "change_password_api_v1_auth_change_password_post", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ChangePasswordRequest"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/users": {"get": {"tags": ["users"], "summary": "List Users", "description": "Paginated user list. super_admin, org_admin, or group_admin, the latter two scoped to the\ngroups they administer.", "operationId": "list_users_api_v1_users_get", "parameters": [{"name": "status", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Status"}}, {"name": "q", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Q"}}, {"name": "org_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Org Id"}}, {"name": "group_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Group Id"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 1, "default": 1, "title": "Page"}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 100, "minimum": 1, "default": 20, "title": "Size"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["users"], "summary": "Create User", "description": "Create a user + enroll them into the chosen group with an initial membership role.\n\nThe new user is created in ``active`` (or ``suspended``) state; ``invited`` is reserved for the\ninvite-mail flow. A group is mandatory \u2014 the user joins it on creation (org is derived from the\ngroup). Email is UNIQUE (-> 409 conflict). super_admin\u00b7org_admin.", "operationId": "create_user_api_v1_users_post", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserCreate"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/users/bulk": {"post": {"tags": ["users"], "summary": "Bulk Create Users", "description": "Roster import: create up to 200 users per request, each with a personal wallet, a\nmembership in the chosen group, and a server-generated initial password returned once in the\nresponse (must_change_password forces rotation at first login).\n\nPartial success by design: rows report ``created`` / ``exists`` / ``invalid`` individually so\nre-uploading a roster is safe \u2014 existing accounts are reported, not fatal. The whole batch is\nidempotent on the Idempotency-Key (a replay returns the stored result, passwords included,\nfor 24h). One audit record per batch, not one per student. super_admin\u00b7org_admin.", "operationId": "bulk_create_users_api_v1_users_bulk_post", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}, {"name": "Idempotency-Key", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Idempotency-Key"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BulkUserCreate"}}}}, "responses": {"207": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/users/resolve": {"get": {"tags": ["users"], "summary": "Resolve User", "description": "Resolve an EXACT email to {id, name} for the volume-share confirm step.\n\nAny authenticated user may call it: sharing needs \"does this address exist, and who is it\",\nand members cannot list users. Only an exact match answers \u2014 no enumeration surface beyond\nwhat grant-by-email already reveals.", "operationId": "resolve_user_api_v1_users_resolve_get", "parameters": [{"name": "email", "in": "query", "required": true, "schema": {"type": "string", "minLength": 3, "maxLength": 254, "title": "Email"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/users/{user_id}": {"get": {"tags": ["users"], "summary": "Get User", "description": "User detail incl. memberships. self or super_admin/org_admin.", "operationId": "get_user_api_v1_users__user_id__get", "parameters": [{"name": "user_id", "in": "path", "required": true, "schema": {"type": "string", "title": "User Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "patch": {"tags": ["users"], "summary": "Update User", "description": "Update a user. What may be changed depends on the caller's role.\n\n- super_admin: email, name, status, and password reset. Group membership has its own endpoint.\n- org_admin: password reset (and group) for users in their organization. Not email or name.\n- group_admin: password reset only, for users in their group.\n- The user themselves: their display name only.", "operationId": "update_user_api_v1_users__user_id__patch", "parameters": [{"name": "user_id", "in": "path", "required": true, "schema": {"type": "string", "title": "User Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserPatch"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["users"], "summary": "Delete User", "description": "Deactivate (soft, default) or hard-delete a user.\n\nDefault = soft delete (``status=suspended`` + ``deleted_at``). ``?hard=true`` is\nsuper_admin-only and refused when a live session exists (ledger preservation -> 409 conflict).", "operationId": "delete_user_api_v1_users__user_id__delete", "parameters": [{"name": "user_id", "in": "path", "required": true, "schema": {"type": "string", "title": "User Id"}}, {"name": "hard", "in": "query", "required": false, "schema": {"type": "boolean", "default": false, "title": "Hard"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/users/{user_id}/usage": {"get": {"tags": ["users"], "summary": "Get User Usage", "description": "Live resource footprint: sessions, host compute, GPU slices, volumes, wallet.\n\nSelf or an administrator (``user.read``). Everything is CURRENT state, not history \u2014 the\nadmin drawer answers \"what is this user holding right now?\".", "operationId": "get_user_usage_api_v1_users__user_id__usage_get", "parameters": [{"name": "user_id", "in": "path", "required": true, "schema": {"type": "string", "title": "User Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/users/{user_id}/department": {"put": {"tags": ["users"], "summary": "Set User Department", "description": "Set, or move, a user's group. super_admin or org_admin.\n\n- super_admin: move to any group, or pass None to remove all group membership.\n- org_admin: move only within their own organization; memberships elsewhere are left alone.\n\nIf the user is not already in the target group they are added as a member, and their previous\ngroup memberships within the caller's scope are removed.", "operationId": "set_user_department_api_v1_users__user_id__department_put", "parameters": [{"name": "user_id", "in": "path", "required": true, "schema": {"type": "string", "title": "User Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserDepartmentSet"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/users/{user_id}/global-role": {"put": {"tags": ["users"], "summary": "Set Global Role", "description": "Grant or revoke global roles; super_admin only. Several roles may be granted at once.", "operationId": "set_global_role_api_v1_users__user_id__global_role_put", "parameters": [{"name": "user_id", "in": "path", "required": true, "schema": {"type": "string", "title": "User Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GlobalRoleSet"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/organizations": {"get": {"tags": ["groups"], "summary": "List Organizations", "description": "List organizations. super_admin sees all; org_admin sees only their own (tenant\nisolation).", "operationId": "list_organizations_api_v1_organizations_get", "parameters": [{"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 1, "default": 1, "title": "Page"}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 100, "minimum": 1, "default": 20, "title": "Size"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["groups"], "summary": "Create Organization", "description": "Create an organization. super_admin only.", "operationId": "create_organization_api_v1_organizations_post", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/OrgCreate"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/organizations/{org_id}": {"get": {"tags": ["groups"], "summary": "Get Organization", "description": "Fetch one organization, for deep links from the edit and admin pages.", "operationId": "get_organization_api_v1_organizations__org_id__get", "parameters": [{"name": "org_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Org Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "patch": {"tags": ["groups"], "summary": "Update Organization", "description": "Update an organization's name or status. super_admin only.", "operationId": "update_organization_api_v1_organizations__org_id__patch", "parameters": [{"name": "org_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Org Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/OrgUpdate"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["groups"], "summary": "Delete Organization", "description": "Soft-delete an organization. super_admin only; 409 while live groups remain, to avoid\norphans.", "operationId": "delete_organization_api_v1_organizations__org_id__delete", "parameters": [{"name": "org_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Org Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/organizations/{org_id}/admins": {"get": {"tags": ["groups"], "summary": "List Org Admins", "description": "List an organization's admins. super_admin, or an org_admin of that organization.", "operationId": "list_org_admins_api_v1_organizations__org_id__admins_get", "parameters": [{"name": "org_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Org Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["groups"], "summary": "Add Org Admin", "description": "Appoint an organization admin. super_admin only.", "operationId": "add_org_admin_api_v1_organizations__org_id__admins_post", "parameters": [{"name": "org_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Org Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/OrgAdminCreate"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/organizations/{org_id}/admins/{user_id}": {"delete": {"tags": ["groups"], "summary": "Remove Org Admin", "description": "Remove an organization admin. super_admin only.", "operationId": "remove_org_admin_api_v1_organizations__org_id__admins__user_id__delete", "parameters": [{"name": "org_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Org Id"}}, {"name": "user_id", "in": "path", "required": true, "schema": {"type": "string", "title": "User Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/projects": {"get": {"tags": ["groups"], "summary": "List Projects", "description": "List groups. super_admin sees all; everyone else sees only the groups they belong to.", "operationId": "list_projects_api_v1_projects_get", "parameters": [{"name": "org_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Org Id"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 1, "default": 1, "title": "Page"}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 100, "minimum": 1, "default": 20, "title": "Size"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["groups"], "summary": "Create Project", "description": "Create a group along with its default wallet. super_admin or org_admin.", "operationId": "create_project_api_v1_projects_post", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ProjectCreate"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/projects/{group_id}": {"get": {"tags": ["groups"], "summary": "Get Project", "description": "Fetch one group, for deep links from the edit, admin, and delete pages.", "operationId": "get_project_api_v1_projects__group_id__get", "parameters": [{"name": "group_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Group Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "patch": {"tags": ["groups"], "summary": "Update Project", "description": "Update a group's name or archived status. super_admin or group_admin.", "operationId": "update_project_api_v1_projects__group_id__patch", "parameters": [{"name": "group_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Group Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ProjectPatch"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["groups"], "summary": "Delete Project", "description": "Soft-delete a group. super_admin or org_admin; 409 while live sessions remain.\n\nMemberships are removed with it, so a soft-deleted group leaves no permissions behind. The\ngroup wallet is preserved.", "operationId": "delete_project_api_v1_projects__group_id__delete", "parameters": [{"name": "group_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Group Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/projects/{group_id}/memberships": {"get": {"tags": ["groups"], "summary": "List Memberships", "description": "List a group's memberships. super_admin or group_admin.", "operationId": "list_memberships_api_v1_projects__group_id__memberships_get", "parameters": [{"name": "group_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Group Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["groups"], "summary": "Add Membership", "description": "Add a member or grant a role. A guest must carry expires_at. super_admin or group_admin.", "operationId": "add_membership_api_v1_projects__group_id__memberships_post", "parameters": [{"name": "group_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Group Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MembershipCreate"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/projects/{group_id}/memberships/{membership_id}": {"patch": {"tags": ["groups"], "summary": "Update Membership", "description": "Change a member's role. Demoting the last admin returns 409. super_admin or group_admin.", "operationId": "update_membership_api_v1_projects__group_id__memberships__membership_id__patch", "parameters": [{"name": "group_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Group Id"}}, {"name": "membership_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Membership Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MembershipPatch"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["groups"], "summary": "Remove Membership", "description": "Remove a member. Removing the last admin returns 409. super_admin or group_admin.", "operationId": "remove_membership_api_v1_projects__group_id__memberships__membership_id__delete", "parameters": [{"name": "group_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Group Id"}}, {"name": "membership_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Membership Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/credits/wallets": {"get": {"tags": ["credits"], "summary": "List Wallets", "operationId": "list_wallets_api_v1_credits_wallets_get", "parameters": [{"name": "owner_type", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Owner Type"}}, {"name": "owner_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Owner Id"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 1, "default": 1, "title": "Page"}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 100, "minimum": 1, "default": 20, "title": "Size"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/WalletRead"}, "title": "Response List Wallets Api V1 Credits Wallets Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/credits/wallets/me": {"get": {"tags": ["credits"], "summary": "My Wallet", "operationId": "my_wallet_api_v1_credits_wallets_me_get", "parameters": [{"name": "group_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Group Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/WalletRead"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/credits/wallets/{wallet_id}": {"get": {"tags": ["credits"], "summary": "Get Wallet", "operationId": "get_wallet_api_v1_credits_wallets__wallet_id__get", "parameters": [{"name": "wallet_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Wallet Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/WalletRead"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/credits/wallets/{wallet_id}/topup": {"post": {"tags": ["credits"], "summary": "Topup", "operationId": "topup_api_v1_credits_wallets__wallet_id__topup_post", "parameters": [{"name": "wallet_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Wallet Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}, {"name": "Idempotency-Key", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Idempotency-Key"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/TopupRequestBody"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/credits/wallets/{wallet_id}/adjust": {"post": {"tags": ["credits"], "summary": "Adjust", "operationId": "adjust_api_v1_credits_wallets__wallet_id__adjust_post", "parameters": [{"name": "wallet_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Wallet Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}, {"name": "Idempotency-Key", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Idempotency-Key"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdjustBody"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/credits/wallets/{wallet_id}/transfer": {"post": {"tags": ["credits"], "summary": "Transfer", "operationId": "transfer_api_v1_credits_wallets__wallet_id__transfer_post", "parameters": [{"name": "wallet_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Wallet Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}, {"name": "Idempotency-Key", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Idempotency-Key"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/TransferBody"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/credits/allocation-scope": {"get": {"tags": ["credits"], "summary": "Allocation Scope", "description": "Return the pools the caller can allocate from, and each pool's targets, with names.\n\nAn org_admin gets their organization's wallet, whose children are that organization's groups; a\ngroup_admin gets their group's wallet, whose children are its members; a super_admin gets\neverything. The UI uses this to pick a pool, populate its targets, and toggle direction between\nallocate and reclaim.", "operationId": "allocation_scope_api_v1_credits_allocation_scope_get", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/credits/refill-schedule": {"get": {"tags": ["credits"], "summary": "Get Refill Schedule", "description": "When the monthly refill fires: day-of-month and hour (KST). Defaults: day 1, 00:00.", "operationId": "get_refill_schedule_api_v1_credits_refill_schedule_get", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "put": {"tags": ["credits"], "summary": "Set Refill Schedule", "description": "Set the refill day/hour. super_admin \u2014 the same authority that sets the monthly total.", "operationId": "set_refill_schedule_api_v1_credits_refill_schedule_put", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/RefillScheduleBody"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/credits/allocate": {"post": {"tags": ["credits"], "summary": "Allocate", "description": "Allocate credits down the hierarchy (organization -> project -> user), or reclaim upwards.\nAn idempotency key is required.\n\n\"Within your allowance\" is enforced automatically by the source wallet's available balance, and\nexceeding it returns 402. Authorization is handled by _assert_can_allocate.", "operationId": "allocate_api_v1_credits_allocate_post", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}, {"name": "Idempotency-Key", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Idempotency-Key"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AllocateBody"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/credits/wallets/{wallet_id}/monthly-grant": {"post": {"tags": ["credits"], "summary": "Set Monthly Grant", "description": "Set a child wallet's monthly automatic refill, as the administrator one level up.\n\nThe siblings' grants must sum within the parent's grant; 0 disables refills for that wallet. The\nrefill itself \u2014 resetting balance to grant at the start of each month, use-it-or-lose-it \u2014 is\nperformed by the credit_refill worker.", "operationId": "set_monthly_grant_api_v1_credits_wallets__wallet_id__monthly_grant_post", "parameters": [{"name": "wallet_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Wallet Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MonthlyGrantBody"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/WalletRead"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/credits/bulk-allocate": {"post": {"tags": ["credits"], "summary": "Bulk Allocate", "description": "Allocate ``amount`` from the group's wallet to EVERY member's personal wallet at once.\n\nThe source balance is checked up front against n\u00d7amount (402 on shortfall \u2014 all or nothing,\nno partially funded cohort), and every per-wallet transaction carries an idempotency key\nderived from the batch key, so a replay after a crash completes exactly once per member.\ngroup_admin and above.", "operationId": "bulk_allocate_api_v1_credits_bulk_allocate_post", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}, {"name": "Idempotency-Key", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Idempotency-Key"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BulkAllocateBody"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/credits/bulk-monthly-grant": {"post": {"tags": ["credits"], "summary": "Bulk Monthly Grant", "description": "Set the same monthly refill on every member wallet of the group.\n\nThe ceiling check runs once for the whole cohort: n\u00d7amount must stay within the group\nwallet's own monthly grant (the same sibling-sum invariant set_monthly_grant enforces\nper wallet, without the O(n\u00b2) of calling it n times). Increases credit immediately, like\nthe single-wallet endpoint. group_admin and above.", "operationId": "bulk_monthly_grant_api_v1_credits_bulk_monthly_grant_post", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BulkMonthlyGrantBody"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/credits/allocation-requests": {"post": {"tags": ["credits"], "summary": "Create Allocation Request", "description": "Create a credit allocation request, routed one level up: a user's goes to the group admin, a\ngroup's to the organization admin, an organization's to a super_admin.", "operationId": "create_allocation_request_api_v1_credits_allocation_requests_post", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AllocationRequestCreate"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "get": {"tags": ["credits"], "summary": "List Allocation Requests", "description": "box=mine lists the requests you raised; box=incoming lists pending requests you can\napprove; box=handled lists the decided ones from the same inbox \u2014 the approver's history.", "operationId": "list_allocation_requests_api_v1_credits_allocation_requests_get", "parameters": [{"name": "box", "in": "query", "required": false, "schema": {"type": "string", "pattern": "^(incoming|mine|handled)$", "default": "incoming", "title": "Box"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 1, "default": 1, "title": "Page"}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 100, "minimum": 1, "default": 20, "title": "Size"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/credits/allocation-requests/{request_id}/approve": {"post": {"tags": ["credits"], "summary": "Approve Allocation Request", "description": "Approve a request: allocate from the parent wallet to the target, or top the target up when\nthe parent is the system wallet. An insufficient pool returns 409, prompting escalation.", "operationId": "approve_allocation_request_api_v1_credits_allocation_requests__request_id__approve_post", "parameters": [{"name": "request_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Request Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}, {"name": "Idempotency-Key", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Idempotency-Key"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/credits/allocation-requests/{request_id}/reject": {"post": {"tags": ["credits"], "summary": "Reject Allocation Request", "operationId": "reject_allocation_request_api_v1_credits_allocation_requests__request_id__reject_post", "parameters": [{"name": "request_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Request Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AllocationRejectBody"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/credits/wallets/{wallet_id}/spend-daily": {"get": {"tags": ["credits"], "summary": "Spend Daily", "description": "Daily spend (consume + storage) over [from, to], for the wallet's usage chart.\n\nAggregation happens here rather than over the paged ledger: a chart drawn from page one of the\ntransactions would silently truncate. Buckets follow the caller's clock via tz_offset_min\n(KST = +540), since \"a day\" on the chart is the user's day, not UTC's. Aggregated in Python so\nthe same query runs on Postgres and the SQLite test harness; the range is capped to a year.", "operationId": "spend_daily_api_v1_credits_wallets__wallet_id__spend_daily_get", "parameters": [{"name": "wallet_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Wallet Id"}}, {"name": "from", "in": "query", "required": true, "schema": {"type": "string", "format": "date", "title": "From"}}, {"name": "to", "in": "query", "required": true, "schema": {"type": "string", "format": "date", "title": "To"}}, {"name": "tz_offset_min", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 840, "minimum": -840, "default": 0, "title": "Tz Offset Min"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/SpendDayRead"}, "title": "Response Spend Daily Api V1 Credits Wallets Wallet Id Spend Daily Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/credits/wallets/{wallet_id}/transactions": {"get": {"tags": ["credits"], "summary": "Transactions", "description": "The wallet ledger.\n\ngroup=\"session\" rolls the per-minute consume rows of one session into a single line (period,\ntotal, entry count); every other transaction type is a discrete event and passes through\nuntouched. Grouping happens in SQL because a long session produces hundreds of rows: folding\nthem client-side would only fold whatever landed on the current page.", "operationId": "transactions_api_v1_credits_wallets__wallet_id__transactions_get", "parameters": [{"name": "wallet_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Wallet Id"}}, {"name": "group", "in": "query", "required": false, "schema": {"type": "string", "pattern": "^(none|session)$", "default": "none", "title": "Group"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 1, "default": 1, "title": "Page"}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 100, "minimum": 1, "default": 20, "title": "Size"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/TransactionRead"}, "title": "Response Transactions Api V1 Credits Wallets Wallet Id Transactions Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/credits/wallets/{wallet_id}/holds": {"get": {"tags": ["credits"], "summary": "Holds", "description": "Active reservations + per-session settle status.\n\nReconstructed from the ledger: hold/consume/settle/refund txns are grouped by ``ref``\n(session id). A session with a hold but no settle is ``active``; otherwise ``settled``.", "operationId": "holds_api_v1_credits_wallets__wallet_id__holds_get", "parameters": [{"name": "wallet_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Wallet Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/credits/topup-requests": {"post": {"tags": ["credits"], "summary": "Create Topup Request", "operationId": "create_topup_request_api_v1_credits_topup_requests_post", "parameters": [{"name": "wallet_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Wallet Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/TopupRequestBody"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "get": {"tags": ["credits"], "summary": "List Topup Requests", "operationId": "list_topup_requests_api_v1_credits_topup_requests_get", "parameters": [{"name": "status", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Status"}}, {"name": "wallet_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Wallet Id"}}, {"name": "scope", "in": "query", "required": false, "schema": {"type": "string", "pattern": "^(mine|all)$", "default": "mine", "title": "Scope"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 1, "default": 1, "title": "Page"}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 100, "minimum": 1, "default": 20, "title": "Size"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/TopupRequestListResponse"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/credits/topup-requests/{request_id}/approve": {"post": {"tags": ["credits"], "summary": "Approve Topup Request", "operationId": "approve_topup_request_api_v1_credits_topup_requests__request_id__approve_post", "parameters": [{"name": "request_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Request Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}, {"name": "Idempotency-Key", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Idempotency-Key"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/credits/topup-requests/{request_id}/reject": {"post": {"tags": ["credits"], "summary": "Reject Topup Request", "operationId": "reject_topup_request_api_v1_credits_topup_requests__request_id__reject_post", "parameters": [{"name": "request_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Request Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/TopupRejectBody"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/budgets": {"get": {"tags": ["budgets"], "summary": "List Budgets", "operationId": "list_budgets_api_v1_budgets_get", "parameters": [{"name": "scope", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Scope"}}, {"name": "scope_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Scope Id"}}, {"name": "action", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Action"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 1, "default": 1, "title": "Page"}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 100, "minimum": 1, "default": 20, "title": "Size"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/BudgetRead"}, "title": "Response List Budgets Api V1 Budgets Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["budgets"], "summary": "Create Budget", "operationId": "create_budget_api_v1_budgets_post", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BudgetCreate"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BudgetRead"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/budgets/{budget_id}": {"get": {"tags": ["budgets"], "summary": "Get Budget", "operationId": "get_budget_api_v1_budgets__budget_id__get", "parameters": [{"name": "budget_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Budget Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BudgetRead"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "patch": {"tags": ["budgets"], "summary": "Update Budget", "operationId": "update_budget_api_v1_budgets__budget_id__patch", "parameters": [{"name": "budget_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Budget Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BudgetUpdate"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BudgetRead"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["budgets"], "summary": "Delete Budget", "operationId": "delete_budget_api_v1_budgets__budget_id__delete", "parameters": [{"name": "budget_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Budget Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/budgets/{budget_id}/forecast": {"get": {"tags": ["budgets"], "summary": "Forecast", "description": "Project depletion from recent burn rate.\n\nburn_rate_per_day = consume over the lookback window / lookback days.\nprojected_depletion_at = now + remaining_credit / burn_rate_per_day.", "operationId": "forecast_api_v1_budgets__budget_id__forecast_get", "parameters": [{"name": "budget_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Budget Id"}}, {"name": "lookback", "in": "query", "required": false, "schema": {"type": "string", "pattern": "^(7d|14d|30d)$", "default": "7d", "title": "Lookback"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BudgetForecast"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/offerings": {"get": {"tags": ["offerings"], "summary": "List Offerings", "description": "Catalog list. Authenticated-but-open read; no admin gate.\n\nRetired (status=inactive) offerings are hidden from non-admins; administrators still see\nthem so they can reactivate or audit pricing.", "operationId": "list_offerings_api_v1_offerings_get", "parameters": [{"name": "gpu_model", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Gpu Model"}}, {"name": "q", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Q"}}, {"name": "sort", "in": "query", "required": false, "schema": {"type": "string", "default": "credit_per_hour", "title": "Sort"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 1, "default": 1, "title": "Page"}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 100, "minimum": 1, "default": 20, "title": "Size"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["offerings"], "summary": "Create Offering", "operationId": "create_offering_api_v1_offerings_post", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/OfferingCreate"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/offerings/{offering_id}": {"get": {"tags": ["offerings"], "summary": "Get Offering", "description": "Fetch one offering, for deep links from the edit page. Readable by any authenticated\ncaller.", "operationId": "get_offering_api_v1_offerings__offering_id__get", "parameters": [{"name": "offering_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Offering Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "patch": {"tags": ["offerings"], "summary": "Update Offering", "description": "Update offering; appends to price history on credit_per_hour change.", "operationId": "update_offering_api_v1_offerings__offering_id__patch", "parameters": [{"name": "offering_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Offering Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/OfferingUpdate"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["offerings"], "summary": "Delete Offering", "description": "Delete an offering; super_admin only. Rejected while a session still references it \u2014\ndeactivate it instead.", "operationId": "delete_offering_api_v1_offerings__offering_id__delete", "parameters": [{"name": "offering_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Offering Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/offerings/{offering_id}/price-history": {"get": {"tags": ["offerings"], "summary": "Price History", "operationId": "price_history_api_v1_offerings__offering_id__price_history_get", "parameters": [{"name": "offering_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Offering Id"}}, {"name": "from", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "From"}}, {"name": "to", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "To"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 1, "default": 1, "title": "Page"}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 100, "minimum": 1, "default": 20, "title": "Size"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/resource-presets": {"get": {"tags": ["presets"], "summary": "List Presets", "operationId": "list_presets_api_v1_resource_presets_get", "parameters": [{"name": "q", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Q"}}, {"name": "kind", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Kind"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 1, "default": 1, "title": "Page"}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 100, "minimum": 1, "default": 20, "title": "Size"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["presets"], "summary": "Create Preset", "operationId": "create_preset_api_v1_resource_presets_post", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PresetCreate"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/resource-presets/{preset_id}": {"get": {"tags": ["presets"], "summary": "Get Preset", "description": "Fetch one preset, for deep links from the edit page.", "operationId": "get_preset_api_v1_resource_presets__preset_id__get", "parameters": [{"name": "preset_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Preset Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "patch": {"tags": ["presets"], "summary": "Update Preset", "description": "Update a preset; super_admin only. kind is immutable, and only the fields belonging to that\nkind are applied.", "operationId": "update_preset_api_v1_resource_presets__preset_id__patch", "parameters": [{"name": "preset_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Preset Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PresetUpdate"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["presets"], "summary": "Delete Preset", "description": "Delete a preset. Same authority as preset.create: super_admin.", "operationId": "delete_preset_api_v1_resource_presets__preset_id__delete", "parameters": [{"name": "preset_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Preset Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/resource-policies/effective": {"get": {"tags": ["policies"], "summary": "Effective Policy", "description": "The effective policy for the caller, with current usage and headroom, so the session wizard\ncan show the limits.\n\nResolution is the per-field merge in app.domain.policy (user \u2192 group \u2192 org \u2192 global), the\nsame module the admission gate uses. Caps are per user, so usage sums over the CALLER's\nactive sessions regardless of group.", "operationId": "effective_policy_api_v1_resource_policies_effective_get", "parameters": [{"name": "group_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Group Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/resource-policies": {"get": {"tags": ["policies"], "summary": "List Policies", "operationId": "list_policies_api_v1_resource_policies_get", "parameters": [{"name": "scope", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Scope"}}, {"name": "scope_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Scope Id"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 1, "default": 1, "title": "Page"}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 100, "minimum": 1, "default": 20, "title": "Size"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["policies"], "summary": "Create Policy", "operationId": "create_policy_api_v1_resource_policies_post", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PolicyCreate"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/resource-policies/requests": {"post": {"tags": ["policies"], "summary": "Create Resource Request", "description": "Ask for a bigger compute quota. At least one target, all positive; note required.", "operationId": "create_resource_request_api_v1_resource_policies_requests_post", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ResourceRequestCreate"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "get": {"tags": ["policies"], "summary": "List Resource Requests", "operationId": "list_resource_requests_api_v1_resource_policies_requests_get", "parameters": [{"name": "box", "in": "query", "required": false, "schema": {"type": "string", "pattern": "^(mine|incoming)$", "default": "mine", "title": "Box"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/resource-policies/requests/{request_id}/approve": {"post": {"tags": ["policies"], "summary": "Approve Resource Request", "description": "Approve: upsert the requester's USER-scope policy with ONLY the granted limit keys.\n\nEverything not granted stays inherited (per-field most-specific merge), so the group/global\ndefaults remain the single source for the rest of the policy.", "operationId": "approve_resource_request_api_v1_resource_policies_requests__request_id__approve_post", "parameters": [{"name": "request_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Request Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/resource-policies/requests/{request_id}/reject": {"post": {"tags": ["policies"], "summary": "Reject Resource Request", "operationId": "reject_resource_request_api_v1_resource_policies_requests__request_id__reject_post", "parameters": [{"name": "request_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Request Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/_RRReject"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/resource-policies/{policy_id}": {"get": {"tags": ["policies"], "summary": "Get Policy", "description": "Fetch one policy, for deep links from the edit page.", "operationId": "get_policy_api_v1_resource_policies__policy_id__get", "parameters": [{"name": "policy_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Policy Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "patch": {"tags": ["policies"], "summary": "Update Policy", "operationId": "update_policy_api_v1_resource_policies__policy_id__patch", "parameters": [{"name": "policy_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Policy Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PolicyUpdate"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["policies"], "summary": "Delete Policy", "description": "Delete a resource policy. The global policy is super_admin only; the rest follow\npolicy.delete, which admits super_admin, org_admin, and group_admin.", "operationId": "delete_policy_api_v1_resource_policies__policy_id__delete", "parameters": [{"name": "policy_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Policy Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/sessions/preview-cost": {"post": {"tags": ["sessions"], "summary": "Preview Cost", "description": "Estimate cost without creating a session (pricing.estimate).\n\nestimated_credit_per_hour = offering.credit_per_hour * occupancy;\noccupancy = max(gpu_mem_mb/device_total_mem_mb, gpu_cores/100);\nhold_amount = estimated_credit_per_hour \u2014 admission reserves one hour up front.\nCPU (free) sessions are always 0.", "operationId": "preview_cost_api_v1_sessions_preview_cost_post", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PreviewCostRequest"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PreviewCostResponse"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/sessions": {"post": {"tags": ["sessions"], "summary": "Create Session", "description": "Admit a session through the scheduler gate. Idempotency-Key required.", "operationId": "create_session_api_v1_sessions_post", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}, {"name": "Idempotency-Key", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Idempotency-Key"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SessionCreate"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SessionRead"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "get": {"tags": ["sessions"], "summary": "List Sessions", "description": "List sessions, newest first, paginated.\n\nscope=\"mine\" (the default, backing the user's Sessions page) returns **only the caller's own\nsessions**, whatever their role. Administrators do not see other people's sessions on their\npersonal screen; that is what scope=all is for. scope=\"all\" (the administrators' session\nmonitoring screen) returns everything within the caller's authority: super_admin and org_admin\nsee all sessions, group_admin sees their own plus those of the groups they administer. A plain\nuser asking for scope=all is silently downgraded to their own sessions, so it cannot be used to\nescalate.", "operationId": "list_sessions_api_v1_sessions_get", "parameters": [{"name": "status", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Status"}}, {"name": "group_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Group Id"}}, {"name": "cluster_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cluster Id"}}, {"name": "scope", "in": "query", "required": false, "schema": {"type": "string", "pattern": "^(mine|all)$", "default": "mine", "title": "Scope"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 1, "default": 1, "title": "Page"}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 100, "minimum": 1, "default": 20, "title": "Size"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/sessions/events": {"get": {"tags": ["sessions"], "summary": "Sessions Monitor Events", "description": "Admin session/queue monitor SSE stream.\n\nNOTE: this literal route MUST be declared before ``/sessions/{session_id}`` \u2014 otherwise the\nparameterized route captures ``/sessions/events`` (session_id=\"events\") and returns 404.", "operationId": "sessions_monitor_events_api_v1_sessions_events_get", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/sessions/gpu-availability": {"get": {"tags": ["sessions"], "summary": "Gpu Availability", "description": "Free GPU capacity per model, used by the session wizard to disable tiers. member and above.\n\nGroups the currently ready devices by model and returns each device's free memory, free cores,\nand mode. The console uses this to disable tiers that would not fit on a single card of the\nselected model, since the scheduler requires a fit on one device.\n\nThis is a literal path, so it must be declared before ``/sessions/{session_id}`` \u2014 the same\nreason the events route is.", "operationId": "gpu_availability_api_v1_sessions_gpu_availability_get", "parameters": [{"name": "fleet", "in": "query", "required": false, "schema": {"type": "boolean", "default": false, "title": "Fleet"}}, {"name": "cluster_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cluster Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/sessions/{session_id}": {"get": {"tags": ["sessions"], "summary": "Get Session", "description": "Fetch a single session (owner\u00b7admin).", "operationId": "get_session_api_v1_sessions__session_id__get", "parameters": [{"name": "session_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Session Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SessionRead"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["sessions"], "summary": "Terminate Session", "description": "Terminate + settle.", "operationId": "terminate_session_api_v1_sessions__session_id__delete", "parameters": [{"name": "session_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Session Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"202": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/sessions/{session_id}/start": {"post": {"tags": ["sessions"], "summary": "Start Session", "description": "Resume billing state machine.", "operationId": "start_session_api_v1_sessions__session_id__start_post", "parameters": [{"name": "session_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Session Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SessionRead"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/sessions/{session_id}/stop": {"post": {"tags": ["sessions"], "summary": "Stop Session", "description": "Stop (pause billing) + finalize remaining consume.", "operationId": "stop_session_api_v1_sessions__session_id__stop_post", "parameters": [{"name": "session_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Session Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SessionRead"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/sessions/{session_id}/restart": {"post": {"tags": ["sessions"], "summary": "Restart Session", "description": "Restart a session.", "operationId": "restart_session_api_v1_sessions__session_id__restart_post", "parameters": [{"name": "session_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Session Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SessionRead"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/sessions/{session_id}/force-terminate": {"post": {"tags": ["sessions"], "summary": "Force Terminate", "description": "Admin force-terminate without owner consent. The free-text reason lands in the audit log;\nthe session's status_reason stays the typed `admin_stopped` the console maps to a message.", "operationId": "force_terminate_api_v1_sessions__session_id__force_terminate_post", "parameters": [{"name": "session_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Session Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"content": {"application/json": {"schema": {"anyOf": [{"$ref": "#/components/schemas/ForceTerminateBody"}, {"type": "null"}], "title": "Body"}}}}, "responses": {"202": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/sessions/bulk-terminate": {"post": {"tags": ["sessions"], "summary": "Bulk Terminate", "description": "Force-terminate multiple sessions; per-target result array, idempotent.", "operationId": "bulk_terminate_api_v1_sessions_bulk_terminate_post", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BulkTerminateRequest"}}}}, "responses": {"202": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/sessions/{session_id}/logs": {"get": {"tags": ["sessions"], "summary": "Session Logs", "description": "NOT IMPLEMENTED: nothing streams pod logs into this plane (the operator has no Redis\nproducer), so this always returned an empty list dressed up as a log. Honest 501 until a log\npipeline exists \u2014 use `kubectl logs` on the session pod meanwhile.", "operationId": "session_logs_api_v1_sessions__session_id__logs_get", "parameters": [{"name": "session_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Session Id"}}, {"name": "tail", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 5000, "minimum": 1, "default": 200, "title": "Tail"}}, {"name": "stream", "in": "query", "required": false, "schema": {"type": "string", "pattern": "^(stdout|stderr|all)$", "default": "all", "title": "Stream"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/sessions/{session_id}/connections": {"get": {"tags": ["sessions"], "summary": "Session Connections", "description": "Issue one-time cnx_ connection tokens; `kind` narrows to one app (one token minted).", "operationId": "session_connections_api_v1_sessions__session_id__connections_get", "parameters": [{"name": "session_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Session Id"}}, {"name": "kind", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string", "pattern": "^(vscode|jupyter|terminal)$"}, {"type": "null"}], "title": "Kind"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/ConnectionInfo"}, "title": "Response Session Connections Api V1 Sessions Session Id Connections Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/sessions/{session_id}/usage": {"get": {"tags": ["sessions"], "summary": "Session Usage Self", "description": "Measured live usage of the session's pod (owner\u00b7admin) \u2014 the detail page's meters.", "operationId": "session_usage_self_api_v1_sessions__session_id__usage_get", "parameters": [{"name": "session_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Session Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/sessions/{session_id}/usage/live": {"get": {"tags": ["sessions"], "summary": "Session Usage Live", "description": "The last few minutes of one-second samples from the node agent (owner\u00b7admin).\n\nThis is the live view. It is deliberately separate from the Prometheus-backed series: that one\nis the record and covers GPU and history, this one answers \"is it running yet\" without waiting\nfor a scrape. `live` says whether an agent is actually reporting, so the console can show the\nslower series instead of a stale line when the DaemonSet is not deployed.", "operationId": "session_usage_live_api_v1_sessions__session_id__usage_live_get", "parameters": [{"name": "session_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Session Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/sessions/{session_id}/usage/timeseries": {"get": {"tags": ["sessions"], "summary": "Session Usage Series Self", "description": "The usage metrics over a range (owner\u00b7admin) \u2014 the detail page's sparklines.", "operationId": "session_usage_series_self_api_v1_sessions__session_id__usage_timeseries_get", "parameters": [{"name": "session_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Session Id"}}, {"name": "range", "in": "query", "required": false, "schema": {"type": "string", "default": "15m", "title": "Range"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/sessions/{session_id}/timeline": {"get": {"tags": ["sessions"], "summary": "Session Timeline", "description": "Lifecycle timeline for the detail screen: created \u2192 queued/preparing \u2192 running \u2192 \u2026 with\nreasons, so an error state is readable from the log instead of a separate message box.", "operationId": "session_timeline_api_v1_sessions__session_id__timeline_get", "parameters": [{"name": "session_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Session Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/sessions/{session_id}/checkpoints": {"post": {"tags": ["sessions"], "summary": "Create Checkpoint", "description": "NOT IMPLEMENTED: no worker or operator path takes a checkpoint yet \u2014 a row here would be\nan empty promise (storage_ref stays NULL forever). Honest 501 until the pipeline exists.", "operationId": "create_checkpoint_api_v1_sessions__session_id__checkpoints_post", "parameters": [{"name": "session_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Session Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"202": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "get": {"tags": ["sessions"], "summary": "List Checkpoints", "description": "List checkpoints for a session, newest first.", "operationId": "list_checkpoints_api_v1_sessions__session_id__checkpoints_get", "parameters": [{"name": "session_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Session Id"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 1, "default": 1, "title": "Page"}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 100, "minimum": 1, "default": 20, "title": "Size"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/sessions/{session_id}/checkpoints/{checkpoint_id}/restore": {"post": {"tags": ["sessions"], "summary": "Restore Checkpoint", "description": "NOT IMPLEMENTED: restore has no execution path (the operator's checkpointer states restore\nis intentionally unimplemented) \u2014 returning \"restoring\" here was a fabrication. Honest 501.", "operationId": "restore_checkpoint_api_v1_sessions__session_id__checkpoints__checkpoint_id__restore_post", "parameters": [{"name": "session_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Session Id"}}, {"name": "checkpoint_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Checkpoint Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"202": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/sessions/{session_id}/events": {"get": {"tags": ["sessions"], "summary": "Session Events", "description": "Per-session SSE event stream.\n\nSubscribes to the session's status-change channel (Redis pub/sub fed by status_sync) and pushes\nstatus_changed / phase / allocation / heartbeat events. 404/403 are raised before the stream is\nestablished; the stream terminates cleanly on client disconnect.\n\nThe access check opens its own short-lived DB session: a Depends(get_db) session would stay\nchecked out of the pool for the whole stream (see get_sse_principal).", "operationId": "session_events_api_v1_sessions__session_id__events_get", "parameters": [{"name": "session_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Session Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/queue": {"get": {"tags": ["queue"], "summary": "List Queue", "operationId": "list_queue_api_v1_queue_get", "parameters": [{"name": "group_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Group Id"}}, {"name": "cluster_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cluster Id"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 1, "default": 1, "title": "Page"}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 100, "minimum": 1, "default": 20, "title": "Size"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/QueueList"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/queue/mine": {"get": {"tags": ["queue"], "summary": "My Queue", "operationId": "my_queue_api_v1_queue_mine_get", "parameters": [{"name": "cluster_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cluster Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/QueueMineList"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/queue/{queue_entry_id}": {"delete": {"tags": ["queue"], "summary": "Cancel Queue Entry", "description": "Cancel a queued entry; the session goes terminated and its hold is released.", "operationId": "cancel_queue_entry_api_v1_queue__queue_entry_id__delete", "parameters": [{"name": "queue_entry_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Queue Entry Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "patch": {"tags": ["queue"], "summary": "Update Priority", "description": "Admin reorders the queue: QueueEntry.priority sets the priority band.", "operationId": "update_priority_api_v1_queue__queue_entry_id__patch", "parameters": [{"name": "queue_entry_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Queue Entry Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/QueuePriorityPatch"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/storage/volumes": {"get": {"tags": ["volumes"], "summary": "List Volumes", "description": "List volumes with scope/type/access_mode filters + pagination.\n\nDefault view is the caller's own world for every role \u2014 the user console must show a\nsuper_admin their volumes, not everyone's. `?all=true` (super_admin only) lists the fleet\nfor the admin volume page.", "operationId": "list_volumes_api_v1_storage_volumes_get", "parameters": [{"name": "scope", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Scope"}}, {"name": "scope_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Scope Id"}}, {"name": "type", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Type"}}, {"name": "access_mode", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Access Mode"}}, {"name": "all", "in": "query", "required": false, "schema": {"type": "boolean", "default": false, "title": "All"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 1, "default": 1, "title": "Page"}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 100, "minimum": 1, "default": 20, "title": "Size"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/VolumeRead"}, "title": "Response List Volumes Api V1 Storage Volumes Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["volumes"], "summary": "Create Volume", "description": "Create a named volume of any type.\n\nEvery type \u2014 personal workspace, group share, dataset, scratch \u2014 can be created as often as\nneeded and attached to sessions. There is no auto-provisioned singleton. Within a scope, the\n(type, name) pair is unique.", "operationId": "create_volume_api_v1_storage_volumes_post", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/VolumeCreate"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/VolumeRead"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/storage/volumes/quota-usage": {"get": {"tags": ["volumes"], "summary": "Storage Quota Usage", "description": "Per-scope storage limit, usage, and headroom, for the limit warning on the new-volume form.\n\nReads the same (scope, scope_id) policy and volume quota sum that _assert_storage_quota uses at\ncreation time. has_limit=false means unlimited: no policy, or a limit of 0.", "operationId": "storage_quota_usage_api_v1_storage_volumes_quota_usage_get", "parameters": [{"name": "scope", "in": "query", "required": true, "schema": {"type": "string", "title": "Scope"}}, {"name": "scope_id", "in": "query", "required": true, "schema": {"type": "string", "title": "Scope Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/storage/volumes/{volume_id}": {"get": {"tags": ["volumes"], "summary": "Get Volume", "description": "Volume detail.", "operationId": "get_volume_api_v1_storage_volumes__volume_id__get", "parameters": [{"name": "volume_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Volume Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/VolumeRead"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "patch": {"tags": ["volumes"], "summary": "Update Volume", "description": "Update volume meta: quota_gb / access_mode / mount_locked.\n\nThe quota only grows. Kubernetes never shrinks a claim, so a smaller ledger figure would free\npolicy headroom against space the pool still physically holds (100 GB \"shrunk\" to 10 GB plus a\nnew 90 GB volume = 190 GB on disk). An increase is bounded by the scope's storage policy \u2014\nvolumes are governed by the policy limit alone, not billed.", "operationId": "update_volume_api_v1_storage_volumes__volume_id__patch", "parameters": [{"name": "volume_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Volume Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/VolumePatch"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/VolumeRead"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["volumes"], "summary": "Delete Volume", "description": "Permanently delete a volume; requires confirm==volume_id, rejects if mounted.\n\n``force`` (system administrator only) terminates every active session still mounting the\nvolume and then deletes it \u2014 the answer to a mount that keeps a shared volume alive against\nits owner's will. The audit row names the sessions that were cut off.", "operationId": "delete_volume_api_v1_storage_volumes__volume_id__delete", "parameters": [{"name": "volume_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Volume Id"}}, {"name": "confirm", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Confirm"}}, {"name": "force", "in": "query", "required": false, "schema": {"type": "boolean", "default": false, "title": "Force"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/storage/volumes/{volume_id}/folders": {"get": {"tags": ["volumes"], "summary": "List Folders", "description": "List folders of a volume.", "operationId": "list_folders_api_v1_storage_volumes__volume_id__folders_get", "parameters": [{"name": "volume_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Volume Id"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 1, "default": 1, "title": "Page"}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 100, "minimum": 1, "default": 20, "title": "Size"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["volumes"], "summary": "Create Folder", "description": "Create a folder (absolute path) under a volume.", "operationId": "create_folder_api_v1_storage_volumes__volume_id__folders_post", "parameters": [{"name": "volume_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Volume Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true, "title": "Body"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/storage/volumes/{volume_id}/permissions": {"get": {"tags": ["volumes"], "summary": "List Permissions", "description": "List volume share permissions with user names.", "operationId": "list_permissions_api_v1_storage_volumes__volume_id__permissions_get", "parameters": [{"name": "volume_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Volume Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["volumes"], "summary": "Grant Permission", "description": "Grant/update a volume share permission; upsert on UNIQUE(volume_id,user_id).", "operationId": "grant_permission_api_v1_storage_volumes__volume_id__permissions_post", "parameters": [{"name": "volume_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Volume Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PermissionBody"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/storage/volumes/{volume_id}/permissions/{user_id}": {"delete": {"tags": ["volumes"], "summary": "Revoke Permission", "description": "Revoke a share permission; refuse to remove the last owner.\n\nRemoving YOURSELF (leaving a share) is always allowed \u2014 a recipient's \"delete\" is an\nunshare, never a deletion of the original volume. Removing anyone else needs manage rights.", "operationId": "revoke_permission_api_v1_storage_volumes__volume_id__permissions__user_id__delete", "parameters": [{"name": "volume_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Volume Id"}}, {"name": "user_id", "in": "path", "required": true, "schema": {"type": "string", "title": "User Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/storage/volumes/{volume_id}/snapshots": {"post": {"tags": ["volumes"], "summary": "Create Snapshot", "description": "NOT IMPLEMENTED: there is no CSI/storage integration behind snapshots \u2014 rows were being\nauto-flipped to ready by a timer without any data being copied. Honest 501 until a real\nsnapshot backend exists (blocked on the storage-backend decision).", "operationId": "create_snapshot_api_v1_storage_volumes__volume_id__snapshots_post", "parameters": [{"name": "volume_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Volume Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"content": {"application/json": {"schema": {"anyOf": [{"type": "object", "additionalProperties": true}, {"type": "null"}], "title": "Body"}}}}, "responses": {"202": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "get": {"tags": ["volumes"], "summary": "List Snapshots", "description": "List snapshots of a volume.", "operationId": "list_snapshots_api_v1_storage_volumes__volume_id__snapshots_get", "parameters": [{"name": "volume_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Volume Id"}}, {"name": "status", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Status"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 1, "default": 1, "title": "Page"}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 100, "minimum": 1, "default": 20, "title": "Size"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/storage/volumes/{volume_id}/snapshots/{snapshot_id}/restore": {"post": {"tags": ["volumes"], "summary": "Restore Snapshot", "description": "NOT IMPLEMENTED: restore performed no data operation (audit row + fabricated \"restoring\").\nHonest 501 until a real snapshot backend exists.", "operationId": "restore_snapshot_api_v1_storage_volumes__volume_id__snapshots__snapshot_id__restore_post", "parameters": [{"name": "volume_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Volume Id"}}, {"name": "snapshot_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Snapshot Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"content": {"application/json": {"schema": {"anyOf": [{"type": "object", "additionalProperties": true}, {"type": "null"}], "title": "Body"}}}}, "responses": {"202": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/storage/volumes/{volume_id}/snapshots/{snapshot_id}": {"delete": {"tags": ["volumes"], "summary": "Delete Snapshot", "description": "Delete a snapshot; reject while still creating.", "operationId": "delete_snapshot_api_v1_storage_volumes__volume_id__snapshots__snapshot_id__delete", "parameters": [{"name": "volume_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Volume Id"}}, {"name": "snapshot_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Snapshot Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/storage/pools": {"get": {"tags": ["storage"], "summary": "List Pools", "description": "Every registered pool; `cluster_id` narrows to the ones that cluster may use.", "operationId": "list_pools_api_v1_storage_pools_get", "parameters": [{"name": "cluster_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cluster Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["storage"], "summary": "Create Pool", "description": "Register a pool. Capacity is left empty: the operator's next tick measures it.", "operationId": "create_pool_api_v1_storage_pools_post", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/app__api__storage_pools_router__PoolCreate"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/storage/pools/{pool_id}": {"patch": {"tags": ["storage"], "summary": "Update Pool", "operationId": "update_pool_api_v1_storage_pools__pool_id__patch", "parameters": [{"name": "pool_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Pool Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PoolPatch"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["storage"], "summary": "Delete Pool", "description": "Deregister a pool. Nothing on the storage server is touched \u2014 this only stops gShare\ncounting it; the volumes already on it keep working through their StorageClass.", "operationId": "delete_pool_api_v1_storage_pools__pool_id__delete", "parameters": [{"name": "pool_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Pool Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/clusters/summary": {"get": {"tags": ["clusters"], "summary": "Cluster Summary", "description": "The clusters a signed-in user can name: id, name and status only. The console's cluster\nselector (hidden while there is one) and the session wizard's cluster choice read this; the\nfull list with kubeconfig references stays super_admin-only.", "operationId": "cluster_summary_api_v1_clusters_summary_get", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/clusters": {"get": {"tags": ["clusters"], "summary": "List Clusters", "description": "Cluster list. super_admin only.", "operationId": "list_clusters_api_v1_clusters_get", "parameters": [{"name": "role", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Role"}}, {"name": "status", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Status"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 1, "default": 1, "title": "Page"}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 100, "minimum": 1, "default": 20, "title": "Size"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ClusterList"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["clusters"], "summary": "Register Cluster", "description": "Register a cluster: validate kubeconfig, store secret ref, seed inventory.", "operationId": "register_cluster_api_v1_clusters_post", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ClusterRegister"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/clusters/{cluster_id}": {"get": {"tags": ["clusters"], "summary": "Get Cluster", "description": "Cluster detail + node/device summary. super_admin only.", "operationId": "get_cluster_api_v1_clusters__cluster_id__get", "parameters": [{"name": "cluster_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Cluster Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "patch": {"tags": ["clusters"], "summary": "Update Cluster", "description": "Update name/role only. kubeconfig rotation is out of scope here. super_admin only.", "operationId": "update_cluster_api_v1_clusters__cluster_id__patch", "parameters": [{"name": "cluster_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Cluster Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ClusterPatch"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["clusters"], "summary": "Deregister Cluster", "description": "Deregister a cluster + discard its kubeconfig secret ref.\n\nRefused while live sessions/allocations exist on the cluster (-> 409). super_admin only.", "operationId": "deregister_cluster_api_v1_clusters__cluster_id__delete", "parameters": [{"name": "cluster_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Cluster Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/clusters/{cluster_id}/connection-test": {"post": {"tags": ["clusters"], "summary": "Connection Test", "description": "Re-validate connectivity/health to the cluster apiserver.\n\nRe-runs the runtime/connectivity probe, applies the state-machine transition, and persists the\nnew ``status``. super_admin only.", "operationId": "connection_test_api_v1_clusters__cluster_id__connection_test_post", "parameters": [{"name": "cluster_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Cluster Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/images": {"get": {"tags": ["images"], "summary": "List Images", "description": "List images/templates/ISOs with optional kind/q/tag/public filters. any authenticated.\n\n``public=true`` is what the session wizard uses, listing public images only. The administrative\ncatalogue passes no filter and sees both public and private images.", "operationId": "list_images_api_v1_images_get", "parameters": [{"name": "kind", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Kind"}}, {"name": "q", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Q"}}, {"name": "tag", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Tag"}}, {"name": "public", "in": "query", "required": false, "schema": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Public"}}, {"name": "mine", "in": "query", "required": false, "schema": {"type": "boolean", "default": false, "title": "Mine"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 1, "default": 1, "title": "Page"}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 100, "minimum": 1, "default": 20, "title": "Size"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ImageList"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["images"], "summary": "Create Image", "description": "Register a catalog image referencing an existing registry path. super_admin\u00b7org_admin.", "operationId": "create_image_api_v1_images_post", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ImageCreate"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/images/{image_id}": {"get": {"tags": ["images"], "summary": "Get Image", "description": "Image detail. any authenticated.", "operationId": "get_image_api_v1_images__image_id__get", "parameters": [{"name": "image_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Image Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "patch": {"tags": ["images"], "summary": "Update Image", "description": "Update image metadata: public/private, name, CUDA version, supported GPUs. super_admin, gated\non image.create.", "operationId": "update_image_api_v1_images__image_id__patch", "parameters": [{"name": "image_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Image Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ImageUpdate"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["images"], "summary": "Delete Image", "description": "Delete a catalogue image that no session has ever used. super_admin, gated on image.create.\n\nSessions keep a foreign key to their image for the audit trail, so an image with any session\nhistory cannot be deleted \u2014 retire it instead by setting ``public: false``.\n\nOwners may delete their OWN built (private) images; everything else stays admin-gated.", "operationId": "delete_image_api_v1_images__image_id__delete", "parameters": [{"name": "image_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Image Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/images/import": {"post": {"tags": ["images"], "summary": "Import Image", "description": "Register an image from an external registry reference. any authenticated.\n\nRegistration is synchronous catalog metadata: the actual pull happens on the GPU node's\ncontainer runtime when a session using the image first starts (there is no control-plane pull\nworker). The row is therefore created ``import_status=ready`` immediately. Private-registry\ncredentials are not supported yet \u2014 imagePullSecrets plumbing does not exist \u2014 so a request\ncarrying ``registry_auth`` is refused rather than silently losing the credential.\n\nWho owns the row:\n\n* ``image.create`` holders (super_admin) register SHARED catalog entries \u2014 ``owner_user_id``\n null, ``public`` as given. Re-registering a ref that already exists as a shared row is a 409.\n* everyone else registers a PRIVATE row they own (``public=false``, ``kind=container``), which\n only they and admins can see. The same public ref may therefore be imported by many members.\n\nAlways answers 202. Deduplication never fails the request: re-importing your own ref, or a ref\nthat is already in the shared catalogue, returns that row with ``existing: true`` instead of\ncreating a second one \u2014 the caller uses the returned ``id`` either way.", "operationId": "import_image_api_v1_images_import_post", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ImageImport"}}}}, "responses": {"202": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/image-builds": {"post": {"tags": ["images"], "summary": "Create Build", "description": "Create a console image build: record desired state, then hand the GShareImageBuild CR to\nthe operator (which runs kaniko and reports back via /internal/image-builds/{id}/status).\n\nMembers build for themselves; the pushed image lands as a PRIVATE Image row\n(owner + admins only). One build in flight per user.", "operationId": "create_build_api_v1_image_builds_post", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BuildCreate"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "get": {"tags": ["images"], "summary": "List Builds", "description": "List builds. member sees own-project builds; restricted to membership projects.", "operationId": "list_builds_api_v1_image_builds_get", "parameters": [{"name": "group_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Group Id"}}, {"name": "status", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Status"}}, {"name": "source", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Source"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 1, "default": 1, "title": "Page"}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 100, "minimum": 1, "default": 20, "title": "Size"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ImageBuildList"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/image-builds/{build_id}": {"get": {"tags": ["images"], "summary": "Get Build", "description": "Build detail incl. image_ref / scan summary.", "operationId": "get_build_api_v1_image_builds__build_id__get", "parameters": [{"name": "build_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Build Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/image-builds/{build_id}/logs": {"get": {"tags": ["images"], "summary": "Build Logs", "description": "Return the stored kaniko log tail (the operator reports the last ~16KB on each callback).", "operationId": "build_logs_api_v1_image_builds__build_id__logs_get", "parameters": [{"name": "build_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Build Id"}}, {"name": "tail", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 10000, "minimum": 1, "default": 500, "title": "Tail"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/notifications/events": {"get": {"tags": ["notifications"], "summary": "Notification Events", "description": "Live SSE stream of the caller's notifications: a ping event per new notification, which the\nconsole turns into a refetch of the list.\n\nEventSource cannot set headers, so authentication goes through ``?access_token=``, which\nget_current_principal accepts.", "operationId": "notification_events_api_v1_notifications_events_get", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/notifications": {"get": {"tags": ["notifications"], "summary": "List Notifications", "description": "List the caller's own notifications, newest first. Self-scoped only.", "operationId": "list_notifications_api_v1_notifications_get", "parameters": [{"name": "unread", "in": "query", "required": false, "schema": {"type": "boolean", "default": false, "title": "Unread"}}, {"name": "include_deleted", "in": "query", "required": false, "schema": {"type": "boolean", "default": false, "title": "Include Deleted"}}, {"name": "type", "in": "query", "required": false, "schema": {"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}], "title": "Type"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 1, "default": 1, "title": "Page"}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 100, "minimum": 1, "default": 20, "title": "Size"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["notifications"], "summary": "Delete All Notifications", "description": "Delete every notification of the caller. Self-scoped by construction.", "operationId": "delete_all_notifications_api_v1_notifications_delete", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/notifications/{notification_id}/read": {"post": {"tags": ["notifications"], "summary": "Mark Read", "description": "Mark one notification read; idempotent no-op if already read.", "operationId": "mark_read_api_v1_notifications__notification_id__read_post", "parameters": [{"name": "notification_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Notification Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/notifications/{notification_id}": {"delete": {"tags": ["notifications"], "summary": "Delete Notification", "description": "Delete one of the caller's notifications. 404 for other users' rows (no existence leak).", "operationId": "delete_notification_api_v1_notifications__notification_id__delete", "parameters": [{"name": "notification_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Notification Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/notifications/read-all": {"post": {"tags": ["notifications"], "summary": "Mark All Read", "description": "Bulk mark the caller's unread notifications read, optionally bounded.", "operationId": "mark_all_read_api_v1_notifications_read_all_post", "parameters": [{"name": "type", "in": "query", "required": false, "schema": {"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}], "title": "Type"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"content": {"application/json": {"schema": {"anyOf": [{"type": "object", "additionalProperties": true}, {"type": "null"}], "title": "Body"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/audit-logs/export": {"get": {"tags": ["audit"], "summary": "Export Audit Logs", "description": "The current audit view as a CSV file, newest first, same scope and filters as the list.\n\nUTF-8 with a BOM so Excel opens Korean names correctly; ``detail`` is the JSON blob verbatim.\nTaking the export is itself audited (``audit.export``, with the filters and the row count):\na file leaving the system is exactly the kind of event the log exists for.", "operationId": "export_audit_logs_api_v1_audit_logs_export_get", "parameters": [{"name": "actor_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Actor Id"}}, {"name": "actor_q", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Actor Q"}}, {"name": "action", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Action"}}, {"name": "target", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Target"}}, {"name": "at[gte]", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "At[Gte]"}}, {"name": "at[lt]", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "At[Lt]"}}, {"name": "result", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Result"}}, {"name": "cluster_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cluster Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/audit-logs": {"get": {"tags": ["audit"], "summary": "List Audit Logs", "operationId": "list_audit_logs_api_v1_audit_logs_get", "parameters": [{"name": "actor_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Actor Id"}}, {"name": "actor_q", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Actor Q"}}, {"name": "action", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Action"}}, {"name": "target", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Target"}}, {"name": "at[gte]", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "At[Gte]"}}, {"name": "at[lt]", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "At[Lt]"}}, {"name": "result", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Result"}}, {"name": "cluster_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cluster Id"}}, {"name": "sort", "in": "query", "required": false, "schema": {"type": "string", "default": "-at", "title": "Sort"}}, {"name": "verify", "in": "query", "required": false, "schema": {"type": "boolean", "default": false, "title": "Verify"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 1, "default": 1, "title": "Page"}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 100, "minimum": 1, "default": 20, "title": "Size"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AuditLogList"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/webhooks": {"get": {"tags": ["webhooks"], "summary": "List Webhooks", "operationId": "list_webhooks_api_v1_webhooks_get", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["webhooks"], "summary": "Create Webhook", "operationId": "create_webhook_api_v1_webhooks_post", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/WebhookCreate"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/webhooks/{webhook_id}": {"delete": {"tags": ["webhooks"], "summary": "Delete Webhook", "operationId": "delete_webhook_api_v1_webhooks__webhook_id__delete", "parameters": [{"name": "webhook_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Webhook Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/dashboard/summary": {"get": {"tags": ["dashboard"], "summary": "Dashboard Summary", "operationId": "dashboard_summary_api_v1_dashboard_summary_get", "parameters": [{"name": "scope", "in": "query", "required": false, "schema": {"enum": ["mine", "managed"], "type": "string", "default": "mine", "title": "Scope"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/DashboardSummary"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/nodes": {"get": {"tags": ["infra"], "summary": "List Nodes", "description": "Nodes, optionally narrowed to one cluster.\n\nThe console filtered this list in the browser, which meant every screen fetched every\ncluster's nodes to show one cluster's. `GpuNode.cluster_id` is indexed; the filter belongs here.", "operationId": "list_nodes_api_v1_nodes_get", "parameters": [{"name": "status", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Status"}}, {"name": "region", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Region"}}, {"name": "cluster_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cluster Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/NodeList"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["infra"], "summary": "Register Node", "description": "Register a node, initially offline. super_admin only.\n\nWithout a cluster_id the node is attached to the only registered cluster, which covers test and\nsingle-cluster setups. With several clusters the field is required.", "operationId": "register_node_api_v1_nodes_post", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/_NodeRegister"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/nodes/{node_id}": {"get": {"tags": ["infra"], "summary": "Get Node", "description": "Fetch one node, for deep links from the drain and device pages.", "operationId": "get_node_api_v1_nodes__node_id__get", "parameters": [{"name": "node_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Node Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["infra"], "summary": "Delete Node", "description": "Decommission a node: clear its inventory and have the operator remove it from Kubernetes.\n\nsuper_admin only. Two phases, because the control plane never calls the workload Kubernetes\nAPI itself. This request clears the cards and marks the node ``decommissioning``; the operator\ndeletes the Node object on its next pass and reports back, and only then does the row go. That\nis what stops a removed node from reappearing: while the Node object exists, every inventory\nreport recreates the row (upsert by cluster + hostname), which is exactly what a manual\n`kubectl delete node` used to be needed for.\n\nRefuses while the node still carries live work \u2014 a live allocation on one of its cards, or a\nnon-terminal session the operator placed there \u2014 so removal can never strand a running\nsession's ledger. Ended allocations keep their history: the row survives with device_id NULL\nand its gpu_uuid intact, since the card it names no longer exists.\n\nRefuses while the node is still up: deleting a live node's object only makes its kubelet\nregister again seconds later. Drain it and power it down first.", "operationId": "delete_node_api_v1_nodes__node_id__delete", "parameters": [{"name": "node_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Node Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/nodes/{node_id}/cordon": {"post": {"tags": ["infra"], "summary": "Cordon Node", "description": "Cordon or uncordon a node. super_admin only.\n\ncordon=true blocks new scheduling (status=cordoned); false restores it (status=ready). Sessions\nalready running are left alone.", "operationId": "cordon_node_api_v1_nodes__node_id__cordon_post", "parameters": [{"name": "node_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Node Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/_CordonBody"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/nodes/{node_id}/drain": {"post": {"tags": ["infra"], "summary": "Drain Node", "description": "Drain a node. super_admin only.\n\nBoth modes cordon FIRST (no new placements can land back), then act on every session holding\na live allocation on the node's devices:\n - reschedule: pause \u2192 resume each running/preparing session. Resume re-runs placement,\n which excludes cordoned nodes \u2014 the session comes back on another eligible card (same\n GPU model, enough VRAM/cores, pool access). No capacity \u21d2 the session stays PAUSED\n (\"parked\"): work preserved, owner can resume later.\n - force_terminate: terminate each affected session (settled like an admin stop).", "operationId": "drain_node_api_v1_nodes__node_id__drain_post", "parameters": [{"name": "node_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Node Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/_DrainBody"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/node-pools": {"get": {"tags": ["infra"], "summary": "List Node Pools", "description": "List node pools. An org_admin sees only the pools carrying a grant for one of their\norganizations (or a group in them).", "operationId": "list_node_pools_api_v1_node_pools_get", "parameters": [{"name": "cluster_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cluster Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PoolList"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["infra"], "summary": "Create Node Pool", "operationId": "create_node_pool_api_v1_node_pools_post", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/app__api__schemas__node_pool__PoolCreate"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PoolRead"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/node-pools/{pool_id}": {"patch": {"tags": ["infra"], "summary": "Update Node Pool", "operationId": "update_node_pool_api_v1_node_pools__pool_id__patch", "parameters": [{"name": "pool_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Pool Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PoolUpdate"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PoolRead"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["infra"], "summary": "Delete Node Pool", "description": "Delete a pool: its nodes become shared (pool_id NULL) and its grants go with it. Refused\nwhile any live session holds an allocation on one of its nodes \u2014 cordon and drain first.", "operationId": "delete_node_pool_api_v1_node_pools__pool_id__delete", "parameters": [{"name": "pool_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Pool Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/nodes/{node_id}/pool": {"put": {"tags": ["infra"], "summary": "Set Node Pool", "description": "Move a node into a pool (or back to shared with pool_id null). The pool must be in the\nnode's cluster. Sessions already on the node are left alone; only new placements change.", "operationId": "set_node_pool_api_v1_nodes__node_id__pool_put", "parameters": [{"name": "node_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Node Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/NodePoolSet"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/node-pools/{pool_id}/grants": {"post": {"tags": ["infra"], "summary": "Grant Node Pool", "description": "Grant a pool to an organization or group. super_admin: anything (the target must exist).\norg_admin: only sub-assignment of a pool already granted to their organization, to a group in\nthat organization (domain.node_pools.assert_may_grant).", "operationId": "grant_node_pool_api_v1_node_pools__pool_id__grants_post", "parameters": [{"name": "pool_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Pool Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PoolGrantCreate"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/node-pools/{pool_id}/grants/{grant_id}": {"delete": {"tags": ["infra"], "summary": "Revoke Node Pool Grant", "description": "Revoke a grant. Same rule as granting: an org_admin may only remove group grants inside an\norganization the pool is granted to.", "operationId": "revoke_node_pool_grant_api_v1_node_pools__pool_id__grants__grant_id__delete", "parameters": [{"name": "pool_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Pool Id"}}, {"name": "grant_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Grant Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/gpu-devices": {"get": {"tags": ["infra"], "summary": "List Gpu Devices", "operationId": "list_gpu_devices_api_v1_gpu_devices_get", "parameters": [{"name": "node_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Node Id"}}, {"name": "cluster_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cluster Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GpuDeviceList"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/gpu-devices/{device_id}/alias": {"put": {"tags": ["infra"], "summary": "Set Device Alias", "description": "Name a physical card. super_admin only.\n\nThe console otherwise numbers cards by their position in the list, so \"card #2\" means a\ndifferent card the day a card is added or removed. An alias pins a name to the UUID. Unique\nwithin the cluster; the ledger owns it and inventory reports never overwrite it.", "operationId": "set_device_alias_api_v1_gpu_devices__device_id__alias_put", "parameters": [{"name": "device_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Device Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/_DeviceAliasBody"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/gpu-devices/{device_id}/health": {"put": {"tags": ["infra"], "summary": "Set Device Health", "description": "Take one card out of service (or put it back). super_admin only.\n\n``unhealthy`` removes the card from placement and ends every session bound to it with\n``gpu_fault`` \u2014 a process whose CUDA context died cannot be resumed, so the honest outcome is\na settled session and a notified owner, not a running one that bills. ``ready`` puts the\ncard back after repair. The same routine serves operator health events that name a card.", "operationId": "set_device_health_api_v1_gpu_devices__device_id__health_put", "parameters": [{"name": "device_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Device Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/_DeviceHealthBody"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/gpu-devices/{device_id}/mode": {"put": {"tags": ["infra"], "summary": "Set Gpu Device Mode", "description": "Set a card's target pool (per-CARD, not per-node). super_admin, same tier as cordon.\n\nfractional\u2194exclusive is a metadata change applied as soon as the card is empty; \u2194mig also\nneeds the node-side geometry change, executed by the drain state machine (the card stops\naccepting placements immediately and flips when its last allocation ends).", "operationId": "set_gpu_device_mode_api_v1_gpu_devices__device_id__mode_put", "parameters": [{"name": "device_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Device Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GpuDeviceModeSet"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/gpu-pools/{cluster_id}": {"put": {"tags": ["infra"], "summary": "Set Pool Targets", "description": "Set the cluster's MIG/hami-core pool split by TARGET COUNT \u2014 the console's coarse control\n(simpler and safer than dragging individual cards). The emptiest eligible cards are chosen;\neach transition drains first and runs through the GpuModeChange machinery.", "operationId": "set_pool_targets_api_v1_gpu_pools__cluster_id__put", "parameters": [{"name": "cluster_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Cluster Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PoolTargetsBody"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/metrics/cluster": {"get": {"tags": ["infra"], "summary": "Metrics Cluster", "description": "Fleet figures, or one cluster's when `cluster_id` is given.\n\nWithout the filter the console showed a cluster's card grid beside fleet-wide totals \u2014 eight\nnodes and three cards while the panel below said one. Every aggregate here now narrows the\nsame way, so the numbers on one screen describe one thing.", "operationId": "metrics_cluster_api_v1_metrics_cluster_get", "parameters": [{"name": "region", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Region"}}, {"name": "cluster_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cluster Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ClusterMetrics"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/metrics/billing-report": {"get": {"tags": ["infra"], "summary": "Billing Report", "description": "Settlement and billing report: the credit ledger (consume, settle, topup) aggregated by a\ngroup_by key.\n\nConsumption and top-ups join CreditTransaction to CreditWallet and are attributed to the owner\n(organization, group, or user). gpu_hours is a best-effort sum of the occupancy hours of GPU\nsessions overlapping [from_, to].", "operationId": "billing_report_api_v1_metrics_billing_report_get", "parameters": [{"name": "scope", "in": "query", "required": true, "schema": {"type": "string", "pattern": "^(org|group|wallet)$", "title": "Scope"}}, {"name": "scope_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Scope Id"}}, {"name": "from", "in": "query", "required": true, "schema": {"type": "string", "format": "date-time", "title": "From"}}, {"name": "to", "in": "query", "required": true, "schema": {"type": "string", "format": "date-time", "title": "To"}}, {"name": "group_by", "in": "query", "required": false, "schema": {"type": "string", "pattern": "^(group|offering|wallet)$", "default": "group", "title": "Group By"}}, {"name": "format", "in": "query", "required": false, "schema": {"type": "string", "default": "json", "title": "Format"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BillingReport"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/monitoring/status": {"get": {"tags": ["monitoring"], "summary": "Monitoring Status", "description": "Whether the metrics backend answers, so the page can say \"not configured\" instead of\nrendering empty charts.", "operationId": "monitoring_status_api_v1_monitoring_status_get", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/monitoring/sessions/{session_id}/usage": {"get": {"tags": ["monitoring"], "summary": "Session Usage", "description": "Measured live usage of ONE session's pod: cadvisor CPU/MEM + HAMi VRAM/GPU-core.\n\nInstant values for the monitor drawer, refreshed by the console. VRAM and core come from\nHAMi's in-container monitor, which only reports while a CUDA context exists \u2014 an idle\nnotebook legitimately reads 0 there while CPU/MEM still move.", "operationId": "session_usage_api_v1_monitoring_sessions__session_id__usage_get", "parameters": [{"name": "session_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Session Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/monitoring/sessions/{session_id}/usage/timeseries": {"get": {"tags": ["monitoring"], "summary": "Session Usage Timeseries", "description": "The four per-session usage metrics over a range, for the monitor detail sparklines. A\nfinished session gets its whole run instead of a trailing window.", "operationId": "session_usage_timeseries_api_v1_monitoring_sessions__session_id__usage_timeseries_get", "parameters": [{"name": "session_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Session Id"}}, {"name": "range", "in": "query", "required": false, "schema": {"type": "string", "default": "15m", "title": "Range"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/monitoring/timeseries": {"get": {"tags": ["monitoring"], "summary": "Monitoring Timeseries", "description": "One panel over a time range. The panel id selects the query; the console never sends PromQL.", "operationId": "monitoring_timeseries_api_v1_monitoring_timeseries_get", "parameters": [{"name": "panel", "in": "query", "required": true, "schema": {"type": "string", "title": "Panel"}}, {"name": "range", "in": "query", "required": false, "schema": {"type": "string", "default": "1h", "title": "Range"}}, {"name": "node", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Node"}}, {"name": "gpu", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Gpu"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/monitoring/instant": {"get": {"tags": ["monitoring"], "summary": "Monitoring Instant", "description": "Current value per series \u2014 the snapshot table and the tiles.", "operationId": "monitoring_instant_api_v1_monitoring_instant_get", "parameters": [{"name": "panel", "in": "query", "required": true, "schema": {"type": "string", "title": "Panel"}}, {"name": "node", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Node"}}, {"name": "gpu", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Gpu"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/monitoring/gpu-inventory": {"get": {"tags": ["monitoring"], "summary": "Monitoring Gpu Inventory", "description": "The ledger's view of each card: mode, allocated VRAM/cores, and the sessions holding it.\n\nDCGM cannot attribute a shared card's usage to a session (HAMi splits below its visibility), so\nsession attribution comes from the control plane instead of being guessed from metrics.", "operationId": "monitoring_gpu_inventory_api_v1_monitoring_gpu_inventory_get", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/system/branding": {"get": {"tags": ["system"], "summary": "Get Branding", "description": "Public: the sign-in screen renders this before anyone has a token.", "operationId": "get_branding_api_v1_system_branding_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BrandingOut"}}}}}}, "put": {"tags": ["system"], "summary": "Set Branding", "description": "Set the service name and logo; super_admin only.", "operationId": "set_branding_api_v1_system_branding_put", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BrandingUpdate"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BrandingOut"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/system/signup": {"get": {"tags": ["system"], "summary": "Get Signup Policy", "description": "Public: the sign-in screen shows its sign-up tab only when this is not `closed`.", "operationId": "get_signup_policy_api_v1_system_signup_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SignupPolicyOut"}}}}}}, "put": {"tags": ["system"], "summary": "Set Signup Policy", "description": "Set the sign-up policy; super_admin only.", "operationId": "set_signup_policy_api_v1_system_signup_put", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SignupPolicyUpdate"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SignupPolicyOut"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/system/placement": {"get": {"tags": ["system"], "summary": "Get Placement", "description": "The fractional-slice placement policy. Readable by anyone who may see the admin console.", "operationId": "get_placement_api_v1_system_placement_get", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PlacementOut"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "put": {"tags": ["system"], "summary": "Set Placement", "description": "Set the placement policy. Applies to the next reservation; running sessions do not move.", "operationId": "set_placement_api_v1_system_placement_put", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PlacementUpdate"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PlacementOut"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/internal/sessions/{session_id}/status": {"post": {"tags": ["internal"], "summary": "Report Status", "operationId": "report_status_internal_sessions__session_id__status_post", "parameters": [{"name": "session_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Session Id"}}, {"name": "authorization", "in": "header", "required": true, "schema": {"type": "string", "title": "Authorization"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/OperatorStatusEvent"}}}}, "responses": {"202": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/internal/image-builds/{build_id}/status": {"post": {"tags": ["internal"], "summary": "Report Build Status", "operationId": "report_build_status_internal_image_builds__build_id__status_post", "parameters": [{"name": "build_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Build Id"}}, {"name": "authorization", "in": "header", "required": true, "schema": {"type": "string", "title": "Authorization"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BuildStatusEvent"}}}}, "responses": {"202": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/internal/connect/verify": {"get": {"tags": ["internal"], "summary": "Connect Verify", "operationId": "connect_verify_internal_connect_verify_get", "parameters": [{"name": "token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Token"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/internal/audit/operator": {"post": {"tags": ["internal"], "summary": "Audit Operator", "operationId": "audit_operator_internal_audit_operator_post", "parameters": [{"name": "authorization", "in": "header", "required": true, "schema": {"type": "string", "title": "Authorization"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/OperatorAuditEvent"}}}}, "responses": {"202": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/internal/inventory/gpu-devices": {"post": {"tags": ["internal"], "summary": "Upsert Gpu Device", "operationId": "upsert_gpu_device_internal_inventory_gpu_devices_post", "parameters": [{"name": "authorization", "in": "header", "required": true, "schema": {"type": "string", "title": "Authorization"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/OperatorGpuDeviceUpsert"}}}}, "responses": {"202": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/internal/inventory/nodes": {"post": {"tags": ["internal"], "summary": "Upsert Node", "operationId": "upsert_node_internal_inventory_nodes_post", "parameters": [{"name": "authorization", "in": "header", "required": true, "schema": {"type": "string", "title": "Authorization"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/OperatorNodeUpsert"}}}}, "responses": {"202": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/internal/nodes/cordoned": {"get": {"tags": ["internal"], "summary": "Cordoned Nodes", "description": "Hostnames the ledger holds as cordoned, for the operator to mirror onto the Kubernetes\nnodes (spec.unschedulable). The control plane never touches Kubernetes itself; without this\nmirror a gShare cordon only steered the ledger's card placement and kube-scheduler could still\nput a CPU session \u2014 or a resumed one \u2014 straight back on a node being drained.", "operationId": "cordoned_nodes_internal_nodes_cordoned_get", "parameters": [{"name": "authorization", "in": "header", "required": true, "schema": {"type": "string", "title": "Authorization"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/internal/nodes/decommissioning": {"get": {"tags": ["internal"], "summary": "Decommissioning Nodes", "description": "Hostnames an administrator asked to remove from the cluster.\n\nThe console's delete button clears the ledger and parks the node here; the operator deletes\nthe matching Node object and calls back below. Without that second step the Node object\nsurvives, every inventory pass recreates the row, and the node reappears in the console \u2014\nwhich is why removing a node used to need a manual `kubectl delete node`.", "operationId": "decommissioning_nodes_internal_nodes_decommissioning_get", "parameters": [{"name": "authorization", "in": "header", "required": true, "schema": {"type": "string", "title": "Authorization"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/internal/nodes/decommissioned": {"post": {"tags": ["internal"], "summary": "Node Decommissioned", "description": "The operator confirms the Node object is gone; the ledger row goes with it.", "operationId": "node_decommissioned_internal_nodes_decommissioned_post", "parameters": [{"name": "authorization", "in": "header", "required": true, "schema": {"type": "string", "title": "Authorization"}}], "requestBody": {"content": {"application/json": {"schema": {"type": "object", "additionalProperties": true, "title": "Body"}}}}, "responses": {"202": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/internal/inventory/drift": {"post": {"tags": ["internal"], "summary": "Report Drift", "operationId": "report_drift_internal_inventory_drift_post", "parameters": [{"name": "authorization", "in": "header", "required": true, "schema": {"type": "string", "title": "Authorization"}}], "requestBody": {"content": {"application/json": {"schema": {"type": "object", "additionalProperties": true, "title": "Body"}}}}, "responses": {"202": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/internal/nodes/health-events": {"post": {"tags": ["internal"], "summary": "Node Health Event", "description": "Record a NodeHealthEvent and, on a cordon action, mark GpuNode.status=cordoned.\n\nThe operator already cordoned the K8s node; the ledger reflects it so the console/scheduler\nstop placing new sessions there.", "operationId": "node_health_event_internal_nodes_health_events_post", "parameters": [{"name": "authorization", "in": "header", "required": true, "schema": {"type": "string", "title": "Authorization"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/OperatorNodeHealthEvent"}}}}, "responses": {"202": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/.well-known/gshare-internal-jwks.json": {"get": {"tags": ["internal"], "summary": "Internal Jwks", "operationId": "internal_jwks__well_known_gshare_internal_jwks_json_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}}}}, "/internal/volumes/sync": {"post": {"tags": ["internal"], "summary": "Sync Volumes", "operationId": "sync_volumes_internal_volumes_sync_post", "parameters": [{"name": "authorization", "in": "header", "required": true, "schema": {"type": "string", "title": "Authorization"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/OperatorVolumeSync"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/VolumeSyncResponse"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/internal/metrics/session-samples": {"post": {"tags": ["internal"], "summary": "Ingest Session Samples", "description": "Accept one node's batch of session samples.\n\nThe agent knows a session only by its CR name (the pod label), so the ids are resolved here \u2014\nthe same lower/underscore mapping the CR builder uses. An unknown name is dropped rather than\nstored under a key nothing will ever read.", "operationId": "ingest_session_samples_internal_metrics_session_samples_post", "parameters": [{"name": "authorization", "in": "header", "required": true, "schema": {"type": "string", "title": "Authorization"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AgentReport"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true, "title": "Response Ingest Session Samples Internal Metrics Session Samples Post"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/healthz": {"get": {"tags": ["health"], "summary": "Healthz", "operationId": "healthz_healthz_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}}}}}, "components": {"schemas": {"AdjustBody": {"properties": {"amount": {"anyOf": [{"type": "number", "maximum": 1000000000000.0, "minimum": -1000000000000.0}, {"type": "string", "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$"}], "title": "Amount"}, "reason": {"type": "string", "title": "Reason"}}, "type": "object", "required": ["amount", "reason"], "title": "AdjustBody"}, "AgentReport": {"properties": {"node": {"type": "string", "title": "Node"}, "samples": {"items": {"$ref": "#/components/schemas/AgentSample"}, "type": "array", "maxItems": 500, "title": "Samples"}}, "type": "object", "required": ["node"], "title": "AgentReport"}, "AgentSample": {"properties": {"session": {"type": "string", "title": "Session"}, "at": {"type": "number", "title": "At"}, "cpu_cores": {"type": "number", "minimum": 0.0, "title": "Cpu Cores"}, "mem_bytes": {"type": "integer", "minimum": 0.0, "title": "Mem Bytes"}}, "type": "object", "required": ["session", "at", "cpu_cores", "mem_bytes"], "title": "AgentSample"}, "AllocateBody": {"properties": {"from_wallet_id": {"type": "string", "title": "From Wallet Id"}, "to_wallet_id": {"type": "string", "title": "To Wallet Id"}, "amount": {"anyOf": [{"type": "number", "maximum": 1000000000000.0, "exclusiveMinimum": 0.0}, {"type": "string", "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$"}], "title": "Amount"}, "reason": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Reason"}}, "type": "object", "required": ["from_wallet_id", "to_wallet_id", "amount"], "title": "AllocateBody"}, "AllocationRejectBody": {"properties": {"reason": {"type": "string", "title": "Reason"}}, "type": "object", "required": ["reason"], "title": "AllocationRejectBody"}, "AllocationRequestCreate": {"properties": {"amount": {"anyOf": [{"type": "number", "maximum": 1000000000000.0, "exclusiveMinimum": 0.0}, {"type": "string", "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$"}], "title": "Amount"}, "level": {"type": "string", "pattern": "^(user|group|org)$", "title": "Level", "default": "user"}, "group_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Group Id"}, "org_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Org Id"}, "note": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Note"}}, "type": "object", "required": ["amount"], "title": "AllocationRequestCreate"}, "AuditChainResult": {"properties": {"ok": {"type": "boolean", "title": "Ok"}, "checked": {"type": "integer", "title": "Checked"}, "broken_at": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Broken At"}, "reason": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Reason"}}, "type": "object", "required": ["ok", "checked"], "title": "AuditChainResult", "description": "Optional integrity attestation appended when ``verify=true``."}, "AuditLogEntry": {"properties": {"id": {"type": "string", "title": "Id"}, "actor_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Actor Id"}, "action": {"type": "string", "title": "Action"}, "target": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Target"}, "result": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Result"}, "detail": {"additionalProperties": true, "type": "object", "title": "Detail"}, "trace_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Trace Id"}, "prev_hash": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Prev Hash"}, "entry_hash": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Entry Hash"}, "at": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "At"}, "actor_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Actor Name"}, "actor_email": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Actor Email"}, "target_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Target Name"}, "cluster_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cluster Id"}}, "type": "object", "required": ["id", "action", "detail"], "title": "AuditLogEntry", "description": "One audit-log row as projected by ``_audit_view`` (+ resolved actor_name)."}, "AuditLogList": {"properties": {"data": {"items": {"$ref": "#/components/schemas/AuditLogEntry"}, "type": "array", "title": "Data"}, "pagination": {"$ref": "#/components/schemas/PageMeta"}, "names": {"additionalProperties": {"type": "string"}, "type": "object", "title": "Names", "default": {}}, "chain": {"anyOf": [{"$ref": "#/components/schemas/AuditChainResult"}, {"type": "null"}]}}, "type": "object", "required": ["data", "pagination"], "title": "AuditLogList", "description": "GET /audit-logs envelope (+ optional ``chain`` when verify=true)."}, "BillingReport": {"properties": {"rows": {"items": {"$ref": "#/components/schemas/BillingReportRow"}, "type": "array", "title": "Rows"}, "totals": {"additionalProperties": {"type": "string"}, "type": "object", "title": "Totals"}, "currency": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Currency"}}, "type": "object", "required": ["rows", "totals"], "title": "BillingReport"}, "BillingReportRow": {"properties": {"group": {"type": "string", "title": "Group"}, "group_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Group Name"}, "consumed": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Consumed"}, "topup": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Topup"}, "gpu_hours": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Gpu Hours"}}, "type": "object", "required": ["group"], "title": "BillingReportRow"}, "BoundSession": {"properties": {"session_id": {"type": "string", "title": "Session Id"}, "gpu_mem_mb": {"type": "integer", "title": "Gpu Mem Mb"}, "gpu_cores": {"type": "integer", "title": "Gpu Cores"}}, "type": "object", "required": ["session_id", "gpu_mem_mb", "gpu_cores"], "title": "BoundSession"}, "BrandingOut": {"properties": {"service_name": {"type": "string", "title": "Service Name"}, "logo": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Logo"}}, "type": "object", "required": ["service_name"], "title": "BrandingOut"}, "BrandingUpdate": {"properties": {"service_name": {"anyOf": [{"type": "string", "maxLength": 40, "minLength": 1}, {"type": "null"}], "title": "Service Name"}, "logo": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Logo"}, "clear_logo": {"type": "boolean", "title": "Clear Logo", "default": false}}, "type": "object", "title": "BrandingUpdate"}, "BudgetCreate": {"properties": {"scope": {"type": "string", "pattern": "^(org|group)$", "title": "Scope"}, "scope_id": {"type": "string", "title": "Scope Id"}, "period_start": {"type": "string", "format": "date-time", "title": "Period Start"}, "period": {"type": "string", "title": "Period", "default": "monthly"}, "limit_credit": {"anyOf": [{"type": "number", "exclusiveMinimum": 0.0}, {"type": "string", "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$"}], "title": "Limit Credit"}, "action": {"type": "string", "pattern": "^(alert|block)$", "title": "Action", "default": "alert"}}, "type": "object", "required": ["scope", "scope_id", "period_start", "limit_credit"], "title": "BudgetCreate"}, "BudgetForecast": {"properties": {"projected_exhaustion_date": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Projected Exhaustion Date"}, "burn_rate_per_day": {"type": "number", "title": "Burn Rate Per Day"}}, "type": "object", "required": ["burn_rate_per_day"], "title": "BudgetForecast"}, "BudgetRead": {"properties": {"id": {"type": "string", "title": "Id"}, "scope": {"type": "string", "title": "Scope"}, "scope_id": {"type": "string", "title": "Scope Id"}, "limit_credit": {"type": "string", "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", "title": "Limit Credit"}, "spent_credit": {"type": "string", "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", "title": "Spent Credit"}, "action": {"type": "string", "title": "Action"}}, "type": "object", "required": ["id", "scope", "scope_id", "limit_credit", "spent_credit", "action"], "title": "BudgetRead"}, "BudgetUpdate": {"properties": {"limit_credit": {"anyOf": [{"type": "number", "exclusiveMinimum": 0.0}, {"type": "string", "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$"}, {"type": "null"}], "title": "Limit Credit"}, "action": {"anyOf": [{"type": "string", "pattern": "^(alert|block)$"}, {"type": "null"}], "title": "Action"}}, "type": "object", "title": "BudgetUpdate"}, "BuildCreate": {"properties": {"group_id": {"type": "string", "title": "Group Id"}, "name": {"type": "string", "maxLength": 120, "minLength": 1, "title": "Name"}, "source": {"type": "string", "title": "Source"}, "dockerfile": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Dockerfile"}, "git_url": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Git Url"}, "git_ref": {"type": "string", "title": "Git Ref", "default": "main"}, "context": {"type": "string", "title": "Context", "default": "."}, "build_args": {"additionalProperties": true, "type": "object", "title": "Build Args"}, "target_tag": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Target Tag"}}, "type": "object", "required": ["group_id", "name", "source"], "title": "BuildCreate"}, "BuildStatusEvent": {"properties": {"phase": {"type": "string", "title": "Phase"}, "image_ref": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Image Ref"}, "error": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Error"}, "log_tail": {"anyOf": [{"type": "string", "maxLength": 32768}, {"type": "null"}], "title": "Log Tail"}}, "type": "object", "required": ["phase"], "title": "BuildStatusEvent"}, "BulkAllocateBody": {"properties": {"group_id": {"type": "string", "title": "Group Id"}, "amount": {"anyOf": [{"type": "number", "maximum": 1000000000000.0, "exclusiveMinimum": 0.0}, {"type": "string", "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$"}], "title": "Amount"}, "reason": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Reason"}}, "type": "object", "required": ["group_id", "amount"], "title": "BulkAllocateBody"}, "BulkMonthlyGrantBody": {"properties": {"group_id": {"type": "string", "title": "Group Id"}, "amount": {"anyOf": [{"type": "number", "maximum": 1000000000000.0, "minimum": 0.0}, {"type": "string", "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$"}], "title": "Amount"}}, "type": "object", "required": ["group_id", "amount"], "title": "BulkMonthlyGrantBody"}, "BulkTerminateRequest": {"properties": {"session_ids": {"items": {"type": "string"}, "type": "array", "title": "Session Ids"}, "reason": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Reason"}}, "type": "object", "required": ["session_ids"], "title": "BulkTerminateRequest"}, "BulkUserCreate": {"properties": {"group_id": {"type": "string", "title": "Group Id"}, "initial_role": {"type": "string", "title": "Initial Role", "default": "member"}, "rows": {"items": {"$ref": "#/components/schemas/BulkUserRow"}, "type": "array", "maxItems": 200, "minItems": 1, "title": "Rows"}}, "type": "object", "required": ["group_id", "rows"], "title": "BulkUserCreate"}, "BulkUserRow": {"properties": {"email": {"type": "string", "maxLength": 254, "minLength": 3, "title": "Email"}, "name": {"type": "string", "maxLength": 100, "minLength": 1, "title": "Name"}}, "type": "object", "required": ["email", "name"], "title": "BulkUserRow"}, "ChangePasswordRequest": {"properties": {"current_password": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Current Password"}, "new_password": {"type": "string", "maxLength": 128, "minLength": 8, "title": "New Password"}}, "type": "object", "required": ["new_password"], "title": "ChangePasswordRequest"}, "ClusterCompute": {"properties": {"cpu": {"$ref": "#/components/schemas/ClusterResource"}, "mem_gb": {"$ref": "#/components/schemas/ClusterResource"}, "disk_gb": {"$ref": "#/components/schemas/ClusterResource"}}, "type": "object", "required": ["cpu", "mem_gb", "disk_gb"], "title": "ClusterCompute"}, "ClusterCredit": {"properties": {"consumed_last_24h": {"type": "string", "title": "Consumed Last 24H"}, "active_holds": {"type": "string", "title": "Active Holds"}}, "type": "object", "required": ["consumed_last_24h", "active_holds"], "title": "ClusterCredit"}, "ClusterGpu": {"properties": {"device_total": {"type": "integer", "title": "Device Total"}, "vram_total_mb": {"type": "integer", "title": "Vram Total Mb"}, "vram_used_mb": {"type": "integer", "title": "Vram Used Mb"}, "vram_load_pct": {"type": "number", "title": "Vram Load Pct"}, "avg_utilization_pct": {"type": "number", "title": "Avg Utilization Pct"}, "empty_gpu_count": {"type": "integer", "title": "Empty Gpu Count"}}, "type": "object", "required": ["device_total", "vram_total_mb", "vram_used_mb", "vram_load_pct", "avg_utilization_pct", "empty_gpu_count"], "title": "ClusterGpu"}, "ClusterList": {"properties": {"data": {"items": {"$ref": "#/components/schemas/ClusterRead"}, "type": "array", "title": "Data"}, "pagination": {"$ref": "#/components/schemas/PageMeta"}}, "type": "object", "required": ["data", "pagination"], "title": "ClusterList", "description": "GET /clusters envelope."}, "ClusterMetrics": {"properties": {"as_of": {"type": "string", "title": "As Of"}, "nodes": {"$ref": "#/components/schemas/ClusterNodes"}, "gpu": {"$ref": "#/components/schemas/ClusterGpu"}, "sessions": {"$ref": "#/components/schemas/ClusterSessions"}, "credit": {"$ref": "#/components/schemas/ClusterCredit"}, "compute": {"$ref": "#/components/schemas/ClusterCompute"}, "storage": {"anyOf": [{"$ref": "#/components/schemas/ClusterStorage"}, {"type": "null"}]}}, "type": "object", "required": ["as_of", "nodes", "gpu", "sessions", "credit", "compute"], "title": "ClusterMetrics"}, "ClusterNodes": {"properties": {"total": {"type": "integer", "title": "Total"}, "ready": {"type": "integer", "title": "Ready"}, "busy": {"type": "integer", "title": "Busy"}, "cordoned": {"type": "integer", "title": "Cordoned"}, "offline": {"type": "integer", "title": "Offline"}}, "type": "object", "required": ["total", "ready", "busy", "cordoned", "offline"], "title": "ClusterNodes"}, "ClusterPatch": {"properties": {"name": {"anyOf": [{"type": "string", "maxLength": 80, "minLength": 1}, {"type": "null"}], "title": "Name"}, "role": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Role"}, "session_domain": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Session Domain"}}, "type": "object", "title": "ClusterPatch"}, "ClusterRead": {"properties": {"id": {"type": "string", "title": "Id"}, "name": {"type": "string", "title": "Name"}, "role": {"type": "string", "title": "Role"}, "api_server": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Api Server"}, "runtime": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Runtime"}, "session_domain": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Session Domain"}, "status": {"type": "string", "title": "Status"}, "node_count": {"type": "integer", "title": "Node Count"}, "gpu_count": {"type": "integer", "title": "Gpu Count"}, "registered_at": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Registered At"}}, "type": "object", "required": ["id", "name", "role", "status", "node_count", "gpu_count"], "title": "ClusterRead", "description": "One cluster as projected by ``_serialize_cluster`` (list rows)."}, "ClusterRegister": {"properties": {"name": {"type": "string", "maxLength": 80, "minLength": 1, "title": "Name"}, "role": {"type": "string", "title": "Role"}, "kubeconfig_b64": {"type": "string", "title": "Kubeconfig B64"}, "session_domain": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Session Domain"}}, "type": "object", "required": ["name", "role", "kubeconfig_b64"], "title": "ClusterRegister"}, "ClusterResource": {"properties": {"used": {"type": "integer", "title": "Used"}, "total": {"type": "integer", "title": "Total"}}, "type": "object", "required": ["used", "total"], "title": "ClusterResource", "description": "One host-compute dimension fleet-wide: promised to active sessions vs node capacity."}, "ClusterSessions": {"properties": {"running": {"type": "integer", "title": "Running"}, "queued": {"type": "integer", "title": "Queued"}}, "type": "object", "required": ["running", "queued"], "title": "ClusterSessions"}, "ClusterStorage": {"properties": {"disk_gb": {"$ref": "#/components/schemas/ClusterStorageDisk"}, "node_count": {"type": "integer", "title": "Node Count", "default": 0}, "shared": {"type": "boolean", "title": "Shared", "default": false}, "pools": {"items": {"$ref": "#/components/schemas/ClusterStoragePool"}, "type": "array", "title": "Pools", "default": []}}, "type": "object", "required": ["disk_gb"], "title": "ClusterStorage"}, "ClusterStorageDisk": {"properties": {"used": {"type": "integer", "title": "Used"}, "total": {"type": "integer", "title": "Total"}, "source": {"type": "string", "title": "Source", "default": "node_disk"}}, "type": "object", "required": ["used", "total"], "title": "ClusterStorageDisk"}, "ClusterStoragePool": {"properties": {"id": {"type": "string", "title": "Id"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Name"}, "hostname": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Hostname"}, "cluster_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cluster Id"}, "cluster_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cluster Name"}, "storage_class": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Storage Class"}, "share_scope": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Share Scope"}, "capacity_gb": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Capacity Gb"}, "capacity_source": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Capacity Source"}, "capacity_reported_at": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Capacity Reported At"}}, "type": "object", "required": ["id"], "title": "ClusterStoragePool", "description": "One registered volume-backing pool: what it is, where it sits, and who may use it."}, "ConnectionInfo": {"properties": {"kind": {"type": "string", "title": "Kind"}, "url": {"type": "string", "title": "Url"}, "connection_token": {"type": "string", "title": "Connection Token"}, "expires_at": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Expires At"}, "command": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Command"}}, "type": "object", "required": ["kind", "url", "connection_token"], "title": "ConnectionInfo"}, "DashboardAllocation": {"properties": {"instances": {"$ref": "#/components/schemas/DashboardInstances"}, "vram": {"$ref": "#/components/schemas/MyVram"}, "gpu_cores": {"anyOf": [{"$ref": "#/components/schemas/ResourceUsage"}, {"type": "null"}]}}, "type": "object", "required": ["instances", "vram"], "title": "DashboardAllocation"}, "DashboardCompute": {"properties": {"cpu": {"$ref": "#/components/schemas/ResourceUsage"}, "mem_gb": {"$ref": "#/components/schemas/ResourceUsage"}, "disk_gb": {"$ref": "#/components/schemas/ResourceUsage"}}, "type": "object", "required": ["cpu", "mem_gb", "disk_gb"], "title": "DashboardCompute", "description": "Host compute held by the caller's active sessions \u2014 separate from GPU/credits, since\ncpu/mem/disk are quota-governed, not billed."}, "DashboardCredit": {"properties": {"available": {"anyOf": [{"type": "number"}, {"type": "null"}], "title": "Available"}, "balance": {"anyOf": [{"type": "number"}, {"type": "null"}], "title": "Balance"}, "reserved": {"anyOf": [{"type": "number"}, {"type": "null"}], "title": "Reserved"}}, "type": "object", "title": "DashboardCredit", "description": "Owner wallet snapshot; all None when the user has no wallet."}, "DashboardInstances": {"properties": {"used": {"type": "integer", "title": "Used"}, "total": {"type": "integer", "title": "Total"}}, "type": "object", "required": ["used", "total"], "title": "DashboardInstances"}, "DashboardPool": {"properties": {"id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Id"}, "name": {"type": "string", "title": "Name"}, "kind": {"type": "string", "title": "Kind"}, "tier": {"type": "string", "title": "Tier"}}, "type": "object", "required": ["name", "kind", "tier"], "title": "DashboardPool", "description": "A node pool the caller may place on. `id` is None for the unassigned (\"shared\") bucket."}, "DashboardRegion": {"properties": {"model": {"type": "string", "title": "Model"}, "total": {"type": "integer", "title": "Total"}, "free": {"type": "integer", "title": "Free"}, "free_mb": {"type": "integer", "title": "Free Mb", "default": 0}, "total_mb": {"type": "integer", "title": "Total Mb", "default": 0}}, "type": "object", "required": ["model", "total", "free"], "title": "DashboardRegion", "description": "Per-GPU-model availability bucket.\n\n`free`/`total` count whole cards, which under fractional sharing understates what is usable: a\ncard holding one small slice is not \"free\" yet can still host several more sessions. The VRAM\nfigures are what actually decide whether a session starts, so both are reported."}, "DashboardSessions": {"properties": {"running": {"type": "integer", "title": "Running"}, "active": {"type": "integer", "title": "Active"}}, "type": "object", "required": ["running", "active"], "title": "DashboardSessions"}, "DashboardSummary": {"properties": {"credit": {"$ref": "#/components/schemas/DashboardCredit"}, "sessions": {"$ref": "#/components/schemas/DashboardSessions"}, "vram": {"$ref": "#/components/schemas/DashboardVram"}, "regions": {"items": {"$ref": "#/components/schemas/DashboardRegion"}, "type": "array", "title": "Regions"}, "pools": {"items": {"$ref": "#/components/schemas/DashboardPool"}, "type": "array", "title": "Pools", "default": []}, "allocation": {"$ref": "#/components/schemas/DashboardAllocation"}, "compute": {"$ref": "#/components/schemas/DashboardCompute"}, "storage": {"anyOf": [{"additionalProperties": true, "type": "object"}, {"type": "null"}], "title": "Storage"}}, "type": "object", "required": ["credit", "sessions", "vram", "regions", "allocation", "compute"], "title": "DashboardSummary", "description": "GET /dashboard/summary aggregate."}, "DashboardVram": {"properties": {"used_mb": {"type": "integer", "title": "Used Mb"}, "total_mb": {"type": "integer", "title": "Total Mb"}}, "type": "object", "required": ["used_mb", "total_mb"], "title": "DashboardVram"}, "ForceTerminateBody": {"properties": {"reason": {"anyOf": [{"type": "string", "maxLength": 500}, {"type": "null"}], "title": "Reason"}}, "type": "object", "title": "ForceTerminateBody", "description": "Admin's justification \u2014 recorded verbatim in the audit log (the console requires it)."}, "GlobalRoleSet": {"properties": {"global_roles": {"anyOf": [{"items": {"type": "string"}, "type": "array"}, {"type": "null"}], "title": "Global Roles"}, "global_role": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Global Role"}}, "type": "object", "title": "GlobalRoleSet"}, "GpuDeviceList": {"properties": {"data": {"items": {"$ref": "#/components/schemas/GpuDeviceRow"}, "type": "array", "title": "Data"}, "total": {"type": "integer", "title": "Total"}}, "type": "object", "required": ["data", "total"], "title": "GpuDeviceList"}, "GpuDeviceModeSet": {"properties": {"desired_mode": {"anyOf": [{"type": "string", "pattern": "^(fractional|exclusive|mig)$"}, {"type": "null"}], "title": "Desired Mode"}}, "type": "object", "title": "GpuDeviceModeSet"}, "GpuDeviceRow": {"properties": {"id": {"type": "string", "title": "Id"}, "node_id": {"type": "string", "title": "Node Id"}, "model": {"type": "string", "title": "Model"}, "alias": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Alias"}, "mode": {"type": "string", "title": "Mode"}, "desired_mode": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Desired Mode"}, "mode_state": {"type": "string", "title": "Mode State", "default": "ready"}, "status": {"type": "string", "title": "Status"}, "node_status": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Node Status"}, "gpu_uuid": {"type": "string", "title": "Gpu Uuid"}, "total_mem_mb": {"type": "integer", "title": "Total Mem Mb"}, "used_mem_mb": {"type": "integer", "title": "Used Mem Mb"}, "free_mem_mb": {"type": "integer", "title": "Free Mem Mb"}, "total_cores": {"type": "integer", "title": "Total Cores"}, "used_cores": {"type": "integer", "title": "Used Cores"}, "free_cores": {"type": "integer", "title": "Free Cores"}, "bound_sessions": {"items": {"$ref": "#/components/schemas/BoundSession"}, "type": "array", "title": "Bound Sessions"}}, "type": "object", "required": ["id", "node_id", "model", "mode", "status", "gpu_uuid", "total_mem_mb", "used_mem_mb", "free_mem_mb", "total_cores", "used_cores", "free_cores", "bound_sessions"], "title": "GpuDeviceRow"}, "HTTPValidationError": {"properties": {"detail": {"items": {"$ref": "#/components/schemas/ValidationError"}, "type": "array", "title": "Detail"}}, "type": "object", "title": "HTTPValidationError"}, "ImageBuildList": {"properties": {"data": {"items": {"$ref": "#/components/schemas/ImageBuildRead"}, "type": "array", "title": "Data"}, "pagination": {"$ref": "#/components/schemas/PageMeta"}}, "type": "object", "required": ["data", "pagination"], "title": "ImageBuildList", "description": "GET /image-builds envelope."}, "ImageBuildRead": {"properties": {"id": {"type": "string", "title": "Id"}, "group_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Group Id"}, "owner_user_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Owner User Id"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Name"}, "source": {"type": "string", "title": "Source"}, "status": {"type": "string", "title": "Status"}, "error": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Error"}, "image_ref": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Image Ref"}, "image_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Image Id"}, "created_at": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Created At"}, "started_at": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Started At"}, "finished_at": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Finished At"}}, "type": "object", "required": ["id", "source", "status"], "title": "ImageBuildRead", "description": "One build row as projected by ``_serialize_build``."}, "ImageCreate": {"properties": {"name": {"type": "string", "maxLength": 120, "minLength": 1, "title": "Name"}, "registry": {"type": "string", "title": "Registry"}, "kind": {"type": "string", "title": "Kind"}, "tags": {"additionalProperties": true, "type": "object", "title": "Tags"}, "supported_gpus": {"items": {"type": "string"}, "type": "array", "title": "Supported Gpus"}, "cuda_version": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cuda Version"}, "gpu_ready": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Gpu Ready"}}, "type": "object", "required": ["name", "registry", "kind"], "title": "ImageCreate"}, "ImageImport": {"properties": {"source_type": {"type": "string", "title": "Source Type"}, "source": {"type": "string", "title": "Source"}, "name": {"type": "string", "maxLength": 120, "minLength": 1, "title": "Name"}, "kind": {"type": "string", "title": "Kind"}, "registry_auth": {"anyOf": [{"additionalProperties": true, "type": "object"}, {"type": "null"}], "title": "Registry Auth"}, "tags": {"additionalProperties": true, "type": "object", "title": "Tags"}, "supported_gpus": {"items": {"type": "string"}, "type": "array", "title": "Supported Gpus"}, "cuda_version": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cuda Version"}, "gpu_ready": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Gpu Ready"}}, "type": "object", "required": ["source_type", "source", "name", "kind"], "title": "ImageImport"}, "ImageList": {"properties": {"data": {"items": {"$ref": "#/components/schemas/ImageRead"}, "type": "array", "title": "Data"}, "pagination": {"$ref": "#/components/schemas/PageMeta"}}, "type": "object", "required": ["data", "pagination"], "title": "ImageList", "description": "GET /images envelope."}, "ImageRead": {"properties": {"id": {"type": "string", "title": "Id"}, "name": {"type": "string", "title": "Name"}, "registry": {"type": "string", "title": "Registry"}, "kind": {"type": "string", "title": "Kind"}, "tags": {"additionalProperties": true, "type": "object", "title": "Tags"}, "supported_gpus": {"items": {}, "type": "array", "title": "Supported Gpus"}, "cuda_version": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cuda Version"}, "gpu_ready": {"type": "boolean", "title": "Gpu Ready", "default": false}, "public": {"type": "boolean", "title": "Public", "default": true}, "owner_user_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Owner User Id"}, "created_at": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Created At"}, "import_status": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Import Status"}}, "type": "object", "required": ["id", "name", "registry", "kind", "tags", "supported_gpus"], "title": "ImageRead", "description": "One catalog image as projected by ``_serialize_image``.\n\n``import_status`` is only present for imported/registered rows, so it is Optional."}, "ImageUpdate": {"properties": {"name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Name"}, "public": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Public"}, "registry": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Registry"}, "cuda_version": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cuda Version"}, "supported_gpus": {"anyOf": [{"items": {"type": "string"}, "type": "array"}, {"type": "null"}], "title": "Supported Gpus"}, "gpu_ready": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Gpu Ready"}}, "type": "object", "title": "ImageUpdate"}, "MeMembership": {"properties": {"group_id": {"type": "string", "title": "Group Id"}, "project_name": {"type": "string", "title": "Project Name"}, "org_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Org Id"}, "org_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Org Name"}, "role": {"type": "string", "title": "Role"}, "has_group_admin": {"type": "boolean", "title": "Has Group Admin", "default": true}}, "type": "object", "required": ["group_id", "project_name", "role"], "title": "MeMembership", "description": "One membership row resolved for the context switcher / RBAC."}, "MeResponse": {"properties": {"user_id": {"type": "string", "title": "User Id"}, "global_role": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Global Role"}, "global_roles": {"items": {"type": "string"}, "type": "array", "title": "Global Roles", "default": []}, "org_admin_orgs": {"items": {"type": "string"}, "type": "array", "title": "Org Admin Orgs", "default": []}, "memberships": {"items": {"$ref": "#/components/schemas/MeMembership"}, "type": "array", "title": "Memberships"}, "email": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Email"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Name"}, "must_change_password": {"type": "boolean", "title": "Must Change Password"}}, "type": "object", "required": ["user_id", "memberships", "must_change_password"], "title": "MeResponse", "description": "Identity/role/memberships of the current principal (GET /auth/me)."}, "MembershipCreate": {"properties": {"user_id": {"type": "string", "title": "User Id"}, "role": {"type": "string", "title": "Role"}, "expires_at": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Expires At"}, "grant_credit": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Grant Credit"}}, "type": "object", "required": ["user_id", "role"], "title": "MembershipCreate"}, "MembershipPatch": {"properties": {"role": {"type": "string", "title": "Role"}}, "type": "object", "required": ["role"], "title": "MembershipPatch"}, "MonthlyGrantBody": {"properties": {"amount": {"anyOf": [{"type": "number", "maximum": 1000000000000.0, "minimum": 0.0}, {"type": "string", "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$"}], "title": "Amount"}}, "type": "object", "required": ["amount"], "title": "MonthlyGrantBody"}, "MyVram": {"properties": {"used_mb": {"type": "integer", "title": "Used Mb"}, "limit_mb": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Limit Mb"}}, "type": "object", "required": ["used_mb"], "title": "MyVram", "description": "The caller's own VRAM footprint vs their policy limit (None = unlimited)."}, "NodeList": {"properties": {"data": {"items": {"$ref": "#/components/schemas/NodeRow"}, "type": "array", "title": "Data"}, "total": {"type": "integer", "title": "Total"}}, "type": "object", "required": ["data", "total"], "title": "NodeList"}, "NodePoolSet": {"properties": {"pool_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Pool Id"}}, "type": "object", "title": "NodePoolSet"}, "NodeRow": {"properties": {"id": {"type": "string", "title": "Id"}, "hostname": {"type": "string", "title": "Hostname"}, "cluster_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cluster Id"}, "cluster_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cluster Name"}, "status": {"type": "string", "title": "Status"}, "cpu": {"type": "integer", "title": "Cpu"}, "mem_gb": {"type": "integer", "title": "Mem Gb"}, "disk_gb": {"type": "integer", "title": "Disk Gb", "default": 0}, "role": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Role"}, "region": {"type": "string", "title": "Region"}, "gpu_mode": {"type": "string", "title": "Gpu Mode"}, "mode_counts": {"additionalProperties": {"type": "integer"}, "type": "object", "title": "Mode Counts", "default": {}}, "device_count": {"type": "integer", "title": "Device Count"}, "alloc_cpu": {"type": "integer", "title": "Alloc Cpu", "default": 0}, "alloc_mem_gb": {"type": "integer", "title": "Alloc Mem Gb", "default": 0}, "alloc_disk_gb": {"type": "integer", "title": "Alloc Disk Gb", "default": 0}, "running_sessions": {"type": "integer", "title": "Running Sessions", "default": 0}, "heartbeat_at": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Heartbeat At"}, "pool_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Pool Id"}, "pool_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Pool Name"}}, "type": "object", "required": ["id", "hostname", "status", "cpu", "mem_gb", "region", "gpu_mode", "device_count"], "title": "NodeRow"}, "OfferingCreate": {"properties": {"name": {"type": "string", "title": "Name"}, "resource_class": {"type": "string", "title": "Resource Class", "default": "gpu"}, "gpu_model": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Gpu Model"}, "gpu_mem_mb": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Gpu Mem Mb"}, "gpu_cores": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Gpu Cores"}, "cpu": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Cpu"}, "mem_gb": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Mem Gb"}, "disk_gb": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Disk Gb"}, "credit_per_hour": {"type": "string", "title": "Credit Per Hour"}, "status": {"type": "string", "title": "Status", "default": "active"}, "min_cuda": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Min Cuda"}}, "type": "object", "required": ["name", "credit_per_hour"], "title": "OfferingCreate"}, "OfferingUpdate": {"properties": {"name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Name"}, "gpu_model": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Gpu Model"}, "gpu_mem_mb": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Gpu Mem Mb"}, "gpu_cores": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Gpu Cores"}, "cpu": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Cpu"}, "mem_gb": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Mem Gb"}, "disk_gb": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Disk Gb"}, "credit_per_hour": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Credit Per Hour"}, "status": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Status"}, "min_cuda": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Min Cuda"}}, "type": "object", "title": "OfferingUpdate"}, "OperatorAuditEvent": {"properties": {"actor": {"type": "string", "title": "Actor"}, "action": {"type": "string", "title": "Action"}, "target": {"type": "string", "title": "Target"}, "result": {"type": "string", "title": "Result"}, "detail": {"anyOf": [{"additionalProperties": true, "type": "object"}, {"type": "null"}], "title": "Detail"}, "trace_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Trace Id"}, "ts": {"type": "string", "format": "date-time", "title": "Ts"}}, "type": "object", "required": ["actor", "action", "target", "result", "ts"], "title": "OperatorAuditEvent", "description": "Operator privileged-action audit callback."}, "OperatorGpuDeviceUpsert": {"properties": {"node_id": {"type": "string", "title": "Node Id"}, "uuid": {"type": "string", "title": "Uuid"}, "mode": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Mode"}, "total_mem_mb": {"type": "integer", "title": "Total Mem Mb", "default": 0}, "used_mem_mb": {"type": "integer", "title": "Used Mem Mb", "default": 0}, "total_cores": {"type": "integer", "title": "Total Cores", "default": 0}, "used_cores": {"type": "integer", "title": "Used Cores", "default": 0}, "status": {"type": "string", "title": "Status", "default": "ready"}, "model": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Model"}, "cluster_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cluster Id"}, "node_cpu": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Node Cpu"}, "node_ready": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Node Ready"}, "node_mem_gb": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Node Mem Gb"}, "node_disk_gb": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Node Disk Gb"}}, "type": "object", "required": ["node_id", "uuid"], "title": "OperatorGpuDeviceUpsert", "description": "GPU device inventory upsert (operator InventoryReconciler -> ledger).\n\nPure reflection of measured device-plugin/DCGM capacity. The control plane preserves\nledger-owned occupancy (used_mem_mb/used_cores) \u2014 those are NOT overwritten from here."}, "OperatorNodeHealthEvent": {"properties": {"node_id": {"type": "string", "title": "Node Id"}, "kind": {"type": "string", "title": "Kind"}, "severity": {"type": "string", "title": "Severity", "default": "critical"}, "action": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Action"}, "gpu_uuid": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Gpu Uuid"}, "message": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Message"}, "cluster_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cluster Id"}, "id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Id"}}, "type": "object", "required": ["node_id", "kind"], "title": "OperatorNodeHealthEvent", "description": "Node health transition (operator HealthReconciler -> ledger).\n\nOn a critical breach (e.g. fatal Xid) the operator cordons the node and reports this; the\ncontrol plane records a NodeHealthEvent and marks GpuNode.status=cordoned."}, "OperatorNodeUpsert": {"properties": {"node_id": {"type": "string", "title": "Node Id"}, "cluster_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cluster Id"}, "node_cpu": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Node Cpu"}, "node_ready": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Node Ready"}, "node_mem_gb": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Node Mem Gb"}, "node_disk_gb": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Node Disk Gb"}, "lossless_capable": {"type": "boolean", "title": "Lossless Capable", "default": false}, "role": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Role"}}, "type": "object", "required": ["node_id"], "title": "OperatorNodeUpsert", "description": "Upsert node inventory, reporting capacity for every node whether or not it has a GPU.\n\nGPU-less nodes \u2014 CPU workers and the control plane \u2014 still contribute their cpu, mem, and disk\nto the displayed inventory and the capacity calculation. "}, "OperatorPoolCapacity": {"properties": {"storage_class": {"type": "string", "title": "Storage Class"}, "capacity_bytes": {"type": "integer", "minimum": 0.0, "title": "Capacity Bytes"}}, "type": "object", "required": ["storage_class", "capacity_bytes"], "title": "OperatorPoolCapacity", "description": "What the CSI driver says a StorageClass's backing pool holds.\n\nRead from the CSIStorageCapacity objects the external-provisioner publishes. The control plane\ncannot ask a CSI driver anything \u2014 it never touches the workload API \u2014 and the only figure it\ncould see on its own is the storage node's root disk, which is not the pool."}, "OperatorSessionDisk": {"properties": {"name": {"type": "string", "title": "Name"}, "ephemeral_used_bytes": {"type": "integer", "title": "Ephemeral Used Bytes"}, "ephemeral_limit_bytes": {"type": "integer", "title": "Ephemeral Limit Bytes"}}, "type": "object", "required": ["name", "ephemeral_used_bytes", "ephemeral_limit_bytes"], "title": "OperatorSessionDisk", "description": "Ephemeral (scratch) disk usage of one session pod, read from kubelet /stats/summary.\n\n``name`` is the GShareSession CR name \u2014 the sanitized session id (``ses-01abc...``)."}, "OperatorStatusEvent": {"properties": {"phase": {"type": "string", "title": "Phase"}, "bound_gpu_uuid": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Bound Gpu Uuid"}, "node_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Node Name"}, "yield_state": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Yield State"}, "pod_ref": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Pod Ref"}, "used_mem_mb": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Used Mem Mb"}, "message": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Message"}, "trace_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Trace Id"}, "restart_count": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Restart Count"}, "generation": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Generation"}, "container_state": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Container State"}, "ts": {"type": "string", "format": "date-time", "title": "Ts"}}, "type": "object", "required": ["phase", "ts"], "title": "OperatorStatusEvent", "description": "Status callback payload. Drives consume/settle triggers."}, "OperatorVolumeObserved": {"properties": {"name": {"type": "string", "title": "Name"}, "volume_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Volume Id"}, "capacity_gb": {"type": "integer", "title": "Capacity Gb", "default": 0}, "used_bytes": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Used Bytes"}, "mounted": {"type": "boolean", "title": "Mounted", "default": false}}, "type": "object", "required": ["name"], "title": "OperatorVolumeObserved", "description": "One PVC the operator sees in the session namespace (label gshare.io/volume)."}, "OperatorVolumeSync": {"properties": {"volumes": {"items": {"$ref": "#/components/schemas/OperatorVolumeObserved"}, "type": "array", "title": "Volumes"}, "cluster_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cluster Id"}, "sessions": {"items": {"$ref": "#/components/schemas/OperatorSessionDisk"}, "type": "array", "title": "Sessions", "default": []}, "pools": {"items": {"$ref": "#/components/schemas/OperatorPoolCapacity"}, "type": "array", "title": "Pools", "default": []}}, "type": "object", "required": ["volumes"], "title": "OperatorVolumeSync", "description": "POST /internal/volumes/sync \u2014 the operator's periodic view of every session-volume PVC."}, "OrgAdminCreate": {"properties": {"user_id": {"type": "string", "title": "User Id"}}, "type": "object", "required": ["user_id"], "title": "OrgAdminCreate"}, "OrgCreate": {"properties": {"name": {"type": "string", "maxLength": 80, "minLength": 1, "title": "Name"}, "status": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Status"}, "create_node_pool": {"type": "boolean", "title": "Create Node Pool", "default": false}}, "type": "object", "required": ["name"], "title": "OrgCreate"}, "OrgUpdate": {"properties": {"name": {"anyOf": [{"type": "string", "maxLength": 80}, {"type": "null"}], "title": "Name"}, "status": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Status"}}, "type": "object", "title": "OrgUpdate"}, "PageMeta": {"properties": {"page": {"type": "integer", "title": "Page"}, "size": {"type": "integer", "title": "Size"}, "total": {"type": "integer", "title": "Total"}, "total_pages": {"type": "integer", "title": "Total Pages"}}, "type": "object", "required": ["page", "size", "total", "total_pages"], "title": "PageMeta", "description": "Standard pagination envelope (page/size/total/total_pages)."}, "PageMetaNoPages": {"properties": {"page": {"type": "integer", "title": "Page"}, "size": {"type": "integer", "title": "Size"}, "total": {"type": "integer", "title": "Total"}}, "type": "object", "required": ["page", "size", "total"], "title": "PageMetaNoPages", "description": "Pagination envelope without ``total_pages`` (queue list)."}, "PermissionBody": {"properties": {"user_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "User Id"}, "email": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Email"}, "role": {"type": "string", "pattern": "^(owner|rw|ro)$", "title": "Role"}}, "type": "object", "required": ["role"], "title": "PermissionBody"}, "PlacementOut": {"properties": {"gpu_packing": {"type": "string", "title": "Gpu Packing"}}, "type": "object", "required": ["gpu_packing"], "title": "PlacementOut"}, "PlacementUpdate": {"properties": {"gpu_packing": {"type": "string", "pattern": "^(binpack|spread)$", "title": "Gpu Packing"}}, "type": "object", "required": ["gpu_packing"], "title": "PlacementUpdate"}, "PolicyCreate": {"properties": {"scope": {"type": "string", "title": "Scope"}, "scope_id": {"type": "string", "title": "Scope Id"}, "max_concurrent": {"type": "integer", "title": "Max Concurrent"}, "max_queued": {"type": "integer", "title": "Max Queued"}, "max_runtime_min": {"type": "integer", "title": "Max Runtime Min"}, "idle_timeout_sec": {"type": "integer", "title": "Idle Timeout Sec"}, "cpu_session_max_concurrent": {"type": "integer", "title": "Cpu Session Max Concurrent", "default": 1}, "cpu_session_max_runtime_min": {"type": "integer", "title": "Cpu Session Max Runtime Min", "default": 240}, "cpu_session_idle_timeout_sec": {"type": "integer", "title": "Cpu Session Idle Timeout Sec", "default": 1800}, "limits": {"additionalProperties": true, "type": "object", "title": "Limits", "default": {}}}, "type": "object", "required": ["scope", "scope_id", "max_concurrent", "max_queued", "max_runtime_min", "idle_timeout_sec"], "title": "PolicyCreate"}, "PolicyUpdate": {"properties": {"max_concurrent": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Max Concurrent"}, "max_queued": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Max Queued"}, "max_runtime_min": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Max Runtime Min"}, "idle_timeout_sec": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Idle Timeout Sec"}, "cpu_session_max_concurrent": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Cpu Session Max Concurrent"}, "cpu_session_max_runtime_min": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Cpu Session Max Runtime Min"}, "cpu_session_idle_timeout_sec": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Cpu Session Idle Timeout Sec"}, "limits": {"anyOf": [{"additionalProperties": true, "type": "object"}, {"type": "null"}], "title": "Limits"}}, "type": "object", "title": "PolicyUpdate"}, "PoolGrantCreate": {"properties": {"scope": {"type": "string", "pattern": "^(org|group)$", "title": "Scope"}, "scope_id": {"type": "string", "minLength": 1, "title": "Scope Id"}}, "type": "object", "required": ["scope", "scope_id"], "title": "PoolGrantCreate"}, "PoolGrantRead": {"properties": {"id": {"type": "string", "title": "Id"}, "scope": {"type": "string", "title": "Scope"}, "scope_id": {"type": "string", "title": "Scope Id"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Name"}, "created_at": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Created At"}}, "type": "object", "required": ["id", "scope", "scope_id"], "title": "PoolGrantRead"}, "PoolList": {"properties": {"data": {"items": {"$ref": "#/components/schemas/PoolRead"}, "type": "array", "title": "Data"}, "total": {"type": "integer", "title": "Total"}}, "type": "object", "required": ["data", "total"], "title": "PoolList"}, "PoolNodeRow": {"properties": {"id": {"type": "string", "title": "Id"}, "hostname": {"type": "string", "title": "Hostname"}, "status": {"type": "string", "title": "Status"}, "device_count": {"type": "integer", "title": "Device Count"}}, "type": "object", "required": ["id", "hostname", "status", "device_count"], "title": "PoolNodeRow"}, "PoolPatch": {"properties": {"name": {"anyOf": [{"type": "string", "maxLength": 80, "minLength": 1}, {"type": "null"}], "title": "Name"}, "storage_class": {"anyOf": [{"type": "string", "maxLength": 200, "minLength": 1}, {"type": "null"}], "title": "Storage Class"}, "node_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Node Id"}, "share_scope": {"anyOf": [{"type": "string", "pattern": "^(all|selected)$"}, {"type": "null"}], "title": "Share Scope"}, "shared_with": {"anyOf": [{"items": {"type": "string"}, "type": "array"}, {"type": "null"}], "title": "Shared With"}, "manual_capacity_gb": {"anyOf": [{"type": "integer", "minimum": 0.0}, {"type": "null"}], "title": "Manual Capacity Gb"}}, "type": "object", "title": "PoolPatch"}, "PoolRead": {"properties": {"id": {"type": "string", "title": "Id"}, "cluster_id": {"type": "string", "title": "Cluster Id"}, "cluster_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cluster Name"}, "name": {"type": "string", "title": "Name"}, "description": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Description"}, "kind": {"type": "string", "title": "Kind"}, "node_count": {"type": "integer", "title": "Node Count"}, "nodes": {"items": {"$ref": "#/components/schemas/PoolNodeRow"}, "type": "array", "title": "Nodes"}, "grants": {"items": {"$ref": "#/components/schemas/PoolGrantRead"}, "type": "array", "title": "Grants"}}, "type": "object", "required": ["id", "cluster_id", "name", "kind", "node_count", "nodes", "grants"], "title": "PoolRead"}, "PoolTargetsBody": {"properties": {"mig_cards": {"type": "integer", "minimum": 0.0, "title": "Mig Cards"}}, "type": "object", "required": ["mig_cards"], "title": "PoolTargetsBody"}, "PoolUpdate": {"properties": {"name": {"anyOf": [{"type": "string", "maxLength": 120, "minLength": 1}, {"type": "null"}], "title": "Name"}, "description": {"anyOf": [{"type": "string", "maxLength": 1000}, {"type": "null"}], "title": "Description"}, "kind": {"anyOf": [{"type": "string", "pattern": "^(shared|dedicated)$"}, {"type": "null"}], "title": "Kind"}}, "type": "object", "title": "PoolUpdate"}, "PresetCreate": {"properties": {"name": {"type": "string", "title": "Name"}, "kind": {"type": "string", "title": "Kind", "default": "gpu"}, "cpu": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Cpu"}, "mem_gb": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Mem Gb"}, "disk_gb": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Disk Gb"}, "gpu_frac": {"anyOf": [{"type": "number"}, {"type": "null"}], "title": "Gpu Frac"}, "gpu_cores": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Gpu Cores"}, "mode": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Mode"}}, "type": "object", "required": ["name"], "title": "PresetCreate"}, "PresetUpdate": {"properties": {"name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Name"}, "cpu": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Cpu"}, "mem_gb": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Mem Gb"}, "disk_gb": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Disk Gb"}, "gpu_frac": {"anyOf": [{"type": "number"}, {"type": "null"}], "title": "Gpu Frac"}, "gpu_cores": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Gpu Cores"}, "mode": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Mode"}}, "type": "object", "title": "PresetUpdate"}, "PreviewCostRequest": {"properties": {"name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Name"}, "offering_id": {"type": "string", "title": "Offering Id"}, "image_id": {"type": "string", "title": "Image Id"}, "resource_class": {"type": "string", "pattern": "^(gpu|cpu)$", "title": "Resource Class"}, "cluster_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cluster Id"}, "cluster_mode": {"type": "string", "pattern": "^(single|multi)$", "title": "Cluster Mode", "default": "single"}, "group_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Group Id"}, "gpu_mem_mb": {"anyOf": [{"type": "integer", "minimum": 0.0}, {"type": "null"}], "title": "Gpu Mem Mb"}, "gpu_cores": {"anyOf": [{"type": "integer", "maximum": 100.0, "minimum": 0.0}, {"type": "null"}], "title": "Gpu Cores"}, "cpu": {"anyOf": [{"type": "integer", "minimum": 0.0}, {"type": "null"}], "title": "Cpu"}, "mem_gb": {"anyOf": [{"type": "integer", "minimum": 0.0}, {"type": "null"}], "title": "Mem Gb"}, "disk_gb": {"anyOf": [{"type": "integer", "minimum": 0.0}, {"type": "null"}], "title": "Disk Gb"}, "mode": {"anyOf": [{"type": "string", "pattern": "^(fractional|exclusive)$"}, {"type": "null"}], "title": "Mode"}, "billing_wallet_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Billing Wallet Id"}, "preemptible": {"type": "boolean", "title": "Preemptible", "default": false}, "privileged": {"type": "boolean", "title": "Privileged", "default": false}, "priority": {"type": "integer", "minimum": 0.0, "title": "Priority", "default": 0}, "volume_mounts": {"items": {"$ref": "#/components/schemas/VolumeMountSpec"}, "type": "array", "title": "Volume Mounts", "default": []}}, "additionalProperties": false, "type": "object", "required": ["offering_id", "image_id", "resource_class"], "title": "PreviewCostRequest"}, "PreviewCostResponse": {"properties": {"estimated_credit_per_hour": {"type": "number", "title": "Estimated Credit Per Hour"}, "occupancy": {"type": "number", "title": "Occupancy"}, "hold_amount": {"type": "number", "title": "Hold Amount"}}, "type": "object", "required": ["estimated_credit_per_hour", "occupancy", "hold_amount"], "title": "PreviewCostResponse"}, "ProjectCreate": {"properties": {"org_id": {"type": "string", "title": "Org Id"}, "name": {"type": "string", "maxLength": 80, "minLength": 1, "title": "Name"}, "status": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Status"}, "create_project_wallet": {"type": "boolean", "title": "Create Project Wallet", "default": true}, "default_member_credit": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Default Member Credit"}, "create_node_pool": {"type": "boolean", "title": "Create Node Pool", "default": false}}, "type": "object", "required": ["org_id", "name"], "title": "ProjectCreate"}, "ProjectPatch": {"properties": {"name": {"anyOf": [{"type": "string", "maxLength": 80}, {"type": "null"}], "title": "Name"}, "status": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Status"}, "default_member_credit": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Default Member Credit"}}, "type": "object", "title": "ProjectPatch"}, "QueueEntryView": {"properties": {"id": {"type": "string", "title": "Id"}, "session_id": {"type": "string", "title": "Session Id"}, "priority": {"type": "integer", "title": "Priority"}, "status": {"type": "string", "title": "Status"}, "position": {"type": "integer", "title": "Position"}, "score": {"type": "number", "title": "Score"}, "session_req": {"additionalProperties": true, "type": "object", "title": "Session Req"}, "enqueued_at": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Enqueued At"}, "eta_minutes": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Eta Minutes"}, "session_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Session Name"}, "owner_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Owner Name"}, "gpu_model": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Gpu Model"}, "cluster_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cluster Id"}, "reason": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Reason"}}, "type": "object", "required": ["id", "session_id", "priority", "status", "position", "score", "session_req"], "title": "QueueEntryView", "description": "One queue entry as projected by ``_entry_view`` (status is always ``queued``)."}, "QueueList": {"properties": {"data": {"items": {"$ref": "#/components/schemas/QueueEntryView"}, "type": "array", "title": "Data"}, "pagination": {"$ref": "#/components/schemas/PageMetaNoPages"}}, "type": "object", "required": ["data", "pagination"], "title": "QueueList", "description": "GET /queue envelope (pagination without total_pages)."}, "QueueMineList": {"properties": {"data": {"items": {"$ref": "#/components/schemas/QueueEntryView"}, "type": "array", "title": "Data"}}, "type": "object", "required": ["data"], "title": "QueueMineList", "description": "GET /queue/mine envelope (data only, no pagination)."}, "QueuePriorityPatch": {"properties": {"priority": {"type": "integer", "minimum": 0.0, "title": "Priority"}}, "type": "object", "required": ["priority"], "title": "QueuePriorityPatch"}, "RefillScheduleBody": {"properties": {"day": {"type": "integer", "maximum": 28.0, "minimum": 1.0, "title": "Day"}, "hour": {"type": "integer", "maximum": 23.0, "minimum": 0.0, "title": "Hour"}}, "type": "object", "required": ["day", "hour"], "title": "RefillScheduleBody"}, "ResourceRequestCreate": {"properties": {"group_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Group Id"}, "cpu": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Cpu"}, "mem_gb": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Mem Gb"}, "storage_gb": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Storage Gb"}, "gpu_mem_mb": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Gpu Mem Mb"}, "gpu_cores": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Gpu Cores"}, "note": {"type": "string", "title": "Note"}}, "type": "object", "required": ["note"], "title": "ResourceRequestCreate"}, "ResourceUsage": {"properties": {"used": {"type": "integer", "title": "Used"}, "limit": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Limit"}}, "type": "object", "required": ["used"], "title": "ResourceUsage", "description": "One compute dimension: what the caller's active sessions hold vs the policy limit\n(None = no limit configured)."}, "SessionCreate": {"properties": {"name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Name"}, "offering_id": {"type": "string", "title": "Offering Id"}, "image_id": {"type": "string", "title": "Image Id"}, "resource_class": {"type": "string", "pattern": "^(gpu|cpu)$", "title": "Resource Class"}, "cluster_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cluster Id"}, "cluster_mode": {"type": "string", "pattern": "^(single|multi)$", "title": "Cluster Mode", "default": "single"}, "group_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Group Id"}, "gpu_mem_mb": {"anyOf": [{"type": "integer", "minimum": 0.0}, {"type": "null"}], "title": "Gpu Mem Mb"}, "gpu_cores": {"anyOf": [{"type": "integer", "maximum": 100.0, "minimum": 0.0}, {"type": "null"}], "title": "Gpu Cores"}, "cpu": {"anyOf": [{"type": "integer", "minimum": 0.0}, {"type": "null"}], "title": "Cpu"}, "mem_gb": {"anyOf": [{"type": "integer", "minimum": 0.0}, {"type": "null"}], "title": "Mem Gb"}, "disk_gb": {"anyOf": [{"type": "integer", "minimum": 0.0}, {"type": "null"}], "title": "Disk Gb"}, "mode": {"anyOf": [{"type": "string", "pattern": "^(fractional|exclusive)$"}, {"type": "null"}], "title": "Mode"}, "billing_wallet_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Billing Wallet Id"}, "preemptible": {"type": "boolean", "title": "Preemptible", "default": false}, "privileged": {"type": "boolean", "title": "Privileged", "default": false}, "priority": {"type": "integer", "minimum": 0.0, "title": "Priority", "default": 0}, "volume_mounts": {"items": {"$ref": "#/components/schemas/VolumeMountSpec"}, "type": "array", "title": "Volume Mounts", "default": []}}, "additionalProperties": false, "type": "object", "required": ["offering_id", "image_id", "resource_class"], "title": "SessionCreate"}, "SessionMountRead": {"properties": {"volume_id": {"type": "string", "title": "Volume Id"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Name"}, "type": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Type"}, "quota_gb": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Quota Gb"}, "mount_path": {"type": "string", "title": "Mount Path"}, "mode": {"type": "string", "title": "Mode"}}, "type": "object", "required": ["volume_id", "mount_path", "mode"], "title": "SessionMountRead", "description": "One mounted volume, joined with its display facts for the detail screens."}, "SessionRead": {"properties": {"id": {"type": "string", "title": "Id"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Name"}, "status": {"type": "string", "title": "Status"}, "status_reason": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Status Reason"}, "occupancy": {"anyOf": [{"type": "number"}, {"type": "null"}], "title": "Occupancy"}, "bound_gpu_uuid": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Bound Gpu Uuid"}, "node_hostname": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Node Hostname"}, "node_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Node Id"}, "cluster_id": {"type": "string", "title": "Cluster Id"}, "group_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Group Id"}, "group_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Group Name"}, "org_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Org Id"}, "org_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Org Name"}, "resource_class": {"type": "string", "title": "Resource Class"}, "mode": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Mode"}, "gpu_mem_mb": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Gpu Mem Mb"}, "gpu_cores": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Gpu Cores"}, "cpu": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Cpu"}, "mem_gb": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Mem Gb"}, "disk_gb": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Disk Gb"}, "gpu_model": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Gpu Model"}, "gpu_alias": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Gpu Alias"}, "image_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Image Name"}, "image_ref": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Image Ref"}, "disk_used_bytes": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Disk Used Bytes"}, "disk_limit_bytes": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Disk Limit Bytes"}, "mounts": {"items": {"$ref": "#/components/schemas/SessionMountRead"}, "type": "array", "title": "Mounts", "default": []}, "owner_user_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Owner User Id"}, "owner_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Owner Name"}, "credit_per_hour_snapshot": {"anyOf": [{"type": "number"}, {"type": "null"}], "title": "Credit Per Hour Snapshot"}, "started_at": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Started At"}, "terminated_at": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Terminated At"}, "usage_summary": {"anyOf": [{"additionalProperties": true, "type": "object"}, {"type": "null"}], "title": "Usage Summary"}, "credit_consumed": {"anyOf": [{"type": "number"}, {"type": "null"}], "title": "Credit Consumed"}, "queued_reason": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Queued Reason"}, "privileged": {"type": "boolean", "title": "Privileged", "default": false}, "created_at": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Created At"}, "status_changed_at": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Status Changed At"}}, "type": "object", "required": ["id", "status", "cluster_id", "resource_class"], "title": "SessionRead"}, "SignupPolicyOut": {"properties": {"mode": {"type": "string", "title": "Mode"}, "allowed_domains": {"items": {"type": "string"}, "type": "array", "title": "Allowed Domains", "default": []}}, "type": "object", "required": ["mode"], "title": "SignupPolicyOut"}, "SignupPolicyUpdate": {"properties": {"mode": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Mode"}, "allowed_domains": {"anyOf": [{"items": {"type": "string"}, "type": "array"}, {"type": "null"}], "title": "Allowed Domains"}}, "type": "object", "title": "SignupPolicyUpdate"}, "SpendDayRead": {"properties": {"date": {"type": "string", "title": "Date"}, "amount": {"type": "number", "title": "Amount"}}, "type": "object", "required": ["date", "amount"], "title": "SpendDayRead", "description": "One day of spend (consume + storage), for the wallet's usage chart."}, "TopupRejectBody": {"properties": {"reason": {"type": "string", "minLength": 1, "title": "Reason"}}, "type": "object", "required": ["reason"], "title": "TopupRejectBody"}, "TopupRequestBody": {"properties": {"amount": {"anyOf": [{"type": "number", "maximum": 1000000000000.0, "exclusiveMinimum": 0.0}, {"type": "string", "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$"}], "title": "Amount"}, "note": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Note"}, "wallet_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Wallet Id"}}, "type": "object", "required": ["amount"], "title": "TopupRequestBody"}, "TopupRequestListResponse": {"properties": {"data": {"items": {"$ref": "#/components/schemas/TopupRequestRead"}, "type": "array", "title": "Data"}}, "type": "object", "required": ["data"], "title": "TopupRequestListResponse"}, "TopupRequestRead": {"properties": {"id": {"type": "string", "title": "Id"}, "wallet_id": {"type": "string", "title": "Wallet Id"}, "amount": {"type": "string", "title": "Amount"}, "status": {"type": "string", "title": "Status"}, "requester_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Requester Id"}, "requester_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Requester Name"}, "note": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Note"}, "wallet_owner_type": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Wallet Owner Type"}, "wallet_owner_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Wallet Owner Name"}, "decided_reason": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Decided Reason"}, "decided_by": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Decided By"}, "created_at": {"type": "string", "title": "Created At"}}, "type": "object", "required": ["id", "wallet_id", "amount", "status", "created_at"], "title": "TopupRequestRead"}, "TransactionRead": {"properties": {"id": {"type": "string", "title": "Id"}, "type": {"type": "string", "title": "Type"}, "amount": {"type": "string", "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", "title": "Amount"}, "balance_after": {"type": "string", "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", "title": "Balance After"}, "ref": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Ref"}, "ref_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Ref Name"}, "created_at": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Created At"}, "entry_count": {"type": "integer", "title": "Entry Count", "default": 1}, "period_start": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Period Start"}, "period_end": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Period End"}, "settled": {"type": "boolean", "title": "Settled", "default": false}, "live": {"type": "boolean", "title": "Live", "default": false}}, "type": "object", "required": ["id", "type", "amount", "balance_after"], "title": "TransactionRead"}, "TransferBody": {"properties": {"to_wallet_id": {"type": "string", "title": "To Wallet Id"}, "amount": {"anyOf": [{"type": "number", "maximum": 1000000000000.0, "exclusiveMinimum": 0.0}, {"type": "string", "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$"}], "title": "Amount"}}, "type": "object", "required": ["to_wallet_id", "amount"], "title": "TransferBody"}, "UserApproveBody": {"properties": {"group_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Group Id"}, "initial_role": {"type": "string", "title": "Initial Role", "default": "member"}}, "type": "object", "title": "UserApproveBody", "description": "Department is optional: an administrator may let someone in and sort out the department\nlater, but doing both at once is the normal path."}, "UserCreate": {"properties": {"email": {"type": "string", "maxLength": 254, "minLength": 3, "title": "Email"}, "name": {"type": "string", "maxLength": 80, "minLength": 1, "title": "Name"}, "group_id": {"type": "string", "title": "Group Id"}, "status": {"type": "string", "title": "Status", "default": "active"}, "initial_role": {"type": "string", "title": "Initial Role", "default": "member"}, "password": {"type": "string", "maxLength": 128, "minLength": 8, "title": "Password"}}, "type": "object", "required": ["email", "name", "group_id", "password"], "title": "UserCreate"}, "UserDepartmentSet": {"properties": {"group_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Group Id"}}, "type": "object", "title": "UserDepartmentSet"}, "UserPatch": {"properties": {"name": {"anyOf": [{"type": "string", "maxLength": 80}, {"type": "null"}], "title": "Name"}, "status": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Status"}, "email": {"anyOf": [{"type": "string", "maxLength": 254, "minLength": 3}, {"type": "null"}], "title": "Email"}, "password": {"anyOf": [{"type": "string", "maxLength": 128, "minLength": 8}, {"type": "null"}], "title": "Password"}, "force_password_reset": {"type": "boolean", "title": "Force Password Reset", "default": false}}, "additionalProperties": false, "type": "object", "title": "UserPatch"}, "ValidationError": {"properties": {"loc": {"items": {"anyOf": [{"type": "string"}, {"type": "integer"}]}, "type": "array", "title": "Location"}, "msg": {"type": "string", "title": "Message"}, "type": {"type": "string", "title": "Error Type"}, "input": {"title": "Input"}, "ctx": {"type": "object", "title": "Context"}}, "type": "object", "required": ["loc", "msg", "type"], "title": "ValidationError"}, "VolumeCreate": {"properties": {"scope": {"type": "string", "pattern": "^(user|group|global)$", "title": "Scope"}, "scope_id": {"type": "string", "title": "Scope Id"}, "type": {"type": "string", "pattern": "^(home|group|dataset|scratch)$", "title": "Type"}, "name": {"type": "string", "maxLength": 80, "minLength": 1, "title": "Name"}, "access_mode": {"type": "string", "pattern": "^(RWO|RWX|ROX)$", "title": "Access Mode"}, "quota_gb": {"type": "integer", "minimum": 0.0, "title": "Quota Gb"}}, "type": "object", "required": ["scope", "scope_id", "type", "name", "access_mode", "quota_gb"], "title": "VolumeCreate"}, "VolumeMountSpec": {"properties": {"volume_id": {"type": "string", "title": "Volume Id"}, "mount_path": {"type": "string", "title": "Mount Path"}, "mode": {"type": "string", "pattern": "^(ro|rw)$", "title": "Mode", "default": "rw"}}, "additionalProperties": false, "type": "object", "required": ["volume_id", "mount_path"], "title": "VolumeMountSpec"}, "VolumePatch": {"properties": {"quota_gb": {"anyOf": [{"type": "integer", "minimum": 1.0}, {"type": "null"}], "title": "Quota Gb"}, "access_mode": {"anyOf": [{"type": "string", "pattern": "^(RWO|RWX|ROX)$"}, {"type": "null"}], "title": "Access Mode"}, "mount_locked": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Mount Locked"}}, "additionalProperties": false, "type": "object", "title": "VolumePatch", "description": "Owner-side edits. The quota only grows (bounded above by the scope's storage policy on the\nserver); `mount_locked` blocks new mounts so the volume can be drained for deletion."}, "VolumeRead": {"properties": {"id": {"type": "string", "title": "Id"}, "scope": {"type": "string", "title": "Scope"}, "scope_id": {"type": "string", "title": "Scope Id"}, "type": {"type": "string", "title": "Type"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Name"}, "access_mode": {"type": "string", "title": "Access Mode"}, "quota_gb": {"type": "integer", "title": "Quota Gb"}, "used_gb": {"type": "integer", "title": "Used Gb"}, "mount_locked": {"type": "boolean", "title": "Mount Locked", "default": false}, "role": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Role"}, "owner_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Owner Id"}, "owner_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Owner Name"}, "shared_count": {"type": "integer", "title": "Shared Count", "default": 0}, "active_mounts": {"anyOf": [{"items": {"additionalProperties": true, "type": "object"}, "type": "array"}, {"type": "null"}], "title": "Active Mounts"}}, "type": "object", "required": ["id", "scope", "scope_id", "type", "access_mode", "quota_gb", "used_gb"], "title": "VolumeRead"}, "VolumeSyncDirective": {"properties": {"name": {"type": "string", "title": "Name"}, "volume_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Volume Id"}, "quota_gb": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Quota Gb"}, "reclaim": {"type": "boolean", "title": "Reclaim", "default": false}}, "type": "object", "required": ["name"], "title": "VolumeSyncDirective", "description": "What the control plane wants for one observed PVC."}, "VolumeSyncResponse": {"properties": {"volumes": {"items": {"$ref": "#/components/schemas/VolumeSyncDirective"}, "type": "array", "title": "Volumes"}, "orphans": {"type": "integer", "title": "Orphans", "default": 0}}, "type": "object", "required": ["volumes"], "title": "VolumeSyncResponse"}, "WalletRead": {"properties": {"id": {"type": "string", "title": "Id"}, "owner_type": {"type": "string", "title": "Owner Type"}, "owner_id": {"type": "string", "title": "Owner Id"}, "balance": {"type": "string", "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", "title": "Balance"}, "reserved": {"type": "string", "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", "title": "Reserved"}, "monthly_grant": {"type": "string", "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", "title": "Monthly Grant", "default": "0"}, "owner_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Owner Name"}, "available": {"type": "string", "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", "title": "Available", "readOnly": true}}, "type": "object", "required": ["id", "owner_type", "owner_id", "balance", "reserved", "available"], "title": "WalletRead"}, "WebhookCreate": {"properties": {"url": {"type": "string", "title": "Url"}, "events": {"items": {"type": "string"}, "type": "array", "title": "Events"}, "secret": {"type": "string", "title": "Secret"}, "scope": {"anyOf": [{"additionalProperties": true, "type": "object"}, {"type": "null"}], "title": "Scope"}, "org_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Org Id"}}, "type": "object", "required": ["url", "events", "secret"], "title": "WebhookCreate"}, "_CordonBody": {"properties": {"cordon": {"type": "boolean", "title": "Cordon", "default": true}, "reason": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Reason"}}, "type": "object", "title": "_CordonBody"}, "_DeviceAliasBody": {"properties": {"alias": {"anyOf": [{"type": "string", "maxLength": 32}, {"type": "null"}], "title": "Alias"}}, "type": "object", "title": "_DeviceAliasBody"}, "_DeviceHealthBody": {"properties": {"status": {"type": "string", "enum": ["ready", "unhealthy"], "title": "Status"}, "reason": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Reason"}}, "type": "object", "required": ["status"], "title": "_DeviceHealthBody"}, "_DrainBody": {"properties": {"mode": {"type": "string", "pattern": "^(reschedule|force_terminate)$", "title": "Mode", "default": "reschedule"}, "reason": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Reason"}}, "type": "object", "title": "_DrainBody"}, "_LoginRequest": {"properties": {"email": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Email"}, "password": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Password"}}, "type": "object", "title": "_LoginRequest"}, "_NodeRegister": {"properties": {"hostname": {"type": "string", "maxLength": 120, "minLength": 1, "title": "Hostname"}, "region": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Region"}, "cpu": {"anyOf": [{"type": "integer", "minimum": 0.0}, {"type": "null"}], "title": "Cpu"}, "mem_gb": {"anyOf": [{"type": "integer", "minimum": 0.0}, {"type": "null"}], "title": "Mem Gb"}, "cluster_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cluster Id"}}, "type": "object", "required": ["hostname"], "title": "_NodeRegister"}, "_RRReject": {"properties": {"reason": {"type": "string", "title": "Reason"}}, "type": "object", "required": ["reason"], "title": "_RRReject"}, "_SignupRequest": {"properties": {"email": {"type": "string", "maxLength": 254, "minLength": 3, "title": "Email"}, "name": {"type": "string", "maxLength": 100, "minLength": 1, "title": "Name"}, "password": {"type": "string", "maxLength": 128, "minLength": 8, "title": "Password"}}, "type": "object", "required": ["email", "name", "password"], "title": "_SignupRequest"}, "app__api__schemas__node_pool__PoolCreate": {"properties": {"cluster_id": {"type": "string", "title": "Cluster Id"}, "name": {"type": "string", "maxLength": 120, "minLength": 1, "title": "Name"}, "description": {"anyOf": [{"type": "string", "maxLength": 1000}, {"type": "null"}], "title": "Description"}, "kind": {"type": "string", "pattern": "^(shared|dedicated)$", "title": "Kind", "default": "dedicated"}}, "type": "object", "required": ["cluster_id", "name"], "title": "PoolCreate"}, "app__api__storage_pools_router__PoolCreate": {"properties": {"name": {"type": "string", "maxLength": 80, "minLength": 1, "title": "Name"}, "cluster_id": {"type": "string", "title": "Cluster Id"}, "storage_class": {"type": "string", "maxLength": 200, "minLength": 1, "title": "Storage Class"}, "node_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Node Id"}, "share_scope": {"type": "string", "pattern": "^(all|selected)$", "title": "Share Scope", "default": "all"}, "shared_with": {"items": {"type": "string"}, "type": "array", "title": "Shared With", "default": []}, "manual_capacity_gb": {"anyOf": [{"type": "integer", "minimum": 0.0}, {"type": "null"}], "title": "Manual Capacity Gb"}}, "type": "object", "required": ["name", "cluster_id", "storage_class"], "title": "PoolCreate"}}}} +{"openapi": "3.1.0", "info": {"title": "gShare API", "version": "v1"}, "paths": {"/api/v1/auth/me": {"get": {"tags": ["users"], "summary": "Auth Me", "description": "Return identity/role/memberships of the current principal.\n\nmemberships is returned as a list of {group_id, project_name, role} for the console's context\nswitcher and RBAC, converted from the internal {project_id: role} mapping with names resolved.", "operationId": "auth_me_api_v1_auth_me_get", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MeResponse"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/auth/login": {"post": {"tags": ["users"], "summary": "Auth Login", "description": "Email+password local login (gated by AUTH_ALLOW_LOCAL_PASSWORD).\n\nLooks the user up by email and issues an HS256 token signed with USER_JWT_SECRET, which\njwt_auth.verify_jwt verifies.", "operationId": "auth_login_api_v1_auth_login_post", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/_LoginRequest"}}}, "required": true}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/auth/signup": {"post": {"tags": ["users"], "summary": "Auth Signup", "description": "Self-service registration, governed by the sign-up policy in system settings.\n\n`closed` refuses outright; `open` creates a usable account; `approval` creates it as\n``pending``, which cannot sign in until an administrator approves it. Either way the account\nstarts with **no department** \u2014 an administrator assigns one at approval, or later. Until then\nthe account resolves to the global resource policy and has no group wallet to draw credits\nfrom, which is why the approval screen assigns a department in the same step.", "operationId": "auth_signup_api_v1_auth_signup_post", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/_SignupRequest"}}}, "required": true}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/users/{user_id}/approve": {"post": {"tags": ["users"], "summary": "Approve User", "description": "Approve a self-registered account: assign the department, activate, grant welcome credit.\n\nOne step rather than three, because a half-approved account (active, no department, no\ncredits) is a state nobody wants to find. super_admin, or an org_admin for a group of theirs.", "operationId": "approve_user_api_v1_users__user_id__approve_post", "parameters": [{"name": "user_id", "in": "path", "required": true, "schema": {"type": "string", "title": "User Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserApproveBody"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/auth/change-password": {"post": {"tags": ["users"], "summary": "Change Password", "description": "Change your own password. Unless must_change_password is set, the current password is\nverified. A fresh token is issued afterwards.", "operationId": "change_password_api_v1_auth_change_password_post", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ChangePasswordRequest"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/users": {"get": {"tags": ["users"], "summary": "List Users", "description": "Paginated user list. super_admin, org_admin, or group_admin, the latter two scoped to the\ngroups they administer.", "operationId": "list_users_api_v1_users_get", "parameters": [{"name": "status", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Status"}}, {"name": "q", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Q"}}, {"name": "org_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Org Id"}}, {"name": "group_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Group Id"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 1, "default": 1, "title": "Page"}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 100, "minimum": 1, "default": 20, "title": "Size"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["users"], "summary": "Create User", "description": "Create a user + enroll them into the chosen group with an initial membership role.\n\nThe new user is created in ``active`` (or ``suspended``) state; ``invited`` is reserved for the\ninvite-mail flow. A group is mandatory \u2014 the user joins it on creation (org is derived from the\ngroup). Email is UNIQUE (-> 409 conflict). super_admin\u00b7org_admin.", "operationId": "create_user_api_v1_users_post", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserCreate"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/users/bulk": {"post": {"tags": ["users"], "summary": "Bulk Create Users", "description": "Roster import: create up to 200 users per request, each with a personal wallet, a\nmembership in the chosen group, and a server-generated initial password returned once in the\nresponse (must_change_password forces rotation at first login).\n\nPartial success by design: rows report ``created`` / ``exists`` / ``invalid`` individually so\nre-uploading a roster is safe \u2014 existing accounts are reported, not fatal. The whole batch is\nidempotent on the Idempotency-Key (a replay returns the stored result, passwords included,\nfor 24h). One audit record per batch, not one per student. super_admin\u00b7org_admin.", "operationId": "bulk_create_users_api_v1_users_bulk_post", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}, {"name": "Idempotency-Key", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Idempotency-Key"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BulkUserCreate"}}}}, "responses": {"207": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/users/resolve": {"get": {"tags": ["users"], "summary": "Resolve User", "description": "Resolve an EXACT email to {id, name} for the volume-share confirm step.\n\nAny authenticated user may call it: sharing needs \"does this address exist, and who is it\",\nand members cannot list users. Only an exact match answers \u2014 no enumeration surface beyond\nwhat grant-by-email already reveals.", "operationId": "resolve_user_api_v1_users_resolve_get", "parameters": [{"name": "email", "in": "query", "required": true, "schema": {"type": "string", "minLength": 3, "maxLength": 254, "title": "Email"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/users/{user_id}": {"get": {"tags": ["users"], "summary": "Get User", "description": "User detail incl. memberships. self or super_admin/org_admin.", "operationId": "get_user_api_v1_users__user_id__get", "parameters": [{"name": "user_id", "in": "path", "required": true, "schema": {"type": "string", "title": "User Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "patch": {"tags": ["users"], "summary": "Update User", "description": "Update a user. What may be changed depends on the caller's role.\n\n- super_admin: email, name, status, and password reset. Group membership has its own endpoint.\n- org_admin: password reset (and group) for users in their organization. Not email or name.\n- group_admin: password reset only, for users in their group.\n- The user themselves: their display name only.", "operationId": "update_user_api_v1_users__user_id__patch", "parameters": [{"name": "user_id", "in": "path", "required": true, "schema": {"type": "string", "title": "User Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserPatch"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["users"], "summary": "Delete User", "description": "Deactivate (soft, default) or hard-delete a user.\n\nDefault = soft delete (``status=suspended`` + ``deleted_at``). ``?hard=true`` is\nsuper_admin-only and refused when a live session exists (ledger preservation -> 409 conflict).", "operationId": "delete_user_api_v1_users__user_id__delete", "parameters": [{"name": "user_id", "in": "path", "required": true, "schema": {"type": "string", "title": "User Id"}}, {"name": "hard", "in": "query", "required": false, "schema": {"type": "boolean", "default": false, "title": "Hard"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/users/{user_id}/usage": {"get": {"tags": ["users"], "summary": "Get User Usage", "description": "Live resource footprint: sessions, host compute, GPU slices, volumes, wallet.\n\nSelf or an administrator (``user.read``). Everything is CURRENT state, not history \u2014 the\nadmin drawer answers \"what is this user holding right now?\".", "operationId": "get_user_usage_api_v1_users__user_id__usage_get", "parameters": [{"name": "user_id", "in": "path", "required": true, "schema": {"type": "string", "title": "User Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/users/{user_id}/department": {"put": {"tags": ["users"], "summary": "Set User Department", "description": "Set, or move, a user's group. super_admin or org_admin.\n\n- super_admin: move to any group, or pass None to remove all group membership.\n- org_admin: move only within their own organization; memberships elsewhere are left alone.\n\nIf the user is not already in the target group they are added as a member, and their previous\ngroup memberships within the caller's scope are removed.", "operationId": "set_user_department_api_v1_users__user_id__department_put", "parameters": [{"name": "user_id", "in": "path", "required": true, "schema": {"type": "string", "title": "User Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserDepartmentSet"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/users/{user_id}/global-role": {"put": {"tags": ["users"], "summary": "Set Global Role", "description": "Grant or revoke global roles; super_admin only. Several roles may be granted at once.", "operationId": "set_global_role_api_v1_users__user_id__global_role_put", "parameters": [{"name": "user_id", "in": "path", "required": true, "schema": {"type": "string", "title": "User Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GlobalRoleSet"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/organizations": {"get": {"tags": ["groups"], "summary": "List Organizations", "description": "List organizations. super_admin sees all; org_admin sees only their own (tenant\nisolation).", "operationId": "list_organizations_api_v1_organizations_get", "parameters": [{"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 1, "default": 1, "title": "Page"}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 100, "minimum": 1, "default": 20, "title": "Size"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["groups"], "summary": "Create Organization", "description": "Create an organization. super_admin only.", "operationId": "create_organization_api_v1_organizations_post", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/OrgCreate"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/organizations/{org_id}": {"get": {"tags": ["groups"], "summary": "Get Organization", "description": "Fetch one organization, for deep links from the edit and admin pages.", "operationId": "get_organization_api_v1_organizations__org_id__get", "parameters": [{"name": "org_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Org Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "patch": {"tags": ["groups"], "summary": "Update Organization", "description": "Update an organization's name or status. super_admin only.", "operationId": "update_organization_api_v1_organizations__org_id__patch", "parameters": [{"name": "org_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Org Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/OrgUpdate"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["groups"], "summary": "Delete Organization", "description": "Soft-delete an organization. super_admin only; 409 while live groups remain, to avoid\norphans.", "operationId": "delete_organization_api_v1_organizations__org_id__delete", "parameters": [{"name": "org_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Org Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/organizations/{org_id}/admins": {"get": {"tags": ["groups"], "summary": "List Org Admins", "description": "List an organization's admins. super_admin, or an org_admin of that organization.", "operationId": "list_org_admins_api_v1_organizations__org_id__admins_get", "parameters": [{"name": "org_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Org Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["groups"], "summary": "Add Org Admin", "description": "Appoint an organization admin. super_admin only.", "operationId": "add_org_admin_api_v1_organizations__org_id__admins_post", "parameters": [{"name": "org_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Org Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/OrgAdminCreate"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/organizations/{org_id}/admins/{user_id}": {"delete": {"tags": ["groups"], "summary": "Remove Org Admin", "description": "Remove an organization admin. super_admin only.", "operationId": "remove_org_admin_api_v1_organizations__org_id__admins__user_id__delete", "parameters": [{"name": "org_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Org Id"}}, {"name": "user_id", "in": "path", "required": true, "schema": {"type": "string", "title": "User Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/projects": {"get": {"tags": ["groups"], "summary": "List Projects", "description": "List groups. super_admin sees all; everyone else sees only the groups they belong to.", "operationId": "list_projects_api_v1_projects_get", "parameters": [{"name": "org_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Org Id"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 1, "default": 1, "title": "Page"}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 100, "minimum": 1, "default": 20, "title": "Size"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["groups"], "summary": "Create Project", "description": "Create a group along with its default wallet. super_admin or org_admin.", "operationId": "create_project_api_v1_projects_post", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ProjectCreate"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/projects/{group_id}": {"get": {"tags": ["groups"], "summary": "Get Project", "description": "Fetch one group, for deep links from the edit, admin, and delete pages.", "operationId": "get_project_api_v1_projects__group_id__get", "parameters": [{"name": "group_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Group Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "patch": {"tags": ["groups"], "summary": "Update Project", "description": "Update a group's name or archived status. super_admin or group_admin.", "operationId": "update_project_api_v1_projects__group_id__patch", "parameters": [{"name": "group_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Group Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ProjectPatch"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["groups"], "summary": "Delete Project", "description": "Soft-delete a group. super_admin or org_admin; 409 while live sessions remain.\n\nMemberships are removed with it, so a soft-deleted group leaves no permissions behind. The\ngroup wallet is preserved.", "operationId": "delete_project_api_v1_projects__group_id__delete", "parameters": [{"name": "group_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Group Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/projects/{group_id}/memberships": {"get": {"tags": ["groups"], "summary": "List Memberships", "description": "List a group's memberships. super_admin or group_admin.", "operationId": "list_memberships_api_v1_projects__group_id__memberships_get", "parameters": [{"name": "group_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Group Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["groups"], "summary": "Add Membership", "description": "Add a member or grant a role. A guest must carry expires_at. super_admin or group_admin.", "operationId": "add_membership_api_v1_projects__group_id__memberships_post", "parameters": [{"name": "group_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Group Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MembershipCreate"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/projects/{group_id}/memberships/{membership_id}": {"patch": {"tags": ["groups"], "summary": "Update Membership", "description": "Change a member's role. Demoting the last admin returns 409. super_admin or group_admin.", "operationId": "update_membership_api_v1_projects__group_id__memberships__membership_id__patch", "parameters": [{"name": "group_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Group Id"}}, {"name": "membership_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Membership Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MembershipPatch"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["groups"], "summary": "Remove Membership", "description": "Remove a member. Removing the last admin returns 409. super_admin or group_admin.", "operationId": "remove_membership_api_v1_projects__group_id__memberships__membership_id__delete", "parameters": [{"name": "group_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Group Id"}}, {"name": "membership_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Membership Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/credits/wallets": {"get": {"tags": ["credits"], "summary": "List Wallets", "operationId": "list_wallets_api_v1_credits_wallets_get", "parameters": [{"name": "owner_type", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Owner Type"}}, {"name": "owner_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Owner Id"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 1, "default": 1, "title": "Page"}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 100, "minimum": 1, "default": 20, "title": "Size"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/WalletRead"}, "title": "Response List Wallets Api V1 Credits Wallets Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/credits/wallets/me": {"get": {"tags": ["credits"], "summary": "My Wallet", "operationId": "my_wallet_api_v1_credits_wallets_me_get", "parameters": [{"name": "group_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Group Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/WalletRead"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/credits/wallets/{wallet_id}": {"get": {"tags": ["credits"], "summary": "Get Wallet", "operationId": "get_wallet_api_v1_credits_wallets__wallet_id__get", "parameters": [{"name": "wallet_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Wallet Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/WalletRead"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/credits/wallets/{wallet_id}/topup": {"post": {"tags": ["credits"], "summary": "Topup", "operationId": "topup_api_v1_credits_wallets__wallet_id__topup_post", "parameters": [{"name": "wallet_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Wallet Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}, {"name": "Idempotency-Key", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Idempotency-Key"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/TopupRequestBody"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/credits/wallets/{wallet_id}/adjust": {"post": {"tags": ["credits"], "summary": "Adjust", "operationId": "adjust_api_v1_credits_wallets__wallet_id__adjust_post", "parameters": [{"name": "wallet_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Wallet Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}, {"name": "Idempotency-Key", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Idempotency-Key"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdjustBody"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/credits/wallets/{wallet_id}/transfer": {"post": {"tags": ["credits"], "summary": "Transfer", "operationId": "transfer_api_v1_credits_wallets__wallet_id__transfer_post", "parameters": [{"name": "wallet_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Wallet Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}, {"name": "Idempotency-Key", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Idempotency-Key"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/TransferBody"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/credits/allocation-scope": {"get": {"tags": ["credits"], "summary": "Allocation Scope", "description": "Return the pools the caller can allocate from, and each pool's targets, with names.\n\nAn org_admin gets their organization's wallet, whose children are that organization's groups; a\ngroup_admin gets their group's wallet, whose children are its members; a super_admin gets\neverything. The UI uses this to pick a pool, populate its targets, and toggle direction between\nallocate and reclaim.", "operationId": "allocation_scope_api_v1_credits_allocation_scope_get", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/credits/refill-schedule": {"get": {"tags": ["credits"], "summary": "Get Refill Schedule", "description": "When the monthly refill fires: day-of-month and hour (KST). Defaults: day 1, 00:00.", "operationId": "get_refill_schedule_api_v1_credits_refill_schedule_get", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "put": {"tags": ["credits"], "summary": "Set Refill Schedule", "description": "Set the refill day/hour. super_admin \u2014 the same authority that sets the monthly total.", "operationId": "set_refill_schedule_api_v1_credits_refill_schedule_put", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/RefillScheduleBody"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/credits/allocate": {"post": {"tags": ["credits"], "summary": "Allocate", "description": "Allocate credits down the hierarchy (organization -> project -> user), or reclaim upwards.\nAn idempotency key is required.\n\n\"Within your allowance\" is enforced automatically by the source wallet's available balance, and\nexceeding it returns 402. Authorization is handled by _assert_can_allocate.", "operationId": "allocate_api_v1_credits_allocate_post", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}, {"name": "Idempotency-Key", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Idempotency-Key"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AllocateBody"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/credits/wallets/{wallet_id}/monthly-grant": {"post": {"tags": ["credits"], "summary": "Set Monthly Grant", "description": "Set a child wallet's monthly automatic refill, as the administrator one level up.\n\nThe siblings' grants must sum within the parent's grant; 0 disables refills for that wallet. The\nrefill itself \u2014 topping the balance up to the grant at the start of each month, never taking\na surplus away \u2014 is performed by the credit_refill worker.", "operationId": "set_monthly_grant_api_v1_credits_wallets__wallet_id__monthly_grant_post", "parameters": [{"name": "wallet_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Wallet Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MonthlyGrantBody"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/WalletRead"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/credits/bulk-allocate": {"post": {"tags": ["credits"], "summary": "Bulk Allocate", "description": "Allocate ``amount`` from the group's wallet to EVERY member's personal wallet at once.\n\nThe source balance is checked up front against n\u00d7amount (402 on shortfall \u2014 all or nothing,\nno partially funded cohort), and every per-wallet transaction carries an idempotency key\nderived from the batch key, so a replay after a crash completes exactly once per member.\ngroup_admin and above.", "operationId": "bulk_allocate_api_v1_credits_bulk_allocate_post", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}, {"name": "Idempotency-Key", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Idempotency-Key"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BulkAllocateBody"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/credits/bulk-monthly-grant": {"post": {"tags": ["credits"], "summary": "Bulk Monthly Grant", "description": "Set the same monthly refill on every member wallet of the group.\n\nThe ceiling check runs once for the whole cohort: n\u00d7amount must stay within the group\nwallet's own monthly grant (the same sibling-sum invariant set_monthly_grant enforces\nper wallet, without the O(n\u00b2) of calling it n times). Increases credit immediately, like\nthe single-wallet endpoint. group_admin and above.", "operationId": "bulk_monthly_grant_api_v1_credits_bulk_monthly_grant_post", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BulkMonthlyGrantBody"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/credits/allocation-requests": {"post": {"tags": ["credits"], "summary": "Create Allocation Request", "description": "Create a credit allocation request, routed one level up: a user's goes to the group admin, a\ngroup's to the organization admin, an organization's to a super_admin.", "operationId": "create_allocation_request_api_v1_credits_allocation_requests_post", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AllocationRequestCreate"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "get": {"tags": ["credits"], "summary": "List Allocation Requests", "description": "box=mine lists the requests you raised; box=incoming lists pending requests you can\napprove; box=handled lists the decided ones from the same inbox \u2014 the approver's history.", "operationId": "list_allocation_requests_api_v1_credits_allocation_requests_get", "parameters": [{"name": "box", "in": "query", "required": false, "schema": {"type": "string", "pattern": "^(incoming|mine|handled)$", "default": "incoming", "title": "Box"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 1, "default": 1, "title": "Page"}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 100, "minimum": 1, "default": 20, "title": "Size"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/credits/allocation-requests/{request_id}/approve": {"post": {"tags": ["credits"], "summary": "Approve Allocation Request", "description": "Approve a request: allocate from the parent wallet to the target, or top the target up when\nthe parent is the system wallet. An insufficient pool returns 409, prompting escalation.", "operationId": "approve_allocation_request_api_v1_credits_allocation_requests__request_id__approve_post", "parameters": [{"name": "request_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Request Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}, {"name": "Idempotency-Key", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Idempotency-Key"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/credits/allocation-requests/{request_id}/reject": {"post": {"tags": ["credits"], "summary": "Reject Allocation Request", "operationId": "reject_allocation_request_api_v1_credits_allocation_requests__request_id__reject_post", "parameters": [{"name": "request_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Request Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AllocationRejectBody"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/credits/wallets/{wallet_id}/spend-daily": {"get": {"tags": ["credits"], "summary": "Spend Daily", "description": "Daily spend (consume + storage) over [from, to], for the wallet's usage chart.\n\nAggregation happens here rather than over the paged ledger: a chart drawn from page one of the\ntransactions would silently truncate. Buckets follow the caller's clock via tz_offset_min\n(KST = +540), since \"a day\" on the chart is the user's day, not UTC's. Aggregated in Python so\nthe same query runs on Postgres and the SQLite test harness; the range is capped to a year.", "operationId": "spend_daily_api_v1_credits_wallets__wallet_id__spend_daily_get", "parameters": [{"name": "wallet_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Wallet Id"}}, {"name": "from", "in": "query", "required": true, "schema": {"type": "string", "format": "date", "title": "From"}}, {"name": "to", "in": "query", "required": true, "schema": {"type": "string", "format": "date", "title": "To"}}, {"name": "tz_offset_min", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 840, "minimum": -840, "default": 0, "title": "Tz Offset Min"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/SpendDayRead"}, "title": "Response Spend Daily Api V1 Credits Wallets Wallet Id Spend Daily Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/credits/wallets/{wallet_id}/transactions": {"get": {"tags": ["credits"], "summary": "Transactions", "description": "The wallet ledger.\n\ngroup=\"session\" rolls the per-minute consume rows of one session into a single line (period,\ntotal, entry count); every other transaction type is a discrete event and passes through\nuntouched. Grouping happens in SQL because a long session produces hundreds of rows: folding\nthem client-side would only fold whatever landed on the current page.", "operationId": "transactions_api_v1_credits_wallets__wallet_id__transactions_get", "parameters": [{"name": "wallet_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Wallet Id"}}, {"name": "group", "in": "query", "required": false, "schema": {"type": "string", "pattern": "^(none|session)$", "default": "none", "title": "Group"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 1, "default": 1, "title": "Page"}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 100, "minimum": 1, "default": 20, "title": "Size"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/TransactionRead"}, "title": "Response Transactions Api V1 Credits Wallets Wallet Id Transactions Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/credits/wallets/{wallet_id}/holds": {"get": {"tags": ["credits"], "summary": "Holds", "description": "Active reservations + per-session settle status.\n\nReconstructed from the ledger: hold/consume/settle/refund txns are grouped by ``ref``\n(session id). A session with a hold but no settle is ``active``; otherwise ``settled``.", "operationId": "holds_api_v1_credits_wallets__wallet_id__holds_get", "parameters": [{"name": "wallet_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Wallet Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/credits/topup-requests": {"post": {"tags": ["credits"], "summary": "Create Topup Request", "operationId": "create_topup_request_api_v1_credits_topup_requests_post", "parameters": [{"name": "wallet_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Wallet Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/TopupRequestBody"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "get": {"tags": ["credits"], "summary": "List Topup Requests", "operationId": "list_topup_requests_api_v1_credits_topup_requests_get", "parameters": [{"name": "status", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Status"}}, {"name": "wallet_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Wallet Id"}}, {"name": "scope", "in": "query", "required": false, "schema": {"type": "string", "pattern": "^(mine|all)$", "default": "mine", "title": "Scope"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 1, "default": 1, "title": "Page"}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 100, "minimum": 1, "default": 20, "title": "Size"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/TopupRequestListResponse"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/credits/topup-requests/{request_id}/approve": {"post": {"tags": ["credits"], "summary": "Approve Topup Request", "operationId": "approve_topup_request_api_v1_credits_topup_requests__request_id__approve_post", "parameters": [{"name": "request_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Request Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}, {"name": "Idempotency-Key", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Idempotency-Key"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/credits/topup-requests/{request_id}/reject": {"post": {"tags": ["credits"], "summary": "Reject Topup Request", "operationId": "reject_topup_request_api_v1_credits_topup_requests__request_id__reject_post", "parameters": [{"name": "request_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Request Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/TopupRejectBody"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/budgets": {"get": {"tags": ["budgets"], "summary": "List Budgets", "operationId": "list_budgets_api_v1_budgets_get", "parameters": [{"name": "scope", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Scope"}}, {"name": "scope_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Scope Id"}}, {"name": "action", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Action"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 1, "default": 1, "title": "Page"}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 100, "minimum": 1, "default": 20, "title": "Size"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/BudgetRead"}, "title": "Response List Budgets Api V1 Budgets Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["budgets"], "summary": "Create Budget", "operationId": "create_budget_api_v1_budgets_post", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BudgetCreate"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BudgetRead"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/budgets/{budget_id}": {"get": {"tags": ["budgets"], "summary": "Get Budget", "operationId": "get_budget_api_v1_budgets__budget_id__get", "parameters": [{"name": "budget_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Budget Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BudgetRead"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "patch": {"tags": ["budgets"], "summary": "Update Budget", "operationId": "update_budget_api_v1_budgets__budget_id__patch", "parameters": [{"name": "budget_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Budget Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BudgetUpdate"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BudgetRead"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["budgets"], "summary": "Delete Budget", "operationId": "delete_budget_api_v1_budgets__budget_id__delete", "parameters": [{"name": "budget_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Budget Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/budgets/{budget_id}/forecast": {"get": {"tags": ["budgets"], "summary": "Forecast", "description": "Project depletion from recent burn rate.\n\nburn_rate_per_day = consume over the lookback window / lookback days.\nprojected_depletion_at = now + remaining_credit / burn_rate_per_day.", "operationId": "forecast_api_v1_budgets__budget_id__forecast_get", "parameters": [{"name": "budget_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Budget Id"}}, {"name": "lookback", "in": "query", "required": false, "schema": {"type": "string", "pattern": "^(7d|14d|30d)$", "default": "7d", "title": "Lookback"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BudgetForecast"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/offerings": {"get": {"tags": ["offerings"], "summary": "List Offerings", "description": "Catalog list. Authenticated-but-open read; no admin gate.\n\nRetired (status=inactive) offerings are hidden from non-admins; administrators still see\nthem so they can reactivate or audit pricing.", "operationId": "list_offerings_api_v1_offerings_get", "parameters": [{"name": "gpu_model", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Gpu Model"}}, {"name": "q", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Q"}}, {"name": "sort", "in": "query", "required": false, "schema": {"type": "string", "default": "credit_per_hour", "title": "Sort"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 1, "default": 1, "title": "Page"}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 100, "minimum": 1, "default": 20, "title": "Size"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["offerings"], "summary": "Create Offering", "operationId": "create_offering_api_v1_offerings_post", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/OfferingCreate"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/offerings/{offering_id}": {"get": {"tags": ["offerings"], "summary": "Get Offering", "description": "Fetch one offering, for deep links from the edit page. Readable by any authenticated\ncaller.", "operationId": "get_offering_api_v1_offerings__offering_id__get", "parameters": [{"name": "offering_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Offering Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "patch": {"tags": ["offerings"], "summary": "Update Offering", "description": "Update offering; appends to price history on credit_per_hour change.", "operationId": "update_offering_api_v1_offerings__offering_id__patch", "parameters": [{"name": "offering_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Offering Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/OfferingUpdate"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["offerings"], "summary": "Delete Offering", "description": "Delete an offering; super_admin only. Rejected while a session still references it \u2014\ndeactivate it instead.", "operationId": "delete_offering_api_v1_offerings__offering_id__delete", "parameters": [{"name": "offering_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Offering Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/offerings/{offering_id}/price-history": {"get": {"tags": ["offerings"], "summary": "Price History", "operationId": "price_history_api_v1_offerings__offering_id__price_history_get", "parameters": [{"name": "offering_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Offering Id"}}, {"name": "from", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "From"}}, {"name": "to", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "To"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 1, "default": 1, "title": "Page"}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 100, "minimum": 1, "default": 20, "title": "Size"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/resource-presets": {"get": {"tags": ["presets"], "summary": "List Presets", "operationId": "list_presets_api_v1_resource_presets_get", "parameters": [{"name": "q", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Q"}}, {"name": "kind", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Kind"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 1, "default": 1, "title": "Page"}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 100, "minimum": 1, "default": 20, "title": "Size"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["presets"], "summary": "Create Preset", "operationId": "create_preset_api_v1_resource_presets_post", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PresetCreate"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/resource-presets/{preset_id}": {"get": {"tags": ["presets"], "summary": "Get Preset", "description": "Fetch one preset, for deep links from the edit page.", "operationId": "get_preset_api_v1_resource_presets__preset_id__get", "parameters": [{"name": "preset_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Preset Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "patch": {"tags": ["presets"], "summary": "Update Preset", "description": "Update a preset; super_admin only. kind is immutable, and only the fields belonging to that\nkind are applied.", "operationId": "update_preset_api_v1_resource_presets__preset_id__patch", "parameters": [{"name": "preset_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Preset Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PresetUpdate"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["presets"], "summary": "Delete Preset", "description": "Delete a preset. Same authority as preset.create: super_admin.", "operationId": "delete_preset_api_v1_resource_presets__preset_id__delete", "parameters": [{"name": "preset_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Preset Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/resource-policies/effective": {"get": {"tags": ["policies"], "summary": "Effective Policy", "description": "The effective policy for the caller, with current usage and headroom, so the session wizard\ncan show the limits.\n\nResolution is the per-field merge in app.domain.policy (user \u2192 group \u2192 org \u2192 global), the\nsame module the admission gate uses. Caps are per user, so usage sums over the CALLER's\nactive sessions regardless of group.", "operationId": "effective_policy_api_v1_resource_policies_effective_get", "parameters": [{"name": "group_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Group Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/resource-policies": {"get": {"tags": ["policies"], "summary": "List Policies", "operationId": "list_policies_api_v1_resource_policies_get", "parameters": [{"name": "scope", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Scope"}}, {"name": "scope_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Scope Id"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 1, "default": 1, "title": "Page"}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 100, "minimum": 1, "default": 20, "title": "Size"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["policies"], "summary": "Create Policy", "operationId": "create_policy_api_v1_resource_policies_post", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PolicyCreate"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/resource-policies/requests": {"post": {"tags": ["policies"], "summary": "Create Resource Request", "description": "Ask for a bigger compute quota. At least one target, all positive; note required.", "operationId": "create_resource_request_api_v1_resource_policies_requests_post", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ResourceRequestCreate"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "get": {"tags": ["policies"], "summary": "List Resource Requests", "operationId": "list_resource_requests_api_v1_resource_policies_requests_get", "parameters": [{"name": "box", "in": "query", "required": false, "schema": {"type": "string", "pattern": "^(mine|incoming)$", "default": "mine", "title": "Box"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/resource-policies/requests/{request_id}/approve": {"post": {"tags": ["policies"], "summary": "Approve Resource Request", "description": "Approve: upsert the requester's USER-scope policy with ONLY the granted limit keys.\n\nEverything not granted stays inherited (per-field most-specific merge), so the group/global\ndefaults remain the single source for the rest of the policy.", "operationId": "approve_resource_request_api_v1_resource_policies_requests__request_id__approve_post", "parameters": [{"name": "request_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Request Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/resource-policies/requests/{request_id}/reject": {"post": {"tags": ["policies"], "summary": "Reject Resource Request", "operationId": "reject_resource_request_api_v1_resource_policies_requests__request_id__reject_post", "parameters": [{"name": "request_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Request Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/_RRReject"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/resource-policies/{policy_id}": {"get": {"tags": ["policies"], "summary": "Get Policy", "description": "Fetch one policy, for deep links from the edit page.", "operationId": "get_policy_api_v1_resource_policies__policy_id__get", "parameters": [{"name": "policy_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Policy Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "patch": {"tags": ["policies"], "summary": "Update Policy", "operationId": "update_policy_api_v1_resource_policies__policy_id__patch", "parameters": [{"name": "policy_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Policy Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PolicyUpdate"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["policies"], "summary": "Delete Policy", "description": "Delete a resource policy. The global policy is super_admin only; the rest follow\npolicy.delete, which admits super_admin, org_admin, and group_admin.", "operationId": "delete_policy_api_v1_resource_policies__policy_id__delete", "parameters": [{"name": "policy_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Policy Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/sessions/preview-cost": {"post": {"tags": ["sessions"], "summary": "Preview Cost", "description": "Estimate cost without creating a session (pricing.estimate).\n\nestimated_credit_per_hour = offering.credit_per_hour * occupancy;\noccupancy = max(gpu_mem_mb/device_total_mem_mb, gpu_cores/100);\nhold_amount = estimated_credit_per_hour \u2014 admission reserves one hour up front.\nCPU (free) sessions are always 0.", "operationId": "preview_cost_api_v1_sessions_preview_cost_post", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PreviewCostRequest"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PreviewCostResponse"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/sessions": {"post": {"tags": ["sessions"], "summary": "Create Session", "description": "Admit a session through the scheduler gate. Idempotency-Key required.", "operationId": "create_session_api_v1_sessions_post", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}, {"name": "Idempotency-Key", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Idempotency-Key"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SessionCreate"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SessionRead"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "get": {"tags": ["sessions"], "summary": "List Sessions", "description": "List sessions, newest first, paginated.\n\nscope=\"mine\" (the default, backing the user's Sessions page) returns **only the caller's own\nsessions**, whatever their role. Administrators do not see other people's sessions on their\npersonal screen; that is what scope=all is for. scope=\"all\" (the administrators' session\nmonitoring screen) returns everything within the caller's authority: super_admin and org_admin\nsee all sessions, group_admin sees their own plus those of the groups they administer. A plain\nuser asking for scope=all is silently downgraded to their own sessions, so it cannot be used to\nescalate.", "operationId": "list_sessions_api_v1_sessions_get", "parameters": [{"name": "status", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Status"}}, {"name": "group_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Group Id"}}, {"name": "cluster_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cluster Id"}}, {"name": "scope", "in": "query", "required": false, "schema": {"type": "string", "pattern": "^(mine|all)$", "default": "mine", "title": "Scope"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 1, "default": 1, "title": "Page"}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 100, "minimum": 1, "default": 20, "title": "Size"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/sessions/events": {"get": {"tags": ["sessions"], "summary": "Sessions Monitor Events", "description": "Admin session/queue monitor SSE stream.\n\nNOTE: this literal route MUST be declared before ``/sessions/{session_id}`` \u2014 otherwise the\nparameterized route captures ``/sessions/events`` (session_id=\"events\") and returns 404.", "operationId": "sessions_monitor_events_api_v1_sessions_events_get", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/sessions/gpu-availability": {"get": {"tags": ["sessions"], "summary": "Gpu Availability", "description": "Free GPU capacity per model, used by the session wizard to disable tiers. member and above.\n\nGroups the currently ready devices by model and returns each device's free memory, free cores,\nand mode. The console uses this to disable tiers that would not fit on a single card of the\nselected model, since the scheduler requires a fit on one device.\n\nThis is a literal path, so it must be declared before ``/sessions/{session_id}`` \u2014 the same\nreason the events route is.", "operationId": "gpu_availability_api_v1_sessions_gpu_availability_get", "parameters": [{"name": "fleet", "in": "query", "required": false, "schema": {"type": "boolean", "default": false, "title": "Fleet"}}, {"name": "cluster_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cluster Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/sessions/{session_id}": {"get": {"tags": ["sessions"], "summary": "Get Session", "description": "Fetch a single session (owner\u00b7admin).", "operationId": "get_session_api_v1_sessions__session_id__get", "parameters": [{"name": "session_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Session Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SessionRead"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["sessions"], "summary": "Terminate Session", "description": "Terminate + settle.", "operationId": "terminate_session_api_v1_sessions__session_id__delete", "parameters": [{"name": "session_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Session Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"202": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/sessions/{session_id}/start": {"post": {"tags": ["sessions"], "summary": "Start Session", "description": "Resume billing state machine.", "operationId": "start_session_api_v1_sessions__session_id__start_post", "parameters": [{"name": "session_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Session Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SessionRead"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/sessions/{session_id}/stop": {"post": {"tags": ["sessions"], "summary": "Stop Session", "description": "Stop (pause billing) + finalize remaining consume.", "operationId": "stop_session_api_v1_sessions__session_id__stop_post", "parameters": [{"name": "session_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Session Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SessionRead"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/sessions/{session_id}/restart": {"post": {"tags": ["sessions"], "summary": "Restart Session", "description": "Restart a session.", "operationId": "restart_session_api_v1_sessions__session_id__restart_post", "parameters": [{"name": "session_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Session Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SessionRead"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/sessions/{session_id}/force-terminate": {"post": {"tags": ["sessions"], "summary": "Force Terminate", "description": "Admin force-terminate without owner consent. The free-text reason lands in the audit log;\nthe session's status_reason stays the typed `admin_stopped` the console maps to a message.", "operationId": "force_terminate_api_v1_sessions__session_id__force_terminate_post", "parameters": [{"name": "session_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Session Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"content": {"application/json": {"schema": {"anyOf": [{"$ref": "#/components/schemas/ForceTerminateBody"}, {"type": "null"}], "title": "Body"}}}}, "responses": {"202": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/sessions/bulk-terminate": {"post": {"tags": ["sessions"], "summary": "Bulk Terminate", "description": "Force-terminate multiple sessions; per-target result array, idempotent.", "operationId": "bulk_terminate_api_v1_sessions_bulk_terminate_post", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BulkTerminateRequest"}}}}, "responses": {"202": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/sessions/{session_id}/logs": {"get": {"tags": ["sessions"], "summary": "Session Logs", "description": "NOT IMPLEMENTED: nothing streams pod logs into this plane (the operator has no Redis\nproducer), so this always returned an empty list dressed up as a log. Honest 501 until a log\npipeline exists \u2014 use `kubectl logs` on the session pod meanwhile.", "operationId": "session_logs_api_v1_sessions__session_id__logs_get", "parameters": [{"name": "session_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Session Id"}}, {"name": "tail", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 5000, "minimum": 1, "default": 200, "title": "Tail"}}, {"name": "stream", "in": "query", "required": false, "schema": {"type": "string", "pattern": "^(stdout|stderr|all)$", "default": "all", "title": "Stream"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/sessions/{session_id}/connections": {"get": {"tags": ["sessions"], "summary": "Session Connections", "description": "Issue one-time cnx_ connection tokens; `kind` narrows to one app (one token minted).", "operationId": "session_connections_api_v1_sessions__session_id__connections_get", "parameters": [{"name": "session_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Session Id"}}, {"name": "kind", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string", "pattern": "^(vscode|jupyter|terminal)$"}, {"type": "null"}], "title": "Kind"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/ConnectionInfo"}, "title": "Response Session Connections Api V1 Sessions Session Id Connections Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/sessions/{session_id}/usage": {"get": {"tags": ["sessions"], "summary": "Session Usage Self", "description": "Measured live usage of the session's pod (owner\u00b7admin) \u2014 the detail page's meters.", "operationId": "session_usage_self_api_v1_sessions__session_id__usage_get", "parameters": [{"name": "session_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Session Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/sessions/{session_id}/usage/live": {"get": {"tags": ["sessions"], "summary": "Session Usage Live", "description": "The last few minutes of one-second samples from the node agent (owner\u00b7admin).\n\nThis is the live view. It is deliberately separate from the Prometheus-backed series: that one\nis the record and covers GPU and history, this one answers \"is it running yet\" without waiting\nfor a scrape. `live` says whether an agent is actually reporting, so the console can show the\nslower series instead of a stale line when the DaemonSet is not deployed.", "operationId": "session_usage_live_api_v1_sessions__session_id__usage_live_get", "parameters": [{"name": "session_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Session Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/sessions/{session_id}/usage/timeseries": {"get": {"tags": ["sessions"], "summary": "Session Usage Series Self", "description": "The usage metrics over a range (owner\u00b7admin) \u2014 the detail page's sparklines.", "operationId": "session_usage_series_self_api_v1_sessions__session_id__usage_timeseries_get", "parameters": [{"name": "session_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Session Id"}}, {"name": "range", "in": "query", "required": false, "schema": {"type": "string", "default": "15m", "title": "Range"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/sessions/{session_id}/timeline": {"get": {"tags": ["sessions"], "summary": "Session Timeline", "description": "Lifecycle timeline for the detail screen: created \u2192 queued/preparing \u2192 running \u2192 \u2026 with\nreasons, so an error state is readable from the log instead of a separate message box.", "operationId": "session_timeline_api_v1_sessions__session_id__timeline_get", "parameters": [{"name": "session_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Session Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/sessions/{session_id}/checkpoints": {"post": {"tags": ["sessions"], "summary": "Create Checkpoint", "description": "NOT IMPLEMENTED: no worker or operator path takes a checkpoint yet \u2014 a row here would be\nan empty promise (storage_ref stays NULL forever). Honest 501 until the pipeline exists.", "operationId": "create_checkpoint_api_v1_sessions__session_id__checkpoints_post", "parameters": [{"name": "session_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Session Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"202": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "get": {"tags": ["sessions"], "summary": "List Checkpoints", "description": "List checkpoints for a session, newest first.", "operationId": "list_checkpoints_api_v1_sessions__session_id__checkpoints_get", "parameters": [{"name": "session_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Session Id"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 1, "default": 1, "title": "Page"}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 100, "minimum": 1, "default": 20, "title": "Size"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/sessions/{session_id}/checkpoints/{checkpoint_id}/restore": {"post": {"tags": ["sessions"], "summary": "Restore Checkpoint", "description": "NOT IMPLEMENTED: restore has no execution path (the operator's checkpointer states restore\nis intentionally unimplemented) \u2014 returning \"restoring\" here was a fabrication. Honest 501.", "operationId": "restore_checkpoint_api_v1_sessions__session_id__checkpoints__checkpoint_id__restore_post", "parameters": [{"name": "session_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Session Id"}}, {"name": "checkpoint_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Checkpoint Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"202": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/sessions/{session_id}/events": {"get": {"tags": ["sessions"], "summary": "Session Events", "description": "Per-session SSE event stream.\n\nSubscribes to the session's status-change channel (Redis pub/sub fed by status_sync) and pushes\nstatus_changed / phase / allocation / heartbeat events. 404/403 are raised before the stream is\nestablished; the stream terminates cleanly on client disconnect.\n\nThe access check opens its own short-lived DB session: a Depends(get_db) session would stay\nchecked out of the pool for the whole stream (see get_sse_principal).", "operationId": "session_events_api_v1_sessions__session_id__events_get", "parameters": [{"name": "session_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Session Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/queue": {"get": {"tags": ["queue"], "summary": "List Queue", "operationId": "list_queue_api_v1_queue_get", "parameters": [{"name": "group_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Group Id"}}, {"name": "cluster_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cluster Id"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 1, "default": 1, "title": "Page"}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 100, "minimum": 1, "default": 20, "title": "Size"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/QueueList"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/queue/mine": {"get": {"tags": ["queue"], "summary": "My Queue", "operationId": "my_queue_api_v1_queue_mine_get", "parameters": [{"name": "cluster_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cluster Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/QueueMineList"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/queue/{queue_entry_id}": {"delete": {"tags": ["queue"], "summary": "Cancel Queue Entry", "description": "Cancel a queued entry; the session goes terminated and its hold is released.", "operationId": "cancel_queue_entry_api_v1_queue__queue_entry_id__delete", "parameters": [{"name": "queue_entry_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Queue Entry Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "patch": {"tags": ["queue"], "summary": "Update Priority", "description": "Admin reorders the queue: QueueEntry.priority sets the priority band.", "operationId": "update_priority_api_v1_queue__queue_entry_id__patch", "parameters": [{"name": "queue_entry_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Queue Entry Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/QueuePriorityPatch"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/storage/volumes": {"get": {"tags": ["volumes"], "summary": "List Volumes", "description": "List volumes with scope/type/access_mode filters + pagination.\n\nDefault view is the caller's own world for every role \u2014 the user console must show a\nsuper_admin their volumes, not everyone's. `?all=true` (super_admin only) lists the fleet\nfor the admin volume page.", "operationId": "list_volumes_api_v1_storage_volumes_get", "parameters": [{"name": "scope", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Scope"}}, {"name": "scope_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Scope Id"}}, {"name": "type", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Type"}}, {"name": "access_mode", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Access Mode"}}, {"name": "all", "in": "query", "required": false, "schema": {"type": "boolean", "default": false, "title": "All"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 1, "default": 1, "title": "Page"}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 100, "minimum": 1, "default": 20, "title": "Size"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/VolumeRead"}, "title": "Response List Volumes Api V1 Storage Volumes Get"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["volumes"], "summary": "Create Volume", "description": "Create a named volume of any type.\n\nEvery type \u2014 personal workspace, group share, dataset, scratch \u2014 can be created as often as\nneeded and attached to sessions. There is no auto-provisioned singleton. Within a scope, the\n(type, name) pair is unique.", "operationId": "create_volume_api_v1_storage_volumes_post", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/VolumeCreate"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/VolumeRead"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/storage/volumes/quota-usage": {"get": {"tags": ["volumes"], "summary": "Storage Quota Usage", "description": "Per-scope storage limit, usage, and headroom, for the limit warning on the new-volume form.\n\nReads the same (scope, scope_id) policy and volume quota sum that _assert_storage_quota uses at\ncreation time. has_limit=false means unlimited: no policy, or a limit of 0.\n\nThe scope must be one the caller could create a volume in or belongs to: their own user scope,\na group they are a member of, or the global scope. Another tenant's limit and provisioned total\nare not theirs to read.", "operationId": "storage_quota_usage_api_v1_storage_volumes_quota_usage_get", "parameters": [{"name": "scope", "in": "query", "required": true, "schema": {"type": "string", "title": "Scope"}}, {"name": "scope_id", "in": "query", "required": true, "schema": {"type": "string", "title": "Scope Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/storage/volumes/{volume_id}": {"get": {"tags": ["volumes"], "summary": "Get Volume", "description": "Volume detail.", "operationId": "get_volume_api_v1_storage_volumes__volume_id__get", "parameters": [{"name": "volume_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Volume Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/VolumeRead"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "patch": {"tags": ["volumes"], "summary": "Update Volume", "description": "Update volume meta: quota_gb / access_mode / mount_locked.\n\nThe quota only grows. Kubernetes never shrinks a claim, so a smaller ledger figure would free\npolicy headroom against space the pool still physically holds (100 GB \"shrunk\" to 10 GB plus a\nnew 90 GB volume = 190 GB on disk). An increase is bounded by the scope's storage policy \u2014\nvolumes are governed by the policy limit alone, not billed.", "operationId": "update_volume_api_v1_storage_volumes__volume_id__patch", "parameters": [{"name": "volume_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Volume Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/VolumePatch"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/VolumeRead"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["volumes"], "summary": "Delete Volume", "description": "Permanently delete a volume; requires confirm==volume_id, rejects if mounted.\n\n``force`` (system administrator only) terminates every active session still mounting the\nvolume and then deletes it \u2014 the answer to a mount that keeps a shared volume alive against\nits owner's will. The audit row names the sessions that were cut off.", "operationId": "delete_volume_api_v1_storage_volumes__volume_id__delete", "parameters": [{"name": "volume_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Volume Id"}}, {"name": "confirm", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Confirm"}}, {"name": "force", "in": "query", "required": false, "schema": {"type": "boolean", "default": false, "title": "Force"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/storage/volumes/{volume_id}/folders": {"get": {"tags": ["volumes"], "summary": "List Folders", "description": "List folders of a volume.", "operationId": "list_folders_api_v1_storage_volumes__volume_id__folders_get", "parameters": [{"name": "volume_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Volume Id"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 1, "default": 1, "title": "Page"}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 100, "minimum": 1, "default": 20, "title": "Size"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["volumes"], "summary": "Create Folder", "description": "Create a folder (absolute path) under a volume.", "operationId": "create_folder_api_v1_storage_volumes__volume_id__folders_post", "parameters": [{"name": "volume_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Volume Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true, "title": "Body"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/storage/volumes/{volume_id}/permissions": {"get": {"tags": ["volumes"], "summary": "List Permissions", "description": "List volume share permissions with user names.", "operationId": "list_permissions_api_v1_storage_volumes__volume_id__permissions_get", "parameters": [{"name": "volume_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Volume Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["volumes"], "summary": "Grant Permission", "description": "Grant/update a volume share permission; upsert on UNIQUE(volume_id,user_id).", "operationId": "grant_permission_api_v1_storage_volumes__volume_id__permissions_post", "parameters": [{"name": "volume_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Volume Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PermissionBody"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/storage/volumes/{volume_id}/permissions/{user_id}": {"delete": {"tags": ["volumes"], "summary": "Revoke Permission", "description": "Revoke a share permission; refuse to remove the last owner.\n\nRemoving YOURSELF (leaving a share) is always allowed \u2014 a recipient's \"delete\" is an\nunshare, never a deletion of the original volume. Removing anyone else needs manage rights.", "operationId": "revoke_permission_api_v1_storage_volumes__volume_id__permissions__user_id__delete", "parameters": [{"name": "volume_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Volume Id"}}, {"name": "user_id", "in": "path", "required": true, "schema": {"type": "string", "title": "User Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/storage/volumes/{volume_id}/snapshots": {"post": {"tags": ["volumes"], "summary": "Create Snapshot", "description": "NOT IMPLEMENTED: there is no CSI/storage integration behind snapshots \u2014 rows were being\nauto-flipped to ready by a timer without any data being copied. Honest 501 until a real\nsnapshot backend exists (blocked on the storage-backend decision).", "operationId": "create_snapshot_api_v1_storage_volumes__volume_id__snapshots_post", "parameters": [{"name": "volume_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Volume Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"content": {"application/json": {"schema": {"anyOf": [{"type": "object", "additionalProperties": true}, {"type": "null"}], "title": "Body"}}}}, "responses": {"202": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "get": {"tags": ["volumes"], "summary": "List Snapshots", "description": "List snapshots of a volume.", "operationId": "list_snapshots_api_v1_storage_volumes__volume_id__snapshots_get", "parameters": [{"name": "volume_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Volume Id"}}, {"name": "status", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Status"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 1, "default": 1, "title": "Page"}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 100, "minimum": 1, "default": 20, "title": "Size"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/storage/volumes/{volume_id}/snapshots/{snapshot_id}/restore": {"post": {"tags": ["volumes"], "summary": "Restore Snapshot", "description": "NOT IMPLEMENTED: restore performed no data operation (audit row + fabricated \"restoring\").\nHonest 501 until a real snapshot backend exists.", "operationId": "restore_snapshot_api_v1_storage_volumes__volume_id__snapshots__snapshot_id__restore_post", "parameters": [{"name": "volume_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Volume Id"}}, {"name": "snapshot_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Snapshot Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"content": {"application/json": {"schema": {"anyOf": [{"type": "object", "additionalProperties": true}, {"type": "null"}], "title": "Body"}}}}, "responses": {"202": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/storage/volumes/{volume_id}/snapshots/{snapshot_id}": {"delete": {"tags": ["volumes"], "summary": "Delete Snapshot", "description": "Delete a snapshot; reject while still creating.", "operationId": "delete_snapshot_api_v1_storage_volumes__volume_id__snapshots__snapshot_id__delete", "parameters": [{"name": "volume_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Volume Id"}}, {"name": "snapshot_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Snapshot Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/storage/pools": {"get": {"tags": ["storage"], "summary": "List Pools", "description": "Every registered pool; `cluster_id` narrows to the ones that cluster may use.", "operationId": "list_pools_api_v1_storage_pools_get", "parameters": [{"name": "cluster_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cluster Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["storage"], "summary": "Create Pool", "description": "Register a pool. Capacity is left empty: the operator's next tick measures it.", "operationId": "create_pool_api_v1_storage_pools_post", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/app__api__storage_pools_router__PoolCreate"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/storage/pools/{pool_id}": {"patch": {"tags": ["storage"], "summary": "Update Pool", "operationId": "update_pool_api_v1_storage_pools__pool_id__patch", "parameters": [{"name": "pool_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Pool Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PoolPatch"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["storage"], "summary": "Delete Pool", "description": "Deregister a pool. Nothing on the storage server is touched \u2014 this only stops gShare\ncounting it; the volumes already on it keep working through their StorageClass.", "operationId": "delete_pool_api_v1_storage_pools__pool_id__delete", "parameters": [{"name": "pool_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Pool Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/clusters/summary": {"get": {"tags": ["clusters"], "summary": "Cluster Summary", "description": "The clusters a signed-in user can name: id, name and status only. The console's cluster\nselector (hidden while there is one) and the session wizard's cluster choice read this; the\nfull list with kubeconfig references stays super_admin-only.", "operationId": "cluster_summary_api_v1_clusters_summary_get", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/clusters": {"get": {"tags": ["clusters"], "summary": "List Clusters", "description": "Cluster list. super_admin only.", "operationId": "list_clusters_api_v1_clusters_get", "parameters": [{"name": "role", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Role"}}, {"name": "status", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Status"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 1, "default": 1, "title": "Page"}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 100, "minimum": 1, "default": 20, "title": "Size"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ClusterList"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["clusters"], "summary": "Register Cluster", "description": "Register a cluster: validate kubeconfig, store secret ref, seed inventory.", "operationId": "register_cluster_api_v1_clusters_post", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ClusterRegister"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/clusters/{cluster_id}": {"get": {"tags": ["clusters"], "summary": "Get Cluster", "description": "Cluster detail + node/device summary. super_admin only.", "operationId": "get_cluster_api_v1_clusters__cluster_id__get", "parameters": [{"name": "cluster_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Cluster Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "patch": {"tags": ["clusters"], "summary": "Update Cluster", "description": "Update name/role only. kubeconfig rotation is out of scope here. super_admin only.", "operationId": "update_cluster_api_v1_clusters__cluster_id__patch", "parameters": [{"name": "cluster_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Cluster Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ClusterPatch"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["clusters"], "summary": "Deregister Cluster", "description": "Deregister a cluster + discard its kubeconfig secret ref.\n\nRefused while live sessions/allocations exist on the cluster (-> 409). super_admin only.", "operationId": "deregister_cluster_api_v1_clusters__cluster_id__delete", "parameters": [{"name": "cluster_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Cluster Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/clusters/{cluster_id}/connection-test": {"post": {"tags": ["clusters"], "summary": "Connection Test", "description": "Re-validate connectivity/health to the cluster apiserver.\n\nRe-runs the runtime/connectivity probe, applies the state-machine transition, and persists the\nnew ``status``. super_admin only.", "operationId": "connection_test_api_v1_clusters__cluster_id__connection_test_post", "parameters": [{"name": "cluster_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Cluster Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/images": {"get": {"tags": ["images"], "summary": "List Images", "description": "List images/templates/ISOs with optional kind/q/tag/public filters. any authenticated.\n\n``public=true`` is what the session wizard uses, listing public images only. The administrative\ncatalogue passes no filter and sees both public and private images.", "operationId": "list_images_api_v1_images_get", "parameters": [{"name": "kind", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Kind"}}, {"name": "q", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Q"}}, {"name": "tag", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Tag"}}, {"name": "public", "in": "query", "required": false, "schema": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Public"}}, {"name": "mine", "in": "query", "required": false, "schema": {"type": "boolean", "default": false, "title": "Mine"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 1, "default": 1, "title": "Page"}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 100, "minimum": 1, "default": 20, "title": "Size"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ImageList"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["images"], "summary": "Create Image", "description": "Register a catalog image referencing an existing registry path. super_admin\u00b7org_admin.", "operationId": "create_image_api_v1_images_post", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ImageCreate"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/images/{image_id}": {"get": {"tags": ["images"], "summary": "Get Image", "description": "Image detail. any authenticated, for the shared catalogue and one's own images; another\nuser's private image answers 404 so its existence is not confirmed either.", "operationId": "get_image_api_v1_images__image_id__get", "parameters": [{"name": "image_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Image Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "patch": {"tags": ["images"], "summary": "Update Image", "description": "Update image metadata: public/private, name, CUDA version, supported GPUs. super_admin, gated\non image.create.", "operationId": "update_image_api_v1_images__image_id__patch", "parameters": [{"name": "image_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Image Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ImageUpdate"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["images"], "summary": "Delete Image", "description": "Delete a catalogue image that no session has ever used. super_admin, gated on image.create.\n\nSessions keep a foreign key to their image for the audit trail, so an image with any session\nhistory cannot be deleted \u2014 retire it instead by setting ``public: false``.\n\nOwners may delete their OWN built (private) images; everything else stays admin-gated.", "operationId": "delete_image_api_v1_images__image_id__delete", "parameters": [{"name": "image_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Image Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/images/import": {"post": {"tags": ["images"], "summary": "Import Image", "description": "Register an image from an external registry reference. any authenticated.\n\nRegistration is synchronous catalog metadata: the actual pull happens on the GPU node's\ncontainer runtime when a session using the image first starts (there is no control-plane pull\nworker). The row is therefore created ``import_status=ready`` immediately. Private-registry\ncredentials are not supported yet \u2014 imagePullSecrets plumbing does not exist \u2014 so a request\ncarrying ``registry_auth`` is refused rather than silently losing the credential.\n\nWho owns the row:\n\n* ``image.create`` holders (super_admin) register SHARED catalog entries \u2014 ``owner_user_id``\n null, ``public`` as given. Re-registering a ref that already exists as a shared row is a 409.\n* everyone else registers a PRIVATE row they own (``public=false``, ``kind=container``), which\n only they and admins can see. The same public ref may therefore be imported by many members.\n\nAlways answers 202. Deduplication never fails the request: re-importing your own ref, or a ref\nthat is already in the shared catalogue, returns that row with ``existing: true`` instead of\ncreating a second one \u2014 the caller uses the returned ``id`` either way.", "operationId": "import_image_api_v1_images_import_post", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ImageImport"}}}}, "responses": {"202": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/image-builds": {"post": {"tags": ["images"], "summary": "Create Build", "description": "Create a console image build: record desired state, then hand the GShareImageBuild CR to\nthe operator (which runs kaniko and reports back via /internal/image-builds/{id}/status).\n\nMembers build for themselves; the pushed image lands as a PRIVATE Image row\n(owner + admins only). One build in flight per user.", "operationId": "create_build_api_v1_image_builds_post", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BuildCreate"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "get": {"tags": ["images"], "summary": "List Builds", "description": "List builds. member sees own-project builds; restricted to membership projects.", "operationId": "list_builds_api_v1_image_builds_get", "parameters": [{"name": "group_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Group Id"}}, {"name": "status", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Status"}}, {"name": "source", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Source"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 1, "default": 1, "title": "Page"}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 100, "minimum": 1, "default": 20, "title": "Size"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ImageBuildList"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/image-builds/{build_id}": {"get": {"tags": ["images"], "summary": "Get Build", "description": "Build detail incl. image_ref / scan summary.", "operationId": "get_build_api_v1_image_builds__build_id__get", "parameters": [{"name": "build_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Build Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/image-builds/{build_id}/logs": {"get": {"tags": ["images"], "summary": "Build Logs", "description": "Return the stored kaniko log tail (the operator reports the last ~16KB on each callback).", "operationId": "build_logs_api_v1_image_builds__build_id__logs_get", "parameters": [{"name": "build_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Build Id"}}, {"name": "tail", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 10000, "minimum": 1, "default": 500, "title": "Tail"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/notifications/events": {"get": {"tags": ["notifications"], "summary": "Notification Events", "description": "Live SSE stream of the caller's notifications: a ping event per new notification, which the\nconsole turns into a refetch of the list.\n\nEventSource cannot set headers, so authentication goes through ``?access_token=``, which\nget_current_principal accepts.", "operationId": "notification_events_api_v1_notifications_events_get", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/notifications": {"get": {"tags": ["notifications"], "summary": "List Notifications", "description": "List the caller's own notifications, newest first. Self-scoped only.", "operationId": "list_notifications_api_v1_notifications_get", "parameters": [{"name": "unread", "in": "query", "required": false, "schema": {"type": "boolean", "default": false, "title": "Unread"}}, {"name": "include_deleted", "in": "query", "required": false, "schema": {"type": "boolean", "default": false, "title": "Include Deleted"}}, {"name": "type", "in": "query", "required": false, "schema": {"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}], "title": "Type"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 1, "default": 1, "title": "Page"}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 100, "minimum": 1, "default": 20, "title": "Size"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["notifications"], "summary": "Delete All Notifications", "description": "Delete every notification of the caller. Self-scoped by construction.", "operationId": "delete_all_notifications_api_v1_notifications_delete", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/notifications/{notification_id}/read": {"post": {"tags": ["notifications"], "summary": "Mark Read", "description": "Mark one notification read; idempotent no-op if already read.", "operationId": "mark_read_api_v1_notifications__notification_id__read_post", "parameters": [{"name": "notification_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Notification Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/notifications/{notification_id}": {"delete": {"tags": ["notifications"], "summary": "Delete Notification", "description": "Delete one of the caller's notifications. 404 for other users' rows (no existence leak).", "operationId": "delete_notification_api_v1_notifications__notification_id__delete", "parameters": [{"name": "notification_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Notification Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/notifications/read-all": {"post": {"tags": ["notifications"], "summary": "Mark All Read", "description": "Bulk mark the caller's unread notifications read, optionally bounded.", "operationId": "mark_all_read_api_v1_notifications_read_all_post", "parameters": [{"name": "type", "in": "query", "required": false, "schema": {"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}], "title": "Type"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"content": {"application/json": {"schema": {"anyOf": [{"type": "object", "additionalProperties": true}, {"type": "null"}], "title": "Body"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/audit-logs/export": {"get": {"tags": ["audit"], "summary": "Export Audit Logs", "description": "The current audit view as a CSV file, newest first, same scope and filters as the list.\n\nUTF-8 with a BOM so Excel opens Korean names correctly; ``detail`` is the JSON blob verbatim.\nTaking the export is itself audited (``audit.export``, with the filters and the row count):\na file leaving the system is exactly the kind of event the log exists for.", "operationId": "export_audit_logs_api_v1_audit_logs_export_get", "parameters": [{"name": "actor_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Actor Id"}}, {"name": "actor_q", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Actor Q"}}, {"name": "action", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Action"}}, {"name": "target", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Target"}}, {"name": "at[gte]", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "At[Gte]"}}, {"name": "at[lt]", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "At[Lt]"}}, {"name": "result", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Result"}}, {"name": "cluster_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cluster Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/audit-logs": {"get": {"tags": ["audit"], "summary": "List Audit Logs", "operationId": "list_audit_logs_api_v1_audit_logs_get", "parameters": [{"name": "actor_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Actor Id"}}, {"name": "actor_q", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Actor Q"}}, {"name": "action", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Action"}}, {"name": "target", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Target"}}, {"name": "at[gte]", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "At[Gte]"}}, {"name": "at[lt]", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "At[Lt]"}}, {"name": "result", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Result"}}, {"name": "cluster_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cluster Id"}}, {"name": "sort", "in": "query", "required": false, "schema": {"type": "string", "default": "-at", "title": "Sort"}}, {"name": "verify", "in": "query", "required": false, "schema": {"type": "boolean", "default": false, "title": "Verify"}}, {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 1, "default": 1, "title": "Page"}}, {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "maximum": 100, "minimum": 1, "default": 20, "title": "Size"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AuditLogList"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/webhooks": {"get": {"tags": ["webhooks"], "summary": "List Webhooks", "operationId": "list_webhooks_api_v1_webhooks_get", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["webhooks"], "summary": "Create Webhook", "operationId": "create_webhook_api_v1_webhooks_post", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/WebhookCreate"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/webhooks/{webhook_id}": {"delete": {"tags": ["webhooks"], "summary": "Delete Webhook", "operationId": "delete_webhook_api_v1_webhooks__webhook_id__delete", "parameters": [{"name": "webhook_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Webhook Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/dashboard/summary": {"get": {"tags": ["dashboard"], "summary": "Dashboard Summary", "operationId": "dashboard_summary_api_v1_dashboard_summary_get", "parameters": [{"name": "scope", "in": "query", "required": false, "schema": {"enum": ["mine", "managed"], "type": "string", "default": "mine", "title": "Scope"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/DashboardSummary"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/nodes": {"get": {"tags": ["infra"], "summary": "List Nodes", "description": "Nodes, optionally narrowed to one cluster.\n\nThe console filtered this list in the browser, which meant every screen fetched every\ncluster's nodes to show one cluster's. `GpuNode.cluster_id` is indexed; the filter belongs here.", "operationId": "list_nodes_api_v1_nodes_get", "parameters": [{"name": "status", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Status"}}, {"name": "region", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Region"}}, {"name": "cluster_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cluster Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/NodeList"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["infra"], "summary": "Register Node", "description": "Register a node, initially offline. super_admin only.\n\nWithout a cluster_id the node is attached to the only registered cluster, which covers test and\nsingle-cluster setups. With several clusters the field is required.", "operationId": "register_node_api_v1_nodes_post", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/_NodeRegister"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/nodes/{node_id}": {"get": {"tags": ["infra"], "summary": "Get Node", "description": "Fetch one node, for deep links from the drain and device pages.", "operationId": "get_node_api_v1_nodes__node_id__get", "parameters": [{"name": "node_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Node Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["infra"], "summary": "Delete Node", "description": "Decommission a node: clear its inventory and have the operator remove it from Kubernetes.\n\nsuper_admin only. Two phases, because the control plane never calls the workload Kubernetes\nAPI itself. This request clears the cards and marks the node ``decommissioning``; the operator\ndeletes the Node object on its next pass and reports back, and only then does the row go. That\nis what stops a removed node from reappearing: while the Node object exists, every inventory\nreport recreates the row (upsert by cluster + hostname), which is exactly what a manual\n`kubectl delete node` used to be needed for.\n\nRefuses while the node still carries live work \u2014 a live allocation on one of its cards, or a\nnon-terminal session the operator placed there \u2014 so removal can never strand a running\nsession's ledger. Ended allocations keep their history: the row survives with device_id NULL\nand its gpu_uuid intact, since the card it names no longer exists.\n\nRefuses while the node is still up: deleting a live node's object only makes its kubelet\nregister again seconds later. Drain it and power it down first.", "operationId": "delete_node_api_v1_nodes__node_id__delete", "parameters": [{"name": "node_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Node Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/nodes/{node_id}/cordon": {"post": {"tags": ["infra"], "summary": "Cordon Node", "description": "Cordon or uncordon a node. super_admin only.\n\ncordon=true blocks new scheduling (status=cordoned); false restores it (status=ready). Sessions\nalready running are left alone.", "operationId": "cordon_node_api_v1_nodes__node_id__cordon_post", "parameters": [{"name": "node_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Node Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/_CordonBody"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/nodes/{node_id}/drain": {"post": {"tags": ["infra"], "summary": "Drain Node", "description": "Drain a node. super_admin only.\n\nBoth modes cordon FIRST (no new placements can land back), then act on every session holding\na live allocation on the node's devices:\n - reschedule: pause \u2192 resume each running/preparing session. Resume re-runs placement,\n which excludes cordoned nodes \u2014 the session comes back on another eligible card (same\n GPU model, enough VRAM/cores, pool access). No capacity \u21d2 the session stays PAUSED\n (\"parked\"): work preserved, owner can resume later.\n - force_terminate: terminate each affected session (settled like an admin stop).", "operationId": "drain_node_api_v1_nodes__node_id__drain_post", "parameters": [{"name": "node_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Node Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/_DrainBody"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/node-pools": {"get": {"tags": ["infra"], "summary": "List Node Pools", "description": "List node pools. An org_admin sees only the pools carrying a grant for one of their\norganizations (or a group in them).", "operationId": "list_node_pools_api_v1_node_pools_get", "parameters": [{"name": "cluster_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cluster Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PoolList"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "post": {"tags": ["infra"], "summary": "Create Node Pool", "operationId": "create_node_pool_api_v1_node_pools_post", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/app__api__schemas__node_pool__PoolCreate"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PoolRead"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/node-pools/{pool_id}": {"patch": {"tags": ["infra"], "summary": "Update Node Pool", "operationId": "update_node_pool_api_v1_node_pools__pool_id__patch", "parameters": [{"name": "pool_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Pool Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PoolUpdate"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PoolRead"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "delete": {"tags": ["infra"], "summary": "Delete Node Pool", "description": "Delete a pool: its nodes become shared (pool_id NULL) and its grants go with it. Refused\nwhile any live session holds an allocation on one of its nodes \u2014 cordon and drain first.", "operationId": "delete_node_pool_api_v1_node_pools__pool_id__delete", "parameters": [{"name": "pool_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Pool Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/nodes/{node_id}/pool": {"put": {"tags": ["infra"], "summary": "Set Node Pool", "description": "Move a node into a pool (or back to shared with pool_id null). The pool must be in the\nnode's cluster. Sessions already on the node are left alone; only new placements change.", "operationId": "set_node_pool_api_v1_nodes__node_id__pool_put", "parameters": [{"name": "node_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Node Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/NodePoolSet"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/node-pools/{pool_id}/grants": {"post": {"tags": ["infra"], "summary": "Grant Node Pool", "description": "Grant a pool to an organization or group. super_admin: anything (the target must exist).\norg_admin: only sub-assignment of a pool already granted to their organization, to a group in\nthat organization (domain.node_pools.assert_may_grant).", "operationId": "grant_node_pool_api_v1_node_pools__pool_id__grants_post", "parameters": [{"name": "pool_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Pool Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PoolGrantCreate"}}}}, "responses": {"201": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/node-pools/{pool_id}/grants/{grant_id}": {"delete": {"tags": ["infra"], "summary": "Revoke Node Pool Grant", "description": "Revoke a grant. Same rule as granting: an org_admin may only remove group grants inside an\norganization the pool is granted to.", "operationId": "revoke_node_pool_grant_api_v1_node_pools__pool_id__grants__grant_id__delete", "parameters": [{"name": "pool_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Pool Id"}}, {"name": "grant_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Grant Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"204": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/gpu-devices": {"get": {"tags": ["infra"], "summary": "List Gpu Devices", "operationId": "list_gpu_devices_api_v1_gpu_devices_get", "parameters": [{"name": "node_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Node Id"}}, {"name": "cluster_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cluster Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GpuDeviceList"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/gpu-devices/{device_id}/alias": {"put": {"tags": ["infra"], "summary": "Set Device Alias", "description": "Name a physical card. super_admin only.\n\nThe console otherwise numbers cards by their position in the list, so \"card #2\" means a\ndifferent card the day a card is added or removed. An alias pins a name to the UUID. Unique\nwithin the cluster; the ledger owns it and inventory reports never overwrite it.", "operationId": "set_device_alias_api_v1_gpu_devices__device_id__alias_put", "parameters": [{"name": "device_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Device Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/_DeviceAliasBody"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/gpu-devices/{device_id}/health": {"put": {"tags": ["infra"], "summary": "Set Device Health", "description": "Take one card out of service (or put it back). super_admin only.\n\n``unhealthy`` removes the card from placement and ends every session bound to it with\n``gpu_fault`` \u2014 a process whose CUDA context died cannot be resumed, so the honest outcome is\na settled session and a notified owner, not a running one that bills. ``ready`` puts the\ncard back after repair. The same routine serves operator health events that name a card.", "operationId": "set_device_health_api_v1_gpu_devices__device_id__health_put", "parameters": [{"name": "device_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Device Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/_DeviceHealthBody"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/gpu-devices/{device_id}/mode": {"put": {"tags": ["infra"], "summary": "Set Gpu Device Mode", "description": "Set a card's target pool (per-CARD, not per-node). super_admin, same tier as cordon.\n\nfractional\u2194exclusive is a metadata change applied as soon as the card is empty; \u2194mig also\nneeds the node-side geometry change, executed by the drain state machine (the card stops\naccepting placements immediately and flips when its last allocation ends).", "operationId": "set_gpu_device_mode_api_v1_gpu_devices__device_id__mode_put", "parameters": [{"name": "device_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Device Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GpuDeviceModeSet"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/gpu-pools/{cluster_id}": {"put": {"tags": ["infra"], "summary": "Set Pool Targets", "description": "Set the cluster's MIG/hami-core pool split by TARGET COUNT \u2014 the console's coarse control\n(simpler and safer than dragging individual cards). The emptiest eligible cards are chosen;\neach transition drains first and runs through the GpuModeChange machinery.", "operationId": "set_pool_targets_api_v1_gpu_pools__cluster_id__put", "parameters": [{"name": "cluster_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Cluster Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PoolTargetsBody"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/metrics/cluster": {"get": {"tags": ["infra"], "summary": "Metrics Cluster", "description": "Fleet figures, or one cluster's when `cluster_id` is given.\n\nWithout the filter the console showed a cluster's card grid beside fleet-wide totals \u2014 eight\nnodes and three cards while the panel below said one. Every aggregate here now narrows the\nsame way, so the numbers on one screen describe one thing.", "operationId": "metrics_cluster_api_v1_metrics_cluster_get", "parameters": [{"name": "region", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Region"}}, {"name": "cluster_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cluster Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ClusterMetrics"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/metrics/billing-report": {"get": {"tags": ["infra"], "summary": "Billing Report", "description": "Settlement and billing report: the credit ledger (consume, settle, topup) aggregated by a\ngroup_by key.\n\nConsumption and top-ups join CreditTransaction to CreditWallet and are attributed to the owner\n(organization, group, or user). gpu_hours is a best-effort sum of the occupancy hours of GPU\nsessions overlapping [from_, to].", "operationId": "billing_report_api_v1_metrics_billing_report_get", "parameters": [{"name": "scope", "in": "query", "required": true, "schema": {"type": "string", "pattern": "^(org|group|wallet)$", "title": "Scope"}}, {"name": "scope_id", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Scope Id"}}, {"name": "from", "in": "query", "required": true, "schema": {"type": "string", "format": "date-time", "title": "From"}}, {"name": "to", "in": "query", "required": true, "schema": {"type": "string", "format": "date-time", "title": "To"}}, {"name": "group_by", "in": "query", "required": false, "schema": {"type": "string", "pattern": "^(group|offering|wallet)$", "default": "group", "title": "Group By"}}, {"name": "format", "in": "query", "required": false, "schema": {"type": "string", "default": "json", "title": "Format"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BillingReport"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/monitoring/status": {"get": {"tags": ["monitoring"], "summary": "Monitoring Status", "description": "Whether the metrics backend answers, so the page can say \"not configured\" instead of\nrendering empty charts.", "operationId": "monitoring_status_api_v1_monitoring_status_get", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/monitoring/sessions/{session_id}/usage": {"get": {"tags": ["monitoring"], "summary": "Session Usage", "description": "Measured live usage of ONE session's pod: cadvisor CPU/MEM + HAMi VRAM/GPU-core.\n\nInstant values for the monitor drawer, refreshed by the console. VRAM and core come from\nHAMi's in-container monitor, which only reports while a CUDA context exists \u2014 an idle\nnotebook legitimately reads 0 there while CPU/MEM still move.", "operationId": "session_usage_api_v1_monitoring_sessions__session_id__usage_get", "parameters": [{"name": "session_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Session Id"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/monitoring/sessions/{session_id}/usage/timeseries": {"get": {"tags": ["monitoring"], "summary": "Session Usage Timeseries", "description": "The four per-session usage metrics over a range, for the monitor detail sparklines. A\nfinished session gets its whole run instead of a trailing window.", "operationId": "session_usage_timeseries_api_v1_monitoring_sessions__session_id__usage_timeseries_get", "parameters": [{"name": "session_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Session Id"}}, {"name": "range", "in": "query", "required": false, "schema": {"type": "string", "default": "15m", "title": "Range"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/monitoring/timeseries": {"get": {"tags": ["monitoring"], "summary": "Monitoring Timeseries", "description": "One panel over a time range. The panel id selects the query; the console never sends PromQL.", "operationId": "monitoring_timeseries_api_v1_monitoring_timeseries_get", "parameters": [{"name": "panel", "in": "query", "required": true, "schema": {"type": "string", "title": "Panel"}}, {"name": "range", "in": "query", "required": false, "schema": {"type": "string", "default": "1h", "title": "Range"}}, {"name": "node", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Node"}}, {"name": "gpu", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Gpu"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/monitoring/instant": {"get": {"tags": ["monitoring"], "summary": "Monitoring Instant", "description": "Current value per series \u2014 the snapshot table and the tiles.", "operationId": "monitoring_instant_api_v1_monitoring_instant_get", "parameters": [{"name": "panel", "in": "query", "required": true, "schema": {"type": "string", "title": "Panel"}}, {"name": "node", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Node"}}, {"name": "gpu", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Gpu"}}, {"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/monitoring/gpu-inventory": {"get": {"tags": ["monitoring"], "summary": "Monitoring Gpu Inventory", "description": "The ledger's view of each card: mode, allocated VRAM/cores, and the sessions holding it.\n\nDCGM cannot attribute a shared card's usage to a session (HAMi splits below its visibility), so\nsession attribution comes from the control plane instead of being guessed from metrics.", "operationId": "monitoring_gpu_inventory_api_v1_monitoring_gpu_inventory_get", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/system/branding": {"get": {"tags": ["system"], "summary": "Get Branding", "description": "Public: the sign-in screen renders this before anyone has a token.", "operationId": "get_branding_api_v1_system_branding_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BrandingOut"}}}}}}, "put": {"tags": ["system"], "summary": "Set Branding", "description": "Set the service name and logo; super_admin only.", "operationId": "set_branding_api_v1_system_branding_put", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BrandingUpdate"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BrandingOut"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/system/signup": {"get": {"tags": ["system"], "summary": "Get Signup Policy", "description": "Public: the sign-in screen shows its sign-up tab only when this is not `closed`.", "operationId": "get_signup_policy_api_v1_system_signup_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SignupPolicyOut"}}}}}}, "put": {"tags": ["system"], "summary": "Set Signup Policy", "description": "Set the sign-up policy; super_admin only.", "operationId": "set_signup_policy_api_v1_system_signup_put", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SignupPolicyUpdate"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SignupPolicyOut"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/api/v1/system/placement": {"get": {"tags": ["system"], "summary": "Get Placement", "description": "The fractional-slice placement policy. Readable by anyone who may see the admin console.", "operationId": "get_placement_api_v1_system_placement_get", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PlacementOut"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}, "put": {"tags": ["system"], "summary": "Set Placement", "description": "Set the placement policy. Applies to the next reservation; running sessions do not move.", "operationId": "set_placement_api_v1_system_placement_put", "parameters": [{"name": "access_token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)", "title": "Access Token"}, "description": "Token fallback for clients that cannot set custom headers, such as EventSource (SSE)"}, {"name": "authorization", "in": "header", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Bearer ", "title": "Authorization"}, "description": "Bearer "}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PlacementUpdate"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PlacementOut"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/internal/sessions/{session_id}/status": {"post": {"tags": ["internal"], "summary": "Report Status", "operationId": "report_status_internal_sessions__session_id__status_post", "parameters": [{"name": "session_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Session Id"}}, {"name": "authorization", "in": "header", "required": true, "schema": {"type": "string", "title": "Authorization"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/OperatorStatusEvent"}}}}, "responses": {"202": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/internal/image-builds/{build_id}/status": {"post": {"tags": ["internal"], "summary": "Report Build Status", "operationId": "report_build_status_internal_image_builds__build_id__status_post", "parameters": [{"name": "build_id", "in": "path", "required": true, "schema": {"type": "string", "title": "Build Id"}}, {"name": "authorization", "in": "header", "required": true, "schema": {"type": "string", "title": "Authorization"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BuildStatusEvent"}}}}, "responses": {"202": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/internal/connect/verify": {"get": {"tags": ["internal"], "summary": "Connect Verify", "operationId": "connect_verify_internal_connect_verify_get", "parameters": [{"name": "token", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Token"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/internal/audit/operator": {"post": {"tags": ["internal"], "summary": "Audit Operator", "operationId": "audit_operator_internal_audit_operator_post", "parameters": [{"name": "authorization", "in": "header", "required": true, "schema": {"type": "string", "title": "Authorization"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/OperatorAuditEvent"}}}}, "responses": {"202": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/internal/inventory/gpu-devices": {"post": {"tags": ["internal"], "summary": "Upsert Gpu Device", "operationId": "upsert_gpu_device_internal_inventory_gpu_devices_post", "parameters": [{"name": "authorization", "in": "header", "required": true, "schema": {"type": "string", "title": "Authorization"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/OperatorGpuDeviceUpsert"}}}}, "responses": {"202": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/internal/inventory/nodes": {"post": {"tags": ["internal"], "summary": "Upsert Node", "operationId": "upsert_node_internal_inventory_nodes_post", "parameters": [{"name": "authorization", "in": "header", "required": true, "schema": {"type": "string", "title": "Authorization"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/OperatorNodeUpsert"}}}}, "responses": {"202": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/internal/nodes/cordoned": {"get": {"tags": ["internal"], "summary": "Cordoned Nodes", "description": "Hostnames the ledger holds as cordoned, for the operator to mirror onto the Kubernetes\nnodes (spec.unschedulable). The control plane never touches Kubernetes itself; without this\nmirror a gShare cordon only steered the ledger's card placement and kube-scheduler could still\nput a CPU session \u2014 or a resumed one \u2014 straight back on a node being drained.", "operationId": "cordoned_nodes_internal_nodes_cordoned_get", "parameters": [{"name": "authorization", "in": "header", "required": true, "schema": {"type": "string", "title": "Authorization"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/internal/nodes/decommissioning": {"get": {"tags": ["internal"], "summary": "Decommissioning Nodes", "description": "Hostnames an administrator asked to remove from the cluster.\n\nThe console's delete button clears the ledger and parks the node here; the operator deletes\nthe matching Node object and calls back below. Without that second step the Node object\nsurvives, every inventory pass recreates the row, and the node reappears in the console \u2014\nwhich is why removing a node used to need a manual `kubectl delete node`.", "operationId": "decommissioning_nodes_internal_nodes_decommissioning_get", "parameters": [{"name": "authorization", "in": "header", "required": true, "schema": {"type": "string", "title": "Authorization"}}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/internal/nodes/decommissioned": {"post": {"tags": ["internal"], "summary": "Node Decommissioned", "description": "The operator confirms the Node object is gone; the ledger row goes with it.", "operationId": "node_decommissioned_internal_nodes_decommissioned_post", "parameters": [{"name": "authorization", "in": "header", "required": true, "schema": {"type": "string", "title": "Authorization"}}], "requestBody": {"content": {"application/json": {"schema": {"type": "object", "additionalProperties": true, "title": "Body"}}}}, "responses": {"202": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/internal/inventory/drift": {"post": {"tags": ["internal"], "summary": "Report Drift", "operationId": "report_drift_internal_inventory_drift_post", "parameters": [{"name": "authorization", "in": "header", "required": true, "schema": {"type": "string", "title": "Authorization"}}], "requestBody": {"content": {"application/json": {"schema": {"type": "object", "additionalProperties": true, "title": "Body"}}}}, "responses": {"202": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/internal/nodes/health-events": {"post": {"tags": ["internal"], "summary": "Node Health Event", "description": "Record a NodeHealthEvent and, on a cordon action, mark GpuNode.status=cordoned.\n\nThe operator already cordoned the K8s node; the ledger reflects it so the console/scheduler\nstop placing new sessions there.\n\nThe operator's HealthReconciler addresses the node by its Kubernetes NAME (the hostname): it\nhas no ledger id. The row is therefore resolved by (token cluster, hostname) \u2014 with the ledger\nid still accepted for older callers \u2014 and the event carries the row's id, which the FK needs.\nLooking the hostname up as an id never matched: the cordon never reached the ledger and the\nevent insert violated the FK on Postgres.", "operationId": "node_health_event_internal_nodes_health_events_post", "parameters": [{"name": "authorization", "in": "header", "required": true, "schema": {"type": "string", "title": "Authorization"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/OperatorNodeHealthEvent"}}}}, "responses": {"202": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/.well-known/gshare-internal-jwks.json": {"get": {"tags": ["internal"], "summary": "Internal Jwks", "operationId": "internal_jwks__well_known_gshare_internal_jwks_json_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}}}}, "/internal/volumes/sync": {"post": {"tags": ["internal"], "summary": "Sync Volumes", "operationId": "sync_volumes_internal_volumes_sync_post", "parameters": [{"name": "authorization", "in": "header", "required": true, "schema": {"type": "string", "title": "Authorization"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/OperatorVolumeSync"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/VolumeSyncResponse"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/internal/metrics/session-samples": {"post": {"tags": ["internal"], "summary": "Ingest Session Samples", "description": "Accept one node's batch of session samples.\n\nThe agent knows a session only by its CR name (the pod label), so the ids are resolved here \u2014\nthe same lower/underscore mapping the CR builder uses. An unknown name is dropped rather than\nstored under a key nothing will ever read.", "operationId": "ingest_session_samples_internal_metrics_session_samples_post", "parameters": [{"name": "authorization", "in": "header", "required": true, "schema": {"type": "string", "title": "Authorization"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AgentReport"}}}}, "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"type": "object", "additionalProperties": true, "title": "Response Ingest Session Samples Internal Metrics Session Samples Post"}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}, "/healthz": {"get": {"tags": ["health"], "summary": "Healthz", "operationId": "healthz_healthz_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {}}}}}}}}, "components": {"schemas": {"AdjustBody": {"properties": {"amount": {"anyOf": [{"type": "number", "maximum": 1000000000000.0, "minimum": -1000000000000.0}, {"type": "string", "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$"}], "title": "Amount"}, "reason": {"type": "string", "title": "Reason"}}, "type": "object", "required": ["amount", "reason"], "title": "AdjustBody"}, "AgentReport": {"properties": {"node": {"type": "string", "title": "Node"}, "samples": {"items": {"$ref": "#/components/schemas/AgentSample"}, "type": "array", "maxItems": 500, "title": "Samples"}}, "type": "object", "required": ["node"], "title": "AgentReport"}, "AgentSample": {"properties": {"session": {"type": "string", "title": "Session"}, "at": {"type": "number", "title": "At"}, "cpu_cores": {"type": "number", "minimum": 0.0, "title": "Cpu Cores"}, "mem_bytes": {"type": "integer", "minimum": 0.0, "title": "Mem Bytes"}}, "type": "object", "required": ["session", "at", "cpu_cores", "mem_bytes"], "title": "AgentSample"}, "AllocateBody": {"properties": {"from_wallet_id": {"type": "string", "title": "From Wallet Id"}, "to_wallet_id": {"type": "string", "title": "To Wallet Id"}, "amount": {"anyOf": [{"type": "number", "maximum": 1000000000000.0, "exclusiveMinimum": 0.0}, {"type": "string", "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$"}], "title": "Amount"}, "reason": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Reason"}}, "type": "object", "required": ["from_wallet_id", "to_wallet_id", "amount"], "title": "AllocateBody"}, "AllocationRejectBody": {"properties": {"reason": {"type": "string", "title": "Reason"}}, "type": "object", "required": ["reason"], "title": "AllocationRejectBody"}, "AllocationRequestCreate": {"properties": {"amount": {"anyOf": [{"type": "number", "maximum": 1000000000000.0, "exclusiveMinimum": 0.0}, {"type": "string", "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$"}], "title": "Amount"}, "level": {"type": "string", "pattern": "^(user|group|org)$", "title": "Level", "default": "user"}, "group_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Group Id"}, "org_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Org Id"}, "note": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Note"}}, "type": "object", "required": ["amount"], "title": "AllocationRequestCreate"}, "AuditChainResult": {"properties": {"ok": {"type": "boolean", "title": "Ok"}, "checked": {"type": "integer", "title": "Checked"}, "broken_at": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Broken At"}, "reason": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Reason"}}, "type": "object", "required": ["ok", "checked"], "title": "AuditChainResult", "description": "Optional integrity attestation appended when ``verify=true``."}, "AuditLogEntry": {"properties": {"id": {"type": "string", "title": "Id"}, "actor_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Actor Id"}, "action": {"type": "string", "title": "Action"}, "target": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Target"}, "result": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Result"}, "detail": {"additionalProperties": true, "type": "object", "title": "Detail"}, "trace_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Trace Id"}, "prev_hash": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Prev Hash"}, "entry_hash": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Entry Hash"}, "at": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "At"}, "actor_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Actor Name"}, "actor_email": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Actor Email"}, "target_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Target Name"}, "cluster_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cluster Id"}}, "type": "object", "required": ["id", "action", "detail"], "title": "AuditLogEntry", "description": "One audit-log row as projected by ``_audit_view`` (+ resolved actor_name)."}, "AuditLogList": {"properties": {"data": {"items": {"$ref": "#/components/schemas/AuditLogEntry"}, "type": "array", "title": "Data"}, "pagination": {"$ref": "#/components/schemas/PageMeta"}, "names": {"additionalProperties": {"type": "string"}, "type": "object", "title": "Names", "default": {}}, "chain": {"anyOf": [{"$ref": "#/components/schemas/AuditChainResult"}, {"type": "null"}]}}, "type": "object", "required": ["data", "pagination"], "title": "AuditLogList", "description": "GET /audit-logs envelope (+ optional ``chain`` when verify=true)."}, "BillingReport": {"properties": {"rows": {"items": {"$ref": "#/components/schemas/BillingReportRow"}, "type": "array", "title": "Rows"}, "totals": {"additionalProperties": {"type": "string"}, "type": "object", "title": "Totals"}, "currency": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Currency"}}, "type": "object", "required": ["rows", "totals"], "title": "BillingReport"}, "BillingReportRow": {"properties": {"group": {"type": "string", "title": "Group"}, "group_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Group Name"}, "consumed": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Consumed"}, "topup": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Topup"}, "gpu_hours": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Gpu Hours"}}, "type": "object", "required": ["group"], "title": "BillingReportRow"}, "BoundSession": {"properties": {"session_id": {"type": "string", "title": "Session Id"}, "gpu_mem_mb": {"type": "integer", "title": "Gpu Mem Mb"}, "gpu_cores": {"type": "integer", "title": "Gpu Cores"}}, "type": "object", "required": ["session_id", "gpu_mem_mb", "gpu_cores"], "title": "BoundSession"}, "BrandingOut": {"properties": {"service_name": {"type": "string", "title": "Service Name"}, "logo": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Logo"}}, "type": "object", "required": ["service_name"], "title": "BrandingOut"}, "BrandingUpdate": {"properties": {"service_name": {"anyOf": [{"type": "string", "maxLength": 40, "minLength": 1}, {"type": "null"}], "title": "Service Name"}, "logo": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Logo"}, "clear_logo": {"type": "boolean", "title": "Clear Logo", "default": false}}, "type": "object", "title": "BrandingUpdate"}, "BudgetCreate": {"properties": {"scope": {"type": "string", "pattern": "^(org|group)$", "title": "Scope"}, "scope_id": {"type": "string", "title": "Scope Id"}, "period_start": {"type": "string", "format": "date-time", "title": "Period Start"}, "period": {"type": "string", "title": "Period", "default": "monthly"}, "limit_credit": {"anyOf": [{"type": "number", "exclusiveMinimum": 0.0}, {"type": "string", "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$"}], "title": "Limit Credit"}, "action": {"type": "string", "pattern": "^(alert|block)$", "title": "Action", "default": "alert"}}, "type": "object", "required": ["scope", "scope_id", "period_start", "limit_credit"], "title": "BudgetCreate"}, "BudgetForecast": {"properties": {"projected_exhaustion_date": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Projected Exhaustion Date"}, "burn_rate_per_day": {"type": "number", "title": "Burn Rate Per Day"}}, "type": "object", "required": ["burn_rate_per_day"], "title": "BudgetForecast"}, "BudgetRead": {"properties": {"id": {"type": "string", "title": "Id"}, "scope": {"type": "string", "title": "Scope"}, "scope_id": {"type": "string", "title": "Scope Id"}, "limit_credit": {"type": "string", "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", "title": "Limit Credit"}, "spent_credit": {"type": "string", "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", "title": "Spent Credit"}, "action": {"type": "string", "title": "Action"}}, "type": "object", "required": ["id", "scope", "scope_id", "limit_credit", "spent_credit", "action"], "title": "BudgetRead"}, "BudgetUpdate": {"properties": {"limit_credit": {"anyOf": [{"type": "number", "exclusiveMinimum": 0.0}, {"type": "string", "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$"}, {"type": "null"}], "title": "Limit Credit"}, "action": {"anyOf": [{"type": "string", "pattern": "^(alert|block)$"}, {"type": "null"}], "title": "Action"}}, "type": "object", "title": "BudgetUpdate"}, "BuildCreate": {"properties": {"group_id": {"type": "string", "title": "Group Id"}, "name": {"type": "string", "maxLength": 120, "minLength": 1, "title": "Name"}, "source": {"type": "string", "title": "Source"}, "dockerfile": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Dockerfile"}, "git_url": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Git Url"}, "git_ref": {"type": "string", "title": "Git Ref", "default": "main"}, "context": {"type": "string", "title": "Context", "default": "."}, "build_args": {"additionalProperties": true, "type": "object", "title": "Build Args"}, "target_tag": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Target Tag"}}, "type": "object", "required": ["group_id", "name", "source"], "title": "BuildCreate"}, "BuildStatusEvent": {"properties": {"phase": {"type": "string", "title": "Phase"}, "image_ref": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Image Ref"}, "error": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Error"}, "log_tail": {"anyOf": [{"type": "string", "maxLength": 32768}, {"type": "null"}], "title": "Log Tail"}}, "type": "object", "required": ["phase"], "title": "BuildStatusEvent"}, "BulkAllocateBody": {"properties": {"group_id": {"type": "string", "title": "Group Id"}, "amount": {"anyOf": [{"type": "number", "maximum": 1000000000000.0, "exclusiveMinimum": 0.0}, {"type": "string", "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$"}], "title": "Amount"}, "reason": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Reason"}}, "type": "object", "required": ["group_id", "amount"], "title": "BulkAllocateBody"}, "BulkMonthlyGrantBody": {"properties": {"group_id": {"type": "string", "title": "Group Id"}, "amount": {"anyOf": [{"type": "number", "maximum": 1000000000000.0, "minimum": 0.0}, {"type": "string", "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$"}], "title": "Amount"}}, "type": "object", "required": ["group_id", "amount"], "title": "BulkMonthlyGrantBody"}, "BulkTerminateRequest": {"properties": {"session_ids": {"items": {"type": "string"}, "type": "array", "title": "Session Ids"}, "reason": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Reason"}}, "type": "object", "required": ["session_ids"], "title": "BulkTerminateRequest"}, "BulkUserCreate": {"properties": {"group_id": {"type": "string", "title": "Group Id"}, "initial_role": {"type": "string", "title": "Initial Role", "default": "member"}, "rows": {"items": {"$ref": "#/components/schemas/BulkUserRow"}, "type": "array", "maxItems": 200, "minItems": 1, "title": "Rows"}}, "type": "object", "required": ["group_id", "rows"], "title": "BulkUserCreate"}, "BulkUserRow": {"properties": {"email": {"type": "string", "maxLength": 254, "minLength": 3, "title": "Email"}, "name": {"type": "string", "maxLength": 100, "minLength": 1, "title": "Name"}}, "type": "object", "required": ["email", "name"], "title": "BulkUserRow"}, "ChangePasswordRequest": {"properties": {"current_password": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Current Password"}, "new_password": {"type": "string", "maxLength": 128, "minLength": 8, "title": "New Password"}}, "type": "object", "required": ["new_password"], "title": "ChangePasswordRequest"}, "ClusterCompute": {"properties": {"cpu": {"$ref": "#/components/schemas/ClusterResource"}, "mem_gb": {"$ref": "#/components/schemas/ClusterResource"}, "disk_gb": {"$ref": "#/components/schemas/ClusterResource"}}, "type": "object", "required": ["cpu", "mem_gb", "disk_gb"], "title": "ClusterCompute"}, "ClusterCredit": {"properties": {"consumed_last_24h": {"type": "string", "title": "Consumed Last 24H"}, "active_holds": {"type": "string", "title": "Active Holds"}}, "type": "object", "required": ["consumed_last_24h", "active_holds"], "title": "ClusterCredit"}, "ClusterGpu": {"properties": {"device_total": {"type": "integer", "title": "Device Total"}, "vram_total_mb": {"type": "integer", "title": "Vram Total Mb"}, "vram_used_mb": {"type": "integer", "title": "Vram Used Mb"}, "vram_load_pct": {"type": "number", "title": "Vram Load Pct"}, "avg_utilization_pct": {"type": "number", "title": "Avg Utilization Pct"}, "empty_gpu_count": {"type": "integer", "title": "Empty Gpu Count"}}, "type": "object", "required": ["device_total", "vram_total_mb", "vram_used_mb", "vram_load_pct", "avg_utilization_pct", "empty_gpu_count"], "title": "ClusterGpu"}, "ClusterList": {"properties": {"data": {"items": {"$ref": "#/components/schemas/ClusterRead"}, "type": "array", "title": "Data"}, "pagination": {"$ref": "#/components/schemas/PageMeta"}}, "type": "object", "required": ["data", "pagination"], "title": "ClusterList", "description": "GET /clusters envelope."}, "ClusterMetrics": {"properties": {"as_of": {"type": "string", "title": "As Of"}, "nodes": {"$ref": "#/components/schemas/ClusterNodes"}, "gpu": {"$ref": "#/components/schemas/ClusterGpu"}, "sessions": {"$ref": "#/components/schemas/ClusterSessions"}, "credit": {"$ref": "#/components/schemas/ClusterCredit"}, "compute": {"$ref": "#/components/schemas/ClusterCompute"}, "storage": {"anyOf": [{"$ref": "#/components/schemas/ClusterStorage"}, {"type": "null"}]}}, "type": "object", "required": ["as_of", "nodes", "gpu", "sessions", "credit", "compute"], "title": "ClusterMetrics"}, "ClusterNodes": {"properties": {"total": {"type": "integer", "title": "Total"}, "ready": {"type": "integer", "title": "Ready"}, "busy": {"type": "integer", "title": "Busy"}, "cordoned": {"type": "integer", "title": "Cordoned"}, "offline": {"type": "integer", "title": "Offline"}}, "type": "object", "required": ["total", "ready", "busy", "cordoned", "offline"], "title": "ClusterNodes"}, "ClusterPatch": {"properties": {"name": {"anyOf": [{"type": "string", "maxLength": 80, "minLength": 1}, {"type": "null"}], "title": "Name"}, "role": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Role"}, "session_domain": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Session Domain"}}, "type": "object", "title": "ClusterPatch"}, "ClusterRead": {"properties": {"id": {"type": "string", "title": "Id"}, "name": {"type": "string", "title": "Name"}, "role": {"type": "string", "title": "Role"}, "api_server": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Api Server"}, "runtime": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Runtime"}, "session_domain": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Session Domain"}, "status": {"type": "string", "title": "Status"}, "node_count": {"type": "integer", "title": "Node Count"}, "gpu_count": {"type": "integer", "title": "Gpu Count"}, "registered_at": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Registered At"}}, "type": "object", "required": ["id", "name", "role", "status", "node_count", "gpu_count"], "title": "ClusterRead", "description": "One cluster as projected by ``_serialize_cluster`` (list rows)."}, "ClusterRegister": {"properties": {"name": {"type": "string", "maxLength": 80, "minLength": 1, "title": "Name"}, "role": {"type": "string", "title": "Role"}, "kubeconfig_b64": {"type": "string", "title": "Kubeconfig B64"}, "session_domain": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Session Domain"}}, "type": "object", "required": ["name", "role", "kubeconfig_b64"], "title": "ClusterRegister"}, "ClusterResource": {"properties": {"used": {"type": "integer", "title": "Used"}, "total": {"type": "integer", "title": "Total"}}, "type": "object", "required": ["used", "total"], "title": "ClusterResource", "description": "One host-compute dimension fleet-wide: promised to active sessions vs node capacity."}, "ClusterSessions": {"properties": {"running": {"type": "integer", "title": "Running"}, "queued": {"type": "integer", "title": "Queued"}}, "type": "object", "required": ["running", "queued"], "title": "ClusterSessions"}, "ClusterStorage": {"properties": {"disk_gb": {"$ref": "#/components/schemas/ClusterStorageDisk"}, "node_count": {"type": "integer", "title": "Node Count", "default": 0}, "capacity_gb": {"type": "integer", "title": "Capacity Gb", "default": 0}, "unplaced_gb": {"type": "integer", "title": "Unplaced Gb", "default": 0}, "shared": {"type": "boolean", "title": "Shared", "default": false}, "pools": {"items": {"$ref": "#/components/schemas/ClusterStoragePool"}, "type": "array", "title": "Pools", "default": []}}, "type": "object", "required": ["disk_gb"], "title": "ClusterStorage"}, "ClusterStorageDisk": {"properties": {"used": {"type": "integer", "title": "Used"}, "total": {"type": "integer", "title": "Total"}, "source": {"type": "string", "title": "Source", "default": "node_disk"}}, "type": "object", "required": ["used", "total"], "title": "ClusterStorageDisk"}, "ClusterStoragePool": {"properties": {"id": {"type": "string", "title": "Id"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Name"}, "hostname": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Hostname"}, "cluster_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cluster Id"}, "cluster_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cluster Name"}, "storage_class": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Storage Class"}, "share_scope": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Share Scope"}, "capacity_gb": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Capacity Gb"}, "capacity_source": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Capacity Source"}, "capacity_reported_at": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Capacity Reported At"}, "used_gb": {"type": "integer", "title": "Used Gb", "default": 0}}, "type": "object", "required": ["id"], "title": "ClusterStoragePool", "description": "One registered volume-backing pool: what it is, where it sits, and who may use it."}, "ConnectionInfo": {"properties": {"kind": {"type": "string", "title": "Kind"}, "url": {"type": "string", "title": "Url"}, "connection_token": {"type": "string", "title": "Connection Token"}, "expires_at": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Expires At"}, "command": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Command"}}, "type": "object", "required": ["kind", "url", "connection_token"], "title": "ConnectionInfo"}, "DashboardAllocation": {"properties": {"instances": {"$ref": "#/components/schemas/DashboardInstances"}, "vram": {"$ref": "#/components/schemas/MyVram"}, "gpu_cores": {"anyOf": [{"$ref": "#/components/schemas/ResourceUsage"}, {"type": "null"}]}}, "type": "object", "required": ["instances", "vram"], "title": "DashboardAllocation"}, "DashboardCompute": {"properties": {"cpu": {"$ref": "#/components/schemas/ResourceUsage"}, "mem_gb": {"$ref": "#/components/schemas/ResourceUsage"}, "disk_gb": {"$ref": "#/components/schemas/ResourceUsage"}}, "type": "object", "required": ["cpu", "mem_gb", "disk_gb"], "title": "DashboardCompute", "description": "Host compute held by the caller's active sessions \u2014 separate from GPU/credits, since\ncpu/mem/disk are quota-governed, not billed."}, "DashboardCredit": {"properties": {"available": {"anyOf": [{"type": "number"}, {"type": "null"}], "title": "Available"}, "balance": {"anyOf": [{"type": "number"}, {"type": "null"}], "title": "Balance"}, "reserved": {"anyOf": [{"type": "number"}, {"type": "null"}], "title": "Reserved"}}, "type": "object", "title": "DashboardCredit", "description": "Owner wallet snapshot; all None when the user has no wallet."}, "DashboardInstances": {"properties": {"used": {"type": "integer", "title": "Used"}, "total": {"type": "integer", "title": "Total"}}, "type": "object", "required": ["used", "total"], "title": "DashboardInstances"}, "DashboardPool": {"properties": {"id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Id"}, "name": {"type": "string", "title": "Name"}, "kind": {"type": "string", "title": "Kind"}, "tier": {"type": "string", "title": "Tier"}}, "type": "object", "required": ["name", "kind", "tier"], "title": "DashboardPool", "description": "A node pool the caller may place on. `id` is None for the unassigned (\"shared\") bucket."}, "DashboardRegion": {"properties": {"model": {"type": "string", "title": "Model"}, "total": {"type": "integer", "title": "Total"}, "free": {"type": "integer", "title": "Free"}, "free_mb": {"type": "integer", "title": "Free Mb", "default": 0}, "total_mb": {"type": "integer", "title": "Total Mb", "default": 0}}, "type": "object", "required": ["model", "total", "free"], "title": "DashboardRegion", "description": "Per-GPU-model availability bucket.\n\n`free`/`total` count whole cards, which under fractional sharing understates what is usable: a\ncard holding one small slice is not \"free\" yet can still host several more sessions. The VRAM\nfigures are what actually decide whether a session starts, so both are reported."}, "DashboardSessions": {"properties": {"running": {"type": "integer", "title": "Running"}, "active": {"type": "integer", "title": "Active"}}, "type": "object", "required": ["running", "active"], "title": "DashboardSessions"}, "DashboardSummary": {"properties": {"credit": {"$ref": "#/components/schemas/DashboardCredit"}, "sessions": {"$ref": "#/components/schemas/DashboardSessions"}, "vram": {"$ref": "#/components/schemas/DashboardVram"}, "regions": {"items": {"$ref": "#/components/schemas/DashboardRegion"}, "type": "array", "title": "Regions"}, "pools": {"items": {"$ref": "#/components/schemas/DashboardPool"}, "type": "array", "title": "Pools", "default": []}, "allocation": {"$ref": "#/components/schemas/DashboardAllocation"}, "compute": {"$ref": "#/components/schemas/DashboardCompute"}, "storage": {"anyOf": [{"additionalProperties": true, "type": "object"}, {"type": "null"}], "title": "Storage"}}, "type": "object", "required": ["credit", "sessions", "vram", "regions", "allocation", "compute"], "title": "DashboardSummary", "description": "GET /dashboard/summary aggregate."}, "DashboardVram": {"properties": {"used_mb": {"type": "integer", "title": "Used Mb"}, "total_mb": {"type": "integer", "title": "Total Mb"}}, "type": "object", "required": ["used_mb", "total_mb"], "title": "DashboardVram"}, "ForceTerminateBody": {"properties": {"reason": {"anyOf": [{"type": "string", "maxLength": 500}, {"type": "null"}], "title": "Reason"}}, "type": "object", "title": "ForceTerminateBody", "description": "Admin's justification \u2014 recorded verbatim in the audit log (the console requires it)."}, "GlobalRoleSet": {"properties": {"global_roles": {"anyOf": [{"items": {"type": "string"}, "type": "array"}, {"type": "null"}], "title": "Global Roles"}, "global_role": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Global Role"}}, "type": "object", "title": "GlobalRoleSet"}, "GpuDeviceList": {"properties": {"data": {"items": {"$ref": "#/components/schemas/GpuDeviceRow"}, "type": "array", "title": "Data"}, "total": {"type": "integer", "title": "Total"}}, "type": "object", "required": ["data", "total"], "title": "GpuDeviceList"}, "GpuDeviceModeSet": {"properties": {"desired_mode": {"anyOf": [{"type": "string", "pattern": "^(fractional|exclusive|mig)$"}, {"type": "null"}], "title": "Desired Mode"}}, "type": "object", "title": "GpuDeviceModeSet"}, "GpuDeviceRow": {"properties": {"id": {"type": "string", "title": "Id"}, "node_id": {"type": "string", "title": "Node Id"}, "model": {"type": "string", "title": "Model"}, "alias": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Alias"}, "mode": {"type": "string", "title": "Mode"}, "desired_mode": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Desired Mode"}, "mode_state": {"type": "string", "title": "Mode State", "default": "ready"}, "status": {"type": "string", "title": "Status"}, "node_status": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Node Status"}, "gpu_uuid": {"type": "string", "title": "Gpu Uuid"}, "total_mem_mb": {"type": "integer", "title": "Total Mem Mb"}, "used_mem_mb": {"type": "integer", "title": "Used Mem Mb"}, "free_mem_mb": {"type": "integer", "title": "Free Mem Mb"}, "total_cores": {"type": "integer", "title": "Total Cores"}, "used_cores": {"type": "integer", "title": "Used Cores"}, "free_cores": {"type": "integer", "title": "Free Cores"}, "bound_sessions": {"items": {"$ref": "#/components/schemas/BoundSession"}, "type": "array", "title": "Bound Sessions"}}, "type": "object", "required": ["id", "node_id", "model", "mode", "status", "gpu_uuid", "total_mem_mb", "used_mem_mb", "free_mem_mb", "total_cores", "used_cores", "free_cores", "bound_sessions"], "title": "GpuDeviceRow"}, "HTTPValidationError": {"properties": {"detail": {"items": {"$ref": "#/components/schemas/ValidationError"}, "type": "array", "title": "Detail"}}, "type": "object", "title": "HTTPValidationError"}, "ImageBuildList": {"properties": {"data": {"items": {"$ref": "#/components/schemas/ImageBuildRead"}, "type": "array", "title": "Data"}, "pagination": {"$ref": "#/components/schemas/PageMeta"}}, "type": "object", "required": ["data", "pagination"], "title": "ImageBuildList", "description": "GET /image-builds envelope."}, "ImageBuildRead": {"properties": {"id": {"type": "string", "title": "Id"}, "group_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Group Id"}, "owner_user_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Owner User Id"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Name"}, "source": {"type": "string", "title": "Source"}, "status": {"type": "string", "title": "Status"}, "error": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Error"}, "image_ref": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Image Ref"}, "image_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Image Id"}, "created_at": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Created At"}, "started_at": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Started At"}, "finished_at": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Finished At"}}, "type": "object", "required": ["id", "source", "status"], "title": "ImageBuildRead", "description": "One build row as projected by ``_serialize_build``."}, "ImageCreate": {"properties": {"name": {"type": "string", "maxLength": 120, "minLength": 1, "title": "Name"}, "registry": {"type": "string", "title": "Registry"}, "kind": {"type": "string", "title": "Kind"}, "tags": {"additionalProperties": true, "type": "object", "title": "Tags"}, "supported_gpus": {"items": {"type": "string"}, "type": "array", "title": "Supported Gpus"}, "cuda_version": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cuda Version"}, "gpu_ready": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Gpu Ready"}}, "type": "object", "required": ["name", "registry", "kind"], "title": "ImageCreate"}, "ImageImport": {"properties": {"source_type": {"type": "string", "title": "Source Type"}, "source": {"type": "string", "title": "Source"}, "name": {"type": "string", "maxLength": 120, "minLength": 1, "title": "Name"}, "kind": {"type": "string", "title": "Kind"}, "registry_auth": {"anyOf": [{"additionalProperties": true, "type": "object"}, {"type": "null"}], "title": "Registry Auth"}, "tags": {"additionalProperties": true, "type": "object", "title": "Tags"}, "supported_gpus": {"items": {"type": "string"}, "type": "array", "title": "Supported Gpus"}, "cuda_version": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cuda Version"}, "gpu_ready": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Gpu Ready"}}, "type": "object", "required": ["source_type", "source", "name", "kind"], "title": "ImageImport"}, "ImageList": {"properties": {"data": {"items": {"$ref": "#/components/schemas/ImageRead"}, "type": "array", "title": "Data"}, "pagination": {"$ref": "#/components/schemas/PageMeta"}}, "type": "object", "required": ["data", "pagination"], "title": "ImageList", "description": "GET /images envelope."}, "ImageRead": {"properties": {"id": {"type": "string", "title": "Id"}, "name": {"type": "string", "title": "Name"}, "registry": {"type": "string", "title": "Registry"}, "kind": {"type": "string", "title": "Kind"}, "tags": {"additionalProperties": true, "type": "object", "title": "Tags"}, "supported_gpus": {"items": {}, "type": "array", "title": "Supported Gpus"}, "cuda_version": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cuda Version"}, "gpu_ready": {"type": "boolean", "title": "Gpu Ready", "default": false}, "public": {"type": "boolean", "title": "Public", "default": true}, "owner_user_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Owner User Id"}, "created_at": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Created At"}, "import_status": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Import Status"}}, "type": "object", "required": ["id", "name", "registry", "kind", "tags", "supported_gpus"], "title": "ImageRead", "description": "One catalog image as projected by ``_serialize_image``.\n\n``import_status`` is only present for imported/registered rows, so it is Optional."}, "ImageUpdate": {"properties": {"name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Name"}, "public": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Public"}, "registry": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Registry"}, "cuda_version": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cuda Version"}, "supported_gpus": {"anyOf": [{"items": {"type": "string"}, "type": "array"}, {"type": "null"}], "title": "Supported Gpus"}, "gpu_ready": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Gpu Ready"}}, "type": "object", "title": "ImageUpdate"}, "MeMembership": {"properties": {"group_id": {"type": "string", "title": "Group Id"}, "project_name": {"type": "string", "title": "Project Name"}, "org_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Org Id"}, "org_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Org Name"}, "role": {"type": "string", "title": "Role"}, "has_group_admin": {"type": "boolean", "title": "Has Group Admin", "default": true}}, "type": "object", "required": ["group_id", "project_name", "role"], "title": "MeMembership", "description": "One membership row resolved for the context switcher / RBAC."}, "MeResponse": {"properties": {"user_id": {"type": "string", "title": "User Id"}, "global_role": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Global Role"}, "global_roles": {"items": {"type": "string"}, "type": "array", "title": "Global Roles", "default": []}, "org_admin_orgs": {"items": {"type": "string"}, "type": "array", "title": "Org Admin Orgs", "default": []}, "memberships": {"items": {"$ref": "#/components/schemas/MeMembership"}, "type": "array", "title": "Memberships"}, "email": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Email"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Name"}, "must_change_password": {"type": "boolean", "title": "Must Change Password"}}, "type": "object", "required": ["user_id", "memberships", "must_change_password"], "title": "MeResponse", "description": "Identity/role/memberships of the current principal (GET /auth/me)."}, "MembershipCreate": {"properties": {"user_id": {"type": "string", "title": "User Id"}, "role": {"type": "string", "title": "Role"}, "expires_at": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Expires At"}, "grant_credit": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Grant Credit"}}, "type": "object", "required": ["user_id", "role"], "title": "MembershipCreate"}, "MembershipPatch": {"properties": {"role": {"type": "string", "title": "Role"}}, "type": "object", "required": ["role"], "title": "MembershipPatch"}, "MonthlyGrantBody": {"properties": {"amount": {"anyOf": [{"type": "number", "maximum": 1000000000000.0, "minimum": 0.0}, {"type": "string", "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$"}], "title": "Amount"}}, "type": "object", "required": ["amount"], "title": "MonthlyGrantBody"}, "MyVram": {"properties": {"used_mb": {"type": "integer", "title": "Used Mb"}, "limit_mb": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Limit Mb"}}, "type": "object", "required": ["used_mb"], "title": "MyVram", "description": "The caller's own VRAM footprint vs their policy limit (None = unlimited)."}, "NodeList": {"properties": {"data": {"items": {"$ref": "#/components/schemas/NodeRow"}, "type": "array", "title": "Data"}, "total": {"type": "integer", "title": "Total"}}, "type": "object", "required": ["data", "total"], "title": "NodeList"}, "NodePoolSet": {"properties": {"pool_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Pool Id"}}, "type": "object", "title": "NodePoolSet"}, "NodeRow": {"properties": {"id": {"type": "string", "title": "Id"}, "hostname": {"type": "string", "title": "Hostname"}, "cluster_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cluster Id"}, "cluster_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cluster Name"}, "status": {"type": "string", "title": "Status"}, "cpu": {"type": "integer", "title": "Cpu"}, "mem_gb": {"type": "integer", "title": "Mem Gb"}, "disk_gb": {"type": "integer", "title": "Disk Gb", "default": 0}, "role": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Role"}, "region": {"type": "string", "title": "Region"}, "gpu_mode": {"type": "string", "title": "Gpu Mode"}, "mode_counts": {"additionalProperties": {"type": "integer"}, "type": "object", "title": "Mode Counts", "default": {}}, "device_count": {"type": "integer", "title": "Device Count"}, "alloc_cpu": {"type": "integer", "title": "Alloc Cpu", "default": 0}, "alloc_mem_gb": {"type": "integer", "title": "Alloc Mem Gb", "default": 0}, "alloc_disk_gb": {"type": "integer", "title": "Alloc Disk Gb", "default": 0}, "running_sessions": {"type": "integer", "title": "Running Sessions", "default": 0}, "heartbeat_at": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Heartbeat At"}, "pool_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Pool Id"}, "pool_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Pool Name"}}, "type": "object", "required": ["id", "hostname", "status", "cpu", "mem_gb", "region", "gpu_mode", "device_count"], "title": "NodeRow"}, "OfferingCreate": {"properties": {"name": {"type": "string", "title": "Name"}, "resource_class": {"type": "string", "title": "Resource Class", "default": "gpu"}, "gpu_model": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Gpu Model"}, "gpu_mem_mb": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Gpu Mem Mb"}, "gpu_cores": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Gpu Cores"}, "cpu": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Cpu"}, "mem_gb": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Mem Gb"}, "disk_gb": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Disk Gb"}, "credit_per_hour": {"type": "string", "title": "Credit Per Hour"}, "status": {"type": "string", "title": "Status", "default": "active"}, "min_cuda": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Min Cuda"}}, "type": "object", "required": ["name", "credit_per_hour"], "title": "OfferingCreate"}, "OfferingUpdate": {"properties": {"name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Name"}, "gpu_model": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Gpu Model"}, "gpu_mem_mb": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Gpu Mem Mb"}, "gpu_cores": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Gpu Cores"}, "cpu": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Cpu"}, "mem_gb": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Mem Gb"}, "disk_gb": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Disk Gb"}, "credit_per_hour": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Credit Per Hour"}, "status": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Status"}, "min_cuda": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Min Cuda"}}, "type": "object", "title": "OfferingUpdate"}, "OperatorAuditEvent": {"properties": {"actor": {"type": "string", "title": "Actor"}, "action": {"type": "string", "title": "Action"}, "target": {"type": "string", "title": "Target"}, "result": {"type": "string", "title": "Result"}, "detail": {"anyOf": [{"additionalProperties": true, "type": "object"}, {"type": "null"}], "title": "Detail"}, "trace_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Trace Id"}, "ts": {"type": "string", "format": "date-time", "title": "Ts"}}, "type": "object", "required": ["actor", "action", "target", "result", "ts"], "title": "OperatorAuditEvent", "description": "Operator privileged-action audit callback."}, "OperatorGpuDeviceUpsert": {"properties": {"node_id": {"type": "string", "title": "Node Id"}, "uuid": {"type": "string", "title": "Uuid"}, "mode": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Mode"}, "total_mem_mb": {"type": "integer", "title": "Total Mem Mb", "default": 0}, "used_mem_mb": {"type": "integer", "title": "Used Mem Mb", "default": 0}, "total_cores": {"type": "integer", "title": "Total Cores", "default": 0}, "used_cores": {"type": "integer", "title": "Used Cores", "default": 0}, "status": {"type": "string", "title": "Status", "default": "ready"}, "model": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Model"}, "cluster_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cluster Id"}, "node_cpu": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Node Cpu"}, "node_ready": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Node Ready"}, "node_mem_gb": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Node Mem Gb"}, "node_disk_gb": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Node Disk Gb"}}, "type": "object", "required": ["node_id", "uuid"], "title": "OperatorGpuDeviceUpsert", "description": "GPU device inventory upsert (operator InventoryReconciler -> ledger).\n\nPure reflection of measured device-plugin/DCGM capacity. The control plane preserves\nledger-owned occupancy (used_mem_mb/used_cores) \u2014 those are NOT overwritten from here."}, "OperatorNodeHealthEvent": {"properties": {"node_id": {"type": "string", "title": "Node Id"}, "kind": {"type": "string", "title": "Kind"}, "severity": {"type": "string", "title": "Severity", "default": "critical"}, "action": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Action"}, "gpu_uuid": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Gpu Uuid"}, "message": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Message"}, "cluster_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cluster Id"}, "id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Id"}}, "type": "object", "required": ["node_id", "kind"], "title": "OperatorNodeHealthEvent", "description": "Node health transition (operator HealthReconciler -> ledger).\n\nOn a critical breach (e.g. fatal Xid) the operator cordons the node and reports this; the\ncontrol plane records a NodeHealthEvent and marks GpuNode.status=cordoned."}, "OperatorNodeUpsert": {"properties": {"node_id": {"type": "string", "title": "Node Id"}, "cluster_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cluster Id"}, "node_cpu": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Node Cpu"}, "node_ready": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Node Ready"}, "node_mem_gb": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Node Mem Gb"}, "node_disk_gb": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Node Disk Gb"}, "lossless_capable": {"type": "boolean", "title": "Lossless Capable", "default": false}, "role": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Role"}}, "type": "object", "required": ["node_id"], "title": "OperatorNodeUpsert", "description": "Upsert node inventory, reporting capacity for every node whether or not it has a GPU.\n\nGPU-less nodes \u2014 CPU workers and the control plane \u2014 still contribute their cpu, mem, and disk\nto the displayed inventory and the capacity calculation. "}, "OperatorPoolCapacity": {"properties": {"storage_class": {"type": "string", "title": "Storage Class"}, "capacity_bytes": {"type": "integer", "minimum": 0.0, "title": "Capacity Bytes"}}, "type": "object", "required": ["storage_class", "capacity_bytes"], "title": "OperatorPoolCapacity", "description": "What the CSI driver says a StorageClass's backing pool holds.\n\nRead from the CSIStorageCapacity objects the external-provisioner publishes. The control plane\ncannot ask a CSI driver anything \u2014 it never touches the workload API \u2014 and the only figure it\ncould see on its own is the storage node's root disk, which is not the pool."}, "OperatorSessionDisk": {"properties": {"name": {"type": "string", "title": "Name"}, "ephemeral_used_bytes": {"type": "integer", "title": "Ephemeral Used Bytes"}, "ephemeral_limit_bytes": {"type": "integer", "title": "Ephemeral Limit Bytes"}}, "type": "object", "required": ["name", "ephemeral_used_bytes", "ephemeral_limit_bytes"], "title": "OperatorSessionDisk", "description": "Ephemeral (scratch) disk usage of one session pod, read from kubelet /stats/summary.\n\n``name`` is the GShareSession CR name \u2014 the sanitized session id (``ses-01abc...``)."}, "OperatorStatusEvent": {"properties": {"phase": {"type": "string", "title": "Phase"}, "bound_gpu_uuid": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Bound Gpu Uuid"}, "node_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Node Name"}, "yield_state": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Yield State"}, "pod_ref": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Pod Ref"}, "used_mem_mb": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Used Mem Mb"}, "message": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Message"}, "trace_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Trace Id"}, "restart_count": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Restart Count"}, "generation": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Generation"}, "container_state": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Container State"}, "cluster_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cluster Id"}, "ts": {"type": "string", "format": "date-time", "title": "Ts"}}, "type": "object", "required": ["phase", "ts"], "title": "OperatorStatusEvent", "description": "Status callback payload. Drives consume/settle triggers."}, "OperatorVolumeObserved": {"properties": {"name": {"type": "string", "title": "Name"}, "volume_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Volume Id"}, "capacity_gb": {"type": "integer", "title": "Capacity Gb", "default": 0}, "used_bytes": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Used Bytes"}, "mounted": {"type": "boolean", "title": "Mounted", "default": false}, "storage_class": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Storage Class"}}, "type": "object", "required": ["name"], "title": "OperatorVolumeObserved", "description": "One PVC the operator sees in the session namespace (label gshare.io/volume)."}, "OperatorVolumeSync": {"properties": {"volumes": {"items": {"$ref": "#/components/schemas/OperatorVolumeObserved"}, "type": "array", "title": "Volumes"}, "cluster_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cluster Id"}, "sessions": {"items": {"$ref": "#/components/schemas/OperatorSessionDisk"}, "type": "array", "title": "Sessions", "default": []}, "pools": {"items": {"$ref": "#/components/schemas/OperatorPoolCapacity"}, "type": "array", "title": "Pools", "default": []}}, "type": "object", "required": ["volumes"], "title": "OperatorVolumeSync", "description": "POST /internal/volumes/sync \u2014 the operator's periodic view of every session-volume PVC."}, "OrgAdminCreate": {"properties": {"user_id": {"type": "string", "title": "User Id"}}, "type": "object", "required": ["user_id"], "title": "OrgAdminCreate"}, "OrgCreate": {"properties": {"name": {"type": "string", "maxLength": 80, "minLength": 1, "title": "Name"}, "status": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Status"}, "create_node_pool": {"type": "boolean", "title": "Create Node Pool", "default": false}}, "type": "object", "required": ["name"], "title": "OrgCreate"}, "OrgUpdate": {"properties": {"name": {"anyOf": [{"type": "string", "maxLength": 80}, {"type": "null"}], "title": "Name"}, "status": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Status"}}, "type": "object", "title": "OrgUpdate"}, "PageMeta": {"properties": {"page": {"type": "integer", "title": "Page"}, "size": {"type": "integer", "title": "Size"}, "total": {"type": "integer", "title": "Total"}, "total_pages": {"type": "integer", "title": "Total Pages"}}, "type": "object", "required": ["page", "size", "total", "total_pages"], "title": "PageMeta", "description": "Standard pagination envelope (page/size/total/total_pages)."}, "PageMetaNoPages": {"properties": {"page": {"type": "integer", "title": "Page"}, "size": {"type": "integer", "title": "Size"}, "total": {"type": "integer", "title": "Total"}}, "type": "object", "required": ["page", "size", "total"], "title": "PageMetaNoPages", "description": "Pagination envelope without ``total_pages`` (queue list)."}, "PermissionBody": {"properties": {"user_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "User Id"}, "email": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Email"}, "role": {"type": "string", "pattern": "^(owner|rw|ro)$", "title": "Role"}}, "type": "object", "required": ["role"], "title": "PermissionBody"}, "PlacementOut": {"properties": {"gpu_packing": {"type": "string", "title": "Gpu Packing"}}, "type": "object", "required": ["gpu_packing"], "title": "PlacementOut"}, "PlacementUpdate": {"properties": {"gpu_packing": {"type": "string", "pattern": "^(binpack|spread)$", "title": "Gpu Packing"}}, "type": "object", "required": ["gpu_packing"], "title": "PlacementUpdate"}, "PolicyCreate": {"properties": {"scope": {"type": "string", "title": "Scope"}, "scope_id": {"type": "string", "title": "Scope Id"}, "max_concurrent": {"type": "integer", "title": "Max Concurrent"}, "max_queued": {"type": "integer", "title": "Max Queued"}, "max_runtime_min": {"type": "integer", "title": "Max Runtime Min"}, "idle_timeout_sec": {"type": "integer", "title": "Idle Timeout Sec"}, "cpu_session_max_concurrent": {"type": "integer", "title": "Cpu Session Max Concurrent", "default": 1}, "cpu_session_max_runtime_min": {"type": "integer", "title": "Cpu Session Max Runtime Min", "default": 240}, "cpu_session_idle_timeout_sec": {"type": "integer", "title": "Cpu Session Idle Timeout Sec", "default": 1800}, "limits": {"additionalProperties": true, "type": "object", "title": "Limits", "default": {}}}, "type": "object", "required": ["scope", "scope_id", "max_concurrent", "max_queued", "max_runtime_min", "idle_timeout_sec"], "title": "PolicyCreate"}, "PolicyUpdate": {"properties": {"max_concurrent": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Max Concurrent"}, "max_queued": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Max Queued"}, "max_runtime_min": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Max Runtime Min"}, "idle_timeout_sec": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Idle Timeout Sec"}, "cpu_session_max_concurrent": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Cpu Session Max Concurrent"}, "cpu_session_max_runtime_min": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Cpu Session Max Runtime Min"}, "cpu_session_idle_timeout_sec": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Cpu Session Idle Timeout Sec"}, "limits": {"anyOf": [{"additionalProperties": true, "type": "object"}, {"type": "null"}], "title": "Limits"}}, "type": "object", "title": "PolicyUpdate"}, "PoolGrantCreate": {"properties": {"scope": {"type": "string", "pattern": "^(org|group)$", "title": "Scope"}, "scope_id": {"type": "string", "minLength": 1, "title": "Scope Id"}}, "type": "object", "required": ["scope", "scope_id"], "title": "PoolGrantCreate"}, "PoolGrantRead": {"properties": {"id": {"type": "string", "title": "Id"}, "scope": {"type": "string", "title": "Scope"}, "scope_id": {"type": "string", "title": "Scope Id"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Name"}, "created_at": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Created At"}}, "type": "object", "required": ["id", "scope", "scope_id"], "title": "PoolGrantRead"}, "PoolList": {"properties": {"data": {"items": {"$ref": "#/components/schemas/PoolRead"}, "type": "array", "title": "Data"}, "total": {"type": "integer", "title": "Total"}}, "type": "object", "required": ["data", "total"], "title": "PoolList"}, "PoolNodeRow": {"properties": {"id": {"type": "string", "title": "Id"}, "hostname": {"type": "string", "title": "Hostname"}, "status": {"type": "string", "title": "Status"}, "device_count": {"type": "integer", "title": "Device Count"}}, "type": "object", "required": ["id", "hostname", "status", "device_count"], "title": "PoolNodeRow"}, "PoolPatch": {"properties": {"name": {"anyOf": [{"type": "string", "maxLength": 80, "minLength": 1}, {"type": "null"}], "title": "Name"}, "storage_class": {"anyOf": [{"type": "string", "maxLength": 200, "minLength": 1}, {"type": "null"}], "title": "Storage Class"}, "node_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Node Id"}, "share_scope": {"anyOf": [{"type": "string", "pattern": "^(all|selected)$"}, {"type": "null"}], "title": "Share Scope"}, "shared_with": {"anyOf": [{"items": {"type": "string"}, "type": "array"}, {"type": "null"}], "title": "Shared With"}, "manual_capacity_gb": {"anyOf": [{"type": "integer", "minimum": 0.0}, {"type": "null"}], "title": "Manual Capacity Gb"}}, "type": "object", "title": "PoolPatch"}, "PoolRead": {"properties": {"id": {"type": "string", "title": "Id"}, "cluster_id": {"type": "string", "title": "Cluster Id"}, "cluster_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cluster Name"}, "name": {"type": "string", "title": "Name"}, "description": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Description"}, "kind": {"type": "string", "title": "Kind"}, "node_count": {"type": "integer", "title": "Node Count"}, "nodes": {"items": {"$ref": "#/components/schemas/PoolNodeRow"}, "type": "array", "title": "Nodes"}, "grants": {"items": {"$ref": "#/components/schemas/PoolGrantRead"}, "type": "array", "title": "Grants"}}, "type": "object", "required": ["id", "cluster_id", "name", "kind", "node_count", "nodes", "grants"], "title": "PoolRead"}, "PoolTargetsBody": {"properties": {"mig_cards": {"type": "integer", "minimum": 0.0, "title": "Mig Cards"}}, "type": "object", "required": ["mig_cards"], "title": "PoolTargetsBody"}, "PoolUpdate": {"properties": {"name": {"anyOf": [{"type": "string", "maxLength": 120, "minLength": 1}, {"type": "null"}], "title": "Name"}, "description": {"anyOf": [{"type": "string", "maxLength": 1000}, {"type": "null"}], "title": "Description"}, "kind": {"anyOf": [{"type": "string", "pattern": "^(shared|dedicated)$"}, {"type": "null"}], "title": "Kind"}}, "type": "object", "title": "PoolUpdate"}, "PresetCreate": {"properties": {"name": {"type": "string", "title": "Name"}, "kind": {"type": "string", "title": "Kind", "default": "gpu"}, "cpu": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Cpu"}, "mem_gb": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Mem Gb"}, "disk_gb": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Disk Gb"}, "gpu_frac": {"anyOf": [{"type": "number"}, {"type": "null"}], "title": "Gpu Frac"}, "gpu_cores": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Gpu Cores"}, "mode": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Mode"}}, "type": "object", "required": ["name"], "title": "PresetCreate"}, "PresetUpdate": {"properties": {"name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Name"}, "cpu": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Cpu"}, "mem_gb": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Mem Gb"}, "disk_gb": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Disk Gb"}, "gpu_frac": {"anyOf": [{"type": "number"}, {"type": "null"}], "title": "Gpu Frac"}, "gpu_cores": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Gpu Cores"}, "mode": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Mode"}}, "type": "object", "title": "PresetUpdate"}, "PreviewCostRequest": {"properties": {"name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Name"}, "offering_id": {"type": "string", "title": "Offering Id"}, "image_id": {"type": "string", "title": "Image Id"}, "resource_class": {"type": "string", "pattern": "^(gpu|cpu)$", "title": "Resource Class"}, "cluster_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cluster Id"}, "cluster_mode": {"type": "string", "pattern": "^(single|multi)$", "title": "Cluster Mode", "default": "single"}, "group_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Group Id"}, "gpu_mem_mb": {"anyOf": [{"type": "integer", "minimum": 0.0}, {"type": "null"}], "title": "Gpu Mem Mb"}, "gpu_cores": {"anyOf": [{"type": "integer", "maximum": 100.0, "minimum": 0.0}, {"type": "null"}], "title": "Gpu Cores"}, "cpu": {"anyOf": [{"type": "integer", "minimum": 0.0}, {"type": "null"}], "title": "Cpu"}, "mem_gb": {"anyOf": [{"type": "integer", "minimum": 0.0}, {"type": "null"}], "title": "Mem Gb"}, "disk_gb": {"anyOf": [{"type": "integer", "minimum": 0.0}, {"type": "null"}], "title": "Disk Gb"}, "mode": {"anyOf": [{"type": "string", "pattern": "^(fractional|exclusive)$"}, {"type": "null"}], "title": "Mode"}, "billing_wallet_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Billing Wallet Id"}, "preemptible": {"type": "boolean", "title": "Preemptible", "default": false}, "privileged": {"type": "boolean", "title": "Privileged", "default": false}, "priority": {"type": "integer", "minimum": 0.0, "title": "Priority", "default": 0}, "volume_mounts": {"items": {"$ref": "#/components/schemas/VolumeMountSpec"}, "type": "array", "title": "Volume Mounts", "default": []}}, "additionalProperties": false, "type": "object", "required": ["offering_id", "image_id", "resource_class"], "title": "PreviewCostRequest"}, "PreviewCostResponse": {"properties": {"estimated_credit_per_hour": {"type": "number", "title": "Estimated Credit Per Hour"}, "occupancy": {"type": "number", "title": "Occupancy"}, "hold_amount": {"type": "number", "title": "Hold Amount"}}, "type": "object", "required": ["estimated_credit_per_hour", "occupancy", "hold_amount"], "title": "PreviewCostResponse"}, "ProjectCreate": {"properties": {"org_id": {"type": "string", "title": "Org Id"}, "name": {"type": "string", "maxLength": 80, "minLength": 1, "title": "Name"}, "status": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Status"}, "create_project_wallet": {"type": "boolean", "title": "Create Project Wallet", "default": true}, "default_member_credit": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Default Member Credit"}, "create_node_pool": {"type": "boolean", "title": "Create Node Pool", "default": false}}, "type": "object", "required": ["org_id", "name"], "title": "ProjectCreate"}, "ProjectPatch": {"properties": {"name": {"anyOf": [{"type": "string", "maxLength": 80}, {"type": "null"}], "title": "Name"}, "status": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Status"}, "default_member_credit": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Default Member Credit"}}, "type": "object", "title": "ProjectPatch"}, "QueueEntryView": {"properties": {"id": {"type": "string", "title": "Id"}, "session_id": {"type": "string", "title": "Session Id"}, "priority": {"type": "integer", "title": "Priority"}, "status": {"type": "string", "title": "Status"}, "position": {"type": "integer", "title": "Position"}, "score": {"type": "number", "title": "Score"}, "session_req": {"additionalProperties": true, "type": "object", "title": "Session Req"}, "enqueued_at": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Enqueued At"}, "eta_minutes": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Eta Minutes"}, "session_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Session Name"}, "owner_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Owner Name"}, "gpu_model": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Gpu Model"}, "cluster_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cluster Id"}, "reason": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Reason"}}, "type": "object", "required": ["id", "session_id", "priority", "status", "position", "score", "session_req"], "title": "QueueEntryView", "description": "One queue entry as projected by ``_entry_view`` (status is always ``queued``)."}, "QueueList": {"properties": {"data": {"items": {"$ref": "#/components/schemas/QueueEntryView"}, "type": "array", "title": "Data"}, "pagination": {"$ref": "#/components/schemas/PageMetaNoPages"}}, "type": "object", "required": ["data", "pagination"], "title": "QueueList", "description": "GET /queue envelope (pagination without total_pages)."}, "QueueMineList": {"properties": {"data": {"items": {"$ref": "#/components/schemas/QueueEntryView"}, "type": "array", "title": "Data"}}, "type": "object", "required": ["data"], "title": "QueueMineList", "description": "GET /queue/mine envelope (data only, no pagination)."}, "QueuePriorityPatch": {"properties": {"priority": {"type": "integer", "minimum": 0.0, "title": "Priority"}}, "type": "object", "required": ["priority"], "title": "QueuePriorityPatch"}, "RefillScheduleBody": {"properties": {"day": {"type": "integer", "maximum": 28.0, "minimum": 1.0, "title": "Day"}, "hour": {"type": "integer", "maximum": 23.0, "minimum": 0.0, "title": "Hour"}}, "type": "object", "required": ["day", "hour"], "title": "RefillScheduleBody"}, "ResourceRequestCreate": {"properties": {"group_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Group Id"}, "cpu": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Cpu"}, "mem_gb": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Mem Gb"}, "storage_gb": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Storage Gb"}, "gpu_mem_mb": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Gpu Mem Mb"}, "gpu_cores": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Gpu Cores"}, "note": {"type": "string", "title": "Note"}}, "type": "object", "required": ["note"], "title": "ResourceRequestCreate"}, "ResourceUsage": {"properties": {"used": {"type": "integer", "title": "Used"}, "limit": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Limit"}}, "type": "object", "required": ["used"], "title": "ResourceUsage", "description": "One compute dimension: what the caller's active sessions hold vs the policy limit\n(None = no limit configured)."}, "SessionCreate": {"properties": {"name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Name"}, "offering_id": {"type": "string", "title": "Offering Id"}, "image_id": {"type": "string", "title": "Image Id"}, "resource_class": {"type": "string", "pattern": "^(gpu|cpu)$", "title": "Resource Class"}, "cluster_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cluster Id"}, "cluster_mode": {"type": "string", "pattern": "^(single|multi)$", "title": "Cluster Mode", "default": "single"}, "group_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Group Id"}, "gpu_mem_mb": {"anyOf": [{"type": "integer", "minimum": 0.0}, {"type": "null"}], "title": "Gpu Mem Mb"}, "gpu_cores": {"anyOf": [{"type": "integer", "maximum": 100.0, "minimum": 0.0}, {"type": "null"}], "title": "Gpu Cores"}, "cpu": {"anyOf": [{"type": "integer", "minimum": 0.0}, {"type": "null"}], "title": "Cpu"}, "mem_gb": {"anyOf": [{"type": "integer", "minimum": 0.0}, {"type": "null"}], "title": "Mem Gb"}, "disk_gb": {"anyOf": [{"type": "integer", "minimum": 0.0}, {"type": "null"}], "title": "Disk Gb"}, "mode": {"anyOf": [{"type": "string", "pattern": "^(fractional|exclusive)$"}, {"type": "null"}], "title": "Mode"}, "billing_wallet_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Billing Wallet Id"}, "preemptible": {"type": "boolean", "title": "Preemptible", "default": false}, "privileged": {"type": "boolean", "title": "Privileged", "default": false}, "priority": {"type": "integer", "minimum": 0.0, "title": "Priority", "default": 0}, "volume_mounts": {"items": {"$ref": "#/components/schemas/VolumeMountSpec"}, "type": "array", "title": "Volume Mounts", "default": []}}, "additionalProperties": false, "type": "object", "required": ["offering_id", "image_id", "resource_class"], "title": "SessionCreate"}, "SessionMountRead": {"properties": {"volume_id": {"type": "string", "title": "Volume Id"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Name"}, "type": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Type"}, "quota_gb": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Quota Gb"}, "mount_path": {"type": "string", "title": "Mount Path"}, "mode": {"type": "string", "title": "Mode"}}, "type": "object", "required": ["volume_id", "mount_path", "mode"], "title": "SessionMountRead", "description": "One mounted volume, joined with its display facts for the detail screens."}, "SessionRead": {"properties": {"id": {"type": "string", "title": "Id"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Name"}, "status": {"type": "string", "title": "Status"}, "status_reason": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Status Reason"}, "occupancy": {"anyOf": [{"type": "number"}, {"type": "null"}], "title": "Occupancy"}, "bound_gpu_uuid": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Bound Gpu Uuid"}, "node_hostname": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Node Hostname"}, "node_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Node Id"}, "cluster_id": {"type": "string", "title": "Cluster Id"}, "group_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Group Id"}, "group_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Group Name"}, "org_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Org Id"}, "org_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Org Name"}, "resource_class": {"type": "string", "title": "Resource Class"}, "mode": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Mode"}, "gpu_mem_mb": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Gpu Mem Mb"}, "gpu_cores": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Gpu Cores"}, "cpu": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Cpu"}, "mem_gb": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Mem Gb"}, "disk_gb": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Disk Gb"}, "gpu_model": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Gpu Model"}, "gpu_alias": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Gpu Alias"}, "image_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Image Name"}, "image_ref": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Image Ref"}, "disk_used_bytes": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Disk Used Bytes"}, "disk_limit_bytes": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Disk Limit Bytes"}, "mounts": {"items": {"$ref": "#/components/schemas/SessionMountRead"}, "type": "array", "title": "Mounts", "default": []}, "owner_user_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Owner User Id"}, "owner_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Owner Name"}, "credit_per_hour_snapshot": {"anyOf": [{"type": "number"}, {"type": "null"}], "title": "Credit Per Hour Snapshot"}, "started_at": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Started At"}, "terminated_at": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Terminated At"}, "usage_summary": {"anyOf": [{"additionalProperties": true, "type": "object"}, {"type": "null"}], "title": "Usage Summary"}, "credit_consumed": {"anyOf": [{"type": "number"}, {"type": "null"}], "title": "Credit Consumed"}, "queued_reason": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Queued Reason"}, "privileged": {"type": "boolean", "title": "Privileged", "default": false}, "created_at": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Created At"}, "status_changed_at": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Status Changed At"}}, "type": "object", "required": ["id", "status", "cluster_id", "resource_class"], "title": "SessionRead"}, "SignupPolicyOut": {"properties": {"mode": {"type": "string", "title": "Mode"}, "allowed_domains": {"items": {"type": "string"}, "type": "array", "title": "Allowed Domains", "default": []}}, "type": "object", "required": ["mode"], "title": "SignupPolicyOut"}, "SignupPolicyUpdate": {"properties": {"mode": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Mode"}, "allowed_domains": {"anyOf": [{"items": {"type": "string"}, "type": "array"}, {"type": "null"}], "title": "Allowed Domains"}}, "type": "object", "title": "SignupPolicyUpdate"}, "SpendDayRead": {"properties": {"date": {"type": "string", "title": "Date"}, "amount": {"type": "number", "title": "Amount"}}, "type": "object", "required": ["date", "amount"], "title": "SpendDayRead", "description": "One day of spend (consume + storage), for the wallet's usage chart."}, "TopupRejectBody": {"properties": {"reason": {"type": "string", "minLength": 1, "title": "Reason"}}, "type": "object", "required": ["reason"], "title": "TopupRejectBody"}, "TopupRequestBody": {"properties": {"amount": {"anyOf": [{"type": "number", "maximum": 1000000000000.0, "exclusiveMinimum": 0.0}, {"type": "string", "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$"}], "title": "Amount"}, "note": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Note"}, "wallet_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Wallet Id"}}, "type": "object", "required": ["amount"], "title": "TopupRequestBody"}, "TopupRequestListResponse": {"properties": {"data": {"items": {"$ref": "#/components/schemas/TopupRequestRead"}, "type": "array", "title": "Data"}}, "type": "object", "required": ["data"], "title": "TopupRequestListResponse"}, "TopupRequestRead": {"properties": {"id": {"type": "string", "title": "Id"}, "wallet_id": {"type": "string", "title": "Wallet Id"}, "amount": {"type": "string", "title": "Amount"}, "status": {"type": "string", "title": "Status"}, "requester_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Requester Id"}, "requester_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Requester Name"}, "note": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Note"}, "wallet_owner_type": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Wallet Owner Type"}, "wallet_owner_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Wallet Owner Name"}, "decided_reason": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Decided Reason"}, "decided_by": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Decided By"}, "created_at": {"type": "string", "title": "Created At"}}, "type": "object", "required": ["id", "wallet_id", "amount", "status", "created_at"], "title": "TopupRequestRead"}, "TransactionRead": {"properties": {"id": {"type": "string", "title": "Id"}, "type": {"type": "string", "title": "Type"}, "amount": {"type": "string", "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", "title": "Amount"}, "balance_after": {"type": "string", "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", "title": "Balance After"}, "ref": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Ref"}, "ref_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Ref Name"}, "created_at": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Created At"}, "entry_count": {"type": "integer", "title": "Entry Count", "default": 1}, "period_start": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Period Start"}, "period_end": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}], "title": "Period End"}, "settled": {"type": "boolean", "title": "Settled", "default": false}, "live": {"type": "boolean", "title": "Live", "default": false}}, "type": "object", "required": ["id", "type", "amount", "balance_after"], "title": "TransactionRead"}, "TransferBody": {"properties": {"to_wallet_id": {"type": "string", "title": "To Wallet Id"}, "amount": {"anyOf": [{"type": "number", "maximum": 1000000000000.0, "exclusiveMinimum": 0.0}, {"type": "string", "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$"}], "title": "Amount"}}, "type": "object", "required": ["to_wallet_id", "amount"], "title": "TransferBody"}, "UserApproveBody": {"properties": {"group_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Group Id"}, "initial_role": {"type": "string", "title": "Initial Role", "default": "member"}}, "type": "object", "title": "UserApproveBody", "description": "Department is optional: an administrator may let someone in and sort out the department\nlater, but doing both at once is the normal path."}, "UserCreate": {"properties": {"email": {"type": "string", "maxLength": 254, "minLength": 3, "title": "Email"}, "name": {"type": "string", "maxLength": 80, "minLength": 1, "title": "Name"}, "group_id": {"type": "string", "title": "Group Id"}, "status": {"type": "string", "title": "Status", "default": "active"}, "initial_role": {"type": "string", "title": "Initial Role", "default": "member"}, "password": {"type": "string", "maxLength": 128, "minLength": 8, "title": "Password"}}, "type": "object", "required": ["email", "name", "group_id", "password"], "title": "UserCreate"}, "UserDepartmentSet": {"properties": {"group_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Group Id"}}, "type": "object", "title": "UserDepartmentSet"}, "UserPatch": {"properties": {"name": {"anyOf": [{"type": "string", "maxLength": 80}, {"type": "null"}], "title": "Name"}, "status": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Status"}, "email": {"anyOf": [{"type": "string", "maxLength": 254, "minLength": 3}, {"type": "null"}], "title": "Email"}, "password": {"anyOf": [{"type": "string", "maxLength": 128, "minLength": 8}, {"type": "null"}], "title": "Password"}, "force_password_reset": {"type": "boolean", "title": "Force Password Reset", "default": false}}, "additionalProperties": false, "type": "object", "title": "UserPatch"}, "ValidationError": {"properties": {"loc": {"items": {"anyOf": [{"type": "string"}, {"type": "integer"}]}, "type": "array", "title": "Location"}, "msg": {"type": "string", "title": "Message"}, "type": {"type": "string", "title": "Error Type"}, "input": {"title": "Input"}, "ctx": {"type": "object", "title": "Context"}}, "type": "object", "required": ["loc", "msg", "type"], "title": "ValidationError"}, "VolumeCreate": {"properties": {"scope": {"type": "string", "pattern": "^(user|group|global)$", "title": "Scope"}, "scope_id": {"type": "string", "title": "Scope Id"}, "type": {"type": "string", "pattern": "^(home|group|dataset|scratch)$", "title": "Type"}, "name": {"type": "string", "maxLength": 80, "minLength": 1, "title": "Name"}, "access_mode": {"type": "string", "pattern": "^(RWO|RWX|ROX)$", "title": "Access Mode"}, "quota_gb": {"type": "integer", "minimum": 0.0, "title": "Quota Gb"}}, "type": "object", "required": ["scope", "scope_id", "type", "name", "access_mode", "quota_gb"], "title": "VolumeCreate"}, "VolumeMountSpec": {"properties": {"volume_id": {"type": "string", "title": "Volume Id"}, "mount_path": {"type": "string", "title": "Mount Path"}, "mode": {"type": "string", "pattern": "^(ro|rw)$", "title": "Mode", "default": "rw"}}, "additionalProperties": false, "type": "object", "required": ["volume_id", "mount_path"], "title": "VolumeMountSpec"}, "VolumePatch": {"properties": {"quota_gb": {"anyOf": [{"type": "integer", "minimum": 1.0}, {"type": "null"}], "title": "Quota Gb"}, "access_mode": {"anyOf": [{"type": "string", "pattern": "^(RWO|RWX|ROX)$"}, {"type": "null"}], "title": "Access Mode"}, "mount_locked": {"anyOf": [{"type": "boolean"}, {"type": "null"}], "title": "Mount Locked"}}, "additionalProperties": false, "type": "object", "title": "VolumePatch", "description": "Owner-side edits. The quota only grows (bounded above by the scope's storage policy on the\nserver); `mount_locked` blocks new mounts so the volume can be drained for deletion."}, "VolumeRead": {"properties": {"id": {"type": "string", "title": "Id"}, "scope": {"type": "string", "title": "Scope"}, "scope_id": {"type": "string", "title": "Scope Id"}, "type": {"type": "string", "title": "Type"}, "name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Name"}, "access_mode": {"type": "string", "title": "Access Mode"}, "quota_gb": {"type": "integer", "title": "Quota Gb"}, "used_gb": {"type": "integer", "title": "Used Gb"}, "mount_locked": {"type": "boolean", "title": "Mount Locked", "default": false}, "role": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Role"}, "owner_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Owner Id"}, "owner_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Owner Name"}, "shared_count": {"type": "integer", "title": "Shared Count", "default": 0}, "cluster_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cluster Id"}, "cluster_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cluster Name"}, "storage_class": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Storage Class"}, "pool_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Pool Id"}, "pool_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Pool Name"}, "active_mounts": {"anyOf": [{"items": {"additionalProperties": true, "type": "object"}, "type": "array"}, {"type": "null"}], "title": "Active Mounts"}}, "type": "object", "required": ["id", "scope", "scope_id", "type", "access_mode", "quota_gb", "used_gb"], "title": "VolumeRead"}, "VolumeSyncDirective": {"properties": {"name": {"type": "string", "title": "Name"}, "volume_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Volume Id"}, "quota_gb": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Quota Gb"}, "reclaim": {"type": "boolean", "title": "Reclaim", "default": false}}, "type": "object", "required": ["name"], "title": "VolumeSyncDirective", "description": "What the control plane wants for one observed PVC."}, "VolumeSyncResponse": {"properties": {"volumes": {"items": {"$ref": "#/components/schemas/VolumeSyncDirective"}, "type": "array", "title": "Volumes"}, "orphans": {"type": "integer", "title": "Orphans", "default": 0}}, "type": "object", "required": ["volumes"], "title": "VolumeSyncResponse"}, "WalletRead": {"properties": {"id": {"type": "string", "title": "Id"}, "owner_type": {"type": "string", "title": "Owner Type"}, "owner_id": {"type": "string", "title": "Owner Id"}, "balance": {"type": "string", "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", "title": "Balance"}, "reserved": {"type": "string", "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", "title": "Reserved"}, "monthly_grant": {"type": "string", "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", "title": "Monthly Grant", "default": "0"}, "owner_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Owner Name"}, "available": {"type": "string", "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", "title": "Available", "readOnly": true}}, "type": "object", "required": ["id", "owner_type", "owner_id", "balance", "reserved", "available"], "title": "WalletRead"}, "WebhookCreate": {"properties": {"url": {"type": "string", "title": "Url"}, "events": {"items": {"type": "string"}, "type": "array", "title": "Events"}, "secret": {"type": "string", "title": "Secret"}, "scope": {"anyOf": [{"additionalProperties": true, "type": "object"}, {"type": "null"}], "title": "Scope"}, "org_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Org Id"}}, "type": "object", "required": ["url", "events", "secret"], "title": "WebhookCreate"}, "_CordonBody": {"properties": {"cordon": {"type": "boolean", "title": "Cordon", "default": true}, "reason": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Reason"}}, "type": "object", "title": "_CordonBody"}, "_DeviceAliasBody": {"properties": {"alias": {"anyOf": [{"type": "string", "maxLength": 32}, {"type": "null"}], "title": "Alias"}}, "type": "object", "title": "_DeviceAliasBody"}, "_DeviceHealthBody": {"properties": {"status": {"type": "string", "enum": ["ready", "unhealthy"], "title": "Status"}, "reason": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Reason"}}, "type": "object", "required": ["status"], "title": "_DeviceHealthBody"}, "_DrainBody": {"properties": {"mode": {"type": "string", "pattern": "^(reschedule|force_terminate)$", "title": "Mode", "default": "reschedule"}, "reason": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Reason"}}, "type": "object", "title": "_DrainBody"}, "_LoginRequest": {"properties": {"email": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Email"}, "password": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Password"}}, "type": "object", "title": "_LoginRequest"}, "_NodeRegister": {"properties": {"hostname": {"type": "string", "maxLength": 120, "minLength": 1, "title": "Hostname"}, "region": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Region"}, "cpu": {"anyOf": [{"type": "integer", "minimum": 0.0}, {"type": "null"}], "title": "Cpu"}, "mem_gb": {"anyOf": [{"type": "integer", "minimum": 0.0}, {"type": "null"}], "title": "Mem Gb"}, "cluster_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Cluster Id"}}, "type": "object", "required": ["hostname"], "title": "_NodeRegister"}, "_RRReject": {"properties": {"reason": {"type": "string", "title": "Reason"}}, "type": "object", "required": ["reason"], "title": "_RRReject"}, "_SignupRequest": {"properties": {"email": {"type": "string", "maxLength": 254, "minLength": 3, "title": "Email"}, "name": {"type": "string", "maxLength": 100, "minLength": 1, "title": "Name"}, "password": {"type": "string", "maxLength": 128, "minLength": 8, "title": "Password"}}, "type": "object", "required": ["email", "name", "password"], "title": "_SignupRequest"}, "app__api__schemas__node_pool__PoolCreate": {"properties": {"cluster_id": {"type": "string", "title": "Cluster Id"}, "name": {"type": "string", "maxLength": 120, "minLength": 1, "title": "Name"}, "description": {"anyOf": [{"type": "string", "maxLength": 1000}, {"type": "null"}], "title": "Description"}, "kind": {"type": "string", "pattern": "^(shared|dedicated)$", "title": "Kind", "default": "dedicated"}}, "type": "object", "required": ["cluster_id", "name"], "title": "PoolCreate"}, "app__api__storage_pools_router__PoolCreate": {"properties": {"name": {"anyOf": [{"type": "string", "maxLength": 80, "minLength": 1}, {"type": "null"}], "title": "Name"}, "cluster_id": {"type": "string", "title": "Cluster Id"}, "storage_class": {"type": "string", "maxLength": 200, "minLength": 1, "title": "Storage Class"}, "node_id": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Node Id"}, "share_scope": {"type": "string", "pattern": "^(all|selected)$", "title": "Share Scope", "default": "all"}, "shared_with": {"items": {"type": "string"}, "type": "array", "title": "Shared With", "default": []}, "manual_capacity_gb": {"anyOf": [{"type": "integer", "minimum": 0.0}, {"type": "null"}], "title": "Manual Capacity Gb"}}, "type": "object", "required": ["cluster_id", "storage_class"], "title": "PoolCreate"}}}} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5b85691..25bd766 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -949,9 +949,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -999,9 +999,9 @@ } }, "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -1176,9 +1176,9 @@ "license": "MIT" }, "node_modules/@redocly/openapi-core": { - "version": "1.34.15", - "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.15.tgz", - "integrity": "sha512-HAwCnNyKcs5XGQqms+9t7OdAPM/5TDstmhF+0i7tdCFato2QKuYIlyWETwkXd8c5zbltr1oB+6y9NTeQLr2d6Q==", + "version": "1.34.20", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.20.tgz", + "integrity": "sha512-ypeBZ/6BKXR9+7/TtbKhbl4UgD7raHhPS12oknlKno2A8+lnFkxIwiE/Aklu6L2cd/ioH+fCWuMxi9/p3EyAPw==", "dev": true, "license": "MIT", "dependencies": { @@ -1187,7 +1187,7 @@ "colorette": "1.4.0", "https-proxy-agent": "7.0.6", "js-levenshtein": "1.1.6", - "js-yaml": "4.1.1", + "js-yaml": "4.3.2", "minimatch": "5.1.9", "pluralize": "8.0.0", "yaml-ast-parser": "0.0.43" @@ -1197,19 +1197,6 @@ "npm": ">=9.5.0" } }, - "node_modules/@redocly/openapi-core/node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/@redocly/openapi-core/node_modules/minimatch": { "version": "5.1.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", @@ -2307,9 +2294,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.34", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.34.tgz", - "integrity": "sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw==", + "version": "2.11.22", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.22.tgz", + "integrity": "sha512-pWc4w51fBFd7mav43/zKRC+RI6f4yfzQoVlfvE8dECePyfkn1bzLp01Fj0QACcyCZyFhiEMyD2qScfKRWgWibA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2333,9 +2320,9 @@ } }, "node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -2356,9 +2343,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", "dev": true, "funding": [ { @@ -2376,11 +2363,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" }, "bin": { "browserslist": "cli.js" @@ -2434,9 +2421,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001797", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz", - "integrity": "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "dev": true, "funding": [ { @@ -2804,9 +2791,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.369", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.369.tgz", - "integrity": "sha512-XM22K9FNaaCOvMMrBn1caIc8v0g6+pKt660ZbfQqUZvfil0hEzr8ZoiY7VcSLGM3L/x3rz5PqZrk+bKOOmVM9w==", + "version": "1.5.427", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.427.tgz", + "integrity": "sha512-n14zb3FdsChZ2BNobqNHAJMcP3ifFv4paox2LvCrfVAQcqGiSURgbJl+PfMpHVCNFkStnNc+RRVtPBTVW5PDgw==", "dev": true, "license": "ISC" }, @@ -3052,9 +3039,9 @@ } }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -3286,17 +3273,17 @@ "license": "ISC" }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -3433,9 +3420,9 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -3842,9 +3829,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, "funding": [ { @@ -4162,9 +4149,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", "dev": true, "funding": [ { @@ -4188,9 +4175,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.47", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", - "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "version": "2.0.55", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz", + "integrity": "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==", "dev": true, "license": "MIT", "engines": { @@ -4520,9 +4507,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", "dev": true, "funding": [ { @@ -4540,7 +4527,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -4662,9 +4649,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5518,9 +5505,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", "dev": true, "funding": [ { diff --git a/frontend/src/api/hooks/useImages.ts b/frontend/src/api/hooks/useImages.ts index 70ab6c1..b6d10be 100644 --- a/frontend/src/api/hooks/useImages.ts +++ b/frontend/src/api/hooks/useImages.ts @@ -1,7 +1,6 @@ import { fetchRestOfPages, pageTotal, PAGE_MAX } from '@/api/paging'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { api, idemKey } from '@/api/client'; -import type { components } from '@/api/schema'; +import { api } from '@/api/client'; // The image and template registry, plus image builds. // The list response envelope is { data, pagination }. @@ -127,32 +126,4 @@ export function useImageBuilds(filter: ImageBuildFilter = {}) { }); } -export interface CreateBuildBody { - group_id: string; - name: string; - source: 'dockerfile' | 'git'; - dockerfile?: string; - git_url?: string; - git_ref?: string; - context?: string; - build_args?: Record; - target_tag?: string; -} -// POST /image-builds — start a build from a Dockerfile or a git source; asynchronous, returns 202. -export function useCreateBuild() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async (body: CreateBuildBody) => { - // git_ref and context have backend defaults (main and .), so they are optional here and cast - // to satisfy the schema, which marks them required. - const { data } = await api.POST('/api/v1/image-builds', { - body: body as components['schemas']['BuildCreate'], - headers: { 'Idempotency-Key': idemKey() }, - }); - return data; - }, - onSuccess: () => - qc.invalidateQueries({ queryKey: ['image-builds'] }), - }); -} diff --git a/frontend/src/api/hooks/useStoragePools.ts b/frontend/src/api/hooks/useStoragePools.ts new file mode 100644 index 0000000..ee31af0 --- /dev/null +++ b/frontend/src/api/hooks/useStoragePools.ts @@ -0,0 +1,117 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { api, idemKey } from '@/api/client'; + +// Storage pools: the registered storage servers that back user volumes — one per (cluster, +// StorageClass). The capacity gate on volume creation and the dashboard's storage tile read these +// rows, never the node inventory, so a server only counts once an administrator has registered +// it. These endpoints use the loose accessor and type their envelopes locally. +const raw = api as unknown as { + GET: (path: string, init?: { params?: { query?: Record } }) => Promise<{ data?: unknown }>; + POST: (path: string, init?: { body?: unknown; headers?: Record }) => Promise<{ data?: unknown }>; + PATCH: (path: string, init?: { body?: unknown; params?: { path?: Record }; headers?: Record }) => Promise<{ data?: unknown }>; + DELETE: (path: string, init?: { params?: { path?: Record }; headers?: Record }) => Promise<{ data?: unknown }>; +}; + +export type PoolShareScope = 'all' | 'selected'; +/** Where the capacity figure came from, best source first: the CSI driver, the stated figure, the node's disk. */ +export type PoolCapacitySource = 'csi' | 'manual' | 'node_disk' | null; + +export interface StoragePool { + id: string; + name: string; + cluster_id: string; + cluster_name?: string | null; + node_id?: string | null; + node_hostname?: string | null; + storage_class: string; + share_scope: PoolShareScope; + shared_with: string[]; + shared_with_names: string[]; + capacity_gb?: number | null; + capacity_source?: PoolCapacitySource; + capacity_reported_at?: string | null; + manual_capacity_gb?: number | null; + created_at?: string | null; +} + +export interface StoragePoolCreateBody { + name: string; + cluster_id: string; + storage_class: string; + node_id?: string | null; + share_scope: PoolShareScope; + shared_with?: string[]; + manual_capacity_gb?: number | null; +} + +export type StoragePoolPatchBody = Partial>; + +export const storagePoolKeys = { + all: ['storage-pools'] as const, + list: (clusterId?: string) => ['storage-pools', clusterId ?? 'all'] as const, +}; + +// GET /storage/pools — every registered pool (super_admin). The list is small (one row per storage +// server), so it is fetched whole. +export function useStoragePools(clusterId?: string, opts?: { enabled?: boolean }) { + const query: Record = {}; + if (clusterId) query.cluster_id = clusterId; + return useQuery({ + queryKey: storagePoolKeys.list(clusterId), + enabled: opts?.enabled ?? true, + queryFn: async () => { + const { data } = await raw.GET('/api/v1/storage/pools', { params: { query } }); + return (data as { data?: StoragePool[] } | undefined)?.data ?? []; + }, + }); +} + +// Registering, editing or removing a pool changes what the dashboard's storage tile and the volume +// capacity gate see, so those caches are invalidated alongside the pool list. +function useInvalidatePools() { + const qc = useQueryClient(); + return () => Promise.all([ + qc.invalidateQueries({ queryKey: storagePoolKeys.all }), + qc.invalidateQueries({ queryKey: ['metrics', 'cluster'] }), + qc.invalidateQueries({ queryKey: ['volumes'] }), + ]); +} + +// POST /storage/pools — register a storage server. Idempotency key required. +export function useCreateStoragePool() { + const invalidate = useInvalidatePools(); + return useMutation({ + mutationFn: async (body: StoragePoolCreateBody) => { + const { data } = await raw.POST('/api/v1/storage/pools', { body, headers: { 'Idempotency-Key': idemKey() } }); + return data as StoragePool; + }, + onSuccess: () => invalidate(), + }); +} + +// PATCH /storage/pools/{id} — rename, move the node link, change the class, sharing or the stated +// capacity. The cluster itself cannot change: a pool belongs to the cluster its server sits in. +export function useUpdateStoragePool() { + const invalidate = useInvalidatePools(); + return useMutation({ + mutationFn: async ({ id, body }: { id: string; body: StoragePoolPatchBody }) => { + const { data } = await raw.PATCH('/api/v1/storage/pools/{pool_id}', { + params: { path: { pool_id: id } }, body, headers: { 'Idempotency-Key': idemKey() }, + }); + return data as StoragePool; + }, + onSuccess: () => invalidate(), + }); +} + +// DELETE /storage/pools/{id} — stop counting the pool. Volumes already on it keep working through +// their StorageClass; only the capacity gate and the dashboard forget the server. +export function useDeleteStoragePool() { + const invalidate = useInvalidatePools(); + return useMutation({ + mutationFn: async (id: string) => { + await raw.DELETE('/api/v1/storage/pools/{pool_id}', { params: { path: { pool_id: id } }, headers: { 'Idempotency-Key': idemKey() } }); + }, + onSuccess: () => invalidate(), + }); +} diff --git a/frontend/src/api/hooks/useUsers.ts b/frontend/src/api/hooks/useUsers.ts index 56c1da8..7b1e406 100644 --- a/frontend/src/api/hooks/useUsers.ts +++ b/frontend/src/api/hooks/useUsers.ts @@ -215,20 +215,6 @@ export function useDeleteUser() { }); } -// PUT /users/{id}/global-role — grant global roles. super_admin only, and several may be granted. -export function useSetGlobalRole() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async ({ id, global_roles }: { id: string; global_roles: string[] }) => { - const { data } = await raw.PUT('/api/v1/users/{user_id}/global-role', { - params: { path: { user_id: id } }, - body: { global_roles }, - }); - return data as AdminUser; - }, - onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.all }), - }); -} // GET /users/{id}/usage — live resource footprint for the admin drawer. export interface UserUsage { diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index 00ae734..47bc4a4 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -695,8 +695,8 @@ export interface paths { * @description Set a child wallet's monthly automatic refill, as the administrator one level up. * * The siblings' grants must sum within the parent's grant; 0 disables refills for that wallet. The - * refill itself — resetting balance to grant at the start of each month, use-it-or-lose-it — is - * performed by the credit_refill worker. + * refill itself — topping the balance up to the grant at the start of each month, never taking + * a surplus away — is performed by the credit_refill worker. */ post: operations["set_monthly_grant_api_v1_credits_wallets__wallet_id__monthly_grant_post"]; delete?: never; @@ -1788,6 +1788,10 @@ export interface paths { * * Reads the same (scope, scope_id) policy and volume quota sum that _assert_storage_quota uses at * creation time. has_limit=false means unlimited: no policy, or a limit of 0. + * + * The scope must be one the caller could create a volume in or belongs to: their own user scope, + * a group they are a member of, or the global scope. Another tenant's limit and provisioned total + * are not theirs to read. */ get: operations["storage_quota_usage_api_v1_storage_volumes_quota_usage_get"]; put?: never; @@ -2154,7 +2158,8 @@ export interface paths { }; /** * Get Image - * @description Image detail. any authenticated. + * @description Image detail. any authenticated, for the shared catalogue and one's own images; another + * user's private image answers 404 so its existence is not confirmed either. */ get: operations["get_image_api_v1_images__image_id__get"]; put?: never; @@ -3277,6 +3282,12 @@ export interface paths { * * The operator already cordoned the K8s node; the ledger reflects it so the console/scheduler * stop placing new sessions there. + * + * The operator's HealthReconciler addresses the node by its Kubernetes NAME (the hostname): it + * has no ledger id. The row is therefore resolved by (token cluster, hostname) — with the ledger + * id still accepted for older callers — and the event carries the row's id, which the FK needs. + * Looking the hostname up as an id never matched: the cordon never reached the ledger and the + * event insert violated the FK on Postgres. */ post: operations["node_health_event_internal_nodes_health_events_post"]; delete?: never; @@ -3814,6 +3825,16 @@ export interface components { * @default 0 */ node_count: number; + /** + * Capacity Gb + * @default 0 + */ + capacity_gb: number; + /** + * Unplaced Gb + * @default 0 + */ + unplaced_gb: number; /** * Shared * @default false @@ -3862,6 +3883,11 @@ export interface components { capacity_source?: string | null; /** Capacity Reported At */ capacity_reported_at?: string | null; + /** + * Used Gb + * @default 0 + */ + used_gb: number; }; /** ConnectionInfo */ ConnectionInfo: { @@ -4603,6 +4629,8 @@ export interface components { generation?: number | null; /** Container State */ container_state?: string | null; + /** Cluster Id */ + cluster_id?: string | null; /** * Ts * Format: date-time @@ -4630,6 +4658,8 @@ export interface components { * @default false */ mounted: boolean; + /** Storage Class */ + storage_class?: string | null; }; /** * OperatorVolumeSync @@ -5509,6 +5539,16 @@ export interface components { * @default 0 */ shared_count: number; + /** Cluster Id */ + cluster_id?: string | null; + /** Cluster Name */ + cluster_name?: string | null; + /** Storage Class */ + storage_class?: string | null; + /** Pool Id */ + pool_id?: string | null; + /** Pool Name */ + pool_name?: string | null; /** Active Mounts */ active_mounts?: { [key: string]: unknown; @@ -5664,7 +5704,7 @@ export interface components { /** PoolCreate */ app__api__storage_pools_router__PoolCreate: { /** Name */ - name: string; + name?: string | null; /** Cluster Id */ cluster_id: string; /** Storage Class */ diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 387ec94..688aec1 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -42,7 +42,7 @@ import { NotificationBell } from './NotificationBell'; import { AccountMenu } from './AccountMenu'; import { ClusterSelector } from './ClusterSelector'; -interface NavItem { +export interface NavItem { to: string; /** Key into the pending-approval counts, when this destination has an inbox. */ badge?: 'credits' | 'quota' | 'signups'; @@ -65,8 +65,8 @@ const USER_NAV: NavItem[] = [ // Eleven flat rows read as a wall; the admin nav is grouped by concern instead. Routes and // labels are unchanged — only the presentation is grouped. A group disappears entirely when the // role can see none of its items. -const ADMIN_DASHBOARD: NavItem = { to: '/admin', labelKey: 'nav.admin.dashboard', icon: Gauge, minRole: 'group_admin' }; -const ADMIN_GROUPS: { labelKey: string; items: NavItem[] }[] = [ +export const ADMIN_DASHBOARD: NavItem = { to: '/admin', labelKey: 'nav.admin.dashboard', icon: Gauge, minRole: 'group_admin' }; +export const ADMIN_GROUPS: { labelKey: string; items: NavItem[] }[] = [ { labelKey: 'nav.adminGroup.tenancy', items: [ @@ -80,7 +80,8 @@ const ADMIN_GROUPS: { labelKey: string; items: NavItem[] }[] = [ items: [ { to: '/admin/clusters', labelKey: 'nav.admin.clusters', icon: Stack, exactGlobal: 'super_admin' }, { to: '/admin/nodes', labelKey: 'nav.admin.nodes', icon: HardDrives, minRole: 'org_admin' }, - { to: '/admin/gpus', labelKey: 'nav.admin.gpus', icon: GraphicsCard, minRole: 'org_admin' }, + // The GPU inventory reads node.read, which is super_admin only; an org_admin has no tab there. + { to: '/admin/gpus', labelKey: 'nav.admin.gpus', icon: GraphicsCard, exactGlobal: 'super_admin' }, ], }, { @@ -106,7 +107,7 @@ const ADMIN_GROUPS: { labelKey: string; items: NavItem[] }[] = [ }, ]; -function canSee(item: NavItem, globalRole?: string | null, membershipRole?: string): boolean { +export function canSee(item: NavItem, globalRole?: string | null, membershipRole?: string): boolean { const effective = globalRole ?? membershipRole; if (item.exactGlobal) return globalRole === item.exactGlobal || effective === item.exactGlobal; if (item.minRole) return atLeast(effective, item.minRole); diff --git a/frontend/src/features/admin/CreditAllocation.tsx b/frontend/src/features/admin/CreditAllocation.tsx index 97afc19..1e4619a 100644 --- a/frontend/src/features/admin/CreditAllocation.tsx +++ b/frontend/src/features/admin/CreditAllocation.tsx @@ -26,7 +26,7 @@ import { Timestamp } from '@/components/Timestamp'; import { useApproveTopupRequest, useRejectTopupRequest, useTopupRequests, useCreateTopupRequest } from '@/api/hooks/useBilling'; import { StatusPill } from '@/components/StatusPill'; import { ReasonPopover } from '@/components/ReasonPopover'; -import { reqStatusLabel } from '@/lib/format'; +import { reqStatusLabel, formatCredit } from '@/lib/format'; import { Check } from '@/components/icons'; // Credit management: one "pool to children" table, shaped by the caller's role. @@ -35,7 +35,7 @@ import { Check } from '@/components/icons'; // - group_admin: the pool is their group, children are its members // Each child row allocates, reclaims, and sets a refill inline; incoming requests sit underneath. -const C = (v: string | number | undefined) => `${Number(v ?? 0).toLocaleString()} C`; +const C = (v: string | number | undefined) => `${formatCredit(Number(v ?? 0))} C`; // Turn a refill-over-ceiling failure into something a person can act on. function grantErrorMsg(e: unknown): string { diff --git a/frontend/src/features/admin/Dashboard.tsx b/frontend/src/features/admin/Dashboard.tsx index a8767fc..f8ff559 100644 --- a/frontend/src/features/admin/Dashboard.tsx +++ b/frontend/src/features/admin/Dashboard.tsx @@ -328,9 +328,18 @@ export function AdminDashboard() { type StoragePool = { id: string; name?: string | null; hostname?: string | null; cluster_name?: string | null; share_scope?: string | null; - capacity_gb?: number | null; capacity_source?: string | null; + capacity_gb?: number | null; capacity_source?: string | null; used_gb?: number | null; }; - const st = (m as { storage?: { disk_gb: { used: number; total: number; source?: string }; node_count: number; shared?: boolean; pools?: StoragePool[] } | null }).storage; + type Storage = { + disk_gb: { used: number; total: number; source?: string }; + node_count: number; shared?: boolean; pools?: StoragePool[]; + capacity_gb?: number; unplaced_gb?: number; + }; + const st = (m as { storage?: Storage | null }).storage; + // The header meter is the fleet total (every pool's capacity added up); each pool + // row below carries its own allocation against its own capacity, which is the + // figure that decides whether one more volume fits — a volume lives on one server. + const fleetCap = st?.capacity_gb ?? st?.disk_gb.total ?? 0; return (

{t('admin.dashboard.storageTitle')}

@@ -340,39 +349,47 @@ export function AdminDashboard() { {t(st.shared ? 'admin.dashboard.storageSubShared' : 'admin.dashboard.storageSubShort', { count: st.node_count })}

- {/* Each registered pool, tagged with the cluster its server sits in — the - same reading the GPU tiles give. A count alone said how many without - saying which, and on a shared pool that is the first thing an - administrator needs before touching one. */} -
    - {(st.pools ?? []).map((p) => ( -
  • - - {p.name ?? p.hostname ?? p.id} - - {p.cluster_name && ( - - {p.cluster_name} - - )} - {p.share_scope === 'all' && ( - {t('admin.dashboard.storageShareAll')} - )} - - {p.capacity_gb ? `${p.capacity_gb} GB` : t('admin.dashboard.storageCapacityUnknown')} - -
  • - ))} -
0 ? (st.disk_gb.used / st.disk_gb.total) * 100 : 0} - variant={st.disk_gb.used > st.disk_gb.total ? 'danger' : 'primary'} + reading={`${st.disk_gb.used} / ${fleetCap} GB`} + pct={fleetCap > 0 ? (st.disk_gb.used / fleetCap) * 100 : 0} + variant={st.disk_gb.used > fleetCap ? 'danger' : 'primary'} />
+
    + {(st.pools ?? []).map((p) => { + const used = p.used_gb ?? 0; + const cap = p.capacity_gb ?? 0; + return ( +
  • +
    + + {p.name ?? p.hostname ?? p.id} + + {p.cluster_name && ( + + {p.cluster_name} + + )} + {p.share_scope === 'all' && ( + {t('admin.dashboard.storageShareAll')} + )} + + {cap ? `${used} / ${cap} GB` : `${used} GB / ${t('admin.dashboard.storageCapacityUnknown')}`} + +
    + {cap > 0 && ( + cap ? 'danger' : 'primary'} className="!mt-1.5" /> + )} +
  • + ); + })} +
+ {(st.unplaced_gb ?? 0) > 0 && ( +

{t('admin.dashboard.storageUnplaced', { gb: st.unplaced_gb })}

+ )} ) : (

diff --git a/frontend/src/features/admin/Images.tsx b/frontend/src/features/admin/Images.tsx index bc2aa1c..12dc842 100644 --- a/frontend/src/features/admin/Images.tsx +++ b/frontend/src/features/admin/Images.tsx @@ -1,17 +1,13 @@ import { useCallback, useMemo, useState } from 'react'; import { Select } from '@/components/Select'; import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router-dom'; import { useImages, useImageBuilds, useImportImage, useUpdateImage, - useCreateBuild, type ImportImageBody, - type CreateBuildBody, } from '@/api/hooks/useImages'; -import { useProjects } from '@/api/hooks/useGroups'; import { useGpuAvailability, useOfferings } from '@/api/hooks/useResources'; import { cudaCompatible } from '@/lib/cuda'; import { Table, TableToolbar, sortAccessor, type Column } from '@/components/Table'; @@ -29,7 +25,7 @@ import { Hammer, Package, Plus } from '@/components/icons'; import { StatusPill } from '@/components/StatusPill'; import { Tabs } from '@/components/Tabs'; -// The image and template registry plus image builds (/images, /images/import, /image-builds). +// The image and template registry plus the image-build list (/images, /images/import, /image-builds). interface ImageRow { id: string; @@ -449,105 +445,3 @@ function ImportImageForm({ onDone }: { onDone: () => void }) { ); } - -// Image build, at /admin/images/build. -export function BuildImagePage() { - const { t } = useTranslation(); - const navigate = useNavigate(); - const groups = useProjects().data ?? []; - const [projectId, setProjectId] = useState(''); - const effGroupId = projectId || groups[0]?.id || ''; - const [name, setName] = useState(''); - const [source, setSource] = useState<'dockerfile' | 'git'>('git'); - const [gitUrl, setGitUrl] = useState(''); - const [gitRef, setGitRef] = useState('main'); - const [dockerfile, setDockerfile] = useState(''); - const build = useCreateBuild(); - const pushToast = useUiStore((s) => s.pushToast); - - const submit = () => { - const body: CreateBuildBody = { - group_id: effGroupId, - name: name.trim(), - source, - ...(source === 'git' - ? { git_url: gitUrl.trim(), git_ref: gitRef.trim() || 'main' } - : { dockerfile }), - }; - build.mutate(body, { - onSuccess: () => { guard.clear(); pushToast('success', t('admin.images.buildStarted', { name })); navigate('/admin/images'); }, - onError: (e) => pushToast('error', humanizeError(asApiError(e))), - }); - }; - - const valid = - effGroupId.length > 0 && - name.trim().length > 0 && - (source === 'git' ? gitUrl.trim().length > 0 : dockerfile.trim().length > 0); - const guard = useFormGuard(build.isPending); - - return ( -

- -
{ e.preventDefault(); if (valid) submit(); }}> -
- - - - {source === 'git' ? ( - <> - - - - ) : ( -