diff --git a/.env.example b/.env.example index b0d3a38..60a86a1 100644 --- a/.env.example +++ b/.env.example @@ -2,11 +2,13 @@ ENVIRONMENT=local APP_DEBUG=true DATABASE_URL=sqlite:///./idp.db REDIS_URL=redis://localhost:6379/0 -SECRET_KEY=replace-with-a-long-random-secret +# Generate with: openssl rand -hex 32. Placeholder values are never used to sign tokens. +SECRET_KEY= JWT_ALGORITHM=HS256 ACCESS_TOKEN_EXPIRE_MINUTES=60 ALLOWED_ORIGINS=http://localhost:8000,http://127.0.0.1:8000 -ENABLE_PUBLIC_REGISTRATION=true +# Leave off on any reachable server; create accounts with: python -m app.admin create +ENABLE_PUBLIC_REGISTRATION=false AWS_REGION=us-east-1 DEFAULT_INGRESS_DOMAIN=apps.local DEFAULT_DESTINATION_NAME=home-vm @@ -24,3 +26,4 @@ TERRAFORM_JOB_BACKEND=background TERRAFORM_JOB_REDIS_URL=redis://localhost:6379/1 TERRAFORM_JOB_QUEUE_NAME=terraform-jobs RATE_LIMIT_REQUESTS_PER_HOUR=100 +MAX_ACTIVE_SANDBOXES=3 diff --git a/.github/workflows/deploy-application.yaml b/.github/workflows/deploy-application.yaml index 6a91e78..7b97721 100644 --- a/.github/workflows/deploy-application.yaml +++ b/.github/workflows/deploy-application.yaml @@ -48,6 +48,11 @@ jobs: IDP_API_URL: ${{ vars.IDP_API_URL }} APPLICATION_BASE_URL: ${{ vars.APPLICATION_BASE_URL }} APPLICATION_BIND_ADDRESS: ${{ vars.APPLICATION_BIND_ADDRESS || '127.0.0.1' }} + # Every container this workflow starts gets these limits, so one deployment + # cannot starve the VM it shares with everything else. + CONTAINER_MEMORY_LIMIT: ${{ vars.CONTAINER_MEMORY_LIMIT || '512m' }} + CONTAINER_CPU_LIMIT: ${{ vars.CONTAINER_CPU_LIMIT || '1' }} + CONTAINER_PIDS_LIMIT: ${{ vars.CONTAINER_PIDS_LIMIT || '512' }} CALLBACK_TOKEN: ${{ secrets.DEPLOYMENT_CALLBACK_TOKEN }} steps: - name: Validate deployment request @@ -55,10 +60,16 @@ jobs: [[ "$APPLICATION_ID" =~ ^[0-9]+$ ]] [[ "$APPLICATION_NAME" =~ ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ ]] [[ "$APPLICATION_IMAGE" =~ ^[A-Za-z0-9./:@_-]+$ ]] - [[ "$CONTAINER_PORT" =~ ^[0-9]+$ ]] && (( CONTAINER_PORT >= 1 && CONTAINER_PORT <= 65535 )) - [[ "$HOST_PORT" =~ ^[0-9]+$ ]] && (( HOST_PORT >= 10000 && HOST_PORT <= 65535 )) + [[ "$CONTAINER_PORT" =~ ^[0-9]+$ ]] + (( CONTAINER_PORT >= 1 && CONTAINER_PORT <= 65535 )) + [[ "$HOST_PORT" =~ ^[0-9]+$ ]] + (( HOST_PORT >= 10000 && HOST_PORT <= 65535 )) [[ "$ATTEMPT_ID" =~ ^[a-f0-9]{32}$ ]] [[ "$APPLICATION_BIND_ADDRESS" =~ ^[0-9.]+$ ]] + [[ "$CONTAINER_MEMORY_LIMIT" =~ ^[0-9]+[mg]$ ]] + [[ "$CONTAINER_CPU_LIMIT" =~ ^[0-9]+(\.[0-9]+)?$ ]] + [[ "$CONTAINER_PIDS_LIMIT" =~ ^[0-9]+$ ]] + (( CONTAINER_PIDS_LIMIT >= 32 )) test -n "$IDP_API_URL" test -n "$CALLBACK_TOKEN" @@ -96,6 +107,10 @@ jobs: docker run -d \ --name "idp-db-$APPLICATION_ID" \ --restart unless-stopped \ + --memory "$CONTAINER_MEMORY_LIMIT" \ + --cpus "$CONTAINER_CPU_LIMIT" \ + --pids-limit "$CONTAINER_PIDS_LIMIT" \ + --security-opt no-new-privileges \ --network idp-apps \ --env-file "$secret_dir/postgres.env" \ --volume "idp-db-$APPLICATION_ID:/var/lib/postgresql/data" \ @@ -130,6 +145,10 @@ jobs: docker run -d \ --name "$container_name" \ --restart unless-stopped \ + --memory "$CONTAINER_MEMORY_LIMIT" \ + --cpus "$CONTAINER_CPU_LIMIT" \ + --pids-limit "$CONTAINER_PIDS_LIMIT" \ + --security-opt no-new-privileges \ --network idp-apps \ --env-file "$secret_file" \ --label "idp.application-id=$APPLICATION_ID" \ diff --git a/.github/workflows/sandbox-deploy.yml b/.github/workflows/sandbox-deploy.yml index 9398bc3..59af6f7 100644 --- a/.github/workflows/sandbox-deploy.yml +++ b/.github/workflows/sandbox-deploy.yml @@ -35,9 +35,12 @@ jobs: [[ "$DEPLOYMENT_ID" =~ ^[0-9]+$ ]] [[ "$SANDBOX_NAME" =~ ^sandbox-[a-z0-9-]+-[0-9]+$ ]] [[ "$SANDBOX_IMAGE" =~ ^(mpepping/whoami:latest|nginx:1\.25-alpine)$ ]] - [[ "$CONTAINER_PORT" =~ ^[0-9]+$ ]] && (( CONTAINER_PORT >= 1 && CONTAINER_PORT <= 65535 )) - [[ "$HOST_PORT" =~ ^[0-9]+$ ]] && (( HOST_PORT >= 20000 && HOST_PORT <= 65535 )) - [[ "$TTL_SECONDS" =~ ^[0-9]+$ ]] && (( TTL_SECONDS > 0 && TTL_SECONDS <= 900 )) + [[ "$CONTAINER_PORT" =~ ^[0-9]+$ ]] + (( CONTAINER_PORT >= 1 && CONTAINER_PORT <= 65535 )) + [[ "$HOST_PORT" =~ ^[0-9]+$ ]] + (( HOST_PORT >= 20000 && HOST_PORT <= 65535 )) + [[ "$TTL_SECONDS" =~ ^[0-9]+$ ]] + (( TTL_SECONDS > 0 && TTL_SECONDS <= 900 )) [[ "$APPLICATION_BIND_ADDRESS" =~ ^[0-9.]+$ ]] test -n "$IDP_API_URL" test -n "$CALLBACK_TOKEN" diff --git a/.github/workflows/sandbox-teardown.yml b/.github/workflows/sandbox-teardown.yml new file mode 100644 index 0000000..2c128f6 --- /dev/null +++ b/.github/workflows/sandbox-teardown.yml @@ -0,0 +1,53 @@ +name: Teardown Sandbox + +# The API container cannot reach the runner's Docker, so the sandbox sweeper +# asks this workflow to remove expired sandbox containers. +on: + repository_dispatch: + types: [teardown_sandbox] + workflow_dispatch: + inputs: + deployment_id: + description: Sandbox deployment ID, or "all" to remove every sandbox container on the runner + required: true + default: all + type: string + +permissions: + contents: read + +concurrency: + # Same group as Deploy Sandbox, so tearing down cancels a deploy still in progress. + group: sandbox-${{ github.event.client_payload.deployment_id || inputs.deployment_id }} + cancel-in-progress: true + +jobs: + teardown: + name: Remove sandbox container + runs-on: [self-hosted, linux, idp-vm] + timeout-minutes: 5 + env: + DEPLOYMENT_ID: ${{ github.event.client_payload.deployment_id || inputs.deployment_id }} + steps: + - name: Validate teardown request + run: | + [[ "$DEPLOYMENT_ID" =~ ^([0-9]+|all)$ ]] + if [ "$DEPLOYMENT_ID" = "all" ] && [ "$GITHUB_EVENT_NAME" != "workflow_dispatch" ]; then + echo "Removing every sandbox is only allowed from a manual run" + exit 1 + fi + + - name: Remove sandbox containers + run: | + filters=(--filter "label=idp.sandbox=true") + if [ "$DEPLOYMENT_ID" != "all" ]; then + filters+=(--filter "label=idp.deployment-id=$DEPLOYMENT_ID") + fi + containers="$(docker ps --all --quiet "${filters[@]}")" + if [ -z "$containers" ]; then + echo "No sandbox containers to remove" + exit 0 + fi + echo "Removing sandbox containers: $(echo "$containers" | tr '\n' ' ')" + echo "$containers" | xargs docker rm --force >/dev/null + docker image prune --force --filter "until=24h" >/dev/null 2>&1 || true diff --git a/README.md b/README.md index bf660ac..762222b 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,7 @@ cp .env.example .env python3 -m venv .venv source .venv/bin/activate pip install -r requirements.txt +python -m app.admin create --username admin # registration is off by default uvicorn app.main:app --reload # If venv creation fails on Debian/Ubuntu: @@ -226,6 +227,9 @@ The runner workflow: - keeps one persistent Docker volume per requested PostgreSQL database - generates PostgreSQL credentials on the VM and stores them in a `0600` environment file - injects `DATABASE_URL` into the application without sending the password through the API or GitHub payload +- limits every container to `CONTAINER_MEMORY_LIMIT` (default `512m`), `CONTAINER_CPU_LIMIT` (default `1`) and `CONTAINER_PIDS_LIMIT` (default `512`), adjustable as repository variables, and starts it with `no-new-privileges` + +Sandbox demos run on the same runner. The API's sweeper cannot reach the runner's Docker, so when a sandbox expires it dispatches the **Teardown Sandbox** workflow, which removes the container. If the dispatch fails, the sandbox stays active and the next sweep retries. To remove sandbox containers left behind by older versions, run **Actions > Teardown Sandbox > Run workflow** once with `deployment_id` set to `all`; this also stops any sandbox that is running at that moment. The VM-managed environment file is an initial single-server secret adapter, not a replacement for a managed secrets service. AWS deployments should use AWS Secrets Manager or an equivalent external secret provider. @@ -291,7 +295,7 @@ tests/ Unit tests ### Authentication -- `POST /auth/register` - Register a new user +- `POST /auth/register` - Register a new user (only when `ENABLE_PUBLIC_REGISTRATION=true`; off by default) - `POST /auth/login` - Authenticate and receive JWT token - `GET /auth/me` - Get current user info @@ -309,6 +313,8 @@ tests/ Unit tests ### Kubernetes Operations +Admin only: these act on the whole cluster, not on the caller's own resources. + - `POST /kubernetes/namespace/create` - Create a namespace - `POST /kubernetes/service/expose` - Expose a service - `POST /kubernetes/autoscaling/create` - Configure auto-scaling @@ -316,9 +322,9 @@ tests/ Unit tests ### Monitoring -- `GET /monitoring/cluster/health` - Get cluster health status +- `GET /monitoring/cluster/health` - Get cluster health status (admin only) - `GET /monitoring/metrics` - Prometheus metrics endpoint -- `GET /monitoring/logs/{pod}?namespace=default` - Retrieve pod logs +- `GET /monitoring/logs/{pod}?namespace=default` - Retrieve pod logs (admin only) ### Documentation @@ -342,7 +348,7 @@ For local development without a Kubernetes cluster or Terraform credentials, con KUBERNETES_DRY_RUN=true TERRAFORM_DRY_RUN=true DATABASE_URL=sqlite:///./idp.db -ENABLE_PUBLIC_REGISTRATION=true +ENABLE_PUBLIC_REGISTRATION=true # local only; keep it off on any reachable server ``` ### Install & Run @@ -476,7 +482,12 @@ helm upgrade --install idp-api helm/charts/idp-api \ - Redis-backed rate limiting with local fallback - Non-root Docker container - Security headers and restricted CORS origin configuration -- Production startup validation for weak/default `SECRET_KEY` +- Tokens are never signed with a published key: in `production`, `prod` or `staging` the app refuses to start without a `SECRET_KEY` of at least 32 characters; anywhere else an unset or placeholder key is replaced by a random per-process key, so sessions end on restart until a real key is set +- Public registration off by default; create accounts with `python -m app.admin create` +- Image policy: only admins can deploy images outside the platform catalog (`services/catalog.py`), checked when an application is registered and again when it is deployed +- Memory, CPU and process limits plus `no-new-privileges` on every container the deployment workflow starts +- Kubernetes and pod-log APIs restricted to admins +- The anonymous sandbox demo is capped at `MAX_ACTIVE_SANDBOXES` concurrent sandboxes (default 3; 0 disables it) - Helm defaults: public registration disabled, debug disabled, read-only root filesystem, dropped Linux capabilities - Kubernetes RBAC and network-policy examples - No hardcoded production secret requirement in Helm diff --git a/api/routes/applications.py b/api/routes/applications.py index 9a1d123..db2e0fb 100644 --- a/api/routes/applications.py +++ b/api/routes/applications.py @@ -16,6 +16,7 @@ mark_stale_application_deployments, upsert_application_runtime_deployment, ) +from services.catalog import ImageNotAllowedError, ensure_image_allowed from services.destination_service import destination_readiness router = APIRouter() @@ -88,6 +89,8 @@ def create_application_route( ): try: application = create_application(db, current_user, request) + except ImageNotAllowedError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc destination = db.query(Destination).filter(Destination.id == application.destination_id).one() @@ -109,6 +112,10 @@ def deploy_application_route( raise HTTPException(status_code=404, detail="Application not found") if application.source_type != "container_image": raise HTTPException(status_code=409, detail="Repository builds are not configured yet") + try: + ensure_image_allowed(current_user, application.image) + except ImageNotAllowedError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc destination = db.query(Destination).filter(Destination.id == application.destination_id).one() try: dispatch_application_deployment(db, application, destination) diff --git a/api/routes/catalog.py b/api/routes/catalog.py index 90f65b8..563f5c5 100644 --- a/api/routes/catalog.py +++ b/api/routes/catalog.py @@ -1,93 +1,8 @@ from fastapi import APIRouter -router = APIRouter() - -APP_TEMPLATES = [ - { - "id": "deploy-demo-app", - "name": "Deploy Demo App", - "description": ( - "Tiny public whoami service with root and /health endpoints for " - "validating the deployment lifecycle." - ), - "default_app_name": "idp-demo", - "image": "mpepping/whoami:latest", - "port": 8000, - "replicas": 1, - "min_replicas": 1, - "max_replicas": 1, - "cpu_threshold": 70, - }, - { - "id": "nginx-web", - "name": "Nginx web app", - "description": "Official nginx image pinned to a stable tag for a tiny static web app.", - "default_app_name": "nginx-web", - "image": "nginx:1.25-alpine", - "port": 80, - "replicas": 2, - "min_replicas": 1, - "max_replicas": 5, - "cpu_threshold": 70, - }, - { - "id": "apache-web", - "name": "Apache web app", - "description": "Official Apache HTTP server image that works as a simple web deployment.", - "default_app_name": "apache-web", - "image": "httpd:2.4", - "port": 80, - "replicas": 2, - "min_replicas": 1, - "max_replicas": 5, - "cpu_threshold": 70, - }, - { - "id": "whoami-api", - "name": "Whoami API", - "description": "Tiny HTTP app that returns request metadata; useful for smoke tests.", - "default_app_name": "whoami-api", - "image": "traefik/whoami:v1.10", - "port": 80, - "replicas": 2, - "min_replicas": 1, - "max_replicas": 6, - "cpu_threshold": 70, - }, - { - "id": "hello-web", - "name": "Hello web app", - "description": "Nginx demo app that serves a simple hello page.", - "default_app_name": "hello-web", - "image": "nginxdemos/hello:plain-text", - "port": 80, - "replicas": 2, - "min_replicas": 1, - "max_replicas": 5, - "cpu_threshold": 70, - }, - { - "id": "echo-server", - "name": "Echo server", - "description": "Small request/response test service for ingress and header debugging.", - "default_app_name": "echo-server", - "image": "ealen/echo-server:0.9.2", - "port": 80, - "replicas": 2, - "min_replicas": 1, - "max_replicas": 5, - "cpu_threshold": 70, - }, -] +from services.catalog import APP_TEMPLATES, IMAGE_CATALOG -IMAGE_CATALOG = [ - {"label": "IDP demo app", "image": "mpepping/whoami:latest", "port": 8000}, - {"label": "nginx 1.25 alpine", "image": "nginx:1.25-alpine", "port": 80}, - {"label": "httpd 2.4", "image": "httpd:2.4", "port": 80}, - {"label": "traefik whoami", "image": "traefik/whoami:v1.10", "port": 80}, - {"label": "nginx hello demo", "image": "nginxdemos/hello:plain-text", "port": 80}, - {"label": "echo server", "image": "ealen/echo-server:0.9.2", "port": 80}, -] +router = APIRouter() @router.get("") diff --git a/api/routes/deployments.py b/api/routes/deployments.py index 0197ca1..a557e1d 100644 --- a/api/routes/deployments.py +++ b/api/routes/deployments.py @@ -17,6 +17,7 @@ provision_application, ) from services.application_service import mark_stale_runtime_deployments +from services.catalog import ImageNotAllowedError from services.providers.factory import get_deployment_provider router = APIRouter() @@ -89,7 +90,12 @@ def create_deployment_route( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): - return provision_application(db, current_user, request) + try: + return provision_application(db, current_user, request) + except ImageNotAllowedError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc @router.patch("/{id}", response_model=DeploymentResponse) diff --git a/api/routes/kubernetes.py b/api/routes/kubernetes.py index 203b809..060be5f 100644 --- a/api/routes/kubernetes.py +++ b/api/routes/kubernetes.py @@ -1,7 +1,7 @@ from fastapi import APIRouter, Depends, HTTPException from api.schemas import AutoscalingRequest, IngressRequest, NamespaceRequest, ServiceExposeRequest -from auth.rbac import get_current_user +from auth.rbac import require_role from services.autoscaling_service import create_hpa from services.ingress_service import create_ingress from services.k8s_service import create_namespace @@ -10,14 +10,14 @@ router = APIRouter() @router.post("/namespace/create") -def create_namespace_route(request: NamespaceRequest, current_user=Depends(get_current_user)): +def create_namespace_route(request: NamespaceRequest, current_user=Depends(require_role("admin"))): success = create_namespace(request.name) if success: return {"msg": f"Namespace '{request.name}' created."} raise HTTPException(status_code=500, detail="Failed to create namespace.") @router.post("/service/expose") -def expose_service_route(request: ServiceExposeRequest, current_user=Depends(get_current_user)): +def expose_service_route(request: ServiceExposeRequest, current_user=Depends(require_role("admin"))): success = expose_service( namespace=request.namespace, name=request.name, @@ -30,7 +30,7 @@ def expose_service_route(request: ServiceExposeRequest, current_user=Depends(get raise HTTPException(status_code=500, detail="Failed to expose service.") @router.post("/autoscaling/create") -def create_autoscaling_route(request: AutoscalingRequest, current_user=Depends(get_current_user)): +def create_autoscaling_route(request: AutoscalingRequest, current_user=Depends(require_role("admin"))): success = create_hpa( namespace=request.namespace, deployment=request.deployment, @@ -44,7 +44,7 @@ def create_autoscaling_route(request: AutoscalingRequest, current_user=Depends(g @router.post("/ingress/create") -def create_ingress_route(request: IngressRequest, current_user=Depends(get_current_user)): +def create_ingress_route(request: IngressRequest, current_user=Depends(require_role("admin"))): success = create_ingress( namespace=request.namespace, name=request.name, diff --git a/api/routes/monitoring.py b/api/routes/monitoring.py index ca2b4ed..2e65351 100644 --- a/api/routes/monitoring.py +++ b/api/routes/monitoring.py @@ -2,12 +2,12 @@ from services.monitoring_service import get_cluster_health, get_pod_logs from fastapi import Response from prometheus_client import generate_latest, CONTENT_TYPE_LATEST -from auth.rbac import get_current_user +from auth.rbac import get_current_user, require_role router = APIRouter() @router.get("/cluster/health") -def cluster_health(current_user=Depends(get_current_user)): +def cluster_health(current_user=Depends(require_role("admin"))): return get_cluster_health() @router.get("/metrics") @@ -15,5 +15,5 @@ def metrics(current_user=Depends(get_current_user)): return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST) @router.get("/logs/{pod}") -def logs(pod: str, namespace: str = Query("default"), current_user=Depends(get_current_user)): +def logs(pod: str, namespace: str = Query("default"), current_user=Depends(require_role("admin"))): return get_pod_logs(namespace, pod) diff --git a/api/routes/sandbox.py b/api/routes/sandbox.py index 00ac50c..4e528ee 100644 --- a/api/routes/sandbox.py +++ b/api/routes/sandbox.py @@ -5,7 +5,7 @@ from api.schemas import SandboxDemoRequest, SandboxDemoResponse from database.session import get_db -from services.sandbox_service import SANDBOX_TTL_SECONDS +from services.sandbox_service import SANDBOX_TTL_SECONDS, SandboxCapacityError from services.sandbox_service import create_sandbox_deployment, dispatch_sandbox_deployment router = APIRouter() @@ -43,17 +43,31 @@ def create_sandbox_demo( template_name = template or (request.template if request else None) if template_name is None: raise HTTPException(status_code=422, detail="template is required") + deployment = None try: deployment = create_sandbox_deployment(db, template_name) dispatch_sandbox_deployment(deployment) + except SandboxCapacityError as exc: + raise HTTPException( + status_code=429, + detail=str(exc), + headers={"Retry-After": str(exc.retry_after_seconds)}, + ) from exc except ValueError as exc: + _mark_failed(db, deployment, exc) raise HTTPException(status_code=400, detail=str(exc)) from exc except RuntimeError as exc: - if "deployment" in locals(): - deployment.status = "failed" - deployment.last_error = str(exc) - db.add(deployment) - db.commit() + _mark_failed(db, deployment, exc) raise HTTPException(status_code=424, detail=str(exc)) from exc return _response(deployment, template_name) + + +def _mark_failed(db: Session, deployment, exc: Exception) -> None: + """A sandbox that was never dispatched must not hold one of the capped slots.""" + if deployment is None: + return + deployment.status = "failed" + deployment.last_error = str(exc) + db.add(deployment) + db.commit() diff --git a/app/admin.py b/app/admin.py index 91e9c67..827d9ca 100644 --- a/app/admin.py +++ b/app/admin.py @@ -3,7 +3,7 @@ from auth.jwt_utils import get_password_hash from database.models import User -from database.session import SessionLocal +from database.session import SessionLocal, init_db def upsert_admin(username: str) -> None: @@ -34,6 +34,8 @@ def main() -> None: parser.add_argument("--username", default="admin") args = parser.parse_args() if args.command == "create": + # The tables may not exist yet if the API has never started on this database. + init_db() upsert_admin(args.username) diff --git a/app/config.py b/app/config.py index 780711d..e16de07 100644 --- a/app/config.py +++ b/app/config.py @@ -1,9 +1,16 @@ +import logging +import secrets from functools import lru_cache from pydantic import BaseSettings, Field, validator +logger = logging.getLogger(__name__) + PRODUCTION_ENVIRONMENTS = {"production", "prod", "staging"} DEFAULT_SECRET_KEY = "change-me-in-production" +# Values published in this repository. Anyone can sign tokens with them, so +# they are treated exactly like an unset key. +PLACEHOLDER_SECRET_KEYS = {"", DEFAULT_SECRET_KEY, "replace-with-a-long-random-secret"} class Settings(BaseSettings): @@ -13,11 +20,11 @@ class Settings(BaseSettings): DEBUG: bool = Field(default=True, env="APP_DEBUG") DATABASE_URL: str = Field(default="sqlite:///./idp.db", env="DATABASE_URL") REDIS_URL: str = Field(default="redis://localhost:6379/0", env="REDIS_URL") - SECRET_KEY: str = Field(default=DEFAULT_SECRET_KEY, env="SECRET_KEY") + SECRET_KEY: str = Field(default="", env="SECRET_KEY") JWT_ALGORITHM: str = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default=60, env="ACCESS_TOKEN_EXPIRE_MINUTES") ALLOWED_ORIGINS: str = Field(default="http://localhost:8000,http://127.0.0.1:8000", env="ALLOWED_ORIGINS") - ENABLE_PUBLIC_REGISTRATION: bool = Field(default=True, env="ENABLE_PUBLIC_REGISTRATION") + ENABLE_PUBLIC_REGISTRATION: bool = Field(default=False, env="ENABLE_PUBLIC_REGISTRATION") AWS_REGION: str = Field(default="us-east-1", env="AWS_REGION") DEFAULT_INGRESS_DOMAIN: str = Field(default="apps.local", env="DEFAULT_INGRESS_DOMAIN") DEFAULT_DESTINATION_NAME: str = Field(default="home-vm", env="DEFAULT_DESTINATION_NAME") @@ -40,16 +47,32 @@ class Settings(BaseSettings): RATE_LIMIT_REQUESTS_PER_HOUR: int = Field(default=100, env="RATE_LIMIT_REQUESTS_PER_HOUR") REQUIRE_AUTH_FOR_PLATFORM_APIS: bool = Field(default=True, env="REQUIRE_AUTH_FOR_PLATFORM_APIS") ENABLE_SANDBOX_SWEEPER: bool = Field(default=True, env="ENABLE_SANDBOX_SWEEPER") + MAX_ACTIVE_SANDBOXES: int = Field(default=3, env="MAX_ACTIVE_SANDBOXES") PREVIEW_ROUTING_ENABLED: bool = Field(default=False, env="PREVIEW_ROUTING_ENABLED") PREVIEW_ROUTING_DOMAIN: str = Field(default="127.0.0.1.nip.io", env="PREVIEW_ROUTING_DOMAIN") PREVIEW_ROUTING_SCHEME: str = Field(default="http", env="PREVIEW_ROUTING_SCHEME") PREVIEW_ROUTING_PORT: int = Field(default=0, env="PREVIEW_ROUTING_PORT") @validator("SECRET_KEY") - def require_strong_secret_for_non_local(cls, value, values): + def require_unguessable_secret(cls, value, values): environment = values.get("ENVIRONMENT", "local").lower() - if environment in PRODUCTION_ENVIRONMENTS and (value == DEFAULT_SECRET_KEY or len(value) < 32): - raise ValueError("SECRET_KEY must be changed to a random value of at least 32 characters") + production_like = environment in PRODUCTION_ENVIRONMENTS + if value in PLACEHOLDER_SECRET_KEYS: + if production_like: + raise ValueError("SECRET_KEY must be set to a random value of at least 32 characters") + logger.warning( + "SECRET_KEY is not set (or is a published placeholder); using a random key for this " + "process. Tokens stop working when the app restarts. Set SECRET_KEY to keep sessions." + ) + return secrets.token_urlsafe(48) + if production_like and len(value) < 32: + raise ValueError("SECRET_KEY must be set to a random value of at least 32 characters") + return value + + @validator("MAX_ACTIVE_SANDBOXES") + def validate_max_active_sandboxes(cls, value): + if value < 0: + raise ValueError("MAX_ACTIVE_SANDBOXES must be 0 or more") return value @validator("TERRAFORM_JOB_BACKEND") diff --git a/services/application_service.py b/services/application_service.py index 7d876db..c0b09fa 100644 --- a/services/application_service.py +++ b/services/application_service.py @@ -8,6 +8,7 @@ from app.config import settings from api.schemas import ApplicationCreateRequest from database.models import Application, Deployment, Destination, User +from services.catalog import ensure_image_allowed from services.deployment_service import validate_docker_image from services.destination_service import destination_readiness, resources_readiness @@ -34,6 +35,7 @@ def create_application( if request.image: validate_docker_image(request.image) + ensure_image_allowed(user, request.image) readiness = destination_readiness(destination) requested_resources = request.resource_requests diff --git a/services/catalog.py b/services/catalog.py new file mode 100644 index 0000000..3056fa2 --- /dev/null +++ b/services/catalog.py @@ -0,0 +1,108 @@ +"""The platform's curated catalog, and the image policy built on it. + +Admins can deploy any image. Everyone else is limited to images listed here, +because a deployment runs the image on the platform's own hosts. +""" + +APP_TEMPLATES = [ + { + "id": "deploy-demo-app", + "name": "Deploy Demo App", + "description": ( + "Tiny public whoami service with root and /health endpoints for " + "validating the deployment lifecycle." + ), + "default_app_name": "idp-demo", + "image": "mpepping/whoami:latest", + "port": 8000, + "replicas": 1, + "min_replicas": 1, + "max_replicas": 1, + "cpu_threshold": 70, + }, + { + "id": "nginx-web", + "name": "Nginx web app", + "description": "Official nginx image pinned to a stable tag for a tiny static web app.", + "default_app_name": "nginx-web", + "image": "nginx:1.25-alpine", + "port": 80, + "replicas": 2, + "min_replicas": 1, + "max_replicas": 5, + "cpu_threshold": 70, + }, + { + "id": "apache-web", + "name": "Apache web app", + "description": "Official Apache HTTP server image that works as a simple web deployment.", + "default_app_name": "apache-web", + "image": "httpd:2.4", + "port": 80, + "replicas": 2, + "min_replicas": 1, + "max_replicas": 5, + "cpu_threshold": 70, + }, + { + "id": "whoami-api", + "name": "Whoami API", + "description": "Tiny HTTP app that returns request metadata; useful for smoke tests.", + "default_app_name": "whoami-api", + "image": "traefik/whoami:v1.10", + "port": 80, + "replicas": 2, + "min_replicas": 1, + "max_replicas": 6, + "cpu_threshold": 70, + }, + { + "id": "hello-web", + "name": "Hello web app", + "description": "Nginx demo app that serves a simple hello page.", + "default_app_name": "hello-web", + "image": "nginxdemos/hello:plain-text", + "port": 80, + "replicas": 2, + "min_replicas": 1, + "max_replicas": 5, + "cpu_threshold": 70, + }, + { + "id": "echo-server", + "name": "Echo server", + "description": "Small request/response test service for ingress and header debugging.", + "default_app_name": "echo-server", + "image": "ealen/echo-server:0.9.2", + "port": 80, + "replicas": 2, + "min_replicas": 1, + "max_replicas": 5, + "cpu_threshold": 70, + }, +] + +IMAGE_CATALOG = [ + {"label": "IDP demo app", "image": "mpepping/whoami:latest", "port": 8000}, + {"label": "nginx 1.25 alpine", "image": "nginx:1.25-alpine", "port": 80}, + {"label": "httpd 2.4", "image": "httpd:2.4", "port": 80}, + {"label": "traefik whoami", "image": "traefik/whoami:v1.10", "port": 80}, + {"label": "nginx hello demo", "image": "nginxdemos/hello:plain-text", "port": 80}, + {"label": "echo server", "image": "ealen/echo-server:0.9.2", "port": 80}, +] + +CATALOG_IMAGES = frozenset( + [template["image"] for template in APP_TEMPLATES] + [entry["image"] for entry in IMAGE_CATALOG] +) + + +class ImageNotAllowedError(Exception): + """The user's role does not allow deploying this image.""" + + +def ensure_image_allowed(user, image) -> None: + if user.role == "admin" or image in CATALOG_IMAGES: + return + raise ImageNotAllowedError( + "Only images from the platform catalog can be deployed. Ask an administrator to deploy custom images." + ) diff --git a/services/deployment_service.py b/services/deployment_service.py index f257950..cf6537b 100644 --- a/services/deployment_service.py +++ b/services/deployment_service.py @@ -10,6 +10,7 @@ from database.models import Deployment, User from services.kubernetes_client import get_k8s_client from services.autoscaling_service import create_hpa +from services.catalog import ensure_image_allowed from services.ingress_service import create_ingress from services.k8s_service import create_namespace from services.service_service import expose_service @@ -146,6 +147,7 @@ def get_kubernetes_deployment_status(namespace: str, name: str) -> Dict[str, Opt def provision_application(db: Session, user: User, request: DeploymentCreateRequest) -> Deployment: validate_docker_image(request.image) + ensure_image_allowed(user, request.image) namespace = request.namespace or _default_namespace(user, request.name) ingress_host = request.ingress_host or _default_host(user, request.name) deployment = Deployment( diff --git a/services/providers/vm_docker.py b/services/providers/vm_docker.py index 63e4f39..1c106f5 100644 --- a/services/providers/vm_docker.py +++ b/services/providers/vm_docker.py @@ -21,38 +21,40 @@ def sandbox_container_names(deployment: Deployment) -> list[str]: return list(dict.fromkeys(names)) +def _repository_dispatch(event_type: str, client_payload: dict) -> None: + response = httpx.post( + f"https://api.github.com/repos/{settings.GITHUB_REPOSITORY}/dispatches", + headers={ + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {settings.GITHUB_DISPATCH_TOKEN}", + "X-GitHub-Api-Version": "2022-11-28", + }, + json={"event_type": event_type, "client_payload": client_payload}, + timeout=20, + ) + if response.status_code != 204: + detail = response.text[:500] if response.text else "no response body" + accepted_permissions = response.headers.get("x-accepted-github-permissions") + if accepted_permissions: + detail = f"{detail} Required permissions: {accepted_permissions}" + raise RuntimeError(f"GitHub rejected the {event_type} dispatch with status {response.status_code}: {detail}") + + class VMRunnerProvider(BaseDeploymentProvider): def dispatch(self, deployment: Deployment) -> None: if not settings.GITHUB_DISPATCH_TOKEN: raise ValueError("GitHub sandbox dispatch is not configured") - response = httpx.post( - f"https://api.github.com/repos/{settings.GITHUB_REPOSITORY}/dispatches", - headers={ - "Accept": "application/vnd.github+json", - "Authorization": f"Bearer {settings.GITHUB_DISPATCH_TOKEN}", - "X-GitHub-Api-Version": "2022-11-28", - }, - json={ - "event_type": "deploy_sandbox", - "client_payload": { - "deployment_id": str(deployment.id), - "name": deployment.name, - "image": deployment.image, - "container_port": str(deployment.container_port), - "host_port": str(deployment.port), - "ttl_seconds": str(SANDBOX_TTL_SECONDS), - }, + _repository_dispatch( + "deploy_sandbox", + { + "deployment_id": str(deployment.id), + "name": deployment.name, + "image": deployment.image, + "container_port": str(deployment.container_port), + "host_port": str(deployment.port), + "ttl_seconds": str(SANDBOX_TTL_SECONDS), }, - timeout=20, ) - if response.status_code != 204: - detail = response.text[:500] if response.text else "no response body" - accepted_permissions = response.headers.get("x-accepted-github-permissions") - if accepted_permissions: - detail = f"{detail} Required permissions: {accepted_permissions}" - raise RuntimeError( - f"GitHub rejected the sandbox dispatch with status {response.status_code}: {detail}" - ) def get_logs(self, deployment: Deployment, tail: int = 100) -> list[str]: metadata = deployment.metadata_json or {} @@ -78,6 +80,16 @@ def get_logs(self, deployment: Deployment, tail: int = 100) -> list[str]: return persisted_logs.splitlines() or [TERMINATED_LOG_MESSAGE] def teardown(self, deployment: Deployment) -> None: + """Removes the sandbox container. Raises if the removal could not be requested. + + Sandboxes run on the self-hosted runner's Docker, which the API container + cannot reach, so when dispatch is configured the runner removes the + container (.github/workflows/sandbox-teardown.yml). Without dispatch the + API is a local development process sharing Docker with its sandboxes. + """ + if settings.GITHUB_DISPATCH_TOKEN: + _repository_dispatch("teardown_sandbox", {"deployment_id": str(deployment.id)}) + return for container_name in sandbox_container_names(deployment): try: subprocess.run( diff --git a/services/sandbox_service.py b/services/sandbox_service.py index 22dc1ee..7b9ec4a 100644 --- a/services/sandbox_service.py +++ b/services/sandbox_service.py @@ -3,6 +3,7 @@ from sqlalchemy.orm import Session +from app.config import settings from database.models import Deployment from services.providers.factory import get_deployment_provider from services.providers.vm_docker import SANDBOX_TTL_SECONDS @@ -20,6 +21,40 @@ } +# Sandboxes in these states may have a container on the runner. Expired-but-unswept +# ones still count: their container is running until the sweeper removes it. +ACTIVE_SANDBOX_STATUSES = ("queued", "deploying", "running") +MIN_RETRY_AFTER_SECONDS = 30 + + +class SandboxCapacityError(Exception): + def __init__(self, message: str, retry_after_seconds: int): + super().__init__(message) + self.retry_after_seconds = retry_after_seconds + + +def ensure_sandbox_capacity(db: Session, now: datetime) -> None: + """Anyone can start a sandbox without logging in, so cap how many run at once.""" + if settings.MAX_ACTIVE_SANDBOXES == 0: + raise SandboxCapacityError("Sandbox demos are disabled", retry_after_seconds=3600) + expiries = [ + row[0] + for row in db.query(Deployment.expires_at).filter( + Deployment.is_sandbox.is_(True), + Deployment.status.in_(ACTIVE_SANDBOX_STATUSES), + ) + ] + if len(expiries) < settings.MAX_ACTIVE_SANDBOXES: + return + # SQLite hands back naive datetimes; they were stored as UTC. + known = [expiry if expiry.tzinfo else expiry.replace(tzinfo=timezone.utc) for expiry in expiries if expiry] + seconds_until_free = int((min(known) - now).total_seconds()) if known else 0 + raise SandboxCapacityError( + "All sandbox slots are in use. Try again in a few minutes.", + retry_after_seconds=max(seconds_until_free, MIN_RETRY_AFTER_SECONDS), + ) + + def sandbox_template(name: str) -> dict[str, Any]: template = SANDBOX_TEMPLATES.get(name) if template is None: @@ -34,6 +69,7 @@ def sandbox_host_port(deployment_id: int) -> int: def create_sandbox_deployment(db: Session, template_name: str) -> Deployment: template = sandbox_template(template_name) now = datetime.now(timezone.utc) + ensure_sandbox_capacity(db, now) deployment = Deployment( owner_id=0, name=f"sandbox-{template_name}", diff --git a/services/sandbox_sweeper.py b/services/sandbox_sweeper.py index b26991c..9b06a0e 100644 --- a/services/sandbox_sweeper.py +++ b/services/sandbox_sweeper.py @@ -36,17 +36,31 @@ def sweep_expired_sandboxes(db: Session, now: Optional[datetime] = None) -> int: ) .all() ) + swept = 0 for deployment in expired: - remove_sandbox_container(deployment) + try: + remove_sandbox_container(deployment) + except Exception as exc: + # Leave it active so the next sweep retries; marking it expired + # here would leave the container running with nothing tracking it. + logger.warning("Could not tear down sandbox %s: %s", deployment.id, exc) + deployment.metadata_json = { + **(deployment.metadata_json or {}), + "teardown_error": str(exc), + "teardown_attempted_at": now.isoformat(), + } + db.add(deployment) + continue deployment.status = "expired" deployment.metadata_json = { **(deployment.metadata_json or {}), "swept_at": now.isoformat(), } db.add(deployment) + swept += 1 if expired: db.commit() - return len(expired) + return swept async def sandbox_sweeper_loop(interval_seconds: int = SANDBOX_SWEEP_INTERVAL_SECONDS) -> None: diff --git a/tests/conftest.py b/tests/conftest.py index 56aaed3..3bf76b8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,6 +4,7 @@ os.environ.setdefault("DATABASE_URL", "sqlite:///./test_idp.db") os.environ.setdefault("ENABLE_SANDBOX_SWEEPER", "false") os.environ.setdefault("KUBERNETES_DRY_RUN", "true") +os.environ.setdefault("ENABLE_PUBLIC_REGISTRATION", "true") import pytest from fastapi.testclient import TestClient diff --git a/tests/unit/test_namespace.py b/tests/unit/test_namespace.py index e3d3873..8778584 100644 --- a/tests/unit/test_namespace.py +++ b/tests/unit/test_namespace.py @@ -1,10 +1,46 @@ -def test_create_namespace(client): - client.post("/auth/register", json={"username": "namespacetest", "password": "testpass123"}) - login = client.post("/auth/login", json={"username": "namespacetest", "password": "testpass123"}) - token = login.json()["access_token"] +from auth.jwt_utils import create_access_token, get_password_hash +from database.models import User +from database.session import SessionLocal + + +def _token_for(username: str, role: str) -> str: + db = SessionLocal() + try: + user = db.query(User).filter(User.username == username).first() + if user is None: + user = User(username=username, hashed_password=get_password_hash("testpass123"), role=role) + db.add(user) + db.commit() + db.refresh(user) + return create_access_token({"sub": str(user.id), "role": user.role}) + finally: + db.close() + + +def test_create_namespace_requires_admin(client): + token = _token_for("namespace-user", "user") + response = client.post( + "/kubernetes/namespace/create", + json={"name": "test-ns"}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 403 + + +def test_admin_can_create_namespace(client): + token = _token_for("namespace-admin", "admin") response = client.post( "/kubernetes/namespace/create", json={"name": "test-ns"}, headers={"Authorization": f"Bearer {token}"}, ) assert response.status_code in [200, 500] # 500 if cluster not available + + +def test_pod_logs_require_admin(client): + token = _token_for("namespace-user", "user") + response = client.get( + "/monitoring/logs/coredns?namespace=kube-system", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 403 diff --git a/tests/unit/test_platform_hub.py b/tests/unit/test_platform_hub.py index 99a7ddd..1396b2e 100644 --- a/tests/unit/test_platform_hub.py +++ b/tests/unit/test_platform_hub.py @@ -48,7 +48,7 @@ def test_application_catalog_records_destination_and_resource_gaps(): Base.metadata.create_all(bind=engine) db = test_session() try: - user = User(username="platform-user", hashed_password="not-used") + user = User(username="platform-user", hashed_password="not-used", role="admin") destination = Destination( name="home-vm", kind="linux_docker", diff --git a/tests/unit/test_security_hardening.py b/tests/unit/test_security_hardening.py new file mode 100644 index 0000000..febc355 --- /dev/null +++ b/tests/unit/test_security_hardening.py @@ -0,0 +1,277 @@ +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +from fastapi import HTTPException +from pydantic import ValidationError +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from api.routes.applications import deploy_application_route +from api.routes.sandbox import create_sandbox_demo +from api.schemas import ApplicationCreateRequest +from app.config import DEFAULT_SECRET_KEY, Settings +from database.models import Application, Base, Deployment, Destination, User +from services.application_service import create_application +from services.catalog import ImageNotAllowedError, ensure_image_allowed +from services.providers.vm_docker import VMRunnerProvider +from services.sandbox_service import SandboxCapacityError, create_sandbox_deployment +from services.sandbox_sweeper import sweep_expired_sandboxes + +STRONG_KEY = "k" * 40 + + +@pytest.fixture +def db(): + engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool) + Base.metadata.create_all(bind=engine) + session = sessionmaker(bind=engine)() + try: + yield session + finally: + session.close() + Base.metadata.drop_all(bind=engine) + engine.dispose() + + +def _settings(**overrides): + return Settings(_env_file=None, **overrides) + + +# --- SECRET_KEY: never sign tokens with a key published in this repository --- + + +@pytest.mark.parametrize("placeholder", ["", DEFAULT_SECRET_KEY, "replace-with-a-long-random-secret"]) +def test_production_refuses_missing_or_placeholder_secret_key(placeholder): + with pytest.raises(ValidationError): + _settings(ENVIRONMENT="production", SECRET_KEY=placeholder) + + +def test_production_refuses_unset_secret_key(monkeypatch): + monkeypatch.delenv("SECRET_KEY", raising=False) + with pytest.raises(ValidationError): + _settings(ENVIRONMENT="production") + + +def test_production_refuses_short_secret_key(): + with pytest.raises(ValidationError): + _settings(ENVIRONMENT="production", SECRET_KEY="too-short") + + +def test_production_accepts_strong_secret_key(): + assert _settings(ENVIRONMENT="production", SECRET_KEY=STRONG_KEY).SECRET_KEY == STRONG_KEY + + +@pytest.mark.parametrize("placeholder", ["", DEFAULT_SECRET_KEY, "replace-with-a-long-random-secret"]) +def test_local_replaces_placeholder_secret_key_with_random_one(placeholder): + first = _settings(ENVIRONMENT="local", SECRET_KEY=placeholder).SECRET_KEY + second = _settings(ENVIRONMENT="local", SECRET_KEY=placeholder).SECRET_KEY + assert first != placeholder + assert len(first) >= 32 + assert first != second + + +# --- Registration is opt-in --- + + +def test_public_registration_is_off_by_default(monkeypatch): + monkeypatch.delenv("ENABLE_PUBLIC_REGISTRATION", raising=False) + assert _settings().ENABLE_PUBLIC_REGISTRATION is False + + +def test_register_is_rejected_when_registration_is_disabled(client, monkeypatch): + monkeypatch.setattr("api.routes.auth.settings.ENABLE_PUBLIC_REGISTRATION", False) + response = client.post("/auth/register", json={"username": "latecomer", "password": "testpass123"}) + assert response.status_code == 403 + + +# --- Image policy: only admins deploy images outside the catalog --- + + +def test_regular_user_may_deploy_catalog_images(): + ensure_image_allowed(SimpleNamespace(role="user"), "nginx:1.25-alpine") + + +def test_regular_user_may_not_deploy_custom_images(): + with pytest.raises(ImageNotAllowedError): + ensure_image_allowed(SimpleNamespace(role="user"), "ghcr.io/someone/miner:latest") + + +def test_admin_may_deploy_custom_images(): + ensure_image_allowed(SimpleNamespace(role="admin"), "ghcr.io/example/orders-api:1.0.0") + + +def _home_vm(db): + destination = Destination( + name="home-vm", + kind="linux_docker", + provider="existing_server", + environment="development", + config={"enabled": True, "runner_label": "idp-vm", "deployment_workflow": True}, + capabilities=["containers"], + ) + db.add(destination) + db.commit() + db.refresh(destination) + return destination + + +def _user(db, role): + user = User(username=f"{role}-user", hashed_password="not-used", role=role) + db.add(user) + db.commit() + db.refresh(user) + return user + + +def test_regular_user_cannot_register_application_with_custom_image(db): + destination = _home_vm(db) + request = ApplicationCreateRequest( + name="miner", + source_type="container_image", + image="ghcr.io/someone/miner:latest", + port=8080, + destination_id=destination.id, + ) + with pytest.raises(ImageNotAllowedError): + create_application(db, _user(db, "user"), request) + assert db.query(Application).count() == 0 + + +def test_deploy_rechecks_image_policy_for_existing_applications(db, monkeypatch): + # An application registered before the policy existed must not deploy either. + destination = _home_vm(db) + user = _user(db, "user") + application = Application( + owner_id=user.id, + destination_id=destination.id, + name="legacy-app", + source_type="container_image", + image="ghcr.io/someone/miner:latest", + port=8080, + status="ready_to_deploy", + metadata_json={}, + ) + db.add(application) + db.commit() + dispatch = MagicMock() + monkeypatch.setattr("api.routes.applications.dispatch_application_deployment", dispatch) + + with pytest.raises(HTTPException) as raised: + deploy_application_route(application.id, db=db, current_user=user) + + assert raised.value.status_code == 403 + dispatch.assert_not_called() + + +# --- Sandboxes: capped, and actually removed when they expire --- + + +def test_sandboxes_are_capped(db, monkeypatch): + monkeypatch.setattr("services.sandbox_service.settings.MAX_ACTIVE_SANDBOXES", 2) + create_sandbox_deployment(db, "whoami") + create_sandbox_deployment(db, "nginx") + + with pytest.raises(SandboxCapacityError) as raised: + create_sandbox_deployment(db, "whoami") + + assert raised.value.retry_after_seconds >= 30 + assert db.query(Deployment).count() == 2 + + +def test_finished_sandboxes_free_their_slot(db, monkeypatch): + monkeypatch.setattr("services.sandbox_service.settings.MAX_ACTIVE_SANDBOXES", 1) + first = create_sandbox_deployment(db, "whoami") + first.status = "expired" + db.add(first) + db.commit() + + create_sandbox_deployment(db, "whoami") + + +def test_sandbox_endpoint_returns_429_with_retry_after_when_full(db, monkeypatch): + monkeypatch.setattr("services.sandbox_service.settings.MAX_ACTIVE_SANDBOXES", 1) + monkeypatch.setattr("api.routes.sandbox.dispatch_sandbox_deployment", MagicMock()) + create_sandbox_demo(template="whoami", db=db) + + with pytest.raises(HTTPException) as raised: + create_sandbox_demo(template="whoami", db=db) + + assert raised.value.status_code == 429 + assert int(raised.value.headers["Retry-After"]) >= 30 + + +def test_sandbox_that_could_not_be_dispatched_frees_its_slot(db, monkeypatch): + monkeypatch.setattr("services.sandbox_service.settings.MAX_ACTIVE_SANDBOXES", 1) + monkeypatch.setattr("services.providers.vm_docker.settings.GITHUB_DISPATCH_TOKEN", "") + + with pytest.raises(HTTPException) as raised: + create_sandbox_demo(template="whoami", db=db) + + assert raised.value.status_code == 400 + assert db.query(Deployment).one().status == "failed" + create_sandbox_deployment(db, "whoami") # the slot is free again + + +def test_zero_sandbox_cap_disables_the_demo(db, monkeypatch): + monkeypatch.setattr("services.sandbox_service.settings.MAX_ACTIVE_SANDBOXES", 0) + with pytest.raises(SandboxCapacityError, match="disabled"): + create_sandbox_deployment(db, "whoami") + + +def test_teardown_asks_the_runner_when_dispatch_is_configured(monkeypatch): + post = MagicMock(return_value=SimpleNamespace(status_code=204)) + run = MagicMock() + monkeypatch.setattr("services.providers.vm_docker.httpx.post", post) + monkeypatch.setattr("services.providers.vm_docker.subprocess.run", run) + monkeypatch.setattr("services.providers.vm_docker.settings.GITHUB_DISPATCH_TOKEN", "token") + monkeypatch.setattr("services.providers.vm_docker.settings.GITHUB_REPOSITORY", "example/platform") + + VMRunnerProvider().teardown(Deployment(id=42, name="sandbox-whoami-42")) + + payload = post.call_args.kwargs["json"] + assert payload == {"event_type": "teardown_sandbox", "client_payload": {"deployment_id": "42"}} + run.assert_not_called() + + +def test_teardown_raises_when_github_rejects_it(monkeypatch): + rejected = SimpleNamespace(status_code=403, text="Resource not accessible", headers={}) + monkeypatch.setattr("services.providers.vm_docker.httpx.post", MagicMock(return_value=rejected)) + monkeypatch.setattr("services.providers.vm_docker.settings.GITHUB_DISPATCH_TOKEN", "token") + + with pytest.raises(RuntimeError, match="teardown_sandbox"): + VMRunnerProvider().teardown(Deployment(id=42, name="sandbox-whoami-42")) + + +def test_sweeper_keeps_sandbox_active_until_teardown_succeeds(db, monkeypatch): + now = datetime.now(timezone.utc) + deployment = Deployment( + owner_id=0, + name="sandbox-whoami-1", + namespace="sandbox", + image="mpepping/whoami:latest", + port=20001, + container_port=8000, + replicas=1, + is_sandbox=True, + status="running", + expires_at=now - timedelta(minutes=1), + metadata_json={}, + ) + db.add(deployment) + db.commit() + provider = MagicMock() + provider.teardown.side_effect = RuntimeError("GitHub is down") + monkeypatch.setattr("services.sandbox_sweeper.get_deployment_provider", MagicMock(return_value=provider)) + + assert sweep_expired_sandboxes(db, now=now) == 0 + db.refresh(deployment) + assert deployment.status == "running" + assert deployment.metadata_json["teardown_error"] == "GitHub is down" + + provider.teardown.side_effect = None + assert sweep_expired_sandboxes(db, now=now) == 1 + db.refresh(deployment) + assert deployment.status == "expired"