Merge branch 'dev'
This commit is contained in:
@@ -20,9 +20,20 @@ on:
|
|||||||
tags: ["v*.*.*"]
|
tags: ["v*.*.*"]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
IMAGE_NAME: ghcr.io/${{ github.repository }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
build:
|
||||||
runs-on: ubuntu-latest
|
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:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
@@ -31,9 +42,6 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
@@ -48,21 +56,75 @@ jobs:
|
|||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: |
|
images: ${{ env.IMAGE_NAME }}
|
||||||
ghcr.io/${{ github.repository }}
|
|
||||||
|
- 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: |
|
tags: |
|
||||||
type=raw,value=latest,enable={{is_default_branch}}
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
type=sha,prefix=
|
type=sha,prefix=
|
||||||
|
|
||||||
- name: Build and push
|
- name: Create manifest list and push
|
||||||
uses: docker/build-push-action@v6
|
working-directory: /tmp/digests
|
||||||
with:
|
run: |
|
||||||
context: .
|
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||||
platforms: linux/amd64,linux/arm64
|
$(printf '${{ env.IMAGE_NAME }}@sha256:%s ' *)
|
||||||
push: true
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
- name: Inspect image
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
run: |
|
||||||
cache-from: type=gha
|
docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
|
|||||||
+26
-35
@@ -1,10 +1,21 @@
|
|||||||
|
import v8 from 'node:v8';
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { NextRequest } from 'next/server';
|
import { NextRequest } from 'next/server';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
// Health check thresholds
|
const MEMORY_WARNING_THRESHOLD = 0.85;
|
||||||
const MEMORY_WARNING_THRESHOLD = 0.85; // 85% heap usage
|
const MEMORY_CRITICAL_THRESHOLD = 0.95;
|
||||||
const MEMORY_CRITICAL_THRESHOLD = 0.95; // 95% heap usage
|
|
||||||
|
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 {
|
interface HealthStatus {
|
||||||
status: 'healthy' | 'degraded' | 'unhealthy';
|
status: 'healthy' | 'degraded' | 'unhealthy';
|
||||||
@@ -14,6 +25,7 @@ interface HealthStatus {
|
|||||||
memory?: {
|
memory?: {
|
||||||
heapUsed: number;
|
heapUsed: number;
|
||||||
heapTotal: number;
|
heapTotal: number;
|
||||||
|
heapSizeLimit: number;
|
||||||
rss: number;
|
rss: number;
|
||||||
external: number;
|
external: number;
|
||||||
heapUsagePercent: number;
|
heapUsagePercent: number;
|
||||||
@@ -27,14 +39,9 @@ interface HealthStatus {
|
|||||||
/**
|
/**
|
||||||
* Health check endpoint for container orchestration
|
* Health check endpoint for container orchestration
|
||||||
*
|
*
|
||||||
* GET /api/health - Basic health check (returns 200 OK or 503 Service Unavailable)
|
* GET /api/health - Liveness probe for container orchestration
|
||||||
* GET /api/health?detailed=true - Detailed diagnostics with memory stats
|
* GET /api/health?detailed=true - Diagnostics with advisory memory warnings
|
||||||
* HEAD /api/health - Lightweight health check (status code only)
|
* HEAD /api/health - Lightweight liveness probe (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
|
|
||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const searchParams = request.nextUrl.searchParams;
|
const searchParams = request.nextUrl.searchParams;
|
||||||
@@ -43,38 +50,31 @@ export async function GET(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
const memUsage = process.memoryUsage();
|
const memUsage = process.memoryUsage();
|
||||||
const heapUsagePercent = (memUsage.heapUsed / memUsage.heapTotal) * 100;
|
const heapSizeLimit = v8.getHeapStatistics().heap_size_limit;
|
||||||
|
const heapUsagePercent = getHeapUsagePercent(memUsage.heapUsed, memUsage.heapTotal);
|
||||||
// Determine health status based on memory usage
|
|
||||||
let status: 'healthy' | 'degraded' | 'unhealthy' = 'healthy';
|
let status: 'healthy' | 'degraded' | 'unhealthy' = 'healthy';
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
let httpStatus = 200;
|
|
||||||
|
|
||||||
if (heapUsagePercent >= MEMORY_CRITICAL_THRESHOLD * 100) {
|
if (heapUsagePercent >= MEMORY_CRITICAL_THRESHOLD * 100) {
|
||||||
status = 'unhealthy';
|
status = 'degraded';
|
||||||
httpStatus = 503;
|
warnings.push(`V8 heap usage is very high: ${heapUsagePercent.toFixed(1)}% of heap limit`);
|
||||||
} else if (heapUsagePercent >= MEMORY_WARNING_THRESHOLD * 100) {
|
} else if (heapUsagePercent >= MEMORY_WARNING_THRESHOLD * 100) {
|
||||||
status = 'degraded';
|
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 = {
|
const response: HealthStatus = {
|
||||||
status,
|
status: detailed ? status : 'healthy',
|
||||||
timestamp,
|
timestamp,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (status === 'unhealthy') {
|
|
||||||
response.reason = `Memory usage critical: ${heapUsagePercent.toFixed(1)}%`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add detailed information if requested
|
|
||||||
if (detailed) {
|
if (detailed) {
|
||||||
response.uptime = process.uptime();
|
response.uptime = process.uptime();
|
||||||
response.version = process.env.npm_package_version || '0.1.0';
|
response.version = process.env.npm_package_version || '0.1.0';
|
||||||
response.memory = {
|
response.memory = {
|
||||||
heapUsed: memUsage.heapUsed,
|
heapUsed: memUsage.heapUsed,
|
||||||
heapTotal: memUsage.heapTotal,
|
heapTotal: memUsage.heapTotal,
|
||||||
|
heapSizeLimit,
|
||||||
rss: memUsage.rss,
|
rss: memUsage.rss,
|
||||||
external: memUsage.external,
|
external: memUsage.external,
|
||||||
heapUsagePercent: Number(heapUsagePercent.toFixed(2)),
|
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, {
|
return NextResponse.json(response, {
|
||||||
status: httpStatus,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
||||||
'Pragma': 'no-cache',
|
'Pragma': 'no-cache',
|
||||||
@@ -116,13 +114,6 @@ export async function GET(request: NextRequest) {
|
|||||||
*/
|
*/
|
||||||
export async function HEAD() {
|
export async function HEAD() {
|
||||||
try {
|
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 });
|
return new Response(null, { status: 200 });
|
||||||
} catch {
|
} catch {
|
||||||
return new Response(null, { status: 503 });
|
return new Response(null, { status: 503 });
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user