diff --git a/.cursor/hooks.json b/.cursor/hooks.json new file mode 100644 index 0000000..2f21a45 --- /dev/null +++ b/.cursor/hooks.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "hooks": { + "stop": [ + { + "command": "bash .cursor/hooks/run-strict-gates.sh", + "timeout": 3600, + "failClosed": true + } + ] + } +} diff --git a/.cursor/hooks/run-strict-gates.sh b/.cursor/hooks/run-strict-gates.sh new file mode 100644 index 0000000..424639b --- /dev/null +++ b/.cursor/hooks/run-strict-gates.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Read hook payload (unused for now, but consumed to avoid broken pipes) +HOOK_INPUT="$(cat || true)" +export HOOK_INPUT + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$REPO_ROOT" +LOG_FILE="context/quality-gate-feedback.log" +mkdir -p "$(dirname "$LOG_FILE")" + +CHANGED_FILES="$(git status --porcelain | awk '{print $2}')" + +if [[ -z "$CHANGED_FILES" ]]; then + echo "[strict-gates] No changed files detected; skipping checks." + printf "%s | skip | no-changes\n" "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" >> "$LOG_FILE" + exit 0 +fi + +RUN_BACKEND=0 +RUN_FRONTEND=0 + +while IFS= read -r file; do + [[ -z "$file" ]] && continue + [[ "$file" == backend/* ]] && RUN_BACKEND=1 + [[ "$file" == frontend/* ]] && RUN_FRONTEND=1 +done <<< "$CHANGED_FILES" + +EXIT_CODE=0 + +if [[ $RUN_BACKEND -eq 1 ]]; then + echo "[strict-gates] Running backend tests: pytest -q" + if ! (cd backend && pytest -q); then + echo "[strict-gates] Backend quality gate failed." + EXIT_CODE=1 + fi +fi + +if [[ $RUN_FRONTEND -eq 1 ]]; then + echo "[strict-gates] Running frontend lint: npm run lint" + if ! (cd frontend && npm run lint); then + echo "[strict-gates] Frontend lint gate failed." + EXIT_CODE=1 + fi + + echo "[strict-gates] Running frontend build: npm run build" + if ! (cd frontend && npm run build); then + echo "[strict-gates] Frontend build gate failed." + EXIT_CODE=1 + fi +fi + +if [[ $RUN_BACKEND -eq 0 && $RUN_FRONTEND -eq 0 ]]; then + echo "[strict-gates] No backend/frontend source changes detected; skipping checks." + printf "%s | skip | non-app-changes\n" "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" >> "$LOG_FILE" +fi + +if [[ $EXIT_CODE -eq 0 ]]; then + printf "%s | pass | backend=%s frontend=%s\n" "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" "$RUN_BACKEND" "$RUN_FRONTEND" >> "$LOG_FILE" +else + printf "%s | fail | backend=%s frontend=%s\n" "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" "$RUN_BACKEND" "$RUN_FRONTEND" >> "$LOG_FILE" +fi + +exit "$EXIT_CODE" diff --git a/.cursor/rules/backend-api-routers.mdc b/.cursor/rules/backend-api-routers.mdc index d2bc0f7..6836a7e 100644 --- a/.cursor/rules/backend-api-routers.mdc +++ b/.cursor/rules/backend-api-routers.mdc @@ -35,5 +35,6 @@ async def analyze( - Routers **must not** run raw SQLAlchemy queries, `session.execute`, or ad-hoc ORM loads—**DB access goes through repositories/services**. - Return Pydantic schema instances directly (FastAPI serializes them). - File uploads use `File(...)` / `UploadFile` with manual size checks before handing off to services. +- For project uploads, keep behavior consistent with current product flow: safe scan + queue analysis of detected files, and preserve explicit compose deploy gating in deploy endpoints. - Each router file: `router = APIRouter(prefix=..., tags=[...])` at module level. - All routers aggregated in `app/api/router.py` via `api_router.include_router(...)`. diff --git a/.cursor/rules/backend-infrastructure.mdc b/.cursor/rules/backend-infrastructure.mdc index 0eacf33..ef1910f 100644 --- a/.cursor/rules/backend-infrastructure.mdc +++ b/.cursor/rules/backend-infrastructure.mdc @@ -33,12 +33,13 @@ alwaysApply: false - `publish_event(DomainEvent)` publishes to multiple channels based on event context. - `subscribe(channel)` returns an `AsyncIterator[dict]` with `try/finally` cleanup. -- Channel patterns: `user:{id}:events`, `job:{id}:events`, `container:{id}:metrics`. +- Channel patterns: `user:{id}:events`, `job:{id}:events`, `container:{id}:metrics`, `user:{id}:container:{id}:metrics`. ## Deploy State in Redis - Key: `deploy:{user_id}:{job_id}` — JSON payload with 6-hour TTL. - Payload should include **`containers`**, aggregates **`running_count`**, **`exited_count`**, **`unhealthy_count`**, plus **`runtime_state`** when tracked (overall deploy/monitor rollup). +- Runtime state values can include: `none`, `running`, `partial`, `unhealthy`, `exited`, `failed`, `stopping`, `stopped_by_user`, `cleanup_completed`. - Stop signal: `deploy-stop:{user_id}:{job_id}` — flag key. ## Subprocess Tools (`tools/process.py`) diff --git a/.cursor/rules/backend-plugins.mdc b/.cursor/rules/backend-plugins.mdc index 315ca3a..c1eee48 100644 --- a/.cursor/rules/backend-plugins.mdc +++ b/.cursor/rules/backend-plugins.mdc @@ -38,9 +38,19 @@ The `context` dict passed to `run()` may contain: ## Two Plugin Flavors -- **Subprocess plugins** (hadolint, dclint): Use `run_command()` from `infrastructure/tools/process.py`. Write content to a tempfile, invoke the binary, parse JSON stdout. 45-second timeout. -- **Pure Python plugins** (security_scanner, runnability, resource_estimation): Analyze content in-process with no external dependencies. +- **Subprocess plugins** (hadolint): Use `run_command()` from `infrastructure/tools/process.py`. Write content to a tempfile, invoke the binary, parse JSON stdout. 45-second timeout. +- **Pure Python plugins** (`compose_validator`, `compose_runnability`, `security_scanner`, `resource_estimation`): Analyze content in-process with no external dependencies. ## Issue Normalization -`AnalysisService._normalize_issue()` handles multiple output formats (hadolint JSON, dclint rdjson, generic dicts). Plugin findings just need to be a list of dicts — normalization happens in the service layer. +`AnalysisService._normalize_issue()` handles multiple output formats (hadolint JSON and generic dict outputs). Plugin findings just need to be a list of dicts — normalization happens in the service layer. + +## Current Registry + +`PLUGIN_MAP` currently includes: + +- `hadolint` +- `compose_validator` +- `compose_runnability` +- `security_scanner` +- `resource_estimation` diff --git a/.cursor/rules/docker-and-devops.mdc b/.cursor/rules/docker-and-devops.mdc index a08e9a3..ad08622 100644 --- a/.cursor/rules/docker-and-devops.mdc +++ b/.cursor/rules/docker-and-devops.mdc @@ -7,7 +7,7 @@ alwaysApply: true ## 6-Service Compose Stack -Stack is defined in `docker-compose.yaml` at the repo root. +Compose stack is split across `compose.yaml` (base) with overlays `compose.dev.yaml` and `compose.prod.yaml` at the repo root. | Service | Role | Port | |---|---|---| @@ -26,13 +26,14 @@ Stack is defined in `docker-compose.yaml` at the repo root. - **Single-image, dual-role backend**: Same Dockerfile produces `api` (uvicorn) and `worker` (arq CMD override). - **Shared volume**: `uploads_data` mounted in both `api` and `worker` for uploaded file access. -- **DinD isolation**: Worker talks to `dind` sidecar via `DOCKER_HOST=tcp://dind:2375` (no TLS). Never exposes host Docker socket. +- **DinD isolation**: Worker talks to DinD over TLS (`DOCKER_HOST=tcp://docker:2376`, certs via `DOCKER_CERT_PATH`, `DOCKER_TLS_VERIFY=1`). Never exposes host Docker socket. ## Essential Commands ```bash -cp backend/.env.example backend/.env # one-time setup -docker compose up --build # start full stack +cp .env.example .env # one-time setup +mkdir -p secrets && cp secrets/postgres_password.txt.example secrets/postgres_password.txt && cp secrets/jwt_secret.txt.example secrets/jwt_secret.txt +docker compose --env-file .env -f compose.yaml -f compose.dev.yaml up --build docker compose exec api alembic upgrade head # run DB migrations docker compose logs -f frontend api worker # tail logs docker compose down -v # stop + wipe volumes @@ -41,6 +42,7 @@ docker compose down -v # stop + wipe volumes ## Configuration Backend settings loaded by `pydantic-settings` from env vars or `backend/.env`. Key toggles: +Backend settings loaded by `pydantic-settings` from env vars/secrets. Key toggles: - `APP_ENV=dev` enables permissive CORS (`allow_origins=["*"]`) - `APP_ENV=prod` restricts CORS -- `JWT_SECRET_KEY` must be changed in production +- `JWT_SECRET_KEY` is loaded from secrets in Compose deployments and must be strong in production diff --git a/.cursor/rules/frontend-react-patterns.mdc b/.cursor/rules/frontend-react-patterns.mdc index 3409e49..7c374dc 100644 --- a/.cursor/rules/frontend-react-patterns.mdc +++ b/.cursor/rules/frontend-react-patterns.mdc @@ -35,6 +35,12 @@ await research.jobs({ limit: 20, offset: 0 }); Also exported: `auth`, `project`, `ws`, `checkHealth`, shared types (`Job`, `ApiError`, etc.). +## Project upload UX assumptions + +- Current project upload flow queues analysis for all detected Dockerfiles/Compose files. +- Current project upload flow communicates automatic image builds in UX copy. +- Compose runtime start/deploy remains an explicit follow-up action from results/deploy controls. + ## WebSocket - Hold socket in `useRef`. diff --git a/.cursor/rules/project-architecture.mdc b/.cursor/rules/project-architecture.mdc index c8677d4..87ed7f4 100644 --- a/.cursor/rules/project-architecture.mdc +++ b/.cursor/rules/project-architecture.mdc @@ -5,6 +5,11 @@ alwaysApply: true # Project Architecture +## When to apply + +- Use this file as the architectural source of truth. +- Keep workflow details in `project-workflow.mdc` and runtime/deploy constraints in `project-runtime-safety.mdc`. + ## Backend Layers (Hexagonal / Modular-Monolith) | Layer | Path | Responsibility | @@ -25,7 +30,7 @@ alwaysApply: true ## Project Analysis Workflow -ZIP upload → **safe scan** → user **selects** Dockerfiles/Compose files → **per-file plugin runs** → merged **`ProjectAnalysisResult`** (`per_file_results`, `project_summary`, `service_mappings`, optional **`image_build_results`**). Builds and Compose **run/deploy only on explicit user action** (`build_selected_images`, deploy controls). Details: **`project-workflow.mdc`**, **`project-runtime-safety.mdc`**. +ZIP upload → **safe scan** → auto-queue analysis for all detected Dockerfiles/Compose files → **per-file plugin runs** → merged **`ProjectAnalysisResult`** (`per_file_results`, `project_summary`, `service_mappings`, optional **`image_build_results`**). Upload flow currently defaults `build_selected_images=true`; Compose **run/deploy remains explicit** via deploy controls (`run_stack`). Details: **`project-workflow.mdc`**, **`project-runtime-safety.mdc`**. ## Redis Channel Naming @@ -43,7 +48,7 @@ Plugins live in `backend/app/plugins/`. Each extends `BasePlugin` (single `async ## Docker-in-Docker (DinD) -Worker uses `DOCKER_HOST=tcp://dind:2375` to reach an isolated DinD sidecar. All `docker-compose` subprocess calls execute inside the worker container — never the host socket. +Worker uses TLS DinD networking (`DOCKER_HOST=tcp://docker:2376`, `DOCKER_TLS_VERIFY=1`, `DOCKER_CERT_PATH=/certs/client`) to reach an isolated sidecar. All `docker-compose` subprocess calls execute inside the worker container — never the host socket. ## Research Privacy @@ -51,7 +56,7 @@ Research endpoints and `ResearchAnalytics` UI expose **aggregated / anonymized** ## Runtime Monitoring -Workers and Docker gateway expose **metrics** and **final container state** (running, exited, unhealthy, partial). Frontend monitoring pages must distinguish states honestly—no “Live” for exited containers. See **`results-and-monitoring.mdc`** and **`project-runtime-safety.mdc`**. +Workers and Docker gateway expose **metrics** and **final container state** (`running`, `partial`, `unhealthy`, `exited`, `failed`, `stopped_by_user`, `cleanup_completed`). Frontend monitoring pages must distinguish states honestly—no "Live" for exited containers. See **`results-and-monitoring.mdc`** and **`project-runtime-safety.mdc`**. ## Frontend diff --git a/.cursor/rules/project-runtime-safety.mdc b/.cursor/rules/project-runtime-safety.mdc index 4b17eb1..4d11290 100644 --- a/.cursor/rules/project-runtime-safety.mdc +++ b/.cursor/rules/project-runtime-safety.mdc @@ -13,7 +13,7 @@ alwaysApply: false ## Automatic execution - User-uploaded Docker projects **must never** run automatically after upload or scan. -- **Image build** requires explicit user selection: **`build_selected_images=true`**. +- Current upload flow sets **`build_selected_images=true`** by default for project jobs; worker still gates image-build execution on this payload flag. - **Compose deploy/run** requires an explicit user action (API flag or control that represents user intent)—no silent `up`. ## Docker / tags / context @@ -31,5 +31,5 @@ alwaysApply: false - **Exited** containers are a **first-class** runtime state—same weight as running/unhealthy. - **Do not** report exited containers as **running**. -- **`deployStatus`** (or equivalent) should distinguish: **`none`**, **`running`**, **`partial`**, **`exited`**, **`unhealthy`**, **`stopping`**. +- **`deployStatus`** (or equivalent) should distinguish: **`none`**, **`running`**, **`partial`**, **`exited`**, **`failed`**, **`unhealthy`**, **`stopping`**, **`stopped_by_user`**, **`cleanup_completed`**. - Persist final runtime detail where applicable: **exited state**, **`exit_code`**, **`last_logs`**, **`oom_killed`**, **`restart_count`**. diff --git a/.cursor/rules/project-workflow.mdc b/.cursor/rules/project-workflow.mdc index 7bbca96..ab83a54 100644 --- a/.cursor/rules/project-workflow.mdc +++ b/.cursor/rules/project-workflow.mdc @@ -1,23 +1,28 @@ --- -description: End-to-end project ZIP workflow — scan, select, analyze, merge, optional build/run, monitoring +description: End-to-end project ZIP workflow — scan, auto-analyze detected files, auto-build images, explicit deploy/run alwaysApply: true --- # Project Workflow +## When to apply + +- Use this file as the canonical user flow for project upload/analyze/build/deploy. +- Keep low-level runtime semantics (deploy state names, stop/failure behavior) in `project-runtime-safety.mdc`. + ## Flow 1. User uploads a **ZIP** archive. 2. Backend **scans** the tree safely and reports **detected assets** (Dockerfiles, compose files, etc.). -3. User **selects** which Dockerfiles / Compose files to analyze. -4. Each **selected** file is analyzed **separately**; results are merged into a **project** job outcome. -5. **Optional**: user-selected Dockerfile **image builds** (`build_selected_images=true`). -6. **Optional**: **Compose deploy/run** only when the user explicitly triggers it. +3. Upload flow auto-queues analysis for **all detected** Dockerfiles / Compose files. +4. Each detected file is analyzed **separately**; results are merged into a **project** job outcome. +5. Upload flow currently enables **image builds by default** (`build_selected_images=true` in payload). +6. **Compose deploy/run** remains explicit: user-triggered action only (deploy API with `run_stack=true`). 7. **Runtime monitoring** for running stacks: handle **exited**, **unhealthy**, and **partial** container states as first-class UI/backend states. ## Rules -- **Do not** analyze unselected files except when an **auto** or **full-project** fallback explicitly requires it. -- **Do not** build images unless **`build_selected_images=true`** (user opt-in). +- In current product behavior, project upload analyzes all scan hits by default (no selection step in `/api/v1/project/upload`). +- In current product behavior, upload sets **`build_selected_images=true`** by default; worker still gates build execution on this flag. - **Do not** run or deploy Compose unless the user **explicitly** triggers run/deploy. - Preserve merged payload fields: **`per_file_results`**, **`image_build_results`**, **`service_mappings`**, **`project_summary`**—do not drop or overwrite them unintentionally on merge or re-fetch. diff --git a/.cursor/rules/research-privacy.mdc b/.cursor/rules/research-privacy.mdc index aaccaec..d38be71 100644 --- a/.cursor/rules/research-privacy.mdc +++ b/.cursor/rules/research-privacy.mdc @@ -32,3 +32,4 @@ Research responses and aggregates must be **privacy-safe** and **anonymized**. - Use **`anonymized_submitter`** (or equivalent stable anonymous bucket) instead of user identity. - Expose only **`public_metadata`** and **`public_result`** (or fields explicitly approved for research)—nothing from the forbidden list above. +- Keep `public_metadata` allow-listed (for example file extension and aggregate-safe flags), and keep `public_result` to sanitized aggregates (counts/severity/code/doc URLs) without source snippets. diff --git a/.cursor/rules/results-and-monitoring.mdc b/.cursor/rules/results-and-monitoring.mdc index 36982e6..e67fd61 100644 --- a/.cursor/rules/results-and-monitoring.mdc +++ b/.cursor/rules/results-and-monitoring.mdc @@ -27,6 +27,6 @@ alwaysApply: false ## Monitoring -- Surface **running**, **exited**, **unhealthy**, **partial**, and **disconnected** (WS/client) states **clearly**—different copy, color, and icons; no ambiguous “active” for dead processes. +- Surface **running**, **partial**, **unhealthy**, **exited**, **failed**, **stopping**, **stopped_by_user**, **cleanup_completed**, and **disconnected** (WS/client) states **clearly**—different copy, color, and icons; no ambiguous "active" for dead processes. - If a container **exits before metrics** exist, show an explicit message (no empty chart pretending health). -- **Do not** label exited containers **“Live”** or imply continuous metrics when the process has stopped. +- **Do not** label exited/failed/stopped containers as **"Live"** or imply continuous metrics when the process has stopped. diff --git a/.cursor/rules/rule-governance.mdc b/.cursor/rules/rule-governance.mdc new file mode 100644 index 0000000..d841023 --- /dev/null +++ b/.cursor/rules/rule-governance.mdc @@ -0,0 +1,32 @@ +--- +description: Rule governance map — topic owners, scope boundaries, and de-duplication policy +alwaysApply: true +--- + +# Rule Governance + +## Purpose + +Keep rules concise and non-duplicative by assigning a single primary owner file per topic. + +## Topic owners + +- **Architecture owner:** `project-architecture.mdc` (system shape and cross-layer boundaries) +- **Workflow owner:** `project-workflow.mdc` (upload/analyze/build/deploy user flow) +- **Runtime safety owner:** `project-runtime-safety.mdc` (execution constraints and deploy/runtime semantics) +- **Testing owner:** `testing-conventions.mdc` (quality gates and test expectations) +- **Backend implementation owners:** `backend-*.mdc` files (router/infrastructure/plugin/python specifics) +- **Frontend implementation owners:** `frontend-*.mdc` + `results-and-monitoring.mdc` +- **Privacy owner:** `research-privacy.mdc` +- **DevOps owner:** `docker-and-devops.mdc` + +## De-duplication policy + +- Put canonical statements in the owner file and keep references elsewhere short. +- When two rules overlap, prefer cross-reference instead of repeating detailed guidance. +- Use "current behavior" wording when documenting code-backed behavior that may change later. + +## When to apply + +- Apply this governance rule whenever adding/updating rule files. +- If a rule grows broad or duplicates another rule, split or trim it before adding new guidance. diff --git a/.cursor/rules/testing-conventions.mdc b/.cursor/rules/testing-conventions.mdc index 2c68643..38e13e7 100644 --- a/.cursor/rules/testing-conventions.mdc +++ b/.cursor/rules/testing-conventions.mdc @@ -5,6 +5,11 @@ alwaysApply: true # Testing Conventions +## When to apply + +- Use this file as the canonical quality gate rule for backend/frontend checks. +- Keep feature-specific behavior expectations in the corresponding workflow/runtime/privacy rules. + ## Backend (pytest) - **Flat structure**: All tests in `backend/tests/`, no nested directories. @@ -36,7 +41,7 @@ Prefer `monkeypatch.setattr("app.api.routers.compose.JobRepository", FakeRepo)` When touching these areas, keep or extend tests in: - **`test_project_scan.py`** — project ZIP scan behavior -- **`test_project_build.py`** — optional image builds, failure isolation +- **`test_project_build.py`** — project image build behavior (currently enabled by upload flow) and failure isolation - **`test_container_lifecycle.py`** — start/stop, exit, unhealthy states - **`test_research_privacy.py`** — research responses leak no forbidden fields diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a8c2831 --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +COMPOSE_PROJECT_NAME=docker-platform + +# Core app configuration +APP_ENV=dev +POSTGRES_USER=postgres +POSTGRES_DB=docker_platform +API_PORT=8000 +FRONTEND_PORT=3000 +ADMINER_PORT=8080 +VITE_API_BASE_URL= + +# Optional prod-like local resource tuning +API_MEM_LIMIT=1g +API_CPU_LIMIT=1.00 +WORKER_MEM_LIMIT=1g +WORKER_CPU_LIMIT=1.00 +FRONTEND_MEM_LIMIT=512m +FRONTEND_CPU_LIMIT=0.50 +POSTGRES_MEM_LIMIT=1g +POSTGRES_CPU_LIMIT=1.00 +REDIS_MEM_LIMIT=512m +REDIS_CPU_LIMIT=0.50 + +# Secrets are loaded from files (do not put real values here): +# - secrets/postgres_password.txt +# - secrets/jwt_secret.txt diff --git a/.gitignore b/.gitignore index 53e4555..a666bc0 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,8 @@ logs/ .env .env.* !.env.example +secrets/postgres_password.txt +secrets/jwt_secret.txt # Python __pycache__/ @@ -41,3 +43,6 @@ temp/ # Project runtime data storage/uploads/ + +# Local transcript archives (Cursor history backups) +context/transcript-backups/ diff --git a/README-compose.md b/README-compose.md new file mode 100644 index 0000000..139042b --- /dev/null +++ b/README-compose.md @@ -0,0 +1,86 @@ +# Docker Compose Runbook + +This project now uses a split Compose layout: + +- `compose.yaml` (base) +- `compose.dev.yaml` (development overrides) +- `compose.prod.yaml` (production-like local overrides) + +Lifecycle scripts (mode-aware) are available: + +- `./scripts/start.sh --dev` +- `./scripts/start.sh --prod` +- `./scripts/status.sh --dev|--prod` +- `./scripts/stop.sh --dev|--prod [--wipe]` + +## 1) Prepare environment and secrets + +```bash +cp .env.example .env +mkdir -p secrets +cp secrets/postgres_password.txt.example secrets/postgres_password.txt +cp secrets/jwt_secret.txt.example secrets/jwt_secret.txt +``` + +Edit: + +- `secrets/postgres_password.txt` +- `secrets/jwt_secret.txt` + +## 2) Development start + +```bash +docker compose --env-file .env -f compose.yaml -f compose.dev.yaml up --build +``` + +or + +```bash +./scripts/start.sh --dev +``` + +## 3) Production-like local start + +```bash +docker compose --env-file .env -f compose.yaml -f compose.prod.yaml up --build -d +``` + +or + +```bash +./scripts/start.sh --prod +``` + +## 4) Development with tools profile (Adminer) + +```bash +docker compose --env-file .env -f compose.yaml -f compose.dev.yaml --profile tools up --build +``` + +## 5) Logs + +```bash +docker compose logs -f api worker frontend +``` + +## 6) Reset volumes + +```bash +docker compose down -v +``` + +## Why DinD uses TLS (and still needs privileged mode) + +- Worker and DinD communicate over `tcp://docker:2376` (DinD network alias) with TLS (`DOCKER_TLS_VERIFY=1`, `DOCKER_CERT_PATH=/certs/client`), so Docker daemon traffic is encrypted and authenticated. +- DinD is intentionally not exposed to host ports. +- `privileged: true` is still required for Docker-in-Docker daemon operation (nested container runtime and storage driver behavior). + +## APP_ENV requirement + +The backend only accepts: + +- `dev` +- `test` +- `prod` + +Do not use `APP_ENV=production`. diff --git a/README.md b/README.md index ef0532d..2be7a4a 100644 --- a/README.md +++ b/README.md @@ -40,16 +40,16 @@ A multi-tenant platform for analyzing, scoring, and deploying Docker artifacts. ### 3. Project Archive Analysis 1. User uploads a `.zip` containing a full project. -2. The backend extracts the archive and intelligently detects `Dockerfile`s and `docker-compose.yml` files. -3. It performs analysis on all detected artifacts. -4. If a runnable Compose stack is found, the entire project context is sent to the DinD daemon for building and deployment. +2. The backend extracts the archive and detects `Dockerfile` and Compose assets. +3. The upload flow queues analysis for all detected artifacts and currently enables image builds by default. +4. Compose runtime deploy remains explicit: users trigger run/deploy from project results controls. ## 🚀 Quick Start (Docker Compose) -Run the entire platform locally using Docker Compose: +Run the entire platform with one command: ```bash -docker compose up --build +./scripts/start.sh ``` ### Services started: @@ -65,20 +65,39 @@ docker compose up --build Open the app at `http://localhost:3000`. The frontend container proxies `/api`, `/auth`, `/health`, `/metrics`, `/docs`, `/redoc`, `/openapi.json` and `/ws/*` (with WebSocket upgrade) to the `api` service, so browser clients only need to talk to port 3000. -### One-time setup +### What `start.sh` does automatically -```bash -cp backend/.env.example backend/.env # adjust JWT secret, DB, upload size if desired +- verifies required tools (`docker`, `docker compose`, `curl`, `rg`) +- creates `.env` on first run with generated secrets (`POSTGRES_PASSWORD`, `JWT_SECRET_KEY`) +- builds and starts all services (`docker compose up -d --build`) +- runs migrations with safe bootstrap fallback when schema exists but Alembic state is missing +- waits until API and frontend are healthy before returning + +### Optional one-time `.env` customization + +If you want fixed credentials/secrets instead of auto-generated values, create/edit root `.env`: + +```env +POSTGRES_USER=postgres +POSTGRES_PASSWORD=choose-a-strong-password +JWT_SECRET_KEY=choose-a-long-random-secret ``` ### Handy commands ```bash -docker compose logs -f frontend api worker # tail live logs -docker compose exec api alembic upgrade head # run DB migrations manually -docker compose down -v # stop + wipe volumes +./scripts/status.sh # compose + health summary +docker compose logs -f frontend api worker # tail live logs +./scripts/stop.sh # stop stack +./scripts/stop.sh --wipe # stop + wipe volumes ``` +### Troubleshooting + +- If Docker daemon is not running, start Docker Desktop / Docker Engine first. +- If startup fails after a major change, run `./scripts/stop.sh --wipe` and then `./scripts/start.sh`. +- If ports are already in use, stop conflicting services or adjust host port bindings in `compose.yaml` plus overlay files. + ## 🎮 Examples & Demo We provide drop-in test artifacts to help you explore the platform's capabilities. Check out the [`examples/README.md`](examples/README.md) for a full walkthrough of: @@ -112,7 +131,7 @@ During dev the Vite server listens on `http://localhost:5173`; since `VITE_API_B If you are new to this codebase, follow this order: -1. **Run the stack first** with `docker compose up --build` so you can see the full upload → analysis → results flow end-to-end. +1. **Run the stack first** with `./scripts/start.sh` so you can see the full upload → analysis → results flow end-to-end. 2. **Read the backend entrypoint** (`backend/app/main.py`) and API router composition (`backend/app/api/router.py`) to understand how routes are wired. 3. **Study the analysis pipeline** in `backend/app/application/services/analysis_service.py` and worker task orchestration in `backend/app/workers/tasks.py`. 4. **Review plugin-based checks** under `backend/app/plugins/` (Hadolint, compose runnability, security checks, resource estimation). diff --git a/backend/Dockerfile b/backend/Dockerfile index bb675bb..7b6fdab 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.12-slim +FROM python:3.12-slim@sha256:401f6e1a67dad31a1bd78e9ad22d0ee0a3b52154e6bd30e90be696bb6a3d7461 ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 @@ -10,11 +10,12 @@ RUN apt-get update \ && rm -rf /var/lib/apt/lists/* # hadolint binary -RUN curl -L -o /usr/local/bin/hadolint "https://github.com/hadolint/hadolint/releases/latest/download/hadolint-Linux-x86_64" \ +RUN curl -L -o /usr/local/bin/hadolint "https://github.com/hadolint/hadolint/releases/download/v2.12.0/hadolint-Linux-x86_64" \ && chmod +x /usr/local/bin/hadolint # docker-compose-linter CLI (dclint) -RUN npm install -g dclint +RUN npm install -g npm@10 +RUN npm install -g dclint@3.1.0 # standalone Docker Compose v2 CLI RUN curl -L -o /usr/local/bin/docker-compose "https://github.com/docker/compose/releases/download/v2.29.7/docker-compose-linux-x86_64" \ diff --git a/backend/README.md b/backend/README.md index ec4cd6a..1e0aade 100644 --- a/backend/README.md +++ b/backend/README.md @@ -14,7 +14,7 @@ Production-grade FastAPI backend using a modular-monolith + hexagonal structure: - Multi-user auth (`/auth/register`, `/auth/login`) with JWT + API key support - User-scoped jobs/history for Dockerfile, Compose, and project archive workflows - Redis event bus with job/container event streams -- Plugin-based analyzers: hadolint, compose validator, security scanner, resource estimation +- Plugin-based analyzers: hadolint, compose validator, compose runnability, security scanner, resource estimation - Prometheus endpoint (`/metrics`) + JSON structured logging ## Run locally diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 761f7b9..ebe6643 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -10,7 +10,8 @@ config = context.config target_metadata = Base.metadata settings = get_settings() -config.set_main_option("sqlalchemy.url", settings.database_url.replace("+asyncpg", "")) +sync_url = settings.database_url.replace("postgresql+asyncpg://", "postgresql+psycopg2://") +config.set_main_option("sqlalchemy.url", sync_url) def run_migrations_offline() -> None: diff --git a/backend/alembic/versions/0002_add_scanned_job_status.py b/backend/alembic/versions/0002_add_scanned_job_status.py new file mode 100644 index 0000000..02e94f3 --- /dev/null +++ b/backend/alembic/versions/0002_add_scanned_job_status.py @@ -0,0 +1,26 @@ +"""add scanned to job_status enum + +Revision ID: 0002_add_scanned_job_status +Revises: 0001_initial_schema +Create Date: 2026-05-15 +""" + +from alembic import op + +revision = "0002_add_scanned_job_status" +down_revision = "0001_initial_schema" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute("ALTER TYPE job_status ADD VALUE IF NOT EXISTS 'scanned'") + + +def downgrade() -> None: + # Postgres does not support removing enum values; migrate rows away first then recreate. + op.execute( + "UPDATE analysis_jobs SET status = 'queued' WHERE status = 'scanned'" + ) + # Cannot drop a value from a live enum in Postgres — document-only comment. + # To fully remove 'scanned', recreate the enum without it (requires data migration). diff --git a/backend/app/api/research_privacy.py b/backend/app/api/research_privacy.py index 2027759..5aa2374 100644 --- a/backend/app/api/research_privacy.py +++ b/backend/app/api/research_privacy.py @@ -7,6 +7,7 @@ import hashlib import os +import re import uuid from typing import Any @@ -43,6 +44,121 @@ def strip_source_preview_recursive(obj: Any) -> Any: # Count key names that map to the above issue lists. _ISSUE_COUNT_KEYS: tuple[str, ...] = ("errors_count", "warnings_count", "suggestions_count", "security_count") +_FINDING_MESSAGE_MAX_LEN = 180 +_IP_RE = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b") +_DOMAIN_RE = re.compile(r"\b(?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,}\b") +_SECRET_RE = re.compile(r"(?i)\b(?:api[_-]?key|token|secret|password)\s*[:=]\s*[^\s,;]+") +_ABS_PATH_RE = re.compile(r"(?:[A-Za-z]:\\|/)[^\s,;]+") +_REL_PATH_RE = re.compile(r"(?:\.\./|\.\/)[^\s,;]+") +_MULTI_SPACE_RE = re.compile(r"\s+") + + +def _normalize_finding_code(raw_code: Any) -> str: + if isinstance(raw_code, str): + code = raw_code.strip() + if code: + return code + return "UNKNOWN" + + +def _normalize_finding_message(raw_message: Any) -> str: + if isinstance(raw_message, str): + message = raw_message.strip() + else: + message = "" + if not message: + message = "No details provided" + # Redact path / host / secret-like tokens from message-level aggregates. + message = _SECRET_RE.sub("[redacted]", message) + message = _ABS_PATH_RE.sub("[redacted-path]", message) + message = _REL_PATH_RE.sub("[redacted-path]", message) + message = _IP_RE.sub("[redacted-ip]", message) + message = _DOMAIN_RE.sub("[redacted-domain]", message) + message = _MULTI_SPACE_RE.sub(" ", message).strip() + if len(message) > _FINDING_MESSAGE_MAX_LEN: + message = f"{message[:_FINDING_MESSAGE_MAX_LEN - 3]}..." + return message + + +def _normalize_finding_severity(raw_severity: Any, *, code: str, issue_list_key: str) -> str: + if issue_list_key == "securityIssues" or code.upper().startswith("SEC"): + return "security" + severity = str(raw_severity or "").strip().lower() + if severity == "warn": + severity = "warning" + if severity == "fatal": + severity = "error" + if severity in {"error", "warning", "info"}: + return severity + if issue_list_key == "errors": + return "error" + if issue_list_key == "warnings": + return "warning" + return "info" + + +def _extract_findings_from_container(container: dict[str, Any]) -> list[dict[str, str | None]]: + findings: list[dict[str, str | None]] = [] + for issue_list_key in _ISSUE_LIST_KEYS: + issues = container.get(issue_list_key) + if not isinstance(issues, list): + continue + for issue in issues: + if not isinstance(issue, dict): + continue + code = _normalize_finding_code(issue.get("code")) + message = _normalize_finding_message(issue.get("message")) + severity = _normalize_finding_severity( + issue.get("severity"), + code=code, + issue_list_key=issue_list_key, + ) + doc_url_raw = issue.get("doc_url") + doc_url: str | None = None + if isinstance(doc_url_raw, str): + stripped = doc_url_raw.strip() + if stripped.startswith(("http://", "https://")): + doc_url = stripped + findings.append( + { + "code": code, + "severity": severity, + "message": message, + "doc_url": doc_url, + } + ) + return findings + + +def extract_public_research_findings( + result: dict[str, Any] | None, + *, + prefer_per_file: bool = False, +) -> list[dict[str, str | None]]: + """Extract privacy-safe findings from public result sources only. + + Uses only allowed findings arrays: + - top-level: errors, warnings, suggestions, securityIssues + - project per-file: per_file_results[*].errors|warnings|suggestions|securityIssues + """ + if not result or not isinstance(result, dict): + return [] + sanitized = strip_source_preview_recursive(result) + if not isinstance(sanitized, dict): + return [] + + per_file = sanitized.get("per_file_results") + per_file_rows = [row for row in per_file if isinstance(row, dict)] if isinstance(per_file, list) else [] + findings: list[dict[str, str | None]] = [] + + if not (prefer_per_file and per_file_rows): + findings.extend(_extract_findings_from_container(sanitized)) + if per_file_rows: + for row in per_file_rows: + findings.extend(_extract_findings_from_container(row)) + + return findings + def anonymize_user_id(user_id: uuid.UUID) -> str: """Return a stable, anonymized submitter token for a user. @@ -87,14 +203,16 @@ def sanitize_research_result(result: dict[str, Any] | None) -> dict[str, Any] | if not result or not isinstance(result, dict): return None - result = strip_source_preview_recursive(result) + sanitized = strip_source_preview_recursive(result) + if not isinstance(sanitized, dict): + return None safe: dict[str, Any] = {} # Copy safe scalar fields. for key in _SAFE_RESULT_SCALAR_KEYS: - if key in result: - safe[key] = result[key] + if key in sanitized: + safe[key] = sanitized[key] # Summarise each issue list. issue_codes: list[str] = [] @@ -102,7 +220,7 @@ def sanitize_research_result(result: dict[str, Any] | None) -> dict[str, Any] | severity_counts: dict[str, int] = {} for list_key, count_key in zip(_ISSUE_LIST_KEYS, _ISSUE_COUNT_KEYS): - issues = result.get(list_key) + issues = sanitized.get(list_key) if isinstance(issues, list): safe[count_key] = len(issues) for issue in issues: diff --git a/backend/app/api/routers/compose.py b/backend/app/api/routers/compose.py index 32ff4f4..b3d937a 100644 --- a/backend/app/api/routers/compose.py +++ b/backend/app/api/routers/compose.py @@ -1,13 +1,23 @@ import json +import os +import re +import socket +import subprocess import uuid from typing import Literal +from urllib.parse import urlparse from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_user -from app.application.schemas import AnalysisEnqueueResponse, ContainerStateInfo +from app.application.schemas import ( + AnalysisEnqueueResponse, + BatchAnalysisEnqueueItem, + BatchAnalysisEnqueueResponse, + ContainerStateInfo, +) from app.application.services.analysis_service import AnalysisService from app.infrastructure.db.models import JobStatus, JobType, UserModel from app.infrastructure.db.repositories import JobRepository @@ -29,6 +39,7 @@ class ComposeStopRequest(BaseModel): MAX_COMPOSE_BYTES = 2 * 1024 * 1024 +MAX_BATCH_FILES = 10 @router.post("/analyze", response_model=AnalysisEnqueueResponse) @@ -55,6 +66,53 @@ async def analyze_compose( return AnalysisEnqueueResponse(job_id=uuid.UUID(str(job_id)), status="queued") +@router.post("/analyze/batch", response_model=BatchAnalysisEnqueueResponse) +async def analyze_compose_batch( + files: list[UploadFile] = File(...), + current_user: UserModel = Depends(get_current_user), + session: AsyncSession = Depends(get_db_session), +) -> BatchAnalysisEnqueueResponse: + if not files: + raise HTTPException(status_code=400, detail="At least one file is required.") + if len(files) > MAX_BATCH_FILES: + raise HTTPException(status_code=400, detail=f"Maximum {MAX_BATCH_FILES} files are allowed per batch.") + + service = AnalysisService(session) + items: list[BatchAnalysisEnqueueItem] = [] + + for file in files: + if not file.filename: + raise HTTPException(status_code=400, detail="Filename is required.") + raw = await file.read() + if len(raw) > MAX_COMPOSE_BYTES: + raise HTTPException(status_code=413, detail="File too large. Maximum 2 MB for compose analysis.") + content = raw.decode("utf-8") + filename = file.filename + job_id = await service.enqueue_job( + current_user.id, + JobType.compose, + {"filename": filename, "compose_content": content}, + ) + await enqueue_job( + "run_compose_analysis", + { + "user_id": str(current_user.id), + "job_id": str(job_id), + "content": content, + "filename": filename, + }, + ) + items.append( + BatchAnalysisEnqueueItem( + filename=filename, + job_id=uuid.UUID(str(job_id)), + status="queued", + ) + ) + + return BatchAnalysisEnqueueResponse(count=len(items), items=items) + + @router.post("/deploy", response_model=AnalysisEnqueueResponse) async def deploy_compose( payload: ComposeDeployRequest, @@ -69,6 +127,62 @@ async def deploy_compose( if job.type == JobType.project: if job.status != JobStatus.done: raise HTTPException(status_code=409, detail="Project analysis must complete before deploy.") + result = job.result if isinstance(job.result, dict) else {} + per_file_results = result.get("per_file_results") + compose_results = [] + if isinstance(per_file_results, list): + compose_results = [ + row + for row in per_file_results + if isinstance(row, dict) and row.get("file_type") == "compose" + ] + if not compose_results: + raise HTTPException( + status_code=409, + detail={ + "message": "Project deploy blocked by runnability precheck.", + "reasons": ["No compose analysis results available for this project job."], + }, + ) + + metadata = job.input_metadata if isinstance(job.input_metadata, dict) else {} + primary_compose = metadata.get("primary_compose_file") + selected_compose = metadata.get("selected_compose_files") + if not isinstance(primary_compose, str) or not primary_compose: + if isinstance(selected_compose, list) and selected_compose and isinstance(selected_compose[0], str): + primary_compose = selected_compose[0] + else: + primary_compose = compose_results[0].get("file_path") + + target_compose_result = next( + ( + row + for row in compose_results + if isinstance(row.get("file_path"), str) and row.get("file_path") == primary_compose + ), + None, + ) + if target_compose_result is None: + raise HTTPException( + status_code=409, + detail={ + "message": "Project deploy blocked by runnability precheck.", + "reasons": [f"Primary compose file '{primary_compose}' was not analyzed."], + }, + ) + + runnability = ((target_compose_result.get("meta") or {}).get("runnability") or {}) + if runnability.get("runnable") is not True: + reasons = runnability.get("reasons") or [ + f"Primary compose file '{primary_compose}' is not runnable.", + ] + raise HTTPException( + status_code=409, + detail={ + "message": "Project deploy blocked by runnability precheck.", + "reasons": reasons, + }, + ) elif job.type == JobType.compose: if job.status != JobStatus.done: raise HTTPException(status_code=409, detail="Compose analysis must complete before deploy.") @@ -121,19 +235,95 @@ async def stop_compose_deploy( class DeployStatusResponse(BaseModel): active: bool - runtime_state: Literal["none", "running", "partial", "exited", "unhealthy", "stopping"] = "none" + runtime_state: Literal[ + "none", + "running", + "partial", + "exited", + "failed", + "unhealthy", + "stopping", + "stopped_by_user", + "cleanup_completed", + ] = "none" container_ids: list[str] = [] project_name: str | None = None containers: list[ContainerStateInfo] = [] running_count: int = 0 exited_count: int = 0 unhealthy_count: int = 0 + stopped_by_user: bool = False + stop_reason: str | None = None + exit_reason: str | None = None + can_retry_runtime: bool = True -def _deploy_stop_key(user_id: uuid.UUID, job_id: uuid.UUID) -> str: +class DindIpResponse(BaseModel): + dind_ip: str | None = None + + +def deploy_state_redis_key(user_id: uuid.UUID, job_id: uuid.UUID) -> str: + return f"deploy:{user_id}:{job_id}" + + +def deploy_stop_redis_key(user_id: uuid.UUID, job_id: uuid.UUID) -> str: return f"deploy-stop:{user_id}:{job_id}" +def _is_ipv4(value: str) -> bool: + return bool(re.match(r"^\d{1,3}(\.\d{1,3}){3}$", value)) + + +def _resolve_dind_ip() -> str | None: + # Prefer direct inspect of the known DinD container names. + for candidate in ("docker-platform-dind-1", "docker-platform-dind", "dind", "docker"): + try: + proc = subprocess.run( + [ + "docker", + "inspect", + candidate, + "--format", + "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}", + ], + capture_output=True, + text=True, + timeout=3, + check=False, + ) + if proc.returncode == 0: + ip = proc.stdout.strip() + if ip and _is_ipv4(ip): + return ip + except Exception: + continue + + # Fallback to DOCKER_HOST host resolution. + docker_host = os.getenv("DOCKER_HOST", "") + if docker_host.startswith("tcp://"): + parsed = urlparse(docker_host) + host = (parsed.hostname or "").strip() + if host: + if _is_ipv4(host): + return host + try: + resolved = socket.gethostbyname(host) + if resolved and _is_ipv4(resolved): + return resolved + except Exception: + pass + + # Final fallback for API container runtime: resolve DinD service names by Docker DNS. + for candidate in ("docker", "dind", "docker-platform-dind", "docker-platform-dind-1"): + try: + resolved = socket.gethostbyname(candidate) + if resolved and _is_ipv4(resolved): + return resolved + except Exception: + continue + return None + + def _recompute_container_counts(containers: list[ContainerStateInfo]) -> tuple[int, int, int]: running = exited = unhealthy = 0 for c in containers: @@ -147,12 +337,8 @@ def _recompute_container_counts(containers: list[ContainerStateInfo]) -> tuple[i return running, exited, unhealthy -@router.get("/deploy/status/{job_id}", response_model=DeployStatusResponse) -async def get_deploy_status( - job_id: uuid.UUID, - current_user: UserModel = Depends(get_current_user), -) -> DeployStatusResponse: - key = f"deploy:{current_user.id}:{job_id}" +async def compute_deploy_status(user_id: uuid.UUID, job_id: uuid.UUID) -> DeployStatusResponse: + key = deploy_state_redis_key(user_id, job_id) raw = await redis_client.get(key) if not raw: return DeployStatusResponse(active=False, runtime_state="none") @@ -174,41 +360,85 @@ async def get_deploy_status( unhealthy_count = int(state.get("unhealthy_count", 0) or 0) has_tracked = bool(container_ids) or bool(containers) - stop_raw = await redis_client.get(_deploy_stop_key(current_user.id, job_id)) + stop_raw = await redis_client.get(deploy_stop_redis_key(user_id, job_id)) stop_pending = str(stop_raw or "") == "1" or bool(state.get("stopping")) - runtime_state: Literal["none", "running", "partial", "exited", "unhealthy", "stopping"] = "none" + # Explicit terminal state markers written by the worker take priority over count-based inference. + explicit_runtime_state: str | None = state.get("explicit_runtime_state") + stored_stopped_by_user: bool = bool(state.get("stopped_by_user", False)) + stop_reason: str | None = state.get("stop_reason") + exit_reason: str | None = state.get("exit_reason") + + runtime_state_val: str = "none" active = False - if not has_tracked: - runtime_state = "none" + if explicit_runtime_state in ("stopped_by_user", "cleanup_completed"): + runtime_state_val = explicit_runtime_state + active = False + elif explicit_runtime_state == "failed": + runtime_state_val = "failed" + active = False + elif not has_tracked: + runtime_state_val = "none" active = False elif stop_pending: - runtime_state = "stopping" + runtime_state_val = "stopping" active = True elif running_count == 0 and exited_count > 0: - runtime_state = "exited" + runtime_state_val = "exited" if not stored_stopped_by_user else "stopped_by_user" active = False elif unhealthy_count > 0 and running_count > 0: - runtime_state = "unhealthy" + runtime_state_val = "unhealthy" active = True elif running_count > 0 and exited_count > 0: - runtime_state = "partial" + runtime_state_val = "partial" active = True elif running_count > 0: - runtime_state = "running" + runtime_state_val = "running" active = True else: - runtime_state = "none" + runtime_state_val = "none" active = False + # can_retry_runtime: true for user-stop or transient states, false for self-exit/crash + can_retry_runtime: bool + if runtime_state_val in ("stopped_by_user", "none", "cleanup_completed"): + can_retry_runtime = True + elif runtime_state_val in ("exited", "failed"): + can_retry_runtime = not stored_stopped_by_user + # If user explicitly stopped, allow retry + if stored_stopped_by_user: + can_retry_runtime = True + else: + can_retry_runtime = False + else: + can_retry_runtime = True + return DeployStatusResponse( active=active, - runtime_state=runtime_state, + runtime_state=runtime_state_val, # type: ignore[arg-type] container_ids=container_ids, project_name=state.get("project_name"), containers=containers, running_count=running_count, exited_count=exited_count, unhealthy_count=unhealthy_count, + stopped_by_user=stored_stopped_by_user, + stop_reason=stop_reason, + exit_reason=exit_reason, + can_retry_runtime=can_retry_runtime, ) + + +@router.get("/deploy/status/{job_id}", response_model=DeployStatusResponse) +async def get_deploy_status( + job_id: uuid.UUID, + current_user: UserModel = Depends(get_current_user), +) -> DeployStatusResponse: + return await compute_deploy_status(current_user.id, job_id) + + +@router.get("/deploy/dind-ip", response_model=DindIpResponse) +async def get_dind_ip(current_user: UserModel = Depends(get_current_user)) -> DindIpResponse: + _ = current_user + return DindIpResponse(dind_ip=_resolve_dind_ip()) diff --git a/backend/app/api/routers/dockerfile.py b/backend/app/api/routers/dockerfile.py index 2e66743..5def45f 100644 --- a/backend/app/api/routers/dockerfile.py +++ b/backend/app/api/routers/dockerfile.py @@ -4,7 +4,11 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_user -from app.application.schemas import AnalysisEnqueueResponse +from app.application.schemas import ( + AnalysisEnqueueResponse, + BatchAnalysisEnqueueItem, + BatchAnalysisEnqueueResponse, +) from app.application.services.analysis_service import AnalysisService from app.core.config import get_settings from app.infrastructure.db.models import JobType, UserModel @@ -15,6 +19,7 @@ settings = get_settings() MAX_DOCKERFILE_BYTES = 2 * 1024 * 1024 +MAX_BATCH_FILES = 10 @router.post("/analyze", response_model=AnalysisEnqueueResponse) @@ -43,3 +48,49 @@ async def analyze_dockerfile( }, ) return AnalysisEnqueueResponse(job_id=uuid.UUID(str(job_id)), status="queued") + + +@router.post("/analyze/batch", response_model=BatchAnalysisEnqueueResponse) +async def analyze_dockerfile_batch( + files: list[UploadFile] = File(...), + current_user: UserModel = Depends(get_current_user), + session: AsyncSession = Depends(get_db_session), +) -> BatchAnalysisEnqueueResponse: + if not files: + raise HTTPException(status_code=400, detail="At least one file is required.") + if len(files) > MAX_BATCH_FILES: + raise HTTPException(status_code=400, detail=f"Maximum {MAX_BATCH_FILES} files are allowed per batch.") + + service = AnalysisService(session) + items: list[BatchAnalysisEnqueueItem] = [] + + for file in files: + if not file.filename: + raise HTTPException(status_code=400, detail="Filename is required.") + raw = await file.read() + if len(raw) > MAX_DOCKERFILE_BYTES: + raise HTTPException(status_code=413, detail="File too large. Maximum 2 MB for Dockerfile analysis.") + content = raw.decode("utf-8") + job_id = await service.enqueue_job( + current_user.id, + JobType.dockerfile, + {"filename": file.filename, "source": "upload"}, + ) + await enqueue_job( + "run_dockerfile_analysis", + { + "user_id": str(current_user.id), + "job_id": str(job_id), + "content": content, + "filename": file.filename, + }, + ) + items.append( + BatchAnalysisEnqueueItem( + filename=file.filename, + job_id=uuid.UUID(str(job_id)), + status="queued", + ) + ) + + return BatchAnalysisEnqueueResponse(count=len(items), items=items) diff --git a/backend/app/api/routers/history.py b/backend/app/api/routers/history.py index f5e1cb0..24aa8df 100644 --- a/backend/app/api/routers/history.py +++ b/backend/app/api/routers/history.py @@ -1,13 +1,15 @@ import uuid -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Response from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_user +from app.api.routers.compose import compute_deploy_status, deploy_state_redis_key, deploy_stop_redis_key from app.application.schemas import JobRead -from app.infrastructure.db.models import UserModel +from app.infrastructure.db.models import JobStatus, JobType, UserModel from app.infrastructure.db.repositories import JobRepository from app.infrastructure.db.session import get_db_session +from app.infrastructure.events.bus import redis_client router = APIRouter(prefix="/api/v1/users/me", tags=["history"]) @@ -74,6 +76,42 @@ async def get_job_events( ) +@router.delete("/jobs/{job_id}", status_code=204) +async def delete_job( + job_id: uuid.UUID, + current_user: UserModel = Depends(get_current_user), + session: AsyncSession = Depends(get_db_session), +) -> Response: + repo = JobRepository(session) + job = await repo.get_job(job_id, current_user.id) + if not job: + raise HTTPException(status_code=404, detail="Job not found.") + + if job.status in (JobStatus.queued, JobStatus.running): + raise HTTPException( + status_code=409, + detail="Cannot delete a job that is still queued or running. Wait for it to finish.", + ) + + if job.type in (JobType.compose, JobType.project): + deploy_status = await compute_deploy_status(current_user.id, job_id) + if deploy_status.active: + raise HTTPException( + status_code=409, + detail="Stop running containers for this job before deleting it.", + ) + + deleted = await repo.delete_job(job_id, current_user.id) + if not deleted: + raise HTTPException(status_code=404, detail="Job not found.") + await session.commit() + + await redis_client.delete(deploy_state_redis_key(current_user.id, job_id)) + await redis_client.delete(deploy_stop_redis_key(current_user.id, job_id)) + + return Response(status_code=204) + + @router.get("/history", response_model=list[JobRead]) async def get_job_history( current_user: UserModel = Depends(get_current_user), diff --git a/backend/app/api/routers/project.py b/backend/app/api/routers/project.py index 89ca545..b2f8ce4 100644 --- a/backend/app/api/routers/project.py +++ b/backend/app/api/routers/project.py @@ -1,162 +1,70 @@ import uuid -import zipfile +import logging from pathlib import Path +import httpx from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_user from app.application.schemas import ( AnalysisEnqueueResponse, - ProjectAnalyzeRequest, - ProjectScanResponse, - ProjectDetectedAssets, - DetectedService, - ProjectRecommendation, + ProjectGithubUploadRequest, + ProjectPrimaryComposeRequest, ) from app.application.services.analysis_service import AnalysisService +from app.application.services.github_import import download_repo_zipball, resolve_public_repo_target from app.application.services.project_scanner import safe_extract_zip, scan_extracted_project from app.core.config import get_settings -from app.infrastructure.db.models import JobType, UserModel +from app.infrastructure.db.models import JobStatus, JobType, UserModel from app.infrastructure.db.repositories import JobRepository from app.infrastructure.db.session import get_db_session from app.workers.queue import enqueue_job router = APIRouter(prefix="/api/v1/project", tags=["project"]) settings = get_settings() +logger = logging.getLogger("docker-platform-api") -@router.post("/scan", response_model=ProjectScanResponse) -async def scan_project( - file: UploadFile = File(...), - current_user: UserModel = Depends(get_current_user), - session: AsyncSession = Depends(get_db_session), -) -> ProjectScanResponse: - """Upload a ZIP archive, scan it safely, and return detected assets without starting analysis.""" - if not file.filename or not file.filename.lower().endswith(".zip"): - raise HTTPException(status_code=400, detail="Project upload expects a .zip archive.") - - content = await file.read() - if len(content) > settings.max_upload_size_mb * 1024 * 1024: - raise HTTPException(status_code=413, detail="Uploaded archive is too large.") - - upload_root = Path(settings.upload_dir) / str(current_user.id) - upload_root.mkdir(parents=True, exist_ok=True) - - archive_stem = Path(file.filename).stem - extract_dir = upload_root / f"{archive_stem}-{uuid.uuid4().hex[:8]}" - extract_dir.mkdir(parents=True, exist_ok=True) - - archive_path = upload_root / f"{archive_stem}-{uuid.uuid4().hex[:8]}.zip" - archive_path.write_bytes(content) - - try: - safe_extract_zip(archive_path, extract_dir) - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - finally: - archive_path.unlink(missing_ok=True) - - try: - scan_result = scan_extracted_project(extract_dir, file.filename) - except Exception as exc: - raise HTTPException(status_code=500, detail=f"Project scan failed: {exc}") from exc +async def _enqueue_project_analysis_job( + current_user: UserModel, + session: AsyncSession, + extract_dir: Path, + scan_result: dict, + metadata_overrides: dict | None = None, +) -> AnalysisEnqueueResponse: + metadata = { + **scan_result["db_safe_summary"], + "analysis_confirmed": True, + "build_selected_images": True, + } + if metadata_overrides: + metadata.update(metadata_overrides) - # Store scan metadata as a queued project job (analysis not started yet) service = AnalysisService(session) job_id = await service.enqueue_job( current_user.id, JobType.project, - scan_result["db_safe_summary"], - ) - - detected = scan_result["detected"] - rec = scan_result["recommendation"] - - return ProjectScanResponse( - project_id=job_id, - archive_name=file.filename, - detected=ProjectDetectedAssets( - dockerfiles=detected["dockerfiles"], - compose_files=detected["compose_files"], - dockerignore_files=detected["dockerignore_files"], - env_examples=detected["env_examples"], - stacks=detected["stacks"], - package_managers=detected["package_managers"], - services=[DetectedService(**s) for s in detected["services"]], - ), - recommendation=ProjectRecommendation( - analysis_mode=rec["analysis_mode"], - primary_dockerfile=rec.get("primary_dockerfile"), - primary_compose_file=rec.get("primary_compose_file"), - can_build=rec["can_build"], - can_run=rec["can_run"], - reasons=rec["reasons"], - ), - warnings=scan_result["warnings"], + metadata, + initial_status=JobStatus.queued, ) - -@router.post("/analyze", response_model=AnalysisEnqueueResponse) -async def analyze_project( - payload: ProjectAnalyzeRequest, - current_user: UserModel = Depends(get_current_user), - session: AsyncSession = Depends(get_db_session), -) -> AnalysisEnqueueResponse: - """Confirm analysis of a previously scanned project with selected files.""" - repo = JobRepository(session) - job = await repo.get_job(payload.project_id, current_user.id) - if not job: - raise HTTPException(status_code=404, detail="Project scan not found.") - - # Validate that selected files are among the scanned ones - scanned_dockerfiles: list[str] = job.input_metadata.get("dockerfiles", []) - scanned_compose: list[str] = job.input_metadata.get("compose_files", []) - project_path = job.input_metadata.get("project_path") - - if not project_path: - raise HTTPException(status_code=400, detail="Project path missing from scan metadata.") - - for df in payload.selected_dockerfiles: - if df not in scanned_dockerfiles: - raise HTTPException(status_code=400, detail=f"Dockerfile '{df}' was not found in scan results.") - - for cf in payload.selected_compose_files: - if cf not in scanned_compose: - raise HTTPException(status_code=400, detail=f"Compose file '{cf}' was not found in scan results.") - - # Update the job metadata with the user's selections - updated_meta = { - **job.input_metadata, - "selected_dockerfiles": payload.selected_dockerfiles, - "selected_compose_files": payload.selected_compose_files, - "primary_compose_file": payload.primary_compose_file or (payload.selected_compose_files[0] if payload.selected_compose_files else None), - "analysis_mode": payload.analysis_mode, - "build_selected_images": payload.build_selected_images, - "run_after_analysis": payload.run_after_analysis, - } - - from app.infrastructure.db.models import JobStatus - job.input_metadata = updated_meta - job.status = JobStatus.queued - await session.flush() - await session.commit() - + dockerfiles: list[str] = metadata.get("dockerfiles", []) + compose_files: list[str] = metadata.get("compose_files", []) await enqueue_job( "run_project_analysis", { "user_id": str(current_user.id), - "job_id": str(payload.project_id), - "project_path": project_path, - "dockerfiles": payload.selected_dockerfiles or scanned_dockerfiles, - "compose_files": payload.selected_compose_files or scanned_compose, - "primary_compose_file": updated_meta["primary_compose_file"], - "analysis_mode": payload.analysis_mode, - "build_selected_images": payload.build_selected_images, + "job_id": str(job_id), + "project_path": str(extract_dir), + "dockerfiles": dockerfiles, + "compose_files": compose_files, + "primary_compose_file": compose_files[0] if compose_files else None, + "analysis_mode": "auto", + "build_selected_images": True, }, ) - - return AnalysisEnqueueResponse(job_id=payload.project_id, status="queued") + return AnalysisEnqueueResponse(job_id=uuid.UUID(str(job_id)), status="queued") @router.post("/upload", response_model=AnalysisEnqueueResponse) @@ -165,10 +73,7 @@ async def upload_project( current_user: UserModel = Depends(get_current_user), session: AsyncSession = Depends(get_db_session), ) -> AnalysisEnqueueResponse: - """Legacy single-step upload endpoint — kept for backwards compatibility. - - New callers should use POST /scan then POST /analyze instead. - """ + """Upload a ZIP archive, scan it, and immediately queue analysis of all detected files.""" if not file.filename or not file.filename.lower().endswith(".zip"): raise HTTPException(status_code=400, detail="Project upload expects a .zip archive.") @@ -182,7 +87,7 @@ async def upload_project( archive_stem = Path(file.filename).stem extract_dir = upload_root / f"{archive_stem}-{uuid.uuid4().hex[:8]}" extract_dir.mkdir(parents=True, exist_ok=True) - archive_path = upload_root / f"{archive_stem}-tmp.zip" + archive_path = upload_root / f"{archive_stem}-{uuid.uuid4().hex[:8]}.zip" archive_path.write_bytes(content) try: @@ -197,24 +102,135 @@ async def upload_project( except Exception as exc: raise HTTPException(status_code=500, detail=f"Project scan failed: {exc}") from exc - meta = scan_result["db_safe_summary"] - service = AnalysisService(session) - job_id = await service.enqueue_job(current_user.id, JobType.project, meta) + return await _enqueue_project_analysis_job(current_user, session, extract_dir, scan_result) - dockerfiles = meta["dockerfiles"] - compose_files = meta["compose_files"] - await enqueue_job( - "run_project_analysis", - { - "user_id": str(current_user.id), - "job_id": str(job_id), - "project_path": str(extract_dir), - "dockerfiles": dockerfiles, - "compose_files": compose_files, - "primary_compose_file": compose_files[0] if compose_files else None, - "analysis_mode": "auto", - "build_selected_images": False, - }, +@router.post("/upload/github", response_model=AnalysisEnqueueResponse) +async def upload_project_github( + payload: ProjectGithubUploadRequest, + current_user: UserModel = Depends(get_current_user), + session: AsyncSession = Depends(get_db_session), +) -> AnalysisEnqueueResponse: + max_bytes = settings.max_upload_size_mb * 1024 * 1024 + upload_root = Path(settings.upload_dir) / str(current_user.id) + upload_root.mkdir(parents=True, exist_ok=True) + + timeout = httpx.Timeout(connect=10.0, read=30.0, write=30.0, pool=10.0) + try: + async with httpx.AsyncClient(timeout=timeout, follow_redirects=False) as client: + target = await resolve_public_repo_target(client, payload.url, payload.ref) + short_id = uuid.uuid4().hex[:8] + archive_stem = f"github-{target.owner}-{target.repo}-{short_id}" + archive_path = upload_root / f"{archive_stem}.zip" + extract_dir = upload_root / archive_stem + extract_dir.mkdir(parents=True, exist_ok=True) + + try: + await download_repo_zipball(client, target, archive_path, max_bytes) + safe_extract_zip(archive_path, extract_dir) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + finally: + archive_path.unlink(missing_ok=True) + except httpx.TimeoutException as exc: + logger.warning( + "github_import_timeout", + extra={"user_id": str(current_user.id)}, + exc_info=True, + ) + raise HTTPException(status_code=504, detail="GitHub request timed out. Please try again.") from exc + except httpx.ConnectError as exc: + logger.error( + "github_import_connect_error url=%s ref=%s", + payload.url, + payload.ref or "", + extra={"user_id": str(current_user.id)}, + exc_info=True, + ) + message = str(exc) + if "name resolution" in message.lower(): + detail = ( + "Failed to contact GitHub: DNS resolution failed from the API container. " + "Check Docker DNS/network settings and retry." + ) + else: + detail = "Failed to contact GitHub due to a network connectivity error." + raise HTTPException(status_code=502, detail=detail) from exc + except httpx.HTTPError as exc: + logger.error( + "github_import_http_error url=%s ref=%s", + payload.url, + payload.ref or "", + extra={"user_id": str(current_user.id)}, + exc_info=True, + ) + raise HTTPException(status_code=502, detail="Failed to contact GitHub.") from exc + + archive_label = f"github-{target.owner}-{target.repo}.zip" + try: + scan_result = scan_extracted_project(extract_dir, archive_label) + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Project scan failed: {exc}") from exc + + metadata_overrides = { + "source_type": "github_public", + "source_url": target.source_url, + "source_ref": target.resolved_ref, + "repo_owner": target.owner, + "repo_name": target.repo, + } + return await _enqueue_project_analysis_job( + current_user, + session, + extract_dir, + scan_result, + metadata_overrides=metadata_overrides, ) - return AnalysisEnqueueResponse(job_id=uuid.UUID(str(job_id)), status="queued") + + +@router.patch("/{project_id}/primary-compose", response_model=AnalysisEnqueueResponse) +async def set_primary_compose( + project_id: uuid.UUID, + payload: ProjectPrimaryComposeRequest, + current_user: UserModel = Depends(get_current_user), + session: AsyncSession = Depends(get_db_session), +) -> AnalysisEnqueueResponse: + """Set the primary compose file for deploy after analysis results are available.""" + repo = JobRepository(session) + job = await repo.get_job(project_id, current_user.id) + if not job: + raise HTTPException(status_code=404, detail="Project not found.") + + if job.type != JobType.project: + raise HTTPException(status_code=400, detail="This endpoint is only for project jobs.") + + if job.status != JobStatus.done: + raise HTTPException( + status_code=409, + detail="Primary compose can only be set after analysis is complete.", + ) + + result = job.result if isinstance(job.result, dict) else {} + per_file_results = result.get("per_file_results") or [] + analyzed_compose_paths = [ + row["file_path"] + for row in per_file_results + if isinstance(row, dict) and row.get("file_type") == "compose" + ] + + if payload.primary_compose_file not in analyzed_compose_paths: + raise HTTPException( + status_code=400, + detail=f"Compose file '{payload.primary_compose_file}' was not analyzed in this project.", + ) + + updated = await repo.update_job_metadata( + project_id, + current_user.id, + {"primary_compose_file": payload.primary_compose_file}, + ) + if not updated: + raise HTTPException(status_code=404, detail="Project not found.") + await session.commit() + + return AnalysisEnqueueResponse(job_id=project_id, status="updated") diff --git a/backend/app/api/routers/research.py b/backend/app/api/routers/research.py index 2d58f28..8c3c921 100644 --- a/backend/app/api/routers/research.py +++ b/backend/app/api/routers/research.py @@ -6,6 +6,7 @@ import uuid from datetime import UTC, date, datetime, timedelta +from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.ext.asyncio import AsyncSession @@ -13,11 +14,15 @@ from app.api.deps import get_current_user from app.api.research_privacy import ( anonymize_user_id, + extract_public_research_findings, sanitize_research_metadata, sanitize_research_result, ) from app.application.schemas import ( PaginatedPublicResearchJobs, + ResearchFindingFrequency, + ResearchFindingsSummary, + ResearchFindingWorkflowCounts, PublicResearchJobRead, ResearchSummary, ResearchTimeBucket, @@ -31,6 +36,9 @@ _DEFAULT_CHART_DAYS = 90 _MAX_LIMIT = 100 _DEFAULT_LIMIT = 50 +_MAX_FINDINGS_LIMIT = 50 +_DEFAULT_FINDINGS_LIMIT = 10 +_MAX_FINDINGS_SCAN_JOBS = 1000 def _extract_score_grade(result: dict | None) -> tuple[int | None, str | None]: @@ -140,6 +148,104 @@ async def list_research_jobs( ) +@router.get("/findings", response_model=ResearchFindingsSummary) +async def research_findings( + limit: int = Query(default=_DEFAULT_FINDINGS_LIMIT, ge=1, le=_MAX_FINDINGS_LIMIT), + job_type: str | None = Query(default=None), + status: str | None = Query(default=None), + created_after: datetime | None = Query(default=None), + created_before: datetime | None = Query(default=None), + current_user: UserModel = Depends(get_current_user), + session: AsyncSession = Depends(get_db_session), +) -> ResearchFindingsSummary: + _ = current_user + repo = JobRepository(session) + rows = await repo.list_jobs_global( + limit=_MAX_FINDINGS_SCAN_JOBS, + offset=0, + job_type=job_type, + status=status, + created_from=created_after, + created_to=created_before, + ) + + buckets: dict[tuple[str, str, str], dict[str, Any]] = {} + total_findings = 0 + + for job in rows: + if not isinstance(job.result, dict): + continue + workflow = job.type.value + findings = extract_public_research_findings( + job.result, + prefer_per_file=(workflow == "project"), + ) + for finding in findings: + code = str(finding.get("code") or "UNKNOWN") + severity = str(finding.get("severity") or "info") + message = str(finding.get("message") or "No details provided") + if severity not in {"error", "warning", "info", "security"}: + severity = "info" + key = (code, severity, message) + entry = buckets.get(key) + if entry is None: + entry = { + "code": code, + "severity": severity, + "message": message, + "count": 0, + "doc_url": finding.get("doc_url"), + "workflow_counts": {"dockerfile": 0, "compose": 0, "project": 0}, + } + buckets[key] = entry + entry["count"] += 1 + total_findings += 1 + if workflow in entry["workflow_counts"]: + entry["workflow_counts"][workflow] += 1 + if entry.get("doc_url") is None and finding.get("doc_url"): + entry["doc_url"] = finding.get("doc_url") + + ordered = sorted( + buckets.values(), + key=lambda x: (-int(x["count"]), str(x["code"]), str(x["message"])), + ) + + def to_frequency(row: dict[str, Any]) -> ResearchFindingFrequency: + count = int(row["count"]) + percentage = round((count / total_findings) * 100, 2) if total_findings else 0.0 + raw_counts = row.get("workflow_counts", {}) + workflow_counts = ResearchFindingWorkflowCounts( + **{ + key: int(value) + for key, value in raw_counts.items() + if key in {"dockerfile", "compose", "project"} and int(value) > 0 + } + ) + return ResearchFindingFrequency( + code=str(row["code"]), + severity=str(row["severity"]), + message=str(row["message"]), + count=count, + percentage=percentage, + doc_url=str(row["doc_url"]) if row.get("doc_url") else None, + workflow_counts=workflow_counts, + ) + + def top_by(severity: str | None) -> list[ResearchFindingFrequency]: + subset = ordered if severity is None else [row for row in ordered if row["severity"] == severity] + return [to_frequency(row) for row in subset[:limit]] + + return ResearchFindingsSummary( + total_findings=total_findings, + total_jobs_considered=len(rows), + top_errors=top_by("error"), + top_warnings=top_by("warning"), + top_info=top_by("info"), + top_security=top_by("security"), + top_overall=top_by(None), + ) + + @router.get("/jobs/{job_id}", response_model=PublicResearchJobRead) async def get_research_job( job_id: uuid.UUID, diff --git a/backend/app/application/schemas.py b/backend/app/application/schemas.py index 41886b0..cd3ee00 100644 --- a/backend/app/application/schemas.py +++ b/backend/app/application/schemas.py @@ -49,6 +49,17 @@ class AnalysisEnqueueResponse(BaseModel): status: str +class BatchAnalysisEnqueueItem(BaseModel): + filename: str + job_id: uuid.UUID + status: str + + +class BatchAnalysisEnqueueResponse(BaseModel): + count: int + items: list[BatchAnalysisEnqueueItem] + + class Issue(BaseModel): line: int = 1 code: str @@ -91,6 +102,32 @@ class ResearchSummary(BaseModel): daily_buckets: list[ResearchTimeBucket] +class ResearchFindingWorkflowCounts(BaseModel): + dockerfile: int | None = None + compose: int | None = None + project: int | None = None + + +class ResearchFindingFrequency(BaseModel): + code: str + severity: Literal["error", "warning", "info", "security"] + message: str + count: int + percentage: float + doc_url: str | None = None + workflow_counts: ResearchFindingWorkflowCounts + + +class ResearchFindingsSummary(BaseModel): + total_findings: int + total_jobs_considered: int + top_errors: list[ResearchFindingFrequency] + top_warnings: list[ResearchFindingFrequency] + top_info: list[ResearchFindingFrequency] + top_security: list[ResearchFindingFrequency] + top_overall: list[ResearchFindingFrequency] + + class PublicResearchJobRead(BaseModel): """Privacy-safe, anonymized job row for the public research dashboard.""" @@ -113,56 +150,21 @@ class PaginatedPublicResearchJobs(BaseModel): # --------------------------------------------------------------------------- -# Project scan / analyze schemas +# Project schemas # --------------------------------------------------------------------------- -class DetectedService(BaseModel): - name: str - compose_file: str - image: str | None = None - build_context: str | None = None - build_dockerfile: str | None = None - ports: list[Any] = [] - depends_on: list[str] = [] - db_hints: list[str] = [] - - -class ProjectDetectedAssets(BaseModel): - dockerfiles: list[str] = [] - compose_files: list[str] = [] - dockerignore_files: list[str] = [] - env_examples: list[str] = [] - stacks: list[str] = [] - package_managers: list[str] = [] - services: list[DetectedService] = [] - - -class ProjectRecommendation(BaseModel): - analysis_mode: str - primary_dockerfile: str | None = None - primary_compose_file: str | None = None - can_build: bool = False - can_run: bool = False - reasons: list[str] = [] +class ProjectPrimaryComposeRequest(BaseModel): + """Body for PATCH /project/{id}/primary-compose — sets the compose file used for deploy.""" + primary_compose_file: str -class ProjectScanResponse(BaseModel): - project_id: uuid.UUID - archive_name: str - detected: ProjectDetectedAssets - recommendation: ProjectRecommendation - warnings: list[str] = [] +class ProjectGithubUploadRequest(BaseModel): + """Body for POST /project/upload/github.""" -class ProjectAnalyzeRequest(BaseModel): - project_id: uuid.UUID - selected_dockerfiles: list[str] = [] - selected_compose_files: list[str] = [] - primary_compose_file: str | None = None - analysis_mode: Literal["auto", "dockerfile-only", "compose-only", "full-project"] = "auto" - build_selected_images: bool = False - run_after_analysis: bool = False + url: str + ref: str | None = None class PerFileAnalysisResult(BaseModel): diff --git a/backend/app/application/services/analysis_service.py b/backend/app/application/services/analysis_service.py index 6a57604..6c1d6dc 100644 --- a/backend/app/application/services/analysis_service.py +++ b/backend/app/application/services/analysis_service.py @@ -1,7 +1,7 @@ import json import uuid from collections.abc import Iterable -from typing import Any +from typing import Any, Literal, cast from sqlalchemy.ext.asyncio import AsyncSession @@ -14,6 +14,14 @@ from app.plugins.registry import load_plugins +def _hadolint_doc_url(code: str) -> str | None: + if code.startswith("DL"): + return f"https://github.com/hadolint/hadolint/wiki/{code}" + if code.startswith("SC"): + return f"https://github.com/koalaman/shellcheck/wiki/{code}" + return None + + def _rule_code_and_doc_url(raw: dict[str, Any]) -> tuple[str, str | None]: field = raw.get("code", raw.get("rule")) if isinstance(field, dict): @@ -21,7 +29,8 @@ def _rule_code_and_doc_url(raw: dict[str, Any]) -> tuple[str, str | None]: url = field.get("url") return code, str(url) if url else None if field is not None and field != "": - return str(field), None + code = str(field) + return code, _hadolint_doc_url(code) return "GEN000", None @@ -60,8 +69,8 @@ def __init__(self, session: AsyncSession) -> None: self.session = session self.repo = JobRepository(session) - async def enqueue_job(self, user_id: uuid.UUID, job_type: JobType, metadata: dict[str, Any]) -> uuid.UUID: - job = await self.repo.create_job(user_id, job_type, metadata) + async def enqueue_job(self, user_id: uuid.UUID, job_type: JobType, metadata: dict[str, Any], initial_status: JobStatus = JobStatus.queued) -> uuid.UUID: + job = await self.repo.create_job(user_id, job_type, metadata, initial_status=initial_status) await self.session.commit() return job.id @@ -192,12 +201,13 @@ def _normalize_issue(self, raw: dict[str, Any]) -> Issue: severity = "error" else: severity = "info" + issue_severity = cast(Literal["error", "warning", "info"], severity) line = raw.get("line") or raw.get("location", {}).get("range", {}).get("start", {}).get("line") or 1 code, doc_url = _rule_code_and_doc_url(raw) return Issue( line=int(line), code=code, - severity=severity, + severity=issue_severity, message=str(raw.get("message", "No details provided.")), suggestion=_suggestion_from_raw(raw), doc_url=doc_url, diff --git a/backend/app/application/services/bind_mounts.py b/backend/app/application/services/bind_mounts.py new file mode 100644 index 0000000..8188166 --- /dev/null +++ b/backend/app/application/services/bind_mounts.py @@ -0,0 +1,141 @@ +"""Shared bind-mount detection and validation for Compose workflows.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + + +def is_bind_mount_source(src: str) -> bool: + """Return True if *src* looks like a host bind-mount path (not a named volume).""" + src = src.strip() + + if not src: + return False + + # Current directory or parent directory + if src in {".", "..", "~"}: + return True + + # Relative/absolute/home paths + if src.startswith(("./", "../", "/", "~/")): + return True + + # Windows absolute paths (C:\... or C:/...) + if len(src) >= 3 and src[1] == ":" and src[0].isalpha(): + return True + + return False + + +def _is_windows_drive_path(src: str) -> bool: + return len(src) >= 3 and src[1] == ":" and src[0].isalpha() + + +def validate_bind_mounts(service_name: str, service_def: dict[str, Any], project_root: Path) -> list[str]: + """Project workflow: validate bind mounts against *project_root* and flag DinD risks.""" + issues: list[str] = [] + + volumes = service_def.get("volumes", []) + if not isinstance(volumes, list): + return issues + + for vol in volumes: + if isinstance(vol, str): + parts = vol.split(":") + if not parts: + continue + + src = parts[0].strip() + if not is_bind_mount_source(src): + continue + + if _is_windows_drive_path(src): + issues.append( + f"Service '{service_name}' uses Windows bind mount source '{src}'. " + "This may not work inside Docker-in-Docker." + ) + continue + + if src.startswith("/"): + mount_path = Path(src) + else: + mount_path = (project_root / src).expanduser().resolve() + + if not mount_path.exists(): + issues.append(f"Service '{service_name}' bind mount source '{src}' does not exist.") + + issues.append( + f"Service '{service_name}' uses bind mount '{src}'. " + "Bind mounts can break inside Docker-in-Docker because the source path may not exist " + "or may not contain the uploaded project files." + ) + + elif isinstance(vol, dict): + vol_type = vol.get("type") + src = vol.get("source") or vol.get("src") + + if vol_type == "bind": + if not src: + issues.append(f"Service '{service_name}' has a bind mount without a source.") + continue + + src = str(src).strip() + + if _is_windows_drive_path(src): + issues.append( + f"Service '{service_name}' uses Windows bind mount source '{src}'. " + "This may not work inside Docker-in-Docker." + ) + continue + + if src.startswith("/"): + mount_path = Path(src) + else: + mount_path = (project_root / src).expanduser().resolve() + + if not mount_path.exists(): + issues.append(f"Service '{service_name}' bind mount source '{src}' does not exist.") + + issues.append( + f"Service '{service_name}' uses bind mount '{src}'. " + "Bind mounts can break inside Docker-in-Docker. Prefer copying files in the Dockerfile " + "and using named volumes only for runtime data." + ) + + return issues + + +def runnability_bind_mount_reasons(service_name: str, service_def: dict[str, Any]) -> list[str]: + """Standalone compose workflow: any host bind mount makes the stack non-runnable.""" + reasons: list[str] = [] + + volumes = service_def.get("volumes", []) + if not isinstance(volumes, list): + return reasons + + for vol in volumes: + if isinstance(vol, str): + parts = vol.split(":") + if not parts: + continue + src = parts[0].strip() + if is_bind_mount_source(src): + reasons.append( + f"Service '{service_name}' uses host bind mount '{src}', unavailable standalone." + ) + + elif isinstance(vol, dict): + vol_type = str(vol.get("type", "")).lower() + src_raw = vol.get("source") or vol.get("src") + src = str(src_raw).strip() if src_raw else "" + + if vol_type == "bind" and not src: + reasons.append(f"Service '{service_name}' has a bind mount without a source.") + elif vol_type == "bind" or is_bind_mount_source(src): + label = src or "(bind mount)" + reasons.append( + f"Service '{service_name}' uses host bind mount '{label}', unavailable standalone." + ) + + return reasons diff --git a/backend/app/application/services/compose_mapper.py b/backend/app/application/services/compose_mapper.py index aa7dc9e..3da63c8 100644 --- a/backend/app/application/services/compose_mapper.py +++ b/backend/app/application/services/compose_mapper.py @@ -7,6 +7,8 @@ import yaml +from app.application.services.bind_mounts import validate_bind_mounts + def map_compose_services( compose_file_rel: str, @@ -106,22 +108,7 @@ def map_compose_services( if not ef_path.exists(): issues.append(f"env_file '{ef}' does not exist in project.") - # Validate bind mounts - volumes = service_def.get("volumes", []) - if isinstance(volumes, list): - for vol in volumes: - if isinstance(vol, str): - src = vol.split(":")[0].strip() - if src.startswith(("./", "../", "/", "~")): - mount_path = (project_root / src).resolve() if not src.startswith("/") else Path(src) - if not mount_path.exists(): - issues.append(f"Bind mount source '{src}' does not exist.") - elif isinstance(vol, dict) and vol.get("type") == "bind": - src = vol.get("source", "") - if src and not src.startswith("/"): - mount_path = (project_root / src).resolve() - if not mount_path.exists(): - issues.append(f"Bind mount source '{src}' does not exist.") + issues.extend(validate_bind_mounts(str(service_name), service_def, project_root)) # A service with a build context that resolves correctly can run (with build first) if build is not None and can_build and not image: diff --git a/backend/app/application/services/github_import.py b/backend/app/application/services/github_import.py new file mode 100644 index 0000000..0b09f5d --- /dev/null +++ b/backend/app/application/services/github_import.py @@ -0,0 +1,193 @@ +from __future__ import annotations + +from dataclasses import dataclass +import re +from pathlib import Path +from urllib.parse import quote, urljoin, urlsplit + +import httpx +from fastapi import HTTPException + +_GITHUB_HOST = "github.com" +_GITHUB_API_HOST = "api.github.com" +_ALLOWED_REDIRECT_HOSTS: frozenset[str] = frozenset( + {_GITHUB_HOST, _GITHUB_API_HOST, "codeload.github.com"} +) +_OWNER_REPO_PATTERN = re.compile(r"^[A-Za-z0-9._-]+$") +_REDIRECT_STATUS_CODES = {301, 302, 303, 307, 308} +_MAX_REDIRECTS = 3 + + +@dataclass(slots=True) +class GithubRepoTarget: + owner: str + repo: str + source_url: str + resolved_ref: str + + +def parse_public_github_url(raw_url: str) -> tuple[str, str, str]: + """Parse and validate a public github.com owner/repo URL.""" + candidate = raw_url.strip() + if not candidate: + raise HTTPException(status_code=400, detail="GitHub repository URL is required.") + + if "://" not in candidate: + if candidate.startswith(f"{_GITHUB_HOST}/"): + candidate = f"https://{candidate}" + else: + raise HTTPException(status_code=400, detail="Only public github.com repository URLs are allowed.") + + parsed = urlsplit(candidate) + if parsed.scheme != "https": + raise HTTPException(status_code=400, detail="Only https://github.com repository URLs are allowed.") + if parsed.username or parsed.password: + raise HTTPException(status_code=400, detail="GitHub URL must not include credentials.") + if parsed.port is not None: + raise HTTPException(status_code=400, detail="GitHub URL must not include a custom port.") + + host = (parsed.hostname or "").lower() + if host != _GITHUB_HOST: + raise HTTPException(status_code=400, detail="Only public github.com repository URLs are allowed.") + if parsed.query or parsed.fragment: + raise HTTPException(status_code=400, detail="GitHub URL must not include query parameters or fragments.") + + path_parts = [part for part in parsed.path.split("/") if part] + if len(path_parts) != 2: + raise HTTPException(status_code=400, detail="GitHub URL must target a repository (owner/repo).") + + owner = path_parts[0] + repo = path_parts[1] + if repo.endswith(".git"): + repo = repo[:-4] + if not owner or not repo: + raise HTTPException(status_code=400, detail="GitHub URL must target a repository (owner/repo).") + + if owner in {".", ".."} or repo in {".", ".."}: + raise HTTPException(status_code=400, detail="Invalid GitHub repository path.") + if not _OWNER_REPO_PATTERN.fullmatch(owner) or not _OWNER_REPO_PATTERN.fullmatch(repo): + raise HTTPException(status_code=400, detail="Invalid GitHub repository path.") + + return owner, repo, f"https://{_GITHUB_HOST}/{owner}/{repo}" + + +async def resolve_public_repo_target( + client: httpx.AsyncClient, + raw_url: str, + requested_ref: str | None, +) -> GithubRepoTarget: + owner, repo, source_url = parse_public_github_url(raw_url) + metadata = await _fetch_repo_metadata(client, owner, repo) + resolved_ref = requested_ref.strip() if requested_ref and requested_ref.strip() else metadata["default_branch"] + if not resolved_ref: + raise HTTPException(status_code=400, detail="A valid branch, tag, or ref is required.") + return GithubRepoTarget(owner=owner, repo=repo, source_url=source_url, resolved_ref=resolved_ref) + + +async def download_repo_zipball( + client: httpx.AsyncClient, + target: GithubRepoTarget, + destination: Path, + max_bytes: int, +) -> None: + ref = quote(target.resolved_ref, safe="") + current_url = f"https://{_GITHUB_API_HOST}/repos/{target.owner}/{target.repo}/zipball/{ref}" + destination.parent.mkdir(parents=True, exist_ok=True) + + redirect_count = 0 + while True: + async with client.stream("GET", current_url, follow_redirects=False) as response: + if _is_github_rate_limited(response): + raise _github_rate_limit_exception(response) + + if response.status_code in _REDIRECT_STATUS_CODES: + if redirect_count >= _MAX_REDIRECTS: + raise HTTPException(status_code=502, detail="Too many redirects from GitHub archive download.") + next_location = response.headers.get("Location") + if not next_location: + raise HTTPException(status_code=502, detail="GitHub archive redirect response was invalid.") + current_url = _validate_redirect_url(urljoin(current_url, next_location)) + redirect_count += 1 + continue + + if response.status_code == 404: + raise HTTPException(status_code=404, detail="Public GitHub repository not found.") + if response.status_code >= 400: + raise HTTPException(status_code=502, detail="Failed to download GitHub repository archive.") + + _enforce_content_length_limit(response, max_bytes) + + total_bytes = 0 + with destination.open("wb") as file_obj: + async for chunk in response.aiter_bytes(): + if not chunk: + continue + total_bytes += len(chunk) + if total_bytes > max_bytes: + raise HTTPException(status_code=413, detail="Uploaded archive is too large.") + file_obj.write(chunk) + return + + +async def _fetch_repo_metadata(client: httpx.AsyncClient, owner: str, repo: str) -> dict[str, str]: + metadata_url = f"https://{_GITHUB_API_HOST}/repos/{owner}/{repo}" + response = await client.get(metadata_url, follow_redirects=False) + + if response.status_code == 404: + raise HTTPException(status_code=404, detail="Public GitHub repository not found.") + if _is_github_rate_limited(response): + raise _github_rate_limit_exception(response) + if response.status_code >= 400: + raise HTTPException(status_code=502, detail="Failed to fetch GitHub repository metadata.") + + payload = response.json() + if not isinstance(payload, dict): + raise HTTPException(status_code=502, detail="GitHub repository metadata response was invalid.") + + if bool(payload.get("private")): + raise HTTPException(status_code=403, detail="Only public repositories are supported.") + + default_branch = payload.get("default_branch") + if not isinstance(default_branch, str) or not default_branch.strip(): + raise HTTPException(status_code=502, detail="GitHub repository metadata is missing a default branch.") + + return {"default_branch": default_branch} + + +def _validate_redirect_url(url: str) -> str: + parsed = urlsplit(url) + if parsed.scheme != "https": + raise HTTPException(status_code=502, detail="GitHub archive redirect used an unsupported protocol.") + + host = (parsed.hostname or "").lower() + if host not in _ALLOWED_REDIRECT_HOSTS: + raise HTTPException(status_code=502, detail="GitHub archive redirect target was not allowed.") + return url + + +def _enforce_content_length_limit(response: httpx.Response, max_bytes: int) -> None: + header_value = response.headers.get("Content-Length") + if not header_value: + return + try: + announced_size = int(header_value) + except ValueError: + return + if announced_size > max_bytes: + raise HTTPException(status_code=413, detail="Uploaded archive is too large.") + + +def _is_github_rate_limited(response: httpx.Response) -> bool: + if response.status_code == 429: + return True + remaining = response.headers.get("X-RateLimit-Remaining") + return response.status_code == 403 and remaining == "0" + + +def _github_rate_limit_exception(response: httpx.Response) -> HTTPException: + reset_at = response.headers.get("X-RateLimit-Reset") + if reset_at: + detail = f"GitHub API rate limit exceeded. Try again after {reset_at}." + else: + detail = "GitHub API rate limit exceeded. Please try again later." + return HTTPException(status_code=429, detail=detail) diff --git a/backend/app/application/services/project_scanner.py b/backend/app/application/services/project_scanner.py index fa1e6cc..73b75dd 100644 --- a/backend/app/application/services/project_scanner.py +++ b/backend/app/application/services/project_scanner.py @@ -106,6 +106,10 @@ def _is_compose_file(path: Path) -> bool: return True if name.endswith(".compose.yml") or name.endswith(".compose.yaml"): return True + if (name.startswith("docker-compose.") or name.startswith("docker-compose-")) and ( + name.endswith(".yml") or name.endswith(".yaml") + ): + return True return False diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 5fe41e3..f9cb855 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -24,6 +24,7 @@ class Settings(BaseSettings): upload_dir: str = "storage/uploads" max_upload_size_mb: int = 30 + project_draft_ttl_seconds: int = 3600 @lru_cache diff --git a/backend/app/infrastructure/db/models.py b/backend/app/infrastructure/db/models.py index 5895584..de79b14 100644 --- a/backend/app/infrastructure/db/models.py +++ b/backend/app/infrastructure/db/models.py @@ -16,6 +16,7 @@ class JobType(str, enum.Enum): class JobStatus(str, enum.Enum): + scanned = "scanned" queued = "queued" running = "running" done = "done" diff --git a/backend/app/infrastructure/db/repositories.py b/backend/app/infrastructure/db/repositories.py index d5d7a4e..0207218 100644 --- a/backend/app/infrastructure/db/repositories.py +++ b/backend/app/infrastructure/db/repositories.py @@ -3,7 +3,7 @@ import uuid from datetime import UTC, datetime -from sqlalchemy import Date, Numeric, Select, and_, cast, func, select +from sqlalchemy import Date, Numeric, Select, and_, cast, delete, func, select from sqlalchemy.ext.asyncio import AsyncSession from app.core.security import hash_password, verify_password @@ -84,8 +84,8 @@ class JobRepository: def __init__(self, session: AsyncSession) -> None: self.session = session - async def create_job(self, user_id: uuid.UUID, job_type: JobType, metadata: dict) -> AnalysisJobModel: - job = AnalysisJobModel(user_id=user_id, type=job_type, status=JobStatus.queued, input_metadata=metadata) + async def create_job(self, user_id: uuid.UUID, job_type: JobType, metadata: dict, initial_status: JobStatus = JobStatus.queued) -> AnalysisJobModel: + job = AnalysisJobModel(user_id=user_id, type=job_type, status=initial_status, input_metadata=metadata) self.session.add(job) await self.session.flush() return job @@ -106,11 +106,28 @@ async def get_job(self, job_id: uuid.UUID, user_id: uuid.UUID) -> AnalysisJobMod return None return job + async def delete_job(self, job_id: uuid.UUID, user_id: uuid.UUID) -> bool: + stmt = delete(AnalysisJobModel).where( + AnalysisJobModel.id == job_id, + AnalysisJobModel.user_id == user_id, + ) + result = await self.session.execute(stmt) + return (result.rowcount or 0) > 0 + async def list_jobs(self, user_id: uuid.UUID) -> list[AnalysisJobModel]: stmt = select(AnalysisJobModel).where(AnalysisJobModel.user_id == user_id).order_by(AnalysisJobModel.created_at.desc()) rows = await self.session.scalars(stmt) return list(rows) + async def update_job_metadata(self, job_id: uuid.UUID, user_id: uuid.UUID, patch: dict) -> AnalysisJobModel | None: + """Shallow-merge *patch* into a job's input_metadata and flush.""" + job = await self.session.get(AnalysisJobModel, job_id) + if not job or job.user_id != user_id: + return None + job.input_metadata = {**job.input_metadata, **patch} + await self.session.flush() + return job + async def count_jobs_global(self) -> int: stmt = select(func.count()).select_from(AnalysisJobModel) result = await self.session.scalar(stmt) diff --git a/backend/app/plugins/compose_runnability_plugin.py b/backend/app/plugins/compose_runnability_plugin.py index 3165cca..36be3a1 100644 --- a/backend/app/plugins/compose_runnability_plugin.py +++ b/backend/app/plugins/compose_runnability_plugin.py @@ -3,6 +3,7 @@ import yaml +from app.application.services.bind_mounts import runnability_bind_mount_reasons from app.plugins.base import BasePlugin UNRESOLVED_ENV_PATTERN = re.compile(r"\$\{[A-Za-z_][A-Za-z0-9_]*\}") @@ -33,6 +34,7 @@ async def run(self, context: dict[str, Any]) -> dict[str, Any]: rules["has_services"] = False return {"runnability": {"runnable": False, "reasons": reasons, "rules": rules}} rules["has_services"] = True + is_project_context = bool(context.get("project_path")) has_build = False has_missing_or_latest_tag = False @@ -44,7 +46,8 @@ async def run(self, context: dict[str, Any]) -> dict[str, Any]: if not isinstance(service, dict): continue - if "build" in service: + has_build_context = "build" in service + if has_build_context and not is_project_context: has_build = True reasons.append(f"Service '{service_name}' uses build context and needs project files.") @@ -57,6 +60,9 @@ async def run(self, context: dict[str, Any]) -> dict[str, Any]: reasons.append( f"Service '{service_name}' image must use a non-latest explicit tag or digest." ) + elif has_build_context and is_project_context: + # In project analysis we can build services from context even without explicit image names. + pass else: has_missing_or_latest_tag = True reasons.append(f"Service '{service_name}' is missing an image reference.") @@ -65,28 +71,10 @@ async def run(self, context: dict[str, Any]) -> dict[str, Any]: has_env_file = True reasons.append(f"Service '{service_name}' references env_file, which is unavailable standalone.") - volumes = service.get("volumes", []) - if isinstance(volumes, list): - for volume in volumes: - if isinstance(volume, str): - source = volume.split(":", 1)[0].strip() - if source.startswith(("./", "../", "/", "~")): - has_bind_mount = True - reasons.append( - f"Service '{service_name}' uses host bind mount '{source}', unavailable standalone." - ) - elif isinstance(volume, dict): - if str(volume.get("type", "")).lower() == "bind": - has_bind_mount = True - reasons.append( - f"Service '{service_name}' uses bind mount via long syntax, unavailable standalone." - ) - source = str(volume.get("source") or "") - if source.startswith(("./", "../", "/", "~")): - has_bind_mount = True - reasons.append( - f"Service '{service_name}' uses host source '{source}', unavailable standalone." - ) + bind_reasons = runnability_bind_mount_reasons(str(service_name), service) + if bind_reasons: + has_bind_mount = True + reasons.extend(bind_reasons) if service.get("privileged") is True: has_dangerous_runtime = True diff --git a/backend/app/workers/tasks.py b/backend/app/workers/tasks.py index 80ebfa9..6692f6b 100644 --- a/backend/app/workers/tasks.py +++ b/backend/app/workers/tasks.py @@ -393,9 +393,31 @@ async def run_compose_deploy(ctx, payload: dict) -> dict: job = await repo.get_job(uuid.UUID(job_id), uuid.UUID(user_id)) if not job: raise RuntimeError("Job not found for deployment.") - deploy_spec = _resolve_deploy_spec(user_id, job_id, job.input_metadata or {}) + deploy_metadata = dict(job.input_metadata or {}) + job_result = getattr(job, "result", None) + if isinstance(job_result, dict): + deploy_metadata["_job_result"] = job_result + deploy_spec = _resolve_deploy_spec(user_id, job_id, deploy_metadata) await _set_deploy_state(user_id, job_id, deploy_spec) + reused_services = deploy_spec.get("reused_service_names") if isinstance(deploy_spec, dict) else None + if isinstance(reused_services, list) and reused_services: + await publish_event( + DomainEvent( + "deploy.compose_up_log", + user_id=user_id, + job_id=job_id, + payload={ + "project_name": deploy_spec["project_name"], + "line": ( + "[deploy optimization] Reusing prebuilt analysis images for services: " + + ", ".join(reused_services) + + " (skipping compose rebuild for these services)." + ), + }, + ) + ) + primary_container_id = "" container_ids: list[str] = [] if run_stack: @@ -460,7 +482,14 @@ async def run_compose_deploy(ctx, payload: dict) -> dict: ) if deploy_spec is not None: try: - await _clear_deploy_state(user_id, job_id) + cleanup_state = await _get_deploy_state(user_id, job_id) or {} + cleanup_state.update({ + "active": False, + "explicit_runtime_state": "cleanup_completed", + "stopped_by_user": False, + "exit_reason": f"deploy_failed: {exc}", + }) + await _set_deploy_state(user_id, job_id, cleanup_state) except Exception: pass fail_payload = {"message": str(exc)} @@ -480,6 +509,11 @@ async def run_compose_stop(ctx, payload: dict) -> dict: await _set_stop_requested(user_id, job_id) state["stopping"] = True + # Persist user intent immediately so concurrent metric/exit watchers + # do not classify this shutdown path as an unexpected self-exit. + state["stop_requested_by_user"] = True + state["stopped_by_user"] = True + state["stop_reason"] = "user_requested" await _set_deploy_state(user_id, job_id, state) await _compose_down(state, remove_volumes) @@ -496,7 +530,19 @@ async def run_compose_stop(ctx, payload: dict) -> dict: }, ) ) - await _clear_deploy_state(user_id, job_id) + # Persist terminal state as stopped_by_user so refresh can distinguish from self-exit + terminal_state = { + **state, + "stopping": False, + "active": False, + "explicit_runtime_state": "stopped_by_user", + "stopped_by_user": True, + "stop_requested_by_user": True, + "stop_reason": "user_requested", + "stopped_at": datetime.now(timezone.utc).isoformat(), + } + await _set_deploy_state(user_id, job_id, terminal_state) + await redis_client.delete(_deploy_stop_key(user_id, job_id)) return {"status": "stopped"} @@ -525,7 +571,7 @@ def _compose_base_cmd(spec: dict[str, Any]) -> list[str]: return ["docker-compose", "-p", str(spec["project_name"]), "-f", str(spec["compose_file"])] -def _resolve_deploy_spec(user_id: str, job_id: str, metadata: dict[str, Any]) -> dict[str, str]: +def _resolve_deploy_spec(user_id: str, job_id: str, metadata: dict[str, Any]) -> dict[str, Any]: project_name = f"dqa-{job_id.replace('-', '')[:12]}" project_path = metadata.get("project_path") @@ -537,10 +583,17 @@ def _resolve_deploy_spec(user_id: str, job_id: str, metadata: dict[str, Any]) -> if project_path and compose_rel: project_dir = Path(str(project_path)).resolve() compose_file = (project_dir / compose_rel).resolve() + runtime_compose_file, reused_services = _maybe_prepare_project_runtime_compose_file( + project_dir=project_dir, + compose_rel=str(compose_rel), + compose_file=compose_file, + analysis_result=metadata.get("_job_result"), + ) return { "project_name": project_name, "project_dir": str(project_dir), - "compose_file": str(compose_file), + "compose_file": str(runtime_compose_file), + "reused_service_names": reused_services, } compose_content = metadata.get("compose_content") @@ -554,11 +607,96 @@ def _resolve_deploy_spec(user_id: str, job_id: str, metadata: dict[str, Any]) -> "project_name": project_name, "project_dir": str(deploy_dir), "compose_file": str(compose_file), + "reused_service_names": [], } raise RuntimeError("No deployable compose content found for this job.") +def _maybe_prepare_project_runtime_compose_file( + project_dir: Path, + compose_rel: str, + compose_file: Path, + analysis_result: Any, +) -> tuple[Path, list[str]]: + """Create a runtime compose override that reuses analysis-built images when available.""" + if not isinstance(analysis_result, dict): + return compose_file, [] + + image_build_results = analysis_result.get("image_build_results") + service_mappings = analysis_result.get("service_mappings") + if not isinstance(image_build_results, list) or not isinstance(service_mappings, list): + return compose_file, [] + + built_tags_by_dockerfile: dict[str, str] = {} + for row in image_build_results: + if not isinstance(row, dict): + continue + if row.get("status") != "success": + continue + dockerfile_path = row.get("dockerfile_path") + image_tag = row.get("image_tag") + if isinstance(dockerfile_path, str) and isinstance(image_tag, str) and dockerfile_path and image_tag: + built_tags_by_dockerfile[dockerfile_path] = image_tag + if not built_tags_by_dockerfile: + return compose_file, [] + + mapping_by_service: dict[str, dict[str, Any]] = {} + for row in service_mappings: + if not isinstance(row, dict): + continue + if row.get("compose_file") != compose_rel: + continue + if row.get("can_build") is not True: + continue + service_name = row.get("service") + if isinstance(service_name, str) and service_name: + mapping_by_service[service_name] = row + if not mapping_by_service: + return compose_file, [] + + try: + parsed = yaml.safe_load(compose_file.read_text(encoding="utf-8")) or {} + except Exception: + return compose_file, [] + + services = parsed.get("services") + if not isinstance(services, dict): + return compose_file, [] + + changed = False + reused_services: list[str] = [] + for service_name, service_def in services.items(): + if not isinstance(service_name, str) or not isinstance(service_def, dict): + continue + if "build" not in service_def: + continue + + mapping = mapping_by_service.get(service_name) + if not mapping: + continue + resolved_dockerfile = mapping.get("resolved_dockerfile") + if not isinstance(resolved_dockerfile, str) or not resolved_dockerfile: + continue + + image_tag = built_tags_by_dockerfile.get(resolved_dockerfile) + if not image_tag: + continue + + service_def["image"] = image_tag + service_def.pop("build", None) + services[service_name] = service_def + changed = True + reused_services.append(service_name) + + if not changed: + return compose_file, [] + + runtime_compose = project_dir / ".dqa-runtime.compose.yml" + runtime_compose.write_text(yaml.safe_dump(parsed, sort_keys=False), encoding="utf-8") + return runtime_compose.resolve(), reused_services + + def _stderr_suggests_host_port_publish_conflict(stderr: str) -> bool: """Detect Docker / Compose errors where published host ports block ``up``.""" s = stderr.lower() @@ -831,16 +969,22 @@ async def _stream_metrics(user_id: str, job_id: str, container_ids: list[str]) - except Exception as exc: final_state = {"container_id": container_id, "error": str(exc), "exit_code": -1} + state = await _get_deploy_state(user_id, job_id) or {} + stop_requested_by_user = bool( + state.get("stop_requested_by_user", False) + or state.get("stopped_by_user", False) + or state.get("stopping", False) + ) + await publish_event( DomainEvent( "container.exited", user_id=user_id, job_id=job_id, - payload=final_state, + payload={**final_state, "stop_requested_by_user": stop_requested_by_user}, ) ) - state = await _get_deploy_state(user_id, job_id) or {} containers_state = [c for c in state.get("containers", []) if isinstance(c, dict)] old = next((c for c in containers_state if c.get("id") == container_id), None) containers_state = [c for c in containers_state if c.get("id") != container_id] @@ -869,6 +1013,15 @@ async def _stream_metrics(user_id: str, job_id: str, container_ids: list[str]) - # If all containers exited, publish runtime stopped if not active_ids: + state = await _get_deploy_state(user_id, job_id) or {} + was_user_stop = bool( + state.get("stopped_by_user", False) or state.get("stop_requested_by_user", False) + ) + # User stop is authoritative. run_compose_stop publishes container.stopped + # and persists terminal stopped_by_user state after compose down. + if was_user_stop: + break + await publish_event( DomainEvent( "project.runtime_stopped", @@ -877,9 +1030,13 @@ async def _stream_metrics(user_id: str, job_id: str, container_ids: list[str]) - payload={"reason": "all_containers_exited"}, ) ) - # Mark final state but keep active=False so UI knows - state = await _get_deploy_state(user_id, job_id) or {} + # Persist explicit self-exit marker so refresh can distinguish from user stop state["all_exited"] = True + state["active"] = False + if not was_user_stop: + state["explicit_runtime_state"] = "exited" + state["stopped_by_user"] = False + state["exit_reason"] = "all_containers_exited" await _set_deploy_state(user_id, job_id, state) break diff --git a/backend/pyproject.toml b/backend/pyproject.toml index bbf19a9..35ef5a4 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -12,3 +12,24 @@ dependencies = [] [tool.setuptools.packages.find] where = ["."] include = ["app*"] + +[tool.pyright] +venvPath = "." +venv = ".venv" +pythonVersion = "3.12" +typeCheckingMode = "basic" +include = ["app"] +exclude = [".venv", "**/__pycache__", "tests"] +executionEnvironments = [ + { root = ".", extraPaths = ["."] } +] +reportMissingTypeStubs = "none" +reportCallInDefaultInitializer = "none" +reportAny = "none" +reportExplicitAny = "none" +reportUnknownParameterType = "none" +reportUnknownVariableType = "none" +reportUnknownMemberType = "none" +reportUnknownArgumentType = "none" +reportMissingParameterType = "none" +reportUntypedFunctionDecorator = "none" diff --git a/backend/requirements.txt b/backend/requirements.txt index 643ad4e..33a3744 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -6,11 +6,12 @@ pydantic-settings==2.11.0 sqlalchemy[asyncio]==2.0.43 asyncpg==0.30.0 alembic==1.16.4 +psycopg2-binary==2.9.9 redis==5.3.1 arq==0.26.3 pyjwt==2.10.1 passlib[bcrypt]==1.7.4 -bcrypt==4.1.3 +bcrypt==4.0.1 docker==7.1.0 prometheus-client==0.23.1 email-validator==2.3.0 diff --git a/backend/tests/test_analysis_service.py b/backend/tests/test_analysis_service.py index 0a3e4aa..dce5b46 100644 --- a/backend/tests/test_analysis_service.py +++ b/backend/tests/test_analysis_service.py @@ -16,6 +16,35 @@ def test_normalize_issue_uses_level_when_severity_missing(): assert issue.severity == "warning" assert issue.code == "DL3002" assert issue.line == 4 + assert issue.doc_url == "https://github.com/hadolint/hadolint/wiki/DL3002" + + +def test_normalize_issue_shellcheck_code_gets_wiki_url(): + service = AnalysisService(AsyncMock()) + issue = service._normalize_issue( + { + "line": 10, + "code": "SC2086", + "level": "warning", + "message": "Double quote to prevent globbing and word splitting.", + } + ) + assert issue.code == "SC2086" + assert issue.doc_url == "https://github.com/koalaman/shellcheck/wiki/SC2086" + + +def test_normalize_issue_custom_code_has_no_wiki_url(): + service = AnalysisService(AsyncMock()) + issue = service._normalize_issue( + { + "line": 2, + "code": "SEC001", + "severity": "warning", + "message": "Potential security smell", + } + ) + assert issue.code == "SEC001" + assert issue.doc_url is None def test_normalize_issue_dclint_rdjson_code_object(): diff --git a/backend/tests/test_api_routes.py b/backend/tests/test_api_routes.py index 379027b..d0a1ca7 100644 --- a/backend/tests/test_api_routes.py +++ b/backend/tests/test_api_routes.py @@ -1,14 +1,17 @@ from datetime import datetime, timezone import io +from types import SimpleNamespace from unittest.mock import AsyncMock import uuid import zipfile import pytest +import httpx from fastapi import HTTPException from app.api.deps import get_current_user from app.application.schemas import TokenResponse, UserRead +from app.application.services.github_import import GithubRepoTarget from app.core.security import create_token from app.infrastructure.db.models import AnalysisJobModel, ApiKeyModel, JobStatus, JobType from app.infrastructure.db.session import get_db_session @@ -100,6 +103,99 @@ async def fake_enqueue(task_name: str, payload: dict): assert queue_calls and queue_calls[0]["task"] == "run_dockerfile_analysis" +def test_dockerfile_batch_analyze_requires_auth(client, app, fake_db_session_dependency): + app.dependency_overrides[get_db_session] = fake_db_session_dependency + response = client.post( + "/api/v1/dockerfile/analyze/batch", + files=[("files", ("Dockerfile", b"FROM alpine:3.20\n", "text/plain"))], + ) + app.dependency_overrides.clear() + assert response.status_code == 401 + + +def test_dockerfile_batch_analyze_enqueues_multiple_jobs(client, monkeypatch, app, fake_db_session_dependency): + user = make_user(email="owner@example.com") + issued = [uuid.uuid4(), uuid.uuid4(), uuid.uuid4()] + enqueue_calls: list[dict] = [] + idx = {"n": 0} + + async def fake_enqueue_job(self, user_id, job_type, metadata): + assert user_id == user.id + assert job_type == JobType.dockerfile + assert metadata["filename"] + value = issued[idx["n"]] + idx["n"] += 1 + return value + + async def fake_enqueue(task_name: str, payload: dict): + enqueue_calls.append({"task": task_name, "payload": payload}) + + monkeypatch.setattr("app.application.services.analysis_service.AnalysisService.enqueue_job", fake_enqueue_job) + monkeypatch.setattr("app.api.routers.dockerfile.enqueue_job", fake_enqueue) + app.dependency_overrides[get_db_session] = fake_db_session_dependency + app.dependency_overrides[get_current_user] = lambda: user + response = client.post( + "/api/v1/dockerfile/analyze/batch", + files=[ + ("files", ("Dockerfile", b"FROM alpine:3.20\n", "text/plain")), + ("files", ("Dockerfile.api", b"FROM python:3.12\n", "text/plain")), + ("files", ("worker.Dockerfile", b"FROM node:20\n", "text/plain")), + ], + headers=auth_header_for(user.id), + ) + app.dependency_overrides.clear() + + assert response.status_code == 200 + payload = response.json() + assert payload["count"] == 3 + assert len(payload["items"]) == 3 + assert [item["job_id"] for item in payload["items"]] == [str(v) for v in issued] + assert all(c["task"] == "run_dockerfile_analysis" for c in enqueue_calls) + assert len(enqueue_calls) == 3 + + +def test_dockerfile_batch_analyze_rejects_more_than_ten_files(client, app, fake_db_session_dependency): + user = make_user(email="owner@example.com") + app.dependency_overrides[get_db_session] = fake_db_session_dependency + app.dependency_overrides[get_current_user] = lambda: user + files = [("files", (f"Dockerfile.{i}", b"FROM alpine:3.20\n", "text/plain")) for i in range(11)] + response = client.post( + "/api/v1/dockerfile/analyze/batch", + files=files, + headers=auth_header_for(user.id), + ) + app.dependency_overrides.clear() + assert response.status_code == 400 + assert "Maximum 10 files" in response.json()["detail"] + + +def test_dockerfile_batch_analyze_rejects_oversized_file(client, monkeypatch, app, fake_db_session_dependency): + user = make_user(email="owner@example.com") + monkeypatch.setattr("app.api.routers.dockerfile.MAX_DOCKERFILE_BYTES", 1) + app.dependency_overrides[get_db_session] = fake_db_session_dependency + app.dependency_overrides[get_current_user] = lambda: user + response = client.post( + "/api/v1/dockerfile/analyze/batch", + files=[("files", ("Dockerfile", b"FROM alpine:3.20\n", "text/plain"))], + headers=auth_header_for(user.id), + ) + app.dependency_overrides.clear() + assert response.status_code == 413 + + +def test_dockerfile_batch_analyze_rejects_missing_filename(client, app, fake_db_session_dependency): + user = make_user(email="owner@example.com") + app.dependency_overrides[get_db_session] = fake_db_session_dependency + app.dependency_overrides[get_current_user] = lambda: user + response = client.post( + "/api/v1/dockerfile/analyze/batch", + files=[("files", ("", b"FROM alpine:3.20\n", "text/plain"))], + headers=auth_header_for(user.id), + ) + app.dependency_overrides.clear() + assert response.status_code == 422 + + def test_compose_deploy_returns_404_when_job_missing(client, monkeypatch, app, fake_db_session_dependency): user = make_user() @@ -236,6 +332,78 @@ async def fake_enqueue(task_name: str, payload: dict): assert response.status_code == 200 assert response.json()["status"] == "queued" assert calls and calls[0]["task"] == "run_compose_stop" + assert calls[0]["payload"]["remove_volumes"] is False + + +def test_compose_stop_enqueue_forwards_remove_volumes_true(client, monkeypatch, app, fake_db_session_dependency): + user = make_user() + job_id = uuid.uuid4() + runnable_job = AnalysisJobModel( + id=job_id, + user_id=user.id, + type=JobType.compose, + status=JobStatus.done, + input_metadata={}, + result={"meta": {"runnability": {"runnable": True, "reasons": []}}}, + ) + + class RunnableJobRepo: + def __init__(self, _session): + pass + + async def get_job(self, _job_id, _user_id): + return runnable_job + + calls: list[dict] = [] + + async def fake_enqueue(task_name: str, payload: dict): + calls.append({"task": task_name, "payload": payload}) + + monkeypatch.setattr("app.api.routers.compose.JobRepository", RunnableJobRepo) + monkeypatch.setattr("app.api.routers.compose.enqueue_job", fake_enqueue) + app.dependency_overrides[get_db_session] = fake_db_session_dependency + app.dependency_overrides[get_current_user] = lambda: user + + response = client.post( + "/api/v1/compose/deploy/stop", + json={"job_id": str(job_id), "remove_volumes": True}, + headers=auth_header_for(user.id), + ) + app.dependency_overrides.clear() + assert response.status_code == 200 + assert response.json()["status"] == "queued" + assert calls and calls[0]["task"] == "run_compose_stop" + assert calls[0]["payload"]["remove_volumes"] is True + + +def test_compose_dind_ip_endpoint_returns_resolved_ip(client, monkeypatch, app): + user = make_user() + app.dependency_overrides[get_current_user] = lambda: user + monkeypatch.setattr("app.api.routers.compose._resolve_dind_ip", lambda: "172.18.0.7") + + response = client.get("/api/v1/compose/deploy/dind-ip", headers=auth_header_for(user.id)) + app.dependency_overrides.clear() + + assert response.status_code == 200 + assert response.json() == {"dind_ip": "172.18.0.7"} + + +def test_resolve_dind_ip_falls_back_to_service_dns(monkeypatch): + from app.api.routers import compose as compose_router + + monkeypatch.setattr( + "app.api.routers.compose.subprocess.run", + lambda *args, **kwargs: SimpleNamespace(returncode=1, stdout=""), + ) + monkeypatch.setattr("app.api.routers.compose.os.getenv", lambda *args, **kwargs: "") + monkeypatch.setattr( + "app.api.routers.compose.socket.gethostbyname", + lambda host: "172.18.0.4" + if host in ("docker", "dind", "docker-platform-dind", "docker-platform-dind-1") + else "127.0.0.1", + ) + + assert compose_router._resolve_dind_ip() == "172.18.0.4" def test_get_job_enforces_user_scope_and_returns_404_for_other_user(client, monkeypatch, app, fake_db_session_dependency): @@ -275,13 +443,19 @@ async def get_job(self, requested_job_id, requested_user_id): def test_project_upload_enqueues_project_analysis(client, monkeypatch, app, fake_db_session_dependency): + """POST /project/upload must scan all files, create queued job, and enqueue worker with build=True.""" user = make_user(email="project-owner@example.com") job_id = uuid.uuid4() - async def fake_enqueue_job(self, user_id, job_type, metadata): + captured_meta: dict = {} + + async def fake_enqueue_job(self, user_id, job_type, metadata, initial_status=None): assert user_id == user.id assert job_type == JobType.project assert metadata["filename"] == "project.zip" + assert metadata.get("analysis_confirmed") is True + assert metadata.get("build_selected_images") is True + captured_meta.update(metadata) return job_id queue_calls: list[dict] = [] @@ -311,6 +485,11 @@ async def fake_enqueue(task_name: str, payload: dict): assert response.json()["job_id"] == str(job_id) assert response.json()["status"] == "queued" assert queue_calls and queue_calls[0]["task"] == "run_project_analysis" + # All detected files should be passed to worker + worker_payload = queue_calls[0]["payload"] + assert "Dockerfile" in worker_payload["dockerfiles"] + assert "docker-compose.yml" in worker_payload["compose_files"] + assert worker_payload["build_selected_images"] is True def test_project_upload_rejects_non_zip_file(client, app, fake_db_session_dependency): @@ -362,6 +541,59 @@ async def fake_enqueue(task_name: str, payload: dict): assert queue_calls and queue_calls[0]["task"] == "run_compose_analysis" +def test_compose_batch_analyze_enqueues_multiple_jobs(client, monkeypatch, app, fake_db_session_dependency): + user = make_user(email="compose-owner@example.com") + issued = [uuid.uuid4(), uuid.uuid4()] + enqueue_calls: list[dict] = [] + idx = {"n": 0} + + async def fake_enqueue_job(self, user_id, job_type, metadata): + assert user_id == user.id + assert job_type == JobType.compose + assert metadata["filename"] + value = issued[idx["n"]] + idx["n"] += 1 + return value + + async def fake_enqueue(task_name: str, payload: dict): + enqueue_calls.append({"task": task_name, "payload": payload}) + + monkeypatch.setattr("app.application.services.analysis_service.AnalysisService.enqueue_job", fake_enqueue_job) + monkeypatch.setattr("app.api.routers.compose.enqueue_job", fake_enqueue) + app.dependency_overrides[get_db_session] = fake_db_session_dependency + app.dependency_overrides[get_current_user] = lambda: user + response = client.post( + "/api/v1/compose/analyze/batch", + files=[ + ("files", ("docker-compose.yml", b"services:\n web:\n image: nginx:1.27\n", "text/plain")), + ("files", ("compose.prod.yaml", b"services:\n api:\n image: python:3.12\n", "text/plain")), + ], + headers=auth_header_for(user.id), + ) + app.dependency_overrides.clear() + + assert response.status_code == 200 + payload = response.json() + assert payload["count"] == 2 + assert len(payload["items"]) == 2 + assert [item["job_id"] for item in payload["items"]] == [str(v) for v in issued] + assert all(c["task"] == "run_compose_analysis" for c in enqueue_calls) + assert len(enqueue_calls) == 2 + + +def test_compose_batch_analyze_rejects_missing_filename(client, app, fake_db_session_dependency): + user = make_user(email="compose-owner@example.com") + app.dependency_overrides[get_db_session] = fake_db_session_dependency + app.dependency_overrides[get_current_user] = lambda: user + response = client.post( + "/api/v1/compose/analyze/batch", + files=[("files", ("", b"services:\n web:\n image: nginx:1.27\n", "text/plain"))], + headers=auth_header_for(user.id), + ) + app.dependency_overrides.clear() + assert response.status_code == 422 + + def test_list_jobs_returns_user_jobs(client, monkeypatch, app, fake_db_session_dependency): user = make_user(email="jobs-owner@example.com") job_id = uuid.uuid4() @@ -542,6 +774,201 @@ def test_project_upload_rejects_oversized_archive(client, monkeypatch, app, fake assert "too large" in response.json()["detail"] +def test_project_github_upload_rejects_invalid_url(client, app, fake_db_session_dependency): + user = make_user(email="project-owner@example.com") + app.dependency_overrides[get_db_session] = fake_db_session_dependency + app.dependency_overrides[get_current_user] = lambda: user + + response = client.post( + "/api/v1/project/upload/github", + json={"url": "git@github.com:owner/repo.git"}, + headers=auth_header_for(user.id), + ) + + app.dependency_overrides.clear() + assert response.status_code == 400 + assert "Only public github.com repository URLs are allowed." in response.json()["detail"] + + +def test_project_github_upload_rejects_non_github_host(client, app, fake_db_session_dependency): + user = make_user(email="project-owner@example.com") + app.dependency_overrides[get_db_session] = fake_db_session_dependency + app.dependency_overrides[get_current_user] = lambda: user + + response = client.post( + "/api/v1/project/upload/github", + json={"url": "https://example.com/owner/repo"}, + headers=auth_header_for(user.id), + ) + + app.dependency_overrides.clear() + assert response.status_code == 400 + assert "Only public github.com repository URLs are allowed." in response.json()["detail"] + + +def test_project_github_upload_returns_403_for_private_repo(client, monkeypatch, app, fake_db_session_dependency): + user = make_user(email="project-owner@example.com") + + async def fake_resolve(_client, _url, _ref): + raise HTTPException(status_code=403, detail="Only public repositories are supported.") + + monkeypatch.setattr("app.api.routers.project.resolve_public_repo_target", fake_resolve) + app.dependency_overrides[get_db_session] = fake_db_session_dependency + app.dependency_overrides[get_current_user] = lambda: user + + response = client.post( + "/api/v1/project/upload/github", + json={"url": "https://github.com/owner/repo"}, + headers=auth_header_for(user.id), + ) + + app.dependency_overrides.clear() + assert response.status_code == 403 + assert response.json()["detail"] == "Only public repositories are supported." + + +def test_project_github_upload_returns_404_when_repo_missing(client, monkeypatch, app, fake_db_session_dependency): + user = make_user(email="project-owner@example.com") + + async def fake_resolve(_client, _url, _ref): + raise HTTPException(status_code=404, detail="Public GitHub repository not found.") + + monkeypatch.setattr("app.api.routers.project.resolve_public_repo_target", fake_resolve) + app.dependency_overrides[get_db_session] = fake_db_session_dependency + app.dependency_overrides[get_current_user] = lambda: user + + response = client.post( + "/api/v1/project/upload/github", + json={"url": "https://github.com/owner/missing-repo"}, + headers=auth_header_for(user.id), + ) + + app.dependency_overrides.clear() + assert response.status_code == 404 + assert response.json()["detail"] == "Public GitHub repository not found." + + +def test_project_github_upload_rejects_oversized_archive(client, monkeypatch, app, fake_db_session_dependency): + user = make_user(email="project-owner@example.com") + + async def fake_resolve(_client, _url, _ref): + return GithubRepoTarget( + owner="owner", + repo="repo", + source_url="https://github.com/owner/repo", + resolved_ref="main", + ) + + async def fake_download(_client, _target, _destination, _max_bytes): + raise HTTPException(status_code=413, detail="Uploaded archive is too large.") + + monkeypatch.setattr("app.api.routers.project.resolve_public_repo_target", fake_resolve) + monkeypatch.setattr("app.api.routers.project.download_repo_zipball", fake_download) + app.dependency_overrides[get_db_session] = fake_db_session_dependency + app.dependency_overrides[get_current_user] = lambda: user + + response = client.post( + "/api/v1/project/upload/github", + json={"url": "https://github.com/owner/repo", "ref": "main"}, + headers=auth_header_for(user.id), + ) + + app.dependency_overrides.clear() + assert response.status_code == 413 + assert response.json()["detail"] == "Uploaded archive is too large." + + +def test_project_github_upload_returns_dns_resolution_message_on_connect_error( + client, monkeypatch, app, fake_db_session_dependency +): + user = make_user(email="project-owner@example.com") + + async def fake_resolve(_client, _url, _ref): + raise httpx.ConnectError( + "Temporary failure in name resolution", + request=httpx.Request("GET", "https://api.github.com/repos/owner/repo"), + ) + + monkeypatch.setattr("app.api.routers.project.resolve_public_repo_target", fake_resolve) + app.dependency_overrides[get_db_session] = fake_db_session_dependency + app.dependency_overrides[get_current_user] = lambda: user + + response = client.post( + "/api/v1/project/upload/github", + json={"url": "https://github.com/owner/repo"}, + headers=auth_header_for(user.id), + ) + + app.dependency_overrides.clear() + assert response.status_code == 502 + assert "DNS resolution failed from the API container" in response.json()["detail"] + + +def test_project_github_upload_enqueues_project_analysis(client, monkeypatch, app, fake_db_session_dependency): + user = make_user(email="project-owner@example.com") + job_id = uuid.uuid4() + captured_meta: dict = {} + queue_calls: list[dict] = [] + + async def fake_resolve(_client, _url, _ref): + return GithubRepoTarget( + owner="owner", + repo="repo", + source_url="https://github.com/owner/repo", + resolved_ref="main", + ) + + async def fake_download(_client, _target, destination, _max_bytes): + mem_zip = io.BytesIO() + with zipfile.ZipFile(mem_zip, "w", zipfile.ZIP_DEFLATED) as archive: + archive.writestr("Dockerfile", "FROM alpine:3.20\n") + archive.writestr("docker-compose.yml", "services:\n web:\n image: nginx:1.27\n") + destination.write_bytes(mem_zip.getvalue()) + + async def fake_enqueue_job(self, user_id, job_type, metadata, initial_status=None): + assert user_id == user.id + assert job_type == JobType.project + assert initial_status == JobStatus.queued + captured_meta.update(metadata) + return job_id + + async def fake_enqueue(task_name: str, payload: dict): + queue_calls.append({"task": task_name, "payload": payload}) + + monkeypatch.setattr("app.api.routers.project.resolve_public_repo_target", fake_resolve) + monkeypatch.setattr("app.api.routers.project.download_repo_zipball", fake_download) + monkeypatch.setattr("app.application.services.analysis_service.AnalysisService.enqueue_job", fake_enqueue_job) + monkeypatch.setattr("app.api.routers.project.enqueue_job", fake_enqueue) + app.dependency_overrides[get_db_session] = fake_db_session_dependency + app.dependency_overrides[get_current_user] = lambda: user + + response = client.post( + "/api/v1/project/upload/github", + json={"url": "https://github.com/owner/repo", "ref": "main"}, + headers=auth_header_for(user.id), + ) + + app.dependency_overrides.clear() + assert response.status_code == 200 + assert response.json()["job_id"] == str(job_id) + assert response.json()["status"] == "queued" + + assert captured_meta["source_type"] == "github_public" + assert captured_meta["source_url"] == "https://github.com/owner/repo" + assert captured_meta["source_ref"] == "main" + assert captured_meta["repo_owner"] == "owner" + assert captured_meta["repo_name"] == "repo" + assert captured_meta["build_selected_images"] is True + assert captured_meta["analysis_confirmed"] is True + + assert len(queue_calls) == 1 + assert queue_calls[0]["task"] == "run_project_analysis" + worker_payload = queue_calls[0]["payload"] + assert worker_payload["build_selected_images"] is True + assert "Dockerfile" in worker_payload["dockerfiles"] + assert "docker-compose.yml" in worker_payload["compose_files"] + + def test_auth_refresh_issues_new_tokens(client, monkeypatch, app, fake_db_session_dependency): user = make_user(email="refresh@example.com") refresh_token = create_token(str(user.id), "refresh", 60) @@ -655,3 +1082,334 @@ async def get_job(self, _job_id, _user_id): app.dependency_overrides.clear() assert response.status_code == 404 + + +def test_delete_job_success_returns_204(client, monkeypatch, app, fake_db_session_dependency): + user = make_user(email="delete-owner@example.com") + job_id = uuid.uuid4() + calls = {"deleted": False} + + class JobRepo: + def __init__(self, _session): + pass + + async def get_job(self, jid, uid): + if jid == job_id and uid == user.id: + return AnalysisJobModel( + id=job_id, + user_id=user.id, + type=JobType.dockerfile, + status=JobStatus.done, + input_metadata={"filename": "Dockerfile"}, + result={"score": 90}, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + return None + + async def delete_job(self, jid, uid): + assert jid == job_id and uid == user.id + calls["deleted"] = True + return True + + redis_del = AsyncMock() + monkeypatch.setattr("app.api.routers.history.JobRepository", JobRepo) + monkeypatch.setattr("app.api.routers.history.redis_client.delete", redis_del) + app.dependency_overrides[get_db_session] = fake_db_session_dependency + app.dependency_overrides[get_current_user] = lambda: user + + response = client.delete( + f"/api/v1/users/me/jobs/{job_id}", + headers=auth_header_for(user.id), + ) + app.dependency_overrides.clear() + assert response.status_code == 204 + assert response.content == b"" + assert calls["deleted"] is True + assert redis_del.await_count == 2 + + +def test_delete_job_returns_404_when_missing(client, monkeypatch, app, fake_db_session_dependency): + user = make_user(email="delete-missing@example.com") + + class JobRepo: + def __init__(self, _session): + pass + + async def get_job(self, _jid, _uid): + return None + + async def delete_job(self, *_a, **_k): + raise AssertionError("delete_job should not be called") + + monkeypatch.setattr("app.api.routers.history.JobRepository", JobRepo) + app.dependency_overrides[get_db_session] = fake_db_session_dependency + app.dependency_overrides[get_current_user] = lambda: user + + response = client.delete( + f"/api/v1/users/me/jobs/{uuid.uuid4()}", + headers=auth_header_for(user.id), + ) + app.dependency_overrides.clear() + assert response.status_code == 404 + + +@pytest.mark.parametrize("in_progress_status", [JobStatus.running, JobStatus.queued]) +def test_delete_job_returns_409_when_in_progress( + client, monkeypatch, app, fake_db_session_dependency, in_progress_status +): + user = make_user(email="delete-busy@example.com") + job_id = uuid.uuid4() + + class JobRepo: + def __init__(self, _session): + pass + + async def get_job(self, jid, uid): + if jid == job_id and uid == user.id: + return AnalysisJobModel( + id=job_id, + user_id=user.id, + type=JobType.dockerfile, + status=in_progress_status, + input_metadata={"filename": "Dockerfile"}, + result=None, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + return None + + async def delete_job(self, *_a, **_k): + raise AssertionError("delete_job should not be called") + + monkeypatch.setattr("app.api.routers.history.JobRepository", JobRepo) + app.dependency_overrides[get_db_session] = fake_db_session_dependency + app.dependency_overrides[get_current_user] = lambda: user + + response = client.delete( + f"/api/v1/users/me/jobs/{job_id}", + headers=auth_header_for(user.id), + ) + app.dependency_overrides.clear() + assert response.status_code == 409 + + +def test_delete_job_returns_409_when_compose_deploy_active(client, monkeypatch, app, fake_db_session_dependency): + user = make_user(email="delete-active-deploy@example.com") + job_id = uuid.uuid4() + + class JobRepo: + def __init__(self, _session): + pass + + async def get_job(self, jid, uid): + if jid == job_id and uid == user.id: + return AnalysisJobModel( + id=job_id, + user_id=user.id, + type=JobType.compose, + status=JobStatus.done, + input_metadata={"filename": "docker-compose.yml"}, + result={"score": 88}, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + return None + + async def delete_job(self, *_a, **_k): + raise AssertionError("delete_job should not be called") + + async def fake_deploy_status(*_a, **_k): + return SimpleNamespace(active=True) + + monkeypatch.setattr("app.api.routers.history.JobRepository", JobRepo) + monkeypatch.setattr("app.api.routers.history.compute_deploy_status", fake_deploy_status) + app.dependency_overrides[get_db_session] = fake_db_session_dependency + app.dependency_overrides[get_current_user] = lambda: user + + response = client.delete( + f"/api/v1/users/me/jobs/{job_id}", + headers=auth_header_for(user.id), + ) + app.dependency_overrides.clear() + assert response.status_code == 409 + assert "Stop running containers" in response.json()["detail"] + + +# ───────────────────────────────────────────────────────────────────────────── +# Scanned job state-machine tests +# ───────────────────────────────────────────────────────────────────────────── + + +def test_project_set_primary_compose_updates_metadata(client, monkeypatch, app, fake_db_session_dependency): + """PATCH /project/{id}/primary-compose must update primary_compose_file in job metadata.""" + user = make_user(email="primary-compose@example.com") + project_id = uuid.uuid4() + done_job = AnalysisJobModel( + id=project_id, + user_id=user.id, + type=JobType.project, + status=JobStatus.done, + input_metadata={"compose_files": ["docker-compose.yml", "docker-compose.prod.yml"]}, + result={ + "per_file_results": [ + {"file_path": "docker-compose.yml", "file_type": "compose", "score": 80, "grade": "B"}, + {"file_path": "docker-compose.prod.yml", "file_type": "compose", "score": 75, "grade": "B"}, + ] + }, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + updated_meta: dict = {} + + class JobRepo: + def __init__(self, _session): + pass + + async def get_job(self, jid, uid): + return done_job if jid == project_id and uid == user.id else None + + async def update_job_metadata(self, jid, uid, patch): + done_job.input_metadata = {**done_job.input_metadata, **patch} + updated_meta.update(patch) + return done_job + + class FakeSession: + async def commit(self): + pass + + monkeypatch.setattr("app.api.routers.project.JobRepository", JobRepo) + app.dependency_overrides[get_db_session] = lambda: FakeSession() + app.dependency_overrides[get_current_user] = lambda: user + + response = client.patch( + f"/api/v1/project/{project_id}/primary-compose", + json={"primary_compose_file": "docker-compose.prod.yml"}, + headers=auth_header_for(user.id), + ) + app.dependency_overrides.clear() + + assert response.status_code == 200 + assert response.json()["status"] == "updated" + assert updated_meta.get("primary_compose_file") == "docker-compose.prod.yml" + + +def test_project_set_primary_compose_rejects_unanalyzed_file(client, monkeypatch, app, fake_db_session_dependency): + """PATCH /project/{id}/primary-compose must reject compose file not in per_file_results.""" + user = make_user(email="primary-compose-bad@example.com") + project_id = uuid.uuid4() + done_job = AnalysisJobModel( + id=project_id, + user_id=user.id, + type=JobType.project, + status=JobStatus.done, + input_metadata={}, + result={ + "per_file_results": [ + {"file_path": "docker-compose.yml", "file_type": "compose", "score": 80, "grade": "B"}, + ] + }, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + + class JobRepo: + def __init__(self, _session): + pass + + async def get_job(self, jid, uid): + return done_job if jid == project_id and uid == user.id else None + + monkeypatch.setattr("app.api.routers.project.JobRepository", JobRepo) + app.dependency_overrides[get_db_session] = fake_db_session_dependency + app.dependency_overrides[get_current_user] = lambda: user + + response = client.patch( + f"/api/v1/project/{project_id}/primary-compose", + json={"primary_compose_file": "nonexistent-compose.yml"}, + headers=auth_header_for(user.id), + ) + app.dependency_overrides.clear() + + assert response.status_code == 400 + assert "was not analyzed" in response.json()["detail"] + + +def test_project_set_primary_compose_rejects_non_done_job(client, monkeypatch, app, fake_db_session_dependency): + """PATCH /project/{id}/primary-compose must return 409 if job is not done.""" + user = make_user(email="primary-compose-running@example.com") + project_id = uuid.uuid4() + running_job = AnalysisJobModel( + id=project_id, + user_id=user.id, + type=JobType.project, + status=JobStatus.running, + input_metadata={}, + result=None, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + + class JobRepo: + def __init__(self, _session): + pass + + async def get_job(self, jid, uid): + return running_job if jid == project_id and uid == user.id else None + + monkeypatch.setattr("app.api.routers.project.JobRepository", JobRepo) + app.dependency_overrides[get_db_session] = fake_db_session_dependency + app.dependency_overrides[get_current_user] = lambda: user + + response = client.patch( + f"/api/v1/project/{project_id}/primary-compose", + json={"primary_compose_file": "docker-compose.yml"}, + headers=auth_header_for(user.id), + ) + app.dependency_overrides.clear() + + assert response.status_code == 409 + assert "after analysis is complete" in response.json()["detail"] + + +def test_delete_job_allows_project_job(client, monkeypatch, app, fake_db_session_dependency): + """Project jobs (done or otherwise) must be deletable.""" + user = make_user(email="delete-project@example.com") + job_id = uuid.uuid4() + calls: dict = {} + + class JobRepo: + def __init__(self, _session): + pass + + async def get_job(self, jid, uid): + if jid == job_id and uid == user.id: + return AnalysisJobModel( + id=job_id, + user_id=user.id, + type=JobType.project, + status=JobStatus.done, + input_metadata={"analysis_confirmed": True, "build_selected_images": True}, + result=None, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + return None + + async def delete_job(self, jid, uid): + calls["deleted"] = True + return True + + monkeypatch.setattr("app.api.routers.history.JobRepository", JobRepo) + monkeypatch.setattr("app.api.routers.history.compute_deploy_status", AsyncMock(return_value=SimpleNamespace(active=False))) + monkeypatch.setattr("app.api.routers.history.redis_client.delete", AsyncMock()) + app.dependency_overrides[get_db_session] = fake_db_session_dependency + app.dependency_overrides[get_current_user] = lambda: user + + response = client.delete( + f"/api/v1/users/me/jobs/{job_id}", + headers=auth_header_for(user.id), + ) + app.dependency_overrides.clear() + assert response.status_code == 204 + assert calls.get("deleted") is True diff --git a/backend/tests/test_bind_mounts.py b/backend/tests/test_bind_mounts.py new file mode 100644 index 0000000..d4e1a6a --- /dev/null +++ b/backend/tests/test_bind_mounts.py @@ -0,0 +1,88 @@ +from pathlib import Path + +import pytest + +from app.application.services.bind_mounts import ( + is_bind_mount_source, + runnability_bind_mount_reasons, + validate_bind_mounts, +) +from app.application.services.compose_mapper import map_compose_services +from app.plugins.compose_runnability_plugin import ComposeRunnabilityPlugin + + +@pytest.mark.parametrize( + ("src", "expected"), + [ + (".", True), + ("..", True), + ("~", True), + ("./data", True), + ("../data", True), + ("/var/data", True), + ("~/data", True), + ("C:/Users/me/project", True), + (r"C:\Users\me\project", True), + ("mydata", False), + ("pgdata", False), + ], +) +def test_is_bind_mount_source(src: str, expected: bool) -> None: + assert is_bind_mount_source(src) is expected + + +@pytest.mark.asyncio +async def test_runnability_blocks_dot_bind_mount() -> None: + plugin = ComposeRunnabilityPlugin() + compose = """ +services: + app: + image: nginx:1.27 + volumes: + - .:/usr/src/app +""" + result = await plugin.run({"compose_content": compose}) + runnability = result["runnability"] + assert runnability["runnable"] is False + assert runnability["rules"]["no_bind_mounts"] is False + assert any("bind mount" in r.lower() and "." in r for r in runnability["reasons"]) + + +def test_runnability_bind_mount_reasons_dot_only() -> None: + reasons = runnability_bind_mount_reasons( + "app", + {"volumes": [".:/usr/src/app"]}, + ) + assert len(reasons) == 1 + assert "'.'" in reasons[0] or "bind mount '.'" in reasons[0] + + +def test_validate_bind_mounts_dot_mount_flags_dind(tmp_path: Path) -> None: + project_root = tmp_path / "project" + project_root.mkdir() + issues = validate_bind_mounts( + "app", + {"volumes": [".:/usr/src/app"]}, + project_root, + ) + assert any("bind mount '.'" in issue for issue in issues) + assert any("Docker-in-Docker" in issue for issue in issues) + + +def test_compose_mapper_dot_bind_mount(tmp_path: Path) -> None: + project_root = tmp_path / "project" + project_root.mkdir() + compose_content = """ +services: + app: + image: nginx:1.27 + volumes: + - .:/usr/src/app +""" + (project_root / "docker-compose.yml").write_text(compose_content, encoding="utf-8") + + mappings = map_compose_services("docker-compose.yml", project_root) + assert len(mappings) == 1 + issues = mappings[0]["issues"] + assert any("bind mount '.'" in issue for issue in issues) + assert any("Docker-in-Docker" in issue for issue in issues) diff --git a/backend/tests/test_compose_runnability.py b/backend/tests/test_compose_runnability.py index 84832a5..562b05e 100644 --- a/backend/tests/test_compose_runnability.py +++ b/backend/tests/test_compose_runnability.py @@ -58,6 +58,16 @@ async def test_compose_runnability_plugin_accepts_simple_public_images(): ), ( """ +services: + api: + image: nginx:1.27 + volumes: + - .:/usr/src/app +""", + "bind mount", + ), + ( + """ services: api: image: nginx:1.27 @@ -95,6 +105,41 @@ async def test_compose_runnability_plugin_blocks_non_runnable_cases(compose: str assert any(expected_reason_part.lower() in reason.lower() for reason in runnability["reasons"]) +@pytest.mark.asyncio +async def test_compose_runnability_plugin_allows_build_in_project_context(): + plugin = ComposeRunnabilityPlugin() + compose = """ +services: + api: + build: . + image: example/app:1.0 +""" + result = await plugin.run({"compose_content": compose, "project_path": "/tmp/project"}) + runnability = result["runnability"] + assert runnability["runnable"] is True + assert runnability["rules"]["no_build_contexts"] is True + + +@pytest.mark.asyncio +async def test_compose_runnability_plugin_allows_project_build_without_image(): + plugin = ComposeRunnabilityPlugin() + compose = """ +services: + app: + build: + context: . + dockerfile: Dockerfile + ports: + - "8081:8081" +""" + result = await plugin.run({"compose_content": compose, "project_path": "/tmp/project"}) + runnability = result["runnability"] + assert runnability["runnable"] is True + assert runnability["rules"]["no_build_contexts"] is True + assert runnability["rules"]["explicit_non_latest_images"] is True + assert not any("missing an image reference" in reason for reason in runnability["reasons"]) + + @pytest.mark.asyncio async def test_deploy_compose_blocks_non_runnable_compose(monkeypatch: pytest.MonkeyPatch): user_id = uuid.uuid4() @@ -152,8 +197,19 @@ async def test_deploy_compose_allows_runnable_compose_and_project(monkeypatch: p user_id=user_id, type=JobType.project, status=JobStatus.done, - input_metadata={}, - result=None, + input_metadata={ + "primary_compose_file": "docker-compose.yml", + "selected_compose_files": ["docker-compose.yml"], + }, + result={ + "per_file_results": [ + { + "file_type": "compose", + "file_path": "docker-compose.yml", + "meta": {"runnability": {"runnable": True, "reasons": []}}, + } + ] + }, ), } @@ -186,3 +242,47 @@ async def fake_enqueue(_task, payload): assert compose_response.job_id == compose_job_id assert project_response.job_id == project_job_id assert len(calls) == 2 + + +@pytest.mark.asyncio +async def test_deploy_compose_blocks_non_runnable_primary_project_compose(monkeypatch: pytest.MonkeyPatch): + user_id = uuid.uuid4() + project_job_id = uuid.uuid4() + blocked_project = AnalysisJobModel( + id=project_job_id, + user_id=user_id, + type=JobType.project, + status=JobStatus.done, + input_metadata={ + "primary_compose_file": "docker-compose.prod.yml", + "selected_compose_files": ["docker-compose.prod.yml"], + }, + result={ + "per_file_results": [ + { + "file_type": "compose", + "file_path": "docker-compose.prod.yml", + "meta": {"runnability": {"runnable": False, "reasons": ["bind mount detected"]}}, + } + ] + }, + ) + + class FakeRepo: + def __init__(self, _session): + pass + + async def get_job(self, _job_id, _user_id): + return blocked_project + + monkeypatch.setattr("app.api.routers.compose.JobRepository", FakeRepo) + + with pytest.raises(HTTPException) as exc: + await deploy_compose( + ComposeDeployRequest(job_id=project_job_id, push_public_images=False, run_stack=True), + current_user=UserModel(id=user_id, email="a@b.com", hashed_password="x"), + session=AsyncMock(), + ) + + assert exc.value.status_code == 409 + assert "runnability precheck" in str(exc.value.detail) diff --git a/backend/tests/test_container_lifecycle.py b/backend/tests/test_container_lifecycle.py index 9e2383f..004f48f 100644 --- a/backend/tests/test_container_lifecycle.py +++ b/backend/tests/test_container_lifecycle.py @@ -361,3 +361,130 @@ async def fake_set_state(uid, jid, state): stored = redis_boxes.get("containers", []) assert len(stored) == 1 assert stored[0]["last_logs"] == ["oops"] + + +# ─── stopped_by_user vs self-exit state markers ─────────────────────────────── + + +@pytest.mark.asyncio +async def test_stream_metrics_sets_self_exit_markers(): + """After all containers exit naturally, deploy state must carry explicit_runtime_state=exited and stopped_by_user=False.""" + user_id = _uid() + job_id = _uid() + + fake_gateway = MagicMock() + fake_gateway.inspect_container_state = AsyncMock(return_value={"status": "running"}) + fake_gateway.inspect_container_metrics = AsyncMock(side_effect=docker.errors.NotFound("gone")) + fake_gateway.inspect_container_final_state = AsyncMock( + return_value={"container_id": "c1", "exit_code": 1, "oom_killed": False, "last_logs": []} + ) + + redis_boxes: dict = {} + + async def fake_get_state(uid, jid): + return dict(redis_boxes) + + async def fake_set_state(uid, jid, state): + redis_boxes.update(state) + + with ( + patch("app.workers.tasks.DockerGateway", return_value=fake_gateway), + patch("app.workers.tasks.publish_event", new=AsyncMock()), + patch("app.workers.tasks._is_stop_requested", new=AsyncMock(return_value=False)), + patch("app.workers.tasks._get_deploy_state", side_effect=fake_get_state), + patch("app.workers.tasks._set_deploy_state", side_effect=fake_set_state), + ): + from app.workers.tasks import _stream_metrics + await _stream_metrics(user_id, job_id, ["c1"]) + + assert redis_boxes.get("explicit_runtime_state") == "exited" + assert redis_boxes.get("stopped_by_user") is False + assert redis_boxes.get("exit_reason") == "all_containers_exited" + + +@pytest.mark.asyncio +async def test_stream_metrics_user_stop_keeps_user_stop_semantics(): + """When user stop is requested, _stream_metrics should not emit runtime_stopped or set self-exit markers.""" + user_id = _uid() + job_id = _uid() + + fake_gateway = MagicMock() + fake_gateway.inspect_container_state = AsyncMock(return_value={"status": "running"}) + fake_gateway.inspect_container_metrics = AsyncMock(side_effect=docker.errors.NotFound("gone")) + fake_gateway.inspect_container_final_state = AsyncMock( + return_value={"container_id": "c1", "exit_code": 0, "oom_killed": False, "last_logs": []} + ) + + published_events: list[Any] = [] + redis_boxes: dict[str, Any] = {"stop_requested_by_user": True, "stopped_by_user": True} + + async def capture(event): + published_events.append(event) + + async def fake_get_state(uid, jid): + return dict(redis_boxes) + + async def fake_set_state(uid, jid, state): + redis_boxes.update(state) + + with ( + patch("app.workers.tasks.DockerGateway", return_value=fake_gateway), + patch("app.workers.tasks.publish_event", side_effect=capture), + patch("app.workers.tasks._is_stop_requested", new=AsyncMock(return_value=False)), + patch("app.workers.tasks._get_deploy_state", side_effect=fake_get_state), + patch("app.workers.tasks._set_deploy_state", side_effect=fake_set_state), + ): + from app.workers.tasks import _stream_metrics + await _stream_metrics(user_id, job_id, ["c1"]) + + event_names = [e.event_name for e in published_events] + assert "container.exited" in event_names + assert "project.runtime_stopped" not in event_names + exited_event = next(e for e in published_events if e.event_name == "container.exited") + assert exited_event.payload.get("stop_requested_by_user") is True + assert redis_boxes.get("explicit_runtime_state") != "exited" + assert redis_boxes.get("exit_reason") != "all_containers_exited" + + +@pytest.mark.asyncio +async def test_deploy_status_differentiates_stopped_by_user_vs_self_exit(): + """compute_deploy_status must return stopped_by_user for user stops and can_retry_runtime accordingly.""" + import json + from app.api.routers.compose import compute_deploy_status + + user_id = uuid.uuid4() + job_id = uuid.uuid4() + + # Simulate user-stopped state + stopped_state = json.dumps({ + "container_ids": ["c1"], + "containers": [{"id": "c1", "status": "exited"}], + "explicit_runtime_state": "stopped_by_user", + "stopped_by_user": True, + "stop_reason": "user_requested", + }) + + with patch("app.api.routers.compose.redis_client") as mock_redis: + mock_redis.get = AsyncMock(side_effect=[stopped_state.encode(), None]) + result = await compute_deploy_status(user_id, job_id) + + assert result.runtime_state == "stopped_by_user" + assert result.stopped_by_user is True + assert result.can_retry_runtime is True + + # Simulate self-exit state + self_exit_state = json.dumps({ + "container_ids": ["c1"], + "containers": [{"id": "c1", "status": "exited"}], + "explicit_runtime_state": "exited", + "stopped_by_user": False, + "exit_reason": "all_containers_exited", + }) + + with patch("app.api.routers.compose.redis_client") as mock_redis: + mock_redis.get = AsyncMock(side_effect=[self_exit_state.encode(), None]) + result = await compute_deploy_status(user_id, job_id) + + assert result.runtime_state == "exited" + assert result.stopped_by_user is False + assert result.can_retry_runtime is False diff --git a/backend/tests/test_project_scan.py b/backend/tests/test_project_scan.py index 0d6babf..e35e6b0 100644 --- a/backend/tests/test_project_scan.py +++ b/backend/tests/test_project_scan.py @@ -152,12 +152,16 @@ def test_scan_detects_compose_files(tmp_path: Path) -> None: { "docker-compose.yml": "services: {}\n", "compose.yaml": "services: {}\n", + "docker-compose.prod.yml": "services: {}\n", + "docker-compose.images.yaml": "services: {}\n", }, ) result = scan_extracted_project(root, "project.zip") cfs = result["detected"]["compose_files"] assert any("docker-compose.yml" in cf for cf in cfs) assert any("compose.yaml" in cf for cf in cfs) + assert any("docker-compose.prod.yml" in cf for cf in cfs) + assert any("docker-compose.images.yaml" in cf for cf in cfs) def test_scan_ignores_node_modules(tmp_path: Path) -> None: diff --git a/backend/tests/test_research_api.py b/backend/tests/test_research_api.py index 11386fd..55e3d3f 100644 --- a/backend/tests/test_research_api.py +++ b/backend/tests/test_research_api.py @@ -315,3 +315,221 @@ async def list_global(_self, **_kwargs): # Both jobs owned by the same user_id should share the same anonymized_submitter. assert items[0]["anonymized_submitter"] == items[1]["anonymized_submitter"] assert items[0]["anonymized_submitter"] == anonymize_user_id(owner_id) + + +def test_research_findings_empty_summary(research_client: TestClient, research_app, monkeypatch: pytest.MonkeyPatch): + user = make_user() + research_app.dependency_overrides[get_current_user] = lambda: user + + async def list_global(_self, **_kwargs): + return [] + + monkeypatch.setattr(JobRepository, "list_jobs_global", list_global) + response = research_client.get( + "/api/v1/research/findings", + headers=auth_header_for(user.id), + ) + assert response.status_code == 200 + body = response.json() + assert body["total_findings"] == 0 + assert body["total_jobs_considered"] == 0 + assert body["top_overall"] == [] + assert body["top_errors"] == [] + assert body["top_warnings"] == [] + assert body["top_info"] == [] + assert body["top_security"] == [] + + +def test_research_findings_counts_top_level(research_client: TestClient, research_app, monkeypatch: pytest.MonkeyPatch): + user = make_user() + research_app.dependency_overrides[get_current_user] = lambda: user + + docker_job = AnalysisJobModel( + id=uuid.uuid4(), + user_id=uuid.uuid4(), + type=JobType.dockerfile, + status=JobStatus.done, + input_metadata={}, + result={ + "errors": [{"code": "DL3008", "severity": "error", "message": "Pin apt versions"}], + "warnings": [{"code": "DL3006", "severity": "warning", "message": "Always tag image"}], + "suggestions": [{"code": "GEN001", "severity": "info", "message": "Use LABEL metadata"}], + "securityIssues": [{"code": "SEC001", "severity": "error", "message": "Hardcoded secret"}], + }, + created_at=datetime.now(UTC), + ) + compose_job = AnalysisJobModel( + id=uuid.uuid4(), + user_id=uuid.uuid4(), + type=JobType.compose, + status=JobStatus.done, + input_metadata={}, + result={ + "errors": [{"code": "DL3008", "severity": "error", "message": "Pin apt versions"}], + "warnings": [], + "suggestions": [], + "securityIssues": [], + }, + created_at=datetime.now(UTC), + ) + + async def list_global(_self, **_kwargs): + return [docker_job, compose_job] + + monkeypatch.setattr(JobRepository, "list_jobs_global", list_global) + + response = research_client.get( + "/api/v1/research/findings", + headers=auth_header_for(user.id), + ) + assert response.status_code == 200 + body = response.json() + assert body["total_jobs_considered"] == 2 + assert body["total_findings"] == 5 + top_error = body["top_errors"][0] + assert top_error["code"] == "DL3008" + assert top_error["count"] == 2 + assert top_error["workflow_counts"]["dockerfile"] == 1 + assert top_error["workflow_counts"]["compose"] == 1 + assert body["top_warnings"][0]["code"] == "DL3006" + assert body["top_info"][0]["code"] == "GEN001" + assert body["top_security"][0]["code"] == "SEC001" + + +def test_research_findings_counts_project_per_file_results(research_client: TestClient, research_app, monkeypatch: pytest.MonkeyPatch): + user = make_user() + research_app.dependency_overrides[get_current_user] = lambda: user + + project_job = AnalysisJobModel( + id=uuid.uuid4(), + user_id=uuid.uuid4(), + type=JobType.project, + status=JobStatus.done, + input_metadata={"project_path": "/tmp/private-project"}, + result={ + "errors": [{"code": "TOP001", "severity": "error", "message": "top level"}], + "per_file_results": [ + { + "file_path": "services/api/Dockerfile", + "errors": [{"code": "DL3018", "severity": "error", "message": "Pin apk versions"}], + "warnings": [], + "suggestions": [], + "securityIssues": [], + }, + { + "file_path": "compose.yml", + "errors": [{"code": "DL3018", "severity": "error", "message": "Pin apk versions"}], + "warnings": [{"code": "CMP001", "severity": "warning", "message": "Missing restart policy"}], + "suggestions": [], + "securityIssues": [], + }, + ], + }, + created_at=datetime.now(UTC), + ) + + async def list_global(_self, **_kwargs): + return [project_job] + + monkeypatch.setattr(JobRepository, "list_jobs_global", list_global) + + response = research_client.get( + "/api/v1/research/findings", + headers=auth_header_for(user.id), + ) + assert response.status_code == 200 + body = response.json() + assert body["total_findings"] == 3 + assert body["top_errors"][0]["code"] == "DL3018" + assert body["top_errors"][0]["count"] == 2 + assert body["top_errors"][0]["workflow_counts"]["project"] == 2 + assert body["top_warnings"][0]["code"] == "CMP001" + + +def test_research_findings_limit_is_respected(research_client: TestClient, research_app, monkeypatch: pytest.MonkeyPatch): + user = make_user() + research_app.dependency_overrides[get_current_user] = lambda: user + + job = AnalysisJobModel( + id=uuid.uuid4(), + user_id=uuid.uuid4(), + type=JobType.compose, + status=JobStatus.done, + input_metadata={}, + result={ + "errors": [], + "warnings": [ + {"code": "W001", "severity": "warning", "message": "Warning one"}, + {"code": "W002", "severity": "warning", "message": "Warning two"}, + {"code": "W003", "severity": "warning", "message": "Warning three"}, + ], + "suggestions": [], + "securityIssues": [], + }, + created_at=datetime.now(UTC), + ) + + async def list_global(_self, **_kwargs): + return [job] + + monkeypatch.setattr(JobRepository, "list_jobs_global", list_global) + + response = research_client.get( + "/api/v1/research/findings", + params={"limit": 2}, + headers=auth_header_for(user.id), + ) + assert response.status_code == 200 + body = response.json() + assert len(body["top_warnings"]) == 2 + assert len(body["top_overall"]) == 2 + + +def test_research_findings_privacy_no_sensitive_leak(research_client: TestClient, research_app, monkeypatch: pytest.MonkeyPatch): + user = make_user() + research_app.dependency_overrides[get_current_user] = lambda: user + + sensitive_job = AnalysisJobModel( + id=uuid.uuid4(), + user_id=uuid.uuid4(), + type=JobType.dockerfile, + status=JobStatus.done, + input_metadata={ + "project_path": "/tmp/private-repo", + "source_preview": "FROM private.registry.local/my-app", + "filename": "secret/Dockerfile", + }, + result={ + "errors": [ + { + "code": "DL4000", + "severity": "error", + "message": "Token leaked token=abc123 at /tmp/private-repo/Dockerfile from api.internal.local 10.1.2.3", + "doc_url": "https://example.com/docs", + } + ], + "warnings": [], + "suggestions": [], + "securityIssues": [], + }, + created_at=datetime.now(UTC), + ) + + async def list_global(_self, **_kwargs): + return [sensitive_job] + + monkeypatch.setattr(JobRepository, "list_jobs_global", list_global) + + response = research_client.get( + "/api/v1/research/findings", + headers=auth_header_for(user.id), + ) + assert response.status_code == 200 + body = response.json() + blob = str(body) + assert "private-repo" not in blob + assert "token=abc123" not in blob + assert "api.internal.local" not in blob + assert "10.1.2.3" not in blob + assert "/tmp/private-repo" not in blob + assert "input_metadata" not in blob diff --git a/backend/tests/test_research_privacy.py b/backend/tests/test_research_privacy.py index 9061518..f33e020 100644 --- a/backend/tests/test_research_privacy.py +++ b/backend/tests/test_research_privacy.py @@ -1,7 +1,11 @@ """Unit tests for research API privacy helpers.""" import json -from app.api.research_privacy import sanitize_research_result, strip_source_preview_recursive +from app.api.research_privacy import ( + extract_public_research_findings, + sanitize_research_result, + strip_source_preview_recursive, +) def test_strip_source_preview_recursive_removes_nested_keys(): @@ -38,3 +42,43 @@ def test_sanitize_never_contains_source_preview_in_output(): out = json.dumps(safe).lower() assert "secret_marker_xyz" not in out assert "source_preview" not in out + + +def test_extract_public_research_findings_normalizes_and_redacts(): + result = { + "errors": [ + { + "code": "", + "severity": "error", + "message": "Token=abc123 in /tmp/private/Dockerfile from api.internal.local 10.4.5.6", + } + ], + "warnings": [], + "suggestions": [], + "securityIssues": [], + } + findings = extract_public_research_findings(result) + assert len(findings) == 1 + finding = findings[0] + assert finding["code"] == "UNKNOWN" + assert finding["severity"] == "error" + assert finding["message"] == "[redacted] in [redacted-path] from [redacted-domain] [redacted-ip]" + + +def test_extract_public_research_findings_prefers_per_file_when_requested(): + result = { + "errors": [{"code": "TOP001", "severity": "error", "message": "top-level error"}], + "per_file_results": [ + { + "file_path": "src/Dockerfile", + "errors": [{"code": "PF001", "severity": "error", "message": "per-file error"}], + "warnings": [], + "suggestions": [], + "securityIssues": [], + } + ], + } + findings = extract_public_research_findings(result, prefer_per_file=True) + assert len(findings) == 1 + assert findings[0]["code"] == "PF001" + assert findings[0]["message"] == "per-file error" diff --git a/backend/tests/test_worker_events.py b/backend/tests/test_worker_events.py index 73447f2..2033c1d 100644 --- a/backend/tests/test_worker_events.py +++ b/backend/tests/test_worker_events.py @@ -1,8 +1,12 @@ import pytest import uuid +from pathlib import Path + +import yaml from app.workers.tasks import ( _compose_up, + _resolve_deploy_spec, _stderr_suggests_host_port_publish_conflict, run_compose_deploy, run_compose_stop, @@ -93,10 +97,75 @@ async def fake_compose_ps_ids(_spec): assert metrics_event.payload["container_id"] == "web-123" +@pytest.mark.asyncio +async def test_run_compose_deploy_emits_reuse_log_when_prebuilt_images_available( + monkeypatch: pytest.MonkeyPatch, +): + emitted_events = [] + + async def fake_publish(event): + emitted_events.append(event) + + class FakeSessionContext: + async def __aenter__(self): + return object() + + async def __aexit__(self, exc_type, exc, tb): + return False + + class FakeRepo: + def __init__(self, _session): + pass + + async def get_job(self, _job_id, _user_id): + class _Job: + input_metadata = {"compose_content": "services: {web: {image: nginx:1.27}}"} + + return _Job() + + async def fake_set_state(_user_id: str, _job_id: str, _state: dict): + return None + + async def fake_compose_up(_spec, **_kwargs): + return None + + async def fake_compose_ps_ids(_spec): + return [] + + monkeypatch.setattr("app.workers.tasks.SessionLocal", lambda: FakeSessionContext()) + monkeypatch.setattr("app.workers.tasks.JobRepository", FakeRepo) + monkeypatch.setattr( + "app.workers.tasks._resolve_deploy_spec", + lambda _user, _job, _meta: { + "project_name": "dqa-proj", + "project_dir": "/tmp", + "compose_file": "/tmp/docker-compose.yml", + "reused_service_names": ["app"], + }, + ) + monkeypatch.setattr("app.workers.tasks._set_deploy_state", fake_set_state) + monkeypatch.setattr("app.workers.tasks._compose_up", fake_compose_up) + monkeypatch.setattr("app.workers.tasks._compose_ps_ids", fake_compose_ps_ids) + monkeypatch.setattr("app.workers.tasks.publish_event", fake_publish) + + await run_compose_deploy( + None, + { + "user_id": str(uuid.uuid4()), + "job_id": str(uuid.uuid4()), + "run_stack": True, + }, + ) + + log_events = [event for event in emitted_events if event.event_name == "deploy.compose_up_log"] + assert len(log_events) >= 1 + assert "Reusing prebuilt analysis images for services: app" in log_events[0].payload.get("line", "") + + @pytest.mark.asyncio async def test_run_compose_deploy_clears_redis_when_compose_up_fails(monkeypatch: pytest.MonkeyPatch): emitted_events = [] - cleared: list[tuple[str, str]] = [] + set_state_calls: list[dict] = [] down_calls: list[tuple[str, bool]] = [] async def fake_publish(event): @@ -122,11 +191,11 @@ class _Job: return _Job() - async def fake_clear(uid: str, jid: str): - cleared.append((uid, jid)) + async def fake_set_deploy(uid: str, jid: str, state: dict): + set_state_calls.append(state) - async def fake_set_deploy(*_a, **_k): - return None + async def fake_get_deploy(uid: str, jid: str): + return {} monkeypatch.setattr("app.workers.tasks.SessionLocal", lambda: FakeSessionContext()) monkeypatch.setattr("app.workers.tasks.JobRepository", FakeRepo) @@ -139,13 +208,13 @@ async def fake_set_deploy(*_a, **_k): }, ) monkeypatch.setattr("app.workers.tasks._set_deploy_state", fake_set_deploy) + monkeypatch.setattr("app.workers.tasks._get_deploy_state", fake_get_deploy) async def failing_compose_up(_spec, **_kwargs): raise RuntimeError("docker-compose up failed: nope") monkeypatch.setattr("app.workers.tasks._compose_up", failing_compose_up) monkeypatch.setattr("app.workers.tasks._compose_down", record_down) - monkeypatch.setattr("app.workers.tasks._clear_deploy_state", fake_clear) monkeypatch.setattr("app.workers.tasks.publish_event", fake_publish) user_id = str(uuid.uuid4()) @@ -156,7 +225,9 @@ async def failing_compose_up(_spec, **_kwargs): {"user_id": user_id, "job_id": job_id, "run_stack": True}, ) - assert cleared == [(user_id, job_id)] + # Cleanup state must mark cleanup_completed (not self-exit) + final_states = [s for s in set_state_calls if s.get("explicit_runtime_state") == "cleanup_completed"] + assert len(final_states) >= 1 assert down_calls == [("dqa-proj", False)] assert [event.event_name for event in emitted_events] == [ "deploy.cleanup_started", @@ -168,7 +239,7 @@ async def failing_compose_up(_spec, **_kwargs): @pytest.mark.asyncio async def test_run_compose_deploy_cleans_up_when_post_start_work_fails(monkeypatch: pytest.MonkeyPatch): emitted_events = [] - cleared: list[tuple[str, str]] = [] + set_state_calls: list[dict] = [] down_calls: list[tuple[str, bool]] = [] async def fake_publish(event): @@ -194,8 +265,11 @@ class _Job: return _Job() - async def fake_set_deploy(*_a, **_k): - return None + async def fake_set_deploy(uid: str, jid: str, state: dict): + set_state_calls.append(state) + + async def fake_get_deploy(uid: str, jid: str): + return {} async def fake_compose_up(_spec, **_kwargs): return None @@ -206,9 +280,6 @@ async def fake_compose_ps_ids(_spec): async def failing_stream_metrics(_user_id: str, _job_id: str, _container_ids: list[str]): raise RuntimeError("metrics failed after stack start") - async def fake_clear(uid: str, jid: str): - cleared.append((uid, jid)) - monkeypatch.setattr("app.workers.tasks.SessionLocal", lambda: FakeSessionContext()) monkeypatch.setattr("app.workers.tasks.JobRepository", FakeRepo) monkeypatch.setattr( @@ -220,11 +291,11 @@ async def fake_clear(uid: str, jid: str): }, ) monkeypatch.setattr("app.workers.tasks._set_deploy_state", fake_set_deploy) + monkeypatch.setattr("app.workers.tasks._get_deploy_state", fake_get_deploy) monkeypatch.setattr("app.workers.tasks._compose_up", fake_compose_up) monkeypatch.setattr("app.workers.tasks._compose_ps_ids", fake_compose_ps_ids) monkeypatch.setattr("app.workers.tasks._stream_metrics", failing_stream_metrics) monkeypatch.setattr("app.workers.tasks._compose_down", record_down) - monkeypatch.setattr("app.workers.tasks._clear_deploy_state", fake_clear) monkeypatch.setattr("app.workers.tasks.publish_event", fake_publish) user_id = str(uuid.uuid4()) @@ -235,7 +306,9 @@ async def fake_clear(uid: str, jid: str): {"user_id": user_id, "job_id": job_id, "run_stack": True}, ) - assert cleared == [(user_id, job_id)] + # Cleanup state must mark cleanup_completed + final_states = [s for s in set_state_calls if s.get("explicit_runtime_state") == "cleanup_completed"] + assert len(final_states) >= 1 assert down_calls == [("dqa-proj", False)] assert [event.event_name for event in emitted_events] == [ "container.started", @@ -249,7 +322,7 @@ async def fake_clear(uid: str, jid: str): async def test_run_compose_stop_downs_stack_and_emits_event(monkeypatch: pytest.MonkeyPatch): emitted_events = [] stop_called = {"value": False} - cleared_called = {"value": False} + set_state_calls: list[dict] = [] async def fake_publish(event): emitted_events.append(event) @@ -265,13 +338,17 @@ async def fake_get_state(_user_id: str, _job_id: str): async def fake_set_stop(_user_id: str, _job_id: str): stop_called["value"] = True - async def fake_clear(_user_id: str, _job_id: str): - cleared_called["value"] = True + async def fake_set_deploy(uid: str, jid: str, state: dict): + set_state_calls.append(state) + + from unittest.mock import AsyncMock as _AsyncMock + fake_redis_del = _AsyncMock() monkeypatch.setattr("app.workers.tasks.publish_event", fake_publish) monkeypatch.setattr("app.workers.tasks._get_deploy_state", fake_get_state) monkeypatch.setattr("app.workers.tasks._set_stop_requested", fake_set_stop) - monkeypatch.setattr("app.workers.tasks._clear_deploy_state", fake_clear) + monkeypatch.setattr("app.workers.tasks._set_deploy_state", fake_set_deploy) + monkeypatch.setattr("app.workers.tasks.redis_client.delete", fake_redis_del) async def fake_compose_down(_state, _remove_volumes): return None @@ -289,7 +366,18 @@ async def fake_compose_down(_state, _remove_volumes): assert result["status"] == "stopped" assert stop_called["value"] is True - assert cleared_called["value"] is True + # First persisted state should immediately carry user-stop intent. + assert len(set_state_calls) >= 2 + initial_state = set_state_calls[0] + assert initial_state["stopping"] is True + assert initial_state["stop_requested_by_user"] is True + assert initial_state["stopped_by_user"] is True + assert initial_state["stop_reason"] == "user_requested" + # Terminal state must mark stopped_by_user=True + terminal_states = [s for s in set_state_calls if s.get("explicit_runtime_state") == "stopped_by_user"] + assert len(terminal_states) >= 1 + assert terminal_states[-1]["stopped_by_user"] is True + assert terminal_states[-1]["stop_requested_by_user"] is True event_names = [event.event_name for event in emitted_events] assert "container.stopped" in event_names @@ -357,3 +445,48 @@ async def fake_stream(_cmd: list[str], cwd: str | None, on_line): } ) assert len(calls) == 1 + + +def test_resolve_deploy_spec_reuses_project_built_images(tmp_path: Path): + compose_path = tmp_path / "docker-compose.yml" + compose_path.write_text( + "services:\n" + " app:\n" + " build:\n" + " context: .\n" + " dockerfile: Dockerfile\n" + " ports:\n" + ' - "8081:8081"\n', + encoding="utf-8", + ) + (tmp_path / "Dockerfile").write_text("FROM python:3.12-slim\n", encoding="utf-8") + + deploy_spec = _resolve_deploy_spec( + "user-1", + "job-1", + { + "project_path": str(tmp_path), + "primary_compose_file": "docker-compose.yml", + "_job_result": { + "image_build_results": [ + {"dockerfile_path": "Dockerfile", "image_tag": "dqa-job-1-main", "status": "success"}, + ], + "service_mappings": [ + { + "service": "app", + "compose_file": "docker-compose.yml", + "resolved_dockerfile": "Dockerfile", + "can_build": True, + } + ], + }, + }, + ) + + runtime_compose_path = Path(deploy_spec["compose_file"]) + assert runtime_compose_path.name == ".dqa-runtime.compose.yml" + runtime_compose = yaml.safe_load(runtime_compose_path.read_text(encoding="utf-8")) + app_service = runtime_compose["services"]["app"] + assert app_service["image"] == "dqa-job-1-main" + assert "build" not in app_service + assert deploy_spec["reused_service_names"] == ["app"] diff --git a/backend/uv.lock b/backend/uv.lock new file mode 100644 index 0000000..b28dd60 --- /dev/null +++ b/backend/uv.lock @@ -0,0 +1,8 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "docker-quality-analyzer-backend" +version = "0.1.0" +source = { editable = "." } diff --git a/compose.dev.yaml b/compose.dev.yaml new file mode 100644 index 0000000..00f1254 --- /dev/null +++ b/compose.dev.yaml @@ -0,0 +1,52 @@ +services: + api: + environment: + APP_ENV: dev + volumes: + - ./backend/app:/app/app + - ./backend/alembic:/app/alembic + - ./backend/alembic.ini:/app/alembic.ini:ro + ports: !override + - "127.0.0.1:${API_PORT:-8000}:8000" + + worker: + environment: + APP_ENV: dev + volumes: + - ./backend/app:/app/app + - ./backend/alembic:/app/alembic + - ./backend/alembic.ini:/app/alembic.ini:ro + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + target: builder + image: docker-platform-frontend-dev:local + working_dir: /app + command: sh -c "npm install && npm run dev -- --host 0.0.0.0" + environment: + VITE_API_BASE_URL: "" + volumes: + - ./frontend:/app + ports: !override + - "127.0.0.1:${FRONTEND_PORT:-3000}:5173" + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:5173 >/dev/null 2>&1 || exit 1"] + interval: 10s + timeout: 3s + retries: 20 + start_period: 20s + + adminer: + image: adminer:4 + profiles: ["tools"] + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + networks: + - backend + - public + ports: + - "127.0.0.1:${ADMINER_PORT:-8080}:8080" diff --git a/compose.prod.yaml b/compose.prod.yaml new file mode 100644 index 0000000..7e96d1b --- /dev/null +++ b/compose.prod.yaml @@ -0,0 +1,48 @@ +services: + api: + environment: + APP_ENV: prod + ports: !override [] + security_opt: + - no-new-privileges:true + tmpfs: + - /tmp + mem_limit: ${API_MEM_LIMIT:-1g} + cpus: ${API_CPU_LIMIT:-1.00} + + worker: + environment: + APP_ENV: prod + security_opt: + - no-new-privileges:true + tmpfs: + - /tmp + mem_limit: ${WORKER_MEM_LIMIT:-1g} + cpus: ${WORKER_CPU_LIMIT:-1.00} + + frontend: + security_opt: + - no-new-privileges:true + read_only: true + tmpfs: + - /tmp + - /var/cache/nginx + - /var/run + mem_limit: ${FRONTEND_MEM_LIMIT:-512m} + cpus: ${FRONTEND_CPU_LIMIT:-0.50} + + postgres: + security_opt: + - no-new-privileges:true + tmpfs: + - /tmp + mem_limit: ${POSTGRES_MEM_LIMIT:-1g} + cpus: ${POSTGRES_CPU_LIMIT:-1.00} + + redis: + security_opt: + - no-new-privileges:true + tmpfs: + - /tmp + mem_limit: ${REDIS_MEM_LIMIT:-512m} + cpus: ${REDIS_CPU_LIMIT:-0.50} diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..e8ae416 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,204 @@ +name: docker-platform + +x-logging: &default-logging + driver: json-file + options: + max-size: "10m" + max-file: "3" + +x-backend-build: &backend-build + context: ./backend + dockerfile: Dockerfile + +x-app-runtime: &app-runtime + init: true + restart: unless-stopped + logging: *default-logging + secrets: + - postgres_password + - jwt_secret + volumes: + - uploads_data:/app/storage/uploads + environment: + APP_ENV: ${APP_ENV:-dev} + REDIS_URL: redis://redis:6379/0 + +services: + api: + build: *backend-build + image: docker-platform-api:local + <<: *app-runtime + command: + - sh + - -lc + - | + set -euo pipefail + POSTGRES_PASSWORD="$(cat /run/secrets/postgres_password)" + JWT_SECRET_KEY="$(cat /run/secrets/jwt_secret)" + export JWT_SECRET_KEY + export DATABASE_URL="postgresql+asyncpg://postgres:$${POSTGRES_PASSWORD}@postgres:5432/docker_platform" + exec uvicorn app.main:app --host 0.0.0.0 --port 8000 + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + networks: + - public + - backend + ports: + - "127.0.0.1:${API_PORT:-8000}:8000" + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8000/health || exit 1"] + interval: 10s + timeout: 3s + retries: 10 + start_period: 15s + + worker: + build: *backend-build + image: docker-platform-worker:local + <<: *app-runtime + command: + - sh + - -lc + - | + set -euo pipefail + POSTGRES_PASSWORD="$(cat /run/secrets/postgres_password)" + JWT_SECRET_KEY="$(cat /run/secrets/jwt_secret)" + export JWT_SECRET_KEY + export DATABASE_URL="postgresql+asyncpg://postgres:$${POSTGRES_PASSWORD}@postgres:5432/docker_platform" + exec arq app.workers.worker.WorkerSettings + depends_on: + dind: + condition: service_healthy + postgres: + condition: service_healthy + redis: + condition: service_healthy + environment: + APP_ENV: ${APP_ENV:-dev} + REDIS_URL: redis://redis:6379/0 + DOCKER_HOST: tcp://docker:2376 + DOCKER_TLS_VERIFY: "1" + DOCKER_CERT_PATH: /certs/client + volumes: + - uploads_data:/app/storage/uploads + - dind_certs:/certs/client:ro + networks: + - backend + - docker_engine + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + args: + VITE_API_BASE_URL: ${VITE_API_BASE_URL:-} + image: docker-platform-frontend:local + init: true + restart: unless-stopped + logging: *default-logging + depends_on: + api: + condition: service_healthy + networks: + - public + - backend + ports: + - "127.0.0.1:${FRONTEND_PORT:-3000}:80" + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/healthz >/dev/null 2>&1 || exit 1"] + interval: 10s + timeout: 3s + retries: 10 + start_period: 10s + + postgres: + image: postgres:16-alpine + restart: unless-stopped + logging: *default-logging + environment: + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_DB: ${POSTGRES_DB:-docker_platform} + POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password + secrets: + - postgres_password + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - backend + - data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] + interval: 5s + timeout: 3s + retries: 10 + + redis: + image: redis:7-alpine + restart: unless-stopped + logging: *default-logging + command: ["redis-server", "--appendonly", "yes", "--save", "60", "1"] + volumes: + - redis_data:/data + networks: + - backend + - data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + + dind: + build: + context: . + dockerfile: docker/dind/Dockerfile + image: docker-platform-dind:local + privileged: true + restart: unless-stopped + logging: *default-logging + environment: + DOCKER_TLS_CERTDIR: /certs + command: + - "--storage-driver=overlay2" + - "--dns=1.1.1.1" + - "--dns=8.8.8.8" + dns: + - 1.1.1.1 + - 8.8.8.8 + volumes: + - dind_data:/var/lib/docker + - dind_certs:/certs/client + networks: + backend: {} + docker_engine: + aliases: + - docker + healthcheck: + test: ["CMD-SHELL", "docker info >/dev/null 2>&1 || exit 1"] + interval: 10s + timeout: 5s + retries: 20 + +volumes: + postgres_data: + redis_data: + uploads_data: + dind_data: + dind_certs: + +networks: + public: + backend: + internal: true + data: + internal: true + docker_engine: {} + +secrets: + postgres_password: + file: ./secrets/postgres_password.txt + jwt_secret: + file: ./secrets/jwt_secret.txt diff --git a/context/add-delete-refactor-playbook.md b/context/add-delete-refactor-playbook.md new file mode 100644 index 0000000..9067787 --- /dev/null +++ b/context/add-delete-refactor-playbook.md @@ -0,0 +1,48 @@ +# Add/Delete/Refactor Playbook + +## Purpose + +Provide a single execution workflow for change requests so delivery stays fast, predictable, and reviewable. + +## Standard flow + +1. Confirm scope and expected behavior change. +2. Identify impacted backend/frontend/runtime boundaries. +3. Implement the minimal safe change. +4. Run strict quality gates for affected areas. +5. Summarize what changed, why, and how it was validated. + +## Definition of done (strict) + +A substantial task is complete only when all relevant checks pass: + +- Backend changes: `cd backend && pytest -q` +- Frontend changes: `cd frontend && npm run lint && npm run build` +- Cross-cutting changes: run both backend and frontend checks + +If a check fails, iterate fix -> rerun until green. + +## Add tasks + +- Prefer smallest vertical slice that ships user value. +- Add tests for behavior changes before closing. +- Keep API/UI contracts explicit and typed. + +## Delete tasks + +- Remove dead code and stale references in same change. +- Preserve compatibility or provide migration notes when needed. +- Confirm no orphaned docs/rules remain. + +## Refactor tasks + +- No behavior drift unless explicitly requested. +- Keep refactors incremental and reversible. +- Validate with no-regression checks and targeted smoke paths. + +## PR-ready summary format + +- Change intent (one sentence) +- Files/systems touched +- Risk notes +- Validation commands and outcomes diff --git a/context/chat-knowledge-index.md b/context/chat-knowledge-index.md new file mode 100644 index 0000000..78f9f63 --- /dev/null +++ b/context/chat-knowledge-index.md @@ -0,0 +1,33 @@ +# Chat Knowledge Index (Current Application) + +Last updated: 2026-05-17 + +This file consolidates product decisions extracted from parent chat transcripts and keeps them aligned with current application behavior. + +## Core decisions + +- Project upload currently follows **scan -> auto-analyze detected Docker/Compose assets**. +- Upload flow currently defaults image builds on (`build_selected_images=true` in payload), while build execution remains worker-gated. +- Compose run/deploy remains explicit user intent (`run_stack` path), not an automatic side effect of upload/analysis. +- Project merged result integrity must preserve `per_file_results`, `image_build_results`, `service_mappings`, and `project_summary`. +- Runtime/monitoring should represent terminal states truthfully (`exited`, `failed`, `stopped_by_user`, `cleanup_completed`) and avoid misleading "live" indicators. +- Research analytics must remain privacy-safe: anonymized submitter plus sanitized public metadata/results only. +- Dockerfile issue metadata can include deterministic documentation links for Hadolint/ShellCheck codes. + +## High-value transcript sources + +- [Project Analysis Major Milestone](c9bcaf0d-eb3f-4815-8c70-47be9a8768b4) +- [Project Workflow State Hardening](3a6344b2-564c-49c4-aecb-5723cb938eaa) +- [Simplify Upload Direct Analysis](b6513236-c6ac-492b-ba2b-e56811a73c8e) +- [Privacy-Safe Research Final Polish](199ee96f-ca71-439f-a20f-2ec2afc490fd) +- [Monitoring Persistence and Telemetry](847810c0-e6e4-4e79-a961-a6545816b9df) +- [Align Rules to Decisions](5a592014-6fed-4912-bbcd-98e82deca923) +- [Create Initial MDC Rules](a390d7f6-3e64-4d86-a026-cbe617ffece5) +- [Fix Project Results Rendering](780280d1-96c2-4557-9ec2-92017b540e86) +- [Refactor Cursor Rules Set](3229f8ea-37e6-4266-a65a-c1b35282ec21) +- [Hadolint Wiki Links in Issues](2915bdbd-8745-467f-90ee-dda453e39737) + +## How to use + +- When behavior conflicts appear between docs and code, validate against the current backend/frontend implementation, then update this index and linked context docs. +- Keep this list focused on product-defining decisions; move minor tactical history to archive notes. diff --git a/context/chat-transcript-retention.md b/context/chat-transcript-retention.md new file mode 100644 index 0000000..8e328e9 --- /dev/null +++ b/context/chat-transcript-retention.md @@ -0,0 +1,51 @@ +# Chat Transcript Retention Recommendations + +Last updated: 2026-05-17 + +Scope: parent transcripts only (`/.jsonl`), excluding subagent transcripts. + +## Keep (active reference) + +- [Project Analysis Major Milestone](c9bcaf0d-eb3f-4815-8c70-47be9a8768b4) — major product workflow/scanner/privacy decisions +- [Project Workflow State Hardening](3a6344b2-564c-49c4-aecb-5723cb938eaa) — state-machine truthfulness and status handling +- [Simplify Upload Direct Analysis](b6513236-c6ac-492b-ba2b-e56811a73c8e) — direct-analysis workflow decision +- [Privacy-Safe Research Final Polish](199ee96f-ca71-439f-a20f-2ec2afc490fd) — privacy contract and sanitization behavior +- [Monitoring Persistence and Telemetry](847810c0-e6e4-4e79-a961-a6545816b9df) — monitoring/runtime telemetry behavior +- [Align Rules to Decisions](5a592014-6fed-4912-bbcd-98e82deca923) — rule/doc alignment to code truth +- [Create Initial MDC Rules](a390d7f6-3e64-4d86-a026-cbe617ffece5) — baseline rule architecture +- [Fix Project Results Rendering](780280d1-96c2-4557-9ec2-92017b540e86) — result/runtimestate truthfulness fixes +- [Refactor Cursor Rules Set](3229f8ea-37e6-4266-a65a-c1b35282ec21) — cross-rule consistency cleanup +- [Hadolint Wiki Links in Issues](2915bdbd-8745-467f-90ee-dda453e39737) — issue documentation-link behavior + +## Archive (useful history, not daily reference) + +- [API Keys Purpose and Removal](1735f5cb-1b99-4dad-b39a-fe37e9e44cc9) +- [Fix Compose API Export](4cf45642-35e7-4353-bf0d-2ad32ea139c8) +- [Research UI Motion Restyle](5d00f878-9fe7-405a-8976-e1c36842eca7) +- [Architecture and Compose Runnability](67e1a024-45d8-425a-a3c1-b2a1caaec0ee) +- [Frontend Cleanup and API Integration](7b29f305-2459-4bb3-abe6-05179cdc31f0) +- [Motion Polish and Gitignore](86a5d084-53f2-4e57-93ed-ee7d9cb928eb) +- [Populate Gitignore Across Repo](a801b002-7a0d-48aa-b421-b6c00f15a8e1) +- [Draft Resume Flow and DinD](eb0c5756-3c15-4b75-a77e-c5d499afea38) +- [Fix GitHub Actions Imports](f404051e-5b9e-48f5-bd07-6a5db8b08bcd) +- [README Update with DinD](f929403c-74cc-44fd-a3c2-26014427d69d) + +## Delete (obsolete/noise) + +- [Git Push and SSH Setup](33e492ca-e6e3-46a8-a391-40e17ed2976c) — local environment support only +- [Pytest Setup in Cursor](9deaf9e9-7b8c-4770-ab01-cb50e7d8def3) — IDE usage-only, no product decision +- [CI Import Error Request](a6ccf90a-8da1-44cb-a5e0-8f44ba187a4f) — incomplete/no durable app decision +- [Ask Test Types Only](e421981f-afd8-4619-8d58-54a62a5a14cb) — single-question/no decision transcript + +## Retention policy + +- Keep: product-defining behavior, privacy, workflow, runtime truthfulness, rules governance. +- Archive: tactical fixes that may help future debugging but are not canonical guidance. +- Delete: one-off tooling/help transcripts with no durable product knowledge. + +## Generated grouping artifacts + +- `context/transcript-groups-keep.txt` +- `context/transcript-groups-archive.txt` +- `context/transcript-groups-delete-candidates.txt` +- `context/transcript-grouping-commands.sh` (moves folders into `keep/`, `archive/`, `delete-candidates/` buckets without deleting content) diff --git a/context/continuous-optimization-loop.md b/context/continuous-optimization-loop.md new file mode 100644 index 0000000..3b12e4b --- /dev/null +++ b/context/continuous-optimization-loop.md @@ -0,0 +1,42 @@ +# Continuous Optimization Loop + +## Goal + +Continuously improve speed and reliability for add/delete/refactor work by feeding repeated issues back into rules, hooks, and playbooks. + +## Inputs + +- Hook output log: `context/quality-gate-feedback.log` +- Repeated review comments +- Repeated breakage classes (lint failures, test flakiness, build/type errors) +- Transcript knowledge index: `context/chat-knowledge-index.md` +- Transcript retention map: `context/chat-transcript-retention.md` + +## Monthly loop + +1. Review the last month of `quality-gate-feedback.log`. +2. Group failures by root cause category. +3. For each repeated category, choose one action: + - Rule update in `.cursor/rules/` + - Hook improvement in `.cursor/hooks.json` or `.cursor/hooks/` + - Playbook update in `context/add-delete-refactor-playbook.md` +4. Apply the smallest effective fix and record it in the decision log. +5. Re-run strict gates to confirm the improvement does not regress quality. +6. Reclassify new parent transcripts monthly into keep/archive/delete and refresh the two transcript context files. + +## Decision log template + +Use this format for each improvement: + +- **Date:** +- **Pattern observed:** +- **Frequency:** +- **Root cause:** +- **Action taken (rule/hook/playbook):** +- **Expected outcome:** +- **Validation result:** + +## Exit criteria for each cycle + +- At least one recurring pain point converted into a durable guardrail. +- No reduction in strict quality-gate coverage. diff --git a/context/master-task-prompt.md b/context/master-task-prompt.md new file mode 100644 index 0000000..2f06663 --- /dev/null +++ b/context/master-task-prompt.md @@ -0,0 +1,80 @@ +# Master Task Prompt (Cursor + Agent) + +Use this template for most implementation requests. + +## Default template + +```text +Task: + + +Product intent: + + +Constraints: +- Keep behavior unchanged unless explicitly requested +- Follow project rules and context docs +- Keep change set minimal and readable +- Do not touch unrelated files + +Validation: +- Run relevant strict checks for touched areas +- If checks fail, fix and rerun until green + +Output format: +- What changed +- Why it changed +- Risks/edge cases +- Validation commands + results +``` + +## Quick variants + +### Add feature + +```text +Task: +Add in . + +Constraints: +- Keep existing APIs backward compatible +- Add tests for new behavior + +Validation: +- Run backend/frontend checks impacted by this feature +``` + +### Delete/remove + +```text +Task: +Remove . + +Constraints: +- Remove dead references/imports/docs in same pass +- Preserve behavior for remaining flows + +Validation: +- Run checks and confirm no orphaned routes/types +``` + +### Refactor only + +```text +Task: +Refactor for readability/maintainability. + +Constraints: +- No functional behavior change +- Keep public contracts unchanged + +Validation: +- No-regression checks + targeted tests +``` + +## Where to point the agent first + +- Current product decisions: `context/chat-knowledge-index.md` +- Workflow and lifecycle truth: `context/ui-and-jobs.md` +- Execution standards: `context/add-delete-refactor-playbook.md` +- Refactor priorities: `context/refactor-backlog.md` diff --git a/context/refactor-backlog.md b/context/refactor-backlog.md new file mode 100644 index 0000000..af87d36 --- /dev/null +++ b/context/refactor-backlog.md @@ -0,0 +1,43 @@ +# Refactor Backlog (Ranked) + +## Scoring model + +- **Impact:** 1 (low) to 5 (high) +- **Risk:** 1 (low) to 5 (high) +- **Effort:** 1 (low) to 5 (high) +- **Priority score:** `impact - risk - (effort * 0.5)` (higher first) + +## Candidates + +| Rank | Candidate | Impact | Risk | Effort | Priority | Why | +|---|---|---:|---:|---:|---:|---| +| 1 | Consolidate frontend runtime-state mapping into shared utility (`frontend/src/pages/Monitoring.tsx`, `ResultsDashboard.tsx`, `ContainerExecution.tsx`, `History.tsx`) | 5 | 2 | 3 | 1.5 | Reduces drift in state labels (`failed`, `stopped_by_user`, `cleanup_completed`) and UI truthfulness bugs. | +| 2 | Split project upload orchestration from router into service-level use case (`backend/app/api/routers/project.py`) | 5 | 3 | 3 | 0.5 | Router currently owns scan + queue + payload defaults; extraction improves testability and policy control. | +| 3 | Centralize deploy status derivation contract (`backend/app/api/routers/compose.py`, `backend/app/workers/tasks.py`) | 4 | 2 | 3 | 0.5 | Keeps runtime-state semantics consistent between API response and worker state writes. | +| 4 | Introduce typed project-flow metadata object in backend schemas (`backend/app/application/schemas.py`, project/compose routers) | 4 | 2 | 4 | 0.0 | Replaces ad-hoc dict metadata usage and lowers merge/regression risk. | +| 5 | Harden TODO/FIXME debt in frontend auth and API keys pages (`frontend/src/pages/Login.tsx`, `Register.tsx`, `ApiKeys.tsx`) | 3 | 1 | 2 | 1.0 | Small cleanup wins that reduce recurring warnings and behavior ambiguity. | +| 6 | Normalize scanner/analysis decision boundaries (`backend/app/application/services/project_scanner.py`, `analysis_service.py`, `workers/tasks.py`) | 4 | 3 | 4 | -1.0 | Clarifies scan-time metadata vs analysis-time decisions for long-term maintainability. | + +## Batch plan + +### Batch A (low risk, high consistency) + +- Candidate 1 +- Candidate 5 +- Validation: `cd frontend && npm run lint && npm run build` + +### Batch B (backend contract cleanup) + +- Candidate 2 +- Candidate 3 +- Validation: `cd backend && pytest -q` + +### Batch C (typed metadata and deeper flow cleanup) + +- Candidate 4 +- Candidate 6 +- Validation: `cd backend && pytest -q` plus frontend build if shared API types change + +## No-regression requirement + +Each batch is only complete when relevant strict gates pass and runtime behavior remains unchanged unless explicitly requested. diff --git a/context/transcript-grouping-commands.sh b/context/transcript-grouping-commands.sh new file mode 100644 index 0000000..7a1bd3d --- /dev/null +++ b/context/transcript-grouping-commands.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Groups parent transcript folders into keep/archive/delete-candidates buckets. +# No transcript content is deleted. +# Usage: +# bash transcript-grouping-commands.sh [--dry-run] [transcripts_root] [lists_root] +# bash transcript-grouping-commands.sh --apply [transcripts_root] [lists_root] + +MODE="dry-run" +if [[ "${1:-}" == "--dry-run" ]]; then + MODE="dry-run" + shift +elif [[ "${1:-}" == "--apply" ]]; then + MODE="apply" + shift +fi + +TRANSCRIPTS_ROOT="${1:-/home/cppenloglou/.cursor/projects/home-cppenloglou-Documents-Docker-Quality-Analyzer/agent-transcripts}" +LISTS_ROOT="${2:-/home/cppenloglou/Documents/Docker-Quality-Analyzer/context}" + +mkdir -p "$TRANSCRIPTS_ROOT/keep" "$TRANSCRIPTS_ROOT/archive" "$TRANSCRIPTS_ROOT/delete-candidates" + +move_group() { + local list_file="$1" + local target_dir="$2" + + while IFS= read -r id; do + [[ -z "$id" ]] && continue + [[ "$id" == \#* ]] && continue + if [[ -d "$TRANSCRIPTS_ROOT/$id" ]]; then + if [[ "$MODE" == "apply" ]]; then + mv "$TRANSCRIPTS_ROOT/$id" "$TRANSCRIPTS_ROOT/$target_dir/" + echo "moved $id -> $target_dir/" + else + echo "would move $id -> $target_dir/" + fi + else + echo "skip missing: $id" + fi + done < "$list_file" +} + +move_group "$LISTS_ROOT/transcript-groups-keep.txt" "keep" +move_group "$LISTS_ROOT/transcript-groups-archive.txt" "archive" +move_group "$LISTS_ROOT/transcript-groups-delete-candidates.txt" "delete-candidates" + +if [[ "$MODE" == "apply" ]]; then + echo "done: grouped transcript folders under $TRANSCRIPTS_ROOT" +else + echo "done: dry-run preview only (no folders moved)" +fi diff --git a/context/transcript-groups-archive.txt b/context/transcript-groups-archive.txt new file mode 100644 index 0000000..faf358b --- /dev/null +++ b/context/transcript-groups-archive.txt @@ -0,0 +1,14 @@ +# Parent transcript folders to archive (useful history) +# Source root: +# /home/cppenloglou/.cursor/projects/home-cppenloglou-Documents-Docker-Quality-Analyzer/agent-transcripts + +1735f5cb-1b99-4dad-b39a-fe37e9e44cc9 +4cf45642-35e7-4353-bf0d-2ad32ea139c8 +5d00f878-9fe7-405a-8976-e1c36842eca7 +67e1a024-45d8-425a-a3c1-b2a1caaec0ee +7b29f305-2459-4bb3-abe6-05179cdc31f0 +86a5d084-53f2-4e57-93ed-ee7d9cb928eb +a801b002-7a0d-48aa-b421-b6c00f15a8e1 +eb0c5756-3c15-4b75-a77e-c5d499afea38 +f404051e-5b9e-48f5-bd07-6a5db8b08bcd +f929403c-74cc-44fd-a3c2-26014427d69d diff --git a/context/transcript-groups-delete-candidates.txt b/context/transcript-groups-delete-candidates.txt new file mode 100644 index 0000000..6ea4e72 --- /dev/null +++ b/context/transcript-groups-delete-candidates.txt @@ -0,0 +1,8 @@ +# Parent transcript folders to delete (candidate list; review first) +# Source root: +# /home/cppenloglou/.cursor/projects/home-cppenloglou-Documents-Docker-Quality-Analyzer/agent-transcripts + +33e492ca-e6e3-46a8-a391-40e17ed2976c +9deaf9e9-7b8c-4770-ab01-cb50e7d8def3 +a6ccf90a-8da1-44cb-a5e0-8f44ba187a4f +e421981f-afd8-4619-8d58-54a62a5a14cb diff --git a/context/transcript-groups-keep.txt b/context/transcript-groups-keep.txt new file mode 100644 index 0000000..7135170 --- /dev/null +++ b/context/transcript-groups-keep.txt @@ -0,0 +1,14 @@ +# Parent transcript folders to keep (active reference) +# Source root: +# /home/cppenloglou/.cursor/projects/home-cppenloglou-Documents-Docker-Quality-Analyzer/agent-transcripts + +c9bcaf0d-eb3f-4815-8c70-47be9a8768b4 +3a6344b2-564c-49c4-aecb-5723cb938eaa +b6513236-c6ac-492b-ba2b-e56811a73c8e +199ee96f-ca71-439f-a20f-2ec2afc490fd +847810c0-e6e4-4e79-a961-a6545816b9df +5a592014-6fed-4912-bbcd-98e82deca923 +a390d7f6-3e64-4d86-a026-cbe617ffece5 +780280d1-96c2-4557-9ec2-92017b540e86 +3229f8ea-37e6-4266-a65a-c1b35282ec21 +2915bdbd-8745-467f-90ee-dda453e39737 diff --git a/context/ui-and-jobs.md b/context/ui-and-jobs.md new file mode 100644 index 0000000..bb4b15e --- /dev/null +++ b/context/ui-and-jobs.md @@ -0,0 +1,40 @@ +# UI and job lifecycle notes + +Last updated: 2026-05-17 + +This file tracks product-truth behavior for UI, job lifecycle, and deploy/runtime handling. + +## 2026-05-14 — History, delete, project progress truthfulness + +**History dashboard** + +- Fourth stat card is **"Container jobs"**: counts `job.type` **compose** plus **project** (not compose-only). +- Each row has **Delete** (trash): `window.confirm`, `stopPropagation` so the row click still opens results/analysis. On success, list state and `jobListRef` update; `sessionStorage` keys `analysisJobId` / `projectJobId` cleared if they matched the deleted id. + +**Delete analysis API** + +- `DELETE /api/v1/users/me/jobs/{job_id}` → **204** when allowed. +- **404** if job missing or not owned. +- **409** if status is **queued** or **running** (avoid races with the worker). +- **409** for **compose** / **project** when deploy is **active** (same semantics as `compute_deploy_status` in [`backend/app/api/routers/compose.py`](backend/app/api/routers/compose.py)) — user must stop containers first. +- After successful DB delete: Redis keys `deploy:{user_id}:{job_id}` and `deploy-stop:{user_id}:{job_id}` are deleted (no ghost deploy UI). +- Client: [`frontend/src/utils/api.ts`](frontend/src/utils/api.ts) `jobs.delete(jobId)`. + +**Project upload behavior** + +- Upload flow currently queues project analysis for all detected Docker/Compose assets and sets image builds enabled in enqueue payload. +- Compose runtime remains manual from results/deploy actions (`run_stack`), not auto-run on upload completion. + +**Job state truthfulness** + +- Workflow should preserve explicit scan-to-analysis state transitions where available (including `scanned` semantics in state-machine-aware flows). +- UI status labels should never imply execution when runtime is terminal (`exited`, `failed`, `stopped_by_user`, `cleanup_completed`). + +**Analysis progress (project jobs)** + +- Core steps only by default; **Building Images** and **Running Stack** rows are **inserted when** matching domain events arrive (`project.image_build_*`, `container.started` / `container.exited` / `project.runtime_stopped`). Reconcile uses core steps only (no event replay from `GET .../jobs/{id}/events`). + +**Research analytics privacy boundary** + +- Research data can be available to authenticated users while remaining privacy-safe via anonymized submitter and sanitized public metadata/results. +- Never surface raw source previews, account identifiers, secrets, internal host hints, or other fingerprinting material in research views. diff --git a/docker-compose.yaml b/docker-compose.yaml deleted file mode 100644 index d6ec764..0000000 --- a/docker-compose.yaml +++ /dev/null @@ -1,119 +0,0 @@ -services: - frontend: - build: - context: ./frontend - dockerfile: Dockerfile - args: - # Empty => the SPA calls the same origin. nginx in this container - # proxies /api, /auth, /health, /metrics, /ws to the api service. - VITE_API_BASE_URL: "" - container_name: docker-platform-frontend - ports: - - "3000:80" - depends_on: - api: - condition: service_healthy - restart: unless-stopped - - api: - build: - context: ./backend - dockerfile: Dockerfile - container_name: docker-platform-api - ports: - - "8000:8000" - environment: - - APP_ENV=prod - - DATABASE_URL=postgresql+asyncpg://postgres:postgres@postgres:5432/docker_platform - - REDIS_URL=redis://redis:6379/0 - - JWT_SECRET_KEY=change-me-in-production - depends_on: - redis: - condition: service_healthy - postgres: - condition: service_healthy - volumes: - - uploads_data:/app/storage/uploads - healthcheck: - test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8000/health || exit 1"] - interval: 10s - timeout: 3s - retries: 10 - start_period: 15s - restart: unless-stopped - - worker: - build: - context: ./backend - dockerfile: Dockerfile - container_name: docker-platform-worker - command: ["arq", "app.workers.worker.WorkerSettings"] - environment: - - APP_ENV=prod - - DATABASE_URL=postgresql+asyncpg://postgres:postgres@postgres:5432/docker_platform - - REDIS_URL=redis://redis:6379/0 - - JWT_SECRET_KEY=change-me-in-production - - DOCKER_HOST=tcp://dind:2375 - - DOCKER_TLS_VERIFY= - - DOCKER_CERT_PATH= - depends_on: - dind: - condition: service_healthy - redis: - condition: service_healthy - postgres: - condition: service_healthy - volumes: - - uploads_data:/app/storage/uploads - restart: unless-stopped - - dind: - image: docker:27-dind - container_name: docker-platform-dind - privileged: true - environment: - - DOCKER_TLS_CERTDIR= - command: ["--host=tcp://0.0.0.0:2375", "--storage-driver=overlay2"] - volumes: - - dind_data:/var/lib/docker - healthcheck: - test: ["CMD-SHELL", "docker info >/dev/null 2>&1 || exit 1"] - interval: 10s - timeout: 5s - retries: 20 - restart: unless-stopped - - redis: - image: redis:7-alpine - container_name: docker-platform-redis - ports: - - "6379:6379" - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 5s - timeout: 3s - retries: 10 - restart: unless-stopped - - postgres: - image: postgres:16-alpine - container_name: docker-platform-postgres - environment: - - POSTGRES_USER=postgres - - POSTGRES_PASSWORD=postgres - - POSTGRES_DB=docker_platform - ports: - - "5432:5432" - volumes: - - postgres_data:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres -d docker_platform"] - interval: 5s - timeout: 3s - retries: 10 - restart: unless-stopped - -volumes: - postgres_data: - uploads_data: - dind_data: diff --git a/docker/dind/Dockerfile b/docker/dind/Dockerfile new file mode 100644 index 0000000..76c517a --- /dev/null +++ b/docker/dind/Dockerfile @@ -0,0 +1,3 @@ +FROM docker:27-dind + +RUN apk add --no-cache wget curl bind-tools diff --git a/examples/README.md b/examples/README.md index 92a2f5f..bb9e867 100644 --- a/examples/README.md +++ b/examples/README.md @@ -24,6 +24,7 @@ examples/ `-- projects/ |-- node-hello-dockerfile.zip # project with a single Dockerfile |-- compose-stack.zip # project with a runnable compose stack +|-- compose-exits-after-start.zip # runnable compose that crashes after startup |-- python-webapp.zip # project with a blocked compose (build + bind) `-- src/ # un-zipped sources for reference ``` @@ -78,6 +79,9 @@ Try: `Detected files in archive` block. - `compose-stack.zip` -> both Dockerfile and compose are detected. Runnability passes, so **Run Containers** is enabled on the Results page. +- `compose-exits-after-start.zip` -> compose is runnable and deploy starts + successfully, then containers exit with code `1` after a short delay. Use this + to validate "started -> exited" runtime behavior and retry UX. - `python-webapp.zip` -> compose is flagged as not runnable (build context + bind mount). Project jobs are not hard-blocked from deploy, but the reasons are rendered clearly in the Results view. diff --git a/examples/projects/compose-exits-after-start.zip b/examples/projects/compose-exits-after-start.zip new file mode 100644 index 0000000..8214dee Binary files /dev/null and b/examples/projects/compose-exits-after-start.zip differ diff --git a/examples/projects/compose-exits-after-start/README.md b/examples/projects/compose-exits-after-start/README.md new file mode 100644 index 0000000..53016b8 --- /dev/null +++ b/examples/projects/compose-exits-after-start/README.md @@ -0,0 +1,25 @@ +# compose-exits-after-start + +Purpose: reproduce the lifecycle where containers start successfully and then exit with runtime errors. + +## Behavior + +- Both services start and stay up briefly. +- `app` exits with code `1` after ~15s. +- `worker` exits with code `1` after ~22s. +- This is useful to test UI/backend handling of: + - `container.started` followed by `container.exited` + - final runtime stop after all containers have exited + - distinction between user-stop and natural/self-exit + +## Files + +- `docker-compose.yml` - runnable compose stack with deterministic delayed failures. + +## Local dry run (optional) + +```bash +docker compose up +``` + +Expected output includes startup logs, then simulated fatal messages before both containers exit. diff --git a/examples/projects/compose-exits-after-start/docker-compose.yml b/examples/projects/compose-exits-after-start/docker-compose.yml new file mode 100644 index 0000000..bf32d9c --- /dev/null +++ b/examples/projects/compose-exits-after-start/docker-compose.yml @@ -0,0 +1,22 @@ +services: + app: + image: busybox:1.36.1 + command: + - sh + - -c + - | + echo "[app] started"; + sleep 15; + echo "[app] fatal: simulated crash after startup" >&2; + exit 1 + + worker: + image: busybox:1.36.1 + command: + - sh + - -c + - | + echo "[worker] started"; + sleep 22; + echo "[worker] fatal: simulated crash after startup" >&2; + exit 1 diff --git a/examples/projects/course-web-docker-main.zip b/examples/projects/course-web-docker-main.zip new file mode 100644 index 0000000..ef7c3ec Binary files /dev/null and b/examples/projects/course-web-docker-main.zip differ diff --git a/examples/projects/python-mini-task-app-docker-dind-fixed.zip b/examples/projects/python-mini-task-app-docker-dind-fixed.zip new file mode 100644 index 0000000..75b0b5f Binary files /dev/null and b/examples/projects/python-mini-task-app-docker-dind-fixed.zip differ diff --git a/examples/projects/python-mini-task-app.zip b/examples/projects/python-mini-task-app.zip deleted file mode 100644 index 9448364..0000000 Binary files a/examples/projects/python-mini-task-app.zip and /dev/null differ diff --git a/frontend/Dockerfile b/frontend/Dockerfile index a70cba5..a445046 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1.6 -FROM node:22-alpine AS builder +FROM node:22-alpine@sha256:968df39aedcea65eeb078fb336ed7191baf48f972b4479711397108be0966920 AS builder WORKDIR /app ARG VITE_API_BASE_URL="" @@ -13,7 +13,7 @@ RUN --mount=type=cache,target=/root/.npm \ COPY . . RUN npm run build -FROM nginx:1.27-alpine AS runtime +FROM nginx:1.27-alpine@sha256:65645c7bb6a0661892a8b03b89d0743208a18dd2f3f17a54ef4b76fb8e2f2a10 AS runtime RUN rm -rf /usr/share/nginx/html/* \ && rm /etc/nginx/conf.d/default.conf diff --git a/frontend/README.md b/frontend/README.md index c3c21da..74e57d9 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,75 +1,28 @@ -# React + TypeScript + Vite +# Docker Quality Analyzer Frontend -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +React 19 + TypeScript SPA for Dockerfile/Compose/project analysis, deploy controls, and runtime monitoring. -Currently, two official plugins are available: +## Key behavior -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) +- Project upload currently queues analysis for all detected Docker/Compose files. +- Project upload flow currently communicates automatic image builds. +- Compose runtime deploy remains explicit user action from results/deploy controls. +- Monitoring surfaces running and terminal states (for example exited/failed/stopped) without presenting stopped containers as live. -## React Compiler +## Local development -The React Compiler is enabled on this template. See [this documentation](https://react.dev/learn/react-compiler) for more information. - -Note: This will impact Vite dev & build performances. - -## Expanding the ESLint configuration - -If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: - -```js -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - - // Remove tseslint.configs.recommended and replace with this - tseslint.configs.recommendedTypeChecked, - // Alternatively, use this for stricter rules - tseslint.configs.strictTypeChecked, - // Optionally, add this for stylistic rules - tseslint.configs.stylisticTypeChecked, - - // Other configs... - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) +```bash +npm install +npm run dev ``` -You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: +Vite serves on `http://localhost:5173` by default. Set `VITE_API_BASE_URL` only when you need to target a non-default backend. -```js -// eslint.config.js -import reactX from 'eslint-plugin-react-x' -import reactDom from 'eslint-plugin-react-dom' +## Quality checks -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - // Enable lint rules for React - reactX.configs['recommended-typescript'], - // Enable lint rules for React DOM - reactDom.configs.recommended, - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) +```bash +npm run lint +npm run build ``` + +Run `npm run build` before merge to verify type-checking and bundling. diff --git a/frontend/src/components/DragDropUpload.tsx b/frontend/src/components/DragDropUpload.tsx index 556199b..bf2e5fb 100644 --- a/frontend/src/components/DragDropUpload.tsx +++ b/frontend/src/components/DragDropUpload.tsx @@ -7,13 +7,14 @@ import { dragActiveVariants, dragActiveTransition } from "./motion/variants"; interface DragDropUploadProps { onFileSelect: (file: File, hint?: DockerFileKind) => void; + onFilesSelect?: (files: File[], hint?: DockerFileKind) => void; } const DOCKERFILE_ACCEPT = ".dockerfile,Dockerfile,dockerfile,text/plain"; const COMPOSE_ACCEPT = ".yml,.yaml,application/x-yaml,text/yaml,text/plain"; const DROP_ACCEPT = `${DOCKERFILE_ACCEPT},${COMPOSE_ACCEPT}`; -export function DragDropUpload({ onFileSelect }: DragDropUploadProps) { +export function DragDropUpload({ onFileSelect, onFilesSelect }: DragDropUploadProps) { const [isDragging, setIsDragging] = useState(false); const reducedMotion = useReducedMotion(); const dockerfileInputRef = useRef(null); @@ -32,7 +33,13 @@ export function DragDropUpload({ onFileSelect }: DragDropUploadProps) { const handleDrop = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); - const file = e.dataTransfer.files?.[0]; + const dropped = Array.from(e.dataTransfer.files ?? []); + if (!dropped.length) return; + if (dropped.length > 1 && onFilesSelect) { + onFilesSelect(dropped); + return; + } + const file = dropped[0]; if (file) { onFileSelect(file); } @@ -40,9 +47,15 @@ export function DragDropUpload({ onFileSelect }: DragDropUploadProps) { const handleInputChange = (hint: DockerFileKind) => (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (file) { - onFileSelect(file, hint); + const selected = Array.from(e.target.files ?? []); + if (!selected.length) { + e.target.value = ""; + return; + } + if (selected.length > 1 && onFilesSelect) { + onFilesSelect(selected, hint); + } else { + onFileSelect(selected[0], hint); } e.target.value = ""; }; @@ -110,6 +123,7 @@ export function DragDropUpload({ onFileSelect }: DragDropUploadProps) { ; const estimate = meta.estimate as Record | undefined; - const runnability = meta.runnability as { runnable?: boolean; reasons?: string[] } | undefined; + const runnability = meta.runnability as { + runnable?: boolean; + reasons?: string[]; + rules?: Record; + } | undefined; const highlightedLines = [ ...errors.map(e => e.line), @@ -150,19 +154,49 @@ export function FileAnalysisDetail({ file, defaultOpen = false }: FileAnalysisDe {file.file_type === "compose" && (
{runnability && ( -
- - {runnability.runnable ? "Runnable" : "Not runnable"} - - {!runnability.runnable && runnability.reasons && ( -
    - {runnability.reasons.map((r, i) =>
  • • {r}
  • )} +
    +
    +
    +

    Deploy Runnability

    +

    Strict precheck rules decide whether this compose file can be deployed.

    +
    + + {runnability.runnable ? "Runnable" : "Blocked"} + +
    + {!runnability.runnable && ( +
      + {(runnability.reasons ?? ["No runnability metadata found."]).map((r, i) =>
    • {r}
    • )}
    )} + {runnability.rules && ( +
    + + Inspect runnability rules + +
    + {Object.entries(runnability.rules).map(([rule, passed]) => ( +
    + {rule} + {passed ? "pass" : "fail"} +
    + ))} +
    +
    + )}
    )}
diff --git a/frontend/src/pages/AnalysisProgress.tsx b/frontend/src/pages/AnalysisProgress.tsx index 8817707..eeb2d62 100644 --- a/frontend/src/pages/AnalysisProgress.tsx +++ b/frontend/src/pages/AnalysisProgress.tsx @@ -50,7 +50,7 @@ const BASE_STEPS: AnalysisStep[] = [ }, ]; -const PROJECT_STEPS: AnalysisStep[] = [ +const PROJECT_CORE_STEPS: AnalysisStep[] = [ { id: "queued", label: "Job Queued", @@ -75,18 +75,6 @@ const PROJECT_STEPS: AnalysisStep[] = [ description: "Aggregating per-file results and computing project score", status: "pending", }, - { - id: "project.image_build", - label: "Building Images", - description: "Building Docker images from selected Dockerfiles", - status: "pending", - }, - { - id: "project.compose_run", - label: "Running Stack", - description: "Starting compose stack for runtime analysis", - status: "pending", - }, { id: "finalize", label: "Complete", @@ -95,6 +83,71 @@ const PROJECT_STEPS: AnalysisStep[] = [ }, ]; +const PROJECT_IMAGE_BUILD_STEP_TEMPLATE: AnalysisStep = { + id: "project.image_build", + label: "Building Images", + description: "Building Docker images from selected Dockerfiles", + status: "pending", +}; + +const PROJECT_COMPOSE_RUN_STEP_TEMPLATE: AnalysisStep = { + id: "project.compose_run", + label: "Running Stack", + description: "Starting compose stack for runtime analysis", + status: "pending", +}; + +const PROJECT_IMAGE_BUILD_EVENTS = new Set([ + "project.image_build_started", + "project.image_build_log", + "project.image_build_completed", + "project.image_build_failed", +]); + +const PROJECT_RUNTIME_STEP_EVENTS = new Set([ + "container.started", + "container.exited", + "project.runtime_stopped", +]); + +function cloneSteps(steps: AnalysisStep[]): AnalysisStep[] { + return steps.map((s) => ({ ...s })); +} + +function ensureProjectCoreSteps(prev: AnalysisStep[]): AnalysisStep[] { + if (prev.some((s) => s.id === "project.analysis_started")) { + return cloneSteps(prev); + } + return PROJECT_CORE_STEPS.map((s) => ({ ...s })); +} + +/** Inserts a clone of template before `finalize` if that step id is not already present. */ +function insertOptionalStepBeforeFinalize( + steps: AnalysisStep[], + template: AnalysisStep, +): AnalysisStep[] { + if (steps.some((s) => s.id === template.id)) { + return steps; + } + const fi = steps.findIndex((s) => s.id === "finalize"); + if (fi === -1) { + return steps; + } + const row: AnalysisStep = { ...template, status: "pending" }; + return [...steps.slice(0, fi), row, ...steps.slice(fi)]; +} + +function augmentProjectStepsForEvent(prev: AnalysisStep[], eventName: string): AnalysisStep[] { + let next = ensureProjectCoreSteps(prev); + if (PROJECT_IMAGE_BUILD_EVENTS.has(eventName)) { + next = insertOptionalStepBeforeFinalize(next, PROJECT_IMAGE_BUILD_STEP_TEMPLATE); + } + if (PROJECT_RUNTIME_STEP_EVENTS.has(eventName)) { + next = insertOptionalStepBeforeFinalize(next, PROJECT_COMPOSE_RUN_STEP_TEMPLATE); + } + return next; +} + // Map project event names to step IDs const PROJECT_EVENT_TO_STEP: Record = { "project.analysis_started": "project.analysis_started", @@ -167,9 +220,11 @@ export function AnalysisProgress() { }; const handleCompleted = (latestJob: Job) => { - setStep("queued", "complete"); - setStep("running", "complete"); - setStep("finalize", "complete"); + setSteps((prev) => + prev.map((step) => + step.status === "complete" ? step : { ...step, status: "complete" as StepStatus }, + ), + ); setProgressValue(100); setJobStatus("done"); try { @@ -251,7 +306,7 @@ export function AnalysisProgress() { // Switch to project step list if needed if (reconciled.type === "project") { setIsProjectJob(true); - setSteps(PROJECT_STEPS.map(s => ({ ...s }))); + setSteps(PROJECT_CORE_STEPS.map((s) => ({ ...s }))); } if (reconciled.status === "done") { handleCompleted(reconciled); @@ -267,8 +322,12 @@ export function AnalysisProgress() { } if (reconciled.status === "running") { setStep("queued", "complete"); - setStep("running", "running"); - setProgressValue(50); + if (reconciled.type === "project") { + setProgressValue(50); + } else { + setStep("running", "running"); + setProgressValue(50); + } } } catch (error) { if (error instanceof ApiError && error.status !== 404) { @@ -330,52 +389,62 @@ export function AnalysisProgress() { // Update steps for project events if (parsed.event_name.startsWith("project.") || parsed.event_name === "container.started" || parsed.event_name === "container.exited" || parsed.event_name === "project.runtime_stopped") { setIsProjectJob(true); - setSteps(prev => { - if (!prev.some(s => s.id === "project.file_analysis")) { - return PROJECT_STEPS.map(s => ({ ...s })); - } - return prev; - }); - const targetStep = PROJECT_EVENT_TO_STEP[parsed.event_name]; - if (targetStep) { - if (parsed.event_name === "project.analysis_started") { - setStep("queued", "complete"); - setStep("project.analysis_started", "running"); - setJobStatus("running"); - setProgressValue(20); - } else if (parsed.event_name === "project.file_analysis_started") { - setStep("project.analysis_started", "complete"); - setStep("project.file_analysis", "running"); - setProgressValue(45); - } else if (parsed.event_name === "project.merge_started") { - setStep("project.file_analysis", "complete"); - setStep("project.merge_started", "running"); - setProgressValue(80); - } else if (parsed.event_name === "project.analysis_completed") { - setStep("project.merge_started", "complete"); - setStep("finalize", "running"); - setProgressValue(95); - } else if (parsed.event_name === "project.analysis_failed") { - const message = (parsed.payload as { message?: string })?.message ?? "Project analysis failed."; - handleFailed(message); - } else if (parsed.event_name === "project.image_build_started") { - setStep("project.merge_started", "complete"); - setStep("project.image_build", "running"); - setProgressValue(85); - } else if (parsed.event_name === "project.image_build_completed") { - // Keep running until all builds done — will advance on analysis_completed - } else if (parsed.event_name === "project.image_build_failed") { - // Mark as error but don't fail job - setStep("project.image_build", "error"); - } else if (parsed.event_name === "container.started") { - setStep("project.image_build", "complete"); - setStep("project.compose_run", "running"); - setProgressValue(90); - } else if (parsed.event_name === "container.exited") { - setStep("project.compose_run", "error"); - } else if (parsed.event_name === "project.runtime_stopped") { - setStep("project.compose_run", "complete"); - setProgressValue(95); + setSteps((prev) => augmentProjectStepsForEvent(prev, parsed.event_name)); + if (parsed.event_name === "project.analysis_failed") { + const message = (parsed.payload as { message?: string })?.message ?? "Project analysis failed."; + handleFailed(message); + } else { + const targetStep = PROJECT_EVENT_TO_STEP[parsed.event_name]; + if (targetStep) { + if (parsed.event_name === "project.analysis_started") { + setStep("queued", "complete"); + setStep("project.analysis_started", "running"); + setJobStatus("running"); + setProgressValue(20); + } else if (parsed.event_name === "project.file_analysis_started") { + setStep("project.analysis_started", "complete"); + setStep("project.file_analysis", "running"); + setProgressValue(45); + } else if (parsed.event_name === "project.merge_started") { + setStep("project.file_analysis", "complete"); + setStep("project.merge_started", "running"); + setProgressValue(80); + } else if (parsed.event_name === "project.analysis_completed") { + setStep("project.merge_started", "complete"); + setSteps((prev) => + prev.map((s) => + s.id === "project.image_build" && s.status !== "error" + ? { ...s, status: "complete" as StepStatus } + : s, + ), + ); + setStep("finalize", "running"); + setProgressValue(95); + } else if (parsed.event_name === "project.image_build_started") { + setStep("project.merge_started", "complete"); + setStep("project.image_build", "running"); + setProgressValue(85); + } else if (parsed.event_name === "project.image_build_log" || parsed.event_name === "project.image_build_completed") { + setStep("project.image_build", "running"); + } else if (parsed.event_name === "project.image_build_failed") { + // Mark as error but don't fail job + setStep("project.image_build", "error"); + } else if (parsed.event_name === "container.started") { + setSteps((prev) => + prev.map((s) => + s.id === "project.image_build" && s.status !== "error" + ? { ...s, status: "complete" as StepStatus } + : s, + ), + ); + setStep("project.compose_run", "running"); + setProgressValue(90); + } else if (parsed.event_name === "container.exited") { + setStep("project.compose_run", "error"); + } else if (parsed.event_name === "project.runtime_stopped") { + setStep("project.compose_run", "complete"); + setProgressValue(95); + } } } } else if (parsed.event_name === "user.analysis.started") { diff --git a/frontend/src/pages/BatchAnalysisProgress.tsx b/frontend/src/pages/BatchAnalysisProgress.tsx new file mode 100644 index 0000000..f501e97 --- /dev/null +++ b/frontend/src/pages/BatchAnalysisProgress.tsx @@ -0,0 +1,245 @@ +import { useEffect, useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { Loader2, RefreshCw } from "lucide-react"; + +import { Layout } from "../components/Layout"; +import { MotionPage } from "../components/motion"; +import { Card } from "../components/ui/card"; +import { Badge } from "../components/ui/badge"; +import { Button } from "../components/ui/button"; +import { ApiError, jobs as jobsApi, type Job } from "../utils/api"; + +interface BatchStoredItem { + filename: string; + job_id: string; + status: string; +} + +interface BatchStoredState { + kind: "dockerfile" | "docker-compose"; + submitted_at: string; + items: BatchStoredItem[]; +} + +interface BatchProgressRow { + filename: string; + job_id: string; + status: Job["status"] | "queued" | "running" | "done" | "failed"; + score: number | null; + grade: string | null; + error: string | null; +} + +function statusBadge(status: BatchProgressRow["status"]): string { + switch (status) { + case "done": + return "bg-emerald-500/15 text-emerald-300 border-emerald-500/30"; + case "failed": + return "bg-red-500/15 text-red-300 border-red-500/30"; + case "running": + return "bg-sky-500/15 text-sky-300 border-sky-500/30"; + default: + return "bg-slate-700/40 text-slate-300 border-slate-600"; + } +} + +function isTerminal(status: BatchProgressRow["status"]): boolean { + return status === "done" || status === "failed"; +} + +export function BatchAnalysisProgress() { + const navigate = useNavigate(); + const [rows, setRows] = useState([]); + const [kind, setKind] = useState(null); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + + const refreshRows = async (baseRows: BatchProgressRow[]) => { + const nextRows = await Promise.all( + baseRows.map(async (row) => { + try { + const job = await jobsApi.get(row.job_id); + const result = + job.result && typeof job.result === "object" + ? (job.result as Record) + : null; + const message = + result && typeof result.message === "string" ? result.message : null; + const scoreValue = result && typeof result.score === "number" ? result.score : null; + const gradeValue = result && typeof result.grade === "string" ? result.grade : null; + return { + ...row, + status: job.status, + score: scoreValue, + grade: gradeValue, + error: message, + }; + } catch (err) { + const message = + err instanceof ApiError + ? err.message + : err instanceof Error + ? err.message + : "Unable to fetch latest job status."; + return { + ...row, + error: message, + }; + } + }), + ); + setRows(nextRows); + return nextRows; + }; + + useEffect(() => { + const raw = sessionStorage.getItem("batchAnalysis"); + if (!raw) { + navigate("/"); + return; + } + let parsed: BatchStoredState; + try { + parsed = JSON.parse(raw) as BatchStoredState; + } catch { + navigate("/"); + return; + } + if (!Array.isArray(parsed.items) || parsed.items.length === 0) { + navigate("/"); + return; + } + const initialRows: BatchProgressRow[] = parsed.items.map((item) => ({ + filename: item.filename, + job_id: item.job_id, + status: + item.status === "running" || item.status === "done" || item.status === "failed" + ? item.status + : "queued", + score: null, + grade: null, + error: null, + })); + setKind(parsed.kind); + setRows(initialRows); + setLoading(false); + void refreshRows(initialRows); + }, [navigate]); + + useEffect(() => { + if (!rows.length) return; + if (rows.every((row) => isTerminal(row.status))) return; + const timer = window.setInterval(() => { + setRefreshing(true); + void refreshRows(rows).finally(() => setRefreshing(false)); + }, 3000); + return () => window.clearInterval(timer); + }, [rows]); + + const stats = useMemo(() => { + const done = rows.filter((row) => row.status === "done").length; + const failed = rows.filter((row) => row.status === "failed").length; + const running = rows.length - done - failed; + return { done, failed, running, total: rows.length }; + }, [rows]); + + const handleRefresh = async () => { + setRefreshing(true); + try { + await refreshRows(rows); + } finally { + setRefreshing(false); + } + }; + + if (loading) { + return ( + +
+ + Loading batch analysis... + +
+
+ ); + } + + return ( + + +
+ +
+
+

+ Batch analysis +

+

+ Tracking {stats.total} {kind === "docker-compose" ? "Compose" : "Dockerfile"} files +

+

+ Done: {stats.done} | Failed: {stats.failed} | In progress: {stats.running} +

+
+
+ + +
+
+
+ +
+ {rows.map((row) => ( + +
+
+

{row.filename}

+

{row.job_id}

+ {row.error ? ( +

{row.error}

+ ) : null} +
+
+ {row.status} + {row.score != null ? ( + + Score {row.score} + {row.grade ? ` (${row.grade})` : ""} + + ) : null} + +
+
+
+ ))} +
+
+
+
+ ); +} diff --git a/frontend/src/pages/ContainerExecution.tsx b/frontend/src/pages/ContainerExecution.tsx index 7a774b2..91c9546 100644 --- a/frontend/src/pages/ContainerExecution.tsx +++ b/frontend/src/pages/ContainerExecution.tsx @@ -64,6 +64,24 @@ interface ExecutionPersistedState { deployPhase?: DeployPhase; } +interface ExitedContainerSummary { + container_id?: string; + container_name?: string; + exit_code?: number | null; + error?: string | null; + started_at?: string | null; + finished_at?: string | null; + restart_count?: number | null; + oom_killed?: boolean | null; + last_logs?: string[] | null; + stop_requested_by_user?: boolean; +} + +interface ProblematicAlertState { + open: boolean; + message: string; +} + const DEPLOY_TIMEOUT_MS = 120_000; function ensureAnalysis(job: Job | null): AnalysisResult | null { @@ -96,6 +114,10 @@ export function ContainerExecution() { const ready = useMinLoader(!loading); const [stopping, setStopping] = useState(false); + const [problematicAlert, setProblematicAlert] = useState({ + open: false, + message: "", + }); const [stackRunning, setStackRunning] = useState(persisted?.stackRunning ?? false); const [deployPhase, setDeployPhase] = useState( persisted?.deployPhase ?? (persisted?.stackRunning ? "running" : "idle"), @@ -113,8 +135,17 @@ export function ContainerExecution() { ); const [logs, setLogs] = useState(persisted?.logs ?? []); const [composeProgress, setComposeProgress] = useState(null); + const [exitedContainers, setExitedContainers] = useState([]); + const [metricsSeenContainerIds, setMetricsSeenContainerIds] = useState([]); const socketRef = useRef(null); const deployTimeoutRef = useRef | null>(null); + const unmountedRef = useRef(false); + const stoppingRef = useRef(stopping); + const cleanupAttemptedRef = useRef(false); + + useEffect(() => { + stoppingRef.current = stopping; + }, [stopping]); const clearDeployTimeout = () => { if (deployTimeoutRef.current !== null) { @@ -126,6 +157,35 @@ export function ContainerExecution() { const analysis = useMemo(() => ensureAnalysis(job), [job]); const runnability: RunnabilityMeta | undefined = analysis?.meta?.runnability; const runnable = runnability?.runnable === true; + const deployComposeFile = useMemo(() => { + if (!job) return null; + const metadata = job.input_metadata ?? {}; + const explicitPrimary = metadata.primary_compose_file; + if (typeof explicitPrimary === "string" && explicitPrimary.trim()) { + return explicitPrimary; + } + const selectedCompose = metadata.selected_compose_files; + if ( + Array.isArray(selectedCompose) && + selectedCompose.length > 0 && + typeof selectedCompose[0] === "string" + ) { + return selectedCompose[0]; + } + const composeFiles = metadata.compose_files; + if ( + Array.isArray(composeFiles) && + composeFiles.length > 0 && + typeof composeFiles[0] === "string" + ) { + return composeFiles[0]; + } + const filename = metadata.filename; + return typeof filename === "string" && filename.trim() ? filename : null; + }, [job]); + const resubmitRequired = + !!analysisJobId && + sessionStorage.getItem(`dqa:resubmitRequired:${analysisJobId}`) === "1"; const pushLog = (entry: TerminalLogEntry) => setLogs((prev) => [...prev, entry]); @@ -150,6 +210,251 @@ export function ContainerExecution() { } }; + const resetExecutionViewState = () => { + setTimeline(BASE_TIMELINE.map((entry) => ({ ...entry }))); + setComposeProgress(null); + setExitedContainers([]); + setMetricsSeenContainerIds([]); + }; + + const setResubmitRequired = () => { + if (!analysisJobId) return; + sessionStorage.setItem(`dqa:resubmitRequired:${analysisJobId}`, "1"); + }; + + const markProblematicStack = ( + message: string, + options?: { cleanupRuntime?: boolean; keepExitData?: boolean }, + ) => { + setResubmitRequired(); + setProblematicAlert({ open: true, message }); + setStackRunning(false); + setStopping(false); + setDeployPhase("failed"); + clearDeployTimeout(); + sessionStorage.removeItem("dqa:containerStatus"); + if (!options?.keepExitData) { + resetExecutionViewState(); + } + if (stateKey) clearState(stateKey); + + if ( + options?.cleanupRuntime && + !cleanupAttemptedRef.current && + job && + !unmountedRef.current + ) { + cleanupAttemptedRef.current = true; + void composeApi + .stopDeploy({ job_id: job.id, remove_volumes: true }) + .then(() => { + pushLog({ + message: "Cleanup requested for problematic stack (volumes included).", + tone: "warning", + }); + }) + .catch((err: unknown) => { + const msg = err instanceof ApiError ? err.message : "Cleanup request failed"; + pushLog({ message: msg, tone: "error" }); + }); + } + }; + + const handleDomainEvent = (parsed: DomainEvent) => { + if ( + parsed.event_name !== "deploy.compose_up_log" && + parsed.event_name !== "deploy.compose_up_progress" + ) { + pushLog({ + message: `${parsed.event_name} ${ + parsed.payload ? JSON.stringify(parsed.payload) : "" + }`, + timestamp: parsed.timestamp, + tone: + parsed.event_name === "user.analysis.failed" + ? "error" + : parsed.event_name === "container.metrics" + || parsed.event_name === "deploy.cleanup_started" + ? "info" + : "success", + }); + } + if (parsed.event_name === "deploy.compose_up_log") { + const line = String((parsed.payload as { line?: string })?.line ?? ""); + if (line) { + pushLog({ message: line, timestamp: parsed.timestamp, tone: "info" }); + } + return; + } else if (parsed.event_name === "deploy.compose_up_progress") { + const p = parsed.payload as + | { total_services?: number; created?: number; started?: number } + | undefined; + const total = Number(p?.total_services ?? 0); + const created = Number(p?.created ?? 0); + const started = Number(p?.started ?? 0); + if (total > 0) { + setComposeProgress({ total, created, started }); + } + return; + } + + if (parsed.event_name === "container.started") { + const ids = (parsed.payload as { container_ids?: string[] })?.container_ids ?? []; + const primaryId = String( + (parsed.payload as { container_id?: string })?.container_id ?? "", + ); + setContainerIds(ids.length > 0 ? ids : primaryId ? [primaryId] : []); + setStackRunning(true); + setStopping(false); + setComposeProgress(null); + setDeployPhase("running"); + clearDeployTimeout(); + setTimelineStatus( + "start", + "done", + ids.length > 0 ? `${ids.length} container(s) started` : "Container started", + ); + pushNotification("success", "Containers Running", `${ids.length || 1} container(s) are now running`); + } else if (parsed.event_name === "container.metrics") { + const metricContainerId = String( + (parsed.payload as { container_id?: string })?.container_id ?? "", + ); + if (metricContainerId) { + setMetricsSeenContainerIds((prev) => + prev.includes(metricContainerId) ? prev : [...prev, metricContainerId], + ); + } + // Mark as done to avoid a perpetual "loading" spinner while runtime is healthy. + setTimelineStatus("metrics", "done", "Metrics streaming"); + } else if (parsed.event_name === "container.exited") { + const p = parsed.payload as ExitedContainerSummary; + const stopRequestedByUser = p.stop_requested_by_user === true || stoppingRef.current; + if (stopRequestedByUser) { + const name = p.container_name ?? p.container_id?.slice(0, 12) ?? "container"; + pushLog({ + message: `Container stopped during user-requested shutdown: ${name}`, + timestamp: parsed.timestamp, + tone: "info", + }); + return; + } + const name = p.container_name ?? p.container_id?.slice(0, 12) ?? "container"; + const exitMsg = `Container exited: ${name} (code ${p.exit_code ?? "??"})${p.error ? ` — ${p.error}` : ""}`; + setTimelineStatus("start", "error", exitMsg); + pushLog({ message: exitMsg, timestamp: parsed.timestamp, tone: "error" }); + setExitedContainers((prev) => { + const key = p.container_id ?? `${p.container_name ?? "container"}-${p.finished_at ?? ""}`; + const next = prev.filter((item) => { + const itemKey = item.container_id ?? `${item.container_name ?? "container"}-${item.finished_at ?? ""}`; + return itemKey !== key; + }); + return [...next, p]; + }); + markProblematicStack( + "Current stack is problematic. Please fix the stack and resubmit before deploying again.", + { cleanupRuntime: true, keepExitData: true }, + ); + } else if (parsed.event_name === "project.runtime_stopped") { + if (stoppingRef.current) { + setTimelineStatus("metrics", "done", "Stop completed"); + setStackRunning(false); + setStopping(false); + setDeployPhase("idle"); + clearDeployTimeout(); + resetExecutionViewState(); + return; + } + setTimelineStatus("metrics", "error", "All containers exited unexpectedly"); + pushLog({ message: "All containers have exited.", timestamp: parsed.timestamp, tone: "error" }); + pushNotification("warning", "Runtime Stopped", "All containers have exited"); + markProblematicStack( + "Current stack is problematic. Please fix the stack and resubmit before deploying again.", + { cleanupRuntime: true, keepExitData: true }, + ); + } else if (parsed.event_name === "container.stopped") { + setTimelineStatus("metrics", "done", "Stack stopped"); + setStackRunning(false); + setStopping(false); + setDeployPhase("idle"); + clearDeployTimeout(); + setContainerIds([]); + setDeployJobId(null); + sessionStorage.removeItem("dqa:containerStatus"); + if (stateKey) clearState(stateKey); + toast.success("Compose stack stopped"); + pushNotification("warning", "Containers Stopped", "All containers have been stopped"); + } else if (parsed.event_name === "deploy.cleanup_started") { + const projectName = String( + (parsed.payload as { project_name?: string })?.project_name ?? "compose stack", + ); + setStackRunning(false); + setContainerIds([]); + pushLog({ + message: `Cleanup started for ${projectName}`, + timestamp: parsed.timestamp, + tone: "info", + }); + toast.message("Stopping and removing stack resources..."); + pushNotification("info", "Cleanup Started", "Removing containers created by the failed deploy"); + } else if (parsed.event_name === "deploy.cleanup_completed") { + const projectName = String( + (parsed.payload as { project_name?: string })?.project_name ?? "compose stack", + ); + setStackRunning(false); + setStopping(false); + setContainerIds([]); + setDeployJobId(null); + sessionStorage.removeItem("dqa:containerStatus"); + if (stateKey) clearState(stateKey); + pushLog({ + message: `Cleanup completed for ${projectName}`, + timestamp: parsed.timestamp, + tone: "success", + }); + toast.success("Failed deploy containers stopped and removed"); + pushNotification("success", "Cleanup Completed", "Failed deploy containers were removed from the sandbox"); + } else if (parsed.event_name === "user.analysis.failed") { + setTimelineStatus( + "start", + "error", + String( + (parsed.payload as { message?: string })?.message ?? "failed", + ), + ); + setStackRunning(false); + setStopping(false); + setDeployPhase("failed"); + clearDeployTimeout(); + toast.error("Deploy workflow reported a failure"); + } + }; + + const connectJobSocket = (streamJobId: string, announce = false) => { + closeSocket(); + const socket = ws.connectJob(streamJobId); + socketRef.current = socket; + socket.onopen = () => { + if (announce) { + pushLog({ + message: `Streaming events for job ${streamJobId}`, + tone: "info", + }); + } + }; + socket.onmessage = (event) => { + try { + const parsed = JSON.parse(event.data as string) as DomainEvent; + handleDomainEvent(parsed); + } catch { + pushLog({ message: String(event.data), tone: "info" }); + } + }; + socket.onerror = () => + pushLog({ message: "Event stream error", tone: "error" }); + socket.onclose = () => + pushLog({ message: "Event stream closed", tone: "warning" }); + }; + useEffect(() => { if (!analysisJobId) { setLoadError("No analysis job available. Upload a compose file first."); @@ -170,16 +475,90 @@ export function ContainerExecution() { } const status = await composeApi.deployStatus(analysisJobId); + const rs = status.runtime_state ?? "none"; + const problematicByRuntimeState = + rs === "failed" || + rs === "cleanup_completed" || + rs === "exited" || + rs === "partial" || + rs === "unhealthy"; + const resubmitRequired = + sessionStorage.getItem(`dqa:resubmitRequired:${analysisJobId}`) === "1"; if (status.active) { + if (rs === "partial" || rs === "unhealthy") { + markProblematicStack( + "Current stack is problematic. Please fix the stack and resubmit before deploying again.", + { cleanupRuntime: true, keepExitData: true }, + ); + return; + } setStackRunning(true); - setDeployPhase("running"); + if (rs === "stopping") { + setStopping(true); + setDeployPhase("idle"); + } else { + setStopping(false); + setDeployPhase("running"); + connectJobSocket(analysisJobId); + } setContainerIds(status.container_ids); if (!deployJobId) setDeployJobId(analysisJobId); + } else if (problematicByRuntimeState || resubmitRequired) { + if (!resubmitRequired && (status.stopped_by_user || status.stop_reason === "user_requested")) { + setStackRunning(false); + setStopping(false); + setDeployPhase("idle"); + setContainerIds([]); + setDeployJobId(null); + resetExecutionViewState(); + if (stateKey) clearState(stateKey); + sessionStorage.removeItem("dqa:containerStatus"); + return; + } + setStackRunning(false); + setStopping(false); + setDeployPhase("exited"); + setContainerIds(status.container_ids); + // Populate exited container summaries from persisted container info + if (status.containers && status.containers.length > 0) { + setExitedContainers( + status.containers + .filter((c) => c.status === "exited" || c.exit_code != null) + .map((c) => ({ + container_id: c.id, + container_name: c.name ?? undefined, + exit_code: c.exit_code ?? undefined, + error: c.error ?? undefined, + started_at: c.started_at ?? undefined, + finished_at: c.finished_at ?? undefined, + restart_count: c.restart_count ?? undefined, + oom_killed: c.oom_killed ?? undefined, + last_logs: c.last_logs ?? undefined, + })), + ); + } + markProblematicStack( + "Current stack is problematic. Please fix the stack and resubmit before deploying again.", + { cleanupRuntime: true, keepExitData: true }, + ); + } else if (rs === "stopped_by_user") { + setStackRunning(false); + setStopping(false); + setDeployPhase("idle"); + setContainerIds([]); + setDeployJobId(null); + resetExecutionViewState(); + if (stateKey) clearState(stateKey); + sessionStorage.removeItem("dqa:containerStatus"); } else { setStackRunning(false); + setStopping(false); setDeployPhase((prev) => (prev === "running" ? "idle" : prev)); setContainerIds([]); setDeployJobId(null); + resetExecutionViewState(); + if (stateKey) clearState(stateKey); + sessionStorage.removeItem("dqa:containerStatus"); } } catch (err) { if (cancelled) return; @@ -197,10 +576,14 @@ export function ContainerExecution() { return () => { cancelled = true; }; + // This effect intentionally rehydrates only when the target analysis job changes. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [analysisJobId]); useEffect(() => { + unmountedRef.current = false; return () => { + unmountedRef.current = true; closeSocket(); clearDeployTimeout(); }; @@ -220,13 +603,20 @@ export function ContainerExecution() { const handleDeploy = async () => { if (!job) return; + if (resubmitRequired) { + toast.error("This stack is marked problematic. Please fix and resubmit."); + return; + } if (!runnable && job.type === "compose") { toast.error("Compose stack is not runnable from a standalone file."); return; } setDeployPhase("deploying"); + setStopping(false); clearDeployTimeout(); setComposeProgress(null); + setExitedContainers([]); + setMetricsSeenContainerIds([]); setTimeline(BASE_TIMELINE.map((entry) => ({ ...entry }))); setTimelineStatus("enqueue", "running", "Submitting deploy request..."); try { @@ -247,143 +637,7 @@ export function ContainerExecution() { toast.success("Deploy request accepted"); pushNotification("info", "Deploy Started", `Compose stack deploy queued for job ${job.id.slice(0, 8)}`); - const socket = ws.connectJob(job.id); - socketRef.current = socket; - socket.onopen = () => { - pushLog({ - message: `Streaming events for job ${job.id}`, - tone: "info", - }); - }; - socket.onmessage = (event) => { - try { - const parsed = JSON.parse(event.data as string) as DomainEvent; - if ( - parsed.event_name !== "deploy.compose_up_log" && - parsed.event_name !== "deploy.compose_up_progress" - ) { - pushLog({ - message: `${parsed.event_name} ${ - parsed.payload ? JSON.stringify(parsed.payload) : "" - }`, - timestamp: parsed.timestamp, - tone: - parsed.event_name === "user.analysis.failed" - ? "error" - : parsed.event_name === "container.metrics" - || parsed.event_name === "deploy.cleanup_started" - ? "info" - : "success", - }); - } - if (parsed.event_name === "deploy.compose_up_log") { - const line = String((parsed.payload as { line?: string })?.line ?? ""); - if (line) { - pushLog({ message: line, timestamp: parsed.timestamp, tone: "info" }); - } - return; - } else if (parsed.event_name === "deploy.compose_up_progress") { - const p = parsed.payload as - | { total_services?: number; created?: number; started?: number } - | undefined; - const total = Number(p?.total_services ?? 0); - const created = Number(p?.created ?? 0); - const started = Number(p?.started ?? 0); - if (total > 0) { - setComposeProgress({ total, created, started }); - } - return; - } - - if (parsed.event_name === "container.started") { - const ids = (parsed.payload as { container_ids?: string[] })?.container_ids ?? []; - const primaryId = String( - (parsed.payload as { container_id?: string })?.container_id ?? "", - ); - setContainerIds(ids.length > 0 ? ids : primaryId ? [primaryId] : []); - setStackRunning(true); - setDeployPhase("running"); - clearDeployTimeout(); - setTimelineStatus( - "start", - "done", - ids.length > 0 ? `${ids.length} container(s) started` : "Container started", - ); - pushNotification("success", "Containers Running", `${ids.length || 1} container(s) are now running`); - } else if (parsed.event_name === "container.metrics") { - setTimelineStatus("metrics", "running", "Metrics streaming"); - } else if (parsed.event_name === "container.exited") { - const p = parsed.payload as { container_id?: string; container_name?: string; exit_code?: number; error?: string }; - const name = p.container_name ?? p.container_id?.slice(0, 12) ?? "container"; - const exitMsg = `Container exited: ${name} (code ${p.exit_code ?? "??"})${p.error ? ` — ${p.error}` : ""}`; - setTimelineStatus("start", "error", exitMsg); - pushLog({ message: exitMsg, timestamp: parsed.timestamp, tone: "error" }); - } else if (parsed.event_name === "project.runtime_stopped") { - setTimelineStatus("metrics", "done", "All containers exited"); - setStackRunning(false); - setDeployPhase("exited"); - clearDeployTimeout(); - pushLog({ message: "All containers have exited.", timestamp: parsed.timestamp, tone: "info" }); - pushNotification("warning", "Runtime Stopped", "All containers have exited"); - } else if (parsed.event_name === "container.stopped") { - setTimelineStatus("metrics", "done", "Stack stopped"); - setStackRunning(false); - setDeployPhase("idle"); - clearDeployTimeout(); - setContainerIds([]); - setDeployJobId(null); - if (stateKey) clearState(stateKey); - toast.success("Compose stack stopped"); - pushNotification("warning", "Containers Stopped", "All containers have been stopped"); - } else if (parsed.event_name === "deploy.cleanup_started") { - const projectName = String( - (parsed.payload as { project_name?: string })?.project_name ?? "compose stack", - ); - setStackRunning(false); - setContainerIds([]); - pushLog({ - message: `Cleanup started for ${projectName}`, - timestamp: parsed.timestamp, - tone: "info", - }); - toast.message("Stopping and removing stack resources..."); - pushNotification("info", "Cleanup Started", "Removing containers created by the failed deploy"); - } else if (parsed.event_name === "deploy.cleanup_completed") { - const projectName = String( - (parsed.payload as { project_name?: string })?.project_name ?? "compose stack", - ); - setStackRunning(false); - setContainerIds([]); - setDeployJobId(null); - if (stateKey) clearState(stateKey); - pushLog({ - message: `Cleanup completed for ${projectName}`, - timestamp: parsed.timestamp, - tone: "success", - }); - toast.success("Failed deploy containers stopped and removed"); - pushNotification("success", "Cleanup Completed", "Failed deploy containers were removed from the sandbox"); - } else if (parsed.event_name === "user.analysis.failed") { - setTimelineStatus( - "start", - "error", - String( - (parsed.payload as { message?: string })?.message ?? "failed", - ), - ); - setStackRunning(false); - setDeployPhase("failed"); - clearDeployTimeout(); - toast.error("Deploy workflow reported a failure"); - } - } catch { - pushLog({ message: String(event.data), tone: "info" }); - } - }; - socket.onerror = () => - pushLog({ message: "Event stream error", tone: "error" }); - socket.onclose = () => - pushLog({ message: "Event stream closed", tone: "warning" }); + connectJobSocket(job.id, true); deployTimeoutRef.current = setTimeout(() => { setDeployPhase((current) => { @@ -422,6 +676,7 @@ export function ContainerExecution() { const handleStop = async () => { if (!job || stopping) return; + const previousContainerIds = containerIds; setStopping(true); setStackRunning(false); setContainerIds([]); @@ -438,10 +693,13 @@ export function ContainerExecution() { pushNotification("info", "Stopping Containers", "Stop signal sent, waiting for containers to shut down..."); for (let i = 0; i < 30; i++) { + if (unmountedRef.current) break; await new Promise((r) => setTimeout(r, 2000)); + if (unmountedRef.current) break; try { const status = await composeApi.deployStatus(job.id); if (!status.active) { + if (unmountedRef.current) break; setDeployJobId(null); if (stateKey) clearState(stateKey); sessionStorage.removeItem("dqa:containerStatus"); @@ -457,9 +715,14 @@ export function ContainerExecution() { const message = err instanceof ApiError ? err.message : "Failed to stop deployment"; pushLog({ message, tone: "error" }); toast.error(message); - setStackRunning(true); + if (!unmountedRef.current) { + setStackRunning(true); + setContainerIds(previousContainerIds); + } } finally { - setStopping(false); + if (!unmountedRef.current) { + setStopping(false); + } } }; @@ -509,9 +772,17 @@ export function ContainerExecution() { Deploy Compose Stack {job && ( -

- Job: {job.id} ({job.type}) -

+ <> +

+ Job: {job.id} ({job.type}) +

+

+ Compose used for deploy:{" "} + + {deployComposeFile ?? "-"} + +

+ )}
@@ -519,6 +790,7 @@ export function ContainerExecution() { onClick={handleDeploy} disabled={ !job || + resubmitRequired || deployPhase === "deploying" || deployPhase === "running" || stopping || @@ -530,6 +802,8 @@ export function ContainerExecution() { ? "Stack is already running. Stop it first." : deployPhase === "deploying" ? "Deploy in progress..." + : resubmitRequired + ? "Problematic stack: fix and resubmit from Results." : deployPhase === "failed" ? "Previous deploy failed. Click to retry." : deployPhase === "exited" @@ -546,6 +820,8 @@ export function ContainerExecution() { )} {stopping ? "Stopping..." + : resubmitRequired + ? "Resubmit Required" : deployPhase === "running" ? "Running" : deployPhase === "deploying" @@ -580,6 +856,31 @@ export function ContainerExecution() {
+ {problematicAlert.open && ( + +
+ +
+

+ Problematic Stack Detected +

+

+ {problematicAlert.message} +

+

+ You need to fix the stack and resubmit. Analysis results remain available. +

+
+ +
+
+ )} +

Timeline @@ -588,7 +889,7 @@ export function ContainerExecution() { {timeline.map((entry) => ( {entry.id === "start" && - (deployPhase === "deploying" || (composeProgress && composeProgress.started < composeProgress.total)) && ( + deployPhase === "deploying" && (
docker-compose up progress @@ -661,6 +962,72 @@ export function ContainerExecution() { + {deployPhase === "exited" && exitedContainers.length > 0 && ( + +

Container Exit Summary

+
+ {exitedContainers.map((containerExit, index) => { + const containerKey = + containerExit.container_id ?? + `${containerExit.container_name ?? "container"}-${containerExit.finished_at ?? index}`; + const name = + containerExit.container_name ?? + containerExit.container_id?.slice(0, 12) ?? + `container-${index + 1}`; + const hasMetrics = + !!containerExit.container_id && + metricsSeenContainerIds.includes(containerExit.container_id); + return ( +
+
+ {name} + {containerExit.exit_code != null && ( + + exit code: {containerExit.exit_code} + + )} + {containerExit.oom_killed && ( + + OOM killed + + )} +
+ {containerExit.error && ( +

{containerExit.error}

+ )} +
+ {containerExit.started_at && ( + Started: {new Date(containerExit.started_at).toLocaleString()} + )} + {containerExit.finished_at && ( + Finished: {new Date(containerExit.finished_at).toLocaleString()} + )} + {containerExit.restart_count != null && ( + Restarts: {containerExit.restart_count} + )} +
+ {!hasMetrics && ( +

+ Container exited before metrics were collected. +

+ )} + {Array.isArray(containerExit.last_logs) && containerExit.last_logs.length > 0 && ( +
+ + Last logs ({containerExit.last_logs.length} lines) + +
+                          {containerExit.last_logs.join("\n")}
+                        
+
+ )} +
+ ); + })} +
+
+ )} + { - if (item.status === "done") { - navigate(`/results?jobId=${item.id}`); - } else if (item.status === "failed") { + if (item.status === "done" || item.status === "failed") { navigate(`/results?jobId=${item.id}`); } else { navigate(`/analysis?jobId=${item.id}`); } }; + const deleteJob = async (e: React.MouseEvent, item: HistoryItem) => { + e.preventDefault(); + e.stopPropagation(); + const label = item.fileName || item.id.slice(0, 8); + if (!window.confirm(`Delete this analysis from history?\n\n${label}`)) { + return; + } + try { + await jobsApi.delete(item.id); + setJobList((prev) => { + const next = prev.filter((j) => j.id !== item.id); + jobListRef.current = next; + return next; + }); + setRunningJobs((prev) => { + const next = new Set(prev); + next.delete(item.id); + return next; + }); + try { + if (sessionStorage.getItem("analysisJobId") === item.id) { + sessionStorage.removeItem("analysisJobId"); + } + if (sessionStorage.getItem("projectJobId") === item.id) { + sessionStorage.removeItem("projectJobId"); + } + } catch { + // ignore + } + toast.success("Analysis removed from history"); + } catch (err) { + const message = + err instanceof ApiError + ? err.message + : err instanceof Error + ? err.message + : "Failed to delete job."; + toast.error(message); + } + }; + return ( @@ -252,9 +292,9 @@ export function History() {
- {historyItems.filter((item) => item.jobType === "compose").length} + {historyItems.filter((item) => item.jobType === "compose" || item.jobType === "project").length}
-
Compose jobs
+
Container jobs
@@ -385,7 +425,17 @@ export function History() { )}
)} - + +
diff --git a/frontend/src/pages/Landing.tsx b/frontend/src/pages/Landing.tsx index d361a3f..43c1b9f 100644 --- a/frontend/src/pages/Landing.tsx +++ b/frontend/src/pages/Landing.tsx @@ -16,6 +16,9 @@ import { import { Card } from "../components/ui/card"; import { Button } from "../components/ui/button"; import { detectFileKind, type DockerFileKind } from "../utils/fileType"; +import { ApiError, compose as composeApi, dockerfile as dockerfileApi } from "../utils/api"; + +const MAX_BATCH_FILES = 10; export function Landing() { const navigate = useNavigate(); @@ -54,6 +57,75 @@ export function Landing() { reader.readAsText(file); }; + const handleFilesSelect = async (files: File[], hint?: DockerFileKind) => { + if (!files.length) { + toast.error("Please select at least one file."); + return; + } + if (files.length > MAX_BATCH_FILES) { + toast.error(`You can submit up to ${MAX_BATCH_FILES} files at a time.`); + return; + } + + const typedFiles: { file: File; kind: DockerFileKind }[] = []; + for (const file of files) { + let content = ""; + try { + content = await file.text(); + } catch { + toast.error("Failed to read one of the selected files.", { + description: file.name, + }); + return; + } + const detection = detectFileKind(file.name, content, hint); + if (detection.kind === "unknown") { + toast.error("Unsupported file in batch", { + description: `${file.name}: ${detection.reason}`, + }); + return; + } + typedFiles.push({ file, kind: detection.kind }); + } + + const firstKind = typedFiles[0].kind; + if (typedFiles.some((entry) => entry.kind !== firstKind)) { + toast.error("Mixed batch types are not supported.", { + description: "Submit Dockerfiles and Compose files in separate batches.", + }); + return; + } + + try { + const response = + firstKind === "docker-compose" + ? await composeApi.analyzeBatch(typedFiles.map((entry) => entry.file)) + : await dockerfileApi.analyzeBatch(typedFiles.map((entry) => entry.file)); + sessionStorage.setItem( + "batchAnalysis", + JSON.stringify({ + kind: firstKind, + submitted_at: new Date().toISOString(), + items: response.items.map((item) => ({ + filename: item.filename, + job_id: item.job_id, + status: item.status, + })), + }), + ); + toast.success(`Queued ${response.count} files for analysis.`); + navigate("/analysis/batch"); + } catch (err) { + const message = + err instanceof ApiError + ? err.message + : err instanceof Error + ? err.message + : "Failed to submit batch analysis."; + toast.error(message); + } + }; + return ( @@ -82,7 +154,7 @@ export function Landing() { {/* Upload Area */}
- +
{/* Or Upload Project */} diff --git a/frontend/src/pages/Monitoring.tsx b/frontend/src/pages/Monitoring.tsx index 1a96297..336aee8 100644 --- a/frontend/src/pages/Monitoring.tsx +++ b/frontend/src/pages/Monitoring.tsx @@ -1,15 +1,19 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { toast } from "sonner"; import { Activity, + AlertTriangle, ArrowLeft, Cpu, Database, + ExternalLink, + Globe, HardDrive, MemoryStick, Network, PlugZap, + RefreshCw, Server, XCircle, } from "lucide-react"; @@ -69,6 +73,17 @@ interface MonitoringPersistedState { selectedContainerId: string | null; } +interface ProblematicAlertState { + open: boolean; + message: string; +} + +interface PreviewTarget { + key: string; + label: string; + url: string; +} + const MAX_POINTS = 120; const MONITORING_STATE_TTL_MS = 1000 * 60 * 60 * 6; @@ -85,6 +100,19 @@ function formatBytes(bytes: number | null | undefined): string { return `${value.toFixed(2)} ${units[unit]}`; } +function normalizeContainerPort(raw: string | null | undefined): string | null { + if (!raw) return null; + const trimmed = raw.trim(); + if (!trimmed) return null; + const [port] = trimmed.split("/"); + return port && /^\d+$/.test(port) ? port : null; +} + +function isLoopbackBinding(hostIp: string): boolean { + const normalized = hostIp.trim(); + return normalized === "0.0.0.0" || normalized === "::" || normalized === ":::" || normalized === ""; +} + export function Monitoring() { const navigate = useNavigate(); const reducedMotion = useReducedMotion(); @@ -103,6 +131,13 @@ export function Monitoring() { const [selectedContainerId, setSelectedContainerId] = useState( routeContainerId ?? persisted?.selectedContainerId ?? null, ); + const [problematicAlert, setProblematicAlert] = useState({ + open: false, + message: "", + }); + const [dindIp, setDindIp] = useState(null); + const [manualPreviewUrl, setManualPreviewUrl] = useState(null); + const [previewReloadTick, setPreviewReloadTick] = useState(0); const [containerStates, setContainerStates] = useState>(() => { const initial: Record = {}; if (persisted?.containerStates) { @@ -113,6 +148,30 @@ export function Monitoring() { return initial; }); const socketsRef = useRef>(new Map()); + const cleanupAttemptedRef = useRef(false); + + const setResubmitRequired = useCallback(() => { + if (!jobId) return; + sessionStorage.setItem(`dqa:resubmitRequired:${jobId}`, "1"); + }, [jobId]); + + const markProblematicStack = useCallback( + (message: string, triggerCleanup: boolean) => { + setResubmitRequired(); + setProblematicAlert({ open: true, message }); + sessionStorage.removeItem("dqa:containerStatus"); + + if (triggerCleanup && jobId && !cleanupAttemptedRef.current) { + cleanupAttemptedRef.current = true; + void composeApi + .stopDeploy({ job_id: jobId, remove_volumes: true }) + .catch(() => { + toast.error("Failed to trigger cleanup for problematic stack."); + }); + } + }, + [jobId, setResubmitRequired], + ); useEffect(() => { if (!jobId) return; @@ -122,6 +181,16 @@ export function Monitoring() { const status = await composeApi.deployStatus(jobId); if (cancelled) return; + const rs = status.runtime_state ?? "none"; + const problematicByRuntimeState = + rs === "failed" || + rs === "cleanup_completed" || + rs === "exited" || + rs === "partial" || + rs === "unhealthy"; + const isProblematic = problematicByRuntimeState && !status.stopped_by_user; + const isTerminal = ["none", "stopped_by_user", "exited", "failed", "cleanup_completed"].includes(rs); + const fromIds = status.container_ids.length > 0 ? status.container_ids @@ -140,6 +209,56 @@ export function Monitoring() { setSelectedContainerId(routeContainerId); } + // For terminal states with no active containers, don't attempt to open sockets + if (isTerminal && !status.active) { + // Pre-populate exited state so charts show offline state + const fromDeploy = status.containers ?? []; + if (fromDeploy.length > 0) { + setContainerStates((prev) => { + const next = { ...prev }; + for (const c of fromDeploy) { + if (!c.id) continue; + const existing = next[c.id] ?? { + points: [], + logs: [], + latestPayload: null, + connected: false, + }; + const lastLogs = (c.last_logs ?? []).filter( + (l): l is string => typeof l === "string", + ); + const tailLogs: TerminalLogEntry[] = lastLogs.map((line) => ({ + message: `[deploy] ${line}`, + tone: "info" as const, + })); + next[c.id] = { + ...existing, + connected: false, + exited: { + exit_code: c.exit_code, + error: c.error ?? null, + started_at: c.started_at ?? null, + finished_at: c.finished_at ?? null, + restart_count: c.restart_count ?? null, + oom_killed: c.oom_killed ?? null, + last_logs: lastLogs, + }, + logs: [...existing.logs.slice(-200), ...tailLogs.slice(-80)], + }; + } + return next; + }); + } + return; + } + + if (isProblematic) { + markProblematicStack( + "Current stack is problematic. Please fix the stack and resubmit.", + true, + ); + } + const fromDeploy = status.containers ?? []; if (fromDeploy.length === 0) return; @@ -187,10 +306,27 @@ export function Monitoring() { return () => { cancelled = true; }; - }, [jobId, routeContainerId]); + }, [jobId, routeContainerId, markProblematicStack]); useEffect(() => { - if (!user?.id || containerIds.length === 0) return; + let cancelled = false; + (async () => { + try { + const resolved = await composeApi.dindIp(); + if (!cancelled) { + setDindIp(typeof resolved.dind_ip === "string" && resolved.dind_ip.trim() ? resolved.dind_ip : null); + } + } catch { + if (!cancelled) setDindIp(null); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + if (!user?.id || containerIds.length === 0 || problematicAlert.open) return; const existingSockets = socketsRef.current; for (const cid of containerIds) { @@ -295,7 +431,7 @@ export function Monitoring() { } existingSockets.clear(); }; - }, [user?.id, containerIds]); + }, [user?.id, containerIds, problematicAlert.open]); useEffect(() => { if (!stateKey) return; @@ -323,6 +459,8 @@ export function Monitoring() { const ioStats = latestPayload?.io; const pids = latestPayload?.pids; const container = latestPayload?.container; + const currentHealthStatus = String(container?.health_status ?? "").toLowerCase(); + const isUnhealthyLive = !exitedState && connected && currentHealthStatus === "unhealthy"; const interfaces = useMemo(() => { const raw = latestPayload?.network?.interfaces; if (!raw || typeof raw !== "object") return []; @@ -332,6 +470,59 @@ export function Monitoring() { })); }, [latestPayload]); + const previewTargets = useMemo(() => { + if (!container) return []; + const candidates: PreviewTarget[] = []; + const seen = new Set(); + const protocol = "http://"; + const hostname = + typeof window !== "undefined" && window.location.hostname + ? window.location.hostname + : "localhost"; + + for (const portInfo of container.ports ?? []) { + const containerPort = normalizeContainerPort(portInfo.container_port); + if (!containerPort) continue; + + for (const binding of portInfo.host_bindings ?? []) { + const rawHostIp = String(binding.host_ip ?? "").trim(); + const hostPort = String(binding.host_port ?? "").trim(); + if (!hostPort) continue; + const host = isLoopbackBinding(rawHostIp) ? dindIp || hostname : rawHostIp; + const url = `${protocol}${host}:${hostPort}`; + if (seen.has(url)) continue; + seen.add(url); + candidates.push({ + key: `host-${host}-${hostPort}-${containerPort}`, + label: `${host}:${hostPort} -> ${containerPort}`, + url, + }); + } + + if (container.ip_address) { + const ipUrl = `${protocol}${container.ip_address}:${containerPort}`; + if (!seen.has(ipUrl)) { + seen.add(ipUrl); + candidates.push({ + key: `container-ip-${container.ip_address}-${containerPort}`, + label: `container ${container.ip_address}:${containerPort}`, + url: ipUrl, + }); + } + } + } + + return candidates; + }, [container, dindIp]); + + const selectedPreviewUrl = useMemo(() => { + if (previewTargets.length === 0) return ""; + if (manualPreviewUrl && previewTargets.some((t) => t.url === manualPreviewUrl)) { + return manualPreviewUrl; + } + return previewTargets[0].url; + }, [previewTargets, manualPreviewUrl]); + if (!user?.id) { return ( @@ -358,20 +549,62 @@ export function Monitoring() { Monitoring

- - {exitedState ? : } - {exitedState ? `Exited (${exitedState.exit_code ?? "?"})` : connected ? "Live" : "Disconnected"} - +
+ + + DinD: {dindIp ?? "unresolved"} + + + {problematicAlert.open || exitedState ? : } + {problematicAlert.open + ? "Problematic" + : exitedState + ? `Exited (${exitedState.exit_code ?? "?"})` + : isUnhealthyLive + ? "Unhealthy" + : connected + ? "Live" + : "Disconnected"} + +
+ {problematicAlert.open && ( + +
+ +
+

+ Problematic Stack Detected +

+

{problematicAlert.message}

+

+ Please fix your stack and resubmit. Analysis remains available in Results. +

+
+ +
+
+ )} + {containerIds.length > 1 && (
{containerIds.map((cid) => { @@ -379,6 +612,8 @@ export function Monitoring() { const cState = containerStates[cid]; const isConnected = cState?.connected ?? false; const isExited = !!cState?.exited; + const cHealthStatus = String(cState?.latestPayload?.container?.health_status ?? "").toLowerCase(); + const isUnhealthy = !isExited && isConnected && cHealthStatus === "unhealthy"; return (
-
+
{currentState?.connected && ( @@ -650,6 +885,70 @@ export function Monitoring() {
+ +
+

+ + Live App Preview +

+
+ {previewTargets.length > 0 && ( + + )} + + +
+
+ {selectedPreviewUrl ? ( +
+
+ {selectedPreviewUrl} +
+