Skip to content

Commit e16c43c

Browse files
committed
create repo if doesn't exist
1 parent aa24a9a commit e16c43c

File tree

6 files changed

+1111
-9
lines changed

6 files changed

+1111
-9
lines changed

apps/webapp/app/env.server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,8 @@ const EnvironmentSchema = z.object({
232232
DEPLOY_REGISTRY_USERNAME: z.string().optional(),
233233
DEPLOY_REGISTRY_PASSWORD: z.string().optional(),
234234
DEPLOY_REGISTRY_NAMESPACE: z.string().min(1).default("trigger"),
235+
DEPLOY_REGISTRY_ID: z.string().optional(),
236+
DEPLOY_REGISTRY_TAGS: z.string().optional(), // csv, for example: "key1=value1,key2=value2"
235237
DEPLOY_IMAGE_PLATFORM: z.string().default("linux/amd64"),
236238
DEPLOY_TIMEOUT_MS: z.coerce
237239
.number()
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import {
2+
ECRClient,
3+
CreateRepositoryCommand,
4+
DescribeRepositoriesCommand,
5+
type Repository,
6+
type Tag,
7+
RepositoryNotFoundException,
8+
} from "@aws-sdk/client-ecr";
9+
import { tryCatch } from "@trigger.dev/core";
10+
import { logger } from "~/services/logger.server";
11+
12+
export async function getDeploymentImageRef({
13+
host,
14+
namespace,
15+
projectRef,
16+
nextVersion,
17+
environmentSlug,
18+
registryId,
19+
registryTags,
20+
}: {
21+
host: string;
22+
namespace: string;
23+
projectRef: string;
24+
nextVersion: string;
25+
environmentSlug: string;
26+
registryId?: string;
27+
registryTags?: string;
28+
}): Promise<{
29+
imageRef: string;
30+
isEcr: boolean;
31+
}> {
32+
const repositoryName = `${namespace}/${projectRef}`;
33+
const imageRef = `${host}/${repositoryName}:${nextVersion}.${environmentSlug}`;
34+
35+
if (!isEcrRegistry(host)) {
36+
return {
37+
imageRef,
38+
isEcr: false,
39+
};
40+
}
41+
42+
const [ecrRepoError] = await tryCatch(
43+
ensureEcrRepositoryExists({ repositoryName, registryHost: host, registryId, registryTags })
44+
);
45+
46+
if (ecrRepoError) {
47+
logger.error("Failed to ensure ECR repository exists", {
48+
repositoryName,
49+
host,
50+
ecrRepoError: ecrRepoError.message,
51+
});
52+
throw ecrRepoError;
53+
}
54+
55+
return {
56+
imageRef,
57+
isEcr: true,
58+
};
59+
}
60+
61+
function isEcrRegistry(registryHost: string) {
62+
return registryHost.includes("amazonaws.com");
63+
}
64+
65+
function parseRegistryTags(tags: string): Tag[] {
66+
return tags.split(",").map((tag) => {
67+
const [key, value] = tag.split("=");
68+
return { Key: key, Value: value };
69+
});
70+
}
71+
72+
async function createEcrRepository({
73+
repositoryName,
74+
region,
75+
registryId,
76+
registryTags,
77+
}: {
78+
repositoryName: string;
79+
region: string;
80+
registryId?: string;
81+
registryTags?: string;
82+
}): Promise<Repository> {
83+
const ecr = new ECRClient({ region });
84+
85+
const result = await ecr.send(
86+
new CreateRepositoryCommand({
87+
repositoryName,
88+
imageTagMutability: "IMMUTABLE",
89+
encryptionConfiguration: {
90+
encryptionType: "AES256",
91+
},
92+
registryId,
93+
tags: registryTags ? parseRegistryTags(registryTags) : undefined,
94+
})
95+
);
96+
97+
if (!result.repository) {
98+
logger.error("Failed to create ECR repository", { repositoryName, result });
99+
throw new Error(`Failed to create ECR repository: ${repositoryName}`);
100+
}
101+
102+
return result.repository;
103+
}
104+
105+
async function getEcrRepository({
106+
repositoryName,
107+
region,
108+
registryId,
109+
}: {
110+
repositoryName: string;
111+
region: string;
112+
registryId?: string;
113+
}): Promise<Repository | undefined> {
114+
const ecr = new ECRClient({ region });
115+
116+
try {
117+
const result = await ecr.send(
118+
new DescribeRepositoriesCommand({
119+
repositoryNames: [repositoryName],
120+
registryId,
121+
})
122+
);
123+
124+
if (!result.repositories || result.repositories.length === 0) {
125+
logger.debug("ECR repository not found", { repositoryName, region, result });
126+
return undefined;
127+
}
128+
129+
return result.repositories[0];
130+
} catch (error) {
131+
if (error instanceof RepositoryNotFoundException) {
132+
logger.debug("ECR repository not found: RepositoryNotFoundException", {
133+
repositoryName,
134+
region,
135+
});
136+
return undefined;
137+
}
138+
throw error;
139+
}
140+
}
141+
142+
export function getEcrRegion(registryHost: string): string | undefined {
143+
const parts = registryHost.split(".");
144+
if (parts.length !== 6 || parts[1] !== "dkr" || parts[2] !== "ecr") {
145+
return undefined;
146+
}
147+
return parts[3];
148+
}
149+
150+
async function ensureEcrRepositoryExists({
151+
repositoryName,
152+
registryHost,
153+
registryId,
154+
registryTags,
155+
}: {
156+
repositoryName: string;
157+
registryHost: string;
158+
registryId?: string;
159+
registryTags?: string;
160+
}): Promise<Repository> {
161+
const region = getEcrRegion(registryHost);
162+
163+
if (!region) {
164+
throw new Error(`Invalid ECR registry host: ${registryHost}`);
165+
}
166+
167+
const [getRepoError, existingRepo] = await tryCatch(
168+
getEcrRepository({ repositoryName, region, registryId })
169+
);
170+
171+
if (getRepoError) {
172+
logger.error("Failed to get ECR repository", { repositoryName, region, getRepoError });
173+
throw getRepoError;
174+
}
175+
176+
if (existingRepo) {
177+
logger.debug("ECR repository already exists", { repositoryName, region, existingRepo });
178+
return existingRepo;
179+
}
180+
181+
const [createRepoError, newRepo] = await tryCatch(
182+
createEcrRepository({ repositoryName, region, registryId, registryTags })
183+
);
184+
185+
if (createRepoError) {
186+
logger.error("Failed to create ECR repository", { repositoryName, region, createRepoError });
187+
throw createRepoError;
188+
}
189+
190+
if (newRepo.repositoryName !== repositoryName) {
191+
logger.error("ECR repository name mismatch", { repositoryName, region, newRepo });
192+
throw new Error(
193+
`ECR repository name mismatch: ${repositoryName} !== ${newRepo.repositoryName}`
194+
);
195+
}
196+
197+
return newRepo;
198+
}

apps/webapp/app/v3/services/initializeDeployment.server.ts

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { type InitializeDeploymentRequestBody } from "@trigger.dev/core/v3";
2-
import { WorkerDeploymentType } from "@trigger.dev/database";
32
import { customAlphabet } from "nanoid";
43
import { env } from "~/env.server";
54
import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
@@ -9,6 +8,8 @@ import { createRemoteImageBuild, remoteBuildsEnabled } from "../remoteImageBuild
98
import { calculateNextBuildVersion } from "../utils/calculateNextBuildVersion";
109
import { BaseService, ServiceValidationError } from "./baseService.server";
1110
import { TimeoutDeploymentService } from "./timeoutDeployment.server";
11+
import { getDeploymentImageRef } from "../getDeploymentImageRef.server";
12+
import { tryCatch } from "@trigger.dev/core";
1213

1314
const nanoid = customAlphabet("1234567890abcdefghijklmnopqrstuvwxyz", 8);
1415

@@ -68,11 +69,31 @@ export class InitializeDeploymentService extends BaseService {
6869
})
6970
: undefined;
7071

71-
const imageRef = [
72-
env.DEPLOY_REGISTRY_HOST,
73-
env.DEPLOY_REGISTRY_NAMESPACE,
74-
`${environment.project.externalRef}:${nextVersion}.${environment.slug}`,
75-
].join("/");
72+
const [imageRefError, imageRefResult] = await tryCatch(
73+
getDeploymentImageRef({
74+
host: env.DEPLOY_REGISTRY_HOST,
75+
namespace: env.DEPLOY_REGISTRY_NAMESPACE,
76+
projectRef: environment.project.externalRef,
77+
nextVersion,
78+
environmentSlug: environment.slug,
79+
registryId: env.DEPLOY_REGISTRY_ID,
80+
registryTags: env.DEPLOY_REGISTRY_TAGS,
81+
})
82+
);
83+
84+
if (imageRefError) {
85+
logger.error("Failed to get deployment image ref", {
86+
environmentId: environment.id,
87+
projectId: environment.projectId,
88+
version: nextVersion,
89+
triggeredById: triggeredBy?.id,
90+
type: payload.type,
91+
cause: imageRefError.message,
92+
});
93+
throw new ServiceValidationError("Failed to get deployment image ref");
94+
}
95+
96+
const { imageRef, isEcr } = imageRefResult;
7697

7798
logger.debug("Creating deployment", {
7899
environmentId: environment.id,
@@ -81,6 +102,7 @@ export class InitializeDeploymentService extends BaseService {
81102
triggeredById: triggeredBy?.id,
82103
type: payload.type,
83104
imageRef,
105+
isEcr,
84106
});
85107

86108
const deployment = await this._prisma.workerDeployment.create({

apps/webapp/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"dependencies": {
3434
"@ariakit/react": "^0.4.6",
3535
"@ariakit/react-core": "^0.4.6",
36+
"@aws-sdk/client-ecr": "^3.839.0",
3637
"@aws-sdk/client-sqs": "^3.445.0",
3738
"@codemirror/autocomplete": "^6.3.1",
3839
"@codemirror/commands": "^6.1.2",
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { describe, expect, it } from "vitest";
2+
import { getDeploymentImageRef, getEcrRegion } from "../app/v3/getDeploymentImageRef.server";
3+
import { ECRClient, DeleteRepositoryCommand } from "@aws-sdk/client-ecr";
4+
5+
describe.skipIf(process.env.RUN_REGISTRY_TESTS !== "1")("getDeploymentImageRef", () => {
6+
const testHost = "123456789012.dkr.ecr.us-east-1.amazonaws.com";
7+
const testNamespace = "test-namespace";
8+
const testProjectRef = "test-project-" + Math.random().toString(36).substring(7);
9+
10+
const registryId = process.env.DEPLOY_REGISTRY_ID;
11+
const registryTags = "test=test,test2=test2";
12+
13+
// Clean up test repository after tests
14+
afterAll(async () => {
15+
if (!registryId) {
16+
return;
17+
}
18+
19+
if (process.env.KEEP_TEST_REPO === "1") {
20+
return;
21+
}
22+
23+
try {
24+
const region = getEcrRegion(testHost);
25+
const ecr = new ECRClient({ region });
26+
await ecr.send(
27+
new DeleteRepositoryCommand({
28+
repositoryName: `${testNamespace}/${testProjectRef}`,
29+
registryId,
30+
force: true,
31+
})
32+
);
33+
} catch (error) {
34+
console.warn("Failed to delete test repository:", error);
35+
}
36+
});
37+
38+
it("should return the correct image ref for non-ECR registry", async () => {
39+
const imageRef = await getDeploymentImageRef({
40+
host: "registry.digitalocean.com",
41+
namespace: testNamespace,
42+
projectRef: testProjectRef,
43+
nextVersion: "20250630.1",
44+
environmentSlug: "test",
45+
registryId,
46+
registryTags,
47+
});
48+
49+
expect(imageRef.imageRef).toBe(
50+
`registry.digitalocean.com/${testNamespace}/${testProjectRef}:20250630.1.test`
51+
);
52+
expect(imageRef.isEcr).toBe(false);
53+
});
54+
55+
it("should create ECR repository and return correct image ref", async () => {
56+
const imageRef = await getDeploymentImageRef({
57+
host: testHost,
58+
namespace: testNamespace,
59+
projectRef: testProjectRef,
60+
nextVersion: "20250630.1",
61+
environmentSlug: "test",
62+
registryId,
63+
registryTags,
64+
});
65+
66+
expect(imageRef.imageRef).toBe(
67+
`${testHost}/${testNamespace}/${testProjectRef}:20250630.1.test`
68+
);
69+
expect(imageRef.isEcr).toBe(true);
70+
});
71+
72+
it("should reuse existing ECR repository", async () => {
73+
// This should use the repository created in the previous test
74+
const imageRef = await getDeploymentImageRef({
75+
host: testHost,
76+
namespace: testNamespace,
77+
projectRef: testProjectRef,
78+
nextVersion: "20250630.2",
79+
environmentSlug: "prod",
80+
registryId,
81+
registryTags,
82+
});
83+
84+
expect(imageRef.imageRef).toBe(
85+
`${testHost}/${testNamespace}/${testProjectRef}:20250630.2.prod`
86+
);
87+
expect(imageRef.isEcr).toBe(true);
88+
});
89+
90+
it("should throw error for invalid ECR host", async () => {
91+
await expect(
92+
getDeploymentImageRef({
93+
host: "invalid.ecr.amazonaws.com",
94+
namespace: testNamespace,
95+
projectRef: testProjectRef,
96+
nextVersion: "20250630.1",
97+
environmentSlug: "test",
98+
registryId,
99+
registryTags,
100+
})
101+
).rejects.toThrow("Invalid ECR registry host: invalid.ecr.amazonaws.com");
102+
});
103+
});

0 commit comments

Comments
 (0)