From 2729e95a5b1dedc0f7742c4f49ff84945aa80f98 Mon Sep 17 00:00:00 2001 From: chyoo Date: Thu, 2 Jul 2026 15:44:12 +0900 Subject: [PATCH] add build env and path ingress controls --- .../migration.sql | 25 ++++ .../migration.sql | 4 + prisma/schema.prisma | 18 +++ src/apps/apps.controller.ts | 16 ++- src/apps/apps.service.ts | 119 +++++++++++++++++- src/apps/build-runner.service.ts | 27 +++- src/apps/dto.ts | 21 ++++ 7 files changed, 226 insertions(+), 4 deletions(-) create mode 100644 prisma/migrations/20260702063000_add_build_env_variables/migration.sql create mode 100644 prisma/migrations/20260702071000_add_runtime_ingress_strip_prefix/migration.sql diff --git a/prisma/migrations/20260702063000_add_build_env_variables/migration.sql b/prisma/migrations/20260702063000_add_build_env_variables/migration.sql new file mode 100644 index 0000000..2183682 --- /dev/null +++ b/prisma/migrations/20260702063000_add_build_env_variables/migration.sql @@ -0,0 +1,25 @@ +CREATE TABLE "build_env_variables" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "component_id" UUID NOT NULL, + "key" TEXT NOT NULL, + "value" TEXT NOT NULL, + "is_secret" BOOLEAN NOT NULL DEFAULT false, + "created_at" TIMESTAMP(3) WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "build_env_variables_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "build_env_variables_component_id_key_key" ON "build_env_variables"("component_id", "key"); +CREATE INDEX "build_env_variables_component_id_idx" ON "build_env_variables"("component_id"); +CREATE INDEX "build_env_variables_component_id_is_secret_idx" ON "build_env_variables"("component_id", "is_secret"); + +ALTER TABLE "build_env_variables" + ADD CONSTRAINT "build_env_variables_component_id_fkey" + FOREIGN KEY ("component_id") REFERENCES "app_components"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +COMMENT ON TABLE "build_env_variables" IS '구성요소 빌드 시 주입할 환경변수'; +COMMENT ON COLUMN "build_env_variables"."component_id" IS '빌드 대상 구성요소 ID'; +COMMENT ON COLUMN "build_env_variables"."key" IS '환경변수 키'; +COMMENT ON COLUMN "build_env_variables"."value" IS '환경변수 값'; +COMMENT ON COLUMN "build_env_variables"."is_secret" IS '비밀값 여부'; diff --git a/prisma/migrations/20260702071000_add_runtime_ingress_strip_prefix/migration.sql b/prisma/migrations/20260702071000_add_runtime_ingress_strip_prefix/migration.sql new file mode 100644 index 0000000..d87d53b --- /dev/null +++ b/prisma/migrations/20260702071000_add_runtime_ingress_strip_prefix/migration.sql @@ -0,0 +1,4 @@ +ALTER TABLE "runtime_ingress_rules" + ADD COLUMN "strip_prefix" BOOLEAN NOT NULL DEFAULT false; + +COMMENT ON COLUMN "runtime_ingress_rules"."strip_prefix" IS 'Traefik StripPrefix 미들웨어 적용 여부'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2d85ee6..f02e4f5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -170,6 +170,7 @@ model AppComponent { updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) app App @relation(fields: [appId], references: [id], onDelete: Cascade) buildJobs BuildJob[] + buildVariables BuildEnvVariable[] runtimeVariables RuntimeEnvVariable[] ingressRules RuntimeIngressRule[] @@ -177,6 +178,22 @@ model AppComponent { @@map("app_components") } +model BuildEnvVariable { + id String @id @default(uuid()) @db.Uuid + componentId String @map("component_id") @db.Uuid + key String + value String + isSecret Boolean @default(false) @map("is_secret") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + component AppComponent @relation(fields: [componentId], references: [id], onDelete: Cascade) + + @@unique([componentId, key]) + @@index([componentId]) + @@index([componentId, isSecret]) + @@map("build_env_variables") +} + model BuildJob { id String @id @default(uuid()) @db.Uuid componentId String @map("component_id") @db.Uuid @@ -319,6 +336,7 @@ model RuntimeIngressRule { host String path String @default("/") servicePort Int @map("service_port") + stripPrefix Boolean @default(false) @map("strip_prefix") tlsEnabled Boolean @default(false) @map("tls_enabled") tlsSecretName String? @map("tls_secret_name") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) diff --git a/src/apps/apps.controller.ts b/src/apps/apps.controller.ts index 3ce97bf..c923739 100644 --- a/src/apps/apps.controller.ts +++ b/src/apps/apps.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common'; import { LinkComponentSourceDto } from '../gitea/dto'; import { AppsService } from './apps.service'; @@ -13,6 +13,7 @@ import { CreateRuntimeEnvVariableDto, CreateRuntimeIngressRuleDto, EnsureRuntimeNamespaceDto, + ReplaceBuildEnvVariablesDto, } from './dto'; @Controller('apps') @@ -88,6 +89,19 @@ export class AppsController { return this.appsService.linkComponentSource(componentId, body); } + @Get('components/:componentId/build-env') + listComponentBuildEnv(@Param('componentId') componentId: string) { + return this.appsService.listComponentBuildEnv(componentId); + } + + @Put('components/:componentId/build-env') + replaceComponentBuildEnv( + @Param('componentId') componentId: string, + @Body() body: ReplaceBuildEnvVariablesDto, + ) { + return this.appsService.replaceComponentBuildEnv(componentId, body); + } + @Get('releases') listReleases(@Query('appKey') appKey?: string) { return this.appsService.listReleases(appKey); diff --git a/src/apps/apps.service.ts b/src/apps/apps.service.ts index 180ad31..0227022 100644 --- a/src/apps/apps.service.ts +++ b/src/apps/apps.service.ts @@ -22,6 +22,7 @@ import { CreateRuntimeEnvVariableDto, CreateRuntimeIngressRuleDto, EnsureRuntimeNamespaceDto, + ReplaceBuildEnvVariablesDto, } from './dto'; const execFileAsync = promisify(execFile); @@ -43,6 +44,9 @@ export class AppsService { components: { orderBy: { name: 'asc' }, include: { + buildVariables: { + orderBy: { key: 'asc' }, + }, buildJobs: { where: { status: 'SUCCEEDED' }, orderBy: { createdAt: 'desc' }, @@ -194,6 +198,56 @@ export class AppsService { return { component }; } + async listComponentBuildEnv(componentId: string) { + const component = await this.prisma.appComponent.findUnique({ + where: { id: componentId }, + include: { + buildVariables: { + orderBy: { key: 'asc' }, + }, + }, + }); + + if (!component) { + throw new NotFoundException('앱 구성요소를 찾을 수 없습니다.'); + } + + return { variables: component.buildVariables }; + } + + async replaceComponentBuildEnv( + componentId: string, + body: ReplaceBuildEnvVariablesDto, + ) { + const component = await this.prisma.appComponent.findUnique({ + where: { id: componentId }, + }); + + if (!component) { + throw new NotFoundException('앱 구성요소를 찾을 수 없습니다.'); + } + + const variables = normalizeBuildEnvVariables(body.variables ?? []); + + await this.prisma.$transaction([ + this.prisma.buildEnvVariable.deleteMany({ where: { componentId } }), + ...(variables.length + ? [ + this.prisma.buildEnvVariable.createMany({ + data: variables.map((variable) => ({ + componentId, + key: variable.key, + value: variable.value, + isSecret: variable.isSecret, + })), + }), + ] + : []), + ]); + + return this.listComponentBuildEnv(componentId); + } + listReleases(appKey?: string) { return this.prisma.appRelease.findMany({ where: appKey ? { app: { key: appKey } } : undefined, @@ -415,7 +469,21 @@ export class AppsService { if (runtimeProfile?.ingressRules.length) { for (const rule of runtimeProfile.ingressRules) { const serviceName = sanitizeK8sName(rule.component.name); - const ingressName = sanitizeK8sName(`${rule.component.name}-${rule.host}`); + const ingressName = sanitizeK8sName(`${rule.component.name}-${rule.host}-${rule.path}`); + const middlewareName = rule.stripPrefix ? sanitizeK8sName(`${ingressName}-strip`) : null; + + if (middlewareName) { + logs.push(`[apply] middleware/${middlewareName}`); + appendOutput( + logs, + await this.applyK8sManifest(createTraefikStripPrefixMiddlewareManifest({ + namespace, + name: middlewareName, + prefix: rule.path, + })), + ); + } + logs.push(`[apply] ingress/${ingressName}`); appendOutput( logs, @@ -426,6 +494,7 @@ export class AppsService { path: rule.path, serviceName, servicePort: rule.servicePort, + middlewareName, tlsEnabled: rule.tlsEnabled, tlsSecretName: rule.tlsSecretName, })), @@ -875,6 +944,7 @@ export class AppsService { host, path: pathValue, servicePort: body.servicePort, + stripPrefix: Boolean(body.stripPrefix), tlsEnabled: Boolean(body.tlsEnabled), tlsSecretName: body.tlsSecretName?.trim() || null, }, @@ -1367,6 +1437,24 @@ function normalizeOptionalString(value?: string | null) { return trimmed || undefined; } +function normalizeBuildEnvVariables( + variables: Array<{ key?: string; value?: string; isSecret?: boolean }>, +) { + const byKey = new Map(); + + for (const variable of variables) { + const key = variable.key?.trim(); + if (!key) continue; + byKey.set(key, { + key, + value: variable.value ?? '', + isSecret: variable.isSecret ?? false, + }); + } + + return [...byKey.values()].sort((left, right) => left.key.localeCompare(right.key)); +} + function normalizeImageSource(value: string | null | undefined, componentType?: string) { const normalized = value?.trim().toUpperCase(); if (normalized === 'EXTERNAL' || normalized === 'DATABASE') return normalized; @@ -1952,9 +2040,16 @@ function createIngressManifest(input: { path: string; serviceName: string; servicePort: number; + middlewareName?: string | null; tlsEnabled: boolean; tlsSecretName?: string | null; }) { + const annotations: Record = {}; + + if (input.middlewareName) { + annotations['traefik.ingress.kubernetes.io/router.middlewares'] = `${input.namespace}-${input.middlewareName}@kubernetescrd`; + } + const manifest: Record = { apiVersion: 'networking.k8s.io/v1', kind: 'Ingress', @@ -1962,6 +2057,7 @@ function createIngressManifest(input: { name: input.name, namespace: input.namespace, labels: getManagedLabels(input.name, 'ingress'), + ...(Object.keys(annotations).length > 0 ? { annotations } : {}), }, spec: { rules: [ @@ -1998,6 +2094,27 @@ function createIngressManifest(input: { return manifest; } +function createTraefikStripPrefixMiddlewareManifest(input: { + namespace: string; + name: string; + prefix: string; +}) { + return { + apiVersion: 'traefik.io/v1alpha1', + kind: 'Middleware', + metadata: { + name: input.name, + namespace: input.namespace, + labels: getManagedLabels(input.name, 'ingress-middleware'), + }, + spec: { + stripPrefix: { + prefixes: [input.prefix], + }, + }, + }; +} + function createPostgresSecretManifest(input: { namespace: string; name: string; diff --git a/src/apps/build-runner.service.ts b/src/apps/build-runner.service.ts index f1d0b9d..1068a6c 100644 --- a/src/apps/build-runner.service.ts +++ b/src/apps/build-runner.service.ts @@ -12,7 +12,7 @@ import { OnModuleInit, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { App, AppComponent, BuildJob } from '@prisma/client'; +import { App, AppComponent, BuildEnvVariable, BuildJob } from '@prisma/client'; import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; import { PrismaService } from '../prisma/prisma.service'; @@ -20,6 +20,7 @@ import { PrismaService } from '../prisma/prisma.service'; type BuildJobWithComponent = BuildJob & { component: AppComponent & { app: App; + buildVariables: BuildEnvVariable[]; }; }; @@ -93,6 +94,7 @@ export class BuildRunnerService implements OnModuleInit, OnModuleDestroy { component: { include: { app: true, + buildVariables: true, }, }, }, @@ -116,6 +118,7 @@ export class BuildRunnerService implements OnModuleInit, OnModuleDestroy { component: { include: { app: true, + buildVariables: true, }, }, }, @@ -360,12 +363,18 @@ export class BuildRunnerService implements OnModuleInit, OnModuleDestroy { 'BUILD_ARTIFACT_DIR', path.join(os.tmpdir(), 'studio-admin-artifacts'), ); + const buildEnv = resolveBuildEnv(job.component.buildVariables); + + if (job.component.buildVariables.length > 0) { + await append(`[artifact] build env ${job.component.buildVariables.map((variable) => `${variable.key}=${variable.isSecret ? '********' : variable.value}`).join(' ')}`); + } await append(`[artifact] install ${installCommand}`); await runShellCommand({ jobId: job.id, command: installCommand, cwd: workDir, + env: buildEnv, append, onProcess: (child) => this.activeProcesses.set(job.id, child), onProcessEnd: () => this.activeProcesses.delete(job.id), @@ -376,6 +385,7 @@ export class BuildRunnerService implements OnModuleInit, OnModuleDestroy { jobId: job.id, command: buildCommand, cwd: workDir, + env: buildEnv, append, onProcess: (child) => this.activeProcesses.set(job.id, child), onProcessEnd: () => this.activeProcesses.delete(job.id), @@ -625,6 +635,7 @@ async function runShellCommand({ jobId, command, cwd, + env, append, onProcess, onProcessEnd, @@ -632,6 +643,7 @@ async function runShellCommand({ jobId: string; command: string; cwd: string; + env?: NodeJS.ProcessEnv; append: (line: string) => Promise; onProcess: (child: ChildProcess) => void; onProcessEnd: () => void; @@ -641,7 +653,7 @@ async function runShellCommand({ await new Promise((resolve, reject) => { const child = spawn(command, { cwd, - env: process.env, + env: env ?? process.env, stdio: ['ignore', 'pipe', 'pipe'], detached: true, shell: true, @@ -682,6 +694,17 @@ async function runShellCommand({ }); } +function resolveBuildEnv(variables: BuildEnvVariable[]): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...process.env }; + + for (const variable of variables) { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(variable.key)) continue; + env[variable.key] = variable.value; + } + + return env; +} + async function findNewestArtifact(dir: string) { const candidates = await findArtifactCandidates(dir).catch(() => []); candidates.sort((a, b) => b.mtimeMs - a.mtimeMs); diff --git a/src/apps/dto.ts b/src/apps/dto.ts index 997389c..d7318dc 100644 --- a/src/apps/dto.ts +++ b/src/apps/dto.ts @@ -112,6 +112,10 @@ export class CreateRuntimeIngressRuleDto { @Min(1) servicePort!: number; + @IsOptional() + @IsBoolean() + stripPrefix?: boolean; + @IsOptional() @IsBoolean() tlsEnabled?: boolean; @@ -134,6 +138,23 @@ export class CreateBuildJobDto { commitSha?: string; } +export class BuildEnvVariableInputDto { + @IsString() + key!: string; + + @IsString() + value!: string; + + @IsOptional() + @IsBoolean() + isSecret?: boolean; +} + +export class ReplaceBuildEnvVariablesDto { + @IsArray() + variables!: BuildEnvVariableInputDto[]; +} + export class CreateAppReleaseDto { @IsUUID() appId!: string;