Compare commits
5 Commits
3945e42c53
...
release
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f67ee5719 | |||
| a6a4426249 | |||
| 8b7ec648cd | |||
| 8151ec094d | |||
| 63e85d5306 |
@@ -44,6 +44,7 @@ model User {
|
|||||||
userLicenses UserLicense[]
|
userLicenses UserLicense[]
|
||||||
refreshTokens RefreshToken[]
|
refreshTokens RefreshToken[]
|
||||||
affiliations UserAffiliation[]
|
affiliations UserAffiliation[]
|
||||||
|
appRoles UserAppRole[]
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
@@ -113,6 +114,7 @@ model Organization {
|
|||||||
type OrganizationType @default(OTHER)
|
type OrganizationType @default(OTHER)
|
||||||
domain String?
|
domain String?
|
||||||
defaultLicenseCode String? @map("default_license_code")
|
defaultLicenseCode String? @map("default_license_code")
|
||||||
|
instructorInviteCode String @unique @map("instructor_invite_code")
|
||||||
isActive Boolean @default(true) @map("is_active")
|
isActive Boolean @default(true) @map("is_active")
|
||||||
createdById String? @map("created_by_id") @db.Uuid
|
createdById String? @map("created_by_id") @db.Uuid
|
||||||
createdByName String? @map("created_by_name")
|
createdByName String? @map("created_by_name")
|
||||||
@@ -145,3 +147,22 @@ model UserAffiliation {
|
|||||||
@@index([organizationId])
|
@@index([organizationId])
|
||||||
@@map("user_affiliations")
|
@@map("user_affiliations")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model UserAppRole {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
userId String @map("user_id") @db.Uuid
|
||||||
|
appKey String @map("app_key")
|
||||||
|
roleCode String @map("role_code")
|
||||||
|
assignedById String? @map("assigned_by") @db.Uuid
|
||||||
|
assignedAt DateTime @default(now()) @map("assigned_at") @db.Timestamptz(3)
|
||||||
|
startsAt DateTime? @map("starts_at") @db.Timestamptz(3)
|
||||||
|
endsAt DateTime? @map("ends_at") @db.Timestamptz(3)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([userId, appKey, roleCode])
|
||||||
|
@@index([appKey, roleCode])
|
||||||
|
@@index([userId, appKey])
|
||||||
|
@@map("user_app_roles")
|
||||||
|
}
|
||||||
|
|||||||
@@ -77,6 +77,9 @@ model ProvisionedDevice {
|
|||||||
kernel String?
|
kernel String?
|
||||||
distro String?
|
distro String?
|
||||||
device Json?
|
device Json?
|
||||||
|
agentVersion String? @map("agent_version")
|
||||||
|
agentUpdatedAt DateTime? @map("agent_updated_at") @db.Timestamptz(3)
|
||||||
|
agentLastSeenAt DateTime? @map("agent_last_seen_at") @db.Timestamptz(3)
|
||||||
installedAt DateTime @default(now()) @map("installed_at") @db.Timestamptz(3)
|
installedAt DateTime @default(now()) @map("installed_at") @db.Timestamptz(3)
|
||||||
lastProvisionedAt DateTime @default(now()) @map("last_provisioned_at") @db.Timestamptz(3)
|
lastProvisionedAt DateTime @default(now()) @map("last_provisioned_at") @db.Timestamptz(3)
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||||
@@ -85,6 +88,7 @@ model ProvisionedDevice {
|
|||||||
@@unique([accountEmail, tailscaleHostname])
|
@@unique([accountEmail, tailscaleHostname])
|
||||||
@@index([accountEmail])
|
@@index([accountEmail])
|
||||||
@@index([tailscaleHostname])
|
@@index([tailscaleHostname])
|
||||||
|
@@index([agentLastSeenAt])
|
||||||
@@map("provisioned_devices")
|
@@map("provisioned_devices")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "build_jobs"
|
||||||
|
ADD COLUMN "artifact_sha256" TEXT;
|
||||||
@@ -215,6 +215,7 @@ model BuildJob {
|
|||||||
branch String
|
branch String
|
||||||
commitSha String? @map("commit_sha")
|
commitSha String? @map("commit_sha")
|
||||||
artifact String?
|
artifact String?
|
||||||
|
artifactSha256 String? @map("artifact_sha256")
|
||||||
image String?
|
image String?
|
||||||
status JobStatus @default(PENDING)
|
status JobStatus @default(PENDING)
|
||||||
requestedById String? @map("requested_by_id") @db.Uuid
|
requestedById String? @map("requested_by_id") @db.Uuid
|
||||||
|
|||||||
+142
-124
@@ -509,6 +509,15 @@ export class AppsService {
|
|||||||
throw new Error('릴리즈 기준 빌드 작업을 찾을 수 없습니다.');
|
throw new Error('릴리즈 기준 빌드 작업을 찾을 수 없습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const missingArtifactMetadata = sourceBuildJobs.find((job) => (
|
||||||
|
job.component.type === 'ELECTRON' && (!job.artifact || !job.artifactSha256)
|
||||||
|
));
|
||||||
|
if (missingArtifactMetadata) {
|
||||||
|
throw new Error(
|
||||||
|
`${missingArtifactMetadata.component.name} 빌드에 R2 산출물 또는 SHA-256 정보가 없습니다. 새 빌드 후 릴리즈하세요.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
for (const job of sourceBuildJobs) {
|
for (const job of sourceBuildJobs) {
|
||||||
await this.appendReleaseLog(releaseId, `[source] ${job.component.name} develop ${job.commitSha?.slice(0, 7) ?? '-'}`);
|
await this.appendReleaseLog(releaseId, `[source] ${job.component.name} develop ${job.commitSha?.slice(0, 7) ?? '-'}`);
|
||||||
}
|
}
|
||||||
@@ -516,21 +525,10 @@ export class AppsService {
|
|||||||
await this.promoteReleaseBranches(sourceBuildJobs, 'develop', release.branch);
|
await this.promoteReleaseBranches(sourceBuildJobs, 'develop', release.branch);
|
||||||
await this.appendReleaseLog(releaseId, '[promote] completed');
|
await this.appendReleaseLog(releaseId, '[promote] completed');
|
||||||
|
|
||||||
const releaseBuildJobs = await this.createReleaseBuildJobs(sourceBuildJobs, release.branch);
|
await this.appendReleaseLog(releaseId, '[artifact] reusing selected develop build artifacts');
|
||||||
for (const job of releaseBuildJobs) {
|
const commitShas = [...new Set(sourceBuildJobs.map((job) => job.commitSha).filter(Boolean))];
|
||||||
await this.appendReleaseLog(releaseId, `[build] queued ${job.component.name} ${job.branch} ${job.commitSha?.slice(0, 7) ?? '-'}`);
|
const artifact = sourceBuildJobs.find((job) => job.artifact)?.artifact ?? null;
|
||||||
}
|
const imageTags = createReleaseImageTags(sourceBuildJobs, release.app.components);
|
||||||
await this.appendReleaseLog(releaseId, '[wait] waiting for release build jobs');
|
|
||||||
const completedBuildJobs = await this.waitForBuildJobs(releaseBuildJobs.map((job) => job.id));
|
|
||||||
const failed = completedBuildJobs.find((job) => job.status !== 'SUCCEEDED');
|
|
||||||
if (failed) {
|
|
||||||
throw new Error(`${failed.component.name} release 빌드 실패: ${failed.status}`);
|
|
||||||
}
|
|
||||||
await this.appendReleaseLog(releaseId, '[build] release build jobs completed');
|
|
||||||
|
|
||||||
const commitShas = [...new Set(completedBuildJobs.map((job) => job.commitSha).filter(Boolean))];
|
|
||||||
const artifact = completedBuildJobs.find((job) => job.artifact)?.artifact ?? null;
|
|
||||||
const imageTags = createReleaseImageTags(completedBuildJobs, release.app.components);
|
|
||||||
|
|
||||||
await this.appendReleaseLog(releaseId, '[done] release completed');
|
await this.appendReleaseLog(releaseId, '[done] release completed');
|
||||||
await this.prisma.appRelease.update({
|
await this.prisma.appRelease.update({
|
||||||
@@ -572,87 +570,6 @@ export class AppsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async createReleaseBuildJobs(
|
|
||||||
sourceBuildJobs: Array<{
|
|
||||||
componentId: string;
|
|
||||||
commitSha: string | null;
|
|
||||||
requestedById?: string | null;
|
|
||||||
requestedByName?: string | null;
|
|
||||||
requestedByEmail?: string | null;
|
|
||||||
component: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
type: string;
|
|
||||||
repository: string | null;
|
|
||||||
buildTarget: string | null;
|
|
||||||
app: { name: string };
|
|
||||||
};
|
|
||||||
}>,
|
|
||||||
branch: string,
|
|
||||||
) {
|
|
||||||
const jobs = [];
|
|
||||||
for (const sourceJob of sourceBuildJobs) {
|
|
||||||
const component = sourceJob.component;
|
|
||||||
const artifact = getBuildArtifact(component, branch, sourceJob.commitSha);
|
|
||||||
const image = getBuildImage(
|
|
||||||
component,
|
|
||||||
branch,
|
|
||||||
sourceJob.commitSha,
|
|
||||||
this.configService.get<string>('BUILD_REGISTRY', 'registry.local'),
|
|
||||||
);
|
|
||||||
const job = await this.prisma.buildJob.create({
|
|
||||||
data: {
|
|
||||||
componentId: component.id,
|
|
||||||
branch,
|
|
||||||
commitSha: sourceJob.commitSha,
|
|
||||||
artifact,
|
|
||||||
image,
|
|
||||||
status: 'PENDING',
|
|
||||||
requestedById: sourceJob.requestedById,
|
|
||||||
requestedByName: sourceJob.requestedByName,
|
|
||||||
requestedByEmail: sourceJob.requestedByEmail,
|
|
||||||
logs: getBuildPlanLogs({
|
|
||||||
appName: component.app.name,
|
|
||||||
componentName: component.name,
|
|
||||||
componentType: component.type,
|
|
||||||
repository: component.repository || '',
|
|
||||||
branch,
|
|
||||||
commitSha: sourceJob.commitSha,
|
|
||||||
buildTarget: component.buildTarget,
|
|
||||||
artifact,
|
|
||||||
image,
|
|
||||||
giteaBaseUrl: this.configService.get<string>(
|
|
||||||
'GITEA_BASE_URL',
|
|
||||||
'http://localhost:3000',
|
|
||||||
),
|
|
||||||
}).join('\n'),
|
|
||||||
},
|
|
||||||
include: { component: true },
|
|
||||||
});
|
|
||||||
jobs.push(job);
|
|
||||||
}
|
|
||||||
return jobs;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async waitForBuildJobs(buildJobIds: string[]) {
|
|
||||||
const timeoutMs = Number(this.configService.get<string>('RELEASE_BUILD_TIMEOUT_MS', '1800000'));
|
|
||||||
const startedAt = Date.now();
|
|
||||||
|
|
||||||
while (Date.now() - startedAt < timeoutMs) {
|
|
||||||
const jobs = await this.prisma.buildJob.findMany({
|
|
||||||
where: { id: { in: buildJobIds } },
|
|
||||||
include: { component: true },
|
|
||||||
});
|
|
||||||
if (jobs.length === buildJobIds.length && jobs.every((job) => ['SUCCEEDED', 'FAILED', 'CANCELED'].includes(job.status))) {
|
|
||||||
return jobs;
|
|
||||||
}
|
|
||||||
await delay(3000);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('release 빌드 완료 대기 시간이 초과되었습니다.');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private async promoteReleaseBranches(
|
private async promoteReleaseBranches(
|
||||||
buildJobs: Array<{
|
buildJobs: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -1008,35 +925,56 @@ export class AppsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const app = release?.app ?? quickBuild!.component.app;
|
const app = release?.app ?? quickBuild!.component.app;
|
||||||
const images = release
|
const releaseImages = release
|
||||||
? parseReleaseImageTags(release.imageTags).images
|
? parseReleaseImageTags(release.imageTags).images
|
||||||
: { [quickBuild!.component.name]: quickBuild!.image! };
|
: { [quickBuild!.component.name]: quickBuild!.image! };
|
||||||
|
const images = Object.fromEntries(
|
||||||
|
Object.entries(releaseImages).filter(([componentName]) => {
|
||||||
|
const componentType = app.components.find((component) => component.name === componentName)?.type;
|
||||||
|
return componentType === 'CONTAINER' || componentType === 'STATIC_WEB';
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const hasKubernetesWorkload = Object.keys(images).length > 0;
|
||||||
|
const isLauncherArtifactRelease = Boolean(
|
||||||
|
release
|
||||||
|
&& !hasKubernetesWorkload
|
||||||
|
&& body.updateLauncherAppVersion !== false
|
||||||
|
&& app.components.some((component) => component.type === 'ELECTRON')
|
||||||
|
&& hasReleaseArtifact(release.artifact, release.imageTags),
|
||||||
|
);
|
||||||
const deploymentLabel = release
|
const deploymentLabel = release
|
||||||
? `${app.name} ${release.version}`
|
? `${app.name} ${release.version}`
|
||||||
: `${app.name} ${quickBuild!.component.name} ${quickBuild!.commitSha?.slice(0, 7) ?? 'quick'}`;
|
: `${app.name} ${quickBuild!.component.name} ${quickBuild!.commitSha?.slice(0, 7) ?? 'quick'}`;
|
||||||
const requestedResources = body.resources;
|
const requestedResources = body.resources;
|
||||||
const runtimeProfile = await this.prisma.runtimeEnvProfile.findFirst({
|
const runtimeProfile = hasKubernetesWorkload
|
||||||
where: {
|
? await this.prisma.runtimeEnvProfile.findFirst({
|
||||||
appId: app.id,
|
where: {
|
||||||
environment,
|
appId: app.id,
|
||||||
namespace,
|
environment,
|
||||||
},
|
namespace,
|
||||||
include: {
|
},
|
||||||
ingressRules: {
|
include: {
|
||||||
include: { component: true },
|
ingressRules: {
|
||||||
orderBy: { host: 'asc' },
|
include: { component: true },
|
||||||
},
|
orderBy: { host: 'asc' },
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
if (Object.keys(images).length === 0) {
|
})
|
||||||
throw new BadRequestException('릴리즈에 배포 가능한 이미지가 없습니다.');
|
: null;
|
||||||
|
if (!hasKubernetesWorkload && !isLauncherArtifactRelease) {
|
||||||
|
if (release && hasReleaseArtifact(release.artifact, release.imageTags)) {
|
||||||
|
throw new BadRequestException('AppImage 릴리즈는 런처 앱 버전 업데이트를 선택해야 합니다.');
|
||||||
|
}
|
||||||
|
throw new BadRequestException('릴리즈에 배포 가능한 이미지 또는 AppImage 산출물이 없습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const namespaceExists = await this.namespaceExists(namespace);
|
if (hasKubernetesWorkload) {
|
||||||
if (!namespaceExists) {
|
const namespaceExists = await this.namespaceExists(namespace);
|
||||||
throw new BadRequestException(
|
if (!namespaceExists) {
|
||||||
`${namespace} namespace가 없습니다. 런타임 구성에서 Namespace 생성/확인을 먼저 실행하세요.`,
|
throw new BadRequestException(
|
||||||
);
|
`${namespace} namespace가 없습니다. 런타임 구성에서 Namespace 생성/확인을 먼저 실행하세요.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const deploymentJob = await this.prisma.deploymentJob.create({
|
const deploymentJob = await this.prisma.deploymentJob.create({
|
||||||
@@ -1053,6 +991,7 @@ export class AppsService {
|
|||||||
`[running] deployment started: ${deploymentLabel}`,
|
`[running] deployment started: ${deploymentLabel}`,
|
||||||
...(quickBuild ? [`[source] quick build ${quickBuild.id} ${quickBuild.image}`] : []),
|
...(quickBuild ? [`[source] quick build ${quickBuild.id} ${quickBuild.image}`] : []),
|
||||||
`[target] environment=${environment} namespace=${namespace}`,
|
`[target] environment=${environment} namespace=${namespace}`,
|
||||||
|
...(isLauncherArtifactRelease ? ['[launcher] AppImage release; Kubernetes rollout skipped'] : []),
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
@@ -1731,13 +1670,33 @@ export class AppsService {
|
|||||||
async deleteRuntimeProfile(profileId: string) {
|
async deleteRuntimeProfile(profileId: string) {
|
||||||
const profile = await this.prisma.runtimeEnvProfile.findUnique({
|
const profile = await this.prisma.runtimeEnvProfile.findUnique({
|
||||||
where: { id: profileId },
|
where: { id: profileId },
|
||||||
select: { id: true, name: true },
|
include: {
|
||||||
|
variables: {
|
||||||
|
include: {
|
||||||
|
component: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
type: true,
|
||||||
|
workloadType: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!profile) {
|
if (!profile) {
|
||||||
throw new NotFoundException('실행 설정 프로필을 찾을 수 없습니다.');
|
throw new NotFoundException('실행 설정 프로필을 찾을 수 없습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const variable of profile.variables) {
|
||||||
|
await this.removeRuntimeVariableFromWorkload(
|
||||||
|
normalizeNamespace(profile.namespace),
|
||||||
|
variable.component,
|
||||||
|
variable.key,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await this.prisma.runtimeEnvProfile.delete({
|
await this.prisma.runtimeEnvProfile.delete({
|
||||||
where: { id: profileId },
|
where: { id: profileId },
|
||||||
});
|
});
|
||||||
@@ -1840,13 +1799,32 @@ export class AppsService {
|
|||||||
async deleteRuntimeVariable(profileId: string, variableId: string) {
|
async deleteRuntimeVariable(profileId: string, variableId: string) {
|
||||||
const variable = await this.prisma.runtimeEnvVariable.findFirst({
|
const variable = await this.prisma.runtimeEnvVariable.findFirst({
|
||||||
where: { id: variableId, profileId },
|
where: { id: variableId, profileId },
|
||||||
select: { id: true },
|
include: {
|
||||||
|
profile: {
|
||||||
|
select: {
|
||||||
|
namespace: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
component: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
type: true,
|
||||||
|
workloadType: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!variable) {
|
if (!variable) {
|
||||||
throw new NotFoundException('환경변수를 찾을 수 없습니다.');
|
throw new NotFoundException('환경변수를 찾을 수 없습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.removeRuntimeVariableFromWorkload(
|
||||||
|
normalizeNamespace(variable.profile.namespace),
|
||||||
|
variable.component,
|
||||||
|
variable.key,
|
||||||
|
);
|
||||||
|
|
||||||
await this.prisma.runtimeEnvVariable.delete({
|
await this.prisma.runtimeEnvVariable.delete({
|
||||||
where: { id: variable.id },
|
where: { id: variable.id },
|
||||||
});
|
});
|
||||||
@@ -2200,6 +2178,32 @@ export class AppsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async removeRuntimeVariableFromWorkload(
|
||||||
|
namespace: string,
|
||||||
|
component: { name: string; type: string; workloadType: string | null } | null,
|
||||||
|
key: string,
|
||||||
|
) {
|
||||||
|
if (!component || component.type === 'DATABASE') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const workloadType = normalizeWorkloadType(component.workloadType, component.type);
|
||||||
|
if (workloadType === 'Job') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const workloadRef = `${workloadType.toLowerCase()}/${sanitizeK8sName(component.name)}`;
|
||||||
|
try {
|
||||||
|
await this.kubectl(['set', 'env', workloadRef, `${key}-`, '-n', namespace]);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (/\bnot\s*found\b/i.test(message)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async kubectlEnv() {
|
private async kubectlEnv() {
|
||||||
const inClusterHost = process.env.KUBERNETES_SERVICE_HOST;
|
const inClusterHost = process.env.KUBERNETES_SERVICE_HOST;
|
||||||
if (inClusterHost) {
|
if (inClusterHost) {
|
||||||
@@ -3398,6 +3402,16 @@ function parseReleaseImageTags(value: Prisma.JsonValue | null) {
|
|||||||
return { images };
|
return { images };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasReleaseArtifact(artifact: string | null, imageTags: Prisma.JsonValue | null) {
|
||||||
|
if (artifact?.trim()) return true;
|
||||||
|
if (!imageTags || typeof imageTags !== 'object' || Array.isArray(imageTags)) return false;
|
||||||
|
|
||||||
|
const artifacts = (imageTags as Record<string, unknown>).artifacts;
|
||||||
|
if (!artifacts || typeof artifacts !== 'object' || Array.isArray(artifacts)) return false;
|
||||||
|
|
||||||
|
return Object.values(artifacts).some((value) => typeof value === 'string' && value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
function getUsedReleaseBuildIds(releases: Array<{ imageTags: Prisma.JsonValue | null }>) {
|
function getUsedReleaseBuildIds(releases: Array<{ imageTags: Prisma.JsonValue | null }>) {
|
||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
for (const release of releases) {
|
for (const release of releases) {
|
||||||
@@ -3467,6 +3481,7 @@ function createReleaseImageTags(
|
|||||||
branch: string;
|
branch: string;
|
||||||
commitSha: string | null;
|
commitSha: string | null;
|
||||||
artifact: string | null;
|
artifact: string | null;
|
||||||
|
artifactSha256: string | null;
|
||||||
image: string | null;
|
image: string | null;
|
||||||
component: {
|
component: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -3484,6 +3499,7 @@ function createReleaseImageTags(
|
|||||||
): Prisma.InputJsonObject {
|
): Prisma.InputJsonObject {
|
||||||
const images: Record<string, string> = {};
|
const images: Record<string, string> = {};
|
||||||
const artifacts: Record<string, string> = {};
|
const artifacts: Record<string, string> = {};
|
||||||
|
const artifactChecksums: Record<string, string> = {};
|
||||||
|
|
||||||
for (const job of buildJobs) {
|
for (const job of buildJobs) {
|
||||||
if (job.image && !images[job.component.name]) {
|
if (job.image && !images[job.component.name]) {
|
||||||
@@ -3492,13 +3508,17 @@ function createReleaseImageTags(
|
|||||||
if (job.artifact && !artifacts[job.component.name]) {
|
if (job.artifact && !artifacts[job.component.name]) {
|
||||||
artifacts[job.component.name] = job.artifact;
|
artifacts[job.component.name] = job.artifact;
|
||||||
}
|
}
|
||||||
|
if (job.artifactSha256 && !artifactChecksums[job.component.name]) {
|
||||||
|
artifactChecksums[job.component.name] = job.artifactSha256;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const component of components) {
|
for (const component of components) {
|
||||||
const imageSource = normalizeImageSource(component.imageSource, component.type);
|
const imageSource = normalizeImageSource(component.imageSource, component.type);
|
||||||
if (
|
if (
|
||||||
!images[component.name]
|
!images[component.name]
|
||||||
&& (imageSource === 'EXTERNAL' || imageSource === 'DATABASE')
|
&& imageSource === 'EXTERNAL'
|
||||||
|
&& component.type !== 'DATABASE'
|
||||||
&& component.externalImage?.trim()
|
&& component.externalImage?.trim()
|
||||||
) {
|
) {
|
||||||
images[component.name] = component.externalImage.trim();
|
images[component.name] = component.externalImage.trim();
|
||||||
@@ -3508,6 +3528,7 @@ function createReleaseImageTags(
|
|||||||
return {
|
return {
|
||||||
images,
|
images,
|
||||||
artifacts,
|
artifacts,
|
||||||
|
artifactChecksums,
|
||||||
buildJobs: buildJobs.map((job) => ({
|
buildJobs: buildJobs.map((job) => ({
|
||||||
id: job.id,
|
id: job.id,
|
||||||
componentId: job.component.id,
|
componentId: job.component.id,
|
||||||
@@ -3517,6 +3538,7 @@ function createReleaseImageTags(
|
|||||||
commitSha: job.commitSha,
|
commitSha: job.commitSha,
|
||||||
image: job.image,
|
image: job.image,
|
||||||
artifact: job.artifact,
|
artifact: job.artifact,
|
||||||
|
artifactSha256: job.artifactSha256,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -3606,7 +3628,3 @@ function getBuildPlanLogs(input: {
|
|||||||
|
|
||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
function delay(ms: number) {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -447,11 +447,13 @@ export class BuildRunnerService implements OnModuleInit, OnModuleDestroy {
|
|||||||
const destination = path.join(artifactDir, destinationName);
|
const destination = path.join(artifactDir, destinationName);
|
||||||
await fs.copyFile(artifact, destination);
|
await fs.copyFile(artifact, destination);
|
||||||
|
|
||||||
|
const artifactSha256 = await sha256File(artifact);
|
||||||
const uploadedArtifact = await this.uploadArtifactIfConfigured(
|
const uploadedArtifact = await this.uploadArtifactIfConfigured(
|
||||||
job,
|
job,
|
||||||
destination,
|
destination,
|
||||||
destinationName,
|
destinationName,
|
||||||
artifactVersion,
|
artifactVersion,
|
||||||
|
artifactSha256,
|
||||||
append,
|
append,
|
||||||
);
|
);
|
||||||
const publicBaseUrl = this.configService.get<string>('BUILD_ARTIFACT_BASE_URL');
|
const publicBaseUrl = this.configService.get<string>('BUILD_ARTIFACT_BASE_URL');
|
||||||
@@ -462,9 +464,10 @@ export class BuildRunnerService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
await this.prisma.buildJob.update({
|
await this.prisma.buildJob.update({
|
||||||
where: { id: job.id },
|
where: { id: job.id },
|
||||||
data: { artifact: artifactValue },
|
data: { artifact: artifactValue, artifactSha256 },
|
||||||
});
|
});
|
||||||
job.artifact = artifactValue;
|
job.artifact = artifactValue;
|
||||||
|
job.artifactSha256 = artifactSha256;
|
||||||
|
|
||||||
await append(`[artifact] found ${path.relative(workDir, artifact)}`);
|
await append(`[artifact] found ${path.relative(workDir, artifact)}`);
|
||||||
await append(`[artifact] copied to ${artifactValue}`);
|
await append(`[artifact] copied to ${artifactValue}`);
|
||||||
@@ -475,6 +478,7 @@ export class BuildRunnerService implements OnModuleInit, OnModuleDestroy {
|
|||||||
artifactPath: string,
|
artifactPath: string,
|
||||||
artifactName: string,
|
artifactName: string,
|
||||||
artifactVersion: string | null,
|
artifactVersion: string | null,
|
||||||
|
sha256: string,
|
||||||
append: (line: string) => Promise<void>,
|
append: (line: string) => Promise<void>,
|
||||||
) {
|
) {
|
||||||
const bucket = this.configService.get<string>('R2_BUCKET');
|
const bucket = this.configService.get<string>('R2_BUCKET');
|
||||||
@@ -488,7 +492,6 @@ export class BuildRunnerService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
const key = this.createArtifactObjectKey(job, artifactName, artifactVersion);
|
const key = this.createArtifactObjectKey(job, artifactName, artifactVersion);
|
||||||
const stat = await fs.stat(artifactPath);
|
const stat = await fs.stat(artifactPath);
|
||||||
const sha256 = await sha256File(artifactPath);
|
|
||||||
const client = this.getR2Client(accessKeyId, secretAccessKey);
|
const client = this.getR2Client(accessKeyId, secretAccessKey);
|
||||||
|
|
||||||
await append(`[r2] upload ${key}`);
|
await append(`[r2] upload ${key}`);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
BadGatewayException,
|
BadGatewayException,
|
||||||
Injectable,
|
Injectable,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
@@ -118,6 +119,10 @@ export class GiteaService {
|
|||||||
params.set('page', String(page));
|
params.set('page', String(page));
|
||||||
if (query.sha) {
|
if (query.sha) {
|
||||||
params.set('sha', query.sha);
|
params.set('sha', query.sha);
|
||||||
|
const { branches } = await this.listBranches(owner, repo) as { branches: Array<{ name?: string }> };
|
||||||
|
if (!branches.some((branch) => branch.name === query.sha)) {
|
||||||
|
throw new BadRequestException('해당 브랜치가 없습니다.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (query.since) {
|
if (query.since) {
|
||||||
params.set('since', query.since);
|
params.set('since', query.since);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { randomBytes } from 'crypto';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { Prisma } from '@prisma/auth-client';
|
import { Prisma } from '@prisma/auth-client';
|
||||||
|
|
||||||
@@ -48,6 +49,7 @@ export class OrganizationsService {
|
|||||||
type: true,
|
type: true,
|
||||||
domain: true,
|
domain: true,
|
||||||
defaultLicenseCode: true,
|
defaultLicenseCode: true,
|
||||||
|
instructorInviteCode: true,
|
||||||
createdById: true,
|
createdById: true,
|
||||||
createdByName: true,
|
createdByName: true,
|
||||||
createdByEmail: true,
|
createdByEmail: true,
|
||||||
@@ -76,11 +78,13 @@ export class OrganizationsService {
|
|||||||
|
|
||||||
async create(body: CreateOrganizationDto, requester?: AdminRequester) {
|
async create(body: CreateOrganizationDto, requester?: AdminRequester) {
|
||||||
try {
|
try {
|
||||||
|
const instructorInviteCode = await this.createInstructorInviteCode();
|
||||||
const organization = await this.authPrisma.organization.create({
|
const organization = await this.authPrisma.organization.create({
|
||||||
data: {
|
data: {
|
||||||
name: body.name.trim(),
|
name: body.name.trim(),
|
||||||
type: body.type,
|
type: body.type,
|
||||||
domain: body.domain?.trim() || null,
|
domain: body.domain?.trim() || null,
|
||||||
|
instructorInviteCode,
|
||||||
...(requester
|
...(requester
|
||||||
? {
|
? {
|
||||||
createdById: requester.id,
|
createdById: requester.id,
|
||||||
@@ -95,6 +99,7 @@ export class OrganizationsService {
|
|||||||
type: true,
|
type: true,
|
||||||
domain: true,
|
domain: true,
|
||||||
defaultLicenseCode: true,
|
defaultLicenseCode: true,
|
||||||
|
instructorInviteCode: true,
|
||||||
createdById: true,
|
createdById: true,
|
||||||
createdByName: true,
|
createdByName: true,
|
||||||
createdByEmail: true,
|
createdByEmail: true,
|
||||||
@@ -135,6 +140,7 @@ export class OrganizationsService {
|
|||||||
type: true,
|
type: true,
|
||||||
domain: true,
|
domain: true,
|
||||||
defaultLicenseCode: true,
|
defaultLicenseCode: true,
|
||||||
|
instructorInviteCode: true,
|
||||||
createdById: true,
|
createdById: true,
|
||||||
createdByName: true,
|
createdByName: true,
|
||||||
createdByEmail: true,
|
createdByEmail: true,
|
||||||
@@ -262,6 +268,21 @@ export class OrganizationsService {
|
|||||||
updatedUserCount: license ? targetUserIds.length : 0,
|
updatedUserCount: license ? targetUserIds.length : 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async createInstructorInviteCode(): Promise<string> {
|
||||||
|
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||||
|
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||||
|
const bytes = randomBytes(6);
|
||||||
|
const code = Array.from(bytes, (byte) => alphabet[byte % alphabet.length]).join('');
|
||||||
|
const existing = await this.authPrisma.organization.findUnique({
|
||||||
|
where: { instructorInviteCode: code },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!existing) return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ConflictException('강사 가입 코드 생성에 실패했습니다. 다시 시도해주세요.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toPositiveInt(value: string | number | undefined, fallback: number) {
|
function toPositiveInt(value: string | number | undefined, fallback: number) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Delete, Get, Param } from '@nestjs/common';
|
||||||
|
|
||||||
import { TailscaleService } from './tailscale.service';
|
import { TailscaleService } from './tailscale.service';
|
||||||
|
|
||||||
@@ -11,6 +11,11 @@ export class TailscaleController {
|
|||||||
return this.tailscaleService.listNodes();
|
return this.tailscaleService.listNodes();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Delete('nodes/:nodeId')
|
||||||
|
deleteNode(@Param('nodeId') nodeId: string) {
|
||||||
|
return this.tailscaleService.deleteNode(nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('routes')
|
@Get('routes')
|
||||||
listRoutes() {
|
listRoutes() {
|
||||||
return this.tailscaleService.listRoutes();
|
return this.tailscaleService.listRoutes();
|
||||||
|
|||||||
@@ -63,6 +63,22 @@ export class TailscaleService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deleteNode(nodeId: string) {
|
||||||
|
const normalizedNodeId = nodeId.trim();
|
||||||
|
if (!normalizedNodeId) {
|
||||||
|
throw new BadGatewayException('삭제할 Headscale 노드 ID가 없습니다.');
|
||||||
|
}
|
||||||
|
if (!this.gatewayUrl || !this.gatewayToken) {
|
||||||
|
throw new BadGatewayException('PLATFORM_GATEWAY_URL 또는 PLATFORM_GATEWAY_TOKEN이 설정되어 있지 않습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.request<{ deleted?: boolean }>(`/api/admin/tailscale/nodes/${encodeURIComponent(normalizedNodeId)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
return { deleted: true, nodeId: normalizedNodeId };
|
||||||
|
}
|
||||||
|
|
||||||
async listRoutes() {
|
async listRoutes() {
|
||||||
if (!this.gatewayUrl || !this.gatewayToken) {
|
if (!this.gatewayUrl || !this.gatewayToken) {
|
||||||
return {
|
return {
|
||||||
@@ -118,11 +134,13 @@ export class TailscaleService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async request<T>(path: string): Promise<T> {
|
private async request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const response = await fetch(`${this.gatewayUrl}${path}`, {
|
const response = await fetch(`${this.gatewayUrl}${path}`, {
|
||||||
|
...init,
|
||||||
headers: {
|
headers: {
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
'x-platform-gateway-token': this.gatewayToken ?? '',
|
'x-platform-gateway-token': this.gatewayToken ?? '',
|
||||||
|
...(init?.headers ?? {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+6
-1
@@ -1,4 +1,4 @@
|
|||||||
import { IsEmail, IsIn, IsOptional, IsString, IsUUID, MinLength } from 'class-validator';
|
import { IsBoolean, IsEmail, IsIn, IsOptional, IsString, IsUUID, MinLength } from 'class-validator';
|
||||||
|
|
||||||
export class ListUserSessionsQuery {
|
export class ListUserSessionsQuery {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -45,3 +45,8 @@ export class CreateManagedUserDto {
|
|||||||
@IsIn(['FREE', 'EDU', 'PRO'])
|
@IsIn(['FREE', 'EDU', 'PRO'])
|
||||||
licenseCode?: 'FREE' | 'EDU' | 'PRO';
|
licenseCode?: 'FREE' | 'EDU' | 'PRO';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class UpdateLmsInstructorDto {
|
||||||
|
@IsBoolean()
|
||||||
|
isInstructor!: boolean;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
|
||||||
|
|
||||||
import { CreateManagedUserDto, ListUserSessionsQuery } from './dto';
|
import { CreateManagedUserDto, ListUserSessionsQuery, UpdateLmsInstructorDto } from './dto';
|
||||||
import { UsersService } from './users.service';
|
import { UsersService } from './users.service';
|
||||||
|
|
||||||
@Controller('users')
|
@Controller('users')
|
||||||
@@ -27,6 +27,14 @@ export class UsersController {
|
|||||||
return this.usersService.unlockUser(userId);
|
return this.usersService.unlockUser(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch(':userId/lms-instructor')
|
||||||
|
updateLmsInstructor(
|
||||||
|
@Param('userId') userId: string,
|
||||||
|
@Body() body: UpdateLmsInstructorDto,
|
||||||
|
) {
|
||||||
|
return this.usersService.updateLmsInstructor(userId, body.isInstructor);
|
||||||
|
}
|
||||||
|
|
||||||
@Delete('sessions/:sessionId')
|
@Delete('sessions/:sessionId')
|
||||||
terminateSession(@Param('sessionId') sessionId: string) {
|
terminateSession(@Param('sessionId') sessionId: string) {
|
||||||
return this.usersService.terminateSession(sessionId);
|
return this.usersService.terminateSession(sessionId);
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type UserSessionRow = {
|
|||||||
expiresAt: Date | null;
|
expiresAt: Date | null;
|
||||||
lastSeen: Date | null;
|
lastSeen: Date | null;
|
||||||
state: string;
|
state: string;
|
||||||
|
isLmsInstructor: boolean;
|
||||||
primaryAffiliation: {
|
primaryAffiliation: {
|
||||||
id: string;
|
id: string;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
@@ -82,6 +83,13 @@ export class UsersService {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
appRoles: {
|
||||||
|
where: {
|
||||||
|
appKey: 'LMS',
|
||||||
|
roleCode: 'INSTRUCTOR',
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -104,6 +112,7 @@ export class UsersService {
|
|||||||
position: user.affiliations[0].position,
|
position: user.affiliations[0].position,
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
const isLmsInstructor = user.appRoles.length > 0;
|
||||||
|
|
||||||
if (user.refreshTokens.length === 0) {
|
if (user.refreshTokens.length === 0) {
|
||||||
return [
|
return [
|
||||||
@@ -120,6 +129,7 @@ export class UsersService {
|
|||||||
expiresAt: null,
|
expiresAt: null,
|
||||||
lastSeen: user.lastLoginAt,
|
lastSeen: user.lastLoginAt,
|
||||||
state: user.isActive ? '세션 없음' : '잠김',
|
state: user.isActive ? '세션 없음' : '잠김',
|
||||||
|
isLmsInstructor,
|
||||||
primaryAffiliation,
|
primaryAffiliation,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -138,6 +148,7 @@ export class UsersService {
|
|||||||
expiresAt: token.expiresAt,
|
expiresAt: token.expiresAt,
|
||||||
lastSeen: user.lastLoginAt ?? token.createdAt,
|
lastSeen: user.lastLoginAt ?? token.createdAt,
|
||||||
state: getSessionState(user.isActive, token.revokedAt, token.expiresAt, now),
|
state: getSessionState(user.isActive, token.revokedAt, token.expiresAt, now),
|
||||||
|
isLmsInstructor,
|
||||||
primaryAffiliation,
|
primaryAffiliation,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
@@ -260,6 +271,45 @@ export class UsersService {
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateLmsInstructor(userId: string, isInstructor: boolean) {
|
||||||
|
const user = await this.authPrisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('사용자를 찾을 수 없습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInstructor) {
|
||||||
|
await this.authPrisma.userAppRole.upsert({
|
||||||
|
where: {
|
||||||
|
userId_appKey_roleCode: {
|
||||||
|
userId,
|
||||||
|
appKey: 'LMS',
|
||||||
|
roleCode: 'INSTRUCTOR',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
userId,
|
||||||
|
appKey: 'LMS',
|
||||||
|
roleCode: 'INSTRUCTOR',
|
||||||
|
},
|
||||||
|
update: { endsAt: null },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await this.authPrisma.userAppRole.deleteMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
appKey: 'LMS',
|
||||||
|
roleCode: 'INSTRUCTOR',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { userId, isInstructor };
|
||||||
|
}
|
||||||
|
|
||||||
async terminateSession(sessionId: string) {
|
async terminateSession(sessionId: string) {
|
||||||
const session = await this.authPrisma.refreshToken.findUnique({
|
const session = await this.authPrisma.refreshToken.findUnique({
|
||||||
where: { id: sessionId },
|
where: { id: sessionId },
|
||||||
|
|||||||
Reference in New Issue
Block a user