diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index eba67df32d..6a78a1cc95 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -230,7 +230,7 @@ const EnvironmentSchema = z.object({ DEPOT_ORG_ID: z.string().optional(), DEPOT_REGION: z.string().default("us-east-1"), - // Deployment registry + // Deployment registry (v3) DEPLOY_REGISTRY_HOST: z.string().min(1), DEPLOY_REGISTRY_USERNAME: z.string().optional(), DEPLOY_REGISTRY_PASSWORD: z.string().optional(), @@ -238,6 +238,39 @@ const EnvironmentSchema = z.object({ DEPLOY_REGISTRY_ECR_TAGS: z.string().optional(), // csv, for example: "key1=value1,key2=value2" DEPLOY_REGISTRY_ECR_ASSUME_ROLE_ARN: z.string().optional(), DEPLOY_REGISTRY_ECR_ASSUME_ROLE_EXTERNAL_ID: z.string().optional(), + + // Deployment registry (v4) - falls back to v3 registry if not specified + V4_DEPLOY_REGISTRY_HOST: z + .string() + .optional() + .transform((v) => v ?? process.env.DEPLOY_REGISTRY_HOST) + .pipe(z.string().min(1)), // Ensure final type is required string + V4_DEPLOY_REGISTRY_USERNAME: z + .string() + .optional() + .transform((v) => v ?? process.env.DEPLOY_REGISTRY_USERNAME), + V4_DEPLOY_REGISTRY_PASSWORD: z + .string() + .optional() + .transform((v) => v ?? process.env.DEPLOY_REGISTRY_PASSWORD), + V4_DEPLOY_REGISTRY_NAMESPACE: z + .string() + .optional() + .transform((v) => v ?? process.env.DEPLOY_REGISTRY_NAMESPACE) + .pipe(z.string().min(1).default("trigger")), // Ensure final type is required string + V4_DEPLOY_REGISTRY_ECR_TAGS: z + .string() + .optional() + .transform((v) => v ?? process.env.DEPLOY_REGISTRY_ECR_TAGS), + V4_DEPLOY_REGISTRY_ECR_ASSUME_ROLE_ARN: z + .string() + .optional() + .transform((v) => v ?? process.env.DEPLOY_REGISTRY_ECR_ASSUME_ROLE_ARN), + V4_DEPLOY_REGISTRY_ECR_ASSUME_ROLE_EXTERNAL_ID: z + .string() + .optional() + .transform((v) => v ?? process.env.DEPLOY_REGISTRY_ECR_ASSUME_ROLE_EXTERNAL_ID), + DEPLOY_IMAGE_PLATFORM: z.string().default("linux/amd64"), DEPLOY_TIMEOUT_MS: z.coerce .number() diff --git a/apps/webapp/app/v3/getDeploymentImageRef.server.ts b/apps/webapp/app/v3/getDeploymentImageRef.server.ts index c6610146ea..e01f340a26 100644 --- a/apps/webapp/app/v3/getDeploymentImageRef.server.ts +++ b/apps/webapp/app/v3/getDeploymentImageRef.server.ts @@ -10,6 +10,7 @@ import { import { STSClient, AssumeRoleCommand } from "@aws-sdk/client-sts"; import { tryCatch } from "@trigger.dev/core"; import { logger } from "~/services/logger.server"; +import { type RegistryConfig } from "./registryConfig.server"; // Optional configuration for cross-account access export type AssumeRoleConfig = { @@ -97,30 +98,24 @@ export async function createEcrClient({ } export async function getDeploymentImageRef({ - host, - namespace, + registry, projectRef, nextVersion, environmentSlug, - registryTags, - assumeRole, }: { - host: string; - namespace: string; + registry: RegistryConfig; projectRef: string; nextVersion: string; environmentSlug: string; - registryTags?: string; - assumeRole?: AssumeRoleConfig; }): Promise<{ imageRef: string; isEcr: boolean; repoCreated: boolean; }> { - const repositoryName = `${namespace}/${projectRef}`; - const imageRef = `${host}/${repositoryName}:${nextVersion}.${environmentSlug}`; + const repositoryName = `${registry.namespace}/${projectRef}`; + const imageRef = `${registry.host}/${repositoryName}:${nextVersion}.${environmentSlug}`; - if (!isEcrRegistry(host)) { + if (!isEcrRegistry(registry.host)) { return { imageRef, isEcr: false, @@ -131,16 +126,19 @@ export async function getDeploymentImageRef({ const [ecrRepoError, ecrData] = await tryCatch( ensureEcrRepositoryExists({ repositoryName, - registryHost: host, - registryTags, - assumeRole, + registryHost: registry.host, + registryTags: registry.ecrTags, + assumeRole: { + roleArn: registry.ecrAssumeRoleArn, + externalId: registry.ecrAssumeRoleExternalId, + }, }) ); if (ecrRepoError) { logger.error("Failed to ensure ECR repository exists", { repositoryName, - host, + host: registry.host, ecrRepoError: ecrRepoError.message, }); throw ecrRepoError; diff --git a/apps/webapp/app/v3/registryConfig.server.ts b/apps/webapp/app/v3/registryConfig.server.ts new file mode 100644 index 0000000000..72e2abdaee --- /dev/null +++ b/apps/webapp/app/v3/registryConfig.server.ts @@ -0,0 +1,35 @@ +import { env } from "~/env.server"; + +export type RegistryConfig = { + host: string; + username?: string; + password?: string; + namespace: string; + ecrTags?: string; + ecrAssumeRoleArn?: string; + ecrAssumeRoleExternalId?: string; +}; + +export function getRegistryConfig(isV4Deployment: boolean): RegistryConfig { + if (isV4Deployment) { + return { + host: env.V4_DEPLOY_REGISTRY_HOST, + username: env.V4_DEPLOY_REGISTRY_USERNAME, + password: env.V4_DEPLOY_REGISTRY_PASSWORD, + namespace: env.V4_DEPLOY_REGISTRY_NAMESPACE, + ecrTags: env.V4_DEPLOY_REGISTRY_ECR_TAGS, + ecrAssumeRoleArn: env.V4_DEPLOY_REGISTRY_ECR_ASSUME_ROLE_ARN, + ecrAssumeRoleExternalId: env.V4_DEPLOY_REGISTRY_ECR_ASSUME_ROLE_EXTERNAL_ID, + }; + } + + return { + host: env.DEPLOY_REGISTRY_HOST, + username: env.DEPLOY_REGISTRY_USERNAME, + password: env.DEPLOY_REGISTRY_PASSWORD, + namespace: env.DEPLOY_REGISTRY_NAMESPACE, + ecrTags: env.DEPLOY_REGISTRY_ECR_TAGS, + ecrAssumeRoleArn: env.DEPLOY_REGISTRY_ECR_ASSUME_ROLE_ARN, + ecrAssumeRoleExternalId: env.DEPLOY_REGISTRY_ECR_ASSUME_ROLE_EXTERNAL_ID, + }; +} diff --git a/apps/webapp/app/v3/services/finalizeDeploymentV2.server.ts b/apps/webapp/app/v3/services/finalizeDeploymentV2.server.ts index 993f4b6e2c..4bf0305e6f 100644 --- a/apps/webapp/app/v3/services/finalizeDeploymentV2.server.ts +++ b/apps/webapp/app/v3/services/finalizeDeploymentV2.server.ts @@ -1,5 +1,8 @@ -import { ExternalBuildData, FinalizeDeploymentRequestBody } from "@trigger.dev/core/v3/schemas"; -import { AuthenticatedEnvironment } from "~/services/apiAuth.server"; +import { + ExternalBuildData, + type FinalizeDeploymentRequestBody, +} from "@trigger.dev/core/v3/schemas"; +import type { AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; import { BaseService, ServiceValidationError } from "./baseService.server"; import { join } from "node:path"; @@ -11,6 +14,7 @@ import { FinalizeDeploymentService } from "./finalizeDeployment.server"; import { remoteBuildsEnabled } from "../remoteImageBuilder.server"; import { getEcrAuthToken, isEcrRegistry } from "../getDeploymentImageRef.server"; import { tryCatch } from "@trigger.dev/core"; +import { getRegistryConfig, type RegistryConfig } from "../registryConfig.server"; export class FinalizeDeploymentV2Service extends BaseService { public async call( @@ -37,6 +41,7 @@ export class FinalizeDeploymentV2Service extends BaseService { externalBuildData: true, environment: true, imageReference: true, + type: true, worker: { select: { project: true, @@ -78,10 +83,13 @@ export class FinalizeDeploymentV2Service extends BaseService { throw new ServiceValidationError("External build data is invalid"); } + const isV4Deployment = deployment.type === "MANAGED"; + const registryConfig = getRegistryConfig(isV4Deployment); + + // For non-ECR registries, username and password are required upfront if ( - !env.DEPLOY_REGISTRY_HOST || - !env.DEPLOY_REGISTRY_USERNAME || - !env.DEPLOY_REGISTRY_PASSWORD + !isEcrRegistry(registryConfig.host) && + (!registryConfig.username || !registryConfig.password) ) { throw new ServiceValidationError("Missing deployment registry credentials"); } @@ -104,12 +112,7 @@ export class FinalizeDeploymentV2Service extends BaseService { orgToken: env.DEPOT_TOKEN, projectId: externalBuildData.data.projectId, }, - registry: { - host: env.DEPLOY_REGISTRY_HOST, - namespace: env.DEPLOY_REGISTRY_NAMESPACE, - username: env.DEPLOY_REGISTRY_USERNAME, - password: env.DEPLOY_REGISTRY_PASSWORD, - }, + registry: registryConfig, deployment: { version: deployment.version, environmentSlug: deployment.environment.slug, @@ -144,12 +147,7 @@ type ExecutePushToRegistryOptions = { orgToken: string; projectId: string; }; - registry: { - host: string; - namespace: string; - username: string; - password: string; - }; + registry: RegistryConfig; deployment: { version: string; environmentSlug: string; @@ -175,12 +173,7 @@ async function executePushToRegistry( writer?: WritableStreamDefaultWriter ): Promise { // Step 1: We need to "login" to the registry - const [loginError, configDir] = await tryCatch( - ensureLoggedIntoDockerRegistry(registry.host, { - username: registry.username, - password: registry.password, - }) - ); + const [loginError, configDir] = await tryCatch(ensureLoggedIntoDockerRegistry(registry)); if (loginError) { logger.error("Failed to login to registry", { @@ -260,31 +253,35 @@ async function executePushToRegistry( } } -async function ensureLoggedIntoDockerRegistry( - registryHost: string, - auth: { username: string; password: string } | undefined = undefined -) { +async function ensureLoggedIntoDockerRegistry(registryConfig: RegistryConfig) { const tmpDir = await createTempDir(); const dockerConfigPath = join(tmpDir, "config.json"); + let auth: { username: string; password: string }; + // If this is an ECR registry, get fresh credentials - if (isEcrRegistry(registryHost)) { + if (isEcrRegistry(registryConfig.host)) { auth = await getEcrAuthToken({ - registryHost, - assumeRole: env.DEPLOY_REGISTRY_ECR_ASSUME_ROLE_ARN + registryHost: registryConfig.host, + assumeRole: registryConfig.ecrAssumeRoleArn ? { - roleArn: env.DEPLOY_REGISTRY_ECR_ASSUME_ROLE_ARN, - externalId: env.DEPLOY_REGISTRY_ECR_ASSUME_ROLE_EXTERNAL_ID, + roleArn: registryConfig.ecrAssumeRoleArn, + externalId: registryConfig.ecrAssumeRoleExternalId, } : undefined, }); - } else if (!auth) { + } else if (!registryConfig.username || !registryConfig.password) { throw new Error("Authentication required for non-ECR registry"); + } else { + auth = { + username: registryConfig.username, + password: registryConfig.password, + }; } await writeJSONFile(dockerConfigPath, { auths: { - [registryHost]: { + [registryConfig.host]: { auth: Buffer.from(`${auth.username}:${auth.password}`).toString("base64"), }, }, diff --git a/apps/webapp/app/v3/services/initializeDeployment.server.ts b/apps/webapp/app/v3/services/initializeDeployment.server.ts index 813b48af58..cdd174ed92 100644 --- a/apps/webapp/app/v3/services/initializeDeployment.server.ts +++ b/apps/webapp/app/v3/services/initializeDeployment.server.ts @@ -10,6 +10,7 @@ import { BaseService, ServiceValidationError } from "./baseService.server"; import { TimeoutDeploymentService } from "./timeoutDeployment.server"; import { getDeploymentImageRef } from "../getDeploymentImageRef.server"; import { tryCatch } from "@trigger.dev/core"; +import { getRegistryConfig } from "../registryConfig.server"; const nanoid = customAlphabet("1234567890abcdefghijklmnopqrstuvwxyz", 8); @@ -69,20 +70,15 @@ export class InitializeDeploymentService extends BaseService { }) : undefined; + const isV4Deployment = payload.type === "MANAGED"; + const registryConfig = getRegistryConfig(isV4Deployment); + const [imageRefError, imageRefResult] = await tryCatch( getDeploymentImageRef({ - host: env.DEPLOY_REGISTRY_HOST, - namespace: env.DEPLOY_REGISTRY_NAMESPACE, + registry: registryConfig, projectRef: environment.project.externalRef, nextVersion, environmentSlug: environment.slug, - registryTags: env.DEPLOY_REGISTRY_ECR_TAGS, - assumeRole: env.DEPLOY_REGISTRY_ECR_ASSUME_ROLE_ARN - ? { - roleArn: env.DEPLOY_REGISTRY_ECR_ASSUME_ROLE_ARN, - externalId: env.DEPLOY_REGISTRY_ECR_ASSUME_ROLE_EXTERNAL_ID, - } - : undefined, }) ); diff --git a/apps/webapp/test/getDeploymentImageRef.test.ts b/apps/webapp/test/getDeploymentImageRef.test.ts index 01a04c53a0..2810c14435 100644 --- a/apps/webapp/test/getDeploymentImageRef.test.ts +++ b/apps/webapp/test/getDeploymentImageRef.test.ts @@ -56,13 +56,18 @@ describe.skipIf(process.env.RUN_REGISTRY_TESTS !== "1")("getDeploymentImageRef", it("should return the correct image ref for non-ECR registry", async () => { const imageRef = await getDeploymentImageRef({ - host: "registry.digitalocean.com", - namespace: testNamespace, + registry: { + host: "registry.digitalocean.com", + namespace: testNamespace, + username: "test-user", + password: "test-pass", + ecrTags: registryTags, + ecrAssumeRoleArn: roleArn, + ecrAssumeRoleExternalId: externalId, + }, projectRef: testProjectRef, nextVersion: "20250630.1", environmentSlug: "test", - registryTags, - assumeRole, }); expect(imageRef.imageRef).toBe( @@ -73,13 +78,18 @@ describe.skipIf(process.env.RUN_REGISTRY_TESTS !== "1")("getDeploymentImageRef", it("should create ECR repository and return correct image ref", async () => { const imageRef1 = await getDeploymentImageRef({ - host: testHost, - namespace: testNamespace, + registry: { + host: testHost, + namespace: testNamespace, + username: "test-user", + password: "test-pass", + ecrTags: registryTags, + ecrAssumeRoleArn: roleArn, + ecrAssumeRoleExternalId: externalId, + }, projectRef: testProjectRef2, nextVersion: "20250630.1", environmentSlug: "test", - registryTags, - assumeRole, }); expect(imageRef1.imageRef).toBe( @@ -89,13 +99,18 @@ describe.skipIf(process.env.RUN_REGISTRY_TESTS !== "1")("getDeploymentImageRef", expect(imageRef1.repoCreated).toBe(true); const imageRef2 = await getDeploymentImageRef({ - host: testHost, - namespace: testNamespace, + registry: { + host: testHost, + namespace: testNamespace, + username: "test-user", + password: "test-pass", + ecrTags: registryTags, + ecrAssumeRoleArn: roleArn, + ecrAssumeRoleExternalId: externalId, + }, projectRef: testProjectRef2, nextVersion: "20250630.2", environmentSlug: "test", - registryTags, - assumeRole, }); expect(imageRef2.imageRef).toBe( @@ -108,13 +123,18 @@ describe.skipIf(process.env.RUN_REGISTRY_TESTS !== "1")("getDeploymentImageRef", it("should reuse existing ECR repository", async () => { // This should use the repository created in the previous test const imageRef = await getDeploymentImageRef({ - host: testHost, - namespace: testNamespace, + registry: { + host: testHost, + namespace: testNamespace, + username: "test-user", + password: "test-pass", + ecrTags: registryTags, + ecrAssumeRoleArn: roleArn, + ecrAssumeRoleExternalId: externalId, + }, projectRef: testProjectRef, nextVersion: "20250630.2", environmentSlug: "prod", - registryTags, - assumeRole, }); expect(imageRef.imageRef).toBe( @@ -126,13 +146,18 @@ describe.skipIf(process.env.RUN_REGISTRY_TESTS !== "1")("getDeploymentImageRef", it("should throw error for invalid ECR host", async () => { await expect( getDeploymentImageRef({ - host: "invalid.ecr.amazonaws.com", - namespace: testNamespace, + registry: { + host: "invalid.ecr.amazonaws.com", + namespace: testNamespace, + username: "test-user", + password: "test-pass", + ecrTags: registryTags, + ecrAssumeRoleArn: roleArn, + ecrAssumeRoleExternalId: externalId, + }, projectRef: testProjectRef, nextVersion: "20250630.1", environmentSlug: "test", - registryTags, - assumeRole, }) ).rejects.toThrow("Invalid ECR registry host: invalid.ecr.amazonaws.com"); }); diff --git a/apps/webapp/test/registryConfig.test.ts b/apps/webapp/test/registryConfig.test.ts new file mode 100644 index 0000000000..13d56bbb39 --- /dev/null +++ b/apps/webapp/test/registryConfig.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +describe("getRegistryConfig", () => { + // Base env with all required fields to make env.server.ts happy + const baseEnv = { + NODE_ENV: "test" as const, + DATABASE_URL: "postgresql://test:test@localhost:5432/test", + DIRECT_URL: "postgresql://test:test@localhost:5432/test", + SESSION_SECRET: "test-session-secret", + MAGIC_LINK_SECRET: "test-magic-link-secret", + ENCRYPTION_KEY: "test-encryption-key", + CLICKHOUSE_URL: "http://localhost:8123", + }; + + beforeEach(() => { + // Reset modules to ensure fresh imports + vi.resetModules(); + }); + + it("should return v3 config for non-v4 deployments", async () => { + // Set up v3 env vars + process.env = { + ...baseEnv, + DEPLOY_REGISTRY_HOST: "v3-host.example.com", + DEPLOY_REGISTRY_USERNAME: "v3-user", + DEPLOY_REGISTRY_PASSWORD: "v3-password", + DEPLOY_REGISTRY_NAMESPACE: "v3-namespace", + DEPLOY_REGISTRY_ECR_TAGS: "env=v3,version=3", + DEPLOY_REGISTRY_ECR_ASSUME_ROLE_ARN: "arn:aws:iam::123456789012:role/v3-role", + DEPLOY_REGISTRY_ECR_ASSUME_ROLE_EXTERNAL_ID: "v3-external-id", + }; + + const { getRegistryConfig } = await import("../app/v3/registryConfig.server"); + const config = getRegistryConfig(false); + + expect(config).toEqual({ + host: "v3-host.example.com", + username: "v3-user", + password: "v3-password", + namespace: "v3-namespace", + ecrTags: "env=v3,version=3", + ecrAssumeRoleArn: "arn:aws:iam::123456789012:role/v3-role", + ecrAssumeRoleExternalId: "v3-external-id", + }); + }); + + it("should return v4 config for v4 deployments when V4 vars are set", async () => { + // Set up v3 + v4 env vars + process.env = { + ...baseEnv, + DEPLOY_REGISTRY_HOST: "v3-host.example.com", + DEPLOY_REGISTRY_USERNAME: "v3-user", + DEPLOY_REGISTRY_PASSWORD: "v3-password", + DEPLOY_REGISTRY_NAMESPACE: "v3-namespace", + DEPLOY_REGISTRY_ECR_TAGS: "env=v3,version=3", + DEPLOY_REGISTRY_ECR_ASSUME_ROLE_ARN: "arn:aws:iam::123456789012:role/v3-role", + DEPLOY_REGISTRY_ECR_ASSUME_ROLE_EXTERNAL_ID: "v3-external-id", + + V4_DEPLOY_REGISTRY_HOST: "v4-host.example.com", + V4_DEPLOY_REGISTRY_USERNAME: "v4-user", + V4_DEPLOY_REGISTRY_PASSWORD: "v4-password", + V4_DEPLOY_REGISTRY_NAMESPACE: "v4-namespace", + V4_DEPLOY_REGISTRY_ECR_TAGS: "env=v4,version=4", + V4_DEPLOY_REGISTRY_ECR_ASSUME_ROLE_ARN: "arn:aws:iam::456789012345:role/v4-role", + V4_DEPLOY_REGISTRY_ECR_ASSUME_ROLE_EXTERNAL_ID: "v4-external-id", + }; + + const { getRegistryConfig } = await import("../app/v3/registryConfig.server"); + const config = getRegistryConfig(true); + + expect(config).toEqual({ + host: "v4-host.example.com", + username: "v4-user", + password: "v4-password", + namespace: "v4-namespace", + ecrTags: "env=v4,version=4", + ecrAssumeRoleArn: "arn:aws:iam::456789012345:role/v4-role", + ecrAssumeRoleExternalId: "v4-external-id", + }); + }); + + it("should fallback to v3 config when V4 vars are not set", async () => { + // Set up only v3 env vars (no v4 vars) + process.env = { + ...baseEnv, + DEPLOY_REGISTRY_HOST: "v3-only-host.example.com", + DEPLOY_REGISTRY_USERNAME: "v3-only-user", + DEPLOY_REGISTRY_PASSWORD: "v3-only-password", + DEPLOY_REGISTRY_NAMESPACE: "v3-only-namespace", + DEPLOY_REGISTRY_ECR_TAGS: "env=v3only", + DEPLOY_REGISTRY_ECR_ASSUME_ROLE_ARN: "arn:aws:iam::111111111111:role/v3-only-role", + DEPLOY_REGISTRY_ECR_ASSUME_ROLE_EXTERNAL_ID: "v3-only-external-id", + // V4 vars not set - should fallback to v3 via transform + }; + + const { getRegistryConfig } = await import("../app/v3/registryConfig.server"); + const config = getRegistryConfig(true); + + expect(config).toEqual({ + host: "v3-only-host.example.com", + username: "v3-only-user", + password: "v3-only-password", + namespace: "v3-only-namespace", + ecrTags: "env=v3only", + ecrAssumeRoleArn: "arn:aws:iam::111111111111:role/v3-only-role", + ecrAssumeRoleExternalId: "v3-only-external-id", + }); + }); + + it("should handle partial v4 config with mixed fallbacks", async () => { + // Set up v3 vars + only some v4 vars + process.env = { + ...baseEnv, + DEPLOY_REGISTRY_HOST: "v3-mixed-host.example.com", + DEPLOY_REGISTRY_USERNAME: "v3-mixed-user", + DEPLOY_REGISTRY_PASSWORD: "v3-mixed-password", + DEPLOY_REGISTRY_NAMESPACE: "v3-mixed-namespace", + DEPLOY_REGISTRY_ECR_TAGS: "env=v3mixed", + DEPLOY_REGISTRY_ECR_ASSUME_ROLE_ARN: "arn:aws:iam::222222222222:role/v3-mixed-role", + DEPLOY_REGISTRY_ECR_ASSUME_ROLE_EXTERNAL_ID: "v3-mixed-external-id", + + // Only some V4 vars are set - others should fallback to v3 + V4_DEPLOY_REGISTRY_HOST: "v4-partial-host.example.com", + V4_DEPLOY_REGISTRY_USERNAME: "v4-partial-user", + // V4_DEPLOY_REGISTRY_PASSWORD not set - should fallback to v3 + // Other V4 vars not set - should fallback to v3 + }; + + const { getRegistryConfig } = await import("../app/v3/registryConfig.server"); + const config = getRegistryConfig(true); + + expect(config).toEqual({ + host: "v4-partial-host.example.com", // v4 value + username: "v4-partial-user", // v4 value + password: "v3-mixed-password", // v3 fallback + namespace: "v3-mixed-namespace", // v3 fallback + ecrTags: "env=v3mixed", // v3 fallback + ecrAssumeRoleArn: "arn:aws:iam::222222222222:role/v3-mixed-role", // v3 fallback + ecrAssumeRoleExternalId: "v3-mixed-external-id", // v3 fallback + }); + }); + + it("should handle basic registry config without ECR or V4 vars", async () => { + // Set up basic registry config without ECR or V4 vars + process.env = { + ...baseEnv, + DEPLOY_REGISTRY_HOST: "registry.example.com", + DEPLOY_REGISTRY_USERNAME: "basic-user", + DEPLOY_REGISTRY_PASSWORD: "basic-password", + DEPLOY_REGISTRY_NAMESPACE: "basic-namespace", + // No ECR vars and no V4 vars - should all be undefined + }; + + const { getRegistryConfig } = await import("../app/v3/registryConfig.server"); + + const v3Config = getRegistryConfig(false); + const v4Config = getRegistryConfig(true); + + expect(v3Config).toEqual({ + host: "registry.example.com", + username: "basic-user", + password: "basic-password", + namespace: "basic-namespace", + ecrTags: undefined, + ecrAssumeRoleArn: undefined, + ecrAssumeRoleExternalId: undefined, + }); + + // V4 should fallback to v3 values since V4 vars not set + expect(v4Config).toEqual({ + host: "registry.example.com", + username: "basic-user", + password: "basic-password", + namespace: "basic-namespace", + ecrTags: undefined, + ecrAssumeRoleArn: undefined, + ecrAssumeRoleExternalId: undefined, + }); + }); + + it("should handle undefined/null values gracefully", async () => { + // Set up minimal required values only + process.env = { + ...baseEnv, + DEPLOY_REGISTRY_HOST: "minimal-host.example.com", + DEPLOY_REGISTRY_NAMESPACE: "minimal-namespace", + // Other vars not set - should be undefined + }; + + const { getRegistryConfig } = await import("../app/v3/registryConfig.server"); + + const v3Config = getRegistryConfig(false); + const v4Config = getRegistryConfig(true); + + expect(v3Config).toEqual({ + host: "minimal-host.example.com", + username: undefined, + password: undefined, + namespace: "minimal-namespace", + ecrTags: undefined, + ecrAssumeRoleArn: undefined, + ecrAssumeRoleExternalId: undefined, + }); + + expect(v4Config).toEqual({ + host: "minimal-host.example.com", + username: undefined, + password: undefined, + namespace: "minimal-namespace", // fallback default + ecrTags: undefined, + ecrAssumeRoleArn: undefined, + ecrAssumeRoleExternalId: undefined, + }); + }); +});