Docker images / prepare-release (push) Successful in 9s
Docker images / backend-test (push) Successful in 2m18s
Docker images / frontend-image (push) Successful in 4m37s
Docker images / updater-image (push) Successful in 46s
Docker images / backend-image (push) Successful in 3m6s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 16s
Сохранять снимки в S3-compatible бакет, скачивать и удалять их вместе с локальными файлами. Привести /backups к DNA /servers: Frame, KPI, фильтры и реальное восстановление. Co-authored-by: Cursor <[email protected]>
129 lines
3.6 KiB
TypeScript
129 lines
3.6 KiB
TypeScript
import {
|
|
DeleteObjectCommand,
|
|
GetObjectCommand,
|
|
HeadBucketCommand,
|
|
ListObjectsV2Command,
|
|
PutObjectCommand,
|
|
S3Client,
|
|
type S3ClientConfig,
|
|
} from "@aws-sdk/client-s3"
|
|
|
|
export type S3BackupConfig = {
|
|
endpoint: string
|
|
region: string
|
|
bucket: string
|
|
prefix: string
|
|
accessKeyId: string
|
|
secretAccessKey: string
|
|
forcePathStyle: boolean
|
|
}
|
|
|
|
export type S3ListedObject = {
|
|
key: string
|
|
size: number
|
|
lastModified?: string
|
|
}
|
|
|
|
export function normalizeS3Prefix(prefix: string): string {
|
|
return prefix.trim().replace(/^\/+|\/+$/g, "")
|
|
}
|
|
|
|
export function sanitizeServerName(name: string): string {
|
|
const safe = name.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, "")
|
|
return safe || "server"
|
|
}
|
|
|
|
export function buildS3ObjectKey(prefix: string, serverSafe: string, filename: string): string {
|
|
const parts = [normalizeS3Prefix(prefix), sanitizeServerName(serverSafe), filename]
|
|
.filter((part) => part.length > 0)
|
|
return parts.join("/")
|
|
}
|
|
|
|
export function parseS3ObjectKey(key: string): { filename: string; serverName: string } {
|
|
const parts = key.split("/").filter(Boolean)
|
|
const filename = parts.pop() ?? key
|
|
const folder = parts.pop() ?? ""
|
|
const base = filename.replace(/\.(rsc|backup)$/i, "")
|
|
const fromFilename = base.replace(/_\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}$/, "")
|
|
return { filename, serverName: folder || fromFilename || filename }
|
|
}
|
|
|
|
export function createS3ClientFromConfig(cfg: S3BackupConfig): S3Client {
|
|
const options: S3ClientConfig = {
|
|
region: cfg.region.trim() || "us-east-1",
|
|
credentials: {
|
|
accessKeyId: cfg.accessKeyId,
|
|
secretAccessKey: cfg.secretAccessKey,
|
|
},
|
|
forcePathStyle: cfg.forcePathStyle,
|
|
}
|
|
const endpoint = cfg.endpoint.trim()
|
|
if (endpoint) options.endpoint = endpoint
|
|
return new S3Client(options)
|
|
}
|
|
|
|
export async function s3TestConnection(client: S3Client, bucket: string): Promise<void> {
|
|
try {
|
|
await client.send(new HeadBucketCommand({ Bucket: bucket }))
|
|
} catch {
|
|
await client.send(new ListObjectsV2Command({ Bucket: bucket, MaxKeys: 1 }))
|
|
}
|
|
}
|
|
|
|
export async function s3PutObject(
|
|
client: S3Client,
|
|
bucket: string,
|
|
key: string,
|
|
body: Buffer | string,
|
|
): Promise<{ etag?: string }> {
|
|
const out = await client.send(new PutObjectCommand({
|
|
Bucket: bucket,
|
|
Key: key,
|
|
Body: body,
|
|
ContentType: "text/plain; charset=utf-8",
|
|
}))
|
|
return { etag: out.ETag }
|
|
}
|
|
|
|
export async function s3GetObject(
|
|
client: S3Client,
|
|
bucket: string,
|
|
key: string,
|
|
): Promise<Buffer> {
|
|
const out = await client.send(new GetObjectCommand({ Bucket: bucket, Key: key }))
|
|
const bytes = await out.Body?.transformToByteArray()
|
|
if (!bytes) throw new Error("Пустой объект S3")
|
|
return Buffer.from(bytes)
|
|
}
|
|
|
|
export async function s3DeleteObject(client: S3Client, bucket: string, key: string): Promise<void> {
|
|
await client.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }))
|
|
}
|
|
|
|
export async function s3ListObjects(
|
|
client: S3Client,
|
|
bucket: string,
|
|
prefix: string,
|
|
): Promise<S3ListedObject[]> {
|
|
const items: S3ListedObject[] = []
|
|
let token: string | undefined
|
|
const normalized = normalizeS3Prefix(prefix)
|
|
do {
|
|
const out = await client.send(new ListObjectsV2Command({
|
|
Bucket: bucket,
|
|
Prefix: normalized ? `${normalized}/` : undefined,
|
|
ContinuationToken: token,
|
|
}))
|
|
for (const obj of out.Contents ?? []) {
|
|
if (!obj.Key) continue
|
|
items.push({
|
|
key: obj.Key,
|
|
size: obj.Size ?? 0,
|
|
lastModified: obj.LastModified?.toISOString(),
|
|
})
|
|
}
|
|
token = out.IsTruncated ? out.NextContinuationToken : undefined
|
|
} while (token)
|
|
return items
|
|
}
|