diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8bc4b64 --- /dev/null +++ b/.env.example @@ -0,0 +1,34 @@ +# Example environment configuration for GitHub Actions Cache Server + +# Basic Configuration +API_BASE_URL=http://localhost:3000 +NITRO_PORT=3000 +DEBUG=false + +# Storage Configuration +STORAGE_DRIVER=filesystem +# For S3: s3 +# For GCS: gcs + +# Database Configuration +DB_DRIVER=sqlite +# For MySQL: mysql +# For PostgreSQL: postgres + +# Cache Management +CACHE_CLEANUP_OLDER_THAN_DAYS=90 +CACHE_CLEANUP_CRON=0 0 * * * +UPLOAD_CLEANUP_CRON=*/10 * * * * +ENABLE_DIRECT_DOWNLOADS=false + +# Metrics Configuration +METRICS_ENABLED=false +METRICS_HOST=localhost +METRICS_PORT=8125 +METRICS_PREFIX=cache_server. + +# For production with Datadog in Kubernetes: +# METRICS_ENABLED=true +# METRICS_HOST=datadog-agent +# METRICS_PORT=8125 +# METRICS_PREFIX=cache_server. \ No newline at end of file diff --git a/README.md b/README.md index 081936a..b91ce05 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,94 @@ volumes: cache-data: ``` +## Building and Publishing to Private Repository + +### Prerequisites + +- Docker installed and running +- Access to your private container registry +- Docker CLI authenticated with your private registry + +### Local Development Build + +To build and run the application locally without Docker: + +1. **Install dependencies:** + + ```bash + pnpm install + ``` + +2. **Build the application:** + + ```bash + pnpm run build + ``` + +3. **Run the application:** + + ```bash + pnpm run preview + ``` + +4. **For development with hot reload:** + ```bash + pnpm run dev + ``` + +### Docker Build and Push Steps + +1. **Set your private repository environment variable:** + + ```bash + export CACHE_PRIVATE_REPO=your-private-registry.com/github-actions-cache-server + ``` + +2. **Build the Docker image:** + + ```bash + docker build -t $CACHE_PRIVATE_REPO:latest . + ``` + +3. **Tag for your private registry (if needed):** + + ```bash + docker tag $CACHE_PRIVATE_REPO:latest $CACHE_PRIVATE_REPO:v8.1.4 + ``` + +4. **Push to your private registry:** + ```bash + docker push $CACHE_PRIVATE_REPO:latest + docker push $CACHE_PRIVATE_REPO:v8.1.4 + ``` + +### Using Your Private Image + +Update your docker-compose.yml or deployment configuration to use your private image: + +```yaml +services: + cache-server: + image: ${CACHE_PRIVATE_REPO}:latest + ports: + - '3000:3000' + environment: + API_BASE_URL: http://localhost:3000 + volumes: + - cache-data:/app/.data + +volumes: + cache-data: +``` + +### Build Arguments + +The Dockerfile supports a `BUILD_HASH` argument for tracking builds: + +```bash +docker build --build-arg BUILD_HASH=$(git rev-parse HEAD) -t $CACHE_PRIVATE_REPO:latest . +``` + ## Documentation 👉 👈 diff --git a/lib/env.ts b/lib/env.ts index 6ee7127..2d19dba 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -14,6 +14,10 @@ const envSchema = z.object({ DEBUG: z.stringbool().default(false), NITRO_PORT: portSchema.default(3000), TEMP_DIR: z.string().default(tmpdir()), + METRICS_ENABLED: z.stringbool().default(false), + METRICS_HOST: z.string().default('localhost'), + METRICS_PORT: portSchema.default(8125), + METRICS_PREFIX: z.string().default('cache_server.'), }) const parsedEnv = envSchema.safeParse(process.env) diff --git a/lib/metrics.ts b/lib/metrics.ts new file mode 100644 index 0000000..833de07 --- /dev/null +++ b/lib/metrics.ts @@ -0,0 +1,104 @@ +import { StatsD } from 'hot-shots' +import { logger } from '~/lib/logger' + +export interface MetricsConfig { + enabled: boolean + host: string + port: number + prefix: string + globalTags: Record +} + +class MetricsClient { + private client: StatsD | null = null + private config: MetricsConfig + + constructor(config: MetricsConfig) { + this.config = config + + if (config.enabled) { + this.client = new StatsD({ + host: config.host, + port: config.port, + prefix: config.prefix, + globalTags: config.globalTags, + errorHandler: (error) => { + logger.warn('StatsD error:', error) + }, + }) + logger.info(`Metrics enabled - sending to ${config.host}:${config.port}`) + } else { + logger.info('Metrics disabled') + } + } + + increment(metric: string, value: number = 1, tags?: Record): void { + if (!this.client) return + this.client.increment(metric, value, this.formatTags(tags)) + } + + histogram(metric: string, value: number, tags?: Record): void { + if (!this.client) return + this.client.histogram(metric, value, this.formatTags(tags)) + } + + gauge(metric: string, value: number, tags?: Record): void { + if (!this.client) return + this.client.gauge(metric, value, this.formatTags(tags)) + } + + timing(metric: string, value: number, tags?: Record): void { + if (!this.client) return + this.client.timing(metric, value, this.formatTags(tags)) + } + + private formatTags(tags?: Record): string[] | undefined { + if (!tags) return undefined + return Object.entries(tags).map(([key, value]) => `${key}:${value}`) + } + + close(): void { + if (this.client) { + this.client.close() + } + } +} + +let metricsInstance: MetricsClient | null = null + +export function initializeMetrics(config: MetricsConfig): void { + if (metricsInstance) { + metricsInstance.close() + } + metricsInstance = new MetricsClient(config) +} + +export function getMetrics(): MetricsClient { + if (!metricsInstance) { + throw new Error('Metrics not initialized. Call initializeMetrics() first.') + } + return metricsInstance +} + +export interface HttpMetricsTags extends Record { + method: string + endpoint: string + status_code: string +} + +export interface CacheMetricsTags extends Record { + operation: 'hit' | 'miss' | 'upload' | 'download' + storage_driver: string +} + +export const METRICS = { + HTTP: { + REQUESTS_TOTAL: 'http.requests.total', + RESPONSE_TIME: 'http.response_time', + }, + CACHE: { + OPERATIONS_TOTAL: 'cache.operations.total', + SIZE_BYTES: 'cache.size_bytes', + UPLOAD_CHUNKS: 'cache.upload_chunks.total', + }, +} as const diff --git a/package.json b/package.json index 3bf2ce4..5aff2e9 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "type": "module", "version": "8.1.4", "private": true, + "packageManager": "pnpm@10.17.1+sha512.17c560fca4867ae9473a3899ad84a88334914f379be46d455cbf92e5cf4b39d34985d452d2583baf19967fa76cb5c17bc9e245529d0b98745721aa7200ecaf7a", "engines": { "node": "22", "pnpm": "10" @@ -36,6 +37,7 @@ "croner": "^9.1.0", "execa": "^9.6.0", "h3": "^1.15.4", + "hot-shots": "^11.2.0", "kysely": "^0.28.5", "mysql2": "^3.14.4", "nitropack": "^2.12.5", @@ -76,7 +78,12 @@ "better-sqlite3", "cpu-features", "esbuild", - "ssh2" + "protobufjs", + "ssh2", + "unrs-resolver" + ], + "ignoredBuiltDependencies": [ + "unix-dgram" ] } } diff --git a/plugins/setup.ts b/plugins/setup.ts index d808ae7..431a78f 100644 --- a/plugins/setup.ts +++ b/plugins/setup.ts @@ -1,11 +1,22 @@ -import cluster from 'node:cluster' +import type { HttpMetricsTags } from '~/lib/metrics' +import cluster from 'node:cluster' import { H3Error } from 'h3' import { useDB } from '~/lib/db' import { ENV } from '~/lib/env' import { logger } from '~/lib/logger' +import { getMetrics, initializeMetrics, METRICS } from '~/lib/metrics' import { useStorageAdapter } from '~/lib/storage' +function getEndpointName(path: string): string { + // Normalize dynamic routes for better metric grouping + return path + .replaceAll(/\/\d+/g, '/:id') // Replace numeric IDs + .replaceAll(/\/[a-f0-9-]{36}/g, '/:uuid') // Replace UUIDs + .replaceAll(/\/[a-f0-9]{8,}/g, '/:hash') // Replace long hashes + .replace(/\/_apis\/artifactcache/, '/api/cache') // Simplify API paths +} + export default defineNitroPlugin(async (nitro) => { const version = useRuntimeConfig().version if (cluster.isPrimary) logger.info(`🚀 Starting GitHub Actions Cache Server (${version})`) @@ -13,16 +24,79 @@ export default defineNitroPlugin(async (nitro) => { await useDB() await useStorageAdapter() + // Initialize metrics + initializeMetrics({ + enabled: ENV.METRICS_ENABLED, + host: ENV.METRICS_HOST, + port: ENV.METRICS_PORT, + prefix: ENV.METRICS_PREFIX, + globalTags: { + service: 'github-actions-cache-server', + version: version || 'unknown', + storage_driver: ENV.STORAGE_DRIVER, + db_driver: ENV.DB_DRIVER, + }, + }) + + // Track HTTP request metrics + nitro.hooks.hook('request', (event) => { + if (ENV.METRICS_ENABLED) { + event.context._startTime = Date.now() + } + }) + + // eslint-disable-next-line @shopify/prefer-early-return + nitro.hooks.hook('afterResponse', (event) => { + if (ENV.METRICS_ENABLED && event.context._startTime) { + const duration = Date.now() - event.context._startTime + const statusCode = getResponseStatus(event).toString() + const endpoint = getEndpointName(event.path) + + const tags: HttpMetricsTags = { + method: event.method || 'UNKNOWN', + endpoint, + status_code: statusCode, + } + + try { + const metrics = getMetrics() + metrics.increment(METRICS.HTTP.REQUESTS_TOTAL, 1, tags) + metrics.timing(METRICS.HTTP.RESPONSE_TIME, duration, tags) + } catch (err) { + logger.warn('Failed to record HTTP metrics:', err) + } + } + }) + nitro.hooks.hook('error', (error, { event }) => { if (!event) { logger.error(error) return } - logger.error( - `Response: ${event.method} ${event.path} > ${error instanceof H3Error ? error.statusCode : '[no status code]'}\n`, - error, - ) + const statusCode = error instanceof H3Error ? error.statusCode : 500 + + // Record error metrics + if (ENV.METRICS_ENABLED && event.context._startTime) { + const duration = Date.now() - event.context._startTime + const endpoint = getEndpointName(event.path) + + const tags: HttpMetricsTags = { + method: event.method || 'UNKNOWN', + endpoint, + status_code: statusCode.toString(), + } + + try { + const metrics = getMetrics() + metrics.increment(METRICS.HTTP.REQUESTS_TOTAL, 1, tags) + metrics.timing(METRICS.HTTP.RESPONSE_TIME, duration, tags) + } catch (err) { + logger.warn('Failed to record error metrics:', err) + } + } + + logger.error(`Response: ${event.method} ${event.path} > ${statusCode}\n`, error) }) if (ENV.DEBUG) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b460d58..913935b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: h3: specifier: ^1.15.4 version: 1.15.4 + hot-shots: + specifier: ^11.2.0 + version: 11.2.0 kysely: specifier: ^0.28.5 version: 0.28.5 @@ -3822,6 +3825,10 @@ packages: hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + hot-shots@11.2.0: + resolution: {integrity: sha512-cGiFSgTZtVODx0yMW67gPICgref3XuxkTMrXP0h5cSd1HHG3OG7L2C6+aW70MAtlUNl+9+DOq/xXyJUVKDyeUg==} + engines: {node: '>=16.0.0'} + html-entities@2.6.0: resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==} @@ -5878,6 +5885,10 @@ packages: unist-util-visit@5.0.0: resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==} + unix-dgram@2.0.7: + resolution: {integrity: sha512-pWaQorcdxEUBFIKjCqqIlQaOoNVmchyoaNAJ/1LwyyfK2XSxcBhgJNiSE8ZRhR0xkNGyk4xInt1G03QPoKXY5A==} + engines: {node: '>=0.10.48'} + unplugin-utils@0.2.5: resolution: {integrity: sha512-gwXJnPRewT4rT7sBi/IvxKTjsms7jX7QIDLOClApuZwR49SXbrB1z2NLUZ+vDHyqCj/n58OzRRqaW+B8OZi8vg==} engines: {node: '>=18.12.0'} @@ -10910,6 +10921,10 @@ snapshots: hookable@5.5.3: {} + hot-shots@11.2.0: + optionalDependencies: + unix-dgram: 2.0.7 + html-entities@2.6.0: {} http-errors@2.0.0: @@ -13335,6 +13350,12 @@ snapshots: unist-util-is: 6.0.0 unist-util-visit-parents: 6.0.1 + unix-dgram@2.0.7: + dependencies: + bindings: 1.5.0 + nan: 2.23.0 + optional: true + unplugin-utils@0.2.5: dependencies: pathe: 2.0.3 diff --git a/routes/_apis/artifactcache/caches/[cacheId].patch.ts b/routes/_apis/artifactcache/caches/[cacheId].patch.ts index b5ef264..e960f7f 100644 --- a/routes/_apis/artifactcache/caches/[cacheId].patch.ts +++ b/routes/_apis/artifactcache/caches/[cacheId].patch.ts @@ -1,8 +1,10 @@ import type { Buffer } from 'node:buffer' +import type { CacheMetricsTags } from '~/lib/metrics' import { z } from 'zod' +import { ENV } from '~/lib/env' import { logger } from '~/lib/logger' - +import { getMetrics, METRICS } from '~/lib/metrics' import { useStorageAdapter } from '~/lib/storage' const pathParamsSchema = z.object({ @@ -49,6 +51,22 @@ export default defineEventHandler(async (event) => { chunkStart: start, chunkIndex, }) + + // Record chunk upload metrics and cache size + if (ENV.METRICS_ENABLED) { + try { + const metrics = getMetrics() + const chunkSize = end - start + 1 + const tags: CacheMetricsTags = { + operation: 'upload', + storage_driver: ENV.STORAGE_DRIVER, + } + metrics.increment(METRICS.CACHE.UPLOAD_CHUNKS, 1, tags) + metrics.histogram(METRICS.CACHE.SIZE_BYTES, chunkSize, tags) + } catch (err) { + console.warn('Failed to record upload metrics:', err) + } + } }) function parseContentRangeHeader(contentRange: string) { diff --git a/routes/_apis/artifactcache/caches/index.get.ts b/routes/_apis/artifactcache/caches/index.get.ts index 8ade0f6..42007d9 100644 --- a/routes/_apis/artifactcache/caches/index.get.ts +++ b/routes/_apis/artifactcache/caches/index.get.ts @@ -1,6 +1,9 @@ -import { z } from 'zod' +import type { CacheMetricsTags } from '~/lib/metrics' +import { z } from 'zod' import { listEntriesByKey, useDB } from '~/lib/db' +import { ENV } from '~/lib/env' +import { getMetrics, METRICS } from '~/lib/metrics' const queryParamSchema = z.object({ key: z.string().min(1), @@ -19,6 +22,22 @@ export default defineEventHandler(async (event) => { const db = await useDB() const entries = await listEntriesByKey(db, key) + // Record cache hit/miss metrics + if (ENV.METRICS_ENABLED) { + try { + const metrics = getMetrics() + const operation = entries.length > 0 ? 'hit' : 'miss' + const tags: CacheMetricsTags = { + operation, + storage_driver: ENV.STORAGE_DRIVER, + } + metrics.increment(METRICS.CACHE.OPERATIONS_TOTAL, 1, tags) + } catch (err) { + // Don't fail the request if metrics fail + console.warn('Failed to record cache metrics:', err) + } + } + return { totalCount: entries.length, artifactCaches: entries.map((entry) => ({ diff --git a/routes/_apis/artifactcache/caches/index.post.ts b/routes/_apis/artifactcache/caches/index.post.ts index 76bdb10..3531861 100644 --- a/routes/_apis/artifactcache/caches/index.post.ts +++ b/routes/_apis/artifactcache/caches/index.post.ts @@ -1,5 +1,8 @@ -import { z } from 'zod' +import type { CacheMetricsTags } from '~/lib/metrics' +import { z } from 'zod' +import { ENV } from '~/lib/env' +import { getMetrics, METRICS } from '~/lib/metrics' import { useStorageAdapter } from '~/lib/storage' const bodySchema = z.object({ @@ -19,5 +22,21 @@ export default defineEventHandler(async (event) => { const { key, version } = parsedBody.data const adapter = await useStorageAdapter() - return adapter.reserveCache({ key, version }) + const result = await adapter.reserveCache({ key, version }) + + // Record cache creation metrics + if (ENV.METRICS_ENABLED) { + try { + const metrics = getMetrics() + const tags: CacheMetricsTags = { + operation: 'upload', + storage_driver: ENV.STORAGE_DRIVER, + } + metrics.increment(METRICS.CACHE.OPERATIONS_TOTAL, 1, tags) + } catch (err) { + console.warn('Failed to record cache creation metrics:', err) + } + } + + return result }) diff --git a/routes/download/[random]/[cacheFileName].ts b/routes/download/[random]/[cacheFileName].ts index 134ca63..c47cb48 100644 --- a/routes/download/[random]/[cacheFileName].ts +++ b/routes/download/[random]/[cacheFileName].ts @@ -1,6 +1,9 @@ -import type { CacheFileName } from '~/lib/storage/storage-driver' +import type { CacheMetricsTags } from '~/lib/metrics' +import type { CacheFileName } from '~/lib/storage/storage-driver' import { z } from 'zod' +import { ENV } from '~/lib/env' +import { getMetrics, METRICS } from '~/lib/metrics' import { useStorageAdapter } from '~/lib/storage' const pathParamsSchema = z.object({ @@ -19,11 +22,26 @@ export default defineEventHandler(async (event) => { const adapter = await useStorageAdapter() const stream = await adapter.download(cacheFileName as CacheFileName) - if (!stream) + if (!stream) { throw createError({ statusCode: 404, message: 'Cache file not found', }) + } + + // Record cache download metrics + if (ENV.METRICS_ENABLED) { + try { + const metrics = getMetrics() + const tags: CacheMetricsTags = { + operation: 'download', + storage_driver: ENV.STORAGE_DRIVER, + } + metrics.increment(METRICS.CACHE.OPERATIONS_TOTAL, 1, tags) + } catch (err) { + console.warn('Failed to record cache download metrics:', err) + } + } return sendStream(event, stream) }) diff --git a/routes/upload/[cacheId].put.ts b/routes/upload/[cacheId].put.ts index 8ed78bd..d332714 100644 --- a/routes/upload/[cacheId].put.ts +++ b/routes/upload/[cacheId].put.ts @@ -1,9 +1,11 @@ +import type { CacheMetricsTags } from '~/lib/metrics' import { Buffer } from 'node:buffer' -import { randomUUID } from 'node:crypto' +import { randomUUID } from 'node:crypto' import { z } from 'zod' +import { ENV } from '~/lib/env' import { logger } from '~/lib/logger' - +import { getMetrics, METRICS } from '~/lib/metrics' import { useStorageAdapter } from '~/lib/storage' // https://github.com/actions/toolkit/blob/340a6b15b5879eefe1412ee6c8606978b091d3e8/packages/cache/src/cache.ts#L470 @@ -60,6 +62,20 @@ export default defineEventHandler(async (event) => { chunkIndex, }) + // Record upload chunk metrics + if (ENV.METRICS_ENABLED) { + try { + const metrics = getMetrics() + const tags: CacheMetricsTags = { + operation: 'upload', + storage_driver: ENV.STORAGE_DRIVER, + } + metrics.increment(METRICS.CACHE.UPLOAD_CHUNKS, 1, tags) + } catch (err) { + console.warn('Failed to record upload chunk metrics:', err) + } + } + // prevent random EOF error with in tonistiigi/go-actions-cache caused by missing request id setHeader(event, 'x-ms-request-id', randomUUID()) setResponseStatus(event, 201)