Projects

- Deploy a new VM when a push event comes through
- Live updating project overview page
This commit is contained in:
Eric Allam
2023-03-07 15:08:02 +00:00
parent 4fabd481e6
commit f39bc44eec
65 changed files with 2521 additions and 281 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/sdk": patch
---
SDK now passes through the project ID from the env var
+2 -1
View File
@@ -45,4 +45,5 @@ yarn-error.log*
.output
apps/**/public/build
.tests-container-id.txt
.sentryclirc
.sentryclirc
.buildt
+3
View File
@@ -52,6 +52,9 @@ windows:
- cwd: /Users/eric/code/triggerdotdev/trigger.dev
commands:
- exec: ./scripts/proxy-pizzly.sh <your-pizzly-dev>
- cwd: /Users/eric/code/triggerdotdev/trigger.dev
commands:
- exec: ./scripts/proxy-wss.sh <your-wss-dev>
- title: wss and examples
layout:
split_direction: horizontal
+29
View File
@@ -0,0 +1,29 @@
import { useLocales } from "./LocaleProvider";
type IntlDateProps = {
date: Date;
timeZone?: string;
};
export const IntlDate = ({ date, timeZone }: IntlDateProps) => {
const locales = useLocales();
const isoString = date.toISOString();
const formattedDate = new Intl.DateTimeFormat(locales, {
year: "numeric",
month: "short",
day: "numeric",
timeZone,
}).format(date);
const formattedTime = new Intl.DateTimeFormat(locales, {
hour: "numeric",
minute: "numeric",
timeZone,
}).format(date);
return (
<time dateTime={isoString}>
{formattedDate} at {formattedTime}
</time>
);
};
@@ -0,0 +1,32 @@
import type { ReactNode } from "react";
import { useContext } from "react";
import { createContext } from "react";
type LocaleContext = {
locales: string[];
};
type LocaleContextProviderProps = {
locales: string[];
children: ReactNode;
};
const Context = createContext<LocaleContext | null>(null);
export const LocaleContextProvider = ({
locales,
children,
}: LocaleContextProviderProps) => {
const value = { locales };
return <Context.Provider value={value}>{children}</Context.Provider>;
};
const throwIfNoProvider = () => {
throw new Error("Please wrap your application in a LocaleContextProvider.");
};
export const useLocales = () => {
const { locales } = useContext(Context) ?? throwIfNoProvider();
return locales;
};
@@ -8,11 +8,13 @@ import {
ArrowTopRightOnSquareIcon,
BeakerIcon,
ChevronLeftIcon,
CloudArrowUpIcon,
Cog6ToothIcon,
EnvelopeIcon,
ForwardIcon,
PhoneArrowUpRightIcon,
PlusCircleIcon,
QueueListIcon,
Squares2X2Icon,
SquaresPlusIcon,
UsersIcon,
@@ -27,6 +29,7 @@ import {
} from "~/hooks/useOrganizations";
import { useCurrentWorkflow } from "~/hooks/useWorkflows";
import { EnvironmentIcon } from "~/routes/resources/environment";
import { CurrentProject } from "~/routes/__app/orgs/$organizationSlug/projects/$projectP";
import { titleCase } from "~/utils";
import { CopyTextPanel } from "../CopyTextButton";
import { TertiaryA, TertiaryButton } from "../primitives/Buttons";
@@ -152,6 +155,43 @@ export function WorkflowsSideMenu() {
);
}
export function ProjectSideMenu({
project,
backPath,
}: {
project: CurrentProject;
backPath: string;
}) {
if (!project) {
return null;
}
const items: SideMenuItem[] = [
{
name: "Overview",
icon: <ArrowsRightLeftIcon className={iconStyle} />,
to: ``,
},
{
name: "Deploys",
icon: <CloudArrowUpIcon className={iconStyle} />,
to: `deploys`,
},
{
name: "Logs",
icon: <QueueListIcon className={iconStyle} />,
to: `logs`,
},
{
name: "Settings",
icon: <Cog6ToothIcon className={iconStyle} />,
to: `settings`,
},
];
return <SideMenu title={project.name} items={items} backPath={backPath} />;
}
const defaultStyle =
"group flex items-center gap-2 px-3 py-3 text-base rounded transition text-slate-300 hover:bg-slate-850 hover:text-white";
const activeStyle =
+26 -20
View File
@@ -3,30 +3,36 @@ import { hydrateRoot } from "react-dom/client";
import * as Sentry from "@sentry/remix";
import { useEffect } from "react";
import posthog from "posthog-js";
import { LocaleContextProvider } from "./components/LocaleProvider";
hydrateRoot(document, <RemixBrowser />);
hydrateRoot(
document,
<LocaleContextProvider locales={window.navigator.languages as string[]}>
<RemixBrowser />
</LocaleContextProvider>
);
//hack because the type is not exported
type SentryIntegration = (typeof Sentry.defaultIntegrations)[number];
if (process.env.NODE_ENV === "production") {
Sentry.init({
dsn: "https://bf96820b08004fa4b2e1506f2ac74a14@o4504419574087680.ingest.sentry.io/4504419607052288",
tracesSampleRate: 1,
integrations: [
new Sentry.BrowserTracing({
routingInstrumentation: Sentry.remixRouterInstrumentation(
useEffect,
useLocation,
useMatches
),
}),
//casted because TypeScript is unhappy about the type from PostHog
new posthog.SentryIntegration(
posthog,
"triggerdev",
4504419607052288
) as SentryIntegration,
],
});
Sentry.init({
dsn: "https://bf96820b08004fa4b2e1506f2ac74a14@o4504419574087680.ingest.sentry.io/4504419607052288",
tracesSampleRate: 1,
integrations: [
new Sentry.BrowserTracing({
routingInstrumentation: Sentry.remixRouterInstrumentation(
useEffect,
useLocation,
useMatches
),
}),
//casted because TypeScript is unhappy about the type from PostHog
new posthog.SentryIntegration(
posthog,
"triggerdev",
4504419607052288
) as SentryIntegration,
],
});
}
+29 -14
View File
@@ -5,7 +5,9 @@ import { renderToPipeableStream } from "react-dom/server";
import { RemixServer } from "@remix-run/react";
import { Response } from "@remix-run/node"; // or cloudflare/deno
import type { EntryContext, Headers } from "@remix-run/node"; // or cloudflare/deno
import { parseAcceptLanguage } from "intl-parse-accept-language";
import isbot from "isbot";
import { LocaleContextProvider } from "./components/LocaleProvider";
const ABORT_DELAY = 30000;
@@ -15,6 +17,11 @@ export default function handleRequest(
responseHeaders: Headers,
remixContext: EntryContext
) {
const acceptLanguage = request.headers.get("accept-language");
const locales = parseAcceptLanguage(acceptLanguage, {
validate: Intl.DateTimeFormat.supportedLocalesOf,
});
// If the request is from a bot, we want to wait for the full
// response to render before sending it to the client. This
// ensures that bots can see the full page content.
@@ -23,7 +30,8 @@ export default function handleRequest(
request,
responseStatusCode,
responseHeaders,
remixContext
remixContext,
locales
);
}
@@ -31,7 +39,8 @@ export default function handleRequest(
request,
responseStatusCode,
responseHeaders,
remixContext
remixContext,
locales
);
}
@@ -39,15 +48,18 @@ function serveTheBots(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
remixContext: EntryContext,
locales: string[]
) {
return new Promise((resolve, reject) => {
const { pipe, abort } = renderToPipeableStream(
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>,
<LocaleContextProvider locales={locales}>
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>
</LocaleContextProvider>,
{
// Use onAllReady to wait for the entire document to be ready
onAllReady() {
@@ -74,16 +86,19 @@ function serveBrowsers(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
remixContext: EntryContext,
locales: string[]
) {
return new Promise((resolve, reject) => {
let didError = false;
const { pipe, abort } = renderToPipeableStream(
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>,
<LocaleContextProvider locales={locales}>
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>
</LocaleContextProvider>,
{
// use onShellReady to wait until a suspense boundary is triggered
onShellReady() {
+2
View File
@@ -57,6 +57,8 @@ const EnvironmentSchema = z.object({
GITHUB_APP_WEBHOOK_SECRET: z.string().optional(),
INTEGRATIONS_API_KEY: z.string(),
INTEGRATIONS_API_ORIGIN: z.string(),
CAKEWORK_API_KEY: z.string(),
TRIGGER_WSS_URL: z.string().default("wss://wss.trigger.dev/ws"),
});
export type Environment = z.infer<typeof EnvironmentSchema>;
@@ -0,0 +1,10 @@
import {
CakeworkApiClient,
CakeworkApiEnvironment,
} from "@cakework/client/dist";
import { env } from "~/env.server";
export const cakework = new CakeworkApiClient({
environment: CakeworkApiEnvironment.Production,
xApiKey: env.CAKEWORK_API_KEY,
});
@@ -1,7 +1,7 @@
import type { PrismaClient } from "~/db.server";
import { prisma } from "~/db.server";
import { getAppInstallation } from "~/services/github/githubApp.server";
import { taskQueue } from "../messageBroker.server";
import { getAppInstallation } from "~/features/ee/projects/github/githubApp.server";
import { taskQueue } from "../../../../services/messageBroker.server";
export class AppInstallationCallback {
#prismaClient: PrismaClient;
@@ -1,10 +1,11 @@
import type { Endpoints } from "@octokit/types";
import type { PushEvent } from "@octokit/webhooks-types";
import { verify } from "@octokit/webhooks-methods";
import { sign as signJWT } from "jsonwebtoken";
import { z } from "zod";
import { env } from "~/env.server";
import type { EmitterWebhookEventName } from "@octokit/webhooks";
import { taskQueue } from "../messageBroker.server";
import { taskQueue } from "../../../../services/messageBroker.server";
export async function verifyAndReceiveWebhook(request: Request) {
if (!env.GITHUB_APP_WEBHOOK_SECRET) {
@@ -38,7 +39,9 @@ export async function verifyAndReceiveWebhook(request: Request) {
const parsedPayload = JSON.parse(payload);
const name = `${hookName}.${parsedPayload.action}` as EmitterWebhookEventName;
const name = (
parsedPayload.action ? `${hookName}.${parsedPayload.action}` : hookName
) as EmitterWebhookEventName;
console.log(`[webhooks.github] Received event`, {
id,
@@ -70,6 +73,22 @@ async function handleGithubEvent<TName extends EmitterWebhookEventName>({
id: payload.installation.id,
});
}
case "push": {
const push = payload as PushEvent;
// Only on pushes to a branch
if (!push.ref.startsWith("refs/heads/")) {
return;
}
const branch = push.ref.replace("refs/heads/", "");
await taskQueue.publish("GITHUB_PUSH", {
branch: branch,
commitSha: push.after,
repository: push.repository.full_name,
});
}
}
}
@@ -153,10 +172,6 @@ export async function getInstallationRepositories(
perPage
);
console.log(
`Fetched page ${page} of repositories, total_count = ${response.total_count}, repositories.length = ${response.repositories.length}`
);
repositories.push(...response.repositories);
if (response.repositories.length < perPage) {
@@ -194,6 +209,63 @@ async function getInstallationRepositoriesPage(
return await response.json();
}
export async function getRepositoryContent(
token: string,
repo: string,
path: string
): Promise<string | undefined> {
const response = await fetch(
`https://api.github.com/repos/${repo}/contents/${path}`,
{
method: "GET",
headers: {
Authorization: `token ${token}`,
Accept: "application/vnd.github.raw",
},
}
);
if (response.status === 404) {
return;
}
if (!response.ok) {
throw new Error(`Failed to get repository content: ${response.statusText}`);
}
return await response.text();
}
export type GetCommitEndpoint =
Endpoints["GET /repos/{owner}/{repo}/commits/{ref}"];
export type GetCommitResponse = GetCommitEndpoint["response"]["data"];
export type GitHubCommit = GetCommitResponse;
export async function getCommit(
token: string,
repo: string,
ref: string
): Promise<GitHubCommit> {
const response = await fetch(
`https://api.github.com/repos/${repo}/commits/${ref}`,
{
method: "GET",
headers: {
Authorization: `token ${token}`,
Accept: "application/vnd.github.v3+json",
},
}
);
if (!response.ok) {
throw new Error(`Failed to get commit: ${response.statusText}`);
}
return await response.json();
}
export const AccountSchema = z.object({
login: z.string(),
id: z.number(),
@@ -8,8 +8,21 @@ export type GitHubAppAuthorizationWithValidToken = GitHubAppAuthorization & {
};
export async function refreshInstallationAccessToken(
authorization: GitHubAppAuthorization
authorizationOrId: GitHubAppAuthorization | string
): Promise<GitHubAppAuthorizationWithValidToken> {
const authorization =
typeof authorizationOrId === "string"
? await prisma.gitHubAppAuthorization.findUnique({
where: {
id: authorizationOrId,
},
})
: authorizationOrId;
if (!authorization) {
throw new Error("App authorization not found");
}
// Make sure the access token is not expired, or less than 10 minutes from expiring
const accessTokenExpired =
!authorization.installationAccessTokenExpiresAt ||
@@ -0,0 +1,207 @@
import type {
RepositoryProject,
RuntimeEnvironment,
ProjectDeployment,
} from ".prisma/client";
import { z } from "zod";
import { parse as parseYAML } from "yaml";
import { getRepositoryContent } from "~/features/ee/projects/github/githubApp.server";
import { refreshInstallationAccessToken } from "~/features/ee/projects/github/refreshInstallationAccessToken.server";
import { env } from "~/env.server";
import { prisma } from "~/db.server";
export async function findProjectByRepo(name: string) {
return await prisma.repositoryProject.findUnique({
where: {
name,
},
include: {
organization: {
include: {
environments: true,
},
},
},
});
}
export async function findProjectById(id: string) {
return await prisma.repositoryProject.findUnique({
where: {
id,
},
include: {
organization: {
include: {
environments: true,
},
},
},
});
}
export function statusTextForBuilding(
deployment: ProjectDeployment,
project: RepositoryProject
) {
return `Building ${project.name}#${project.branch} at ${truncateSha(
deployment.commitHash
)}: "${deployment.commitMessage}" by ${deployment.committer}`;
}
export function statusTextForDeploying(
deployment: ProjectDeployment,
project: RepositoryProject
) {
return `Deploying ${project.name}#${project.branch} at ${truncateSha(
deployment.commitHash
)}: "${deployment.commitMessage}" by ${deployment.committer}`;
}
export function statusTextForDeployed(
deployment: ProjectDeployment,
project: RepositoryProject
) {
return `Deployed ${project.name}#${project.branch} at ${truncateSha(
deployment.commitHash
)}: "${deployment.commitMessage}" by ${deployment.committer}`;
}
function truncateSha(sha: string) {
return sha.substring(0, 7);
}
export function repositoryProjectReadyToDeploy(project: RepositoryProject) {
return project.status === "PENDING" && hasAllEnvVars(project);
}
export function buildEnvVars(
deployment: ProjectDeployment,
project: RepositoryProject,
environment: RuntimeEnvironment
): Record<string, string> {
const envVars = BluePrintEnvVarsSchema.parse(project.envVars);
const result = envVars.reduce((acc, envVar) => {
if (envVar.key === "TRIGGER_API_KEY") {
return {
...acc,
[envVar.key]: environment.apiKey,
};
}
if (!envVar.value) {
return acc;
}
return {
...acc,
[envVar.key]: envVar.value,
};
}, {});
return {
...result,
TRIGGER_PROJECT_ID: project.id,
TRIGGER_DEPLOYMENT_ID: deployment.id,
TRIGGER_WSS_URL: env.TRIGGER_WSS_URL,
};
}
function hasAllEnvVars(project: RepositoryProject) {
const envVars = BluePrintEnvVarsSchema.parse(project.envVars);
// Removing the TRIGGER_API_KEY environment var, are there any other env vars that don't have a value?
return (
envVars
.filter((envVar) => envVar.key !== "TRIGGER_API_KEY")
.filter((envVar) => !envVar.value).length === 0
);
}
export async function serviceDefinitionFromRepository(
appAuthorizationId: string,
repoName: string
) {
const appAuthorization = await refreshInstallationAccessToken(
appAuthorizationId
);
const renderYamlContent = await getRepositoryContent(
appAuthorization.installationAccessToken,
repoName,
"render.yaml"
);
if (!renderYamlContent) {
return;
}
const rawRenderYaml = safeParseYAML(renderYamlContent);
if (!rawRenderYaml) {
return;
}
const blueprint = BlueprintSchema.safeParse(rawRenderYaml);
if (!blueprint.success) {
return;
}
// Find a worker service with env = node and an envVar with the name TRIGGER_API_KEY
const workerService = blueprint.data.services.find(
(service) =>
service.type === "worker" &&
service.env === "node" &&
service.envVars.find((envVar) => envVar.key === "TRIGGER_API_KEY")
);
return workerService;
}
function safeParseYAML(content: string) {
try {
return parseYAML(content);
} catch (error) {
return;
}
}
const BluePrintEnvVarsSchema = z.array(
z
.object({
key: z.string(),
value: z.any(),
sync: z.boolean().default(true),
})
.passthrough()
);
const BlueprintServiceSchema = z
.object({
name: z.string(),
type: z.enum(["web", "worker", "pserv", "cron"]),
env: z.enum([
"node",
"go",
"python",
"ruby",
"php",
"java",
"docker",
"rust",
"static",
]),
buildCommand: z.string().optional(),
startCommand: z.string().optional(),
autoDeploy: z.boolean().default(true),
envVars: BluePrintEnvVarsSchema,
})
.passthrough();
export type BlueprintService = z.infer<typeof BlueprintServiceSchema>;
const BlueprintSchema = z.object({
services: z.array(BlueprintServiceSchema),
});
@@ -2,9 +2,9 @@ import type { GitHubAppAuthorization } from ".prisma/client";
import type { PrismaClient } from "~/db.server";
import { prisma } from "~/db.server";
import { getRepositoryFromMetadata } from "~/models/workflow.server";
import type { CreateInstallationAccessTokenResponse } from "~/services/github/githubApp.server";
import { getInstallationRepositories } from "~/services/github/githubApp.server";
import { refreshInstallationAccessToken } from "~/services/github/refreshInstallationAccessToken.server";
import type { CreateInstallationAccessTokenResponse } from "~/features/ee/projects/github/githubApp.server";
import { getInstallationRepositories } from "~/features/ee/projects/github/githubApp.server";
import { refreshInstallationAccessToken } from "~/features/ee/projects/github/refreshInstallationAccessToken.server";
export type InstallationRepository = NonNullable<
CreateInstallationAccessTokenResponse["repositories"]
@@ -13,6 +13,7 @@ export type InstallationRepository = NonNullable<
export type RepositoryWithStatus = {
repository: InstallationRepository;
status: "relevant" | "unknown";
appAuthorizationId: string;
};
export class NewProjectPresenter {
@@ -73,6 +74,7 @@ export class NewProjectPresenter {
return {
repository,
status,
appAuthorizationId: authorization.id,
};
}
);
@@ -0,0 +1,51 @@
import { LIVE_ENVIRONMENT } from "~/consts";
import type { PrismaClient } from "~/db.server";
import { prisma } from "~/db.server";
import { getEnvironmentForOrganization } from "~/models/runtimeEnvironment.server";
import { WorkflowsPresenter } from "~/presenters/workflowsPresenter.server";
export class ProjectOverviewPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
async data(organizationSlug: string, projectId: string) {
const liveEnvironment = await getEnvironmentForOrganization(
organizationSlug,
LIVE_ENVIRONMENT
);
if (!liveEnvironment) {
throw new Error("No live environment found");
}
const workflowsPresenter = new WorkflowsPresenter(this.#prismaClient);
const workflows = await workflowsPresenter.data(
{
repositoryProject: {
id: projectId,
},
},
liveEnvironment.id
);
const deployments = await this.#prismaClient.projectDeployment.findMany({
where: {
projectId,
},
orderBy: {
updatedAt: "desc",
},
take: 5,
});
return {
workflows,
organizationSlug,
deployments,
};
}
}
@@ -0,0 +1,71 @@
import { Outlet } from "@remix-run/react";
import type { LoaderArgs } from "@remix-run/server-runtime";
import type { UseDataFunctionReturn } from "remix-typedjson";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import invariant from "tiny-invariant";
import { Container } from "~/components/layout/Container";
import {
ProjectSideMenu,
SideMenuContainer,
} from "~/components/navigation/SideMenu";
import { prisma } from "~/db.server";
import { hydrateObject, useMatchesData } from "~/utils";
export async function loader({ params }: LoaderArgs) {
const { organizationSlug, projectP } = params;
invariant(organizationSlug, "organizationSlug not found");
invariant(projectP, "projectP not found");
const project = await prisma.repositoryProject.findFirstOrThrow({
where: {
id: projectP,
organization: {
slug: organizationSlug,
},
},
include: {
currentDeployment: true,
},
});
return typedjson({
project,
organizationSlug,
});
}
export default function ProjectLayout() {
const { project, organizationSlug } = useTypedLoaderData<typeof loader>();
return (
<>
<SideMenuContainer>
<ProjectSideMenu
project={project}
backPath={`/orgs/${organizationSlug}`}
/>
<Container>
<Outlet />
</Container>
</SideMenuContainer>
</>
);
}
export function useCurrentProject() {
const routeMatch = useMatchesData(
"routes/__app/orgs/$organizationSlug/projects/$projectP"
);
if (!routeMatch || !routeMatch.data.project) {
throw new Error("Calling useCurrentProject outside of a project route");
}
const result = hydrateObject<UseDataFunctionReturn<typeof loader>["project"]>(
routeMatch.data.project
);
return result;
}
export type CurrentProject = ReturnType<typeof useCurrentProject>;
@@ -0,0 +1,219 @@
import type { ProjectDeployment } from ".prisma/client";
import {
ArrowTopRightOnSquareIcon,
ClockIcon,
CloudArrowUpIcon,
CloudIcon,
CubeTransparentIcon,
ExclamationTriangleIcon,
NoSymbolIcon,
StopCircleIcon,
} from "@heroicons/react/24/outline";
import { useRevalidator } from "@remix-run/react";
import type { LoaderArgs } from "@remix-run/server-runtime";
import { useEffect } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { useEventSource } from "remix-utils";
import { z } from "zod";
import { IntlDate } from "~/components/IntlDate";
import { List } from "~/components/layout/List";
import { Panel } from "~/components/layout/Panel";
import { PanelHeader } from "~/components/layout/PanelHeader";
import { TertiaryA } from "~/components/primitives/Buttons";
import { SubTitle } from "~/components/primitives/text/SubTitle";
import { Title } from "~/components/primitives/text/Title";
import { WorkflowList } from "~/components/workflows/workflowList";
import { ProjectOverviewPresenter } from "~/features/ee/projects/presenters/projectOverviewPresenter.server";
import { useCurrentProject } from "../$projectP";
export async function loader({ params }: LoaderArgs) {
const { projectP, organizationSlug } = z
.object({ projectP: z.string(), organizationSlug: z.string() })
.parse(params);
const presenter = new ProjectOverviewPresenter();
return typedjson(await presenter.data(organizationSlug, projectP));
}
export default function ProjectOverviewPage() {
const project = useCurrentProject();
const { workflows, organizationSlug, deployments } =
useTypedLoaderData<typeof loader>();
const events = useEventSource(`/resources/projects/${project.id}`, {
event: "update",
});
const revalidator = useRevalidator();
useEffect(() => {
if (events !== null) {
revalidator.revalidate();
}
// WARNING Don't put the revalidator in the useEffect deps array or bad things will happen
}, [events]); // eslint-disable-line react-hooks/exhaustive-deps
let Icon = ExclamationTriangleIcon;
switch (project.status) {
case "PENDING": {
Icon = ClockIcon;
break;
}
case "BUILDING": {
Icon = CubeTransparentIcon;
break;
}
case "DEPLOYING": {
Icon = CloudArrowUpIcon;
break;
}
case "DEPLOYED": {
Icon = CloudIcon;
break;
}
case "ERROR": {
Icon = ExclamationTriangleIcon;
break;
}
}
return (
<>
<Title>Overview</Title>
<SubTitle>
{project.name}#{project.branch}
</SubTitle>
<Panel>
<PanelHeader
icon={
<div className="mr-1 h-6 w-6">
<Icon />
</div>
}
title={project.statusText ? project.statusText : "No status"}
startedAt={null}
finishedAt={null}
/>
</Panel>
<div className="mt-6 max-w-4xl">
<div className="relative rounded-lg bg-slate-850">
<SubTitle>Workflows</SubTitle>
<WorkflowList
className="relative z-50 !mb-0"
workflows={workflows}
currentOrganizationSlug={organizationSlug}
/>
</div>
</div>
<div className="mt-6 max-w-4xl">
<div className="relative rounded-lg bg-slate-850">
<SubTitle>Latest deploys</SubTitle>
<List className="relative z-50 !mb-0">
{deployments.map((deployment) => (
<DeploymentListItem
key={deployment.id}
deployment={deployment}
repo={project.name}
isCurrentDeployment={
deployment.id === project.currentDeployment?.id
}
/>
))}
</List>
</div>
</div>
</>
);
}
function DeploymentListItem({
deployment,
repo,
isCurrentDeployment,
}: {
deployment: ProjectDeployment;
repo: string;
isCurrentDeployment: boolean;
}) {
let Icon = ExclamationTriangleIcon;
switch (deployment.status) {
case "PENDING": {
Icon = ClockIcon;
break;
}
case "BUILDING": {
Icon = CubeTransparentIcon;
break;
}
case "DEPLOYING": {
Icon = CloudArrowUpIcon;
break;
}
case "DEPLOYED": {
Icon = CloudIcon;
break;
}
case "ERROR": {
Icon = ExclamationTriangleIcon;
break;
}
case "CANCELLED": {
Icon = NoSymbolIcon;
break;
}
case "STOPPED": {
Icon = StopCircleIcon;
break;
}
}
let timestamp = deployment.createdAt;
if (deployment.stoppedAt) {
timestamp = deployment.stoppedAt;
} else if (deployment.buildFinishedAt) {
timestamp = deployment.buildFinishedAt;
} else if (deployment.buildStartedAt) {
timestamp = deployment.buildStartedAt;
}
return (
<li className={isCurrentDeployment ? "border-2 border-green-300" : ""}>
<div className="flex flex-col flex-wrap justify-between py-4 pl-4 pr-4 lg:flex-row lg:flex-nowrap lg:items-center">
<div className="flex flex-1 items-center justify-between">
<div className="relative flex items-center">
<div className="mr-4 h-20 w-20 flex-shrink-0 self-start rounded-md bg-slate-850 p-3">
<Icon className="h-12 w-12 text-slate-500" />
</div>
<div className="flex flex-col">
<div className="text-sm font-medium text-slate-200">
<TertiaryA
href={`https://github.com/${repo}/commit/${deployment.commitHash}`}
target="_blank"
>
Commit #{deployment.commitHash.substring(0, 7)}{" "}
<ArrowTopRightOnSquareIcon className="h-4 w-4" />
</TertiaryA>
</div>
<div className="text-sm font-medium text-slate-200">
{deployment.commitMessage}
</div>
</div>
</div>
<div className="flex flex-col items-end">
<div className="text-sm font-medium text-slate-200">
{deployment.status.toLocaleLowerCase()}
</div>
<div className="text-sm font-medium text-slate-200">
<IntlDate date={timestamp} timeZone="UTC" />
</div>
</div>
</div>
</div>
</li>
);
}
@@ -0,0 +1,266 @@
import { FolderIcon } from "@heroicons/react/20/solid";
import { LockClosedIcon, LockOpenIcon } from "@heroicons/react/24/outline";
import { Await, Form, useLoaderData, useTransition } from "@remix-run/react";
import type { LoaderArgs } from "@remix-run/server-runtime";
import { defer } from "@remix-run/server-runtime";
import classNames from "classnames";
import { Suspense } from "react";
import { redirect, typedjson, useTypedActionData } from "remix-typedjson";
import invariant from "tiny-invariant";
import { OctoKitty } from "~/components/GitHubLoginButton";
import { Container } from "~/components/layout/Container";
import { Panel } from "~/components/layout/Panel";
import { PanelWarning } from "~/components/layout/PanelWarning";
import { StepNumber } from "~/components/onboarding/StepNumber";
import {
PrimaryButton,
PrimaryLink,
SecondaryLink,
} from "~/components/primitives/Buttons";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { Spinner } from "~/components/primitives/Spinner";
import { Body } from "~/components/primitives/text/Body";
import { SubTitle } from "~/components/primitives/text/SubTitle";
import { Title } from "~/components/primitives/text/Title";
import { NewProjectPresenter } from "~/features/ee/projects/presenters/newProjectPresenter.server";
import { CreateProjectService } from "~/features/ee/projects/services/createProject.server";
import { requireUserId } from "~/services/session.server";
export async function loader({ request, params }: LoaderArgs) {
const userId = await requireUserId(request);
invariant(params.organizationSlug, "Organization slug is required");
const presenter = new NewProjectPresenter();
return defer(await presenter.data(userId, params.organizationSlug));
}
export async function action({ request, params }: LoaderArgs) {
const userId = await requireUserId(request);
invariant(params.organizationSlug, "Organization slug is required");
const payload = Object.fromEntries(await request.formData());
const service = new CreateProjectService();
const validation = await service.validate(payload);
switch (validation.type) {
case "payloadError":
case "serviceDefinitionError": {
return typedjson(validation, { status: 422 });
}
case "success": {
const result = await service.call(
userId,
params.organizationSlug,
validation.data,
validation.serviceDefinition
);
if (result.type === "serviceError") {
return typedjson(result, { status: 422 });
}
return redirect(
`/orgs/${params.organizationSlug}/projects/${result.project.id}`
);
}
}
}
export default function Page() {
const { appAuthorizations, redirectTo, repositories } =
useLoaderData<typeof loader>();
const actionData = useTypedActionData<typeof action>();
const transition = useTransition();
const isSubmittingOrLoading =
(transition.state === "submitting" &&
transition.type === "actionSubmission") ||
(transition.state === "loading" && transition.type === "actionRedirect");
return (
<Container>
<Title>Deploy a GitHub repository</Title>
<div className="grid w-full grid-cols-3 gap-8">
<Form method="post" className="col-span-2 max-w-4xl">
{appAuthorizations.length === 0 ? (
<>
<ConnectToGithub redirectTo={redirectTo} />
<ConfigureGithub />
</>
) : (
<>
{(!isSubmittingOrLoading &&
actionData?.type === "serviceDefinitionError") ||
actionData?.type === "serviceError" ? (
<PanelWarning
message={actionData.message}
className="mb-4"
></PanelWarning>
) : !isSubmittingOrLoading &&
actionData?.type === "payloadError" ? (
<PanelWarning
message="Something went wrong with your request. Please try again."
className="mb-4"
></PanelWarning>
) : (
<></>
)}
<Panel className="!p-4">
<div className="mb-3 grid grid-cols-2 gap-4">
<InputGroup>
<Label htmlFor="appAuthorizationId">
Select a GitHub repo
</Label>
<Suspense fallback={<Spinner />}>
<Await
errorElement={<p>Error loading repositories</p>}
resolve={repositories}
>
{(repos) => (
<ul>
{repos.map((repo) => (
<li
key={repo.repository.id}
className={classNames(
"flex items-center justify-between gap-2",
repo.status === "relevant"
? "bg-blue-500 text-white"
: "text-slate-400"
)}
>
<a
href={repo.repository.html_url}
target="_blank"
rel="noreferrer"
>
{repo.repository.full_name}#
{repo.repository.default_branch}
</a>
<span>
{repo.repository.private ? (
<LockClosedIcon className="h-4 w-4 text-white" />
) : (
<LockOpenIcon className="h-4 w-4 text-white" />
)}
</span>
<span>
<Form method="post">
<input
type="hidden"
name="repoId"
value={repo.repository.id}
/>
<input
type="hidden"
name="repoName"
value={repo.repository.full_name}
/>
<input
type="hidden"
name="appAuthorizationId"
value={repo.appAuthorizationId}
/>
<PrimaryButton type="submit" size="regular">
Select
</PrimaryButton>
</Form>
</span>
</li>
))}
</ul>
)}
</Await>
</Suspense>
</InputGroup>
</div>
</Panel>
<Panel className="mt-4">
<div className="mb-3 grid grid-cols-2 gap-4">
{appAuthorizations.map((app) => (
<SecondaryLink
to={`/apps/github?redirectTo=${encodeURIComponent(
redirectTo
)}&authorizationId=${app.id}`}
reloadDocument
key={app.id}
>
Configure {app.accountName}
</SecondaryLink>
))}
<PrimaryLink
size="large"
to={`/apps/github?redirectTo=${encodeURIComponent(
redirectTo
)}`}
>
<OctoKitty className="mr-1 h-5 w-5" />
Add another account
</PrimaryLink>
</div>
</Panel>
</>
)}
</Form>
</div>
</Container>
);
}
function ConnectToGithub({ redirectTo }: { redirectTo: string }) {
return (
<>
<SubTitle className="flex items-center">
<StepNumber active stepNumber="1" />
Grant GitHub repo access to get started
</SubTitle>
<Panel className="mb-6 flex h-56 flex-col items-center justify-center gap-4">
<PrimaryLink
size="large"
to={`/apps/github?redirectTo=${encodeURIComponent(redirectTo)}`}
>
<OctoKitty className="mr-1 h-5 w-5" />
Grant access
</PrimaryLink>
<Body size="extra-small" className="flex items-center text-slate-400">
To deploy a new project you need to authorize our GitHub app.{" "}
<a
href="https://docs.trigger.dev/faq#why-do-we-ask-for-github-access"
target="_blank"
rel="noreferrer"
className="ml-1 underline decoration-slate-500 underline-offset-2 transition hover:cursor-pointer hover:text-slate-300"
>
Learn more.
</a>
</Body>
</Panel>
</>
);
}
function ConfigureGithub() {
return (
<>
<div className="mt-6">
<SubTitle className="flex items-center">
<StepNumber stepNumber="2" />
Create your GitHub repository from a template
</SubTitle>
<Panel className="flex h-56 w-full max-w-4xl items-center justify-center gap-6">
<OctoKitty className="h-10 w-10 text-slate-600" />
<div className="h-[1px] w-16 border border-dashed border-slate-600"></div>
<FolderIcon className="h-10 w-10 text-slate-600" />
</Panel>
</div>
</>
);
}
@@ -0,0 +1,74 @@
import type { LoaderArgs } from "@remix-run/server-runtime";
import { randomUUID } from "crypto";
import { eventStream } from "remix-utils";
import { z } from "zod";
import { prisma } from "~/db.server";
import { requireUserId } from "~/services/session.server";
export async function loader({ request, params }: LoaderArgs) {
await requireUserId(request);
const { id } = z.object({ id: z.string() }).parse(params);
const project = await findProjectForUpdates(id);
if (!project) {
return new Response("Not found", { status: 404 });
}
let lastUpdatedAt: number = project.updatedAt.getTime();
let lastDeploymentId: string | null = project.deployments[0]?.id || null;
let workflowCount = project._count.workflows;
return eventStream(request.signal, (send) => {
const pinger = setInterval(() => {
send({ event: "ping", data: new Date().toISOString() });
}, 1000);
const interval = setInterval(() => {
// Get the updatedAt date from the projects database, and send it to the client if it's different from the last one
findProjectForUpdates(id).then((project) => {
if (project) {
if (lastUpdatedAt !== project.updatedAt.getTime()) {
send({ event: "update", data: project.updatedAt.toISOString() });
} else if (lastDeploymentId !== project.deployments[0]?.id) {
send({ event: "update", data: randomUUID() });
} else if (workflowCount !== project._count.workflows) {
send({ event: "update", data: randomUUID() });
}
workflowCount = project._count.workflows;
lastDeploymentId = project.deployments[0]?.id || null;
lastUpdatedAt = project.updatedAt.getTime();
}
});
}, 348);
return function clear() {
clearInterval(pinger);
clearInterval(interval);
};
});
}
function findProjectForUpdates(id: string) {
return prisma.repositoryProject.findUnique({
where: {
id,
},
select: {
updatedAt: true,
deployments: {
orderBy: {
createdAt: "desc",
},
take: 1,
},
_count: {
select: {
workflows: true,
},
},
},
});
}
@@ -0,0 +1,79 @@
import { z } from "zod";
import type { PrismaClient } from "~/db.server";
import { prisma } from "~/db.server";
import { StartDeployment } from "./startDeployment.server";
const PayloadSchema = z.object({
buildId: z.string(),
imageId: z.string(),
});
export class BuildComplete {
#prismaClient: PrismaClient;
#startDeployment = new StartDeployment();
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public validate(payload: unknown) {
return PayloadSchema.safeParse(payload);
}
public async call(payload: z.infer<typeof PayloadSchema>) {
console.log(`Build complete: ${payload.buildId} -> ${payload.imageId}`);
const deployment = await this.#updateDeployment(
payload.buildId,
payload.imageId
);
if (!deployment) {
return true;
}
// Only continue if this deployment is building
if (deployment.status !== "BUILDING") {
return true;
}
return this.#startDeployment.call({
deployment,
project: deployment.project,
environment: deployment.environment,
});
}
async #updateDeployment(buildId: string, imageId: string) {
try {
return await this.#prismaClient.projectDeployment.update({
where: {
buildId,
},
data: {
imageId,
},
include: {
environment: true,
project: {
include: {
currentDeployment: true,
},
},
},
});
} catch (error) {
if (
typeof error === "object" &&
error &&
"code" in error &&
error.code === "P2025"
) {
// Record to update not found
return;
}
throw error;
}
}
}
@@ -0,0 +1,73 @@
import { CakeworkApiError } from "@cakework/client/dist";
import type { PrismaClient } from "~/db.server";
import { prisma } from "~/db.server";
import { cakework } from "../cakework.server";
export class CleanupDeployment {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
async call(id: string) {
const deployment = await this.#prismaClient.projectDeployment.findUnique({
where: {
id,
},
include: {
project: true,
},
});
if (!deployment) {
return true;
}
if (deployment.status !== "DEPLOYED") {
return true;
}
if (deployment.project.currentDeploymentId === id) {
return true;
}
if (!deployment.vmIdentifier) {
return true;
}
try {
console.log(
`Stopping VM: ${deployment.vmIdentifier} for deployment ${id}`
);
await cakework.stopVm(deployment.vmIdentifier);
console.log(
`Stopped VM: ${deployment.vmIdentifier} for deployment ${id}`
);
await this.#prismaClient.projectDeployment.update({
where: {
id,
},
data: {
status: "STOPPED",
stoppedAt: new Date(),
},
});
return true;
} catch (error) {
if (error instanceof CakeworkApiError) {
console.log(
`Failed to stop VM: ${deployment.vmIdentifier} for deployment ${id}: ${error.statusCode} ${error.message}`
);
return false;
}
throw error;
}
}
}
@@ -0,0 +1,145 @@
import { z } from "zod";
import type { PrismaClient } from "~/db.server";
import { prisma } from "~/db.server";
import type { BlueprintService } from "~/features/ee/projects/models/repositoryProject.server";
import {
repositoryProjectReadyToDeploy,
serviceDefinitionFromRepository,
} from "~/features/ee/projects/models/repositoryProject.server";
import { taskQueue } from "~/services/messageBroker.server";
const FormSchema = z.object({
repoId: z.string(),
repoName: z.string(),
appAuthorizationId: z.string(),
});
export type CreateProjectValidationResult =
| {
type: "payloadError";
errors: z.ZodIssue[];
}
| {
type: "serviceDefinitionError";
message: string;
}
| {
type: "success";
data: z.infer<typeof FormSchema>;
serviceDefinition: BlueprintService;
};
export class CreateProjectService {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call(
userId: string,
organizationSlug: string,
data: z.infer<typeof FormSchema>,
serviceDefinition: BlueprintService
) {
try {
const project = await this.#prismaClient.repositoryProject.create({
data: {
name: data.repoName,
url: `https://github.com/${data.repoName}`,
authorization: {
connect: {
id: data.appAuthorizationId,
},
},
branch: "main",
organization: {
connect: {
slug: organizationSlug,
},
},
buildCommand: serviceDefinition.buildCommand ?? "npm run build",
startCommand: serviceDefinition.startCommand ?? "npm run start",
envVars: serviceDefinition.envVars,
},
});
if (repositoryProjectReadyToDeploy(project)) {
await taskQueue.publish("START_INITIAL_PROJECT_DEPLOYMENT", {
id: project.id,
});
}
return { type: "success" as const, project };
} catch (error) {
// Handle Prisma unique constraint error (name must be unique)
if (
typeof error === "object" &&
error &&
"code" in error &&
error.code === "P2002"
) {
return {
type: "serviceError" as const,
message:
"Cannot deploy this repository because it is already being used.",
};
}
throw error;
}
}
public async validate(
payload: unknown
): Promise<CreateProjectValidationResult> {
const payloadValidation = FormSchema.safeParse(payload);
if (!payloadValidation.success) {
return {
type: "payloadError" as const,
errors: payloadValidation.error.issues,
};
}
const serviceDefinition = await serviceDefinitionFromRepository(
payloadValidation.data.appAuthorizationId,
payloadValidation.data.repoName
);
if (!serviceDefinition) {
return {
type: "serviceDefinitionError" as const,
message:
"Could not find a service definition in the selected repository. We only support repositories based on our templates for now.",
};
}
if (!this.#validateServiceMetadata(serviceDefinition)) {
return {
type: "serviceDefinitionError" as const,
message:
"Could not find a service definition in the selected repository. We only support repositories based on our templates for now.",
};
}
return {
type: "success" as const,
data: payloadValidation.data,
serviceDefinition,
};
}
// Make sure the service metadata is valid
// env = node
// type = worker
// envVar with key TRIGGER_API_KEY
#validateServiceMetadata(serviceMetadata: BlueprintService) {
return (
serviceMetadata.env === "node" &&
serviceMetadata.type === "worker" &&
serviceMetadata.envVars.find((envVar) => envVar.key === "TRIGGER_API_KEY")
);
}
}
@@ -0,0 +1,131 @@
import type { RepositoryProject, RuntimeEnvironment } from ".prisma/client";
import type { PrismaClient } from "~/db.server";
import { prisma } from "~/db.server";
import { cakework } from "../cakework.server";
import { taskQueue } from "~/services/messageBroker.server";
import type { GitHubAppAuthorizationWithValidToken } from "../github/refreshInstallationAccessToken.server";
import type { GitHubCommit } from "../github/githubApp.server";
export type CreateProjectDeploymentOptions = {
project: RepositoryProject;
authorization: GitHubAppAuthorizationWithValidToken;
environment: RuntimeEnvironment;
commit: GitHubCommit;
};
export class CreateProjectDeployment {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call({
project,
environment,
authorization,
commit,
}: CreateProjectDeploymentOptions) {
const dockerfile = formatFileContents(`
FROM node:18-bullseye-slim
WORKDIR /app
COPY package*.json ./
RUN ${project.buildCommand}
COPY . .
CMD [${project.startCommand
.split(" ")
.map((s) => `"${s}"`)
.join(", ")}]
`);
const dockerIgnore = formatFileContents(`
node_modules
`);
console.log(
`Building image for ${project.name} with token ${authorization.installationAccessToken}`
);
const build = await cakework.buildImageFromGithub({
dockerfile: dockerfile,
dockerignore: dockerIgnore,
token: authorization.installationAccessToken,
repository: project.name,
branch: project.branch,
});
console.log(`Build started for ${project.name} with id ${build.buildId}`);
// Create the deployment
// Setting the buildStartAt because even though this is a PENDING deployment,
// we have already started to build it with Cakework (it can still end up not getting deployed if this deployment is cancelled)
const deployment = await this.#prismaClient.projectDeployment.create({
data: {
buildId: build.buildId,
buildStartedAt: new Date(),
project: {
connect: {
id: project.id,
},
},
environment: {
connect: {
id: environment.id,
},
},
status: "PENDING",
branch: project.branch,
commitHash: commit.sha,
commitMessage: commit.commit.message,
committer: getCommitAuthor(commit),
dockerfile,
dockerIgnore,
},
});
await taskQueue.publish("PROJECT_DEPLOYMENT_CREATED", {
id: deployment.id,
});
}
}
function getCommitAuthor(commit: GitHubCommit) {
if (commit.commit.author && commit.commit.author.name) {
return commit.commit.author.name;
}
if (commit.committer && commit.committer.login) {
return commit.committer.login;
}
if (commit.author && commit.author.login) {
return commit.author.login;
}
return "Unknown";
}
// Remove newlines at the beginning of the file, and remove any leading whitespace on each line (make sure not to remove any other whitespace)
// For example, the following input:
//
// FROM node:bullseye-slim
// WORKDIR /app
// COPY package*.json ./
// RUN npm install && npm run build
// COPY . .
// CMD ["node", "dist/index.js"]
//
// Would be formatted to:
// FROM node:bullseye-slim
// WORKDIR /app
// COPY package*.json ./
// RUN npm install && npm run build
// COPY . .
// CMD ["node", "dist/index.js"]
function formatFileContents(contents: string) {
return contents
.trimStart()
.split("\n")
.map((line) => line.trimStart())
.join("\n");
}
@@ -0,0 +1,80 @@
import type { PrismaClient } from "~/db.server";
import { prisma } from "~/db.server";
import { statusTextForBuilding } from "~/features/ee/projects/models/repositoryProject.server";
export class DeploymentCreated {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call(id: string) {
const deployment = await this.#prismaClient.projectDeployment.findUnique({
where: {
id,
},
include: {
project: true,
},
});
if (!deployment) {
return;
}
// If the RepositoryProjec status is:
// - PENDING: Update the project to be building
// - DEPLOYED: Update the project to be building
// - BUILDING: Do nothing
// - ERROR: Update the project to be building
// - DISABLED: Do nothing
// - DEPLOYING: Do nothing
switch (deployment.project.status) {
case "PENDING":
case "DEPLOYED":
case "ERROR": {
// Set the project as "building"
// will transition to "deploying" when the build is complete (see: buildComplete.server.ts)
console.log(
`Setting project ${deployment.project.id} and deployment ${deployment.id} to BUILDING`
);
await this.#prismaClient.repositoryProject.update({
where: {
id: deployment.project.id,
},
data: {
status: "BUILDING",
statusText: statusTextForBuilding(deployment, deployment.project),
},
});
await this.#prismaClient.projectDeployment.update({
where: {
id: deployment.id,
},
data: {
status: "BUILDING",
},
});
}
}
// We also need to check if there are any other PENDING deployments for this project
// and if so we should cancel them (because we have a newer push)
await this.#prismaClient.projectDeployment.updateMany({
where: {
projectId: deployment.project.id,
status: "PENDING",
id: {
not: deployment.id,
},
},
data: {
status: "CANCELLED",
},
});
}
}
@@ -0,0 +1,51 @@
import { LIVE_ENVIRONMENT } from "~/consts";
import { findProjectById } from "~/features/ee/projects/models/repositoryProject.server";
import { getCommit } from "../github/githubApp.server";
import { refreshInstallationAccessToken } from "../github/refreshInstallationAccessToken.server";
import { CreateProjectDeployment } from "./createProjectDeployment.server";
export class InitialProjectDeployment {
#createProjectDeployment = new CreateProjectDeployment();
public async call(projectId: string) {
const project = await findProjectById(projectId);
if (!project) {
return;
}
if (project.status !== "PENDING") {
return;
}
const environment = project.organization.environments.find(
(environment) => environment.slug === LIVE_ENVIRONMENT
);
if (!environment) {
return;
}
// Retrieve the latest commit from the "main" branch in the repo
const appAuthorization = await refreshInstallationAccessToken(
project.authorizationId
);
const latestCommit = await getCommit(
appAuthorization.installationAccessToken,
project.name,
project.branch
);
console.log(
`Creating deployment for latest commit for ${project.name}: ${latestCommit.sha}`
);
await this.#createProjectDeployment.call({
project,
environment,
authorization: appAuthorization,
commit: latestCommit,
});
}
}
@@ -0,0 +1,57 @@
import { LIVE_ENVIRONMENT } from "~/consts";
import { findProjectByRepo } from "~/features/ee/projects/models/repositoryProject.server";
import { getCommit } from "../github/githubApp.server";
import { refreshInstallationAccessToken } from "../github/refreshInstallationAccessToken.server";
import { CreateProjectDeployment } from "./createProjectDeployment.server";
export class ReceiveRepositoryPush {
#createProjectDeployment = new CreateProjectDeployment();
public async call(data: {
branch: string;
commitSha: string;
repository: string;
}) {
console.log(
`Received push for ${data.repository} on ${data.branch}: ${data.commitSha}`
);
const project = await findProjectByRepo(data.repository);
if (!project) {
return;
}
if (project.branch !== data.branch) {
return;
}
const environment = project.organization.environments.find(
(environment) => environment.slug === LIVE_ENVIRONMENT
);
if (!environment) {
return;
}
// Retrieve the latest commit from the "main" branch in the repo
const appAuthorization = await refreshInstallationAccessToken(
project.authorizationId
);
const commit = await getCommit(
appAuthorization.installationAccessToken,
project.name,
data.commitSha
);
console.log(`Received commit for ${project.name}: ${commit.sha}`);
await this.#createProjectDeployment.call({
project,
environment,
authorization: appAuthorization,
commit,
});
}
}
@@ -0,0 +1,150 @@
import type {
ProjectDeployment,
RepositoryProject,
RuntimeEnvironment,
} from ".prisma/client";
import type { PrismaClient } from "~/db.server";
import { prisma } from "~/db.server";
import {
buildEnvVars,
statusTextForDeployed,
statusTextForDeploying,
} from "~/features/ee/projects/models/repositoryProject.server";
import { taskQueue } from "~/services/messageBroker.server";
import { cakework } from "../cakework.server";
export type StartDeploymentOptions = {
deployment: ProjectDeployment;
project: RepositoryProject;
environment: RuntimeEnvironment;
};
export class StartDeployment {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call({
deployment,
project,
environment,
}: StartDeploymentOptions) {
if (!deployment.imageId) {
return true;
}
console.log(`Starting deployment ${deployment.id} for ${project.id}`);
// Update the project and deployment status to deploying
await this.#prismaClient.repositoryProject.update({
where: {
id: project.id,
},
data: {
status: "DEPLOYING",
statusText: statusTextForDeploying(deployment, project),
},
});
await this.#prismaClient.projectDeployment.update({
where: {
id: deployment.id,
},
data: {
status: "DEPLOYING",
},
});
const vmStart = performance.now();
const envVars = buildEnvVars(deployment, project, environment);
console.log(
`Starting VM for ${deployment.id} with envVars: ${JSON.stringify(
envVars
)}`
);
try {
const vm = await cakework.startVm({
imageId: deployment.imageId,
cpu: 1,
memory: 256,
envVars,
});
const vmEnd = performance.now();
console.log(
`Started VM for ${deployment.id} in ${(vmEnd - vmStart).toFixed(2)}ms`
);
// Update the deployment with the VM
await this.#prismaClient.projectDeployment.update({
where: {
id: deployment.id,
},
data: {
vmIdentifier: vm.id,
status: "DEPLOYED",
buildFinishedAt: new Date(),
},
});
// Update the project
await this.#prismaClient.repositoryProject.update({
where: {
id: project.id,
},
data: {
status: "DEPLOYED",
currentDeploymentId: deployment.id,
statusText: statusTextForDeployed(deployment, project),
},
});
// If there are any pending deployments, we can start them now
await taskQueue.publish("DEPLOYMENT_DEPLOYED", {
id: deployment.id,
projectId: project.id,
});
// Make sure to stop the previous deployment
if (project.currentDeploymentId) {
await taskQueue.publish("CLEANUP_DEPLOYMENT", {
id: project.currentDeploymentId,
});
}
} catch (error) {
// Update the deployment to be errored
await this.#prismaClient.projectDeployment.update({
where: {
id: deployment.id,
},
data: {
status: "ERROR",
error: JSON.parse(JSON.stringify(error)),
},
});
// Update the project to be errored
await this.#prismaClient.repositoryProject.update({
where: {
id: project.id,
},
data: {
status: "ERROR",
statusText: `Error building project: ${
error instanceof Error ? error.message : error
}`,
},
});
return false;
}
return true;
}
}
@@ -0,0 +1,55 @@
import type { PrismaClient } from "~/db.server";
import { prisma } from "~/db.server";
import { StartDeployment } from "./startDeployment.server";
export class StartPendingDeployment {
#prismaClient: PrismaClient;
#startDeployment = new StartDeployment();
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
async call(id: string) {
const project = await this.#prismaClient.repositoryProject.findUnique({
where: {
id,
},
});
if (!project) {
return;
}
if (project.status !== "DEPLOYED") {
return;
}
// Find latest pending deployment with an imageIdentifier
const deployment = await this.#prismaClient.projectDeployment.findFirst({
where: {
projectId: project.id,
status: "PENDING",
imageId: {
not: null,
},
},
orderBy: {
createdAt: "desc",
},
include: {
environment: true,
},
});
if (!deployment) {
return;
}
return await this.#startDeployment.call({
deployment,
project,
environment: deployment.environment,
});
}
}
@@ -18,6 +18,30 @@ export async function findEnvironmentByApiKey(apiKey: string) {
return environment;
}
export async function getEnvironmentForOrganization(
organizationSlug: string,
slug: string
) {
const organization = await prisma.organization.findUnique({
where: {
slug: organizationSlug,
},
include: {
environments: true,
},
});
if (!organization) {
return;
}
const environment = organization.environments.find(
(environment) => environment.slug === slug
);
return environment;
}
export const { commitSession, getSession } = createCookieSessionStorage({
cookie: {
name: "__environment",
@@ -1,12 +0,0 @@
import { Outlet } from "@remix-run/react";
import { Container } from "~/components/layout/Container";
import { Title } from "~/components/primitives/text/Title";
export default function NewProjectPage() {
return (
<Container>
<Title>Create a new project</Title>
<Outlet />
</Container>
);
}
@@ -1,180 +0,0 @@
import { FolderIcon } from "@heroicons/react/20/solid";
import { LockClosedIcon, LockOpenIcon } from "@heroicons/react/24/outline";
import { Await, Form, useLoaderData } from "@remix-run/react";
import type { LoaderArgs } from "@remix-run/server-runtime";
import { defer } from "@remix-run/server-runtime";
import classNames from "classnames";
import { Suspense } from "react";
import invariant from "tiny-invariant";
import { OctoKitty } from "~/components/GitHubLoginButton";
import { Panel } from "~/components/layout/Panel";
import { StepNumber } from "~/components/onboarding/StepNumber";
import {
PrimaryButton,
PrimaryLink,
SecondaryLink,
} from "~/components/primitives/Buttons";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { Spinner } from "~/components/primitives/Spinner";
import { Body } from "~/components/primitives/text/Body";
import { SubTitle } from "~/components/primitives/text/SubTitle";
import { NewProjectPresenter } from "~/presenters/newProjectPresenter.server";
import { requireUserId } from "~/services/session.server";
export const loader = async ({ request, params }: LoaderArgs) => {
const userId = await requireUserId(request);
invariant(params.organizationSlug, "Organization slug is required");
const presenter = new NewProjectPresenter();
return defer(await presenter.data(userId, params.organizationSlug));
};
export default function Page() {
const { appAuthorizations, redirectTo, repositories } =
useLoaderData<typeof loader>();
return (
<div className="grid w-full grid-cols-3 gap-8">
<Form method="post" className="col-span-2 max-w-4xl">
{appAuthorizations.length === 0 ? (
<>
<ConnectToGithub redirectTo={redirectTo} />
<ConfigureGithub />
</>
) : (
<>
<Panel className="!p-4">
<div className="mb-3 grid grid-cols-2 gap-4">
<InputGroup>
<Label htmlFor="appAuthorizationId">
Select a GitHub repo
</Label>
<Suspense fallback={<Spinner />}>
<Await
errorElement={<p>Error loading repositories</p>}
resolve={repositories}
>
{(repos) => (
<ul>
{repos.map((repo) => (
<li
key={repo.repository.id}
className={classNames(
"flex items-center justify-between gap-2",
repo.status === "relevant"
? "bg-blue-500 text-white"
: "text-slate-400"
)}
>
<a
href={repo.repository.html_url}
target="_blank"
rel="noreferrer"
>
{repo.repository.full_name}#
{repo.repository.default_branch}
</a>
<span>
{repo.repository.private ? (
<LockClosedIcon className="h-4 w-4 text-white" />
) : (
<LockOpenIcon className="h-4 w-4 text-white" />
)}
</span>
<span>
<PrimaryButton size="regular">
Deploy
</PrimaryButton>
</span>
</li>
))}
</ul>
)}
</Await>
</Suspense>
</InputGroup>
</div>
</Panel>
<Panel className="mt-4">
<div className="mb-3 grid grid-cols-2 gap-4">
{appAuthorizations.map((app) => (
<SecondaryLink
to={`/apps/github?redirectTo=${encodeURIComponent(
redirectTo
)}&authorizationId=${app.id}`}
reloadDocument
key={app.id}
>
Configure {app.accountName}
</SecondaryLink>
))}
<PrimaryLink
size="large"
to={`/apps/github?redirectTo=${encodeURIComponent(
redirectTo
)}`}
>
<OctoKitty className="mr-1 h-5 w-5" />
Add another account
</PrimaryLink>
</div>
</Panel>
</>
)}
</Form>
</div>
);
}
function ConnectToGithub({ redirectTo }: { redirectTo: string }) {
return (
<>
<SubTitle className="flex items-center">
<StepNumber active stepNumber="1" />
Grant GitHub repo access to get started
</SubTitle>
<Panel className="mb-6 flex h-56 flex-col items-center justify-center gap-4">
<PrimaryLink
size="large"
to={`/apps/github?redirectTo=${encodeURIComponent(redirectTo)}`}
>
<OctoKitty className="mr-1 h-5 w-5" />
Grant access
</PrimaryLink>
<Body size="extra-small" className="flex items-center text-slate-400">
To deploy a new project you need to authorize our GitHub app.{" "}
<a
href="https://docs.trigger.dev/faq#why-do-we-ask-for-github-access"
target="_blank"
rel="noreferrer"
className="ml-1 underline decoration-slate-500 underline-offset-2 transition hover:cursor-pointer hover:text-slate-300"
>
Learn more.
</a>
</Body>
</Panel>
</>
);
}
function ConfigureGithub() {
return (
<>
<div className="mt-6">
<SubTitle className="flex items-center">
<StepNumber stepNumber="2" />
Create your GitHub repository from a template
</SubTitle>
<Panel className="flex h-56 w-full max-w-4xl items-center justify-center gap-6">
<OctoKitty className="h-10 w-10 text-slate-600" />
<div className="h-[1px] w-16 border border-dashed border-slate-600"></div>
<FolderIcon className="h-10 w-10 text-slate-600" />
</Panel>
</div>
</>
);
}
@@ -0,0 +1,2 @@
export * from "~/features/ee/projects/routes/select-repo";
export { default } from "~/features/ee/projects/routes/select-repo";
@@ -0,0 +1,2 @@
export * from "~/features/ee/projects/routes/$projectP";
export { default } from "~/features/ee/projects/routes/$projectP";
@@ -0,0 +1,2 @@
export * from "~/features/ee/projects/routes/$projectP/index";
export { default } from "~/features/ee/projects/routes/$projectP/index";
@@ -1,20 +1,13 @@
import { EventRule } from ".prisma/client";
import { Disclosure } from "@headlessui/react";
import { BeakerIcon, CheckCircleIcon } from "@heroicons/react/20/solid";
import {
ArrowsRightLeftIcon,
CheckIcon,
ChevronDownIcon,
Cog6ToothIcon,
InformationCircleIcon,
} from "@heroicons/react/24/outline";
import type { LoaderArgs } from "@remix-run/server-runtime";
import classNames from "classnames";
import {
typedjson,
UseDataFunctionReturn,
useTypedLoaderData,
} from "remix-typedjson";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import invariant from "tiny-invariant";
import { ApiLogoIcon } from "~/components/code/ApiLogoIcon";
import CodeBlock from "~/components/code/CodeBlock";
@@ -26,12 +19,7 @@ import { Panel } from "~/components/layout/Panel";
import { PanelHeader } from "~/components/layout/PanelHeader";
import { PanelInfo } from "~/components/layout/PanelInfo";
import { PanelWarning } from "~/components/layout/PanelWarning";
import {
PrimaryLink,
SecondaryLink,
TertiaryLink,
} from "~/components/primitives/Buttons";
import { PlugIcon } from "~/components/primitives/IconPlug";
import { SecondaryLink, TertiaryLink } from "~/components/primitives/Buttons";
import { Input } from "~/components/primitives/Input";
import { Body } from "~/components/primitives/text/Body";
import { Header2 } from "~/components/primitives/text/Headers";
@@ -0,0 +1,23 @@
import type { ActionArgs } from "@remix-run/server-runtime";
import { taskQueue } from "~/services/messageBroker.server";
import { BuildComplete } from "~/features/ee/projects/services/buildComplete.server";
export async function action({ request }: ActionArgs) {
const payload = await request.json();
const service = new BuildComplete();
const validation = service.validate(payload);
if (!validation.success) {
return new Response(JSON.stringify(validation.error), {
status: 400,
});
}
await taskQueue.publish("DEPLOYMENT_BUILD_COMPLETE", validation.data);
return new Response("OK", {
status: 200,
});
}
@@ -1,5 +1,5 @@
import type { ActionArgs } from "@remix-run/server-runtime";
import { verifyAndReceiveWebhook } from "~/services/github/githubApp.server";
import { verifyAndReceiveWebhook } from "~/features/ee/projects/github/githubApp.server";
export async function action({ request }: ActionArgs) {
return verifyAndReceiveWebhook(request);
@@ -1,7 +1,7 @@
import type { LoaderArgs } from "@remix-run/server-runtime";
import { redirect } from "@remix-run/server-runtime";
import { z } from "zod";
import { AppInstallationCallback } from "~/services/github/appInstallationCallback.server";
import { AppInstallationCallback } from "~/features/ee/projects/github/appInstallationCallback.server";
import { requireUserId } from "~/services/session.server";
const ParamSchema = z.object({
+1 -1
View File
@@ -1,7 +1,7 @@
import type { LoaderArgs } from "@remix-run/server-runtime";
import { redirect } from "@remix-run/server-runtime";
import { z } from "zod";
import { StartAppInstallation } from "~/services/github/startAppInstallation.server";
import { StartAppInstallation } from "~/features/ee/projects/github/startAppInstallation.server";
import { requireUserId } from "~/services/session.server";
const SearchParamsSchema = z.object({
@@ -0,0 +1 @@
export { loader } from "~/features/ee/projects/routes/stream";
@@ -50,6 +50,12 @@ import { RegisterExternalSource } from "./externalSources/registerExternalSource
import { CreateFetchRequest } from "./fetches/createFetchRequest.server";
import { PerformFetchRequest } from "./fetches/performFetchRequest.server";
import { StartFetchRequest } from "./fetches/startFetchRequest.server";
import { BuildComplete } from "../features/ee/projects/services/buildComplete.server";
import { CleanupDeployment } from "../features/ee/projects/services/cleanupDeployment.server";
import { DeploymentCreated } from "../features/ee/projects/services/deploymentCreated.server";
import { InitialProjectDeployment } from "../features/ee/projects/services/initialProjectDeployment.server";
import { ReceiveRepositoryPush } from "../features/ee/projects/services/receiveRepositoryPush.server";
import { StartPendingDeployment } from "../features/ee/projects/services/startPendingDeployment.server";
import type { PulsarClient } from "./pulsarClient.server";
import { createPulsarClient } from "./pulsarClient.server";
import { CreateIntegrationRequest } from "./requests/createIntegrationRequest.server";
@@ -504,6 +510,34 @@ const taskQueueCatalog = {
data: z.object({ id: z.string() }),
properties: z.object({}),
},
START_INITIAL_PROJECT_DEPLOYMENT: {
data: z.object({ id: z.string() }),
properties: z.object({}),
},
DEPLOYMENT_BUILD_COMPLETE: {
data: z.object({ buildId: z.string(), imageId: z.string() }),
properties: z.object({}),
},
GITHUB_PUSH: {
data: z.object({
branch: z.string(),
commitSha: z.string(),
repository: z.string(),
}),
properties: z.object({}),
},
PROJECT_DEPLOYMENT_CREATED: {
data: z.object({ id: z.string() }),
properties: z.object({}),
},
DEPLOYMENT_DEPLOYED: {
data: z.object({ id: z.string(), projectId: z.string() }),
properties: z.object({}),
},
CLEANUP_DEPLOYMENT: {
data: z.object({ id: z.string() }),
properties: z.object({}),
},
};
function createTaskQueue() {
@@ -891,6 +925,69 @@ function createTaskQueue() {
const service = new WorkflowRunCreatedEvent();
return service.call(data.id);
},
START_INITIAL_PROJECT_DEPLOYMENT: async (
id,
data,
properties,
attributes
) => {
const service = new InitialProjectDeployment();
await service.call(data.id);
return true;
},
DEPLOYMENT_BUILD_COMPLETE: async (id, data, properties, attributes) => {
if (attributes.redeliveryCount >= 4) {
return true;
}
const service = new BuildComplete();
return await service.call(data);
},
GITHUB_PUSH: async (id, data, properties, attributes) => {
if (attributes.redeliveryCount >= 4) {
return true;
}
const service = new ReceiveRepositoryPush();
await service.call(data);
return true;
},
PROJECT_DEPLOYMENT_CREATED: async (id, data, properties, attributes) => {
if (attributes.redeliveryCount >= 4) {
return true;
}
const service = new DeploymentCreated();
await service.call(data.id);
return true;
},
CLEANUP_DEPLOYMENT: async (id, data, properties, attributes) => {
if (attributes.redeliveryCount >= 4) {
return true;
}
const service = new CleanupDeployment();
return await service.call(data.id);
},
DEPLOYMENT_DEPLOYED: async (id, data, properties, attributes) => {
if (attributes.redeliveryCount >= 4) {
return true;
}
const service = new StartPendingDeployment();
await service.call(data.projectId);
return true;
},
},
});
@@ -71,6 +71,12 @@ export class RegisterWorkflow {
environment
);
if (isNew) {
await taskQueue.publish("WORKFLOW_CREATED", {
id: workflow.id,
});
}
return {
status: "success" as const,
data: { workflow, environment, organization, isNew },
@@ -122,6 +128,10 @@ export class RegisterWorkflow {
},
});
const metadata = payload.metadata
? JSON.parse(payload.metadata)
: undefined;
const workflow = await this.#prismaClient.workflow.upsert({
where: {
organizationId_slug: {
@@ -136,7 +146,8 @@ export class RegisterWorkflow {
service: payload.trigger.service,
eventNames: payload.trigger.name,
triggerTtlInSeconds: payload.triggerTTL,
metadata: payload.metadata ? JSON.parse(payload.metadata) : undefined,
metadata,
repositoryProjectId: metadata?.env?.PROJECT_ID,
jsonSchema:
"schema" in payload.trigger
? payload.trigger.schema
@@ -154,6 +165,8 @@ export class RegisterWorkflow {
service: payload.trigger.service,
eventNames: payload.trigger.name,
triggerTtlInSeconds: payload.triggerTTL,
metadata,
repositoryProjectId: metadata?.env?.PROJECT_ID,
jsonSchema:
"schema" in payload.trigger
? payload.trigger.schema
@@ -167,13 +180,6 @@ export class RegisterWorkflow {
});
if (!existingWorkflow) {
await taskQueue.publish("WORKFLOW_CREATED", {
id: workflow.id,
});
await taskQueue.publish("WORKFLOW_CREATED", {
id: workflow.id,
});
return { workflow, isNew: true };
}
+4
View File
@@ -44,6 +44,7 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.186.0",
"@aws-sdk/s3-request-presigner": "^3.186.0",
"@cakework/client": "^0.0.42",
"@cfworker/json-schema": "^1.12.5",
"@codemirror/autocomplete": "^6.3.1",
"@codemirror/commands": "^6.1.2",
@@ -95,6 +96,7 @@
"internal-bridge": "workspace:*",
"internal-platform": "workspace:*",
"internal-pulsar": "workspace:*",
"intl-parse-accept-language": "^1.0.0",
"ioredis": "^5.2.4",
"isbot": "^3.6.5",
"javascript-time-ago": "^2.5.7",
@@ -136,12 +138,14 @@
"tiny-invariant": "^1.2.0",
"tsx": "^3.4.3",
"ulid": "^2.3.0",
"yaml": "^2.2.1",
"zod": "^3.20.2",
"zod-error": "^1.1.0"
},
"devDependencies": {
"@faker-js/faker": "^7.6.0",
"@octokit/types": "^9.0.0",
"@octokit/webhooks-types": "^6.10.0",
"@remix-run/dev": "v1.11.0",
"@remix-run/eslint-config": "v1.11.0",
"@swc/core": "^1.3.4",
@@ -0,0 +1,39 @@
/*
Warnings:
- You are about to drop the `GitHubRepository` table. If the table is not empty, all the data it contains will be lost.
*/
-- CreateEnum
CREATE TYPE "RepositoryProjectStatus" AS ENUM ('PENDING', 'DEPLOYING', 'DEPLOYED', 'ERROR', 'DISABLED');
-- DropForeignKey
ALTER TABLE "GitHubRepository" DROP CONSTRAINT "GitHubRepository_authorizationId_fkey";
-- DropForeignKey
ALTER TABLE "GitHubRepository" DROP CONSTRAINT "GitHubRepository_templateId_fkey";
-- DropTable
DROP TABLE "GitHubRepository";
-- CreateTable
CREATE TABLE "RepositoryProject" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"url" TEXT NOT NULL,
"branch" TEXT NOT NULL DEFAULT 'main',
"authorizationId" TEXT NOT NULL,
"buildCommand" TEXT NOT NULL,
"startCommand" TEXT NOT NULL,
"autoDeploy" BOOLEAN NOT NULL DEFAULT true,
"envVars" JSONB NOT NULL,
"dockerDefinitionUrl" TEXT NOT NULL,
"status" "RepositoryProjectStatus" NOT NULL DEFAULT 'PENDING',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "RepositoryProject_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "RepositoryProject" ADD CONSTRAINT "RepositoryProject_authorizationId_fkey" FOREIGN KEY ("authorizationId") REFERENCES "GitHubAppAuthorization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,15 @@
/*
Warnings:
- A unique constraint covering the columns `[name]` on the table `RepositoryProject` will be added. If there are existing duplicate values, this will fail.
- Added the required column `organizationId` to the `RepositoryProject` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "RepositoryProject" ADD COLUMN "organizationId" TEXT NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "RepositoryProject_name_key" ON "RepositoryProject"("name");
-- AddForeignKey
ALTER TABLE "RepositoryProject" ADD CONSTRAINT "RepositoryProject_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,12 @@
/*
Warnings:
- You are about to drop the column `dockerDefinitionUrl` on the `RepositoryProject` table. All the data in the column will be lost.
- Added the required column `dockerIgnore` to the `RepositoryProject` table without a default value. This is not possible if the table is not empty.
- Added the required column `dockerfile` to the `RepositoryProject` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "RepositoryProject" DROP COLUMN "dockerDefinitionUrl",
ADD COLUMN "dockerIgnore" TEXT NOT NULL,
ADD COLUMN "dockerfile" TEXT NOT NULL;
@@ -0,0 +1,10 @@
/*
Warnings:
- You are about to drop the column `dockerIgnore` on the `RepositoryProject` table. All the data in the column will be lost.
- You are about to drop the column `dockerfile` on the `RepositoryProject` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "RepositoryProject" DROP COLUMN "dockerIgnore",
DROP COLUMN "dockerfile";
@@ -0,0 +1,25 @@
-- CreateEnum
CREATE TYPE "ProjectDeploymentStatus" AS ENUM ('PENDING', 'DEPLOYING', 'DEPLOYED', 'ERROR');
-- CreateTable
CREATE TABLE "ProjectDeployment" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"environmentId" TEXT NOT NULL,
"status" "ProjectDeploymentStatus" NOT NULL DEFAULT 'PENDING',
"imageIdentifier" TEXT,
"branch" TEXT NOT NULL,
"commitHash" TEXT NOT NULL,
"commitMessage" TEXT NOT NULL,
"committer" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProjectDeployment_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "ProjectDeployment" ADD CONSTRAINT "ProjectDeployment_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "RepositoryProject"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProjectDeployment" ADD CONSTRAINT "ProjectDeployment_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "RuntimeEnvironment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "RepositoryProject" ADD COLUMN "statusText" TEXT;
@@ -0,0 +1,11 @@
/*
Warnings:
- A unique constraint covering the columns `[projectId,commitHash]` on the table `ProjectDeployment` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "ProjectDeployment" ADD COLUMN "error" JSONB;
-- CreateIndex
CREATE UNIQUE INDEX "ProjectDeployment_projectId_commitHash_key" ON "ProjectDeployment"("projectId", "commitHash");
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "ProjectDeployment" ADD COLUMN "buildDuration" INTEGER;
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "ProjectDeployment" ADD COLUMN "vmIdentifier" TEXT;
-- AlterTable
ALTER TABLE "RepositoryProject" ADD COLUMN "currentVMIdentifier" TEXT;
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "RepositoryProject" ADD COLUMN "currentDeploymentId" TEXT;
-- AddForeignKey
ALTER TABLE "RepositoryProject" ADD CONSTRAINT "RepositoryProject_currentDeploymentId_fkey" FOREIGN KEY ("currentDeploymentId") REFERENCES "ProjectDeployment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,15 @@
/*
Warnings:
- You are about to drop the column `buildDuration` on the `ProjectDeployment` table. All the data in the column will be lost.
- You are about to drop the column `imageIdentifier` on the `ProjectDeployment` table. All the data in the column will be lost.
- Added the required column `buildId` to the `ProjectDeployment` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "ProjectDeployment" DROP COLUMN "buildDuration",
DROP COLUMN "imageIdentifier",
ADD COLUMN "buildFinishedAt" TIMESTAMP(3),
ADD COLUMN "buildId" TEXT NOT NULL,
ADD COLUMN "buildStartedAt" TIMESTAMP(3),
ADD COLUMN "imageId" TEXT;
@@ -0,0 +1,8 @@
/*
Warnings:
- A unique constraint covering the columns `[buildId]` on the table `ProjectDeployment` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateIndex
CREATE UNIQUE INDEX "ProjectDeployment_buildId_key" ON "ProjectDeployment"("buildId");
@@ -0,0 +1,10 @@
/*
Warnings:
- Added the required column `dockerIgnore` to the `ProjectDeployment` table without a default value. This is not possible if the table is not empty.
- Added the required column `dockerfile` to the `ProjectDeployment` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "ProjectDeployment" ADD COLUMN "dockerIgnore" TEXT NOT NULL,
ADD COLUMN "dockerfile" TEXT NOT NULL;
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Workflow" ADD COLUMN "repositoryProjectId" TEXT;
-- AddForeignKey
ALTER TABLE "Workflow" ADD CONSTRAINT "Workflow_repositoryProjectId_fkey" FOREIGN KEY ("repositoryProjectId") REFERENCES "RepositoryProject"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,13 @@
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "ProjectDeploymentStatus" ADD VALUE 'BUILDING';
ALTER TYPE "ProjectDeploymentStatus" ADD VALUE 'CANCELLED';
-- AlterEnum
ALTER TYPE "RepositoryProjectStatus" ADD VALUE 'BUILDING';
@@ -0,0 +1,5 @@
-- AlterEnum
ALTER TYPE "ProjectDeploymentStatus" ADD VALUE 'STOPPED';
-- AlterTable
ALTER TABLE "ProjectDeployment" ADD COLUMN "stoppedAt" TIMESTAMP(3);
+93 -17
View File
@@ -46,16 +46,17 @@ model Organization {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users User[]
workflows Workflow[]
environments RuntimeEnvironment[]
apiConnections APIConnection[]
events TriggerEvent[]
externalSources ExternalSource[]
eventRules EventRule[]
schedulerSources SchedulerSource[]
internalSources InternalSource[]
templates OrganizationTemplate[]
users User[]
workflows Workflow[]
environments RuntimeEnvironment[]
apiConnections APIConnection[]
events TriggerEvent[]
externalSources ExternalSource[]
eventRules EventRule[]
schedulerSources SchedulerSource[]
internalSources InternalSource[]
templates OrganizationTemplate[]
repositoryProjects RepositoryProject[]
}
model APIConnection {
@@ -112,6 +113,7 @@ model RuntimeEnvironment {
eventRules EventRule[]
schedulerSources SchedulerSource[]
internalSources InternalSource[]
deployments ProjectDeployment[]
@@unique([organizationId, slug])
}
@@ -155,6 +157,9 @@ model Workflow {
organizationTemplate OrganizationTemplate? @relation(fields: [organizationTemplateId], references: [id], onDelete: Cascade, onUpdate: Cascade)
organizationTemplateId String?
repositoryProject RepositoryProject? @relation(fields: [repositoryProjectId], references: [id], onDelete: Cascade, onUpdate: Cascade)
repositoryProjectId String?
@@unique([organizationId, slug])
}
@@ -599,11 +604,11 @@ model GitHubAppAuthorization {
installationAccessTokenExpiresAt DateTime?
organizationTemplates OrganizationTemplate[]
repositories GitHubRepository[]
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
GitHubAppAuthorizationAttempt GitHubAppAuthorizationAttempt[]
repositoryProjects RepositoryProject[]
}
enum GitHubAccountType {
@@ -611,20 +616,92 @@ enum GitHubAccountType {
ORGANIZATION
}
model GitHubRepository {
model RepositoryProject {
id String @id @default(cuid())
name String
url String
name String @unique
url String
branch String @default("main")
authorization GitHubAppAuthorization @relation(fields: [authorizationId], references: [id], onDelete: Cascade, onUpdate: Cascade)
authorizationId String
template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade, onUpdate: Cascade)
templateId String?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade, onUpdate: Cascade)
organizationId String
buildCommand String
startCommand String
autoDeploy Boolean @default(true)
envVars Json
status RepositoryProjectStatus @default(PENDING)
statusText String?
currentVMIdentifier String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deployments ProjectDeployment[] @relation("repoProject")
currentDeployment ProjectDeployment? @relation(fields: [currentDeploymentId], references: [id], onDelete: Cascade, onUpdate: Cascade)
currentDeploymentId String?
workflows Workflow[]
}
enum RepositoryProjectStatus {
PENDING
BUILDING
DEPLOYING
DEPLOYED
ERROR
DISABLED
}
model ProjectDeployment {
id String @id @default(cuid())
project RepositoryProject @relation("repoProject", fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade)
projectId String
environment RuntimeEnvironment @relation(fields: [environmentId], references: [id], onDelete: Cascade, onUpdate: Cascade)
environmentId String
status ProjectDeploymentStatus @default(PENDING)
buildId String @unique
imageId String?
vmIdentifier String?
buildStartedAt DateTime?
buildFinishedAt DateTime?
stoppedAt DateTime?
dockerfile String
dockerIgnore String
branch String
commitHash String
commitMessage String
committer String
error Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
RepositoryProject RepositoryProject[]
@@unique([projectId, commitHash])
}
enum ProjectDeploymentStatus {
PENDING
BUILDING
DEPLOYING
DEPLOYED
CANCELLED
ERROR
STOPPED
}
model Template {
@@ -649,7 +726,6 @@ model Template {
isLive Boolean @default(true)
organizationTemplates OrganizationTemplate[]
repositories GitHubRepository[]
}
model OrganizationTemplate {
+14
View File
@@ -762,6 +762,7 @@ export class TriggerClient<TSchema extends z.ZodTypeAny> {
}
: undefined,
packageMetadata,
env: gatherEnvVars(process.env),
},
});
@@ -871,3 +872,16 @@ function safeGetRepoInfo() {
return;
}
}
// Get all env vars that are prefixed with TRIGGER_ (exccpt for TRIGGER_API_KEY)
function gatherEnvVars(env: NodeJS.ProcessEnv): Record<string, string> {
if (!env) {
return {};
}
const envVars = Object.entries(env)
.filter(([key]) => key.startsWith("TRIGGER_") && key !== "TRIGGER_API_KEY")
.map(([key, value]) => [key.replace("TRIGGER_", ""), `${value}`]);
return Object.fromEntries(envVars);
}
+49
View File
@@ -132,6 +132,7 @@ importers:
specifiers:
'@aws-sdk/client-s3': ^3.186.0
'@aws-sdk/s3-request-presigner': ^3.186.0
'@cakework/client': ^0.0.42
'@cfworker/json-schema': ^1.12.5
'@codemirror/autocomplete': ^6.3.1
'@codemirror/commands': ^6.1.2
@@ -152,6 +153,7 @@ importers:
'@octokit/types': ^9.0.0
'@octokit/webhooks': ^10.4.0
'@octokit/webhooks-methods': ^3.0.2
'@octokit/webhooks-types': ^6.10.0
'@prisma/client': ^4.3.0
'@react-email/head': ^0.0.2
'@remix-run/dev': v1.11.0
@@ -231,6 +233,7 @@ importers:
internal-bridge: workspace:*
internal-platform: workspace:*
internal-pulsar: workspace:*
intl-parse-accept-language: ^1.0.0
ioredis: ^5.2.4
isbot: ^3.6.5
javascript-time-ago: ^2.5.7
@@ -289,11 +292,13 @@ importers:
vite: ^3.1.4
vite-tsconfig-paths: ^3.5.1
vitest: ^0.23.4
yaml: ^2.2.1
zod: ^3.20.2
zod-error: ^1.1.0
dependencies:
'@aws-sdk/client-s3': 3.245.0
'@aws-sdk/s3-request-presigner': 3.245.0
'@cakework/client': 0.0.42
'@cfworker/json-schema': 1.12.5
'@codemirror/autocomplete': 6.4.0_czcfkg2f66rxeiodoti7r2gulu
'@codemirror/commands': 6.1.3
@@ -345,6 +350,7 @@ importers:
internal-bridge: link:../../packages/internal-bridge
internal-platform: link:../../packages/internal-platform
internal-pulsar: link:../../packages/internal-pulsar
intl-parse-accept-language: 1.0.0
ioredis: 5.2.4
isbot: 3.6.5
javascript-time-ago: 2.5.9
@@ -386,11 +392,13 @@ importers:
tiny-invariant: 1.3.1
tsx: 3.12.2
ulid: 2.3.0
yaml: 2.2.1
zod: 3.20.2
zod-error: 1.1.0
devDependencies:
'@faker-js/faker': 7.6.0
'@octokit/types': 9.0.0
'@octokit/webhooks-types': 6.10.0
'@remix-run/dev': 1.11.0_biqbaboplfbrettd7655fr4n2y
'@remix-run/eslint-config': 1.11.0_ol4nhuzbuflsbzk2mijpqykzba
'@swc/core': 1.3.26
@@ -3682,6 +3690,20 @@ packages:
resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==}
dev: true
/@cakework/client/0.0.42:
resolution: {integrity: sha512-vnL30mqCFwTYvOWb2WfEfO/XlrMDbfACYuenvmQo3aXumtgv23k6/jGCKbQKI9b/Wpoi7cHwx2Naod14TyRnzA==}
dependencies:
'@types/basic-auth': 1.1.3
'@types/url-join': 4.0.1
axios: 0.27.2
basic-auth: 2.0.1
buffer: 6.0.3
js-base64: 3.7.5
url-join: 4.0.1
transitivePeerDependencies:
- debug
dev: false
/@cfworker/json-schema/1.12.5:
resolution: {integrity: sha512-k+ungOs1TxSfNfmfOxkwaiNL2EgRQjhk2SRweMYoKkBJZUYsGsY+XYpe2KpuS9EyjZWjZhq8QvVM+gwi3g1+hQ==}
dev: false
@@ -5264,6 +5286,10 @@ packages:
engines: {node: '>= 14'}
dev: false
/@octokit/webhooks-types/6.10.0:
resolution: {integrity: sha512-lDNv83BeEyxxukdQ0UttiUXawk9+6DkdjjFtm2GFED+24IQhTVaoSbwV9vWWKONyGLzRmCQqZmoEWkDhkEmPlw==}
dev: true
/@octokit/webhooks-types/6.7.0:
resolution: {integrity: sha512-bykm7UkSnxmb2uhSfcLM1Pity/LQ6ZBSdzy9HU0vXjR+2g+tzlmRhXb7Go8oj0TlgO+vDrTivGXju6zkzOGKjA==}
dev: false
@@ -6213,6 +6239,12 @@ packages:
resolution: {integrity: sha512-XTIieEY+gvJ39ChLcB4If5zHtPxt3Syj5rgZR+e1ctpmK8NjPf0zFqsz4JpLJT0xla9GFDKjy8Cpu331nrmE1Q==}
dev: true
/@types/basic-auth/1.1.3:
resolution: {integrity: sha512-W3rv6J0IGlxqgE2eQ2pTb0gBjaGtejQpJ6uaCjz3UQ65+TFTPC5/lAE+POfx1YLdjtxvejJzsIAfd3MxWiVmfg==}
dependencies:
'@types/node': 18.14.0
dev: false
/@types/bcryptjs/2.4.2:
resolution: {integrity: sha512-LiMQ6EOPob/4yUL66SZzu6Yh77cbzJFYll+ZfaPiPPFswtIlA/Fs1MzdKYA7JApHU49zQTbJGX3PDmCpIdDBRQ==}
dev: true
@@ -6636,6 +6668,10 @@ packages:
resolution: {integrity: sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==}
dev: true
/@types/url-join/4.0.1:
resolution: {integrity: sha512-wDXw9LEEUHyV+7UWy7U315nrJGJ7p1BzaCxDpEoLr789Dk1WDVMMlf3iBfbG2F8NdWnYyFbtTxUn2ZNbm1Q4LQ==}
dev: false
/@types/uuid/9.0.0:
resolution: {integrity: sha512-kr90f+ERiQtKWMz5rP32ltJ/BtULDI5RVO0uavn1HQUOwjx0R1h0rnDYNL0CepF1zL5bSY6FISAfd9tOdDhU5Q==}
dev: true
@@ -12522,6 +12558,10 @@ packages:
engines: {node: '>=10'}
dev: true
/js-base64/3.7.5:
resolution: {integrity: sha512-3MEt5DTINKqfScXKfJFrRbxkrnk2AxPWGBL/ycjz4dK8iqiSJ06UxD8jh8xuh6p10TX4t2+7FsBYVxxQbMg+qA==}
dev: false
/js-beautify/1.14.7:
resolution: {integrity: sha512-5SOX1KXPFKx+5f6ZrPsIPEY7NwKeQz47n3jm2i+XeHx9MoRsfQenlOP13FQhWvg8JRS0+XLO6XYUQ2GX+q+T9A==}
engines: {node: '>=10'}
@@ -17784,6 +17824,10 @@ packages:
resolution: {integrity: sha512-Am1ousAhSLBeB9cG/7k7r2R0zj50uDRlZHPGbazid5s9rlF1F/QKYObEKSIunSjIOkJZqwRRLpvewjEkM7pSqg==}
deprecated: Please see https://github.com/lydell/urix#deprecated
/url-join/4.0.1:
resolution: {integrity: sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==}
dev: false
/url-parse-lax/3.0.0:
resolution: {integrity: sha512-NjFKA0DidqPa5ciFcSrXnAltTtzz84ogy+NebPvfEgAck0+TNg4UJ4IN+fB7zRZfbgUf0syOo9MDxFkDSMuFaQ==}
engines: {node: '>=4'}
@@ -18530,6 +18574,11 @@ packages:
engines: {node: '>= 6'}
dev: true
/yaml/2.2.1:
resolution: {integrity: sha512-e0WHiYql7+9wr4cWMx3TVQrNwejKaEe7/rHNmQmqRjazfOP5W8PB6Jpebb5o6fIapbz9o9+2ipcaTM2ZwDI6lw==}
engines: {node: '>= 14'}
dev: false
/yargs-parser/18.1.3:
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
engines: {node: '>=6'}
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
#
set -e -x
ngrok http --subdomain=${1:-$NGROK_SUBDOMAIN} --region ${2:-eu} 8889