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 = '

No apps yet. Deploy your first image above.

'; + return; + } + + elements.appsList.innerHTML = apps + .map((app) => ` +
+
+
+

${app.name}

+

${app.image}

+
+ ${app.status} +
+

Namespace: ${app.namespace}

+

Replicas: ${app.replicas} | Port: ${app.port}

+

URL: ${app.url ? `${app.url}` : "pending"}

+ ${app.last_error ? `

Error: ${app.last_error}

` : ""} +
+ + + +
+
+ `) + .join(""); +} + +async function loadReadiness() { + const ready = await api("/readyz", { headers: {} }); + elements.modePill.textContent = ready.kubernetes_dry_run || ready.terraform_dry_run ? "dry-run" : ready.environment; +} + +async function loadApps() { + if (!state.token) { + renderApps([]); + return; + } + const apps = await api("/deployments"); + renderApps(apps); +} + +async function handleAuth(event) { + event.preventDefault(); + const username = elements.username.value.trim(); + const password = elements.password.value; + + try { + if (state.mode === "register") { + await api("/auth/register", { + method: "POST", + body: JSON.stringify({ username, password }), + }); + elements.authMessage.textContent = "Account created. Logging you in..."; + } + + const login = await api("/auth/login", { + method: "POST", + body: JSON.stringify({ username, password }), + }); + state.token = login.access_token; + localStorage.setItem("cloudforge_token", state.token); + setSignedIn(true); + elements.authMessage.textContent = "Signed in."; + await loadApps(); + } catch (error) { + elements.authMessage.textContent = error.message; + } +} + +async function handleDeploy(event) { + event.preventDefault(); + elements.deployMessage.textContent = "Deploying..."; + + const payload = { + name: document.querySelector("#appName").value.trim(), + image: document.querySelector("#image").value.trim(), + port: Number(document.querySelector("#port").value), + replicas: Number(document.querySelector("#replicas").value), + min_replicas: Number(document.querySelector("#minReplicas").value), + max_replicas: Number(document.querySelector("#maxReplicas").value), + cpu_threshold: Number(document.querySelector("#cpuThreshold").value), + env: parseEnvVars(document.querySelector("#envVars").value), + }; + + if (state.selectedTemplate?.command) { + payload.command = state.selectedTemplate.command; + } + + if (state.selectedTemplate?.args) { + payload.args = state.selectedTemplate.args; + } + + const ingressHost = document.querySelector("#ingressHost").value.trim(); + if (ingressHost) { + payload.ingress_host = ingressHost; + } + + try { + const app = await api("/deployments", { + method: "POST", + body: JSON.stringify(payload), + }); + elements.deployMessage.textContent = `${app.name} is ${app.status}.`; + elements.deployForm.reset(); + document.querySelector("#port").value = 80; + document.querySelector("#replicas").value = 2; + document.querySelector("#minReplicas").value = 1; + document.querySelector("#maxReplicas").value = 5; + document.querySelector("#cpuThreshold").value = 70; + elements.templateSelect.value = ""; + elements.imageSelect.value = ""; + state.selectedTemplate = null; + await loadApps(); + } catch (error) { + elements.deployMessage.textContent = error.message; + } +} + +async function handleAppAction(event) { + const button = event.target.closest("button[data-action]"); + if (!button) return; + + const action = button.dataset.action; + try { + if (action === "status") { + const app = await api(`/deployments/${button.dataset.id}`); + elements.logsTarget.textContent = app.name; + elements.logsOutput.textContent = JSON.stringify(app, null, 2); + } + + if (action === "logs") { + elements.logsTarget.textContent = `${button.dataset.namespace}/${button.dataset.name}`; + const logs = await api(`/logs/${button.dataset.name}?namespace=${button.dataset.namespace}`); + elements.logsOutput.textContent = logs.logs; + } + + if (action === "delete") { + await api(`/deployments/${button.dataset.id}`, { method: "DELETE" }); + await loadApps(); + } + } catch (error) { + elements.logsOutput.textContent = error.message; + } +} + +elements.loginTab.addEventListener("click", () => setMode("login")); +elements.registerTab.addEventListener("click", () => setMode("register")); +elements.authForm.addEventListener("submit", handleAuth); +elements.deployForm.addEventListener("submit", handleDeploy); +elements.templateSelect.addEventListener("change", (event) => applyTemplate(event.target.value)); +elements.imageSelect.addEventListener("change", (event) => applyImage(event.target.value)); +elements.refreshButton.addEventListener("click", loadApps); +elements.appsList.addEventListener("click", handleAppAction); +elements.logoutButton.addEventListener("click", () => { + state.token = ""; + localStorage.removeItem("cloudforge_token"); + setSignedIn(false); + renderApps([]); +}); + +setSignedIn(Boolean(state.token)); +loadCatalog().catch(() => {}); +loadReadiness().catch(() => {}); +loadApps().catch((error) => { + elements.appsList.innerHTML = `

${error.message}

