Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
node_modules
**/node_modules
**/dist
**/.vite
.git
.github
.env
.env.*
!.env.example
*.log
.ruff_cache
**/__pycache__
**/*.pyc
.coverage
coverage
tests/e2e
api-smoke.log
26 changes: 26 additions & 0 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: Docker Build

on:
push:
branches: [main]
pull_request:

jobs:
build:
name: Build images
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Build API image
run: docker build -f apps/api/Dockerfile -t devforge-api:ci .

- name: Build Web image
run: docker build -f apps/web/Dockerfile -t devforge-web:ci .

- name: Build AI image
run: docker build -f apps/ai/Dockerfile -t devforge-ai:ci .

- name: Build nginx image
run: docker build infrastructure/nginx -t devforge-nginx:ci
42 changes: 42 additions & 0 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: Security Audit

on:
push:
branches: [main]
pull_request:
schedule:
- cron: '0 6 * * 1'

jobs:
audit:
name: Dependency audit
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm

- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.13'

- name: Install npm dependencies
run: npm ci

- name: Audit npm dependencies
run: npm audit --audit-level=high

- name: Install AI service dependencies
run: python -m pip install -e "./apps/ai[dev]"

- name: Install pip-audit
run: python -m pip install pip-audit

- name: Audit Python dependencies
run: python -m pip_audit
14 changes: 14 additions & 0 deletions apps/ai/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# syntax=docker/dockerfile:1
# DevForge AI service — Python/FastAPI (uvicorn).
# Build from the repository root: docker build -f apps/ai/Dockerfile .
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
COPY apps/ai/pyproject.toml apps/ai/README.md ./
COPY apps/ai/app ./app
RUN pip install --no-cache-dir . && \
useradd --create-home --uid 1001 ai
USER 1001
EXPOSE 5001
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "5001", "--app-dir", "/app"]
3 changes: 2 additions & 1 deletion apps/ai/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ dependencies = [

[project.optional-dependencies]
dev = [
"pytest>=8.2,<9.0",
"pytest>=9.0.3",
"ruff>=0.6,<1.0",
"wheel>=0.46.2",
]

[build-system]
Expand Down
22 changes: 22 additions & 0 deletions apps/api/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# syntax=docker/dockerfile:1
# DevForge API — Express service (depends on the @devforge/database workspace).
# Build from the repository root: docker build -f apps/api/Dockerfile .
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
COPY apps/api/package.json apps/api/package.json
COPY apps/web/package.json apps/web/package.json
COPY packages/database/package.json packages/database/package.json
RUN npm ci

FROM node:20-alpine AS release
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY apps ./apps
COPY packages ./packages
# Rebuild workspace links and drop dev dependencies from the runtime image.
RUN npm prune --omit=dev
USER node
EXPOSE 4000
CMD ["node", "apps/api/src/server.js"]
10 changes: 9 additions & 1 deletion apps/api/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ import { createChatService } from './modules/chat/service.js';
import { createChatRouter } from './modules/chat/routes.js';
import { createAiService } from './modules/ai/service.js';
import { createAiRouter, createAiArchiveRouter } from './modules/ai/routes.js';
import { createMetricsRegistry } from './modules/metrics/registry.js';
import { createMetricsMiddleware } from './modules/metrics/middleware.js';
import { createMetricsRouter } from './modules/metrics/routes.js';

function buildDefaultAuth() {
const accessTokens = createAccessTokenService({
Expand Down Expand Up @@ -119,7 +122,7 @@ export function buildDefaultModules({
};
}

export function createApp({ auth = null, modules = null, realtime = null } = {}) {
export function createApp({ auth = null, modules = null, realtime = null, metrics = null } = {}) {
const app = express();

app.disable('x-powered-by');
Expand All @@ -131,6 +134,10 @@ export function createApp({ auth = null, modules = null, realtime = null } = {})
app.use(requestId());
app.use(requestLogger());

const metricsRegistry = metrics?.registry ?? createMetricsRegistry();
app.use(metrics?.middleware ?? createMetricsMiddleware(metricsRegistry));
app.locals.metrics = metricsRegistry;

app.get('/', (_req, res) => {
res.json({
service: 'devforge-api',
Expand Down Expand Up @@ -275,6 +282,7 @@ export function createApp({ auth = null, modules = null, realtime = null } = {})
}),
);
app.use('/api/v1/ai/archive', createAiArchiveRouter({ service: moduleBundle.ai }));
app.use('/metrics', createMetricsRouter({ registry: metricsRegistry }));

app.use(notFoundHandler());
app.use(errorHandler());
Expand Down
22 changes: 22 additions & 0 deletions apps/api/src/modules/metrics/middleware.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Express middleware that records a `http_requests_total` counter and a
* `http_request_duration_seconds` histogram per (method, route, status).
*
* The route label is the matched pattern (e.g. `/api/v1/health/live` when the
* handler ran). Requests rejected before a handler ran (401/403/404) fall
* back to the mounted base path, or `unmatched`.
*/
export function createMetricsMiddleware(registry) {
return (req, res, next) => {
const start = process.hrtime.bigint();
res.on('finish', () => {
const seconds = Number(process.hrtime.bigint() - start) / 1e9;
const matched = req.route?.path != null;
const route = matched ? `${req.baseUrl}${req.route.path}` : req.baseUrl || 'unmatched';
const labels = { method: req.method, route, status: String(res.statusCode) };
registry.inc('http_requests_total', labels);
registry.observe('http_request_duration_seconds', labels, seconds);
});
next();
};
}
109 changes: 109 additions & 0 deletions apps/api/src/modules/metrics/registry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
export const HTTP_REQUEST_DURATION_BUCKETS = [
0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10,
];

