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
34 changes: 34 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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.
88 changes: 88 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

👉 <https://gha-cache-server.falcondev.io/getting-started> 👈
4 changes: 4 additions & 0 deletions lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
104 changes: 104 additions & 0 deletions lib/metrics.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>
}

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<string, string>): void {
if (!this.client) return
this.client.increment(metric, value, this.formatTags(tags))
}

histogram(metric: string, value: number, tags?: Record<string, string>): void {
if (!this.client) return
this.client.histogram(metric, value, this.formatTags(tags))
}

gauge(metric: string, value: number, tags?: Record<string, string>): void {
if (!this.client) return
this.client.gauge(metric, value, this.formatTags(tags))
}

timing(metric: string, value: number, tags?: Record<string, string>): void {
if (!this.client) return
this.client.timing(metric, value, this.formatTags(tags))
}

private formatTags(tags?: Record<string, string>): 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<string, string> {
method: string
endpoint: string
status_code: string
}

export interface CacheMetricsTags extends Record<string, string> {
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
9 changes: 8 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"type": "module",
"version": "8.1.4",
"private": true,
"packageManager": "pnpm@10.17.1+sha512.17c560fca4867ae9473a3899ad84a88334914f379be46d455cbf92e5cf4b39d34985d452d2583baf19967fa76cb5c17bc9e245529d0b98745721aa7200ecaf7a",
"engines": {
"node": "22",
"pnpm": "10"
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -76,7 +78,12 @@
"better-sqlite3",
"cpu-features",
"esbuild",
"ssh2"
"protobufjs",
"ssh2",
"unrs-resolver"
],
"ignoredBuiltDependencies": [
"unix-dgram"
]
}
}
84 changes: 79 additions & 5 deletions plugins/setup.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,102 @@
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})`)

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) {
Expand Down
Loading