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
9 changes: 8 additions & 1 deletion packages/server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,15 @@ POSTGRES_HOST=localhost
POSTGRES_PORT=5436
POSTGRES_DB=kinora

# Local object storage for trace.zip + attachments (S3/R2 later)
# Artifact storage (trace.zip + attachments).
# Leave the S3_* vars empty to store on local disk under STORAGE_DIR.
# Set all five S3_* to use any S3-compatible store (AWS, R2, MinIO, Hetzner); STORAGE_DIR is then ignored.
STORAGE_DIR=.data/artifacts
S3_ENDPOINT=
S3_REGION=
S3_BUCKET=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=

# Google
GOOGLE_CLIENT_ID=
Expand Down
2 changes: 2 additions & 0 deletions packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
"purge-expired-runs": "tsx scripts/purge-expired-runs.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1065.0",
"@aws-sdk/s3-request-presigner": "^3.1065.0",
"@better-auth/api-key": "^1.5.6",
"@hono/node-server": "^2.0.4",
"@hono/trpc-server": "^0.4.2",
Expand Down
30 changes: 29 additions & 1 deletion packages/server/src/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ const envSchema = z.object({
POSTGRES_HOST: z.string(),
POSTGRES_PORT: z.coerce.number(),
POSTGRES_DB: z.string(),
STORAGE_DIR: z.string(),
GOOGLE_CLIENT_ID: z.string(),
GOOGLE_CLIENT_SECRET: z.string(),
GITHUB_CLIENT_ID: z.string(),
Expand All @@ -22,6 +21,12 @@ const envSchema = z.object({
POLAR_WEBHOOK_SECRET: z.string().optional(),
POLAR_PRODUCT_TEAM_ID: z.string().optional(),
POLAR_PRODUCT_PRO_ID: z.string().optional(),
STORAGE_DIR: z.string().default('.data/artifacts'),
S3_ENDPOINT: z.string().optional(),
S3_REGION: z.string().optional(),
S3_BUCKET: z.string().optional(),
S3_ACCESS_KEY_ID: z.string().optional(),
S3_SECRET_ACCESS_KEY: z.string().optional(),
}).refine(
e => !e.KINORA_CLOUD || Boolean(e.POLAR_ACCESS_TOKEN && e.POLAR_WEBHOOK_SECRET && e.POLAR_PRODUCT_TEAM_ID && e.POLAR_PRODUCT_PRO_ID),
{ message: 'KINORA_CLOUD=true requires POLAR_ACCESS_TOKEN, POLAR_WEBHOOK_SECRET, POLAR_PRODUCT_TEAM_ID and POLAR_PRODUCT_PRO_ID' },
Expand Down Expand Up @@ -55,3 +60,26 @@ function resolveCloud(): CloudConfig | null {
}

export const cloud = resolveCloud()

export interface S3Config {
endpoint: string
region: string
bucket: string
accessKey: string
secretKey: string
}

function resolveS3(): S3Config | null {
const {
S3_ENDPOINT: endpoint,
S3_REGION: region,
S3_BUCKET: bucket,
S3_ACCESS_KEY_ID: accessKey,
S3_SECRET_ACCESS_KEY: secretKey,
} = env
if (!endpoint || !region || !bucket || !accessKey || !secretKey)
return null
return { endpoint, region, bucket, accessKey, secretKey }
}

export const s3 = resolveS3()
66 changes: 50 additions & 16 deletions packages/server/src/lib/storage.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,61 @@
import type { Buffer } from 'node:buffer'
import type { S3Config } from './env'
import { mkdir, rm, writeFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { env } from './env'
import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
import { env, s3 } from './env'

// Local FS (dev/self-host) or any S3-compatible store. url() returns a presigned
// GET so the trace viewer fetches large trace.zip straight from storage (range-capable).
export interface Storage {
put: (key: string, body: Buffer | Uint8Array) => Promise<void>
url: (key: string) => string
url: (key: string) => Promise<string>
delete: (key: string) => Promise<void>
}

const root = resolve(env.STORAGE_DIR)
function localStorage(): Storage {
const root = resolve(env.STORAGE_DIR)
return {
async put(key, body) {
const dest = join(root, key)
await mkdir(dirname(dest), { recursive: true })
await writeFile(dest, body)
},
async url(key) {
return `${env.BASE_URL}/artifacts/${key}`
},
async delete(key) {
// force ignores a missing file, so retention purge stays idempotent.
await rm(join(root, key), { force: true })
},
}
}

export const storage: Storage = {
async put(key, body) {
const dest = join(root, key)
await mkdir(dirname(dest), { recursive: true })
await writeFile(dest, body)
},
url(key) {
return `${env.BASE_URL}/artifacts/${key}`
},
async delete(key) {
// force ignores a missing file, so retention purge stays idempotent.
await rm(join(root, key), { force: true })
},
function s3Storage(config: S3Config): Storage {
const client = new S3Client({
endpoint: config.endpoint,
region: config.region,
credentials: { accessKeyId: config.accessKey, secretAccessKey: config.secretKey },
// Most S3-compatible providers (MinIO, Hetzner) need path-style URLs.
forcePathStyle: true,
})
return {
async put(key, body) {
await client.send(new PutObjectCommand({
Bucket: config.bucket,
Key: key,
Body: body,
CacheControl: 'public, max-age=31536000, immutable',
}))
},
async url(key) {
return getSignedUrl(client, new GetObjectCommand({ Bucket: config.bucket, Key: key }), { expiresIn: 3600 })
},
async delete(key) {
await client.send(new DeleteObjectCommand({ Bucket: config.bucket, Key: key }))
},
}
}

export const storage: Storage = s3 ? s3Storage(s3) : localStorage()
2 changes: 1 addition & 1 deletion packages/server/src/public-api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,5 +208,5 @@ publicApi.post('/runs/:runId/artifacts', async (c) => {
size: buf.length,
})

return c.json({ url: storage.url(key) }, 201)
return c.json({ url: await storage.url(key) }, 201)
})
2 changes: 1 addition & 1 deletion packages/server/src/router/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export const dashboardRouter = router({
if (!a.testId)
continue
const m = urlsByTest.get(a.testId) ?? new Map<string, string>()
m.set(a.name, storage.url(a.storageKey))
m.set(a.name, await storage.url(a.storageKey))
urlsByTest.set(a.testId, m)
}

Expand Down
5 changes: 5 additions & 0 deletions packages/server/test/test-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,9 @@ export const TEST_ENV: Record<keyof Env, string> = {
POLAR_WEBHOOK_SECRET: '',
POLAR_PRODUCT_TEAM_ID: 'prod_team_test',
POLAR_PRODUCT_PRO_ID: 'prod_pro_test',
S3_ENDPOINT: '',
S3_REGION: '',
S3_BUCKET: '',
S3_ACCESS_KEY_ID: '',
S3_SECRET_ACCESS_KEY: '',
}
Loading