diff --git a/.env.example b/.env.example index 220b7ee6..2ffc9315 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,9 @@ -# env 단일 출처는 GCP Secret Manager. 평상시 로컬은 `bun run start:local`로 SM에서 받아옵니다. -# (최초 1회: gcloud auth application-default login) +# env 단일 출처는 GCP Secret Manager. 앱이 직접 가져오지 않고 APP_ENV_DOTENV로 주입받습니다. +# 로컬: bun run start:local (scripts/with-env.ts 가 ADC로 조회) +# prod: Cloud Run --set-secrets +# 최초 1회: gcloud auth application-default login # -# 아래는 SM을 건너뛰는 탈출구(오프라인/전환기)입니다. APP_CONFIG_SOURCE=env 로 켜면 사용됩니다. +# 아래는 주입 없이 로컬 파일만으로 띄울 때 쓰는 탈출구입니다. # APP_CONFIG_SOURCE=env diff --git a/.env.test b/.env.test new file mode 100644 index 00000000..c31e3851 --- /dev/null +++ b/.env.test @@ -0,0 +1,11 @@ +# 테스트 전용 더미 값 + +NODE_ENV=test + +GOOGLE_CLOUD_PROJECT=team-mino-test +KAKAO_REST_API_KEY=test + +INSTAGRAM_GRAPHQL_ENDPOINT=https://www.instagram.com/api/graphql +INSTAGRAM_DOC_ID=test +INSTAGRAM_APP_ID=test +INSTAGRAM_USER_AGENT=test diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1d29f1ea..d2f02d1a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -181,5 +181,6 @@ jobs: gcloud run services update team-mino-prod-api \ --image "$IMAGE" \ --set-env-vars "APP_ENV=prod,SENTRY_RELEASE=$SENTRY_RELEASE" \ + --set-secrets "APP_ENV_DOTENV=team-mino-env-prod:latest" \ --region "asia-northeast3" \ --project "team-mino-prod" diff --git a/.gitignore b/.gitignore index e96a4b89..90b21d04 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ skills-lock.json .env.* !.env.example !.env.*.example +!.env.test [._]*.s[a-v][a-z] [._]*.sw[a-p] [._]s[a-rt-v][a-z] diff --git a/bun.lock b/bun.lock index e064ed47..ddf6caaa 100644 --- a/bun.lock +++ b/bun.lock @@ -7,7 +7,6 @@ "dependencies": { "@ai-sdk/google-vertex": "^4.0.147", "@ai-sdk/valibot": "^2.0.31", - "@google-cloud/secret-manager": "^6.2.0", "@google-cloud/storage": "^7.21.0", "@kiyasov/platform-hono": "^2.0.3", "@nestjs/common": "^11.1.24", @@ -27,6 +26,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.4.15", + "@google-cloud/secret-manager": "^6.3.0", "@nestjs/cli": "^11.0.21", "@nestjs/schematics": "^11.1.0", "@nestjs/testing": "^11.1.24", @@ -221,7 +221,7 @@ "@google-cloud/promisify": ["@google-cloud/promisify@4.0.0", "", {}, "sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g=="], - "@google-cloud/secret-manager": ["@google-cloud/secret-manager@6.2.0", "", { "dependencies": { "google-gax": "^5.0.0" } }, "sha512-KjusvEON/HLeS7VelJ1aoY4zUOcNnEYCQ69Vnncf3Fnk5OLl6Dl0QKimgw4ajW3CgR7pCUkNPneiK5HgWSGQOw=="], + "@google-cloud/secret-manager": ["@google-cloud/secret-manager@6.3.0", "", { "dependencies": { "google-gax": "^5.0.0" } }, "sha512-TUuzu8CchbCd2cXd0pYJSXvDBsEVX3wpZu4iOpdLrXLbYbvCDKhdjDyOyC7vo14qQRE9J6Z0AL2hfX08H+fh3A=="], "@google-cloud/storage": ["@google-cloud/storage@7.21.0", "", { "dependencies": { "@google-cloud/paginator": "^5.0.0", "@google-cloud/projectify": "^4.0.0", "@google-cloud/promisify": "<4.1.0", "abort-controller": "^3.0.0", "async-retry": "^1.3.3", "duplexify": "^4.1.3", "fast-xml-parser": "^5.3.4", "gaxios": "^6.0.2", "google-auth-library": "^9.6.3", "html-entities": "^2.5.2", "mime": "^3.0.0", "p-limit": "^3.0.1", "retry-request": "^7.0.0", "teeny-request": "^9.0.0" } }, "sha512-l+IFTkd+6Y5LoAuXyYCKNAKtw/Ci+rAMqgdTB1jv4iZiLhw0rtq+0qjIRbBizXkNzEFmXiXUW0H7sZQQvk1ffA=="], @@ -755,7 +755,7 @@ "google-auth-library": ["google-auth-library@10.5.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.0.0", "gcp-metadata": "^8.0.0", "google-logging-utils": "^1.0.0", "gtoken": "^8.0.0", "jws": "^4.0.0" } }, "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w=="], - "google-gax": ["google-gax@5.0.7", "", { "dependencies": { "@grpc/grpc-js": "^1.12.6", "@grpc/proto-loader": "^0.8.0", "duplexify": "^4.1.3", "google-auth-library": "10.5.0", "google-logging-utils": "1.1.3", "node-fetch": "^3.3.2", "object-hash": "^3.0.0", "proto3-json-serializer": "3.0.4", "protobufjs": "^7.5.4", "retry-request": "^8.0.2", "rimraf": "^5.0.1" } }, "sha512-EhiqaWWJ+9h7sCcKJTsoo6tMcjokVHhWsbSuWCnZJT4vIBP3y4mAoFLnt9SzgkVZeq24ZsFaArr06nnYYku2yA=="], + "google-gax": ["google-gax@5.0.8", "", { "dependencies": { "@grpc/grpc-js": "^1.12.6", "@grpc/proto-loader": "^0.8.0", "duplexify": "^4.1.3", "google-auth-library": "10.5.0", "google-logging-utils": "1.1.3", "node-fetch": "^3.3.2", "object-hash": "^3.0.0", "proto3-json-serializer": "3.0.4", "protobufjs": "^7.5.4", "retry-request": "^8.0.2", "rimraf": "^5.0.1" } }, "sha512-M4vpZcXQIC1gqIVGQ7eaU3jXQA6zecStyTXu514TYfThlgSurYJOxHZo9fzU6hAgwPWvuEynAVHWyaIk80VeEA=="], "google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], @@ -1293,7 +1293,7 @@ "google-auth-library/gaxios": ["gaxios@7.1.5", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg=="], - "google-gax/retry-request": ["retry-request@8.0.3", "", { "dependencies": { "extend": "^3.0.2", "teeny-request": "^10.0.0" } }, "sha512-qqoc4kkGgP9cmQDWELlOpAmfgJOg0Yi7MT82ZjiPWu451ayju4itwomjM4/dBEliify8C1b3tSaeCOldugtwPQ=="], + "google-gax/retry-request": ["retry-request@8.0.4", "", { "dependencies": { "extend": "^3.0.2", "teeny-request": "^10.0.0" } }, "sha512-pI6/7eabUYkZxamkOq0g0uMxKLLGnjzhefY+vL8bVXag5rto4OU2YBTPytWLuHH7aEKD6fn7kJycQfid0Mwnkw=="], "gtoken/gaxios": ["gaxios@7.1.5", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg=="], @@ -1395,7 +1395,7 @@ "glob/minimatch/brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], - "google-gax/retry-request/teeny-request": ["teeny-request@10.1.3", "", { "dependencies": { "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "stream-events": "^1.0.5" } }, "sha512-5yDliI1uWkYPo7W+Zvrxg6YmoWuj5iC5EydewqrRTvc68nyMTZhlPPlLg6cptUGfbQAb+N9XDPDPzF6N081lug=="], + "google-gax/retry-request/teeny-request": ["teeny-request@10.1.4", "", { "dependencies": { "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "stream-events": "^1.0.5" } }, "sha512-R1Cg4Vu0UULeDfHL/kjABLaTW++9yD/B6n2g48y5dJ04hsEaxcfmAqbNDzNsbqAYJyIpZafjklLG9YxRu9uzOg=="], "rimraf/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], diff --git a/e2e/setup.ts b/e2e/setup.ts index 0917d0de..25588ae6 100644 --- a/e2e/setup.ts +++ b/e2e/setup.ts @@ -14,6 +14,7 @@ const probe = Bun.serve({ }); const port = probe.port; const url = `postgres://postgres:postgres@127.0.0.1:${port}/team_mino`; +const schema = "develop"; const migrations = join(process.cwd(), "drizzle"); const pg = new EmbeddedPostgres({ databaseDir: join(tmpdir(), `team-mino-postgres-${randomUUID()}`), @@ -27,21 +28,13 @@ const db = drizzle({ connection: { url, max: 1, - connection: { search_path: "develop" }, + connection: { search_path: schema }, }, }); -Object.assign(process.env, { - DATABASE_SCHEMA: "develop", - DATABASE_URL: url, - GOOGLE_CLOUD_PROJECT: "team-mino-test", - INSTAGRAM_APP_ID: "test", - INSTAGRAM_DOC_ID: "test", - INSTAGRAM_GRAPHQL_ENDPOINT: "https://www.instagram.com/api/graphql", - INSTAGRAM_USER_AGENT: "test", - KAKAO_REST_API_KEY: "test", - NODE_ENV: "test", -}); +// DB 설정은 임베디드 인스턴스에 묶여 있어 여기서 주입합니다(나머지는 .env.test). +process.env.DATABASE_URL = url; +process.env.DATABASE_SCHEMA = schema; beforeAll(async () => { try { @@ -56,7 +49,7 @@ beforeAll(async () => { if (existsSync(migrations)) { await migrate(db, { migrationsFolder: migrations, - migrationsSchema: "develop", + migrationsSchema: schema, }); } }, 30_000); diff --git a/infra/src/resources/secrets.ts b/infra/src/resources/secrets.ts index 9b0ded54..4a6705e7 100644 --- a/infra/src/resources/secrets.ts +++ b/infra/src/resources/secrets.ts @@ -2,7 +2,7 @@ import * as gcp from "@pulumi/gcp"; import * as pulumi from "@pulumi/pulumi"; import { project } from "@/config"; import { enabledServices } from "@/resources/apis"; -import { serverServiceAccount } from "@/resources/identity"; +import { developer, serverServiceAccount } from "@/resources/identity"; /** * 앱 env를 담는 시크릿(local·prod). Pulumi는 "컨테이너"만 선언하고, 실제 값(버전)은 @@ -37,3 +37,31 @@ new gcp.secretmanager.SecretIamMember("team-mino-env-prod-runtime", { role: "roles/secretmanager.secretAccessor", member: pulumi.interpolate`serviceAccount:${serverServiceAccount.email}`, }); + +// 개발자 SA가 로컬 실행 시 두 env를 읽고(secretAccessor)·갱신(secretVersionManager)할 수 있도록. +const developerMember = pulumi.interpolate`serviceAccount:${developer.email}`; + +new gcp.secretmanager.SecretIamMember("team-mino-env-local-developer-read", { + secretId: localEnvSecret.secretId, + project, + role: "roles/secretmanager.secretAccessor", + member: developerMember, +}); +new gcp.secretmanager.SecretIamMember("team-mino-env-local-developer-write", { + secretId: localEnvSecret.secretId, + project, + role: "roles/secretmanager.secretVersionManager", + member: developerMember, +}); +new gcp.secretmanager.SecretIamMember("team-mino-env-prod-developer-read", { + secretId: prodEnvSecret.secretId, + project, + role: "roles/secretmanager.secretAccessor", + member: developerMember, +}); +new gcp.secretmanager.SecretIamMember("team-mino-env-prod-developer-write", { + secretId: prodEnvSecret.secretId, + project, + role: "roles/secretmanager.secretVersionManager", + member: developerMember, +}); diff --git a/package.json b/package.json index 498570c4..0aa2a752 100644 --- a/package.json +++ b/package.json @@ -7,19 +7,19 @@ "build": "bun run scripts/build.ts", "check": "biome check .", "check:staged": "nano-staged --bail", - "db:generate": "drizzle-kit generate", - "db:migrate": "drizzle-kit migrate", - "db:schema-init": "bun run scripts/db-schema-init.ts", - "db:studio": "drizzle-kit studio", + "db:generate": "bun run scripts/with-env.ts -- drizzle-kit generate", + "db:migrate": "bun run scripts/with-env.ts -- drizzle-kit migrate", + "db:schema-init": "bun run scripts/with-env.ts -- bun run scripts/db-schema-init.ts", + "db:studio": "bun run scripts/with-env.ts -- drizzle-kit studio", "format": "biome format --write .", "lint": "biome lint .", "postinstall": "simple-git-hooks", - "start:local": "APP_ENV=local nest start --watch --exec bun", + "start:local": "bun run scripts/with-env.ts -- nest start --watch --exec bun", "start:prod": "./dist/server", "test": "bun run test:unit && bun run test:e2e", - "test:e2e": "bun test --preload ./e2e/setup.ts e2e/", - "test:unit": "bun test src/", - "test:watch": "bun test --watch src/", + "test:e2e": "bun test --env-file=.env.test --preload ./e2e/setup.ts e2e/", + "test:unit": "bun test --env-file=.env.test src/", + "test:watch": "bun test --env-file=.env.test --watch src/", "typecheck": "tsgo --noEmit", "typecheck:tsc": "tsc --noEmit" }, @@ -30,7 +30,6 @@ "dependencies": { "@ai-sdk/google-vertex": "^4.0.147", "@ai-sdk/valibot": "^2.0.31", - "@google-cloud/secret-manager": "^6.2.0", "@google-cloud/storage": "^7.21.0", "@kiyasov/platform-hono": "^2.0.3", "@nestjs/common": "^11.1.24", @@ -50,6 +49,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.4.15", + "@google-cloud/secret-manager": "^6.3.0", "@nestjs/cli": "^11.0.21", "@nestjs/schematics": "^11.1.0", "@nestjs/testing": "^11.1.24", diff --git a/scripts/db-schema-init.ts b/scripts/db-schema-init.ts index 1eb05a13..75504c25 100644 --- a/scripts/db-schema-init.ts +++ b/scripts/db-schema-init.ts @@ -13,7 +13,7 @@ import { resolveDbSchema } from "../src/infrastructures/db/db.env"; async function main() { // 기본은 Secret Manager에서 env를 가져옵니다. APP_CONFIG_SOURCE=env 일 때만 건너뜀. - await loadSecretEnv(); + loadSecretEnv(); const url = process.env.DATABASE_URL; if (!url) { diff --git a/scripts/with-env.ts b/scripts/with-env.ts new file mode 100644 index 00000000..0d51e759 --- /dev/null +++ b/scripts/with-env.ts @@ -0,0 +1,73 @@ +#!/usr/bin/env bun + +/** + * 로컬 실행 래퍼. Secret Manager의 dotenv 원문을 받아 주입하고 자식 명령을 실행합니다. + * + * bun run scripts/with-env.ts -- nest start --watch --exec bun + * + * drizzle-kit처럼 process.env를 직접 읽는 도구를 위해 개별 키까지 함께 주입합니다. + * SDK는 devDependency라 프로덕션 번들에는 포함되지 않습니다. + */ + +import { SecretManagerServiceClient } from "@google-cloud/secret-manager"; +import { ENV_DOTENV_VAR, parseDotenv } from "../src/config/secret-env"; + +const SECRET_FETCH_TIMEOUT_MS = 10_000; + +async function fetchSecret(project: string, secret: string): Promise { + // fallback: true → gRPC 네이티브 의존성 대신 REST 사용. + const client = new SecretManagerServiceClient({ fallback: true }); + const [version] = await client.accessSecretVersion( + { name: `projects/${project}/secrets/${secret}/versions/latest` }, + { timeout: SECRET_FETCH_TIMEOUT_MS }, + ); + return version.payload?.data?.toString() ?? ""; +} + +async function main(): Promise { + // `bun run x.ts -- cmd`는 bun이 `--`를 떼고 넘기지만, 직접 실행 시엔 남는다. + const args = process.argv.slice(2); + const command = args[0] === "--" ? args.slice(1) : args; + if (command.length === 0) { + console.error("usage: bun run scripts/with-env.ts -- [args...]"); + process.exit(1); + } + + const env: Record = { + ...(process.env as Record), + }; + env.APP_ENV ??= "local"; + + if (env.APP_CONFIG_SOURCE !== "env") { + const project = env.GCP_PROJECT ?? "team-mino-prod"; + const secret = env.GCP_ENV_SECRET ?? `team-mino-env-${env.APP_ENV}`; + + let payload: string; + try { + payload = await fetchSecret(project, secret); + } catch (error) { + console.error(`시크릿을 가져오지 못했습니다: ${secret} (${project})`); + console.error(error instanceof Error ? error.message : error); + console.error( + "ADC 로그인(gcloud auth application-default login)을 확인하거나, APP_CONFIG_SOURCE=env 로 우회하세요.", + ); + process.exit(1); + } + + env[ENV_DOTENV_VAR] = payload; + for (const [key, value] of Object.entries(parseDotenv(payload))) { + if (env[key] === undefined) env[key] = value; + } + } + + const child = Bun.spawn(command, { + env, + stdio: ["inherit", "inherit", "inherit"], + }); + process.exit(await child.exited); +} + +main().catch((error: unknown) => { + console.error(error); + process.exit(1); +}); diff --git a/src/config/secret-env.ts b/src/config/secret-env.ts index 8e13681e..f6fbfa2e 100644 --- a/src/config/secret-env.ts +++ b/src/config/secret-env.ts @@ -1,19 +1,14 @@ -import { SecretManagerServiceClient } from "@google-cloud/secret-manager"; - /** - * 부팅 시 GCP Secret Manager에서 env 묶음을 가져와 process.env에 주입합니다. - * - * env의 단일 출처를 Secret Manager로 두고, 로컬·Cloud Run 모두 부팅 시 fetch합니다. - * 환경은 APP_ENV(`local` | `prod`)로 고르며 시크릿 `team-mino-env-${APP_ENV}`에 매핑됩니다. - * APP_CONFIG_SOURCE=env 이면 SM을 건너뛰고 기존 process.env/.env 를 씁니다(오프라인/전환기용). + * 주입된 dotenv 원문(APP_ENV_DOTENV)을 파싱해 process.env에 채웁니다. + * prod는 Cloud Run `--set-secrets`, 로컬은 `scripts/with-env.ts`가 주입합니다. * - * 부팅 최초 단계(ConfigModule 검증 이전)라 ConfigService가 없으므로, - * "env는 ConfigService로만" 규칙의 의도적 예외로 process.env를 직접 다룹니다. + * 시크릿 포맷은 한 줄 = 한 키(여러 줄 값은 base64로 인코딩). + * ConfigModule 검증 이전에 도는 부팅 최초 단계라 process.env를 직접 다룹니다. */ -const SECRET_FETCH_TIMEOUT_MS = 5000; +export const ENV_DOTENV_VAR = "APP_ENV_DOTENV"; -function parseDotenv(src: string): Record { +export function parseDotenv(src: string): Record { const result: Record = {}; for (const line of src.split("\n")) { if (/^\s*(#|$)/.test(line)) continue; @@ -26,27 +21,15 @@ function parseDotenv(src: string): Record { return result; } -function resolveSecretName(): string { - if (process.env.GCP_ENV_SECRET) return process.env.GCP_ENV_SECRET; - return `team-mino-env-${process.env.APP_ENV ?? "local"}`; -} - -export async function loadSecretEnv(): Promise { - if (process.env.APP_CONFIG_SOURCE === "env") return; +export function loadSecretEnv(): void { + const payload = process.env[ENV_DOTENV_VAR]; + if (!payload) return; - const project = process.env.GCP_PROJECT ?? "team-mino-prod"; - const secret = resolveSecretName(); - - // fallback: true → gRPC 네이티브 의존성 대신 REST 사용(bun --compile 단일 바이너리 번들 안전). - const client = new SecretManagerServiceClient({ fallback: true }); - const [version] = await client.accessSecretVersion( - { name: `projects/${project}/secrets/${secret}/versions/latest` }, - { timeout: SECRET_FETCH_TIMEOUT_MS }, - ); - - const payload = version.payload?.data?.toString() ?? ""; for (const [key, value] of Object.entries(parseDotenv(payload))) { - // 이미 설정된 값은 보존 → OS env로 개별 오버라이드 허용. + // 배포·셸이 넣은 값이 주입값보다 우선. if (process.env[key] === undefined) process.env[key] = value; } + + // 원문이 env에 통째로 남지 않도록 정리. + delete process.env[ENV_DOTENV_VAR]; } diff --git a/src/main.ts b/src/main.ts index e33350c1..2b4b2c03 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,7 +6,7 @@ import { } from "./config/sentry.config"; async function main(): Promise { - await loadSecretEnv(); + loadSecretEnv(); initializeSentry(process.env, Sentry.init); const { bootstrap } = await import("./bootstrap"); await bootstrap();