Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .server-changes/sso-directory-sync-dev-environments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---

Team members who join an organization through single sign-on or directory sync now get their own development environment for every project, so the dashboard's dev view and `trigger dev` become available to them without an admin having to set anything up. Members who were already missing one are repaired the next time their membership is synced from your identity provider.
113 changes: 88 additions & 25 deletions apps/webapp/app/models/member.server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import type { Organization, OrgMember, Project } from "@trigger.dev/database";
import { z } from "zod";
import { Prisma as PrismaNamespace, type Prisma, prisma } from "~/db.server";
import { createEnvironment } from "./organization.server";
import {
createDevelopmentEnvironmentForMember,
memberDevelopmentEnvironmentWhere,
} from "./organization.server";
import { customAlphabet } from "nanoid";
import { logger } from "~/services/logger.server";
import { getDefaultEnvironmentConcurrencyLimit } from "~/services/platform.v3.server";
Expand All @@ -13,6 +17,31 @@ export const INVITE_BLOCKED_DIRECTORY_MANAGED =
export const ENV_SETUP_INCOMPLETE =
"You joined the organization, but we couldn't finish setting up your development environments. Please try accepting the invite again, or contact support if this persists.";

/** How a membership came to exist. Also validates the queued job's payload. */
export const MembershipSourceSchema = z.enum(["invite", "sso_jit", "directory_sync", "manual"]);

export type MembershipSource = z.infer<typeof MembershipSourceSchema>;

/** Thrown when provisioning fails partway through; the membership is still valid. */
export class DevEnvironmentProvisioningError extends Error {
readonly logLevel = "warn" as const;

constructor(
message: string,
readonly context: {
source: MembershipSource;
organizationId: string;
orgMemberId: string;
failedProjectId?: string;
createdProjectIds: string[];
},
options?: { cause?: unknown }
) {
super(message, options);
this.name = "DevEnvironmentProvisioningError";
}
}

export function isAcceptInviteFormError(error: unknown): error is Error {
return (
error instanceof Error &&
Expand Down Expand Up @@ -186,7 +215,7 @@ export async function getUsersInvites({ email }: { email: string }) {
});
}

