Skip to content

Commit d00cb39

Browse files
committed
feat: management api
1 parent aae281f commit d00cb39

File tree

14 files changed

+699
-43
lines changed

14 files changed

+699
-43
lines changed

.env

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
API_BASE_URL=http://localhost:3000
2-
DEBUG=true
2+
DEBUG=true
3+
MANAGEMENT_API_KEY=secret

install/kubernetes/github-actions-cache-server/templates/_helpers.tpl

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,10 @@ Generate environment variables from config values.
135135
- name: DEBUG
136136
value: "true"
137137
{{- end }}
138+
{{- if .Values.config.managementApiKey }}
139+
- name: MANAGEMENT_API_KEY
140+
value: {{ .Values.config.managementApiKey | quote }}
141+
{{- end }}
138142
{{/* Storage driver */}}
139143
- name: STORAGE_DRIVER
140144
value: {{ .Values.config.storage.driver | quote }}

install/kubernetes/github-actions-cache-server/values.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ config:
4848
# -- Enable debug logging.
4949
# debug: false
5050

51+
# -- API key for the management API. When set, the management API is enabled
52+
# and protected with this key (passed via the X-Api-Key header).
53+
# Can also be provided via existingSecret (key: MANAGEMENT_API_KEY).
54+
managementApiKey: ''
55+
5156
# -- Storage driver configuration
5257
# See https://gha-cache-server.falcondev.io/storage-drivers
5358
storage:

lib/api/base.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type { H3Event } from 'h3'
2+
import { ORPCError, os } from '@orpc/server'
3+
import { getDatabase } from '../db'
4+
import { env } from '../env'
5+
import { getStorage } from '../storage'
6+
7+
export const base = os
8+
.$context<{
9+
event: H3Event
10+
}>()
11+
.use(async ({ next, context }) => {
12+
const apiKey = getHeader(context.event, 'x-api-key')
13+
if (apiKey !== env.MANAGEMENT_API_KEY) throw new ORPCError('UNAUTHORIZED')
14+
15+
const storage = await getStorage()
16+
const db = await getDatabase()
17+
18+
return next({
19+
context: {
20+
db,
21+
storage,
22+
},
23+
})
24+
})