`; +}); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..0ab7036 --- /dev/null +++ b/web/index.html @@ -0,0 +1,123 @@ + + + + + + CloudForge Developer Dashboard + + + +
+
+

Internal Developer Platform

+

CloudForge Dashboard

+
+
+ Signed out + +
+
+ +
+
+
+

Access

+ JWT +
+
+ + +
+
+ + + +
+

+
+ +
+
+

Create App

+ dry-run +
+
+ + + + + + + + + + + + +
+

+
+ +
+
+

Apps

+ +
+
+
+ +
+
+

Logs

+ select app +
+
Choose an app and click Logs.
+
+
+ + + + diff --git a/web/styles.css b/web/styles.css new file mode 100644 index 0000000..daea9ce --- /dev/null +++ b/web/styles.css @@ -0,0 +1,291 @@ +:root { + color-scheme: light; + --bg: #f6f7f9; + --panel: #ffffff; + --text: #18202a; + --muted: #657284; + --line: #d9dee7; + --primary: #0f766e; + --primary-dark: #115e59; + --danger: #b42318; + --warn: #b54708; + --ok: #027a48; + --shadow: 0 12px 30px rgba(16, 24, 40, 0.08); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: var(--bg); + color: var(--text); + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + padding: 24px 32px; + border-bottom: 1px solid var(--line); + background: #ffffff; +} + +.eyebrow { + margin: 0 0 4px; + color: var(--muted); + font-size: 13px; + font-weight: 700; + text-transform: uppercase; +} + +h1, +h2, +p { + margin-top: 0; +} + +h1 { + margin-bottom: 0; + font-size: 28px; +} + +h2 { + margin-bottom: 0; + font-size: 18px; +} + +.session, +.panel-heading, +.tabs, +.app-actions { + display: flex; + align-items: center; + gap: 10px; +} + +.layout { + display: grid; + grid-template-columns: 340px minmax(0, 1fr); + gap: 18px; + padding: 24px 32px 40px; +} + +.panel { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + box-shadow: var(--shadow); + padding: 18px; +} + +.deploy-panel, +.logs-panel { + grid-column: span 1; +} + +.apps-panel { + grid-column: 1 / -1; +} + +.logs-panel { + grid-column: 1 / -1; +} + +.panel-heading { + justify-content: space-between; + margin-bottom: 16px; +} + +.pill { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 3px 8px; + border-radius: 999px; + background: #e8f3f1; + color: var(--primary-dark); + font-size: 12px; + font-weight: 700; +} + +.tabs { + margin-bottom: 14px; + border-bottom: 1px solid var(--line); +} + +.tab { + border: 0; + border-bottom: 2px solid transparent; + background: transparent; + color: var(--muted); + padding: 8px 2px; +} + +.tab.active { + border-color: var(--primary); + color: var(--primary-dark); + font-weight: 700; +} + +.form { + display: grid; + gap: 14px; +} + +.grid-form { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.wide { + grid-column: 1 / -1; +} + +label { + display: grid; + gap: 6px; + color: var(--muted); + font-size: 13px; + font-weight: 700; +} + +input, +textarea, +select { + width: 100%; + border: 1px solid var(--line); + border-radius: 6px; + padding: 10px 11px; + color: var(--text); + font: inherit; +} + +textarea { + resize: vertical; +} + +button { + min-height: 38px; + border: 1px solid var(--primary); + border-radius: 6px; + background: var(--primary); + color: #ffffff; + font: inherit; + font-weight: 700; + cursor: pointer; +} + +button:hover { + background: var(--primary-dark); +} + +button:disabled, +input:disabled, +textarea:disabled, +select:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +button.ghost { + background: #ffffff; + color: var(--primary-dark); +} + +button.danger { + border-color: var(--danger); + background: #ffffff; + color: var(--danger); +} + +.message { + min-height: 20px; + margin: 12px 0 0; + color: var(--muted); + font-size: 14px; +} + +.apps-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 14px; +} + +.app-card { + display: grid; + gap: 12px; + border: 1px solid var(--line); + border-radius: 8px; + padding: 14px; +} + +.app-card header { + display: flex; + justify-content: space-between; + gap: 12px; +} + +.app-card h3 { + margin: 0 0 4px; + font-size: 17px; +} + +.meta { + margin: 0; + color: var(--muted); + font-size: 13px; + overflow-wrap: anywhere; +} + +.status-running { + background: #ecfdf3; + color: var(--ok); +} + +.status-failed, +.status-delete_failed { + background: #fef3f2; + color: var(--danger); +} + +.status-provisioning { + background: #fffaeb; + color: var(--warn); +} + +pre { + min-height: 220px; + max-height: 440px; + overflow: auto; + margin: 0; + border-radius: 8px; + background: #111827; + color: #d1fae5; + padding: 16px; + font-size: 13px; + line-height: 1.5; + white-space: pre-wrap; +} + +@media (max-width: 900px) { + .topbar, + .layout { + padding-left: 18px; + padding-right: 18px; + } + + .topbar, + .layout, + .grid-form { + grid-template-columns: 1fr; + } + + .topbar { + align-items: flex-start; + flex-direction: column; + } +}