paginate app operations and refine runtime settings layout
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
+380
-26
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user