Merge branch 'dev'

This commit is contained in:
Linus Rath
2026-03-19 18:57:57 +01:00
3 changed files with 203 additions and 52 deletions
+79 -17
View File
@@ -20,9 +20,20 @@ on:
tags: ["v*.*.*"]
workflow_dispatch:
env:
IMAGE_NAME: ghcr.io/${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
build:
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
@@ -31,9 +42,6 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -48,21 +56,75 @@ jobs:
id: meta
uses: docker/metadata-action@v5
with:
images: |
ghcr.io/${{ github.repository }}
images: ${{ env.IMAGE_NAME }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs: build
permissions:
contents: read
packages: write
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.IMAGE_NAME }}@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
+26 -35
View File
@@ -1,10 +1,21 @@
import v8 from 'node:v8';
import { NextResponse } from 'next/server';
import { NextRequest } from 'next/server';
import { logger } from '@/lib/logger';
// Health check thresholds
const MEMORY_WARNING_THRESHOLD = 0.85; // 85% heap usage
const MEMORY_CRITICAL_THRESHOLD = 0.95; // 95% heap usage
const MEMORY_WARNING_THRESHOLD = 0.85;
const MEMORY_CRITICAL_THRESHOLD = 0.95;
function getHeapUsagePercent(heapUsed: number, heapTotal: number): number {
const heapSizeLimit = v8.getHeapStatistics().heap_size_limit;
const denominator = heapSizeLimit > 0 ? heapSizeLimit : heapTotal;
if (denominator <= 0) {
return 0;
}
return (heapUsed / denominator) * 100;
}
interface HealthStatus {
status: 'healthy' | 'degraded' | 'unhealthy';
@@ -14,6 +25,7 @@ interface HealthStatus {
memory?: {
heapUsed: number;
heapTotal: number;
heapSizeLimit: number;
rss: number;
external: number;
heapUsagePercent: number;
@@ -27,14 +39,9 @@ interface HealthStatus {
/**
* Health check endpoint for container orchestration
*
* GET /api/health - Basic health check (returns 200 OK or 503 Service Unavailable)
* GET /api/health?detailed=true - Detailed diagnostics with memory stats
* HEAD /api/health - Lightweight health check (status code only)
*
* Health status based on Node.js heap usage:
* - Healthy (200): < 85% heap usage
* - Degraded (200): 85-95% heap usage (warnings in detailed mode)
* - Unhealthy (503): > 95% heap usage
* GET /api/health - Liveness probe for container orchestration
* GET /api/health?detailed=true - Diagnostics with advisory memory warnings
* HEAD /api/health - Lightweight liveness probe (status code only)
*/
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
@@ -43,38 +50,31 @@ export async function GET(request: NextRequest) {
try {
const timestamp = new Date().toISOString();
const memUsage = process.memoryUsage();
const heapUsagePercent = (memUsage.heapUsed / memUsage.heapTotal) * 100;
// Determine health status based on memory usage
const heapSizeLimit = v8.getHeapStatistics().heap_size_limit;
const heapUsagePercent = getHeapUsagePercent(memUsage.heapUsed, memUsage.heapTotal);
let status: 'healthy' | 'degraded' | 'unhealthy' = 'healthy';
const warnings: string[] = [];
let httpStatus = 200;
if (heapUsagePercent >= MEMORY_CRITICAL_THRESHOLD * 100) {
status = 'unhealthy';
httpStatus = 503;
status = 'degraded';
warnings.push(`V8 heap usage is very high: ${heapUsagePercent.toFixed(1)}% of heap limit`);
} else if (heapUsagePercent >= MEMORY_WARNING_THRESHOLD * 100) {
status = 'degraded';
warnings.push(`Memory usage high: ${heapUsagePercent.toFixed(1)}%`);
warnings.push(`V8 heap usage is high: ${heapUsagePercent.toFixed(1)}% of heap limit`);
}
// Build response
const response: HealthStatus = {
status,
status: detailed ? status : 'healthy',
timestamp,
};
if (status === 'unhealthy') {
response.reason = `Memory usage critical: ${heapUsagePercent.toFixed(1)}%`;
}
// Add detailed information if requested
if (detailed) {
response.uptime = process.uptime();
response.version = process.env.npm_package_version || '0.1.0';
response.memory = {
heapUsed: memUsage.heapUsed,
heapTotal: memUsage.heapTotal,
heapSizeLimit,
rss: memUsage.rss,
external: memUsage.external,
heapUsagePercent: Number(heapUsagePercent.toFixed(2)),
@@ -87,10 +87,8 @@ export async function GET(request: NextRequest) {
}
}
logger.info('Health check', { status, detailed });
return NextResponse.json(response, {
status: httpStatus,
status: 200,
headers: {
'Cache-Control': 'no-store, no-cache, must-revalidate',
'Pragma': 'no-cache',
@@ -116,13 +114,6 @@ export async function GET(request: NextRequest) {
*/
export async function HEAD() {
try {
const memUsage = process.memoryUsage();
const heapUsagePercent = (memUsage.heapUsed / memUsage.heapTotal) * 100;
if (heapUsagePercent >= MEMORY_CRITICAL_THRESHOLD * 100) {
return new Response(null, { status: 503 });
}
return new Response(null, { status: 200 });
} catch {
return new Response(null, { status: 503 });
+98
View File
@@ -0,0 +1,98 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const loggerError = vi.fn();
vi.mock('next/server', () => ({
NextResponse: {
json: (data: unknown, init?: { status?: number; headers?: unknown }) => ({
status: init?.status ?? 200,
headers: init?.headers,
json: async () => data,
}),
},
}));
vi.mock('@/lib/logger', () => ({
logger: {
error: loggerError,
},
}));
describe('health route', () => {
beforeEach(() => {
vi.restoreAllMocks();
loggerError.mockReset();
});
it('returns healthy for the basic liveness probe even when heap usage is high', async () => {
vi.spyOn(process, 'memoryUsage').mockReturnValue({
rss: 120_000_000,
heapTotal: 45_000_000,
heapUsed: 43_000_000,
external: 8_000_000,
arrayBuffers: 1_000_000,
});
const { GET } = await import('@/app/api/health/route');
const response = await GET({ nextUrl: new URL('http://localhost/api/health') } as never);
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload).toMatchObject({
status: 'healthy',
});
expect(payload.warnings).toBeUndefined();
});
it('returns degraded diagnostics in detailed mode without failing the probe', async () => {
vi.spyOn(process, 'memoryUsage').mockReturnValue({
rss: 120_000_000,
heapTotal: 4_100_000_000,
heapUsed: 4_000_000_000,
external: 8_000_000,
arrayBuffers: 1_000_000,
});
vi.spyOn(process, 'uptime').mockReturnValue(123.45);
const { GET } = await import('@/app/api/health/route');
const response = await GET({ nextUrl: new URL('http://localhost/api/health?detailed=true') } as never);
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload.status).toBe('degraded');
expect(payload.memory).toMatchObject({
heapUsed: 4_000_000_000,
heapTotal: 4_100_000_000,
rss: 120_000_000,
external: 8_000_000,
});
expect(payload.memory.heapSizeLimit).toBeGreaterThan(0);
expect(payload.warnings).toEqual([
expect.stringContaining('V8 heap usage is high'),
]);
});
it('keeps HEAD as a stable liveness probe', async () => {
const { HEAD } = await import('@/app/api/health/route');
const response = await HEAD();
expect(response.status).toBe(200);
});
it('returns 503 when collecting health diagnostics throws', async () => {
vi.spyOn(process, 'memoryUsage').mockImplementation(() => {
throw new Error('boom');
});
const { GET } = await import('@/app/api/health/route');
const response = await GET({ nextUrl: new URL('http://localhost/api/health') } as never);
const payload = await response.json();
expect(response.status).toBe(503);
expect(payload).toMatchObject({
status: 'unhealthy',
reason: 'boom',
});
expect(loggerError).toHaveBeenCalledWith('Health check failed', { error: 'boom' });
});
});