lib/api/cache-entries.ts

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import { ORPCError } from '@orpc/client'
2+
import z from 'zod'
3+
import { cacheEntrySchema } from '../db'
4+
import { base } from './base'
5+
6+
export const cacheEntriesRouter = base
7+
.prefix('/cache-entries')
8+
.tag('Cache Entries')
9+
.router({
10+
get: base
11+
.route({
12+
method: 'GET',
13+
path: '/{id}',
14+
summary: 'Get cache entry',
15+
description: 'Retrieve a single cache entry by its id.',
16+
})
17+
.input(z.object({ id: z.string() }))
18+
.output(cacheEntrySchema)
19+
.errors({
20+
NOT_FOUND: {
21+
message: 'Cache entry not found',
22+
},
23+
})
24+
.handler(async ({ input, context }) => {
25+
const cacheEntry = await context.db
26+
.selectFrom('cache_entries')
27+
.where('id', '=', input.id)
28+
.selectAll()
29+
.executeTakeFirst()
30+
if (!cacheEntry) throw new ORPCError('NOT_FOUND')
31+
32+
return cacheEntry
33+
}),
34+
match: base
35+
.route({
36+
method: 'GET',
37+
path: '/match',
38+
summary: 'Match cache entry',
39+
description:
40+
'Find the best matching cache entry using the primary key and optional restore keys across the given scopes. Returns the matched entry along with the match type, or null if no match is found. Basically what the cache server does when deciding which cache entry to restore for a given cache restore request.',
41+
})
42+
.input(
43+
z.object({
44+
primaryKey: z.string().describe('The primary cache key to match against'),
45+
restoreKeys: z
46+
.array(z.string())
47+
.optional()
48+
.describe('Optional fallback keys to try if the primary key does not match'),
49+
scopes: z.array(z.string()).describe('Scopes to search within, checked in order'),
50+
version: z.string().describe('Cache version identifier'),
51+
}),
52+
)
53+
.output(
54+
z
55+
.object({
56+
match: cacheEntrySchema,
57+
type: z
58+
.enum(['exact-primary', 'prefixed-primary', 'exact-restore', 'prefixed-restore'])
59+
.describe(
60+
'How the match was found: exact-primary (exact primary key match), prefixed-primary (primary key prefix match), exact-restore (exact restore key match), prefixed-restore (restore key prefix match)',
61+
),
62+
})
63+
.nullable(),
64+
)
65+
.handler(async ({ input, context }) => {
66+
const cacheEntry = await context.storage.matchCacheEntry({
67+
keys: [input.primaryKey, ...(input.restoreKeys ?? [])],
68+
scopes: input.scopes,
69+
version: input.version,
70+
})
71+
72+
return cacheEntry ?? null
73+
}),
74+
findMany: base
75+
.route({
76+
method: 'GET',
77+
path: '/',
78+
summary: 'List cache entries',
79+
description:
80+
'Retrieve a paginated list of cache entries, optionally filtered by key, version, and scope.',
81+
})
82+
.input(
83+
z.object({
84+
key: z.string().optional().describe('Filter by exact cache key'),
85+
version: z.string().optional().describe('Filter by exact cache version'),
86+
scope: z.string().optional().describe('Filter by exact cache scope'),
87+
itemsPerPage: z
88+
.number()
89+
.int()
90+
.positive()
91+
.min(1)
92+
.max(100)
93+
.default(20)
94+
.describe('Number of items per page'),
95+
page: z.number().int().positive().min(1).default(1).describe('Page number'),
96+
}),
97+
)
98+
.output(
99+
z.object({
100+
total: z.number(),
101+
items: z.array(cacheEntrySchema),
102+
}),
103+
)
104+
.handler(async ({ input, context }) => {
105+
const query = context.db.selectFrom('cache_entries')
106+
if (input.key) query.where('key', '=', input.key)
107+
if (input.version) query.where('version', '=', input.version)
108+
if (input.scope) query.where('scope', '=', input.scope)
109+
110+
const [cacheEntries, countResult] = await Promise.all([
111+
query
112+
.selectAll()
113+
.limit(input.itemsPerPage)
114+
.offset((input.page - 1) * input.itemsPerPage)
115+
.execute(),
116+
query.select((eb) => [eb.fn.countAll<number>().as('count')]).executeTakeFirst(),
117+
])
118+
119+
return {
120+
total: countResult?.count ?? 0,
121+
items: cacheEntries,
122+
}
123+
}),
124+
delete: base
125+
.route({
126+
method: 'DELETE',
127+
path: '/{id}',
128+
summary: 'Delete cache entry',
129+
description:
130+
'Delete a single cache entry by its id. Triggers cleanup of orphaned storage locations.',
131+
})
132+
.input(
133+
z.object({
134+
id: z.string(),
135+
}),
136+
)
137+
.handler(async ({ input, context }) => {
138+
await context.db.deleteFrom('cache_entries').where('id', '=', input.id).execute()
139+
context.event.waitUntil(runTask('cleanup:storage-locations'))
140+
}),
141+
deleteMany: base
142+
.route({
143+
method: 'DELETE',
144+
path: '/',
145+
summary: 'Delete cache entries',
146+
description:
147+
'Delete multiple cache entries matching the given filters. Triggers cleanup of orphaned storage locations.',
148+
})
149+
.input(
150+
z.object({
151+
key: z.string().optional().describe('Filter by exact cache key'),
152+
version: z.string().optional().describe('Filter by exact cache version'),
153+
scope: z.string().optional().describe('Filter by exact cache scope'),
154+
}),
155+
)
156+
.handler(async ({ input, context }) => {
157+
const query = context.db.deleteFrom('cache_entries')
158+
if (input.key) query.where('key', '=', input.key)
159+
if (input.version) query.where('version', '=', input.version)
160+
if (input.scope) query.where('scope', '=', input.scope)
161+
162+
await query.execute()
163+
context.event.waitUntil(runTask('cleanup:storage-locations'))
164+
}),
165+
})

