diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..cacf5e1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,53 @@ +--- +name: Bug Report +about: Report a bug to help us improve Dequel +title: "[Bug]: " +labels: bug, triage +assignees: "" +--- + +## Description + +A clear and concise description of what the bug is. + +## Steps to Reproduce + +1. Go to '...' +2. Click on '...' +3. Scroll down to '...' +4. See error + +## Expected Behavior + +What did you expect to happen? + +## Actual Behavior + +What actually happened? + +## Screenshots / Logs + +If applicable, add screenshots or relevant logs. + +## Environment + +- **Dequel Version**: (check `VERSION` or `scripts/dequel status`) +- **OS**: +- **Docker version**: +- **Bun version** (if relevant): +- **Browser** (if relevant): + +## Affected Component + +- [ ] API (Backend) +- [ ] Web Dashboard (Frontend) +- [ ] Docs +- [ ] CLI / Install Script +- [ ] Docker / Deployment +- [ ] Build System (Railpack / BuildKit) +- [ ] Caddy / Ingress +- [ ] Monitoring (Prometheus / Grafana / Loki) + +## Additional Context + +Add any other context, workarounds, or related issues. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..cd40805 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,41 @@ +--- +name: Feature Request +about: Suggest an idea for Dequel +title: "[Feature]: " +labels: enhancement +assignees: "" +--- + +## Problem Statement + +Is your feature request related to a problem? Please describe what you're trying to solve. + +## Proposed Solution + +A clear and concise description of what you want to happen. + +## Alternative Solutions + +Any alternative solutions or features you've considered. + +## Affected Component + +- [ ] API (Backend) +- [ ] Web Dashboard (Frontend) +- [ ] Docs +- [ ] CLI / Install Script +- [ ] Build System (Railpack / BuildKit) +- [ ] Caddy / Ingress +- [ ] Monitoring (Prometheus / Grafana / Loki) + +## Mockups / Examples + +If applicable, add mockups, diagrams, or examples from other projects. + +## Additional Context + +Add any other context or screenshots. + +## Would you like to implement this? + +- [ ] Yes, I'd be happy to submit a PR diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..c5bbfa3 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,43 @@ +## Description + +Please provide a summary of the changes and the motivation behind them. What problem does this PR solve? + +Fixes #(issue) + +## Type of Change + +- [ ] Bug fix (non-breaking change that fixes an issue) +- [ ] New feature (non-breaking change that adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] Documentation update +- [ ] Refactor (no functional changes) +- [ ] CI / Build / Tooling +- [ ] Other (please describe): + +## How Has This Been Tested? + +Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. + +- [ ] Existing tests pass (`bun test` in `apps/api/`) +- [ ] New tests added (if applicable) +- [ ] Manual testing performed (describe steps) + +## Checklist + +- [ ] My code follows the project's code style (no comments, named exports, functional components, etc.) +- [ ] I have read the [contributing guidelines](../CONTRIBUTING.md) +- [ ] I have added tests that prove my fix is effective or that my feature works +- [ ] I have updated the documentation (if applicable) +- [ ] My changes generate no new warnings or lint errors +- [ ] I have run `bun test` in `apps/api/` and all tests pass +- [ ] I have synced the VERSION file if needed (`bun run sync-versions`) + +## Screenshots (if applicable) + +| Before | After | +|--------|-------| +| (insert here) | (insert here) | + +## Additional Context + +Add any other context about the PR here (e.g., migration notes, deployment considerations, rollback strategy). diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 19c64a9..581fc02 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -71,11 +71,7 @@ jobs: cp docker-compose.yml "$TAR_DIR/" cp scripts/dequel "$TAR_DIR/dequel" cp infra/caddy/Caddyfile "$TAR_DIR/infra/caddy/" - cp infra/monitoring/prometheus.yml "$TAR_DIR/infra/monitoring/" - cp infra/monitoring/loki-config.yml "$TAR_DIR/infra/monitoring/" - cp infra/monitoring/promtail-config.yml "$TAR_DIR/infra/monitoring/" - cp infra/monitoring/grafana/datasources/loki.yml "$TAR_DIR/infra/monitoring/grafana/datasources/" - cp infra/monitoring/grafana/datasources/prometheus.yml "$TAR_DIR/infra/monitoring/grafana/datasources/" + cp -r infra/monitoring "$TAR_DIR/infra/" cd "$TAR_DIR" && tar -czf "../dequel-config-${VERSION}.tar.gz" . - name: Create GitHub Release diff --git a/.github/workflows/vuln-scan.yml b/.github/workflows/vuln-scan.yml new file mode 100644 index 0000000..c83645d --- /dev/null +++ b/.github/workflows/vuln-scan.yml @@ -0,0 +1,50 @@ +name: Security Scans + +on: + push: + pull_request: + workflow_dispatch: + +concurrency: + group: security-scans-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: read + +jobs: + forbidden-pattern-scan: + name: Forbidden Pattern Scan + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + + - name: Run forbidden pattern scan + run: bash scripts/workflow/forbidden-pattern-scan.sh "${{ github.workspace }}" + + lazarus-scanner: + name: Lazarus Scanner + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Download lazarus_scanner.py + run: | + curl -fsSL -o lazarus_scanner.py \ + https://raw.githubusercontent.com/hngprojects/lazarus-scanner/b1367e3a7a1463fbef90919867555a73f6acdf66/lazarus_scanner.py + echo "ddbb2181479f9a07d5859ceeac9160fd45084d28d95311ce49fc63fc1959e831 lazarus_scanner.py" | sha256sum --check || { echo "FAIL: SHA256 mismatch"; exit 1; } + + - name: Run Lazarus scanner + run: python3 lazarus_scanner.py diff --git a/.gitignore b/.gitignore index 3b0d81f..4578e81 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ infra/caddy/routes/ bugs apps/api/index docker-compose.yml +bump.sh +scripts/workflow/bump.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index f781212..c2c89aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.1] - 2026-06-19 + +### Added + +- Per-project Grafana dashboards automatically created on successful deployment +- Configurable `CADDY_BASE_DOMAIN` for public ingress with automatic Let's Encrypt SSL +- Dynamic `railpack.json` generation with deployment abort support +- GitHub webhook management and project management API endpoints +- Project source and port configuration options +- SMTP configuration and system settings API + +### Changed + +- Monitoring stack hardened: Prometheus now validates TSDB blocks and quarantines corrupted ones on startup; Promtail scoped to `dequel_net` network; Grafana datasources use stable UIDs for reliable dashboard provisioning +- `PUBLIC_URL` is now derived from `CADDY_BASE_DOMAIN` instead of requiring separate configuration +- Refactored infrastructure monitoring configs into dedicated files for maintainability + +### Fixed + +- Container network reconciliation now force-disconnects stale network references before starting containers, preventing Docker network ID changes from breaking deployments + +### Documentation + +- Installation guide, quickstart, and system configuration docs updated for `CADDY_BASE_DOMAIN` + ## [0.1.0] - 2026-06-08 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..b4d8d89 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,107 @@ +# Contributing to Dequel + +Thank you for your interest in contributing! Here's how to get started. + +## Getting Started + +1. Fork and clone the repo +2. Install dependencies: `bun install` (from repo root — needed for tests, version syncing, and local tooling) +3. Read [`AGENTS.md`](./AGENTS.md) for the full architecture and conventions + +## Reporting Bugs + +Open a [Bug Report](https://github.com/Lftobs/dequel/issues/new?template=bug_report.md). Include: + +- Steps to reproduce +- Expected vs actual behavior +- Dequel version (`VERSION` file or `scripts/dequel status`) +- Environment details (OS, Docker version, browser if relevant) + +## Suggesting Features + +Open a [Feature Request](https://github.com/Lftobs/dequel/issues/new?template=feature_request.md). Describe: + +- The problem you're solving +- Your proposed solution +- Any alternatives considered +- Whether you'd like to implement it yourself + +## Development + +### Running Locally + +Run the full stack with Docker Compose: + +```bash +docker compose up -d --build +``` + +This starts all services (API, Web, Caddy, BuildKit, Redis, Prometheus, Loki, Grafana). The dashboard is at `https://localhost`, API at `https://localhost/api`, Grafana at `https://localhost/grafana` (admin/admin). + +### Code Conventions + +- **No comments** in source code unless absolutely necessary +- **Named exports** over default exports +- **Functional components + hooks** in React +- **Tailwind CSS** for styling (web and docs) +- Max ~500 lines per file — split into feature-grouped directories +- `set -euo pipefail` in all bash scripts + +### Database Migrations + +Run from `apps/api/`: + +```bash +cd apps/api +bunx drizzle-kit generate +bunx drizzle-kit push +``` + +### Testing + +Run from `apps/api/`: + +```bash +cd apps/api && bun test +``` + +Always run tests before committing API changes. + +### Versioning + +```bash +./bump.sh v0.2.0 +``` + +This updates `VERSION`, all `package.json` files, and optionally adds a changelog entry. + +## Pull Requests + +1. Create a PR from your fork using the [PR template](./.github/PULL_REQUEST_TEMPLATE.md) +2. Ensure all tests pass (`cd apps/api && bun test`) +3. Keep changes focused — one feature/fix per PR +4. Update documentation if your change affects user-facing behavior +5. If changing API behavior, update the docs site content + +### PR Checklist + +- [ ] Tests pass (`cd apps/api && bun test`) +- [ ] No new warnings or lint errors +- [ ] Documentation updated (if applicable) +- [ ] Version synced (`bun run sync-versions`) if `VERSION` changed +- [ ] Follows code conventions (no comments, named exports, etc.) + +## Release Process + +Maintainers cut releases by tagging: + +```bash +git tag vX.Y.Z +git push origin vX.Y.Z +``` + +CI builds Docker images to `ghcr.io/lftobs/dequel/{api,web}:X.Y.Z`, deploys docs to Vercel, and creates a GitHub Release. + +## Questions? + +Open a [Discussion](https://github.com/Lftobs/dequel/discussions) for questions and community support. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..925f81e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Lftobs + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/VERSION b/VERSION index 6e8bf73..17e51c3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0 +0.1.1 diff --git a/apps/api/package.json b/apps/api/package.json index 210da08..66d9c11 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,6 +1,6 @@ { "name": "dequel-api", - "version": "0.1.0", + "version": "0.1.1", "private": true, "type": "module", "scripts": { diff --git a/apps/api/src/api/projects/index.ts b/apps/api/src/api/projects/index.ts index 11473a7..0c5045d 100644 --- a/apps/api/src/api/projects/index.ts +++ b/apps/api/src/api/projects/index.ts @@ -7,6 +7,7 @@ import { getProjectById, updateProject, deleteProject, + listDomains, } from "../../db/repo"; import { tryRun, reloadCaddy } from "../../orchestrator/runtime"; import { removeFromCaddyRoute } from "../../utils/domain-verifier"; diff --git a/apps/api/src/orchestrator/pipeline.ts b/apps/api/src/orchestrator/pipeline.ts index 34367ad..6f82a91 100644 --- a/apps/api/src/orchestrator/pipeline.ts +++ b/apps/api/src/orchestrator/pipeline.ts @@ -26,6 +26,7 @@ import { reloadCaddy, tryRun, } from "./runtime"; +import { ensureProjectDashboard } from "../utils/grafana"; const now = () => new Date() @@ -542,6 +543,24 @@ export class PipelineOrchestrator { }, ); + if (projectName) { + const dashSlug = projectName + .toLowerCase() + .replace(/[^a-z0-9-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 63); + const containerRegex = `${dashSlug}-.*`; + ensureProjectDashboard( + projectName, + containerRegex, + ).catch((e) => + console.warn( + "[Pipeline] Grafana dashboard creation failed:", + e, + ), + ); + } + if ( oldContainerName && deployment.projectId @@ -626,14 +645,22 @@ export class PipelineOrchestrator { return false; } finally { this.abortControllers.delete(deploymentId); - if (workspacePath) - await cleanupWorkspace( - workspacePath, - ); - if (uploadedArchivePath) - await rm(uploadedArchivePath, { - force: true, - }); + try { + if (workspacePath) + await cleanupWorkspace( + workspacePath, + ); + } catch { + // cleanup failures must never mask the deployment result + } + try { + if (uploadedArchivePath) + await rm(uploadedArchivePath, { + force: true, + }); + } catch { + // cleanup failures must never mask the deployment result + } } } diff --git a/apps/api/src/orchestrator/runtime.ts b/apps/api/src/orchestrator/runtime.ts index 22e7422..30e7f4b 100644 --- a/apps/api/src/orchestrator/runtime.ts +++ b/apps/api/src/orchestrator/runtime.ts @@ -75,6 +75,7 @@ const waitForRunningContainer = async ( await new Promise(r => setTimeout(r, 500)); } await onLog(`Container ${containerName} did not reach running state — attempting docker start`); + await tryRun(dockerBin, ['network', 'disconnect', '-f', config.dockerNetwork, containerName]); await tryRun(dockerBin, ['start', containerName]); await tryRun(dockerBin, ['network', 'connect', config.dockerNetwork, containerName]); }; @@ -82,7 +83,10 @@ const waitForRunningContainer = async ( export const ensureContainerRunning = async (containerName: string) => { try { const status = (await run(dockerBin, ['inspect', '-f', '{{.State.Status}}', containerName])).trim(); - if (status !== 'running') await run(dockerBin, ['start', containerName]); + if (status !== 'running') { + await tryRun(dockerBin, ['network', 'disconnect', '-f', config.dockerNetwork, containerName]); + await run(dockerBin, ['start', containerName]); + } await tryRun(dockerBin, ['network', 'connect', config.dockerNetwork, containerName]); } catch (error) { console.error(`Failed to reconcile container ${containerName}:`, error); diff --git a/apps/api/src/utils/config.ts b/apps/api/src/utils/config.ts index 8c6772e..f6c8dd4 100644 --- a/apps/api/src/utils/config.ts +++ b/apps/api/src/utils/config.ts @@ -1,43 +1,124 @@ -import { loadFileConfig, type FileConfig } from "./config-loader"; +import { loadFileConfig } from "./config-loader"; const fileConfig = loadFileConfig(); const withFile = ( - key: string, - envDefault: string, - transform?: (v: string) => T, + key: string, + envDefault: string, + transform?: (v: string) => T, ): T => { - const envVal = process.env[key]; - if (envVal !== undefined) { - return transform ? transform(envVal) : (envVal as unknown as T); - } - const fileVal = (fileConfig as Record)[key]; - if (fileVal !== undefined) return fileVal as T; - return transform ? transform(envDefault) : (envDefault as unknown as T); + const envVal = process.env[key]; + if (envVal !== undefined) { + return transform + ? transform(envVal) + : (envVal as unknown as T); + } + const fileVal = ( + fileConfig as Record + )[key]; + if (fileVal !== undefined) + return fileVal as T; + return transform + ? transform(envDefault) + : (envDefault as unknown as T); }; +const SYSTEM = { + databasePath: "/app/data/dequel.db", + workspaceRoot: "/app/workspace", + caddyRoutesDir: "/caddy/routes", + dockerNetwork: "dequel_net", + buildkitHost: "tcp://buildkit:1234", + redisUrl: "redis://redis:6379", +} as const; + export const config = { - port: withFile("PORT", "3001", Number), - databasePath: withFile("DATABASE_PATH", "/app/data/dequel.db"), - workspaceRoot: withFile("WORKSPACE_ROOT", "/app/workspace"), - caddyRoutesDir: withFile("CADDY_ROUTES_DIR", "/caddy/routes"), - caddyBaseDomain: withFile("CADDY_BASE_DOMAIN", "localhost"), - dockerNetwork: withFile("DOCKER_NETWORK", "dequel_net"), - appInternalPort: withFile("APP_INTERNAL_PORT", "3000", Number), - buildkitHost: withFile("BUILDKIT_HOST", "tcp://buildkit:1234"), - envEncryptionKey: withFile("ENV_ENCRYPTION_KEY", "dev-env-key-change-me"), - redisUrl: withFile("REDIS_URL", "redis://redis:6379"), - queueConcurrency: withFile("QUEUE_CONCURRENCY", "1", Number), - queueRetryMax: withFile("QUEUE_RETRY_MAX", "5", Number), - queueRetryBaseMs: withFile("QUEUE_RETRY_BASE_MS", "5000", Number), - smtpHost: withFile("SMTP_HOST", ""), - smtpPort: withFile("SMTP_PORT", "587", Number), - smtpUser: withFile("SMTP_USER", ""), - smtpPass: withFile("SMTP_PASS", ""), - smtpFrom: withFile("SMTP_FROM", "dequel@localhost"), - alertEvalIntervalMs: withFile("ALERT_EVAL_INTERVAL_MS", "60000", Number), - githubClientId: withFile("GITHUB_CLIENT_ID", ""), - githubClientSecret: withFile("GITHUB_CLIENT_SECRET", ""), - githubAppName: withFile("GITHUB_APP_NAME", "Dequel"), - githubWebhookSecret: withFile("GITHUB_WEBHOOK_SECRET", ""), + ...SYSTEM, + port: withFile( + "PORT", + "17474", + Number, + ), + caddyBaseDomain: withFile( + "CADDY_BASE_DOMAIN", + "localhost", + ), + appInternalPort: withFile( + "APP_INTERNAL_PORT", + "17476", + Number, + ), + envEncryptionKey: withFile( + "ENV_ENCRYPTION_KEY", + "dev-env-key-change-me", + ), + queueConcurrency: withFile( + "QUEUE_CONCURRENCY", + "3", + Number, + ), + queueRetryMax: withFile( + "QUEUE_RETRY_MAX", + "5", + Number, + ), + queueRetryBaseMs: withFile( + "QUEUE_RETRY_BASE_MS", + "5000", + Number, + ), + smtpHost: withFile( + "SMTP_HOST", + "", + ), + smtpPort: withFile( + "SMTP_PORT", + "587", + Number, + ), + smtpUser: withFile( + "SMTP_USER", + "", + ), + smtpPass: withFile( + "SMTP_PASS", + "", + ), + smtpFrom: withFile( + "SMTP_FROM", + "dequel@localhost", + ), + alertEvalIntervalMs: withFile( + "ALERT_EVAL_INTERVAL_MS", + "60000", + Number, + ), + githubClientId: withFile( + "GITHUB_CLIENT_ID", + "", + ), + githubClientSecret: withFile( + "GITHUB_CLIENT_SECRET", + "", + ), + githubAppName: withFile( + "GITHUB_APP_NAME", + "Dequel", + ), + githubWebhookSecret: withFile( + "GITHUB_WEBHOOK_SECRET", + "", + ), + grafanaUrl: withFile( + "GRAFANA_URL", + "http://grafana:3000", + ), + grafanaUser: withFile( + "GRAFANA_USER", + "admin", + ), + grafanaPass: withFile( + "GRAFANA_PASS", + "admin", + ), }; diff --git a/apps/api/src/utils/grafana.ts b/apps/api/src/utils/grafana.ts new file mode 100644 index 0000000..08304bd --- /dev/null +++ b/apps/api/src/utils/grafana.ts @@ -0,0 +1,239 @@ +import { config } from "./config"; + +interface GrafanaDashboard { + dashboard: { + title: string; + uid: string; + tags: string[]; + schemaVersion: number; + version: number; + timezone: string; + refresh: string; + panels: unknown[]; + }; + overwrite: boolean; +} + +let sessionCookie: string | null = null; +let sessionExpires = 0; + +async function grafanaLogin(): Promise { + if (sessionCookie && Date.now() < sessionExpires) { + return sessionCookie; + } + try { + const res = await fetch(`${config.grafanaUrl}/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + user: config.grafanaUser, + password: config.grafanaPass, + }), + }); + const setCookie = res.headers.get("set-cookie"); + if (!setCookie) return null; + sessionCookie = setCookie.split(";")[0]; + sessionExpires = Date.now() + 60 * 60 * 1000; + return sessionCookie; + } catch { + return null; + } +} + +async function grafanaPost( + path: string, + body: unknown, +): Promise { + const cookie = await grafanaLogin(); + if (!cookie) return null; + try { + const res = await fetch(`${config.grafanaUrl}/api${path}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Cookie: cookie, + }, + body: JSON.stringify(body), + }); + return await res.json(); + } catch { + return null; + } +} + +export async function ensureProjectDashboard( + projectName: string, + containerRegex: string, +): Promise { + const slug = projectName + .toLowerCase() + .replace(/[^a-z0-9-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 63); + + const dashboard: GrafanaDashboard = { + dashboard: { + title: `Dequel \u2014 ${projectName}`, + uid: `dequel-project-${slug}`, + tags: ["dequel", "project", slug], + schemaVersion: 39, + version: 1, + timezone: "browser", + refresh: "30s", + panels: [ + { + type: "row", + title: "Resource Usage", + collapsed: false, + gridPos: { h: 1, w: 24, x: 0, y: 0 }, + }, + { + id: 1, + type: "timeseries", + title: "CPU Usage", + datasource: { type: "prometheus", uid: "prometheus" }, + gridPos: { h: 9, w: 12, x: 0, y: 1 }, + fieldConfig: { + defaults: { + unit: "short", + custom: { + stacking: { mode: "normal" }, + fillOpacity: 30, + lineWidth: 1, + }, + }, + overrides: [], + }, + options: { + legend: { + displayMode: "table", + placement: "right", + showLegend: true, + }, + tooltip: { mode: "multi" }, + }, + targets: [ + { + datasource: { type: "prometheus", uid: "prometheus" }, + expr: `rate(container_cpu_usage_seconds_total{name=~"${containerRegex}"}[$__rate_interval])`, + legendFormat: "{{name}}", + refId: "A", + }, + ], + }, + { + id: 2, + type: "timeseries", + title: "Memory Usage", + datasource: { type: "prometheus", uid: "prometheus" }, + gridPos: { h: 9, w: 12, x: 12, y: 1 }, + fieldConfig: { + defaults: { + unit: "bytes", + custom: { + stacking: { mode: "normal" }, + fillOpacity: 30, + lineWidth: 1, + }, + }, + overrides: [], + }, + options: { + legend: { + displayMode: "table", + placement: "right", + showLegend: true, + }, + tooltip: { mode: "multi" }, + }, + targets: [ + { + datasource: { type: "prometheus", uid: "prometheus" }, + expr: `container_memory_working_set_bytes{name=~"${containerRegex}"}`, + legendFormat: "{{name}}", + refId: "A", + }, + ], + }, + { + type: "row", + title: "Request Metrics", + collapsed: false, + gridPos: { h: 1, w: 24, x: 0, y: 10 }, + }, + { + id: 4, + type: "timeseries", + title: "Request Rate", + datasource: { type: "loki", uid: "loki" }, + gridPos: { h: 9, w: 24, x: 0, y: 11 }, + fieldConfig: { + defaults: { + unit: "reqps", + custom: { + fillOpacity: 30, + lineWidth: 1, + }, + }, + overrides: [], + }, + options: { + legend: { + displayMode: "table", + placement: "right", + showLegend: true, + }, + tooltip: { mode: "multi" }, + }, + targets: [ + { + datasource: { type: "loki", uid: "loki" }, + expr: `sum by(host) (count_over_time({container=~"${containerRegex}"} | json [5m]))`, + legendFormat: "{{host}}", + refId: "A", + }, + ], + }, + { + type: "row", + title: "Logs", + collapsed: false, + gridPos: { h: 1, w: 24, x: 0, y: 20 }, + }, + { + id: 3, + type: "logs", + title: "Container Logs", + datasource: { type: "loki", uid: "loki" }, + gridPos: { h: 12, w: 24, x: 0, y: 21 }, + options: { + showLabels: true, + showTime: true, + wrapLogMessage: true, + enableLogDetails: true, + dedupStrategy: "none", + }, + targets: [ + { + datasource: { type: "loki", uid: "loki" }, + expr: `{container=~"${containerRegex}"}`, + refId: "A", + }, + ], + }, + ], + }, + overwrite: true, + }; + + const result = await grafanaPost("/dashboards/db", dashboard); + if (result) { + console.log( + `[Grafana] Dashboard created/updated for ${projectName}`, + ); + } else { + console.warn( + `[Grafana] Failed to create dashboard for ${projectName}`, + ); + } +} diff --git a/apps/docs/package.json b/apps/docs/package.json index cc2b340..136545a 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -1,7 +1,7 @@ { "name": "dequel-docs", "type": "module", - "version": "0.1.0", + "version": "0.1.1", "scripts": { "dev": "astro dev", "start": "astro dev", diff --git a/apps/web/package.json b/apps/web/package.json index bc71984..cea64d6 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "dequel-web", - "version": "0.1.0", + "version": "0.1.1", "private": true, "type": "module", "scripts": { diff --git a/docker-compose.yml b/docker-compose.yml index df049a8..dbb51b9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,11 +18,8 @@ services: healthcheck: test: [ - "CMD", - "wget", - "--spider", - "-q", - "tcp://localhost:1234", + "CMD-SHELL", + "wget -q -T 2 -O /dev/null http://localhost:1234 >/dev/null 2>&1; [ $$? -ne 4 ]", ] interval: 5s timeout: 3s @@ -104,7 +101,7 @@ services: "--watch", ] environment: - CADDY_EMAIL: ${CADDY_EMAIL:-} + CADDY_EMAIL: ${CADDY_EMAIL:-admin@dequel.local} CADDY_BASE_DOMAIN: ${CADDY_BASE_DOMAIN:-localhost} ports: - "80:80" @@ -166,13 +163,13 @@ services: prometheus: image: prom/prometheus:latest + stop_grace_period: 60s + entrypoint: [] command: - - --config.file=/etc/prometheus/prometheus.yml - - --storage.tsdb.path=/prometheus - - --web.console.libraries=/usr/share/prometheus/console_libraries - - --web.console.templates=/usr/share/prometheus/consoles - - --storage.tsdb.retention.time=30d + - sh + - /entrypoint.sh volumes: + - ./infra/monitoring/prometheus-entrypoint.sh:/entrypoint.sh:ro - ./infra/monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro - prometheus-data:/prometheus depends_on: @@ -229,6 +226,7 @@ services: - ./infra/monitoring/promtail-config.yml:/etc/promtail/promtail-config.yml:ro - /var/run/docker.sock:/var/run/docker.sock - /var/lib/docker/containers:/var/lib/docker/containers:ro + - promtail-data:/data depends_on: loki: condition: service_started @@ -245,6 +243,7 @@ services: GF_INSTALL_PLUGINS: "" volumes: - ./infra/monitoring/grafana/datasources:/etc/grafana/provisioning/datasources:ro + - ./infra/monitoring/grafana/dashboards:/etc/grafana/provisioning/dashboards:ro - grafana-data:/var/lib/grafana depends_on: prometheus: @@ -279,3 +278,4 @@ volumes: prometheus-data: loki-data: grafana-data: + promtail-data: diff --git a/infra/caddy/Caddyfile b/infra/caddy/Caddyfile index 6b34c41..0dcac11 100644 --- a/infra/caddy/Caddyfile +++ b/infra/caddy/Caddyfile @@ -1,5 +1,5 @@ { - email {$CADDY_EMAIL} + email {$CADDY_EMAIL:admin@dequel.local} } import /etc/caddy/routes/*.caddy diff --git a/infra/monitoring/grafana/dashboards/dashboards.yml b/infra/monitoring/grafana/dashboards/dashboards.yml new file mode 100644 index 0000000..dd16c25 --- /dev/null +++ b/infra/monitoring/grafana/dashboards/dashboards.yml @@ -0,0 +1,11 @@ +apiVersion: 1 + +providers: + - name: "Dequel" + orgId: 1 + folder: "" + type: file + disableDeletion: true + editable: false + options: + path: /etc/grafana/provisioning/dashboards diff --git a/infra/monitoring/grafana/dashboards/deployed-apps.json b/infra/monitoring/grafana/dashboards/deployed-apps.json new file mode 100644 index 0000000..dc9fc72 --- /dev/null +++ b/infra/monitoring/grafana/dashboards/deployed-apps.json @@ -0,0 +1,157 @@ +{ + "title": "Dequel — Deployed Apps", + "uid": "dequel-deployed-apps", + "tags": ["dequel"], + "schemaVersion": 39, + "version": 1, + "timezone": "browser", + "refresh": "30s", + "templating": { + "list": [ + { + "name": "container", + "type": "query", + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "query": { + "query": "label_values(container_cpu_usage_seconds_total{job=\"cadvisor\"}, name)", + "refId": "container-var" + }, + "regex": "/([^/]+$)", + "sort": 1, + "multi": true, + "includeAll": true, + "allValue": ".*", + "refresh": 1, + "hide": 0, + "current": { + "text": "All", + "value": "$__all" + } + } + ] + }, + "panels": [ + { + "type": "row", + "title": "Resource Usage", + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 } + }, + { + "id": 1, + "type": "timeseries", + "title": "CPU Usage", + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "gridPos": { "h": 9, "w": 12, "x": 0, "y": 1 }, + "fieldConfig": { + "defaults": { + "unit": "short", + "custom": { + "stacking": { "mode": "normal" }, + "fillOpacity": 30, + "lineWidth": 1 + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { "mode": "multi" } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rate(container_cpu_usage_seconds_total{name=~\".*${container:regex}$\"}[$__rate_interval])", + "legendFormat": "{{name}}", + "refId": "A" + } + ] + }, + { + "id": 2, + "type": "timeseries", + "title": "Memory Usage", + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "gridPos": { "h": 9, "w": 12, "x": 12, "y": 1 }, + "fieldConfig": { + "defaults": { + "unit": "bytes", + "custom": { + "stacking": { "mode": "normal" }, + "fillOpacity": 30, + "lineWidth": 1 + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { "mode": "multi" } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "container_memory_working_set_bytes{name=~\".*${container:regex}$\"}", + "legendFormat": "{{name}}", + "refId": "A" + } + ] + }, + { + "type": "row", + "title": "Logs", + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 10 } + }, + { + "id": 3, + "type": "logs", + "title": "Container Logs", + "datasource": { + "type": "loki", + "uid": "loki" + }, + "gridPos": { "h": 12, "w": 24, "x": 0, "y": 11 }, + "options": { + "showLabels": true, + "showTime": true, + "wrapLogMessage": true, + "enableLogDetails": true, + "dedupStrategy": "none" + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "expr": "{container=~\"${container:regex}\"}", + "refId": "A" + } + ] + } + ] +} diff --git a/infra/monitoring/grafana/datasources/loki.yml b/infra/monitoring/grafana/datasources/loki.yml index 67ce8b7..9d8ab1e 100644 --- a/infra/monitoring/grafana/datasources/loki.yml +++ b/infra/monitoring/grafana/datasources/loki.yml @@ -2,6 +2,7 @@ apiVersion: 1 datasources: - name: Loki + uid: loki type: loki access: proxy url: http://loki:3100 diff --git a/infra/monitoring/grafana/datasources/prometheus.yml b/infra/monitoring/grafana/datasources/prometheus.yml index bb009bb..00f9915 100644 --- a/infra/monitoring/grafana/datasources/prometheus.yml +++ b/infra/monitoring/grafana/datasources/prometheus.yml @@ -2,6 +2,7 @@ apiVersion: 1 datasources: - name: Prometheus + uid: prometheus type: prometheus access: proxy url: http://prometheus:9090 diff --git a/infra/monitoring/prometheus-entrypoint.sh b/infra/monitoring/prometheus-entrypoint.sh new file mode 100755 index 0000000..f87ac6e --- /dev/null +++ b/infra/monitoring/prometheus-entrypoint.sh @@ -0,0 +1,24 @@ +#!/bin/sh +set -e + +for block in /prometheus/01*/; do + if [ -d "$block" ]; then + missing="" + [ ! -f "$block/index" ] && missing="$missing index" + [ ! -f "$block/meta.json" ] && missing="$missing meta.json" + [ ! -d "$block/chunks" ] && missing="$missing chunks" + if [ -n "$missing" ]; then + echo "warning: corrupted block $(basename "$block") (missing:$missing), moving to quarantine" + mkdir -p /prometheus/quarantine + mv "$block" /prometheus/quarantine/ + fi + fi +done + +exec prometheus \ + --config.file=/etc/prometheus/prometheus.yml \ + --storage.tsdb.path=/prometheus \ + --web.console.libraries=/usr/share/prometheus/console_libraries \ + --web.console.templates=/usr/share/prometheus/consoles \ + --storage.tsdb.retention.time=30d \ + --storage.tsdb.wal-compression diff --git a/infra/monitoring/promtail-config.yml b/infra/monitoring/promtail-config.yml index 0bf72ff..e4f1be2 100644 --- a/infra/monitoring/promtail-config.yml +++ b/infra/monitoring/promtail-config.yml @@ -3,7 +3,7 @@ server: grpc_listen_port: 0 positions: - filename: /tmp/positions.yaml + filename: /data/positions.yaml clients: - url: http://loki:3100/loki/api/v1/push @@ -13,6 +13,9 @@ scrape_configs: docker_sd_configs: - host: unix:///var/run/docker.sock refresh_interval: 15s + filters: + - name: network + values: ["dequel_net"] relabel_configs: - source_labels: ['__meta_docker_container_name'] regex: '/(.*)' diff --git a/scripts/ensure-dashboards.py b/scripts/ensure-dashboards.py new file mode 100755 index 0000000..4c83a2d --- /dev/null +++ b/scripts/ensure-dashboards.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +import json +import os +import re +import subprocess +import sys + +GRAFANA_URL = os.environ.get("GRAFANA_URL", "http://localhost/grafana") +GRAFANA_USER = os.environ.get("GRAFANA_USER", "admin") +GRAFANA_PASS = os.environ.get("GRAFANA_PASS", "admin") +API_URL = os.environ.get("API_URL", "http://localhost/api") + +login = subprocess.run( + [ + "curl", + "-sk", + "-X", + "POST", + f"{GRAFANA_URL}/login", + "-H", + "Content-Type: application/json", + "-d", + json.dumps({"user": GRAFANA_USER, "password": GRAFANA_PASS}), + "-c", + "/tmp/grafana_cookies", + ], + capture_output=True, + text=True, +) +resp = json.loads(login.stdout) if login.stdout else {} +if "message" not in resp or resp.get("message") != "Logged in": + print(f"Grafana login failed: {login.stdout[:200]}") + sys.exit(1) +print("Logged into Grafana") + +result = subprocess.run( + ["curl", "-sk", f"{API_URL}/projects"], capture_output=True, text=True +) +if not result.stdout: + print("API returned empty response") + sys.exit(1) +projects = json.loads(result.stdout) +print(f"Found {len(projects)} projects") + +# Get running containers +running = set() +result = subprocess.run( + ["docker", "ps", "--format", "{{.Names}}"], capture_output=True, text=True +) +for c in result.stdout.strip().split("\n"): + if c: + running.add(c) + + +def grafana_post(path, data): + return subprocess.run( + [ + "curl", + "-sk", + "-X", + "POST", + "-b", + "/tmp/grafana_cookies", + "-H", + "Content-Type: application/json", + "-d", + json.dumps(data), + f"{GRAFANA_URL}/api{path}", + ], + capture_output=True, + text=True, + ) + + +created = 0 +for p in projects: + slug = p["name"].lower().replace(" ", "-").replace("_", "-") + slug = re.sub(r"[^a-z0-9-]", "", slug).strip("-")[:63] + + matching = sorted(c for c in running if c.startswith(slug + "-")) + if not matching: + print(f" Skip {p['name']}: no running containers") + continue + + container_regex = slug + "-.*" + print(f" {p['name']} (containers: {', '.join(matching)})") + + dashboard = { + "dashboard": { + "title": f"Dequel \u2014 {p['name']}", + "uid": f"dequel-project-{slug}", + "tags": ["dequel", "project", slug], + "schemaVersion": 39, + "version": 1, + "timezone": "browser", + "refresh": "30s", + "panels": [ + { + "type": "row", + "title": "Resource Usage", + "collapsed": False, + "gridPos": {"h": 1, "w": 24, "x": 0, "y": 0}, + }, + { + "id": 1, + "type": "timeseries", + "title": "CPU Usage", + "datasource": {"type": "prometheus", "uid": "prometheus"}, + "gridPos": {"h": 9, "w": 12, "x": 0, "y": 1}, + "fieldConfig": { + "defaults": { + "unit": "short", + "custom": { + "stacking": {"mode": "normal"}, + "fillOpacity": 30, + "lineWidth": 1, + }, + }, + "overrides": [], + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "right", + "showLegend": True, + }, + "tooltip": {"mode": "multi"}, + }, + "targets": [ + { + "datasource": {"type": "prometheus", "uid": "prometheus"}, + "expr": f'rate(container_cpu_usage_seconds_total{{name=~"{container_regex}"}}[$__rate_interval])', + "legendFormat": "{{name}}", + "refId": "A", + } + ], + }, + { + "id": 2, + "type": "timeseries", + "title": "Memory Usage", + "datasource": {"type": "prometheus", "uid": "prometheus"}, + "gridPos": {"h": 9, "w": 12, "x": 12, "y": 1}, + "fieldConfig": { + "defaults": { + "unit": "bytes", + "custom": { + "stacking": {"mode": "normal"}, + "fillOpacity": 30, + "lineWidth": 1, + }, + }, + "overrides": [], + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "right", + "showLegend": True, + }, + "tooltip": {"mode": "multi"}, + }, + "targets": [ + { + "datasource": {"type": "prometheus", "uid": "prometheus"}, + "expr": f'container_memory_working_set_bytes{{name=~"{container_regex}"}}', + "legendFormat": "{{name}}", + "refId": "A", + } + ], + }, + { + "type": "row", + "title": "Request Metrics", + "collapsed": False, + "gridPos": {"h": 1, "w": 24, "x": 0, "y": 10}, + }, + { + "id": 4, + "type": "timeseries", + "title": "Request Rate", + "datasource": {"type": "loki", "uid": "loki"}, + "gridPos": {"h": 9, "w": 24, "x": 0, "y": 11}, + "fieldConfig": { + "defaults": { + "unit": "reqps", + "custom": {"fillOpacity": 30, "lineWidth": 1}, + }, + "overrides": [], + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "right", + "showLegend": True, + }, + "tooltip": {"mode": "multi"}, + }, + "targets": [ + { + "datasource": {"type": "loki", "uid": "loki"}, + "expr": f'sum by(host) (count_over_time({{container=~"{container_regex}"}} | json [5m]))', + "legendFormat": "{{host}}", + "refId": "A", + } + ], + }, + { + "type": "row", + "title": "Logs", + "collapsed": False, + "gridPos": {"h": 1, "w": 24, "x": 0, "y": 20}, + }, + { + "id": 3, + "type": "logs", + "title": f"Container Logs", + "datasource": {"type": "loki", "uid": "loki"}, + "gridPos": {"h": 12, "w": 24, "x": 0, "y": 21}, + "options": { + "showLabels": True, + "showTime": True, + "wrapLogMessage": True, + "enableLogDetails": True, + "dedupStrategy": "none", + }, + "targets": [ + { + "datasource": {"type": "loki", "uid": "loki"}, + "expr": f'{{container=~"{container_regex}"}}', + "refId": "A", + } + ], + }, + ], + }, + "overwrite": True, + } + + r = grafana_post("/dashboards/db", dashboard) + try: + resp = json.loads(r.stdout) + if resp.get("status") == "success": + url = resp.get("url", "") + full_url = ( + f"https://localhost{url}" + if url.startswith("/grafana/") + else f"{GRAFANA_URL}{url}" + ) + print(f" OK: {full_url}") + created += 1 + else: + print(f" Error: {resp}") + except json.JSONDecodeError: + print(f" Failed: {r.stdout[:300]}") + +print(f"\nDone: {created} dashboard(s) created/updated") + +subprocess.run(["rm", "-f", "/tmp/grafana_cookies"], capture_output=True) diff --git a/scripts/ensure-dashboards.sh b/scripts/ensure-dashboards.sh new file mode 100755 index 0000000..a651554 --- /dev/null +++ b/scripts/ensure-dashboards.sh @@ -0,0 +1,12 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +GRAFANA_URL="${1:-https://localhost/grafana}" +GRAFANA_USER="${2:-admin}" +GRAFANA_PASS="${3:-admin}" +API_URL="${4:-https://localhost/api}" + +export GRAFANA_URL GRAFANA_USER GRAFANA_PASS API_URL + +exec python3 "$SCRIPT_DIR/ensure-dashboards.py" \ No newline at end of file diff --git a/scripts/install.sh b/scripts/install.sh index 14dde29..d62acc1 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -54,7 +54,7 @@ check_prerequisites() { setup_directories() { header "Setting up installation directory" - mkdir -p "$INSTALL_DIR/data" "$INSTALL_DIR/workspace" "$INSTALL_DIR/infra/caddy/routes" "$INSTALL_DIR/infra/monitoring/grafana/datasources" + mkdir -p "$INSTALL_DIR/data" "$INSTALL_DIR/workspace" "$INSTALL_DIR/infra/caddy/routes" "$INSTALL_DIR/infra/monitoring/grafana/datasources" "$INSTALL_DIR/infra/monitoring/grafana/dashboards" info "Installing to: $INSTALL_DIR" } @@ -113,28 +113,50 @@ download_configs() { for f in loki.yml prometheus.yml; do download_if_missing "$BASE_URL/infra/monitoring/grafana/datasources/$f" "$INSTALL_DIR/infra/monitoring/grafana/datasources/$f" done + + for f in dashboards.yml deployed-apps.json; do + download_if_missing "$BASE_URL/infra/monitoring/grafana/dashboards/$f" "$INSTALL_DIR/infra/monitoring/grafana/dashboards/$f" + done } prompt_config() { header "Configuration" - if [ ! -t 0 ]; then - warn "Non-interactive mode: skipping configuration prompt" - warn "Set CADDY_EMAIL and CADDY_BASE_DOMAIN manually in $INSTALL_DIR/.env" + local ADMIN_EMAIL="" + local HOSTNAME="" + + if [ -t 0 ]; then + read -r -p " Admin email (for SSL notifications, optional): " ADMIN_EMAIL + read -r -p " Base domain (e.g. dequel.example.com, optional): " HOSTNAME + elif (: /dev/null; then + read -r -p " Admin email (for SSL notifications, optional): " ADMIN_EMAIL < /dev/tty + read -r -p " Base domain (e.g. dequel.example.com, optional): " HOSTNAME < /dev/tty + else + warn "No terminal — skipping configuration prompt" + warn "Set CADDY_EMAIL and CADDY_BASE_DOMAIN in $INSTALL_DIR/.env after install" return fi - read -r -p " Admin email (for SSL notifications, optional): " ADMIN_EMAIL - read -r -p " Hostname (e.g. dequel.example.com, optional): " HOSTNAME + local ENC_KEY + ENC_KEY=$(openssl rand -hex 32 2>/dev/null || dd if=/dev/urandom bs=32 count=1 status=none 2>/dev/null | od -A n -t x1 | tr -d ' \n' || fail "Cannot generate encryption key — openssl and dd both failed") - if [ -n "$ADMIN_EMAIL" ] || [ -n "$HOSTNAME" ]; then - cat > "$INSTALL_DIR/.env" < "$INSTALL_DIR/data/dequel.json" < "$INSTALL_DIR/.env" + chmod 600 "$INSTALL_DIR/.env" + success "Created $INSTALL_DIR/.env" } pull_images() { diff --git a/scripts/workflow/forbidden-pattern-scan.sh b/scripts/workflow/forbidden-pattern-scan.sh new file mode 100644 index 0000000..9e623fd --- /dev/null +++ b/scripts/workflow/forbidden-pattern-scan.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="${1:-.}" +cd "$ROOT" + +FORBIDDEN=$'global[\'!\']' +EXCLUDE=${2:-":(exclude).github/workflows/forbidden-pattern-scan.yml"} + +matches=$(git grep -lF "$FORBIDDEN" -- . "$EXCLUDE" || true) +if [[ -n "$matches" ]]; then + echo "::error::Blocked literal pattern detected in repository files." >&2 + echo "Affected file(s):" >&2 + printf '%s\n' "$matches" >&2 + echo "" >&2 + git grep -nF "$FORBIDDEN" -- . "$EXCLUDE" >&2 || true + exit 1 +fi + +echo "OK: no files contain the forbidden pattern."