diff --git a/backend/prisma/migrations/20260510195508_make_app_group_names_unique/migration.sql b/backend/prisma/migrations/20260510195508_make_app_group_names_unique/migration.sql new file mode 100644 index 00000000..1ea34aeb --- /dev/null +++ b/backend/prisma/migrations/20260510195508_make_app_group_names_unique/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - A unique constraint covering the columns `[orgId,name]` on the table `AppGroup` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateIndex +CREATE UNIQUE INDEX "AppGroup_orgId_name_key" ON "AppGroup"("orgId", "name"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 75ed930a..1df36742 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -213,6 +213,8 @@ model AppGroup { // Is monolith app and should be displayed to the user as a single app not part of a group. isMono Boolean @default(false) apps App[] + + @@unique([orgId, name]) } model Deployment { diff --git a/backend/src/db/models.ts b/backend/src/db/models.ts index 752010b3..26900f8c 100644 --- a/backend/src/db/models.ts +++ b/backend/src/db/models.ts @@ -220,6 +220,7 @@ export interface AppCreate { namespace: string; clusterUsername: string; projectId: string; + configId: number; } export interface RepoImportState { diff --git a/backend/src/db/repo/app.ts b/backend/src/db/repo/app.ts index 3a98a50f..33128af0 100644 --- a/backend/src/db/repo/app.ts +++ b/backend/src/db/repo/app.ts @@ -109,6 +109,7 @@ export class AppRepo { id: appData.orgId, }, }, + config: { connect: { id: appData.configId } }, // This cluster username will be used to automatically update the app after a build job or webhook payload // TODO: make this a setting in the UI diff --git a/backend/src/db/repo/deployment.ts b/backend/src/db/repo/deployment.ts index ef57005c..132defa8 100644 --- a/backend/src/db/repo/deployment.ts +++ b/backend/src/db/repo/deployment.ts @@ -81,6 +81,37 @@ export class DeploymentRepo { return deployment; } + async createConfig(config: WorkloadConfigCreate | HelmConfigCreate) { + const configClone = structuredClone(config); + const appType = configClone.appType; + if (appType === "workload") { + delete configClone.appType; + } else if (appType === "helm") { + delete configClone.appType; + delete configClone.source; + } + + const cfg = await this.client.deploymentConfig.create({ + data: { + appType: appType, + ...(appType === "workload" + ? { + workloadConfig: { + create: this.encryptEnv(configClone), + }, + } + : { + helmConfig: { + create: configClone, + }, + }), + }, + select: { id: true }, + }); + + return cfg.id; + } + async create({ appId, config, @@ -94,32 +125,12 @@ export class DeploymentRepo { workflowRunId?: number; status?: DeploymentStatus; }): Promise { - const configClone = structuredClone(config); - const appType = configClone.appType; - if (appType === "workload") { - delete configClone.appType; - } else if (appType === "helm") { - delete configClone.appType; - delete configClone.source; - } + const configId = await this.createConfig(config); return await this.client.deployment.create({ data: { app: { connect: { id: appId } }, config: { - create: { - appType: appType, - ...(appType === "workload" - ? { - workloadConfig: { - create: this.encryptEnv(configClone), - }, - } - : { - helmConfig: { - create: configClone, - }, - }), - }, + connect: { id: configId }, }, commitMessage, workflowRunId, diff --git a/backend/src/handlers/createApp.ts b/backend/src/handlers/createApp.ts index 07e6cfa6..973ea614 100644 --- a/backend/src/handlers/createApp.ts +++ b/backend/src/handlers/createApp.ts @@ -13,12 +13,15 @@ export const createAppHandler: HandlerMap["createApp"] = async ( req: AuthenticatedRequest, res, ) => { + let appId: number; + let createFirstDeployment: () => Promise; try { - const appId = await createAppService.createApp( + const res = await createAppService.createApp( ctx.request.requestBody, req.user.id, ); - return json(200, res, { id: appId }); + appId = res.appId; + createFirstDeployment = res.createFirstDeployment; } catch (e) { if (e instanceof OrgNotFoundError) { return json(400, res, { code: 400, message: "Organization not found" }); @@ -27,13 +30,6 @@ export const createAppHandler: HandlerMap["createApp"] = async ( code: 400, message: e.message, }); - } else if (e instanceof DeploymentError) { - // The app was created, but a Deployment couldn't be created - logger.error(e, "Failed to create app's first deloyment"); - return json(500, res, { - code: 500, - message: "Failed to create a deployment for your app.", - }); } else { logger.error(e, "Failed to create app"); return json(500, res, { @@ -42,4 +38,20 @@ export const createAppHandler: HandlerMap["createApp"] = async ( }); } } + + // Always respond with 200 OK after this since an app was created, + // although the deployment may fail for other reasons + + try { + await createFirstDeployment(); + } catch (e) { + if (e instanceof DeploymentError) { + // The app was created, but a Deployment couldn't be created + logger.error(e, "Failed to create app's first deployment"); + } else { + logger.error(e, "Failed to create app"); + } + } + + return json(200, res, { id: appId }); }; diff --git a/backend/src/service/common/deploymentConfig.ts b/backend/src/service/common/deploymentConfig.ts index 55ca926b..4f15df99 100644 --- a/backend/src/service/common/deploymentConfig.ts +++ b/backend/src/service/common/deploymentConfig.ts @@ -155,7 +155,7 @@ export class DeploymentConfigService { */ populateImageTag( config: GitConfigCreate | HelmConfigCreate | WorkloadConfigCreate, - app: App, + app: Pick, ) { if (config.source === "GIT") { return { diff --git a/backend/src/service/createApp.ts b/backend/src/service/createApp.ts index d9d00e19..126056fc 100644 --- a/backend/src/service/createApp.ts +++ b/backend/src/service/createApp.ts @@ -3,6 +3,7 @@ import { ConflictError } from "../db/errors/index.ts"; import type { App } from "../db/models.ts"; import type { AppRepo } from "../db/repo/app.ts"; import type { AppGroupRepo } from "../db/repo/appGroup.ts"; +import type { DeploymentRepo } from "../db/repo/deployment.ts"; import type { OrganizationRepo } from "../db/repo/organization.ts"; import type { UserRepo } from "../db/repo/user.ts"; import type { components } from "../generated/openapi.ts"; @@ -28,6 +29,7 @@ export class CreateAppService { private appRepo: AppRepo; private appGroupRepo: AppGroupRepo; private userRepo: UserRepo; + private deploymentRepo: DeploymentRepo; private appService: AppService; private deploymentService: DeploymentService; private deploymentConfigService: DeploymentConfigService; @@ -37,6 +39,7 @@ export class CreateAppService { appRepo: AppRepo, appGroupRepo: AppGroupRepo, userRepo: UserRepo, + deploymentRepo: DeploymentRepo, appService: AppService, deploymentService: DeploymentService, deploymentConfigService: DeploymentConfigService, @@ -45,6 +48,7 @@ export class CreateAppService { this.appRepo = appRepo; this.appGroupRepo = appGroupRepo; this.userRepo = userRepo; + this.deploymentRepo = deploymentRepo; this.appService = appService; this.deploymentService = deploymentService; this.deploymentConfigService = deploymentConfigService; @@ -83,22 +87,42 @@ export class CreateAppService { case "create-new": { this.appService.validateAppGroupName(appData.appGroup.name); - appGroupId = await this.appGroupRepo.create( - appData.orgId, - appData.appGroup.name, - false, - ); + try { + appGroupId = await this.appGroupRepo.create( + appData.orgId, + appData.appGroup.name, + false, + ); + } catch (e) { + if (e instanceof ConflictError) { + throw new ValidationError( + "An app group already exists with that name.", + { cause: e }, + ); + } + throw e; + } break; } case "standalone": { const groupName = `${appData.name.substring(0, MAX_GROUPNAME_LEN - RANDOM_TAG_LEN - 1)}-${getRandomTag()}`; this.appService.validateAppGroupName(groupName); - appGroupId = await this.appGroupRepo.create( - appData.orgId, - groupName, - true, - ); + try { + appGroupId = await this.appGroupRepo.create( + appData.orgId, + groupName, + true, + ); + } catch (e) { + if (e instanceof ConflictError) { + throw new ValidationError( + "An app group already exists with that name.", + { cause: e }, + ); + } + throw e; + } break; } @@ -110,6 +134,7 @@ export class CreateAppService { let deploymentConfig = config; try { + const configId = await this.deploymentRepo.createConfig(deploymentConfig); app = await this.appRepo.create({ orgId: appData.orgId, appGroupId: appGroupId, @@ -117,6 +142,7 @@ export class CreateAppService { clusterUsername: user.clusterUsername, projectId: appData.projectId, namespace: appData.namespace, + configId, }); logger.info({ orgId: appData.orgId, appId: app.id }, "App created"); @@ -133,18 +159,22 @@ export class CreateAppService { throw err; } - try { - await this.deploymentService.create({ - appId: app.id, - commitMessage, - config: deploymentConfig, - }); - } catch (err) { - const span = trace.getActiveSpan(); - span?.recordException(err as Error); - span?.setStatus({ code: SpanStatusCode.ERROR }); - throw new DeploymentError(err as Error); - } - return app.id; + return { + appId: app.id, + createFirstDeployment: async () => { + try { + await this.deploymentService.create({ + appId: app.id, + commitMessage, + config: deploymentConfig, + }); + } catch (err) { + const span = trace.getActiveSpan(); + span?.recordException(err as Error); + span?.setStatus({ code: SpanStatusCode.ERROR }); + throw new DeploymentError(err as Error); + } + }, + }; } } diff --git a/backend/src/service/createAppGroup.ts b/backend/src/service/createAppGroup.ts index 22c9637b..00845129 100644 --- a/backend/src/service/createAppGroup.ts +++ b/backend/src/service/createAppGroup.ts @@ -3,6 +3,7 @@ import { ConflictError } from "../db/errors/index.ts"; import type { App } from "../db/models.ts"; import type { AppRepo } from "../db/repo/app.ts"; import type { AppGroupRepo } from "../db/repo/appGroup.ts"; +import type { DeploymentRepo } from "../db/repo/deployment.ts"; import type { OrganizationRepo } from "../db/repo/organization.ts"; import type { UserRepo } from "../db/repo/user.ts"; import type { components } from "../generated/openapi.ts"; @@ -24,6 +25,7 @@ export class CreateAppGroupService { private appRepo: AppRepo; private appGroupRepo: AppGroupRepo; private userRepo: UserRepo; + private deploymentRepo: DeploymentRepo; private appService: AppService; private deploymentService: DeploymentService; private deploymentConfigService: DeploymentConfigService; @@ -33,6 +35,7 @@ export class CreateAppGroupService { appRepo: AppRepo, appGroupRepo: AppGroupRepo, userRepo: UserRepo, + deploymentRepo: DeploymentRepo, appService: AppService, deploymentService: DeploymentService, deploymentConfigService: DeploymentConfigService, @@ -41,6 +44,7 @@ export class CreateAppGroupService { this.appRepo = appRepo; this.appGroupRepo = appGroupRepo; this.userRepo = userRepo; + this.deploymentRepo = deploymentRepo; this.appService = appService; this.deploymentService = deploymentService; this.deploymentConfigService = deploymentConfigService; @@ -85,24 +89,24 @@ export class CreateAppGroupService { metadata: validationResults[idx], })); - const groupId = await this.appGroupRepo.create(orgId, groupName, false); - // let groupId: number; - // try { - // groupId = await this.appGroupRepo.create(orgId, groupName, false); - // } catch (e) { - // if (e instanceof ConflictError) { - // throw new ValidationError( - // "An app group already exists with the same name.", - // ); - // } - // throw e; - // } + let groupId: number; + try { + groupId = await this.appGroupRepo.create(orgId, groupName, false); + } catch (e) { + if (e instanceof ConflictError) { + throw new ValidationError( + "An app group already exists with the same name.", + ); + } + throw e; + } for (const { appData, metadata } of appsWithMetadata) { const { config: _config, commitMessage } = metadata; let config = _config; let app: App; try { + const initialConfigId = await this.deploymentRepo.createConfig(config); app = await this.appRepo.create({ orgId: appData.orgId, appGroupId: groupId, @@ -110,6 +114,7 @@ export class CreateAppGroupService { clusterUsername: user.clusterUsername, projectId: appData.projectId, namespace: appData.namespace, + configId: initialConfigId, }); config = this.deploymentConfigService.populateImageTag(config, app); } catch (err) { diff --git a/backend/src/service/getAppByID.ts b/backend/src/service/getAppByID.ts index 7178b7f7..4b49f541 100644 --- a/backend/src/service/getAppByID.ts +++ b/backend/src/service/getAppByID.ts @@ -1,3 +1,4 @@ +import type { DeploymentConfig } from "../db/models.ts"; import type { AppRepo } from "../db/repo/app.ts"; import type { AppGroupRepo } from "../db/repo/appGroup.ts"; import type { DeploymentRepo } from "../db/repo/deployment.ts"; @@ -64,17 +65,23 @@ export class GetAppByIDService { } }; - const [org, appGroup, currentConfig, activeDeployment] = await Promise.all([ - this.orgRepo.getById(app.orgId), - this.appGroupRepo.getById(app.appGroupId), - this.deploymentRepo.getConfig(recentDeployment.id), - getK8sDeployment().then( - (d) => - d?.spec?.template?.metadata?.labels?.[ - "anvilops.rcac.purdue.edu/deployment-id" - ], - ), - ]); + const [org, appGroup, mostRecentConfig, activeDeployment] = + await Promise.all([ + this.orgRepo.getById(app.orgId), + this.appGroupRepo.getById(app.appGroupId), + recentDeployment + ? this.deploymentRepo.getConfig(recentDeployment.id) + : Promise.resolve(null), + getK8sDeployment().then( + (d) => + d?.spec?.template?.metadata?.labels?.[ + "anvilops.rcac.purdue.edu/deployment-id" + ], + ), + ]); + + const currentConfig = + mostRecentConfig ?? (await this.appRepo.getDeploymentConfig(app.id)); // Fetch repository info if this app is deployed from a Git repository let repoId: number = undefined, diff --git a/backend/src/service/index.ts b/backend/src/service/index.ts index b36c5d6e..21bed893 100644 --- a/backend/src/service/index.ts +++ b/backend/src/service/index.ts @@ -254,6 +254,7 @@ export const createAppService = new CreateAppService( db.app, db.appGroup, db.user, + db.deployment, appService, deploymentService, deploymentConfigService, @@ -264,6 +265,7 @@ export const createAppGroupService = new CreateAppGroupService( db.app, db.appGroup, db.user, + db.deployment, appService, deploymentService, deploymentConfigService, diff --git a/backend/src/service/updateApp.ts b/backend/src/service/updateApp.ts index 9becdfbc..cd1c34e3 100644 --- a/backend/src/service/updateApp.ts +++ b/backend/src/service/updateApp.ts @@ -1,4 +1,5 @@ import { SpanStatusCode, trace } from "@opentelemetry/api"; +import { ConflictError } from "../db/errors/index.ts"; import type { Deployment, DeploymentConfig, @@ -97,12 +98,22 @@ export class UpdateAppService { case "create-new": { this.appService.validateAppGroupName(appData.appGroup.name); - appGroupId = await this.appGroupRepo.create( - originalApp.orgId, - appData.appGroup.name, - false, - ); - await this.appRepo.setGroup(originalApp.id, appGroupId); + try { + appGroupId = await this.appGroupRepo.create( + originalApp.orgId, + appData.appGroup.name, + false, + ); + await this.appRepo.setGroup(originalApp.id, appGroupId); + } catch (e) { + if (e instanceof ConflictError) { + throw new ValidationError( + "An app group already exists with that name.", + { cause: e }, + ); + } + throw e; + } break; } @@ -112,12 +123,22 @@ export class UpdateAppService { } const groupName = `${originalApp.name.substring(0, MAX_GROUPNAME_LEN - RANDOM_TAG_LEN - 1)}-${getRandomTag()}`; this.appService.validateAppGroupName(groupName); - appGroupId = await this.appGroupRepo.create( - originalApp.orgId, - groupName, - true, - ); - await this.appRepo.setGroup(originalApp.id, appGroupId); + try { + appGroupId = await this.appGroupRepo.create( + originalApp.orgId, + groupName, + true, + ); + await this.appRepo.setGroup(originalApp.id, appGroupId); + } catch (e) { + if (e instanceof ConflictError) { + throw new ValidationError( + "An app group already exists with that name.", + { cause: e }, + ); + } + throw e; + } break; } diff --git a/frontend/src/components/config/workload/git/EnabledGitConfigFields.tsx b/frontend/src/components/config/workload/git/EnabledGitConfigFields.tsx index 370ca3e2..1a89755c 100644 --- a/frontend/src/components/config/workload/git/EnabledGitConfigFields.tsx +++ b/frontend/src/components/config/workload/git/EnabledGitConfigFields.tsx @@ -112,7 +112,7 @@ export const EnabledGitConfigFields = ({ if (branch !== newBranch) { setGitState({ branch: newBranch }); } - }, [branches, branch, setGitState]); + }, [branches]); const [importDialogShown, setImportDialogShown] = useState(false); return ( diff --git a/frontend/src/pages/app/OverviewTab.tsx b/frontend/src/pages/app/OverviewTab.tsx index 45a7cc4f..13aaa76c 100644 --- a/frontend/src/pages/app/OverviewTab.tsx +++ b/frontend/src/pages/app/OverviewTab.tsx @@ -487,7 +487,7 @@ const RetryDeploymentPrompt = ({ "/app/{appId}", ); - if (deployments?.[0]?.status === "ERROR") { + if (deployments?.[0]?.status === "ERROR" || deployments?.length === 0) { return (

diff --git a/log-shipper/main.go b/log-shipper/main.go index 548b2824..1f1323cf 100644 --- a/log-shipper/main.go +++ b/log-shipper/main.go @@ -281,7 +281,7 @@ func send(lines []LogLine, env *EnvVars) { } } } - } else if res.StatusCode != 200 { + } else if res.StatusCode < 200 || res.StatusCode >= 300 { body, err := io.ReadAll(res.Body) _ = res.Body.Close()