lib/api/storage-locations.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { ORPCError } from '@orpc/client'
2+
import z from 'zod'
3+
import { storageLocationSchema } from '../db'
4+
import { base } from './base'
5+
6+
export const storageLocationsRouter = base
7+
.prefix('/storage-locations')
8+
.tag('Storage Locations')
9+
.router({
10+
get: base
11+
.route({
12+
method: 'GET',
13+
path: '/{id}',
14+
summary: 'Get storage location',
15+
description: 'Retrieve a single storage location by its id.',
16+
})
17+
.input(z.object({ id: z.string() }))
18+
.output(storageLocationSchema)
19+
.errors({
20+
NOT_FOUND: {
21+
message: 'Storage location not found',
22+
},
23+
})
24+
.handler(async ({ input, context }) => {
25+
const storageLocation = await context.db
26+
.selectFrom('storage_locations')
27+
.where('id', '=', input.id)
28+
.selectAll()
29+
.executeTakeFirst()
30+
if (!storageLocation) throw new ORPCError('NOT_FOUND')
31+
32+
return storageLocation
33+
}),
34+
delete: base
35+
.route({
36+
method: 'DELETE',
37+
path: '/{id}',
38+
summary: 'Delete storage location',
39+
description:
40+
'Delete a storage location by its id. Removes the associated folder from storage.',
41+
})
42+
.input(z.object({ id: z.string() }))
43+
.handler(async ({ input, context }) => {
44+
const storageLocation = await context.db
45+
.selectFrom('storage_locations')
46+
.where('id', '=', input.id)
47+
.selectAll()
48+
.executeTakeFirst()
49+
if (!storageLocation) return
50+
51+
await context.db.transaction().execute(async (tx) => {
52+
await tx.deleteFrom('storage_locations').where('id', '=', input.id).execute()
53+
await context.storage.adapter.deleteFolder(storageLocation.folderName)
54+
})
55+
}),
56+
})

lib/db.ts

Lines changed: 32 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -7,43 +7,44 @@ import { Kysely, Migrator, MysqlDialect, PostgresDialect, SqliteDialect } from '
77
import { createPool } from 'mysql2'
88
import pg from 'pg'
99
import { match } from 'ts-pattern'
10+
import z from 'zod'
1011
import { env } from './env'
1112
import { logger } from './logger'
1213
import { migrations } from './migrations'
1314

14-
interface CacheEntry {
15-
id: string
16-
key: string
17-
version: string
18-
scope: string
19-
updatedAt: number
20-
locationId: string
21-
}
15+
export const cacheEntrySchema = z.object({
16+
id: z.string(),
17+
key: z.string(),
18+
version: z.string(),
19+
scope: z.string(),
20+
updatedAt: z.number(),
21+
locationId: z.string(),
22+
})
23+
type CacheEntry = z.infer<typeof cacheEntrySchema>
2224

23-
export interface StorageLocation {
24-
id: string
25-
folderName: string
26-
/**
27-
* Number of parts uploaded for this entry or null if parts have already been combined
28-
*/
29-
partCount: number
30-
mergeStartedAt: number | null
31-
mergedAt: number | null
32-
partsDeletedAt: number | null
33-
lastDownloadedAt: number | null
34-
}
25+
export const storageLocationSchema = z.object({
26+
id: z.string(),
27+
folderName: z.string(),
28+
partCount: z.number(),
29+
mergeStartedAt: z.number().nullable(),
30+
mergedAt: z.number().nullable(),
31+
partsDeletedAt: z.number().nullable(),
32+
lastDownloadedAt: z.number().nullable(),
33+
})
34+
export type StorageLocation = z.infer<typeof storageLocationSchema>
3535

36-
interface Upload {
37-
id: number
38-
key: string
39-
version: string
40-
scope: string
41-
createdAt: number
42-
lastPartUploadedAt: number | null
43-
startedPartUploadCount: number
44-
finishedPartUploadCount: number
45-
folderName: string
46-
}
36+
export const uploadSchema = z.object({
37+
id: z.number(),
38+
key: z.string(),
39+
version: z.string(),
40+
scope: z.string(),
41+
createdAt: z.number(),
42+
lastPartUploadedAt: z.number().nullable(),
43+
startedPartUploadCount: z.number(),
44+
finishedPartUploadCount: z.number(),
45+
folderName: z.string(),
46+
})
47+
type Upload = z.infer<typeof uploadSchema>
4748

4849
export interface Database {
4950
cache_entries: CacheEntry

lib/schemas.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export const envBaseSchema = type({
6565
'ENABLE_DIRECT_DOWNLOADS': 'boolean = false',
6666
'BENCHMARK': 'boolean = false',
6767
'SKIP_TOKEN_VALIDATION': 'boolean = false',
68+
'MANAGEMENT_API_KEY?': 'string',
6869
})
6970

7071
export const envSchema = envBaseSchema.and(envStorageDriverSchema).and(envDbDriverSchema)

0 commit comments

Comments
 (0)