From 5258437befb2fe31cc6310a51642b463a030eb41 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Sat, 24 Jan 2026 09:52:26 -0600 Subject: [PATCH] feat(dev): add dev.abaci.one for build artifacts - Add nginx static server at dev.abaci.one for serving: - Playwright HTML reports at /smoke-reports/ - Storybook (future) at /storybook/ - Coverage reports (future) at /coverage/ - NFS-backed PVC shared between artifact producers and nginx - Smoke tests now save HTML reports with automatic cleanup (keeps 20) - Reports accessible at dev.abaci.one/smoke-reports/latest/ Infrastructure: - infra/terraform/dev-artifacts.tf: nginx deployment, PVC, ingress - Updated smoke-tests.tf to mount shared PVC - Updated smoke-test-runner.ts to generate and save HTML reports Co-Authored-By: Claude Opus 4.5 --- apps/web/scripts/smoke-test-runner.ts | 136 +++++++++++- infra/terraform/dev-artifacts.tf | 306 ++++++++++++++++++++++++++ infra/terraform/smoke-tests.tf | 21 ++ 3 files changed, 456 insertions(+), 7 deletions(-) create mode 100644 infra/terraform/dev-artifacts.tf diff --git a/apps/web/scripts/smoke-test-runner.ts b/apps/web/scripts/smoke-test-runner.ts index a3921468..ff9bb54e 100644 --- a/apps/web/scripts/smoke-test-runner.ts +++ b/apps/web/scripts/smoke-test-runner.ts @@ -3,23 +3,35 @@ * Smoke Test Runner * * Runs Playwright smoke tests and reports results to the abaci-app API. + * Optionally saves HTML reports to a filesystem directory for viewing. * * Environment variables: * - BASE_URL: The base URL to test against (default: http://localhost:3000) * - RESULTS_API_URL: The URL to POST results to (default: http://localhost:3000/api/smoke-test-results) + * - REPORT_DIR: Directory to save HTML reports (optional, e.g., /artifacts/smoke-reports) * * Usage: * npx tsx scripts/smoke-test-runner.ts */ -import { spawn } from "child_process"; +import { spawn, execSync } from "child_process"; import { randomUUID } from "crypto"; -import { existsSync, readFileSync, mkdirSync } from "fs"; +import { + existsSync, + mkdirSync, + cpSync, + rmSync, + symlinkSync, + unlinkSync, + readdirSync, + statSync, +} from "fs"; import { join } from "path"; const BASE_URL = process.env.BASE_URL || "http://localhost:3000"; const RESULTS_API_URL = process.env.RESULTS_API_URL || `${BASE_URL}/api/smoke-test-results`; +const REPORT_DIR = process.env.REPORT_DIR; // Optional: directory to save HTML reports interface PlaywrightTestResult { title: string; @@ -84,6 +96,96 @@ async function reportResults(results: { } } +/** + * Save HTML report to the artifacts directory + */ +function saveHtmlReport( + runId: string, + htmlReportDir: string, + passed: boolean, +): string | null { + if (!REPORT_DIR) { + return null; + } + + try { + // Ensure report directory exists + if (!existsSync(REPORT_DIR)) { + mkdirSync(REPORT_DIR, { recursive: true }); + } + + // Create run-specific directory with timestamp prefix for sorting + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const status = passed ? "passed" : "failed"; + const reportDirName = `${timestamp}_${status}_${runId.slice(0, 8)}`; + const destDir = join(REPORT_DIR, reportDirName); + + // Copy HTML report to destination + if (existsSync(htmlReportDir)) { + cpSync(htmlReportDir, destDir, { recursive: true }); + console.log(`HTML report saved to: ${destDir}`); + + // Update "latest" symlink + const latestLink = join(REPORT_DIR, "latest"); + try { + if (existsSync(latestLink)) { + unlinkSync(latestLink); + } + symlinkSync(reportDirName, latestLink); + console.log(`Updated 'latest' symlink to: ${reportDirName}`); + } catch (symlinkError) { + console.warn("Could not create latest symlink:", symlinkError); + } + + // Clean up old reports (keep last 20) + cleanupOldReports(20); + + return reportDirName; + } else { + console.warn(`HTML report directory not found: ${htmlReportDir}`); + return null; + } + } catch (error) { + console.error("Error saving HTML report:", error); + return null; + } +} + +/** + * Remove old reports, keeping only the most recent N + */ +function cleanupOldReports(keepCount: number): void { + if (!REPORT_DIR || !existsSync(REPORT_DIR)) { + return; + } + + try { + const entries = readdirSync(REPORT_DIR) + .filter((name) => { + // Only consider directories that match our naming pattern (timestamp_status_id) + const fullPath = join(REPORT_DIR, name); + return ( + name !== "latest" && + existsSync(fullPath) && + statSync(fullPath).isDirectory() && + /^\d{4}-\d{2}-\d{2}T/.test(name) + ); + }) + .sort() + .reverse(); // Most recent first + + // Remove old entries + const toRemove = entries.slice(keepCount); + for (const dir of toRemove) { + const fullPath = join(REPORT_DIR, dir); + rmSync(fullPath, { recursive: true, force: true }); + console.log(`Removed old report: ${dir}`); + } + } catch (error) { + console.error("Error cleaning up old reports:", error); + } +} + async function runTests(): Promise { const runId = randomUUID(); const startedAt = new Date().toISOString(); @@ -100,15 +202,19 @@ async function runTests(): Promise { }); const reportDir = join(process.cwd(), "playwright-report"); + const htmlReportDir = join(process.cwd(), "playwright-html-report"); const jsonReportPath = join(reportDir, "results.json"); - // Ensure report directory exists + // Ensure report directories exist if (!existsSync(reportDir)) { mkdirSync(reportDir, { recursive: true }); } + if (!existsSync(htmlReportDir)) { + mkdirSync(htmlReportDir, { recursive: true }); + } try { - // Run Playwright tests with JSON reporter + // Run Playwright tests with JSON reporter (stdout) and HTML reporter (directory) // Note: testDir in playwright.config.ts is './e2e', so we pass 'smoke' not 'e2e/smoke' const playwrightProcess = spawn( "npx", @@ -116,7 +222,7 @@ async function runTests(): Promise { "playwright", "test", "smoke", - "--reporter=json", + "--reporter=json,html", `--output=${reportDir}`, ], { @@ -125,6 +231,7 @@ async function runTests(): Promise { ...process.env, BASE_URL, CI: "true", // Ensure CI mode + PLAYWRIGHT_HTML_REPORT: htmlReportDir, // Output directory for HTML report }, stdio: ["inherit", "pipe", "pipe"], }, @@ -165,12 +272,19 @@ async function runTests(): Promise { report.stats.expected + report.stats.unexpected + report.stats.skipped; const passedTests = report.stats.expected; const failedTests = report.stats.unexpected; + const passed = failedTests === 0; + + // Save HTML report to artifacts directory + const savedReportDir = saveHtmlReport(runId, htmlReportDir, passed); + if (savedReportDir) { + console.log(`HTML report: https://dev.abaci.one/smoke-reports/${savedReportDir}/`); + } await reportResults({ id: runId, startedAt, completedAt, - status: failedTests > 0 ? "failed" : "passed", + status: passed ? "passed" : "failed", totalTests, passedTests, failedTests, @@ -187,11 +301,19 @@ async function runTests(): Promise { process.exit(exitCode); } else { // Fallback: report based on exit code + const passed = exitCode === 0; + + // Save HTML report to artifacts directory + const savedReportDir = saveHtmlReport(runId, htmlReportDir, passed); + if (savedReportDir) { + console.log(`HTML report: https://dev.abaci.one/smoke-reports/${savedReportDir}/`); + } + await reportResults({ id: runId, startedAt, completedAt, - status: exitCode === 0 ? "passed" : "failed", + status: passed ? "passed" : "failed", durationMs, errorMessage: exitCode !== 0 diff --git a/infra/terraform/dev-artifacts.tf b/infra/terraform/dev-artifacts.tf new file mode 100644 index 00000000..a5582542 --- /dev/null +++ b/infra/terraform/dev-artifacts.tf @@ -0,0 +1,306 @@ +# Dev Artifacts Server +# +# Serves static build artifacts at dev.abaci.one: +# - /smoke-reports/ - Playwright HTML reports from smoke tests +# - /storybook/ - Component library documentation +# - /coverage/ - Test coverage reports (future) +# +# Architecture: +# - NFS-backed PVC shared between artifact producers and nginx +# - nginx serves files read-only with directory listing enabled +# - Smoke tests CronJob writes reports directly to filesystem + +# NFS PersistentVolume for dev artifacts +resource "kubernetes_persistent_volume" "dev_artifacts" { + metadata { + name = "dev-artifacts-pv" + labels = { + type = "nfs" + app = "dev-artifacts" + } + } + + spec { + capacity = { + storage = "10Gi" + } + access_modes = ["ReadWriteMany"] + persistent_volume_reclaim_policy = "Retain" + storage_class_name = "nfs" + + persistent_volume_source { + nfs { + server = var.nfs_server + path = "/volume1/homes/antialias/projects/abaci.one/dev-artifacts" + } + } + } +} + +resource "kubernetes_persistent_volume_claim" "dev_artifacts" { + metadata { + name = "dev-artifacts" + namespace = kubernetes_namespace.abaci.metadata[0].name + } + + spec { + access_modes = ["ReadWriteMany"] + storage_class_name = "nfs" + + resources { + requests = { + storage = "10Gi" + } + } + + selector { + match_labels = { + type = "nfs" + app = "dev-artifacts" + } + } + } +} + +# nginx ConfigMap for custom configuration +resource "kubernetes_config_map" "dev_artifacts_nginx" { + metadata { + name = "dev-artifacts-nginx" + namespace = kubernetes_namespace.abaci.metadata[0].name + } + + data = { + "default.conf" = <<-EOT + server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + + # Enable directory listing + autoindex on; + autoindex_exact_size off; + autoindex_localtime on; + + # Serve static files with caching + location / { + try_files $uri $uri/ =404; + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { + expires 1d; + add_header Cache-Control "public, immutable"; + } + + # HTML files - no cache for fresh reports + location ~* \.html$ { + expires -1; + add_header Cache-Control "no-store, no-cache, must-revalidate"; + } + } + + # Health check endpoint + location /health { + return 200 'ok'; + add_header Content-Type text/plain; + } + } + EOT + } +} + +# nginx Deployment +resource "kubernetes_deployment" "dev_artifacts" { + metadata { + name = "dev-artifacts" + namespace = kubernetes_namespace.abaci.metadata[0].name + labels = { + app = "dev-artifacts" + } + } + + spec { + replicas = 1 + + selector { + match_labels = { + app = "dev-artifacts" + } + } + + template { + metadata { + labels = { + app = "dev-artifacts" + } + } + + spec { + container { + name = "nginx" + image = "nginx:1.25-alpine" + + port { + container_port = 80 + } + + volume_mount { + name = "artifacts" + mount_path = "/usr/share/nginx/html" + read_only = true + } + + volume_mount { + name = "nginx-config" + mount_path = "/etc/nginx/conf.d" + read_only = true + } + + resources { + requests = { + memory = "32Mi" + cpu = "10m" + } + limits = { + memory = "64Mi" + cpu = "100m" + } + } + + liveness_probe { + http_get { + path = "/health" + port = 80 + } + initial_delay_seconds = 5 + period_seconds = 30 + } + + readiness_probe { + http_get { + path = "/health" + port = 80 + } + initial_delay_seconds = 2 + period_seconds = 10 + } + } + + volume { + name = "artifacts" + persistent_volume_claim { + claim_name = kubernetes_persistent_volume_claim.dev_artifacts.metadata[0].name + } + } + + volume { + name = "nginx-config" + config_map { + name = kubernetes_config_map.dev_artifacts_nginx.metadata[0].name + } + } + } + } + } +} + +# Service for nginx +resource "kubernetes_service" "dev_artifacts" { + metadata { + name = "dev-artifacts" + namespace = kubernetes_namespace.abaci.metadata[0].name + } + + spec { + selector = { + app = "dev-artifacts" + } + + port { + port = 80 + target_port = 80 + } + + type = "ClusterIP" + } +} + +# Ingress for dev.abaci.one +resource "kubernetes_ingress_v1" "dev_artifacts" { + metadata { + name = "dev-artifacts" + namespace = kubernetes_namespace.abaci.metadata[0].name + annotations = { + "cert-manager.io/cluster-issuer" = var.use_staging_certs ? "letsencrypt-staging" : "letsencrypt-prod" + "traefik.ingress.kubernetes.io/router.entrypoints" = "websecure" + "traefik.ingress.kubernetes.io/router.middlewares" = "${kubernetes_namespace.abaci.metadata[0].name}-hsts@kubernetescrd" + } + } + + spec { + ingress_class_name = "traefik" + + tls { + hosts = ["dev.${var.app_domain}"] + secret_name = "dev-artifacts-tls" + } + + rule { + host = "dev.${var.app_domain}" + + http { + path { + path = "/" + path_type = "Prefix" + + backend { + service { + name = kubernetes_service.dev_artifacts.metadata[0].name + port { + number = 80 + } + } + } + } + } + } + } + + depends_on = [null_resource.cert_manager_issuers] +} + +# HTTP to HTTPS redirect for dev subdomain +resource "kubernetes_ingress_v1" "dev_artifacts_http_redirect" { + metadata { + name = "dev-artifacts-http-redirect" + namespace = kubernetes_namespace.abaci.metadata[0].name + annotations = { + "traefik.ingress.kubernetes.io/router.entrypoints" = "web" + "traefik.ingress.kubernetes.io/router.middlewares" = "${kubernetes_namespace.abaci.metadata[0].name}-redirect-https@kubernetescrd" + } + } + + spec { + ingress_class_name = "traefik" + + rule { + host = "dev.${var.app_domain}" + + http { + path { + path = "/" + path_type = "Prefix" + + backend { + service { + name = kubernetes_service.dev_artifacts.metadata[0].name + port { + number = 80 + } + } + } + } + } + } + } +} diff --git a/infra/terraform/smoke-tests.tf b/infra/terraform/smoke-tests.tf index 89a17f42..ed6861e0 100644 --- a/infra/terraform/smoke-tests.tf +++ b/infra/terraform/smoke-tests.tf @@ -2,6 +2,7 @@ # # Runs Playwright smoke tests every 15 minutes and reports results to the app API. # Results are exposed via /api/smoke-test-status for Gatus monitoring. +# HTML reports are saved to the dev-artifacts PVC for viewing at dev.abaci.one/smoke-reports/ resource "kubernetes_cron_job_v1" "smoke_tests" { metadata { @@ -65,6 +66,12 @@ resource "kubernetes_cron_job_v1" "smoke_tests" { value = "http://abaci-app-primary.${kubernetes_namespace.abaci.metadata[0].name}.svc.cluster.local/api/smoke-test-results" } + env { + # Directory to save HTML reports (on shared PVC) + name = "REPORT_DIR" + value = "/artifacts/smoke-reports" + } + resources { requests = { memory = "512Mi" @@ -81,6 +88,12 @@ resource "kubernetes_cron_job_v1" "smoke_tests" { name = "dshm" mount_path = "/dev/shm" } + + # Mount dev-artifacts PVC for saving HTML reports + volume_mount { + name = "artifacts" + mount_path = "/artifacts" + } } # Chromium needs shared memory for rendering @@ -92,6 +105,14 @@ resource "kubernetes_cron_job_v1" "smoke_tests" { } } + # Shared storage for artifacts (HTML reports) + volume { + name = "artifacts" + persistent_volume_claim { + claim_name = kubernetes_persistent_volume_claim.dev_artifacts.metadata[0].name + } + } + restart_policy = "Never" } }