d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
321 lines
11 KiB
TypeScript
321 lines
11 KiB
TypeScript
import { createLogger } from '@sim/logger'
|
|
import { toError } from '@sim/utils/errors'
|
|
import { generateShortId } from '@sim/utils/id'
|
|
import { backoffWithJitter, parseRetryAfter } from '@sim/utils/retry'
|
|
import { JWT } from 'google-auth-library'
|
|
import { z } from 'zod'
|
|
import {
|
|
buildObjectKey,
|
|
normalizePrefix,
|
|
type ParsedServiceAccount,
|
|
parseServiceAccount,
|
|
refineServiceAccountJson,
|
|
sleepUntilAborted,
|
|
} from '@/lib/data-drains/destinations/utils'
|
|
import type { DrainDestination } from '@/lib/data-drains/types'
|
|
|
|
const logger = createLogger('DataDrainGCSDestination')
|
|
|
|
const SCOPE = 'https://www.googleapis.com/auth/devstorage.read_write'
|
|
const GCS_HOST = 'https://storage.googleapis.com'
|
|
const USER_AGENT = 'sim-data-drain/1.0'
|
|
const MAX_ATTEMPTS = 4
|
|
const PER_ATTEMPT_TIMEOUT_MS = 60_000
|
|
/** GCS caps total custom metadata at 8 KiB per object (sum of key + value bytes). */
|
|
const MAX_CUSTOM_METADATA_BYTES = 8 * 1024
|
|
/** GCS object names are at most 1024 bytes when UTF-8 encoded (flat-namespace buckets). */
|
|
const MAX_OBJECT_NAME_BYTES = 1024
|
|
|
|
const GCS_BUCKET_COMPONENT_RE = /^[a-z0-9]([a-z0-9_-]*[a-z0-9])?$/
|
|
const IPV4_LIKE_RE = /^(\d{1,3}\.){3}\d{1,3}$/
|
|
const GOOGLE_RESERVED_RE = /^(goog|google|g00gle)/i
|
|
const GOOGLE_CONTAINS_RE = /(google|g00gle)/i
|
|
|
|
function validateGcsBucketComponents(v: string): string | null {
|
|
if (v.length < 3 || v.length > 222) return 'bucket must be 3-222 characters'
|
|
const components = v.split('.')
|
|
for (const c of components) {
|
|
if (c.length < 1 || c.length > 63) {
|
|
return 'each dot-separated component must be 1-63 characters'
|
|
}
|
|
if (!GCS_BUCKET_COMPONENT_RE.test(c)) {
|
|
return 'each component must be lowercase, start/end alphanumeric, letters/digits/_/- only'
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
const gcsConfigSchema = z.object({
|
|
bucket: z
|
|
.string()
|
|
.min(3, 'bucket must be 3-222 characters')
|
|
.max(222, 'bucket must be 3-222 characters')
|
|
.superRefine((v, ctx) => {
|
|
const err = validateGcsBucketComponents(v)
|
|
if (err) ctx.addIssue({ code: z.ZodIssueCode.custom, message: err })
|
|
})
|
|
.refine((v) => !IPV4_LIKE_RE.test(v), {
|
|
message: 'bucket must not look like an IP address',
|
|
})
|
|
.refine((v) => !v.includes('..'), { message: 'bucket must not contain consecutive dots' })
|
|
.refine((v) => !v.includes('-.') && !v.includes('.-'), {
|
|
message: 'bucket must not contain a dash adjacent to a dot',
|
|
})
|
|
.refine((v) => !GOOGLE_RESERVED_RE.test(v) && !GOOGLE_CONTAINS_RE.test(v), {
|
|
message: 'bucket name cannot begin with "goog" or contain "google" / close misspellings',
|
|
}),
|
|
prefix: z
|
|
.string()
|
|
.max(512)
|
|
.refine((v) => Buffer.byteLength(v, 'utf8') <= 512, {
|
|
message: 'prefix must be at most 512 bytes (UTF-8)',
|
|
})
|
|
.refine((v) => !v.startsWith('.well-known/acme-challenge/'), {
|
|
message: 'prefix must not start with ".well-known/acme-challenge/" (reserved by GCS)',
|
|
})
|
|
.optional(),
|
|
})
|
|
|
|
const gcsCredentialsSchema = z
|
|
.object({
|
|
serviceAccountJson: z.string().min(1, 'serviceAccountJson is required'),
|
|
})
|
|
.superRefine(refineServiceAccountJson)
|
|
|
|
export type GCSDestinationConfig = z.infer<typeof gcsConfigSchema>
|
|
export type GCSDestinationCredentials = z.infer<typeof gcsCredentialsSchema>
|
|
|
|
function buildJwt(account: ParsedServiceAccount): JWT {
|
|
return new JWT({ email: account.clientEmail, key: account.privateKey, scopes: [SCOPE] })
|
|
}
|
|
|
|
async function getAccessToken(jwt: JWT): Promise<string> {
|
|
const { token } = await jwt.getAccessToken()
|
|
if (!token) throw new Error('Failed to obtain GCS access token')
|
|
return token
|
|
}
|
|
|
|
interface UploadInput {
|
|
bucket: string
|
|
objectName: string
|
|
body: Buffer
|
|
contentType: string
|
|
metadata: Record<string, string>
|
|
signal: AbortSignal
|
|
jwt: JWT
|
|
}
|
|
|
|
function isRetryableStatus(status: number): boolean {
|
|
return (
|
|
status === 408 ||
|
|
status === 429 ||
|
|
status === 500 ||
|
|
status === 502 ||
|
|
status === 503 ||
|
|
status === 504
|
|
)
|
|
}
|
|
|
|
interface RetryRequestInput {
|
|
action: string
|
|
bucket: string
|
|
url: string
|
|
method: string
|
|
/**
|
|
* Built per attempt so the OAuth access token is refreshed if it expired
|
|
* between retries (google-auth-library caches and refreshes on demand).
|
|
*/
|
|
buildHeaders: () => Promise<Record<string, string>>
|
|
body?: BodyInit | Buffer
|
|
signal: AbortSignal
|
|
/** HTTP statuses to treat as success in addition to 2xx. */
|
|
successStatuses?: number[]
|
|
}
|
|
|
|
async function fetchWithRetry(input: RetryRequestInput): Promise<void> {
|
|
let lastError: unknown
|
|
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
if (input.signal.aborted) throw input.signal.reason ?? new Error('Aborted')
|
|
const perAttempt = AbortSignal.any([input.signal, AbortSignal.timeout(PER_ATTEMPT_TIMEOUT_MS)])
|
|
let response: Response
|
|
try {
|
|
const headers = await input.buildHeaders()
|
|
response = await fetch(input.url, {
|
|
method: input.method,
|
|
body: input.body as BodyInit | undefined,
|
|
headers,
|
|
signal: perAttempt,
|
|
})
|
|
} catch (error) {
|
|
lastError = error
|
|
logger.debug('GCS request failed', {
|
|
action: input.action,
|
|
attempt,
|
|
bucket: input.bucket,
|
|
error: toError(error).message,
|
|
})
|
|
if (attempt < MAX_ATTEMPTS) {
|
|
await sleepUntilAborted(backoffWithJitter(attempt, null), input.signal)
|
|
continue
|
|
}
|
|
throw error
|
|
}
|
|
if (response.ok || input.successStatuses?.includes(response.status)) {
|
|
/** Drain the success body so undici can return the socket to the keep-alive pool. */
|
|
await response.text().catch(() => '')
|
|
return
|
|
}
|
|
if (!isRetryableStatus(response.status) || attempt === MAX_ATTEMPTS) {
|
|
const text = await response.text().catch(() => '')
|
|
logger.warn('GCS operation failed', {
|
|
action: input.action,
|
|
bucket: input.bucket,
|
|
status: response.status,
|
|
})
|
|
throw new Error(
|
|
`GCS ${input.action} failed (HTTP ${response.status}): ${text || response.statusText}`
|
|
)
|
|
}
|
|
lastError = new Error(`GCS ${input.action} responded with HTTP ${response.status}`)
|
|
const retryAfterMs = parseRetryAfter(response.headers.get('retry-after'))
|
|
/** Drain the retryable response body so undici can return the socket to the keep-alive pool. */
|
|
await response.text().catch(() => '')
|
|
await sleepUntilAborted(backoffWithJitter(attempt, retryAfterMs), input.signal)
|
|
}
|
|
throw lastError instanceof Error
|
|
? lastError
|
|
: new Error(`GCS ${input.action} failed after retries`)
|
|
}
|
|
|
|
/** GCS uses HTTP headers (x-goog-meta-*) to carry custom metadata; the spec forbids non-ASCII. */
|
|
const ASCII_ONLY_RE = /^[\x20-\x7e]*$/
|
|
|
|
async function uploadObject(action: string, input: UploadInput): Promise<void> {
|
|
const objectNameBytes = Buffer.byteLength(input.objectName, 'utf8')
|
|
if (objectNameBytes < 1 || objectNameBytes > MAX_OBJECT_NAME_BYTES) {
|
|
throw new Error(
|
|
`GCS object name is ${objectNameBytes} bytes, must be 1-${MAX_OBJECT_NAME_BYTES} bytes (UTF-8)`
|
|
)
|
|
}
|
|
let metadataBytes = 0
|
|
for (const [key, value] of Object.entries(input.metadata)) {
|
|
if (!ASCII_ONLY_RE.test(key) || !ASCII_ONLY_RE.test(value)) {
|
|
throw new Error(`GCS custom metadata key/value must be ASCII printable: ${key}`)
|
|
}
|
|
metadataBytes += Buffer.byteLength(key, 'utf8') + Buffer.byteLength(value, 'utf8')
|
|
}
|
|
if (metadataBytes > MAX_CUSTOM_METADATA_BYTES) {
|
|
throw new Error(
|
|
`GCS custom metadata is ${metadataBytes} bytes, exceeds the ${MAX_CUSTOM_METADATA_BYTES}-byte per-object limit`
|
|
)
|
|
}
|
|
const url = `${GCS_HOST}/upload/storage/v1/b/${encodeURIComponent(input.bucket)}/o?uploadType=media&name=${encodeURIComponent(input.objectName)}`
|
|
await fetchWithRetry({
|
|
action,
|
|
bucket: input.bucket,
|
|
url,
|
|
method: 'POST',
|
|
buildHeaders: async () => {
|
|
const token = await getAccessToken(input.jwt)
|
|
const headers: Record<string, string> = {
|
|
Authorization: `Bearer ${token}`,
|
|
'Content-Type': input.contentType,
|
|
'User-Agent': USER_AGENT,
|
|
}
|
|
for (const [key, value] of Object.entries(input.metadata)) {
|
|
headers[`x-goog-meta-${key}`] = value
|
|
}
|
|
return headers
|
|
},
|
|
body: input.body,
|
|
signal: input.signal,
|
|
})
|
|
}
|
|
|
|
async function deleteObject(input: {
|
|
bucket: string
|
|
objectName: string
|
|
jwt: JWT
|
|
signal: AbortSignal
|
|
}): Promise<void> {
|
|
const url = `${GCS_HOST}/storage/v1/b/${encodeURIComponent(input.bucket)}/o/${encodeURIComponent(input.objectName)}`
|
|
await fetchWithRetry({
|
|
action: 'delete-object',
|
|
bucket: input.bucket,
|
|
url,
|
|
method: 'DELETE',
|
|
buildHeaders: async () => {
|
|
const token = await getAccessToken(input.jwt)
|
|
return {
|
|
Authorization: `Bearer ${token}`,
|
|
'User-Agent': USER_AGENT,
|
|
}
|
|
},
|
|
signal: input.signal,
|
|
successStatuses: [404],
|
|
})
|
|
}
|
|
|
|
export const gcsDestination: DrainDestination<GCSDestinationConfig, GCSDestinationCredentials> = {
|
|
type: 'gcs',
|
|
displayName: 'Google Cloud Storage',
|
|
configSchema: gcsConfigSchema,
|
|
credentialsSchema: gcsCredentialsSchema,
|
|
|
|
async test({ config, credentials, signal }) {
|
|
const account = parseServiceAccount(credentials.serviceAccountJson)
|
|
const jwt = buildJwt(account)
|
|
const probeName = `${normalizePrefix(config.prefix)}.sim-drain-write-probe/${generateShortId(12)}`
|
|
await uploadObject('test-put', {
|
|
bucket: config.bucket,
|
|
objectName: probeName,
|
|
body: Buffer.alloc(0),
|
|
contentType: 'application/octet-stream',
|
|
metadata: {},
|
|
signal,
|
|
jwt,
|
|
})
|
|
try {
|
|
await deleteObject({ bucket: config.bucket, objectName: probeName, jwt, signal })
|
|
} catch (cleanupError) {
|
|
logger.debug('GCS test write probe cleanup failed (non-fatal)', {
|
|
bucket: config.bucket,
|
|
objectName: probeName,
|
|
error: cleanupError,
|
|
})
|
|
}
|
|
},
|
|
|
|
openSession({ config, credentials }) {
|
|
const account = parseServiceAccount(credentials.serviceAccountJson)
|
|
const jwt = buildJwt(account)
|
|
return {
|
|
async deliver({ body, contentType, metadata, signal }) {
|
|
const objectName = buildObjectKey(config.prefix, metadata)
|
|
await uploadObject('put-object', {
|
|
bucket: config.bucket,
|
|
objectName,
|
|
body,
|
|
contentType,
|
|
metadata: {
|
|
'sim-drain-id': metadata.drainId,
|
|
'sim-run-id': metadata.runId,
|
|
'sim-source': metadata.source,
|
|
'sim-sequence': metadata.sequence.toString(),
|
|
'sim-row-count': metadata.rowCount.toString(),
|
|
},
|
|
signal,
|
|
jwt,
|
|
})
|
|
logger.debug('GCS chunk delivered', {
|
|
bucket: config.bucket,
|
|
objectName,
|
|
bytes: body.byteLength,
|
|
})
|
|
return { locator: `gs://${config.bucket}/${objectName}` }
|
|
},
|
|
async close() {},
|
|
}
|
|
},
|
|
}
|