Phase4
This commit is contained in:
102
packages/contracts/src/backups.ts
Normal file
102
packages/contracts/src/backups.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const backupStatusSchema = z.enum([
|
||||
'PENDING',
|
||||
'RUNNING',
|
||||
'AVAILABLE',
|
||||
'FAILED',
|
||||
'DELETED',
|
||||
]);
|
||||
|
||||
export const backupTypeSchema = z.enum([
|
||||
'MANUAL',
|
||||
'SCHEDULED',
|
||||
'PRE_RESTORE',
|
||||
'PRE_WORLD_REPLACE',
|
||||
]);
|
||||
|
||||
export const backupRestoreStatusSchema = z.enum([
|
||||
'PENDING',
|
||||
'RUNNING',
|
||||
'COMPLETED',
|
||||
'FAILED',
|
||||
]);
|
||||
|
||||
export const backupResponseSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
serverId: z.string().uuid(),
|
||||
type: backupTypeSchema,
|
||||
status: backupStatusSchema,
|
||||
s3Key: z.string().nullable(),
|
||||
sizeBytes: z.string().nullable(),
|
||||
sha256: z.string().nullable(),
|
||||
label: z.string().nullable(),
|
||||
failureReason: z.string().nullable(),
|
||||
createdAt: z.string().datetime(),
|
||||
completedAt: z.string().datetime().nullable(),
|
||||
expiresAt: z.string().datetime().nullable(),
|
||||
});
|
||||
|
||||
export const backupListResponseSchema = z.object({
|
||||
backups: z.array(backupResponseSchema),
|
||||
});
|
||||
|
||||
export const createBackupRequestSchema = z.object({
|
||||
label: z.string().max(128).optional(),
|
||||
});
|
||||
|
||||
export const backupActionResponseSchema = z.object({
|
||||
backup: backupResponseSchema,
|
||||
jobId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const backupDownloadUrlResponseSchema = z.object({
|
||||
downloadUrl: z.string().url(),
|
||||
expiresAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
export const restoreBackupRequestSchema = z.object({
|
||||
confirm: z.literal(true, {
|
||||
errorMap: () => ({ message: 'Restore must be explicitly confirmed' }),
|
||||
}),
|
||||
});
|
||||
|
||||
export const backupRestoreResponseSchema = z.object({
|
||||
restore: z.object({
|
||||
id: z.string().uuid(),
|
||||
serverId: z.string().uuid(),
|
||||
backupId: z.string().uuid(),
|
||||
safetyBackupId: z.string().uuid().nullable(),
|
||||
status: backupRestoreStatusSchema,
|
||||
failureReason: z.string().nullable(),
|
||||
createdAt: z.string().datetime(),
|
||||
completedAt: z.string().datetime().nullable(),
|
||||
}),
|
||||
jobId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const backupScheduleSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
intervalHours: z.number().int().min(1).max(168),
|
||||
retentionCount: z.number().int().min(1).max(50),
|
||||
nextRunAt: z.string().datetime().nullable(),
|
||||
lastRunAt: z.string().datetime().nullable(),
|
||||
});
|
||||
|
||||
export const updateBackupScheduleSchema = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
intervalHours: z.number().int().min(1).max(168).optional(),
|
||||
retentionCount: z.number().int().min(1).max(50).optional(),
|
||||
});
|
||||
|
||||
export type BackupStatus = z.infer<typeof backupStatusSchema>;
|
||||
export type BackupType = z.infer<typeof backupTypeSchema>;
|
||||
export type BackupResponse = z.infer<typeof backupResponseSchema>;
|
||||
export type BackupListResponse = z.infer<typeof backupListResponseSchema>;
|
||||
export type CreateBackupRequest = z.infer<typeof createBackupRequestSchema>;
|
||||
export type BackupActionResponse = z.infer<typeof backupActionResponseSchema>;
|
||||
export type BackupDownloadUrlResponse = z.infer<typeof backupDownloadUrlResponseSchema>;
|
||||
export type RestoreBackupRequest = z.infer<typeof restoreBackupRequestSchema>;
|
||||
export type BackupRestoreResponse = z.infer<typeof backupRestoreResponseSchema>;
|
||||
export type BackupSchedule = z.infer<typeof backupScheduleSchema>;
|
||||
export type UpdateBackupSchedule = z.infer<typeof updateBackupScheduleSchema>;
|
||||
@@ -113,3 +113,44 @@ export {
|
||||
type OperatorEntry,
|
||||
type PlayerListResponse,
|
||||
} from './players';
|
||||
|
||||
export {
|
||||
backupStatusSchema,
|
||||
backupTypeSchema,
|
||||
backupRestoreStatusSchema,
|
||||
backupResponseSchema,
|
||||
backupListResponseSchema,
|
||||
createBackupRequestSchema,
|
||||
backupActionResponseSchema,
|
||||
backupDownloadUrlResponseSchema,
|
||||
restoreBackupRequestSchema,
|
||||
backupRestoreResponseSchema,
|
||||
backupScheduleSchema,
|
||||
updateBackupScheduleSchema,
|
||||
type BackupStatus,
|
||||
type BackupType,
|
||||
type BackupResponse,
|
||||
type BackupListResponse,
|
||||
type CreateBackupRequest,
|
||||
type BackupActionResponse,
|
||||
type BackupDownloadUrlResponse,
|
||||
type RestoreBackupRequest,
|
||||
type BackupRestoreResponse,
|
||||
type BackupSchedule,
|
||||
type UpdateBackupSchedule,
|
||||
} from './backups';
|
||||
|
||||
export {
|
||||
worldSummarySchema,
|
||||
worldInfoResponseSchema,
|
||||
initiateWorldUploadSchema,
|
||||
worldUploadInitResponseSchema,
|
||||
worldUploadResponseSchema,
|
||||
worldDownloadUrlResponseSchema,
|
||||
type WorldSummary,
|
||||
type WorldInfoResponse,
|
||||
type InitiateWorldUpload,
|
||||
type WorldUploadInitResponse,
|
||||
type WorldUploadResponse,
|
||||
type WorldDownloadUrlResponse,
|
||||
} from './worlds';
|
||||
|
||||
@@ -8,6 +8,8 @@ export const gameServerStatusSchema = z.enum([
|
||||
'STARTING',
|
||||
'RUNNING',
|
||||
'STOPPING',
|
||||
'BACKING_UP',
|
||||
'RESTORING',
|
||||
'ERROR',
|
||||
'DELETING',
|
||||
'DELETED',
|
||||
@@ -53,6 +55,7 @@ export const serverResponseSchema = z.object({
|
||||
hostPort: z.number().int().nullable(),
|
||||
containerId: z.string().nullable(),
|
||||
dataPath: z.string().nullable(),
|
||||
activeWorldName: z.string(),
|
||||
ramMb: z.number().int(),
|
||||
createdAt: z.string().datetime(),
|
||||
updatedAt: z.string().datetime(),
|
||||
|
||||
59
packages/contracts/src/worlds.ts
Normal file
59
packages/contracts/src/worlds.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const worldSummarySchema = z.object({
|
||||
name: z.string(),
|
||||
path: z.string(),
|
||||
sizeBytes: z.string(),
|
||||
hasLevelDat: z.boolean(),
|
||||
});
|
||||
|
||||
export const worldInfoResponseSchema = z.object({
|
||||
activeWorldName: z.string(),
|
||||
worlds: z.array(worldSummarySchema),
|
||||
});
|
||||
|
||||
export const initiateWorldUploadSchema = z.object({
|
||||
worldName: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid world name'),
|
||||
});
|
||||
|
||||
export const worldUploadInitResponseSchema = z.object({
|
||||
uploadId: z.string().uuid(),
|
||||
uploadUrl: z.string().url(),
|
||||
expiresAt: z.string().datetime(),
|
||||
s3Key: z.string(),
|
||||
});
|
||||
|
||||
export const worldUploadResponseSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
serverId: z.string().uuid(),
|
||||
status: z.enum([
|
||||
'PENDING',
|
||||
'UPLOADING',
|
||||
'VALIDATING',
|
||||
'READY',
|
||||
'COMPLETED',
|
||||
'FAILED',
|
||||
]),
|
||||
worldName: z.string(),
|
||||
sizeBytes: z.string().nullable(),
|
||||
failureReason: z.string().nullable(),
|
||||
createdAt: z.string().datetime(),
|
||||
completedAt: z.string().datetime().nullable(),
|
||||
});
|
||||
|
||||
export const worldDownloadUrlResponseSchema = z.object({
|
||||
downloadUrl: z.string().url(),
|
||||
expiresAt: z.string().datetime(),
|
||||
backupId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export type WorldSummary = z.infer<typeof worldSummarySchema>;
|
||||
export type WorldInfoResponse = z.infer<typeof worldInfoResponseSchema>;
|
||||
export type InitiateWorldUpload = z.infer<typeof initiateWorldUploadSchema>;
|
||||
export type WorldUploadInitResponse = z.infer<typeof worldUploadInitResponseSchema>;
|
||||
export type WorldUploadResponse = z.infer<typeof worldUploadResponseSchema>;
|
||||
export type WorldDownloadUrlResponse = z.infer<typeof worldDownloadUrlResponseSchema>;
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,99 @@
|
||||
-- Phase 4: Worlds & Backups
|
||||
|
||||
-- AlterEnum
|
||||
ALTER TYPE "GameServerStatus" ADD VALUE 'BACKING_UP';
|
||||
ALTER TYPE "GameServerStatus" ADD VALUE 'RESTORING';
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "BackupStatus" AS ENUM ('PENDING', 'RUNNING', 'AVAILABLE', 'FAILED', 'DELETED');
|
||||
CREATE TYPE "BackupType" AS ENUM ('MANUAL', 'SCHEDULED', 'PRE_RESTORE', 'PRE_WORLD_REPLACE');
|
||||
CREATE TYPE "BackupRestoreStatus" AS ENUM ('PENDING', 'RUNNING', 'COMPLETED', 'FAILED');
|
||||
CREATE TYPE "WorldUploadStatus" AS ENUM ('PENDING', 'UPLOADING', 'VALIDATING', 'READY', 'COMPLETED', 'FAILED');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "game_servers" ADD COLUMN "activeWorldName" TEXT NOT NULL DEFAULT 'world';
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "server_backups" (
|
||||
"id" TEXT NOT NULL,
|
||||
"serverId" TEXT NOT NULL,
|
||||
"type" "BackupType" NOT NULL DEFAULT 'MANUAL',
|
||||
"status" "BackupStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"s3Key" TEXT,
|
||||
"sizeBytes" BIGINT,
|
||||
"sha256" TEXT,
|
||||
"label" TEXT,
|
||||
"failureReason" TEXT,
|
||||
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"completedAt" TIMESTAMPTZ(3),
|
||||
"expiresAt" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "server_backups_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "backup_restores" (
|
||||
"id" TEXT NOT NULL,
|
||||
"serverId" TEXT NOT NULL,
|
||||
"backupId" TEXT NOT NULL,
|
||||
"safetyBackupId" TEXT,
|
||||
"status" "BackupRestoreStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"failureReason" TEXT,
|
||||
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"completedAt" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "backup_restores_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "backup_schedules" (
|
||||
"id" TEXT NOT NULL,
|
||||
"serverId" TEXT NOT NULL,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"intervalHours" INTEGER NOT NULL DEFAULT 24,
|
||||
"retentionCount" INTEGER NOT NULL DEFAULT 5,
|
||||
"nextRunAt" TIMESTAMPTZ(3),
|
||||
"lastRunAt" TIMESTAMPTZ(3),
|
||||
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "backup_schedules_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "world_uploads" (
|
||||
"id" TEXT NOT NULL,
|
||||
"serverId" TEXT NOT NULL,
|
||||
"status" "WorldUploadStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"s3Key" TEXT NOT NULL,
|
||||
"worldName" TEXT NOT NULL DEFAULT 'world',
|
||||
"sha256" TEXT,
|
||||
"sizeBytes" BIGINT,
|
||||
"failureReason" TEXT,
|
||||
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"completedAt" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "world_uploads_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "server_backups_serverId_idx" ON "server_backups"("serverId");
|
||||
CREATE INDEX "server_backups_status_idx" ON "server_backups"("status");
|
||||
CREATE INDEX "server_backups_createdAt_idx" ON "server_backups"("createdAt");
|
||||
|
||||
CREATE INDEX "backup_restores_serverId_idx" ON "backup_restores"("serverId");
|
||||
CREATE INDEX "backup_restores_backupId_idx" ON "backup_restores"("backupId");
|
||||
CREATE INDEX "backup_restores_status_idx" ON "backup_restores"("status");
|
||||
|
||||
CREATE UNIQUE INDEX "backup_schedules_serverId_key" ON "backup_schedules"("serverId");
|
||||
|
||||
CREATE INDEX "world_uploads_serverId_idx" ON "world_uploads"("serverId");
|
||||
CREATE INDEX "world_uploads_status_idx" ON "world_uploads"("status");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "server_backups" ADD CONSTRAINT "server_backups_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "game_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "backup_restores" ADD CONSTRAINT "backup_restores_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "game_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "backup_restores" ADD CONSTRAINT "backup_restores_backupId_fkey" FOREIGN KEY ("backupId") REFERENCES "server_backups"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "backup_restores" ADD CONSTRAINT "backup_restores_safetyBackupId_fkey" FOREIGN KEY ("safetyBackupId") REFERENCES "server_backups"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "backup_schedules" ADD CONSTRAINT "backup_schedules_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "game_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "world_uploads" ADD CONSTRAINT "world_uploads_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "game_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -37,6 +37,8 @@ enum GameServerStatus {
|
||||
STARTING
|
||||
RUNNING
|
||||
STOPPING
|
||||
BACKING_UP
|
||||
RESTORING
|
||||
ERROR
|
||||
DELETING
|
||||
DELETED
|
||||
@@ -63,6 +65,37 @@ enum JobStatus {
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum BackupStatus {
|
||||
PENDING
|
||||
RUNNING
|
||||
AVAILABLE
|
||||
FAILED
|
||||
DELETED
|
||||
}
|
||||
|
||||
enum BackupType {
|
||||
MANUAL
|
||||
SCHEDULED
|
||||
PRE_RESTORE
|
||||
PRE_WORLD_REPLACE
|
||||
}
|
||||
|
||||
enum BackupRestoreStatus {
|
||||
PENDING
|
||||
RUNNING
|
||||
COMPLETED
|
||||
FAILED
|
||||
}
|
||||
|
||||
enum WorldUploadStatus {
|
||||
PENDING
|
||||
UPLOADING
|
||||
VALIDATING
|
||||
READY
|
||||
COMPLETED
|
||||
FAILED
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
@@ -219,6 +252,7 @@ model GameServer {
|
||||
containerId String?
|
||||
dataPath String?
|
||||
ramMb Int @default(1024)
|
||||
activeWorldName String @default("world")
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
||||
|
||||
@@ -227,6 +261,10 @@ model GameServer {
|
||||
node GameNode? @relation(fields: [nodeId], references: [id])
|
||||
stateTransitions GameServerStateTransition[]
|
||||
allocation GameServerAllocation?
|
||||
backups ServerBackup[]
|
||||
backupRestores BackupRestore[]
|
||||
backupSchedule BackupSchedule?
|
||||
worldUploads WorldUpload[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([nodeId])
|
||||
@@ -385,3 +423,82 @@ model JobRecord {
|
||||
@@index([createdAt])
|
||||
@@map("job_records")
|
||||
}
|
||||
|
||||
model ServerBackup {
|
||||
id String @id @default(uuid())
|
||||
serverId String
|
||||
type BackupType @default(MANUAL)
|
||||
status BackupStatus @default(PENDING)
|
||||
s3Key String?
|
||||
sizeBytes BigInt?
|
||||
sha256 String?
|
||||
label String?
|
||||
failureReason String?
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
completedAt DateTime? @db.Timestamptz(3)
|
||||
expiresAt DateTime? @db.Timestamptz(3)
|
||||
|
||||
server GameServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
|
||||
restores BackupRestore[] @relation("RestoredFromBackup")
|
||||
safetyForRestores BackupRestore[] @relation("SafetyBackup")
|
||||
|
||||
@@index([serverId])
|
||||
@@index([status])
|
||||
@@index([createdAt])
|
||||
@@map("server_backups")
|
||||
}
|
||||
|
||||
model BackupRestore {
|
||||
id String @id @default(uuid())
|
||||
serverId String
|
||||
backupId String
|
||||
safetyBackupId String?
|
||||
status BackupRestoreStatus @default(PENDING)
|
||||
failureReason String?
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
completedAt DateTime? @db.Timestamptz(3)
|
||||
|
||||
server GameServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
|
||||
backup ServerBackup @relation("RestoredFromBackup", fields: [backupId], references: [id])
|
||||
safetyBackup ServerBackup? @relation("SafetyBackup", fields: [safetyBackupId], references: [id])
|
||||
|
||||
@@index([serverId])
|
||||
@@index([backupId])
|
||||
@@index([status])
|
||||
@@map("backup_restores")
|
||||
}
|
||||
|
||||
model BackupSchedule {
|
||||
id String @id @default(uuid())
|
||||
serverId String @unique
|
||||
enabled Boolean @default(true)
|
||||
intervalHours Int @default(24)
|
||||
retentionCount Int @default(5)
|
||||
nextRunAt DateTime? @db.Timestamptz(3)
|
||||
lastRunAt DateTime? @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
||||
|
||||
server GameServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("backup_schedules")
|
||||
}
|
||||
|
||||
model WorldUpload {
|
||||
id String @id @default(uuid())
|
||||
serverId String
|
||||
status WorldUploadStatus @default(PENDING)
|
||||
s3Key String
|
||||
worldName String @default("world")
|
||||
sha256 String?
|
||||
sizeBytes BigInt?
|
||||
failureReason String?
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
completedAt DateTime? @db.Timestamptz(3)
|
||||
|
||||
server GameServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([serverId])
|
||||
@@index([status])
|
||||
@@map("world_uploads")
|
||||
}
|
||||
|
||||
@@ -10,7 +10,12 @@ export type {
|
||||
PlatformRole,
|
||||
UserPlatformRole,
|
||||
GameServer,
|
||||
GameServerAllocation,
|
||||
GameServerStateTransition,
|
||||
ServerBackup,
|
||||
BackupRestore,
|
||||
BackupSchedule,
|
||||
WorldUpload,
|
||||
GameNode,
|
||||
GameNodeHeartbeat,
|
||||
Plan,
|
||||
|
||||
File diff suppressed because one or more lines are too long
23
packages/storage/package.json
Normal file
23
packages/storage/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@hexahost/storage",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.750.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.750.0",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hexahost/typescript-config": "workspace:*",
|
||||
"@types/node": "^22.10.2",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^3.0.8"
|
||||
}
|
||||
}
|
||||
90
packages/storage/src/client.ts
Normal file
90
packages/storage/src/client.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
DeleteObjectCommand,
|
||||
HeadObjectCommand,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
|
||||
import type { StorageConfig } from './config';
|
||||
|
||||
export function createS3Client(config: StorageConfig): S3Client {
|
||||
return new S3Client({
|
||||
region: config.S3_REGION,
|
||||
endpoint: config.S3_ENDPOINT,
|
||||
forcePathStyle: config.S3_FORCE_PATH_STYLE,
|
||||
credentials: {
|
||||
accessKeyId: config.S3_ACCESS_KEY,
|
||||
secretAccessKey: config.S3_SECRET_KEY,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function backupObjectKey(serverId: string, backupId: string): string {
|
||||
return `servers/${serverId}/backups/${backupId}.tar.gz`;
|
||||
}
|
||||
|
||||
export function worldUploadObjectKey(serverId: string, uploadId: string): string {
|
||||
return `servers/${serverId}/uploads/${uploadId}/world.tar.gz`;
|
||||
}
|
||||
|
||||
export async function createPresignedPutUrl(
|
||||
client: S3Client,
|
||||
bucket: string,
|
||||
key: string,
|
||||
expiresInSeconds: number,
|
||||
contentType = 'application/gzip',
|
||||
): Promise<string> {
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
ContentType: contentType,
|
||||
});
|
||||
|
||||
return getSignedUrl(client, command, { expiresIn: expiresInSeconds });
|
||||
}
|
||||
|
||||
export async function createPresignedGetUrl(
|
||||
client: S3Client,
|
||||
bucket: string,
|
||||
key: string,
|
||||
expiresInSeconds: number,
|
||||
): Promise<string> {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
});
|
||||
|
||||
return getSignedUrl(client, command, { expiresIn: expiresInSeconds });
|
||||
}
|
||||
|
||||
export async function deleteStoredObject(
|
||||
client: S3Client,
|
||||
bucket: string,
|
||||
key: string,
|
||||
): Promise<void> {
|
||||
await client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function headStoredObject(
|
||||
client: S3Client,
|
||||
bucket: string,
|
||||
key: string,
|
||||
): Promise<{ sizeBytes: number; etag?: string }> {
|
||||
const response = await client.send(
|
||||
new HeadObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
sizeBytes: Number(response.ContentLength ?? 0),
|
||||
etag: response.ETag,
|
||||
};
|
||||
}
|
||||
28
packages/storage/src/config.ts
Normal file
28
packages/storage/src/config.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const storageConfigSchema = z.object({
|
||||
S3_ENDPOINT: z.string().url(),
|
||||
S3_REGION: z.string().min(1).default('us-east-1'),
|
||||
S3_BUCKET: z.string().min(1),
|
||||
S3_ACCESS_KEY: z.string().min(1),
|
||||
S3_SECRET_KEY: z.string().min(1),
|
||||
S3_FORCE_PATH_STYLE: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => value === 'true' || value === '1'),
|
||||
});
|
||||
|
||||
export type StorageConfig = z.infer<typeof storageConfigSchema>;
|
||||
|
||||
export function loadStorageConfig(
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
): StorageConfig {
|
||||
return storageConfigSchema.parse({
|
||||
S3_ENDPOINT: env['S3_ENDPOINT'],
|
||||
S3_REGION: env['S3_REGION'],
|
||||
S3_BUCKET: env['S3_BUCKET'],
|
||||
S3_ACCESS_KEY: env['S3_ACCESS_KEY'],
|
||||
S3_SECRET_KEY: env['S3_SECRET_KEY'],
|
||||
S3_FORCE_PATH_STYLE: env['S3_FORCE_PATH_STYLE'],
|
||||
});
|
||||
}
|
||||
15
packages/storage/src/index.ts
Normal file
15
packages/storage/src/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export {
|
||||
storageConfigSchema,
|
||||
loadStorageConfig,
|
||||
type StorageConfig,
|
||||
} from './config';
|
||||
|
||||
export {
|
||||
createS3Client,
|
||||
backupObjectKey,
|
||||
worldUploadObjectKey,
|
||||
createPresignedPutUrl,
|
||||
createPresignedGetUrl,
|
||||
deleteStoredObject,
|
||||
headStoredObject,
|
||||
} from './client';
|
||||
17
packages/storage/src/keys.test.ts
Normal file
17
packages/storage/src/keys.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { backupObjectKey, worldUploadObjectKey } from './client';
|
||||
|
||||
describe('storage object keys', () => {
|
||||
it('builds backup keys', () => {
|
||||
expect(backupObjectKey('srv-1', 'bak-1')).toBe(
|
||||
'servers/srv-1/backups/bak-1.tar.gz',
|
||||
);
|
||||
});
|
||||
|
||||
it('builds world upload keys', () => {
|
||||
expect(worldUploadObjectKey('srv-1', 'up-1')).toBe(
|
||||
'servers/srv-1/uploads/up-1/world.tar.gz',
|
||||
);
|
||||
});
|
||||
});
|
||||
9
packages/storage/tsconfig.json
Normal file
9
packages/storage/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@hexahost/typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user