From 73917551bd62d747a588fd27b62842d03555ff65 Mon Sep 17 00:00:00 2001 From: OpenClaw Agent Date: Sun, 15 Feb 2026 08:04:56 +0000 Subject: [PATCH 1/3] Fix rate limits, concurrency control, copy button - DATA-BACKED RATE LIMITS: * Reduce global rate limit from 10,000/min to 100/min * Add PDF conversion rate limits: 10/min free, 30/min pro * Set recovery rate limit to 3/hour (was 5/hour) * Add concurrency limiter: max 3 simultaneous PDFs, queue rest * Return 429 if queue > 10 - BUG-025: Fix copy button functionality * Improve fallback handling for execCommand * Add better error handling and user feedback * Fix secure context detection - Add concurrency monitoring endpoint /v1/concurrency --- bugs.md | 24 +++++++ decisions.md | 21 ++++++ public/app.js | 53 +++++++++++++-- sessions.md | 37 +++++++++++ src/index.ts | 12 +++- src/middleware/pdfRateLimit.ts | 115 +++++++++++++++++++++++++++++++++ src/routes/convert.ts | 57 ++++++++++++++-- src/routes/recover.ts | 2 +- state.json | 35 ++++++++++ 9 files changed, 339 insertions(+), 17 deletions(-) create mode 100644 bugs.md create mode 100644 decisions.md create mode 100644 sessions.md create mode 100644 src/middleware/pdfRateLimit.ts create mode 100644 state.json diff --git a/bugs.md b/bugs.md new file mode 100644 index 0000000..8ebec70 --- /dev/null +++ b/bugs.md @@ -0,0 +1,24 @@ +# DocFast Bugs + +## Open + +### BUG-030: Email change backend not implemented +- **Severity:** High +- **Found:** 2026-02-14 QA session +- **Description:** Frontend UI for email change is deployed (modal, form, JS handlers), but no backend routes exist. Frontend calls `/v1/email-change` and `/v1/email-change/verify` which return 404. +- **Impact:** Users see "Change Email" link in footer but the feature doesn't work. +- **Fix:** Implement `src/routes/email-change.ts` with verification code flow similar to signup/recover. + +### BUG-031: Stray file "\001@" in repository +- **Severity:** Low +- **Found:** 2026-02-14 +- **Description:** An accidental file named `\001@` was committed to the repo. +- **Fix:** `git rm "\001@"` and commit. + +### BUG-032: Swagger UI content not rendered via web_fetch +- **Severity:** Low (cosmetic) +- **Found:** 2026-02-14 +- **Description:** /docs page loads (200) and has swagger-ui assets, but content is JS-rendered so web_fetch can't verify full render. Needs browser-based QA for full verification. + +## Fixed +(none yet - this is first QA session) diff --git a/decisions.md b/decisions.md new file mode 100644 index 0000000..a68912d --- /dev/null +++ b/decisions.md @@ -0,0 +1,21 @@ +# DocFast Decisions Log + +## 2026-02-14: Mandatory QA After Every Deployment + +**Rule:** Every deployment MUST be followed by a full QA session. No exceptions. + +**QA Checklist:** +- Landing page loads, zero console errors +- Signup flow works (email verification) +- Key recovery flow works +- Email change flow works (when backend is implemented) +- Swagger UI loads at /docs +- API endpoints work (HTML→PDF, Markdown→PDF, URL→PDF) +- Health endpoint returns ok +- All previous features still working + +**Rationale:** Code was deployed to production without verification multiple times, leading to broken features being live. QA catches regressions before users do. + +## 2026-02-14: Code Must Be Committed Before Deployment + +Changes were found uncommitted on the production server. All code changes must be committed and pushed to Forgejo before deploying. diff --git a/public/app.js b/public/app.js index 9841160..ce2e351 100644 --- a/public/app.js +++ b/public/app.js @@ -239,17 +239,56 @@ function doCopy(text, btn) { btn.textContent = '\u2713 Copied!'; setTimeout(function() { btn.textContent = 'Copy'; }, 2000); } + function showFailed() { + btn.textContent = 'Failed'; + setTimeout(function() { btn.textContent = 'Copy'; }, 2000); + } try { - navigator.clipboard.writeText(text).then(showCopied).catch(function() { + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(text).then(showCopied).catch(function() { + // Fallback to execCommand + try { + var ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + ta.style.top = '-9999px'; + ta.style.left = '-9999px'; + document.body.appendChild(ta); + ta.focus(); + ta.select(); + var success = document.execCommand('copy'); + document.body.removeChild(ta); + if (success) { + showCopied(); + } else { + showFailed(); + } + } catch (err) { + showFailed(); + } + }); + } else { + // Direct fallback for non-secure contexts var ta = document.createElement('textarea'); - ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; - document.body.appendChild(ta); ta.select(); - document.execCommand('copy'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + ta.style.top = '-9999px'; + ta.style.left = '-9999px'; + document.body.appendChild(ta); + ta.focus(); + ta.select(); + var success = document.execCommand('copy'); document.body.removeChild(ta); - showCopied(); - }); + if (success) { + showCopied(); + } else { + showFailed(); + } + } } catch(e) { - showCopied(); + showFailed(); } } diff --git a/sessions.md b/sessions.md new file mode 100644 index 0000000..436b3bf --- /dev/null +++ b/sessions.md @@ -0,0 +1,37 @@ +# DocFast Sessions Log + +## 2026-02-14 22:14 UTC — Deployment + QA Session + +**Trigger:** Latest code changes (Swagger UI, key recovery UI, email change) were not deployed despite being in the working tree. + +**Actions:** +1. SSH'd into server (167.235.156.214) +2. Found uncommitted changes in working tree (email change UI, Swagger UI, key recovery link) +3. Committed all changes: `d859e9f` — "feat: email change UI, Swagger UI improvements, key recovery link on landing page" +4. Pushed to Forgejo (openclawd/docfast) +5. Rebuilt container with `docker compose build --no-cache` +6. Restarted: `docker compose up -d` +7. Verified server healthy: 15-page browser pool, version 0.2.1 + +**QA Results:** +| Test | Result | +|------|--------| +| Landing page loads | ✅ 200 OK | +| Key recovery link on landing | ✅ Present | +| Email change link in footer | ✅ Present | +| Swagger UI at /docs | ✅ 200 OK | +| Signup endpoint | ✅ Works (verification_required) | +| Key recovery endpoint | ✅ Works (recovery_sent) | +| Email change backend | ❌ NOT IMPLEMENTED (BUG-030) | +| HTML→PDF conversion | ✅ Valid PDF | +| Markdown→PDF conversion | ✅ Valid PDF | +| URL→PDF conversion | ✅ Valid PDF | +| Health endpoint | ✅ Pool: 15 pages, 0 active | +| Browser pool | ✅ 1 browser × 15 pages | + +**Bugs Found:** +- BUG-030: Email change backend not implemented (frontend-only) +- BUG-031: Stray `\001@` file in repo +- BUG-032: Swagger UI needs browser QA for full verification + +**Note:** Browser-based QA not available (openclaw browser service unreachable). Console error check, mobile responsive test, and full Swagger UI render verification deferred. diff --git a/src/index.ts b/src/index.ts index f1b20b1..0d2d85e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,7 @@ import { emailChangeRouter } from "./routes/email-change.js"; import { authMiddleware } from "./middleware/auth.js"; import { usageMiddleware } from "./middleware/usage.js"; import { getUsageStats } from "./middleware/usage.js"; +import { pdfRateLimitMiddleware, getConcurrencyStats } from "./middleware/pdfRateLimit.js"; import { initBrowser, closeBrowser } from "./services/browser.js"; import { loadKeys, getAllKeys } from "./services/keys.js"; import { verifyToken } from "./services/verification.js"; @@ -59,10 +60,10 @@ app.use(express.text({ limit: "2mb", type: "text/*" })); // Trust nginx proxy app.set("trust proxy", 1); -// Rate limiting +// Global rate limiting - reduced from 10,000 to reasonable limit const limiter = rateLimit({ windowMs: 60_000, - max: 10000, + max: 100, standardHeaders: true, legacyHeaders: false, }); @@ -76,7 +77,7 @@ app.use("/v1/billing", billingRouter); app.use("/v1/email-change", emailChangeRouter); // Authenticated routes -app.use("/v1/convert", authMiddleware, usageMiddleware, convertRouter); +app.use("/v1/convert", authMiddleware, usageMiddleware, pdfRateLimitMiddleware, convertRouter); app.use("/v1/templates", authMiddleware, usageMiddleware, templatesRouter); // Admin: usage stats @@ -84,6 +85,11 @@ app.get("/v1/usage", authMiddleware, (_req, res) => { res.json(getUsageStats()); }); +// Admin: concurrency stats +app.get("/v1/concurrency", authMiddleware, (_req, res) => { + res.json(getConcurrencyStats()); +}); + // Email verification endpoint app.get("/verify", (req, res) => { const token = req.query.token as string; diff --git a/src/middleware/pdfRateLimit.ts b/src/middleware/pdfRateLimit.ts new file mode 100644 index 0000000..ca49ca6 --- /dev/null +++ b/src/middleware/pdfRateLimit.ts @@ -0,0 +1,115 @@ +import { Request, Response, NextFunction } from "express"; +import { isProKey } from "../services/keys.js"; + +interface RateLimitEntry { + count: number; + resetTime: number; +} + +// Per-key rate limits (requests per minute) +const FREE_RATE_LIMIT = 10; +const PRO_RATE_LIMIT = 30; +const RATE_WINDOW_MS = 60_000; // 1 minute + +// Concurrency limits +const MAX_CONCURRENT_PDFS = 3; +const MAX_QUEUE_SIZE = 10; + +const rateLimitStore = new Map(); +let activePdfCount = 0; +const pdfQueue: Array<{ resolve: () => void; reject: (error: Error) => void }> = []; + +function cleanupExpiredEntries(): void { + const now = Date.now(); + for (const [key, entry] of rateLimitStore.entries()) { + if (now >= entry.resetTime) { + rateLimitStore.delete(key); + } + } +} + +function getRateLimit(apiKey: string): number { + return isProKey(apiKey) ? PRO_RATE_LIMIT : FREE_RATE_LIMIT; +} + +function checkRateLimit(apiKey: string): boolean { + cleanupExpiredEntries(); + + const now = Date.now(); + const limit = getRateLimit(apiKey); + const entry = rateLimitStore.get(apiKey); + + if (!entry || now >= entry.resetTime) { + // Create new window + rateLimitStore.set(apiKey, { + count: 1, + resetTime: now + RATE_WINDOW_MS + }); + return true; + } + + if (entry.count >= limit) { + return false; + } + + entry.count++; + return true; +} + +async function acquireConcurrencySlot(): Promise { + if (activePdfCount < MAX_CONCURRENT_PDFS) { + activePdfCount++; + return; + } + + if (pdfQueue.length >= MAX_QUEUE_SIZE) { + throw new Error("QUEUE_FULL"); + } + + return new Promise((resolve, reject) => { + pdfQueue.push({ resolve, reject }); + }); +} + +function releaseConcurrencySlot(): void { + activePdfCount--; + + const waiter = pdfQueue.shift(); + if (waiter) { + activePdfCount++; + waiter.resolve(); + } +} + +export function pdfRateLimitMiddleware(req: Request & { apiKeyInfo?: any }, res: Response, next: NextFunction): void { + const keyInfo = req.apiKeyInfo; + const apiKey = keyInfo?.key || "unknown"; + + // Check rate limit first + if (!checkRateLimit(apiKey)) { + const limit = getRateLimit(apiKey); + const tier = isProKey(apiKey) ? "pro" : "free"; + res.status(429).json({ + error: "Rate limit exceeded", + limit: `${limit} PDFs per minute`, + tier, + retryAfter: "60 seconds" + }); + return; + } + + // Add concurrency control to the request + (req as any).acquirePdfSlot = acquireConcurrencySlot; + (req as any).releasePdfSlot = releaseConcurrencySlot; + + next(); +} + +export function getConcurrencyStats() { + return { + activePdfCount, + queueSize: pdfQueue.length, + maxConcurrent: MAX_CONCURRENT_PDFS, + maxQueue: MAX_QUEUE_SIZE + }; +} \ No newline at end of file diff --git a/src/routes/convert.ts b/src/routes/convert.ts index 07d657e..b36077e 100644 --- a/src/routes/convert.ts +++ b/src/routes/convert.ts @@ -34,7 +34,8 @@ interface ConvertBody { } // POST /v1/convert/html -convertRouter.post("/html", async (req: Request, res: Response) => { +convertRouter.post("/html", async (req: Request & { acquirePdfSlot?: () => Promise; releasePdfSlot?: () => void }, res: Response) => { + let slotAcquired = false; try { // Reject non-JSON content types const ct = req.headers["content-type"] || ""; @@ -50,6 +51,12 @@ convertRouter.post("/html", async (req: Request, res: Response) => { return; } + // Acquire concurrency slot + if (req.acquirePdfSlot) { + await req.acquirePdfSlot(); + slotAcquired = true; + } + // Wrap bare HTML fragments const fullHtml = body.html.includes(" { res.send(pdf); } catch (err: any) { console.error("Convert HTML error:", err); - if (err.message === "QUEUE_FULL") { const pool = getPoolStats(); res.status(429).json({ error: "Server busy", queueDepth: pool.queueDepth }); return; } res.status(500).json({ error: "PDF generation failed", detail: err.message }); + if (err.message === "QUEUE_FULL") { + res.status(429).json({ error: "Server busy - too many concurrent PDF generations. Please try again in a few seconds." }); + return; + } + res.status(500).json({ error: "PDF generation failed", detail: err.message }); + } finally { + if (slotAcquired && req.releasePdfSlot) { + req.releasePdfSlot(); + } } }); // POST /v1/convert/markdown -convertRouter.post("/markdown", async (req: Request, res: Response) => { +convertRouter.post("/markdown", async (req: Request & { acquirePdfSlot?: () => Promise; releasePdfSlot?: () => void }, res: Response) => { + let slotAcquired = false; try { const body: ConvertBody = typeof req.body === "string" ? { markdown: req.body } : req.body; @@ -83,6 +99,12 @@ convertRouter.post("/markdown", async (req: Request, res: Response) => { return; } + // Acquire concurrency slot + if (req.acquirePdfSlot) { + await req.acquirePdfSlot(); + slotAcquired = true; + } + const html = markdownToHtml(body.markdown, body.css); const pdf = await renderPdf(html, { format: body.format, @@ -97,12 +119,21 @@ convertRouter.post("/markdown", async (req: Request, res: Response) => { res.send(pdf); } catch (err: any) { console.error("Convert MD error:", err); - if (err.message === "QUEUE_FULL") { const pool = getPoolStats(); res.status(429).json({ error: "Server busy", queueDepth: pool.queueDepth }); return; } res.status(500).json({ error: "PDF generation failed", detail: err.message }); + if (err.message === "QUEUE_FULL") { + res.status(429).json({ error: "Server busy - too many concurrent PDF generations. Please try again in a few seconds." }); + return; + } + res.status(500).json({ error: "PDF generation failed", detail: err.message }); + } finally { + if (slotAcquired && req.releasePdfSlot) { + req.releasePdfSlot(); + } } }); // POST /v1/convert/url -convertRouter.post("/url", async (req: Request, res: Response) => { +convertRouter.post("/url", async (req: Request & { acquirePdfSlot?: () => Promise; releasePdfSlot?: () => void }, res: Response) => { + let slotAcquired = false; try { const body = req.body as { url?: string; format?: string; landscape?: boolean; margin?: any; printBackground?: boolean; waitUntil?: string; filename?: string }; @@ -136,6 +167,12 @@ convertRouter.post("/url", async (req: Request, res: Response) => { return; } + // Acquire concurrency slot + if (req.acquirePdfSlot) { + await req.acquirePdfSlot(); + slotAcquired = true; + } + const pdf = await renderUrlPdf(body.url, { format: body.format, landscape: body.landscape, @@ -150,6 +187,14 @@ convertRouter.post("/url", async (req: Request, res: Response) => { res.send(pdf); } catch (err: any) { console.error("Convert URL error:", err); - if (err.message === "QUEUE_FULL") { const pool = getPoolStats(); res.status(429).json({ error: "Server busy", queueDepth: pool.queueDepth }); return; } res.status(500).json({ error: "PDF generation failed", detail: err.message }); + if (err.message === "QUEUE_FULL") { + res.status(429).json({ error: "Server busy - too many concurrent PDF generations. Please try again in a few seconds." }); + return; + } + res.status(500).json({ error: "PDF generation failed", detail: err.message }); + } finally { + if (slotAcquired && req.releasePdfSlot) { + req.releasePdfSlot(); + } } }); diff --git a/src/routes/recover.ts b/src/routes/recover.ts index f7a9031..8c0d934 100644 --- a/src/routes/recover.ts +++ b/src/routes/recover.ts @@ -8,7 +8,7 @@ const router = Router(); const recoverLimiter = rateLimit({ windowMs: 60 * 60 * 1000, - max: 5, + max: 3, message: { error: "Too many recovery attempts. Please try again in 1 hour." }, standardHeaders: true, legacyHeaders: false, diff --git a/state.json b/state.json new file mode 100644 index 0000000..90f9593 --- /dev/null +++ b/state.json @@ -0,0 +1,35 @@ +{ + "project": "DocFast", + "domain": "docfast.dev", + "server": "167.235.156.214", + "sshKey": "/home/openclaw/.ssh/docfast", + "repo": "openclawd/docfast", + "status": "live", + "version": "0.2.1", + "lastDeployment": "2026-02-14T22:17:00Z", + "lastQA": "2026-02-14T22:18:00Z", + "features": { + "htmlToPdf": true, + "markdownToPdf": true, + "urlToPdf": true, + "templates": true, + "signup": true, + "emailVerification": true, + "keyRecovery": true, + "emailChange": "frontend-only", + "swaggerDocs": true, + "browserPool": "1 browser × 15 pages", + "stripeIntegration": true + }, + "infrastructure": { + "webServer": "nginx", + "ssl": "letsencrypt", + "container": "docker-compose", + "email": "postfix + opendkim" + }, + "todos": [ + "Implement email change backend route (/v1/email-change + /v1/email-change/verify)", + "Set up staging environment for pre-production testing", + "Remove obsolete \\001@ file from repo" + ] +} From 0453176544f384ca3013e39ca1a9f756a87e082d Mon Sep 17 00:00:00 2001 From: OpenClaw Agent Date: Sun, 15 Feb 2026 09:50:04 +0000 Subject: [PATCH 2/3] Fix BUG-033: Update OpenAPI spec with Pro tier rate limit (30 req/min) Fix BUG-032: Add 375px mobile breakpoint for terminal gap issues - Reduced container padding to 12px on smallest screens - Optimized code-section margins and padding - Improved terminal header and code-block spacing - Enhanced hero section padding for mobile --- public/index.html | 21 +++++++++++++++++++++ public/openapi.json | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/public/index.html b/public/index.html index 065cf70..e2f3ccd 100644 --- a/public/index.html +++ b/public/index.html @@ -144,6 +144,27 @@ footer .container { display: flex; align-items: center; justify-content: space-b .trust-grid { gap: 24px; } } +/* Fix mobile terminal gaps at 375px and smaller */ +@media (max-width: 375px) { + .container { + padding: 0 12px !important; + } + .code-section { + margin: 32px auto 0; + max-width: calc(100vw - 24px) !important; + } + .code-header { + padding: 8px 12px; + } + .code-block { + padding: 12px !important; + font-size: 0.7rem; + } + .hero { + padding: 56px 0 40px; + } +} + /* Additional mobile overflow fixes */ html, body { overflow-x: hidden !important; diff --git a/public/openapi.json b/public/openapi.json index 811ada6..f3e5023 100644 --- a/public/openapi.json +++ b/public/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "DocFast API", "version": "1.0.0", - "description": "Convert HTML, Markdown, and URLs to pixel-perfect PDFs. Built-in invoice & receipt templates.\n\n## Authentication\nAll conversion and template endpoints require an API key via `Authorization: Bearer ` or `X-API-Key: ` header.\n\n## Rate Limits\n- Free tier: 100 PDFs/month, 10 req/min\n- Pro tier: 10,000 PDFs/month\n\n## Getting Started\n1. Sign up at [docfast.dev](https://docfast.dev) or via `POST /v1/signup/free`\n2. Verify your email with the 6-digit code\n3. Use your API key to convert documents", + "description": "Convert HTML, Markdown, and URLs to pixel-perfect PDFs. Built-in invoice & receipt templates.\n\n## Authentication\nAll conversion and template endpoints require an API key via `Authorization: Bearer ` or `X-API-Key: ` header.\n\n## Rate Limits\n- Free tier: 100 PDFs/month, 10 req/min\n- Pro tier: 10,000 PDFs/month, 30 req/min\n\n## Getting Started\n1. Sign up at [docfast.dev](https://docfast.dev) or via `POST /v1/signup/free`\n2. Verify your email with the 6-digit code\n3. Use your API key to convert documents", "contact": { "name": "DocFast", "url": "https://docfast.dev" } }, "servers": [{ "url": "https://docfast.dev", "description": "Production" }], From 7d3525fe57ea658c5f10cd02f3b94cdc74481ea7 Mon Sep 17 00:00:00 2001 From: openclawd Date: Sun, 15 Feb 2026 11:02:43 +0000 Subject: [PATCH 3/3] Add CI/CD deployment pipeline with Forgejo Actions - Add .forgejo/workflows/deploy.yml for automated deployment - Include rollback mechanism with image tagging - Add health check verification (http://127.0.0.1:3100/health) - Create manual rollback script for emergency use - Add deployment documentation and setup instructions - Supports auto-rollback on deployment failure --- .forgejo/workflows/deploy.yml | 100 ++++++++++++++++++++++++++++++++++ DEPLOYMENT.md | 77 ++++++++++++++++++++++++++ scripts/rollback.sh | 72 ++++++++++++++++++++++++ scripts/setup-secrets.sh | 41 ++++++++++++++ 4 files changed, 290 insertions(+) create mode 100644 .forgejo/workflows/deploy.yml create mode 100644 DEPLOYMENT.md create mode 100755 scripts/rollback.sh create mode 100755 scripts/setup-secrets.sh diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml new file mode 100644 index 0000000..61efbb4 --- /dev/null +++ b/.forgejo/workflows/deploy.yml @@ -0,0 +1,100 @@ +name: Deploy to Production + +on: + push: + branches: [ main ] + +jobs: + deploy: + name: Deploy to Server + runs-on: ubuntu-latest + + steps: + - name: Deploy via SSH + uses: appleboy/ssh-action@v1.1.0 + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USER }} + key: ${{ secrets.SSH_PRIVATE_KEY }} + script: | + set -e + + echo "🚀 Starting deployment..." + + # Navigate to project directory + cd /root/docfast + + # Check current git status + echo "📋 Current status:" + git status --short || true + + # Pull latest changes + echo "📥 Pulling latest changes..." + git fetch origin + git pull origin main + + # Tag current running image for rollback + echo "🏷️ Tagging current image for rollback..." + TIMESTAMP=$(date +%Y%m%d-%H%M%S) + docker tag docfast-docfast:latest docfast-docfast:rollback-$TIMESTAMP || echo "No existing image to tag" + + # Build new image + echo "🔨 Building new Docker image..." + docker compose build --no-cache + + # Stop services gracefully + echo "⏹️ Stopping services..." + docker compose down --timeout 30 + + # Start services + echo "▶️ Starting services..." + docker compose up -d + + # Wait for service to be ready + echo "⏱️ Waiting for service to be ready..." + for i in {1..30}; do + if curl -f -s http://127.0.0.1:3100/health > /dev/null; then + echo "✅ Service is healthy!" + break + fi + if [ $i -eq 30 ]; then + echo "❌ Service failed to start - initiating rollback..." + docker compose down + + # Try to rollback to previous image + ROLLBACK_IMAGE=$(docker images --format "table {{.Repository}}:{{.Tag}}" | grep "docfast-docfast:rollback-" | head -n1 | tr -s ' ' | cut -d' ' -f1) + if [ ! -z "$ROLLBACK_IMAGE" ]; then + echo "🔄 Rolling back to $ROLLBACK_IMAGE" + docker tag $ROLLBACK_IMAGE docfast-docfast:latest + docker compose up -d + + # Wait for rollback to be healthy + sleep 10 + if curl -f -s http://127.0.0.1:3100/health > /dev/null; then + echo "✅ Rollback successful" + else + echo "❌ Rollback failed - manual intervention required" + fi + else + echo "❌ No rollback image available" + fi + + exit 1 + fi + echo "⏳ Attempt $i/30 - waiting 5 seconds..." + sleep 5 + done + + # Cleanup old rollback images (keep last 5) + echo "🧹 Cleaning up old rollback images..." + docker images --format "table {{.Repository}}:{{.Tag}}" | grep "docfast-docfast:rollback-" | tail -n +6 | awk '{print $1":"$2}' | xargs -r docker rmi || true + + # Final health check and status + echo "🔍 Final health check..." + HEALTH_STATUS=$(curl -f -s http://127.0.0.1:3100/health || echo "UNHEALTHY") + echo "Health status: $HEALTH_STATUS" + + echo "📊 Service status:" + docker compose ps + + echo "🎉 Deployment completed successfully!" \ No newline at end of file diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..f9ca614 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,77 @@ +# DocFast CI/CD Deployment + +This repository uses Forgejo Actions for automated deployment to production. + +## Setup Instructions + +### 1. Repository Secrets + +Go to repository settings → Actions → Secrets and add these secrets: + +- **SERVER_HOST**: `167.235.156.214` +- **SERVER_USER**: `root` +- **SSH_PRIVATE_KEY**: The private SSH key content from `/home/openclaw/.ssh/docfast` + +### 2. How Deployment Works + +**Trigger**: Push to `main` branch +**Process**: +1. SSH to production server +2. Pull latest code from git +3. Tag current Docker image for rollback +4. Build new Docker image +5. Stop current services +6. Start new services +7. Health check at `http://127.0.0.1:3100/health` +8. Rollback automatically if health check fails + +### 3. Rollback Procedure + +**Automatic Rollback**: +- Happens automatically if deployment fails health checks +- Reverts to the previously tagged image + +**Manual Rollback**: +```bash +# On the production server +cd /root/docfast +./scripts/rollback.sh +``` + +**Emergency Rollback via SSH**: +```bash +ssh root@167.235.156.214 +cd /root/docfast +docker compose down +docker tag docfast-docfast:rollback-YYYYMMDD-HHMMSS docfast-docfast:latest +docker compose up -d +``` + +### 4. Monitoring + +- **Health Check**: `curl http://127.0.0.1:3100/health` +- **Service Status**: `docker compose ps` +- **Logs**: `docker compose logs -f` + +### 5. File Structure + +``` +.forgejo/workflows/deploy.yml # Main deployment workflow +scripts/rollback.sh # Manual rollback script +scripts/setup-secrets.sh # Helper for setting up secrets +DEPLOYMENT.md # This documentation +``` + +### 6. Testing the Pipeline + +1. Make a small change (e.g., bump version comment) +2. Commit and push to main branch +3. Check Actions tab in Forgejo to see deployment progress +4. Verify service is running with `curl http://127.0.0.1:3100/health` + +## Troubleshooting + +- **SSH Issues**: Ensure SSH key is properly added to secrets +- **Docker Build Issues**: Check server has enough disk space and memory +- **Health Check Fails**: Check if service is binding to correct port (3100) +- **Permission Issues**: Ensure user has Docker privileges on server \ No newline at end of file diff --git a/scripts/rollback.sh b/scripts/rollback.sh new file mode 100755 index 0000000..4004a31 --- /dev/null +++ b/scripts/rollback.sh @@ -0,0 +1,72 @@ +#!/bin/bash +set -e + +echo "🔄 DocFast Rollback Script" +echo "==========================" + +# Check if we're on the server +if [ ! -d "/root/docfast" ]; then + echo "❌ This script should be run on the production server" + exit 1 +fi + +cd /root/docfast + +# List available rollback images +echo "📋 Available rollback images:" +ROLLBACK_IMAGES=$(docker images --format "table {{.Repository}}:{{.Tag}}\t{{.CreatedAt}}" | grep "docfast-docfast:rollback-" | head -10) + +if [ -z "$ROLLBACK_IMAGES" ]; then + echo "❌ No rollback images available" + exit 1 +fi + +echo "$ROLLBACK_IMAGES" +echo "" + +# Get the most recent rollback image +LATEST_ROLLBACK=$(docker images --format "{{.Repository}}:{{.Tag}}" | grep "docfast-docfast:rollback-" | head -n1) + +if [ -z "$LATEST_ROLLBACK" ]; then + echo "❌ No rollback image found" + exit 1 +fi + +echo "🎯 Will rollback to: $LATEST_ROLLBACK" +echo "" + +# Confirm rollback +read -p "⚠️ Are you sure you want to rollback? (y/N): " -n 1 -r +echo +if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo "❌ Rollback cancelled" + exit 1 +fi + +echo "🛑 Stopping current services..." +docker compose down --timeout 30 + +echo "🔄 Rolling back to $LATEST_ROLLBACK..." +docker tag $LATEST_ROLLBACK docfast-docfast:latest + +echo "▶️ Starting services..." +docker compose up -d + +echo "⏱️ Waiting for service to be ready..." +for i in {1..20}; do + if curl -f -s http://127.0.0.1:3100/health > /dev/null; then + echo "✅ Rollback successful! Service is healthy." + break + fi + if [ $i -eq 20 ]; then + echo "❌ Rollback failed - service is not responding" + exit 1 + fi + echo "⏳ Attempt $i/20 - waiting 3 seconds..." + sleep 3 +done + +echo "📊 Service status:" +docker compose ps + +echo "🎉 Rollback completed successfully!" \ No newline at end of file diff --git a/scripts/setup-secrets.sh b/scripts/setup-secrets.sh new file mode 100755 index 0000000..90c7cae --- /dev/null +++ b/scripts/setup-secrets.sh @@ -0,0 +1,41 @@ +#!/bin/bash + +echo "🔐 Forgejo Repository Secrets Setup" +echo "====================================" + +# Source credentials to get Forgejo token +source /home/openclaw/.openclaw/workspace/.credentials/docfast.env + +# Repository secrets to set up +REPO_URL="https://git.cloonar.com/api/v1/repos/openclawd/docfast/actions/secrets" + +echo "Setting up repository secrets for CI/CD..." + +# Server host +echo "📡 Setting SERVER_HOST..." +curl -X PUT "$REPO_URL/SERVER_HOST" \ + -H "Authorization: token $FORGEJO_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"data":"167.235.156.214"}' \ + --silent + +# Server user +echo "👤 Setting SERVER_USER..." +curl -X PUT "$REPO_URL/SERVER_USER" \ + -H "Authorization: token $FORGEJO_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"data":"root"}' \ + --silent + +# SSH Private Key +echo "🔑 Setting SSH_PRIVATE_KEY..." +SSH_KEY_CONTENT=$(cat /home/openclaw/.ssh/docfast | jq -Rs .) +curl -X PUT "$REPO_URL/SSH_PRIVATE_KEY" \ + -H "Authorization: token $FORGEJO_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"data\":$SSH_KEY_CONTENT}" \ + --silent + +echo "✅ Repository secrets have been configured!" +echo "" +echo "🔍 To verify, check: https://git.cloonar.com/openclawd/docfast/settings/actions/secrets" \ No newline at end of file