Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: allow creation of multiple project envs #2514

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions backend/src/keystore/keystore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ export const KeyStorePrefixes = {
WaitUntilReadyKmsOrgKeyCreation: "wait-until-ready-kms-org-key-creation-",
WaitUntilReadyKmsOrgDataKeyCreation: "wait-until-ready-kms-org-data-key-creation-",

ProjectEnvironmentCreate: "project-environment-creation-lock",
ProjectEnvironmentUpdate: "project-environment-update-lock",
ProjectEnvironmentDelete: "project-environment-delete-lock",
WaitUntilReadyCreateProjectEnvironment: "wait-until-ready-create-project-environments-",
WaitUntilReadyUpdateProjectEnvironment: "wait-until-ready-update-project-environments-",
WaitUntilReadyDeleteProjectEnvironment: "wait-until-ready-delete-project-environments-",

SyncSecretIntegrationLock: (projectId: string, environmentSlug: string, secretPath: string) =>
`sync-integration-mutex-${projectId}-${environmentSlug}-${secretPath}` as const,
SyncSecretIntegrationLastRunTimestamp: (projectId: string, environmentSlug: string, secretPath: string) =>
Expand Down
1 change: 1 addition & 0 deletions backend/src/server/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -742,6 +742,7 @@ export const registerRoutes = async (
const projectEnvService = projectEnvServiceFactory({
permissionService,
projectEnvDAL,
keyStore,
licenseService,
projectDAL,
folderDAL
Expand Down
174 changes: 125 additions & 49 deletions backend/src/services/project-env/project-env-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { ForbiddenError } from "@casl/ability";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { logger } from "@app/lib/logger";

import { TProjectDALFactory } from "../project/project-dal";
import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
Expand All @@ -16,6 +18,7 @@ type TProjectEnvServiceFactoryDep = {
projectDAL: Pick<TProjectDALFactory, "findById">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
keyStore: Pick<TKeyStoreFactory, "acquireLock" | "setItemWithExpiry" | "getItem" | "waitTillReady">;
};

export type TProjectEnvServiceFactory = ReturnType<typeof projectEnvServiceFactory>;
Expand All @@ -24,6 +27,7 @@ export const projectEnvServiceFactory = ({
projectEnvDAL,
permissionService,
licenseService,
keyStore,
projectDAL,
folderDAL
}: TProjectEnvServiceFactoryDep) => {
Expand All @@ -45,32 +49,56 @@ export const projectEnvServiceFactory = ({
);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Environments);

const envs = await projectEnvDAL.find({ projectId });
const existingEnv = envs.find(({ slug: envSlug }) => envSlug === slug);
if (existingEnv)
throw new BadRequestError({
message: "Environment with slug already exist",
name: "Create envv"
});
const lock = await keyStore
.acquireLock([KeyStorePrefixes.ProjectEnvironmentCreate, projectId], 5000)
.catch(() => null);

try {
if (!lock) {
await keyStore.waitTillReady({
key: `${KeyStorePrefixes.WaitUntilReadyCreateProjectEnvironment}${projectId}`,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should have used a function for this key name because right now the next person will have to remembert to append the project id on it

keyCheckCb: (val) => val === "true",
waitingCb: () => logger.debug("Create project environment. Waiting for "),
delay: 500
});
}

const envs = await projectEnvDAL.find({ projectId });
const existingEnv = envs.find(({ slug: envSlug }) => envSlug === slug);
if (existingEnv)
throw new BadRequestError({
message: "Environment with slug already exist",
name: "CreateEnvironment"
});

const project = await projectDAL.findById(projectId);
const plan = await licenseService.getPlan(project.orgId);
if (plan.environmentLimit !== null && envs.length >= plan.environmentLimit) {
// case: limit imposed on number of environments allowed
// case: number of environments used exceeds the number of environments allowed
throw new BadRequestError({
message:
"Failed to create environment due to environment limit reached. Upgrade plan to create more environments."
});
}

const project = await projectDAL.findById(projectId);
const plan = await licenseService.getPlan(project.orgId);
if (plan.environmentLimit !== null && envs.length >= plan.environmentLimit) {
// case: limit imposed on number of environments allowed
// case: number of environments used exceeds the number of environments allowed
throw new BadRequestError({
message:
"Failed to create environment due to environment limit reached. Upgrade plan to create more environments."
const env = await projectEnvDAL.transaction(async (tx) => {
const lastPos = await projectEnvDAL.findLastEnvPosition(projectId, tx);
const doc = await projectEnvDAL.create({ slug, name, projectId, position: lastPos + 1 }, tx);
await folderDAL.create({ name: "root", parentId: null, envId: doc.id, version: 1 }, tx);
return doc;
});
}

const env = await projectEnvDAL.transaction(async (tx) => {
const lastPos = await projectEnvDAL.findLastEnvPosition(projectId, tx);
const doc = await projectEnvDAL.create({ slug, name, projectId, position: lastPos + 1 }, tx);
await folderDAL.create({ name: "root", parentId: null, envId: doc.id, version: 1 }, tx);
return doc;
});
return env;
await keyStore.setItemWithExpiry(
`${KeyStorePrefixes.WaitUntilReadyCreateProjectEnvironment}${projectId}`,
10,
"true"
);

return env;
} finally {
await lock?.release();
}
};

const updateEnvironment = async ({
Expand All @@ -93,26 +121,50 @@ export const projectEnvServiceFactory = ({
);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Environments);

const oldEnv = await projectEnvDAL.findOne({ id, projectId });
if (!oldEnv) throw new BadRequestError({ message: "Environment not found" });

if (slug) {
const existingEnv = await projectEnvDAL.findOne({ slug, projectId });
if (existingEnv && existingEnv.id !== id) {
throw new BadRequestError({
message: "Environment with slug already exist",
name: "Create envv"
const lock = await keyStore
.acquireLock([KeyStorePrefixes.ProjectEnvironmentUpdate, projectId], 5000)
.catch(() => null);

try {
if (!lock) {
await keyStore.waitTillReady({
key: `${KeyStorePrefixes.WaitUntilReadyUpdateProjectEnvironment}${projectId}`,
keyCheckCb: (val) => val === "true",
waitingCb: () => logger.debug("Update project environment. Waiting for project environment update"),
delay: 500
});
}
}

const env = await projectEnvDAL.transaction(async (tx) => {
if (position) {
await projectEnvDAL.updateAllPosition(projectId, oldEnv.position, position, tx);
const oldEnv = await projectEnvDAL.findOne({ id, projectId });
if (!oldEnv) throw new NotFoundError({ message: "Environment not found", name: "UpdateEnvironment" });

if (slug) {
const existingEnv = await projectEnvDAL.findOne({ slug, projectId });
if (existingEnv && existingEnv.id !== id) {
throw new BadRequestError({
message: "Environment with slug already exist",
name: "UpdateEnvironment"
});
}
}
return projectEnvDAL.updateById(oldEnv.id, { name, slug, position }, tx);
});
return { environment: env, old: oldEnv };

const env = await projectEnvDAL.transaction(async (tx) => {
if (position) {
await projectEnvDAL.updateAllPosition(projectId, oldEnv.position, position, tx);
}
return projectEnvDAL.updateById(oldEnv.id, { name, slug, position }, tx);
});

await keyStore.setItemWithExpiry(
`${KeyStorePrefixes.WaitUntilReadyUpdateProjectEnvironment}${projectId}`,
10,
"true"
);

return { environment: env, old: oldEnv };
} finally {
await lock?.release();
}
};

const deleteEnvironment = async ({ projectId, actor, actorId, actorOrgId, actorAuthMethod, id }: TDeleteEnvDTO) => {
Expand All @@ -125,18 +177,42 @@ export const projectEnvServiceFactory = ({
);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Environments);

const env = await projectEnvDAL.transaction(async (tx) => {
const [doc] = await projectEnvDAL.delete({ id, projectId }, tx);
if (!doc)
throw new BadRequestError({
message: "Env doesn't exist",
name: "Re-order env"
const lock = await keyStore
.acquireLock([KeyStorePrefixes.ProjectEnvironmentDelete, projectId], 5000)
.catch(() => null);

try {
if (!lock) {
await keyStore.waitTillReady({
key: `${KeyStorePrefixes.WaitUntilReadyDeleteProjectEnvironment}${projectId}`,
keyCheckCb: (val) => val === "true",
waitingCb: () => logger.debug("Delete project environment. Waiting for "),
delay: 500
});
}

await projectEnvDAL.updateAllPosition(projectId, doc.position, -1, tx);
return doc;
});
return env;
const env = await projectEnvDAL.transaction(async (tx) => {
const [doc] = await projectEnvDAL.delete({ id, projectId }, tx);
if (!doc)
throw new NotFoundError({
message: "Environment doesn't exist",
name: "DeleteEnvironment"
});

await projectEnvDAL.updateAllPosition(projectId, doc.position, -1, tx);
return doc;
});

await keyStore.setItemWithExpiry(
`${KeyStorePrefixes.WaitUntilReadyDeleteProjectEnvironment}${projectId}`,
10,
"true"
);

return env;
} finally {
await lock?.release();
}
};

const getEnvironmentById = async ({ projectId, actor, actorId, actorOrgId, actorAuthMethod, id }: TGetEnvDTO) => {
Expand Down
Loading