Access
+ JWT +Create App
+ dry-run +Apps
+ +Logs
+ select app +Choose an app and click Logs.+
diff --git a/README.md b/README.md index 72b9911..e90e577 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ api/ Route handlers and Pydantic schemas auth/ JWT, RBAC, rate limiting database/ SQLAlchemy models and session lifecycle services/ Kubernetes, Terraform, deployment, monitoring logic +web/ Developer dashboard served by FastAPI kubernetes/ Cluster RBAC and network policy examples terraform/ AWS Terraform templates helm/ Helm chart for the API itself @@ -66,6 +67,8 @@ Monitoring: Swagger/OpenAPI is available at `/docs`. +The developer dashboard is available at `/dashboard/`. + ## Local Development Create an environment file: @@ -90,6 +93,15 @@ python3 -m venv .venv ./.venv/bin/uvicorn app.main:app --reload ``` +Open the dashboard: + +```text +http://127.0.0.1:8000/dashboard/ +``` + +The dashboard lets developers register/login, deploy Docker images, see app status, delete deployments, and fetch pod logs. +It also includes app-template and image-catalog dropdowns so developers can start from known defaults and still override the generated values. + Register and log in: ```bash diff --git a/api/routes/catalog.py b/api/routes/catalog.py new file mode 100644 index 0000000..27e7d1b --- /dev/null +++ b/api/routes/catalog.py @@ -0,0 +1,69 @@ +from fastapi import APIRouter + +router = APIRouter() + +APP_TEMPLATES = [ + { + "id": "nginx-web", + "name": "Nginx web app", + "description": "Official nginx image that serves a default HTTP page on port 80.", + "default_app_name": "nginx-web", + "image": "nginx:1.25", + "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 and container details.", + "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, + }, +] + +IMAGE_CATALOG = [ + {"label": "nginx 1.25", "image": "nginx:1.25", "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}, +] + + +@router.get("") +def get_catalog(): + return { + "apps": APP_TEMPLATES, + "images": IMAGE_CATALOG, + } diff --git a/api/routes/deployments.py b/api/routes/deployments.py index 5fe5917..898f204 100644 --- a/api/routes/deployments.py +++ b/api/routes/deployments.py @@ -13,6 +13,20 @@ router = APIRouter() + +@router.get("", response_model=list[DeploymentResponse]) +def list_deployments( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + return ( + db.query(Deployment) + .filter(Deployment.owner_id == current_user.id) + .order_by(Deployment.created_at.desc()) + .all() + ) + + @router.post("", response_model=DeploymentResponse, status_code=201) def create_deployment_route( request: DeploymentCreateRequest, diff --git a/api/schemas.py b/api/schemas.py index 882e19c..9d86750 100644 --- a/api/schemas.py +++ b/api/schemas.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional from pydantic import BaseModel, Field, validator @@ -49,6 +49,8 @@ class DeploymentCreateRequest(BaseModel): cpu_threshold: int = Field(default=70, ge=10, le=95) ingress_host: Optional[str] = Field(default=None, max_length=253) env: Dict[str, str] = Field(default_factory=dict) + command: Optional[List[str]] = None + args: Optional[List[str]] = None @validator("max_replicas") def max_gte_min(cls, value, values): diff --git a/app/main.py b/app/main.py index e0da8b9..91cc939 100644 --- a/app/main.py +++ b/app/main.py @@ -1,5 +1,7 @@ from fastapi import Depends, FastAPI -from api.routes import auth, infrastructure, deployments, kubernetes, monitoring +from fastapi.responses import RedirectResponse +from fastapi.staticfiles import StaticFiles +from api.routes import auth, catalog, deployments, infrastructure, kubernetes, monitoring from auth.rate_limit import rate_limiter from app.config import settings from app.logger import setup_logging @@ -36,10 +38,12 @@ def on_startup(): tags=["deployments"], dependencies=[Depends(rate_limiter)], ) +app.include_router(catalog.router, prefix="/catalog", tags=["catalog"], dependencies=[Depends(rate_limiter)]) app.include_router(kubernetes.router, prefix="/kubernetes", tags=["kubernetes"], dependencies=[Depends(rate_limiter)]) app.include_router(monitoring.router, prefix="/monitoring", tags=["monitoring"], dependencies=[Depends(rate_limiter)]) app.include_router(kubernetes.router, tags=["kubernetes"], dependencies=[Depends(rate_limiter)]) app.include_router(monitoring.router, tags=["monitoring"], dependencies=[Depends(rate_limiter)]) +app.mount("/dashboard", StaticFiles(directory="web", html=True), name="dashboard") @app.get("/healthz") @@ -55,3 +59,8 @@ def readiness_check(): "kubernetes_dry_run": settings.KUBERNETES_DRY_RUN, "terraform_dry_run": settings.TERRAFORM_DRY_RUN, } + + +@app.get("/") +def root(): + return RedirectResponse(url="/dashboard/") diff --git a/services/deployment_service.py b/services/deployment_service.py index 010ec59..f257950 100644 --- a/services/deployment_service.py +++ b/services/deployment_service.py @@ -65,6 +65,8 @@ def create_deployment( port: int = 80, replicas: int = 1, secret_name: Optional[str] = None, + command: Optional[list[str]] = None, + args: Optional[list[str]] = None, ): if settings.KUBERNETES_DRY_RUN: logger.info("Dry run: deployment %s/%s with image %s would be created", namespace, name, image) @@ -78,6 +80,8 @@ def create_deployment( env_from=[ client.V1EnvFromSource(secret_ref=client.V1SecretEnvSource(name=secret_name)) ] if secret_name else None, + command=command, + args=args, ) template = client.V1PodTemplateSpec( metadata=client.V1ObjectMeta(labels={"app": name}), @@ -154,7 +158,11 @@ def provision_application(db: Session, user: User, request: DeploymentCreateRequ ingress_host=ingress_host, url=f"https://{ingress_host}", status="provisioning", - metadata_json={"env_keys": sorted(request.env.keys())}, + metadata_json={ + "env_keys": sorted(request.env.keys()), + "command": request.command, + "args": request.args, + }, ) db.add(deployment) db.commit() @@ -171,6 +179,8 @@ def provision_application(db: Session, user: User, request: DeploymentCreateRequ request.port, request.replicas, secret_name=f"{request.name}-env" if request.env else None, + command=request.command, + args=request.args, ), expose_service(namespace, request.name, 80, request.port), create_ingress(namespace, request.name, request.name, 80, ingress_host), diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..a81f905 --- /dev/null +++ b/web/app.js @@ -0,0 +1,298 @@ +const state = { + token: localStorage.getItem("cloudforge_token") || "", + mode: "login", + catalog: { apps: [], images: [] }, + selectedTemplate: null, +}; + +const elements = { + sessionStatus: document.querySelector("#sessionStatus"), + logoutButton: document.querySelector("#logoutButton"), + loginTab: document.querySelector("#loginTab"), + registerTab: document.querySelector("#registerTab"), + authForm: document.querySelector("#authForm"), + authSubmit: document.querySelector("#authSubmit"), + authMessage: document.querySelector("#authMessage"), + username: document.querySelector("#username"), + password: document.querySelector("#password"), + deployForm: document.querySelector("#deployForm"), + templateSelect: document.querySelector("#templateSelect"), + imageSelect: document.querySelector("#imageSelect"), + deployMessage: document.querySelector("#deployMessage"), + appsList: document.querySelector("#appsList"), + refreshButton: document.querySelector("#refreshButton"), + logsTarget: document.querySelector("#logsTarget"), + logsOutput: document.querySelector("#logsOutput"), + modePill: document.querySelector("#modePill"), +}; + +function authHeaders() { + return state.token ? { Authorization: `Bearer ${state.token}` } : {}; +} + +async function api(path, options = {}) { + const response = await fetch(path, { + ...options, + headers: { + "Content-Type": "application/json", + ...authHeaders(), + ...(options.headers || {}), + }, + }); + const contentType = response.headers.get("content-type") || ""; + const payload = contentType.includes("application/json") ? await response.json() : await response.text(); + if (!response.ok) { + const detail = payload.detail || payload || "Request failed"; + throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail)); + } + return payload; +} + +function setMode(mode) { + state.mode = mode; + elements.loginTab.classList.toggle("active", mode === "login"); + elements.registerTab.classList.toggle("active", mode === "register"); + elements.authSubmit.textContent = mode === "login" ? "Login" : "Register"; + elements.authMessage.textContent = ""; +} + +function setSignedIn(signedIn) { + elements.sessionStatus.textContent = signedIn ? "Signed in" : "Signed out"; + elements.logoutButton.disabled = !signedIn; + elements.refreshButton.disabled = !signedIn; + elements.deployForm.querySelectorAll("input, textarea, select, button").forEach((field) => { + field.disabled = !signedIn; + }); + elements.deployMessage.textContent = signedIn ? "" : "Sign in to deploy and manage apps."; +} + +function setValue(selector, value) { + document.querySelector(selector).value = value; +} + +function renderCatalog(catalog) { + state.catalog = catalog; + elements.templateSelect.innerHTML = [ + '', + ...catalog.apps.map((app) => ``), + ].join(""); + + elements.imageSelect.innerHTML = [ + '', + ...catalog.images.map((image) => ``), + ].join(""); +} + +async function loadCatalog() { + const catalog = await api("/catalog", { headers: {} }); + renderCatalog(catalog); +} + +function applyTemplate(templateId) { + const template = state.catalog.apps.find((app) => app.id === templateId); + state.selectedTemplate = template || null; + if (!template) return; + setValue("#appName", template.default_app_name); + setValue("#image", template.image); + setValue("#imageSelect", template.image); + setValue("#port", template.port); + setValue("#replicas", template.replicas); + setValue("#minReplicas", template.min_replicas); + setValue("#maxReplicas", template.max_replicas); + setValue("#cpuThreshold", template.cpu_threshold); +} + +function applyImage(imageRef) { + const image = state.catalog.images.find((item) => item.image === imageRef); + if (!image) return; + setValue("#image", image.image); + setValue("#port", image.port); +} + +function parseEnvVars(value) { + return value + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .reduce((acc, line) => { + const [key, ...rest] = line.split("="); + if (key && rest.length) { + acc[key.trim()] = rest.join("=").trim(); + } + return acc; + }, {}); +} + +function statusClass(status) { + return `pill status-${status}`; +} + +function renderApps(apps) { + if (!apps.length) { + elements.appsList.innerHTML = '
'; + return; + } + + elements.appsList.innerHTML = apps + .map((app) => ` +Internal Developer Platform
+Choose an app and click Logs.+