Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
23 changes: 21 additions & 2 deletions .github/workflows/deploy-application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -48,17 +48,28 @@ 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
run: |
[[ "$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"

Expand Down Expand Up @@ -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" \
Expand Down Expand Up @@ -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" \
Expand Down
9 changes: 6 additions & 3 deletions .github/workflows/sandbox-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
53 changes: 53 additions & 0 deletions .github/workflows/sandbox-teardown.yml
Original file line number Diff line number Diff line change
@@ -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
21 changes: 16 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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

Expand All @@ -309,16 +313,18 @@ 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
- `POST /kubernetes/ingress/create` - Create ingress rules

### 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

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions api/routes/applications.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand Down
89 changes: 2 additions & 87 deletions api/routes/catalog.py
Original file line number Diff line number Diff line change
@@ -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("")
Expand Down
8 changes: 7 additions & 1 deletion api/routes/deployments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading