improve admin release and deployment safeguards
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-07-09 09:46:43 +09:00
parent da30d88688
commit c2f834e92d
6 changed files with 101 additions and 7 deletions
@@ -1,3 +1,8 @@
ALTER TABLE IF EXISTS "launcher_common_settings" RENAME TO "launcher_settings";
COMMENT ON TABLE "launcher_settings" IS '런처 소스, 빌드 환경 등 내부 설정';
DO $$
BEGIN
IF to_regclass('public.launcher_settings') IS NOT NULL THEN
COMMENT ON TABLE "launcher_settings" IS '런처 소스, 빌드 환경 등 내부 설정';
END IF;
END $$;
@@ -24,9 +24,9 @@ CREATE UNIQUE INDEX IF NOT EXISTS "admin_roles_code_key"
CREATE INDEX IF NOT EXISTS "admin_roles_is_active_sort_order_idx"
ON "admin_roles"("is_active", "sort_order");
INSERT INTO "admin_roles" ("code", "name", "description", "sort_order", "is_active")
INSERT INTO "admin_roles" ("id", "code", "name", "description", "sort_order", "is_active", "updated_at")
VALUES
('OWNER', 'OWNER', '전체 관리자 권한', 1, true),
('ADMIN', 'ADMIN', '관리자 권한', 2, true),
('VIEWER', 'VIEWER', '조회 권한', 3, true)
(gen_random_uuid(), 'OWNER', 'OWNER', '전체 관리자 권한', 1, true, CURRENT_TIMESTAMP),
(gen_random_uuid(), 'ADMIN', 'ADMIN', '관리자 권한', 2, true, CURRENT_TIMESTAMP),
(gen_random_uuid(), 'VIEWER', 'VIEWER', '조회 권한', 3, true, CURRENT_TIMESTAMP)
ON CONFLICT ("code") DO NOTHING;
@@ -0,0 +1,5 @@
ALTER TABLE "build_jobs"
ADD COLUMN IF NOT EXISTS "release_candidate_hidden_at" TIMESTAMPTZ(3);
CREATE INDEX IF NOT EXISTS "build_jobs_release_candidate_hidden_at_idx"
ON "build_jobs"("release_candidate_hidden_at");
+2
View File
@@ -220,10 +220,12 @@ model BuildJob {
logs String?
startedAt DateTime? @map("started_at") @db.Timestamptz(3)
finishedAt DateTime? @map("finished_at") @db.Timestamptz(3)
releaseCandidateHiddenAt DateTime? @map("release_candidate_hidden_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
component AppComponent @relation(fields: [componentId], references: [id], onDelete: Cascade)
@@index([componentId, createdAt])
@@index([releaseCandidateHiddenAt])
@@map("build_jobs")
}
+12 -1
View File
@@ -77,8 +77,9 @@ export class AppsController {
@Query('limit') limit?: string,
@Query('since') since?: string,
@Query('status') status?: string,
@Query('hideReleaseCandidates') hideReleaseCandidates?: string,
) {
return this.appsService.listBuildJobs(appKey, page, limit, since, status);
return this.appsService.listBuildJobs(appKey, page, limit, since, status, hideReleaseCandidates);
}
@Post('builds')
@@ -91,6 +92,11 @@ export class AppsController {
return this.appsService.cancelBuildJob(buildJobId);
}
@Patch('builds/:buildJobId/hide-release-candidate')
hideReleaseCandidate(@Param('buildJobId') buildJobId: string) {
return this.appsService.hideReleaseCandidate(buildJobId);
}
@Delete('builds/:buildJobId')
deleteBuildJob(@Param('buildJobId') buildJobId: string) {
return this.appsService.deleteBuildJob(buildJobId);
@@ -132,6 +138,11 @@ export class AppsController {
return this.appsService.createRelease(body);
}
@Delete('releases/:releaseId')
deleteRelease(@Param('releaseId') releaseId: string) {
return this.appsService.deleteRelease(releaseId);
}
@Get('deployments')
listDeployments(
@Query('appKey') appKey?: string,
+72 -1
View File
@@ -1139,6 +1139,36 @@ export class AppsService {
}
}
async deleteRelease(releaseId: string) {
const release = await this.prisma.appRelease.findUnique({
where: { id: releaseId },
select: {
id: true,
status: true,
deployments: {
where: { status: 'SUCCEEDED' },
select: { id: true },
take: 1,
},
},
});
if (!release) {
throw new NotFoundException('릴리즈를 찾을 수 없습니다.');
}
if (release.status === 'PENDING' || release.status === 'RUNNING') {
throw new BadRequestException('진행 중인 릴리즈는 삭제할 수 없습니다.');
}
if (release.deployments.length > 0) {
throw new BadRequestException('이미 성공 배포된 릴리즈는 삭제할 수 없습니다.');
}
await this.prisma.appRelease.delete({ where: { id: releaseId } });
return { deleted: true };
}
async listMigrationJobs(appId?: string, page?: string, limit?: string, since?: string) {
const pagination = parsePagination(page, limit);
const sinceDate = parseSinceDate(since);
@@ -1326,13 +1356,15 @@ export class AppsService {
}
}
async listBuildJobs(appKey?: string, page?: string, limit?: string, since?: string, status?: string) {
async listBuildJobs(appKey?: string, page?: string, limit?: string, since?: string, status?: string, hideReleaseCandidates?: string) {
const pagination = parsePagination(page, limit);
const sinceDate = parseSinceDate(since);
const excludeHiddenReleaseCandidates = hideReleaseCandidates === 'true';
const where: Prisma.BuildJobWhereInput = {
...(appKey ? { component: { app: { key: appKey } } } : {}),
...(sinceDate ? { createdAt: { gte: sinceDate } } : {}),
...(status ? { status: normalizeJobStatus(status) } : {}),
...(excludeHiddenReleaseCandidates ? { releaseCandidateHiddenAt: null } : {}),
};
const include = {
component: {
@@ -1347,6 +1379,7 @@ export class AppsService {
where: {
...(appKey ? { component: { app: { key: appKey } } } : {}),
...(status ? { status: normalizeJobStatus(status) } : {}),
...(excludeHiddenReleaseCandidates ? { releaseCandidateHiddenAt: null } : {}),
},
orderBy: { createdAt: 'desc' },
include,
@@ -1941,6 +1974,44 @@ export class AppsService {
return { deleted: true };
}
async hideReleaseCandidate(buildJobId: string) {
const buildJob = await this.prisma.buildJob.findUnique({
where: { id: buildJobId },
select: {
id: true,
status: true,
releaseCandidateHiddenAt: true,
},
});
if (!buildJob) {
throw new NotFoundException('빌드 작업을 찾을 수 없습니다.');
}
if (buildJob.status === 'PENDING' || buildJob.status === 'RUNNING') {
throw new BadRequestException('진행 중인 빌드는 릴리즈 후보에서 숨길 수 없습니다.');
}
const usedReleases = await this.prisma.appRelease.findMany({
select: { imageTags: true },
});
if (getUsedReleaseBuildIds(usedReleases).includes(buildJob.id)) {
throw new BadRequestException('이미 릴리즈에 사용된 빌드는 후보에서 숨길 수 없습니다.');
}
const updated = await this.prisma.buildJob.update({
where: { id: buildJob.id },
data: { releaseCandidateHiddenAt: buildJob.releaseCandidateHiddenAt ?? new Date() },
include: {
component: {
include: { app: true },
},
},
});
return { buildJob: updated };
}
private async kubectl(args: string[]) {
const kubectlBin = this.configService.get<string>('KUBECTL_BIN', 'kubectl');
const env = await this.kubectlEnv();