async function getProjectsMissingMemberDevelopmentEnvironments({
export async function getProjectsMissingMemberDevelopmentEnvironments({
memberId,
organizationId,
projects,
Expand All @@ -201,10 +230,11 @@ async function getProjectsMissingMemberDevelopmentEnvironments({

const existingEnvs = await prisma.runtimeEnvironment.findMany({
where: {
orgMemberId: memberId,
organizationId,
type: "DEVELOPMENT",
projectId: { in: projects.map((project) => project.id) },
...memberDevelopmentEnvironmentWhere({
orgMemberId: memberId,
projectId: { in: projects.map((project) => project.id) },
}),
},
select: { projectId: true },
});
Expand All @@ -214,15 +244,15 @@ async function getProjectsMissingMemberDevelopmentEnvironments({
}

export async function provisionMemberDevelopmentEnvironments({
source,
inviteId,
user,
member,
organization,
projects,
maximumConcurrencyLimit,
}: {
inviteId: string;
user: { id: string; email: string };
source: MembershipSource;
inviteId?: string;
member: OrgMember;
organization: Pick<Organization, "id" | "maximumConcurrencyLimit">;
projects: Pick<Project, "id">[];
Expand All @@ -233,7 +263,7 @@ export async function provisionMemberDevelopmentEnvironments({
organizationId: organization.id,
projects,
});
const projectIds = projects.map((project) => project.id);
const requestedProjectIds = projects.map((project) => project.id);
const createdProjectIds: string[] = [];
let failedProjectId: string | undefined;
let failedProjectIndex: number | undefined;
Expand All @@ -243,39 +273,74 @@ export async function provisionMemberDevelopmentEnvironments({
failedProjectId = project.id;
failedProjectIndex = index;

await createEnvironment({
const { created } = await createDevelopmentEnvironmentForMember({
organization,
project,
type: "DEVELOPMENT",
// We set this true but no backfill (yet!?) so never used
// for dev environments
isBranchableEnvironment: true,
member,
maximumConcurrencyLimit,
});

createdProjectIds.push(project.id);
if (created) {
createdProjectIds.push(project.id);
}
failedProjectId = undefined;
failedProjectIndex = undefined;
}
} catch (error) {
logger.error("acceptInvite: development environment creation failed after membership created", {
const message =
"provisionMemberDevelopmentEnvironments: development environment creation failed after membership created";
const context = {
source,
inviteId,
userId: user.id,
userId: member.userId,
organizationId: organization.id,
orgMemberId: member.id,
projectIds,
requestedProjectIds,
failedProjectId,
failedProjectIndex,
totalProjects: projectsNeedingEnvs.length,
projectsNeedingEnvs: projectsNeedingEnvs.length,
createdProjectIds,
error:
error instanceof Error
? { name: error.name, message: error.message, stack: error.stack }
: String(error),
});
};

if (source === "invite") {
logger.error(message, context);
} else {
logger.warn(message, context);
}

throw new Error(ENV_SETUP_INCOMPLETE);
throw new DevEnvironmentProvisioningError(
`Failed to create development environments for org member ${member.id}`,
{
source,
organizationId: organization.id,
orgMemberId: member.id,
failedProjectId,
createdProjectIds,
},
{ cause: error }
);
}
}

/** Provisions inline and surfaces a failure to the joiner as a retryable message. */
async function provisionInviteDevelopmentEnvironments(args: {
inviteId: string;
member: OrgMember;
organization: Pick<Organization, "id" | "maximumConcurrencyLimit">;
projects: Pick<Project, "id">[];
maximumConcurrencyLimit: number;
}) {
try {
await provisionMemberDevelopmentEnvironments({ source: "invite", ...args });
} catch (error) {
if (error instanceof DevEnvironmentProvisioningError) {
throw new Error(ENV_SETUP_INCOMPLETE, { cause: error });
}
throw error;
}
}

Expand Down Expand Up @@ -375,9 +440,8 @@ async function tryRecoverIncompleteInviteAccept({
"DEVELOPMENT"
);

await provisionMemberDevelopmentEnvironments({
await provisionInviteDevelopmentEnvironments({
inviteId,
user,
member,
organization: member.organization,
projects: missingProjects,
Expand Down Expand Up @@ -484,9 +548,8 @@ export async function acceptInvite({
}
}

await provisionMemberDevelopmentEnvironments({
await provisionInviteDevelopmentEnvironments({
inviteId,
user,
member,
organization: invite.organization,
projects: invite.organization.projects,
Expand Down
37 changes: 32 additions & 5 deletions apps/webapp/app/models/orgMember.server.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { Prisma, prisma } from "~/db.server";
import type { MembershipSource } from "~/models/member.server";
import { logger } from "~/services/logger.server";
import { enqueueMemberDevelopmentEnvironments } from "~/services/memberDevEnvironments.server";
import { rbac } from "~/services/rbac.server";
import {
getValidPersonalAccessTokens,
Expand All @@ -13,10 +15,15 @@ export type EnsureOrgMemberParams = {
// value is an RBAC role id; when an RBAC plugin is installed it gets
// attached after the OrgMember row is created.
roleId: string | null;
source: "sso_jit" | "invite" | "manual" | "directory_sync";
source: MembershipSource;
};

export type EnsureOrgMemberResult = { created: boolean; orgMemberId: string };
export type EnsureOrgMemberResult = {
created: boolean;
orgMemberId: string;
/** False when provisioning could not be queued; the membership is still valid. */
devEnvironmentsQueued: boolean;
};

// Completes a JIT role assignment for an ALREADY-existing membership whose
// RBAC role never got applied. This is a no-op when a role is already
Expand Down Expand Up @@ -82,7 +89,12 @@ export async function ensureOrgMember(
if (roleId !== null) {
await healMissingRoleAssignment({ userId, organizationId, roleId, source });
}
return { created: false, orgMemberId: existing.id };
const { enqueued } = await enqueueMemberDevelopmentEnvironments({
userId,
organizationId,
source,
});
return { created: false, orgMemberId: existing.id, devEnvironmentsQueued: enqueued };
}

// Two concurrent JIT/invite flows can both miss the findFirst above and
Expand All @@ -106,7 +118,16 @@ export async function ensureOrgMember(
select: { id: true },
});
if (existingAfterConflict) {
return { created: false, orgMemberId: existingAfterConflict.id };
const { enqueued } = await enqueueMemberDevelopmentEnvironments({
userId,
organizationId,
source,
});
return {
created: false,
orgMemberId: existingAfterConflict.id,
devEnvironmentsQueued: enqueued,
};
}
}
throw error;
Expand Down Expand Up @@ -134,7 +155,13 @@ export async function ensureOrgMember(
}
}

return { created: true, orgMemberId: member.id };
const { enqueued } = await enqueueMemberDevelopmentEnvironments({
userId,
organizationId,
source,
});

return { created: true, orgMemberId: member.id, devEnvironmentsQueued: enqueued };
}

// Find-or-create a User for a directory-provisioned member. Directory Sync
Expand Down
84 changes: 83 additions & 1 deletion apps/webapp/app/models/organization.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@ import { tryCatch } from "@trigger.dev/core/utils";
import { customAlphabet } from "nanoid";
import { generate } from "random-words";
import slug from "slug";
import { $replica, prisma, type PrismaClientOrTransaction } from "~/db.server";
import {
$replica,
Prisma as PrismaNamespace,
prisma,
type PrismaClientOrTransaction,
} from "~/db.server";
import { env } from "~/env.server";
import { featuresForUrl } from "~/features.server";
import { createApiKeyForEnv, createPkApiKeyForEnv, envSlug } from "./api-key.server";
Expand Down Expand Up @@ -224,6 +229,83 @@ export async function createEnvironment({
return environment;
}

/**
* A member's root development environment for a project, never a branch under
* it. Not keyed on slug, so a legacy root with another slug still matches.
*/
export function memberDevelopmentEnvironmentWhere({
projectId,
orgMemberId,
}: {
projectId?: string | { in: string[] };
orgMemberId: string;
}): Prisma.RuntimeEnvironmentWhereInput {
return {
...(projectId === undefined ? {} : { projectId }),
orgMemberId,
type: "DEVELOPMENT",
parentEnvironmentId: null,
};
}

/**
* Create a member's development environment, reporting `created: false` when a
* concurrent writer already made it. Any other conflict still throws.
*
* Not transaction-aware: a unique violation aborts an enclosing transaction, so
* the read that confirms the concurrent row has to run outside one.
*/
export async function createDevelopmentEnvironmentForMember({
organization,
project,
member,
maximumConcurrencyLimit,
}: {
organization: Pick<Organization, "id" | "maximumConcurrencyLimit">;
project: Pick<Project, "id">;
member: OrgMember;
maximumConcurrencyLimit?: number;
}): Promise<{ created: boolean }> {
try {
await createEnvironment({
organization,
project,
type: "DEVELOPMENT",
isBranchableEnvironment: true,
member,
maximumConcurrencyLimit,
});
return { created: true };
} catch (error) {
if (
!(error instanceof PrismaNamespace.PrismaClientKnownRequestError) ||
error.code !== "P2002"
) {
throw error;
}

const existing = await prisma.runtimeEnvironment.findFirst({
where: memberDevelopmentEnvironmentWhere({
projectId: project.id,
orgMemberId: member.id,
}),
select: { id: true },
});

if (!existing) {
throw error;
}
Comment thread
0ski marked this conversation as resolved.

logger.debug("Development environment already created by a concurrent writer", {
organizationId: organization.id,
projectId: project.id,
orgMemberId: member.id,
});

return { created: false };
}
}

function createShortcode() {
return generate({ exactly: 2 });
}
Loading