paginate app operations and refine runtime settings layout
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-07-07 15:33:08 +09:00
parent 895e56088c
commit 5be7f0980b
8 changed files with 512 additions and 34 deletions
+380 -26
View File
@@ -17,6 +17,7 @@ import {
CreateAppReleaseDto,
CreateBuildJobDto,
CreateDeploymentJobDto,
DecommissionAppDto,
CreateMigrationJobDto,
CreateRuntimeEnvProfileDto,
CreateRuntimeEnvVariableDto,
@@ -78,6 +79,15 @@ export class AppsService {
}
async deleteApp(appId: string) {
return this.decommissionApp(appId, {
unlinkSources: true,
scaleDownWorkloads: false,
deleteKubernetesResources: false,
deletePersistentVolumes: false,
});
}
async decommissionApp(appId: string, body: DecommissionAppDto = {}) {
const app = await this.prisma.app.findUnique({
where: { id: appId },
include: {
@@ -90,12 +100,46 @@ export class AppsService {
if (!app) throw new NotFoundException('앱을 찾을 수 없습니다.');
const logs: string[] = [`[app] decommission ${app.name}`];
const namespace = app.namespace ? normalizeNamespace(app.namespace) : '';
if (namespace && (body.scaleDownWorkloads || body.deleteKubernetesResources || body.deletePersistentVolumes)) {
const exists = await this.namespaceExists(namespace);
if (!exists) {
logs.push(`[skip] namespace ${namespace} not found`);
} else {
if (body.scaleDownWorkloads) {
await this.scaleDownAppWorkloads(namespace, app.components, logs);
}
if (body.deleteKubernetesResources) {
await this.deleteAppKubernetesResources(namespace, app.components, logs);
}
if (body.deletePersistentVolumes) {
await this.deleteAppPersistentVolumeClaims(namespace, app.components, logs);
}
}
}
if (body.unlinkSources) {
const result = await this.prisma.appComponent.updateMany({
where: { appId },
data: {
repository: null,
branch: null,
buildTarget: null,
},
});
logs.push(`[db] unlinked ${result.count} component source settings`);
}
await this.prisma.app.delete({ where: { id: appId } });
logs.push('[db] app deleted');
return {
deleted: true,
appId,
namespace: app.namespace,
logs: logs.filter(Boolean).join('\n'),
};
}
@@ -250,12 +294,50 @@ export class AppsService {
return this.listComponentBuildEnv(componentId);
}
listReleases(appKey?: string) {
return this.prisma.appRelease.findMany({
async listReleases(appKey?: string, page?: string, limit?: string, since?: string) {
const pagination = parsePagination(page, limit);
const sinceDate = parseSinceDate(since);
const where: Prisma.AppReleaseWhereInput = {
...(appKey ? { app: { key: appKey } } : {}),
...(sinceDate ? { createdAt: { gte: sinceDate } } : {}),
};
if (!pagination && !sinceDate) {
return this.prisma.appRelease.findMany({
where: appKey ? { app: { key: appKey } } : undefined,
orderBy: { createdAt: 'desc' },
include: { app: true },
});
}
const pageNumber = pagination?.page ?? 1;
const pageLimit = pagination?.limit ?? 20;
const [items, total] = await this.prisma.$transaction([
this.prisma.appRelease.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (pageNumber - 1) * pageLimit,
take: pageLimit,
include: { app: true },
}),
this.prisma.appRelease.count({ where }),
]);
const usedReleases = await this.prisma.appRelease.findMany({
where: appKey ? { app: { key: appKey } } : undefined,
orderBy: { createdAt: 'desc' },
include: { app: true },
select: {
artifact: true,
imageTags: true,
},
});
return {
releases: items,
total,
page: pageNumber,
limit: pageLimit,
usedBuildIds: getUsedReleaseBuildIds(usedReleases),
usedValues: getUsedReleaseValues(usedReleases),
};
}
async createRelease(body: CreateAppReleaseDto) {
@@ -666,15 +748,56 @@ export class AppsService {
return data.login;
}
listDeployments(appKey?: string) {
return this.prisma.deploymentJob.findMany({
where: appKey ? { app: { key: appKey } } : undefined,
orderBy: { createdAt: 'desc' },
include: {
app: true,
release: true,
async listDeployments(appKey?: string, page?: string, limit?: string, since?: string) {
const pagination = parsePagination(page, limit);
const sinceDate = parseSinceDate(since);
const where: Prisma.DeploymentJobWhereInput = {
...(appKey ? { app: { key: appKey } } : {}),
...(sinceDate ? { createdAt: { gte: sinceDate } } : {}),
};
const include = {
app: true,
release: true,
};
if (!pagination && !sinceDate) {
return this.prisma.deploymentJob.findMany({
where: appKey ? { app: { key: appKey } } : undefined,
orderBy: { createdAt: 'desc' },
include,
});
}
const pageNumber = pagination?.page ?? 1;
const pageLimit = pagination?.limit ?? 20;
const [items, total] = await this.prisma.$transaction([
this.prisma.deploymentJob.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (pageNumber - 1) * pageLimit,
take: pageLimit,
include,
}),
this.prisma.deploymentJob.count({ where }),
]);
const deployedJobs = await this.prisma.deploymentJob.findMany({
where: {
...(appKey ? { app: { key: appKey } } : {}),
status: 'SUCCEEDED',
releaseId: { not: null },
},
select: { releaseId: true },
});
return {
deployments: items,
total,
page: pageNumber,
limit: pageLimit,
deployedReleaseIds: deployedJobs
.map((job) => job.releaseId)
.filter((releaseId): releaseId is string => Boolean(releaseId)),
};
}
async createDeploymentJob(body: CreateDeploymentJobDto) {
@@ -907,11 +1030,40 @@ export class AppsService {
}
}
listMigrationJobs() {
return this.prisma.runtimeControlJob.findMany({
where: { action: 'MIGRATION' },
orderBy: { createdAt: 'desc' },
});
async listMigrationJobs(appId?: string, page?: string, limit?: string, since?: string) {
const pagination = parsePagination(page, limit);
const sinceDate = parseSinceDate(since);
const where: Prisma.RuntimeControlJobWhereInput = {
action: 'MIGRATION',
...(appId ? { request: { path: ['appId'], equals: appId } } : {}),
...(sinceDate ? { createdAt: { gte: sinceDate } } : {}),
};
if (!pagination && !sinceDate && !appId) {
return this.prisma.runtimeControlJob.findMany({
where: { action: 'MIGRATION' },
orderBy: { createdAt: 'desc' },
});
}
const pageNumber = pagination?.page ?? 1;
const pageLimit = pagination?.limit ?? 20;
const [items, total] = await this.prisma.$transaction([
this.prisma.runtimeControlJob.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (pageNumber - 1) * pageLimit,
take: pageLimit,
}),
this.prisma.runtimeControlJob.count({ where }),
]);
return {
jobs: items,
total,
page: pageNumber,
limit: pageLimit,
};
}
async createMigrationJob(body: CreateMigrationJobDto) {
@@ -1043,18 +1195,52 @@ export class AppsService {
}
}
listBuildJobs(appKey?: string) {
return this.prisma.buildJob.findMany({
where: appKey ? { component: { app: { key: appKey } } } : undefined,
orderBy: { createdAt: 'desc' },
include: {
component: {
include: {
app: true,
},
async listBuildJobs(appKey?: string, page?: string, limit?: string, since?: string, status?: string) {
const pagination = parsePagination(page, limit);
const sinceDate = parseSinceDate(since);
const where: Prisma.BuildJobWhereInput = {
...(appKey ? { component: { app: { key: appKey } } } : {}),
...(sinceDate ? { createdAt: { gte: sinceDate } } : {}),
...(status ? { status: normalizeJobStatus(status) } : {}),
};
const include = {
component: {
include: {
app: true,
},
},
});
};
if (!pagination && !sinceDate) {
return this.prisma.buildJob.findMany({
where: {
...(appKey ? { component: { app: { key: appKey } } } : {}),
...(status ? { status: normalizeJobStatus(status) } : {}),
},
orderBy: { createdAt: 'desc' },
include,
});
}
const pageNumber = pagination?.page ?? 1;
const pageLimit = pagination?.limit ?? 20;
const [items, total] = await this.prisma.$transaction([
this.prisma.buildJob.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (pageNumber - 1) * pageLimit,
take: pageLimit,
include,
}),
this.prisma.buildJob.count({ where }),
]);
return {
jobs: items,
total,
page: pageNumber,
limit: pageLimit,
};
}
listRuntimeProfiles(appId?: string) {
@@ -1716,6 +1902,121 @@ export class AppsService {
}
}
private async scaleDownAppWorkloads(
namespace: string,
components: Array<{ name: string; type: string; workloadType?: string | null }>,
logs: string[],
) {
for (const component of components) {
const name = sanitizeK8sName(component.name);
if (!name) continue;
const workloadType = normalizeWorkloadType(component.workloadType, component.type);
if (workloadType === 'Job') continue;
const resource = workloadType === 'StatefulSet' ? 'statefulset' : 'deployment';
await this.tryKubectl(logs, `[scale] ${resource}/${name} replicas=0`, [
'scale',
`${resource}/${name}`,
'--replicas=0',
'-n',
namespace,
]);
}
}
private async deleteAppKubernetesResources(
namespace: string,
components: Array<{ name: string; type: string; workloadType?: string | null }>,
logs: string[],
) {
for (const component of components) {
const name = sanitizeK8sName(component.name);
if (!name) continue;
const workloadType = normalizeWorkloadType(component.workloadType, component.type);
if (workloadType !== 'Job') {
const workload = workloadType === 'StatefulSet' ? 'statefulset' : 'deployment';
await this.tryKubectl(logs, `[delete] ${workload}/${name}`, [
'delete',
workload,
name,
'-n',
namespace,
'--ignore-not-found=true',
]);
}
for (const resource of ['service', 'ingress', 'configmap', 'secret']) {
await this.tryKubectl(logs, `[delete] ${resource}/${name}`, [
'delete',
resource,
name,
'-n',
namespace,
'--ignore-not-found=true',
]);
}
await this.tryKubectl(logs, `[delete] configmap/${name}-env`, [
'delete',
'configmap',
`${name}-env`,
'-n',
namespace,
'--ignore-not-found=true',
]);
await this.tryKubectl(logs, `[delete] secret/${name}-secret`, [
'delete',
'secret',
`${name}-secret`,
'-n',
namespace,
'--ignore-not-found=true',
]);
}
}
private async deleteAppPersistentVolumeClaims(
namespace: string,
components: Array<{ name: string }>,
logs: string[],
) {
const output = await this.kubectl(['get', 'pvc', '-n', namespace, '-o', 'json']);
const parsed = JSON.parse(output) as { items?: Array<{ metadata?: { name?: string } }> };
const names = new Set(components.map((component) => sanitizeK8sName(component.name)).filter(Boolean));
const claims = (parsed.items ?? [])
.map((item) => item.metadata?.name ?? '')
.filter((claimName) => Array.from(names).some((name) => claimName === name || claimName.startsWith(`data-${name}-`) || claimName.includes(`-${name}-`)));
if (claims.length === 0) {
logs.push('[pvc] matched persistent volume claims not found');
return;
}
for (const claim of claims) {
await this.tryKubectl(logs, `[delete] pvc/${claim}`, [
'delete',
'pvc',
claim,
'-n',
namespace,
'--ignore-not-found=true',
]);
}
}
private async tryKubectl(logs: string[], label: string, args: string[]) {
logs.push(label);
try {
const output = await this.kubectl(args);
if (output.trim()) appendOutput(logs, output);
} catch (error) {
const message = error instanceof Error ? error.message : 'kubectl failed';
logs.push(`[warn] ${label} failed: ${message}`);
}
}
private async applyDatabaseResources(input: {
namespace: string;
appKey: string;
@@ -2675,6 +2976,59 @@ function parseReleaseImageTags(value: Prisma.JsonValue | null) {
return { images };
}
function getUsedReleaseBuildIds(releases: Array<{ imageTags: Prisma.JsonValue | null }>) {
const ids = new Set<string>();
for (const release of releases) {
const value = release.imageTags;
if (!value || typeof value !== 'object' || Array.isArray(value)) continue;
const promotion = (value as Record<string, unknown>).promotion;
if (!promotion || typeof promotion !== 'object' || Array.isArray(promotion)) continue;
const sourceBuildJobIds = (promotion as Record<string, unknown>).sourceBuildJobIds;
if (!Array.isArray(sourceBuildJobIds)) continue;
for (const id of sourceBuildJobIds) {
if (typeof id === 'string') ids.add(id);
}
}
return [...ids];
}
function getUsedReleaseValues(releases: Array<{ artifact: string | null; imageTags: Prisma.JsonValue | null }>) {
const values = new Set<string>();
for (const release of releases) {
if (release.artifact) values.add(release.artifact);
const parsed = parseReleaseImageTags(release.imageTags);
for (const image of Object.values(parsed.images)) {
if (image) values.add(image);
}
if (release.imageTags && typeof release.imageTags === 'object' && !Array.isArray(release.imageTags)) {
const artifacts = (release.imageTags as Record<string, unknown>).artifacts;
if (artifacts && typeof artifacts === 'object' && !Array.isArray(artifacts)) {
for (const artifact of Object.values(artifacts)) {
if (typeof artifact === 'string' && artifact) values.add(artifact);
}
}
}
}
return [...values];
}
function normalizeJobStatus(status: string) {
return status.trim().toUpperCase() as Prisma.EnumJobStatusFilter['equals'];
}
function parsePagination(page?: string, limit?: string) {
if (!page && !limit) return null;
const pageNumber = Math.max(1, Number.parseInt(page || '1', 10) || 1);
const pageLimit = Math.min(100, Math.max(1, Number.parseInt(limit || '20', 10) || 20));
return { page: pageNumber, limit: pageLimit };
}
function parseSinceDate(value?: string) {
if (!value) return null;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
async function gitRefExists(workDir: string, ref: string, gitBin = 'git') {
try {