add build env and path ingress controls

This commit is contained in:
2026-07-02 15:44:12 +09:00
parent 3195d36527
commit 2729e95a5b
7 changed files with 226 additions and 4 deletions
+15 -1
View File
@@ -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);
+118 -1
View File
@@ -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<string, { key: string; value: string; isSecret: boolean }>();
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<string, string> = {};
if (input.middlewareName) {
annotations['traefik.ingress.kubernetes.io/router.middlewares'] = `${input.namespace}-${input.middlewareName}@kubernetescrd`;
}
const manifest: Record<string, unknown> = {
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;
+25 -2
View File
@@ -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<void>;
onProcess: (child: ChildProcess) => void;
onProcessEnd: () => void;
@@ -641,7 +653,7 @@ async function runShellCommand({
await new Promise<void>((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);
+21
View File
@@ -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;