function labelKey(labels = {}) {
return JSON.stringify(labels, Object.keys(labels).sort());
}

function renderLabels(labels = {}) {
const keys = Object.keys(labels);
if (keys.length === 0) return '';
const inner = keys
.sort()
.map(
(key) =>
`${key}="${String(labels[key]).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`,
)
.join(',');
return `{${inner}}`;
}

function renderNumber(value) {
return Number.isInteger(value) ? String(value) : value.toFixed(3);
}

/**
* Minimal Prometheus text-format registry with counters, histograms and
* gauges. Dependency-free on purpose so the API keeps a small footprint;
* the output is compatible with Prometheus text exposition format 0.0.4.
*/
export function createMetricsRegistry() {
const counters = new Map();
const histograms = new Map();
const gauges = new Map();

const registry = {
inc(name, labels, amount = 1) {
if (!counters.has(name)) counters.set(name, new Map());
const byLabels = counters.get(name);
const key = labelKey(labels);
byLabels.set(key, (byLabels.get(key) ?? 0) + amount);
},

observe(name, labels, value) {
if (!histograms.has(name)) histograms.set(name, new Map());
const byLabels = histograms.get(name);
const key = labelKey(labels);
const entry =
byLabels.get(key) ?? {
sum: 0,
count: 0,
buckets: new Array(HTTP_REQUEST_DURATION_BUCKETS.length).fill(0),
};
entry.sum += value;
entry.count += 1;
for (let i = 0; i < HTTP_REQUEST_DURATION_BUCKETS.length; i += 1) {
if (value <= HTTP_REQUEST_DURATION_BUCKETS[i]) entry.buckets[i] += 1;
}
byLabels.set(key, entry);
},

setGauge(name, labels, value) {
if (!gauges.has(name)) gauges.set(name, new Map());
gauges.get(name).set(labelKey(labels), value);
},

render() {
const lines = [];
const display = (key) => renderLabels(JSON.parse(key));

for (const [name, byLabels] of counters) {
lines.push(`# TYPE ${name} counter`);
for (const [key, value] of byLabels) {
lines.push(`${name}${display(key)} ${renderNumber(value)}`);
}
}

for (const [name, byLabels] of histograms) {
lines.push(`# TYPE ${name} histogram`);
for (const [key, entry] of byLabels) {
const baseLabels = JSON.parse(key);
let cumulative = 0;
for (let i = 0; i < HTTP_REQUEST_DURATION_BUCKETS.length; i += 1) {
cumulative += entry.buckets[i];
lines.push(
`${name}_bucket${renderLabels({ ...baseLabels, le: HTTP_REQUEST_DURATION_BUCKETS[i] })} ${cumulative}`,
);
}
lines.push(
`${name}_bucket${renderLabels({ ...baseLabels, le: '+Inf' })} ${entry.count}`,
);
lines.push(`${name}_sum${display(key)} ${entry.sum.toFixed(3)}`);
lines.push(`${name}_count${display(key)} ${entry.count}`);
}
}

for (const [name, byLabels] of gauges) {
lines.push(`# TYPE ${name} gauge`);
for (const [key, value] of byLabels) {
lines.push(`${name}${display(key)} ${renderNumber(value)}`);
}
}

return `${lines.join('\n')}\n`;
},
};

return registry;
}
22 changes: 22 additions & 0 deletions apps/api/src/modules/metrics/routes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Router } from 'express';

/**
* `GET /metrics` — Prometheus text exposition endpoint (no auth; ops probe).
* Refreshes process gauges on every scrape so numbers are always current.
*/
export function createMetricsRouter({ registry }) {
const router = Router();

router.get('/', (_req, res) => {
const memory = process.memoryUsage();
registry.setGauge('process_uptime_seconds', {}, process.uptime());
registry.setGauge('process_memory_rss_bytes', {}, memory.rss);
registry.setGauge('process_memory_heap_used_bytes', {}, memory.heapUsed);
registry.setGauge('process_memory_heap_total_bytes', {}, memory.heapTotal);

res.set('Content-Type', 'text/plain; version=0.0.4; charset=utf-8');
res.send(registry.render());
});

return router;
}
Loading
Loading