From 6868fc0fb12f29a898e784e01e6ea9745fa9d8b6 Mon Sep 17 00:00:00 2001 From: Xing Liu Date: Tue, 9 Jun 2026 11:12:27 +0800 Subject: [PATCH] feat(infra): Auto-provision shared S3 bucket per generated app Add S3 to the kickoff-infra auto-provisioning flow alongside Postgres and Redis. Unlike PG/Redis (one Dokploy container each), S3 uses a single shared bucket configured once on the server; every generated app gets an isolated folder (key prefix = app slug). - types: InfraServiceKind gains "s3"; RequiredServices gains needsS3; InfraServiceInfo gains optional `env` map for multi-key services - detect: regex + LLM service-detector now classify S3 (file/image/media upload, object storage, presigned URLs) - s3.ts: reads BLUEPRINT_S3_* shared-bucket config, allocates per-app prefix, emits the AWS_S3_* env bundle (no Dokploy call) - index: S3 provisioning runs independently of Dokploy; adds s3EnvFrom() - coding/route + deploy/pipeline: inject S3 env into backend/.env and the deployed compose env - generated-code-env: add generic upsertEnvVars for multi-key sets - InfraSection UI: S3 chip (skips ping / port display) - .env.example: document BLUEPRINT_S3_* (AWS / R2 / MinIO compatible) Secrets only land in gitignored .blueprint/kickoff-infra.json and the generated .env; UI/metadata expose only the s3://bucket/prefix string. Tests: detect + s3 unit suites (34 passing). --- .env.example | 17 ++ src/app/api/agents/coding/route.ts | 7 +- src/components/kickoff/InfraSection.tsx | 27 ++- src/lib/agents/infra/service-detector.ts | 19 +- src/lib/deploy/__tests__/pipeline.test.ts | 1 + src/lib/deploy/pipeline.ts | 9 + src/lib/pipeline/generated-code-env.ts | 26 +++ .../kickoff-infra/__tests__/detect.test.ts | 54 ++++- .../kickoff-infra/__tests__/s3.test.ts | 147 +++++++++++++ src/lib/pipeline/kickoff-infra/detect.ts | 12 ++ src/lib/pipeline/kickoff-infra/index.ts | 193 +++++++++++------- src/lib/pipeline/kickoff-infra/s3.ts | 127 ++++++++++++ src/lib/pipeline/kickoff-infra/types.ts | 20 +- 13 files changed, 550 insertions(+), 109 deletions(-) create mode 100644 src/lib/pipeline/kickoff-infra/__tests__/s3.test.ts create mode 100644 src/lib/pipeline/kickoff-infra/s3.ts diff --git a/.env.example b/.env.example index 731ce225..72ca412a 100644 --- a/.env.example +++ b/.env.example @@ -106,6 +106,23 @@ CODE_OUTPUT_DIR=generated-code # Use a URL-encoded password if it contains @ (e.g. p%40ssword for p@ssword). # BLUEPRINT_GENERATED_DATABASE_URL=postgresql://user:pass@localhost:5432/dbname +# --- Infra auto-provisioning (kickoff) --- +# Postgres/Redis: one Dokploy container per generated app. Set both to enable. +# DOKPLOY_URL=https://dokploy.example.com +# DOKPLOY_TOKEN= +# +# S3 object storage: ONE shared bucket for all generated apps; each app gets an +# isolated folder (key prefix = app slug). Set BLUEPRINT_S3_BUCKET to enable; +# the kickoff "service detector" then auto-wires AWS_S3_* into the app's .env +# whenever the PRD/TRD implies file/image/media upload. Works with AWS S3 or any +# S3-compatible store (MinIO, Cloudflare R2) via the optional endpoint vars. +# BLUEPRINT_S3_BUCKET=my-shared-test-bucket +# BLUEPRINT_S3_ACCESS_KEY_ID= +# BLUEPRINT_S3_SECRET_ACCESS_KEY= +# BLUEPRINT_S3_REGION=us-east-1 +# BLUEPRINT_S3_ENDPOINT= # e.g. https://.r2.cloudflarestorage.com (S3-compatible only) +# BLUEPRINT_S3_FORCE_PATH_STYLE= # set to 1 for MinIO / path-style addressing + # --- Project kick-off: Git / Jira (optional; webhooks OR direct API) --- # If a webhook URL is set, it is used instead of the direct API for that integration. # diff --git a/src/app/api/agents/coding/route.ts b/src/app/api/agents/coding/route.ts index 339c8a50..48a4117e 100644 --- a/src/app/api/agents/coding/route.ts +++ b/src/app/api/agents/coding/route.ts @@ -35,6 +35,7 @@ import { resolveBackendPort, upsertBackendPrivyAppIdMirror, resolvePrivyAppIdMirrorFromFilledResources, + upsertEnvVars, } from "@/lib/pipeline/generated-code-env"; import { normalizeProjectTier, @@ -48,6 +49,7 @@ import { readKickoffInfraMetadata, databaseUrlFrom, redisUrlFrom, + s3EnvFrom, } from "@/lib/pipeline/kickoff-infra"; import { readResourceRequirements, @@ -1401,7 +1403,10 @@ export async function POST(request: NextRequest) { const withRedisUrl = redisUrlForEnv ? upsertRedisUrlEnv(withDbUrl, redisUrlForEnv) : withDbUrl; - const withJwt = upsertJwtEnvVars(withRedisUrl); + // S3 credential bundle (shared bucket + per-app folder) from kickoff infra. + const s3Env = s3EnvFrom(kickoffInfra); + const withS3 = s3Env ? upsertEnvVars(withRedisUrl, s3Env) : withRedisUrl; + const withJwt = upsertJwtEnvVars(withS3); const withPort = upsertBackendPortEnv(withJwt); const withResources = upsertResourceEnvVars(withPort, backendResources); const privyMirror = diff --git a/src/components/kickoff/InfraSection.tsx b/src/components/kickoff/InfraSection.tsx index 9b7d67ec..d5bf06bb 100644 --- a/src/components/kickoff/InfraSection.tsx +++ b/src/components/kickoff/InfraSection.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useState } from "react"; export interface InfraServiceMeta { - kind: "postgres" | "redis"; + kind: "postgres" | "redis" | "s3"; appName: string; externalPort: number; publicUrl: string; @@ -27,11 +27,13 @@ interface Props { const KIND_ICON: Record = { postgres: "🐘", redis: "🟥", + s3: "🪣", }; const KIND_LABEL: Record = { postgres: "Postgres", redis: "Redis", + s3: "S3", }; type PingStatus = @@ -72,9 +74,12 @@ export default function InfraSection({ infra, dokployBaseUrl }: Props) { setPings((p) => ({ ...p, [key]: result })); }, []); - // Initial probe — fires once per service when the panel mounts. + // Initial probe — fires once per service when the panel mounts. S3 is an + // external bucket reached over HTTPS with credentials, not a pingable TCP + // endpoint, so we skip the reachability probe for it. useEffect(() => { for (const svc of services) { + if (svc.kind === "s3") continue; const key = `${svc.kind}-${svc.appName}`; runPing(key, svc.publicUrl); } @@ -156,13 +161,17 @@ export default function InfraSection({ infra, dokployBaseUrl }: Props) { {KIND_LABEL[svc.kind]} - - :{svc.externalPort} - - runPing(key, svc.publicUrl)} - /> + {svc.kind !== "s3" && ( + + :{svc.externalPort} + + )} + {svc.kind !== "s3" && ( + runPing(key, svc.publicUrl)} + /> + )}