Compare commits

...

13 Commits

Author SHA1 Message Date
nicktrn ba68d19a74 track if repo created and fix test
🚀 Publish Trigger.dev Docker / units (push) Failing after 4s
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 7s
🚀 Publish Trigger.dev Docker / publish-webapp (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker-v4 (push) Has been skipped
2025-07-02 17:10:28 +01:00
nicktrn e65875d126 tag parsing tests 2025-07-02 17:05:30 +01:00
nicktrn 1c82f72442 improve tag parsing 2025-07-02 16:58:59 +01:00
nicktrn 2eae336f61 improve ecr check 2025-07-02 16:50:20 +01:00
nicktrn 6202130aa5 Merge remote-tracking branch 'origin/main' into feat/ecr-support 2025-07-02 16:31:24 +01:00
nicktrn 512d6827f9 assume role fix and env var changes 2025-07-02 16:31:17 +01:00
nicktrn 372c7ce1e6 make test repo namespace configurable 2025-07-02 13:22:53 +01:00
nicktrn ef87110b0e log when machine overrides enabled 2025-07-02 13:22:38 +01:00
nicktrn a93b54cc92 Merge remote-tracking branch 'origin/main' into feat/ecr-support 2025-07-02 11:33:57 +01:00
nicktrn 14cbc22c19 optional assume role 2025-07-01 13:26:21 +01:00
nicktrn e9fc7afcdd Merge remote-tracking branch 'origin/main' into feat/arm64-registry 2025-06-30 17:25:08 +01:00
nicktrn eb99545729 fresh auth token for each deploy 2025-06-30 17:25:04 +01:00
nicktrn e16c43ca9f create repo if doesn't exist 2025-06-30 16:36:58 +01:00
8 changed files with 1866 additions and 16 deletions
+6
View File
@@ -229,15 +229,21 @@ const EnvironmentSchema = z.object({
DEPOT_TOKEN: z.string().optional(),
DEPOT_ORG_ID: z.string().optional(),
DEPOT_REGION: z.string().default("us-east-1"),
// Deployment registry
DEPLOY_REGISTRY_HOST: z.string().min(1),
DEPLOY_REGISTRY_USERNAME: z.string().optional(),
DEPLOY_REGISTRY_PASSWORD: z.string().optional(),
DEPLOY_REGISTRY_NAMESPACE: z.string().min(1).default("trigger"),
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(),
DEPLOY_IMAGE_PLATFORM: z.string().default("linux/amd64"),
DEPLOY_TIMEOUT_MS: z.coerce
.number()
.int()
.default(60 * 1000 * 8), // 8 minutes
OBJECT_STORE_BASE_URL: z.string().optional(),
OBJECT_STORE_ACCESS_KEY_ID: z.string().optional(),
OBJECT_STORE_SECRET_ACCESS_KEY: z.string().optional(),
@@ -102,6 +102,8 @@ function initializeMachinePresets(): {
};
}
logger.info("🎛️ Overriding machine presets", { overrides });
return {
defaultMachine: overrideDefaultMachine(defaultMachineFromPlatform, overrides.defaultMachine),
machines: overrideMachines(machinesFromPlatform, overrides.machines),
@@ -0,0 +1,379 @@
import {
ECRClient,
CreateRepositoryCommand,
DescribeRepositoriesCommand,
type Repository,
type Tag,
RepositoryNotFoundException,
GetAuthorizationTokenCommand,
} from "@aws-sdk/client-ecr";
import { STSClient, AssumeRoleCommand } from "@aws-sdk/client-sts";
import { tryCatch } from "@trigger.dev/core";
import { logger } from "~/services/logger.server";
// Optional configuration for cross-account access
export type AssumeRoleConfig = {
roleArn?: string;
externalId?: string;
};
async function getAssumedRoleCredentials({
region,
assumeRole,
}: {
region: string;
assumeRole?: AssumeRoleConfig;
}): Promise<{
accessKeyId: string;
secretAccessKey: string;
sessionToken: string;
}> {
const sts = new STSClient({ region });
// Generate a unique session name using timestamp and random string
// This helps with debugging but doesn't affect concurrent sessions
const timestamp = Date.now();
const randomSuffix = Math.random().toString(36).substring(2, 8);
const sessionName = `TriggerWebappECRAccess_${timestamp}_${randomSuffix}`;
try {
const response = await sts.send(
new AssumeRoleCommand({
RoleArn: assumeRole?.roleArn,
RoleSessionName: sessionName,
// Sessions automatically expire after 1 hour
// AWS allows 5000 concurrent sessions by default
DurationSeconds: 3600,
ExternalId: assumeRole?.externalId,
})
);
if (!response.Credentials) {
throw new Error("STS: No credentials returned from assumed role");
}
if (
!response.Credentials.AccessKeyId ||
!response.Credentials.SecretAccessKey ||
!response.Credentials.SessionToken
) {
throw new Error("STS: Invalid credentials returned from assumed role");
}
return {
accessKeyId: response.Credentials.AccessKeyId,
secretAccessKey: response.Credentials.SecretAccessKey,
sessionToken: response.Credentials.SessionToken,
};
} catch (error) {
logger.error("Failed to assume role", {
assumeRole,
sessionName,
error,
});
throw error;
}
}
export async function createEcrClient({
region,
assumeRole,
}: {
region: string;
assumeRole?: AssumeRoleConfig;
}) {
if (!assumeRole) {
return new ECRClient({ region });
}
// Get credentials for cross-account access
const credentials = await getAssumedRoleCredentials({ region, assumeRole });
return new ECRClient({
region,
credentials,
});
}
export async function getDeploymentImageRef({
host,
namespace,
projectRef,
nextVersion,
environmentSlug,
registryTags,
assumeRole,
}: {
host: string;
namespace: string;
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}`;
if (!isEcrRegistry(host)) {
return {
imageRef,
isEcr: false,
repoCreated: false,
};
}
const [ecrRepoError, ecrData] = await tryCatch(
ensureEcrRepositoryExists({
repositoryName,
registryHost: host,
registryTags,
assumeRole,
})
);
if (ecrRepoError) {
logger.error("Failed to ensure ECR repository exists", {
repositoryName,
host,
ecrRepoError: ecrRepoError.message,
});
throw ecrRepoError;
}
return {
imageRef,
isEcr: true,
repoCreated: ecrData.repoCreated,
};
}
export function isEcrRegistry(registryHost: string) {
try {
parseEcrRegistryDomain(registryHost);
return true;
} catch {
return false;
}
}
export function parseRegistryTags(tags: string): Tag[] {
if (!tags) {
return [];
}
return tags
.split(",")
.map((t) => {
const tag = t.trim();
if (tag.length === 0) {
return null;
}
// If there's no '=' in the tag, treat the whole tag as the key with an empty value
const equalIndex = tag.indexOf("=");
const key = equalIndex === -1 ? tag : tag.slice(0, equalIndex);
const value = equalIndex === -1 ? "" : tag.slice(equalIndex + 1);
if (key.trim().length === 0) {
logger.warn("Invalid ECR tag format (empty key), skipping tag", { tag: t });
return null;
}
return {
Key: key.trim(),
Value: value.trim(),
} as Tag;
})
.filter((tag): tag is Tag => tag !== null);
}
async function createEcrRepository({
repositoryName,
region,
accountId,
registryTags,
assumeRole,
}: {
repositoryName: string;
region: string;
accountId?: string;
registryTags?: string;
assumeRole?: AssumeRoleConfig;
}): Promise<Repository> {
const ecr = await createEcrClient({ region, assumeRole });
const result = await ecr.send(
new CreateRepositoryCommand({
repositoryName,
imageTagMutability: "IMMUTABLE",
encryptionConfiguration: {
encryptionType: "AES256",
},
registryId: accountId,
tags: registryTags ? parseRegistryTags(registryTags) : undefined,
})
);
if (!result.repository) {
logger.error("Failed to create ECR repository", { repositoryName, result });
throw new Error(`Failed to create ECR repository: ${repositoryName}`);
}
return result.repository;
}
async function getEcrRepository({
repositoryName,
region,
accountId,
assumeRole,
}: {
repositoryName: string;
region: string;
accountId?: string;
assumeRole?: AssumeRoleConfig;
}): Promise<Repository | undefined> {
const ecr = await createEcrClient({ region, assumeRole });
try {
const result = await ecr.send(
new DescribeRepositoriesCommand({
repositoryNames: [repositoryName],
registryId: accountId,
})
);
if (!result.repositories || result.repositories.length === 0) {
logger.debug("ECR repository not found", { repositoryName, region, result });
return undefined;
}
return result.repositories[0];
} catch (error) {
if (error instanceof RepositoryNotFoundException) {
logger.debug("ECR repository not found: RepositoryNotFoundException", {
repositoryName,
region,
});
return undefined;
}
throw error;
}
}
export type EcrRegistryComponents = {
accountId: string;
region: string;
};
export function parseEcrRegistryDomain(registryHost: string): EcrRegistryComponents {
const parts = registryHost.split(".");
const isValid =
parts.length === 6 &&
parts[1] === "dkr" &&
parts[2] === "ecr" &&
parts[4] === "amazonaws" &&
parts[5] === "com";
if (!isValid) {
throw new Error(`Invalid ECR registry host: ${registryHost}`);
}
return {
accountId: parts[0],
region: parts[3],
};
}
async function ensureEcrRepositoryExists({
repositoryName,
registryHost,
registryTags,
assumeRole,
}: {
repositoryName: string;
registryHost: string;
registryTags?: string;
assumeRole?: AssumeRoleConfig;
}): Promise<{ repo: Repository; repoCreated: boolean }> {
const { region, accountId } = parseEcrRegistryDomain(registryHost);
const [getRepoError, existingRepo] = await tryCatch(
getEcrRepository({ repositoryName, region, accountId, assumeRole })
);
if (getRepoError) {
logger.error("Failed to get ECR repository", { repositoryName, region, getRepoError });
throw getRepoError;
}
if (existingRepo) {
logger.debug("ECR repository already exists", { repositoryName, region, existingRepo });
return {
repo: existingRepo,
repoCreated: false,
};
}
const [createRepoError, newRepo] = await tryCatch(
createEcrRepository({ repositoryName, region, accountId, registryTags, assumeRole })
);
if (createRepoError) {
logger.error("Failed to create ECR repository", { repositoryName, region, createRepoError });
throw createRepoError;
}
if (newRepo.repositoryName !== repositoryName) {
logger.error("ECR repository name mismatch", { repositoryName, region, newRepo });
throw new Error(
`ECR repository name mismatch: ${repositoryName} !== ${newRepo.repositoryName}`
);
}
return {
repo: newRepo,
repoCreated: true,
};
}
export async function getEcrAuthToken({
registryHost,
assumeRole,
}: {
registryHost: string;
assumeRole?: AssumeRoleConfig;
}): Promise<{ username: string; password: string }> {
const { region, accountId } = parseEcrRegistryDomain(registryHost);
if (!region) {
logger.error("Invalid ECR registry host", { registryHost });
throw new Error("Invalid ECR registry host");
}
const ecr = await createEcrClient({ region, assumeRole });
const response = await ecr.send(
new GetAuthorizationTokenCommand({
registryIds: accountId ? [accountId] : undefined,
})
);
if (!response.authorizationData) {
throw new Error("Failed to get ECR authorization token");
}
const authData = response.authorizationData[0];
if (!authData.authorizationToken) {
throw new Error("No authorization token returned from ECR");
}
const authToken = Buffer.from(authData.authorizationToken, "base64").toString();
const [username, password] = authToken.split(":");
return { username, password };
}
@@ -9,6 +9,8 @@ import { env } from "~/env.server";
import { depot as execDepot } from "@depot/cli";
import { FinalizeDeploymentService } from "./finalizeDeployment.server";
import { remoteBuildsEnabled } from "../remoteImageBuilder.server";
import { getEcrAuthToken, isEcrRegistry } from "../getDeploymentImageRef.server";
import { tryCatch } from "@trigger.dev/core";
export class FinalizeDeploymentV2Service extends BaseService {
public async call(
@@ -172,11 +174,27 @@ async function executePushToRegistry(
{ depot, registry, deployment }: ExecutePushToRegistryOptions,
writer?: WritableStreamDefaultWriter
): Promise<ExecutePushResult> {
// Step 1: We need to "login" to the digital ocean registry
const configDir = await ensureLoggedIntoDockerRegistry(registry.host, {
username: registry.username,
password: registry.password,
});
// Step 1: We need to "login" to the registry
const [loginError, configDir] = await tryCatch(
ensureLoggedIntoDockerRegistry(registry.host, {
username: registry.username,
password: registry.password,
})
);
if (loginError) {
logger.error("Failed to login to registry", {
deployment,
registryHost: registry.host,
error: loginError.message,
});
return {
ok: false as const,
error: "Failed to login to registry",
logs: "",
};
}
const imageTag = deployment.imageReference;
@@ -244,12 +262,24 @@ async function executePushToRegistry(
async function ensureLoggedIntoDockerRegistry(
registryHost: string,
auth: { username: string; password: string }
auth: { username: string; password: string } | undefined = undefined
) {
const tmpDir = await createTempDir();
// Read the current docker config
const dockerConfigPath = join(tmpDir, "config.json");
// If this is an ECR registry, get fresh credentials
if (isEcrRegistry(registryHost)) {
auth = await getEcrAuthToken({
registryHost,
assumeRole: {
roleArn: env.DEPLOY_REGISTRY_ECR_ASSUME_ROLE_ARN,
externalId: env.DEPLOY_REGISTRY_ECR_ASSUME_ROLE_EXTERNAL_ID,
},
});
} else if (!auth) {
throw new Error("Authentication required for non-ECR registry");
}
await writeJSONFile(dockerConfigPath, {
auths: {
[registryHost]: {
@@ -1,5 +1,4 @@
import { type InitializeDeploymentRequestBody } from "@trigger.dev/core/v3";
import { WorkerDeploymentType } from "@trigger.dev/database";
import { customAlphabet } from "nanoid";
import { env } from "~/env.server";
import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
@@ -9,6 +8,8 @@ import { createRemoteImageBuild, remoteBuildsEnabled } from "../remoteImageBuild
import { calculateNextBuildVersion } from "../utils/calculateNextBuildVersion";
import { BaseService, ServiceValidationError } from "./baseService.server";
import { TimeoutDeploymentService } from "./timeoutDeployment.server";
import { getDeploymentImageRef } from "../getDeploymentImageRef.server";
import { tryCatch } from "@trigger.dev/core";
const nanoid = customAlphabet("1234567890abcdefghijklmnopqrstuvwxyz", 8);
@@ -68,11 +69,34 @@ export class InitializeDeploymentService extends BaseService {
})
: undefined;
const imageRef = [
env.DEPLOY_REGISTRY_HOST,
env.DEPLOY_REGISTRY_NAMESPACE,
`${environment.project.externalRef}:${nextVersion}.${environment.slug}`,
].join("/");
const [imageRefError, imageRefResult] = await tryCatch(
getDeploymentImageRef({
host: env.DEPLOY_REGISTRY_HOST,
namespace: env.DEPLOY_REGISTRY_NAMESPACE,
projectRef: environment.project.externalRef,
nextVersion,
environmentSlug: environment.slug,
registryTags: env.DEPLOY_REGISTRY_ECR_TAGS,
assumeRole: {
roleArn: env.DEPLOY_REGISTRY_ECR_ASSUME_ROLE_ARN,
externalId: env.DEPLOY_REGISTRY_ECR_ASSUME_ROLE_EXTERNAL_ID,
},
})
);
if (imageRefError) {
logger.error("Failed to get deployment image ref", {
environmentId: environment.id,
projectId: environment.projectId,
version: nextVersion,
triggeredById: triggeredBy?.id,
type: payload.type,
cause: imageRefError.message,
});
throw new ServiceValidationError("Failed to get deployment image ref");
}
const { imageRef, isEcr, repoCreated } = imageRefResult;
logger.debug("Creating deployment", {
environmentId: environment.id,
@@ -81,6 +105,8 @@ export class InitializeDeploymentService extends BaseService {
triggeredById: triggeredBy?.id,
type: payload.type,
imageRef,
isEcr,
repoCreated,
});
const deployment = await this._prisma.workerDeployment.create({
+2
View File
@@ -33,7 +33,9 @@
"dependencies": {
"@ariakit/react": "^0.4.6",
"@ariakit/react-core": "^0.4.6",
"@aws-sdk/client-ecr": "^3.839.0",
"@aws-sdk/client-sqs": "^3.445.0",
"@aws-sdk/client-sts": "^3.840.0",
"@codemirror/autocomplete": "^6.3.1",
"@codemirror/commands": "^6.1.2",
"@codemirror/lang-javascript": "^6.1.1",
@@ -0,0 +1,244 @@
import { describe, expect, it } from "vitest";
import {
createEcrClient,
getDeploymentImageRef,
getEcrAuthToken,
parseEcrRegistryDomain,
parseRegistryTags,
} from "../app/v3/getDeploymentImageRef.server";
import { DeleteRepositoryCommand } from "@aws-sdk/client-ecr";
describe.skipIf(process.env.RUN_REGISTRY_TESTS !== "1")("getDeploymentImageRef", () => {
const testHost =
process.env.DEPLOY_REGISTRY_HOST || "123456789012.dkr.ecr.us-east-1.amazonaws.com";
const testNamespace = process.env.DEPLOY_REGISTRY_NAMESPACE || "test-namespace";
const testProjectRef = "proj_test_" + Math.random().toString(36).substring(7);
const testProjectRef2 = testProjectRef + "_2";
const registryTags = process.env.DEPLOY_REGISTRY_ECR_TAGS || "test=test,test2=test2";
const roleArn = process.env.DEPLOY_REGISTRY_ECR_ASSUME_ROLE_ARN;
const externalId = process.env.DEPLOY_REGISTRY_ECR_ASSUME_ROLE_EXTERNAL_ID;
const assumeRole = {
roleArn,
externalId,
};
// Clean up test repository after tests
afterAll(async () => {
if (process.env.KEEP_TEST_REPO === "1") {
return;
}
try {
const { region, accountId } = parseEcrRegistryDomain(testHost);
const ecr = await createEcrClient({ region, assumeRole });
await Promise.all([
ecr.send(
new DeleteRepositoryCommand({
repositoryName: `${testNamespace}/${testProjectRef}`,
registryId: accountId,
force: true,
})
),
ecr.send(
new DeleteRepositoryCommand({
repositoryName: `${testNamespace}/${testProjectRef2}`,
registryId: accountId,
force: true,
})
),
]);
} catch (error) {
console.warn("Failed to delete test repository:", error);
}
});
it("should return the correct image ref for non-ECR registry", async () => {
const imageRef = await getDeploymentImageRef({
host: "registry.digitalocean.com",
namespace: testNamespace,
projectRef: testProjectRef,
nextVersion: "20250630.1",
environmentSlug: "test",
registryTags,
assumeRole,
});
expect(imageRef.imageRef).toBe(
`registry.digitalocean.com/${testNamespace}/${testProjectRef}:20250630.1.test`
);
expect(imageRef.isEcr).toBe(false);
});
it("should create ECR repository and return correct image ref", async () => {
const imageRef1 = await getDeploymentImageRef({
host: testHost,
namespace: testNamespace,
projectRef: testProjectRef2,
nextVersion: "20250630.1",
environmentSlug: "test",
registryTags,
assumeRole,
});
expect(imageRef1.imageRef).toBe(
`${testHost}/${testNamespace}/${testProjectRef2}:20250630.1.test`
);
expect(imageRef1.isEcr).toBe(true);
expect(imageRef1.repoCreated).toBe(true);
const imageRef2 = await getDeploymentImageRef({
host: testHost,
namespace: testNamespace,
projectRef: testProjectRef2,
nextVersion: "20250630.2",
environmentSlug: "test",
registryTags,
assumeRole,
});
expect(imageRef2.imageRef).toBe(
`${testHost}/${testNamespace}/${testProjectRef2}:20250630.2.test`
);
expect(imageRef2.isEcr).toBe(true);
expect(imageRef2.repoCreated).toBe(false);
});
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,
projectRef: testProjectRef,
nextVersion: "20250630.2",
environmentSlug: "prod",
registryTags,
assumeRole,
});
expect(imageRef.imageRef).toBe(
`${testHost}/${testNamespace}/${testProjectRef}:20250630.2.prod`
);
expect(imageRef.isEcr).toBe(true);
});
it("should throw error for invalid ECR host", async () => {
await expect(
getDeploymentImageRef({
host: "invalid.ecr.amazonaws.com",
namespace: testNamespace,
projectRef: testProjectRef,
nextVersion: "20250630.1",
environmentSlug: "test",
registryTags,
assumeRole,
})
).rejects.toThrow("Invalid ECR registry host: invalid.ecr.amazonaws.com");
});
});
describe.skipIf(process.env.RUN_REGISTRY_AUTH_TESTS !== "1")("getEcrAuthToken", () => {
const testHost =
process.env.DEPLOY_REGISTRY_HOST || "123456789012.dkr.ecr.us-east-1.amazonaws.com";
const roleArn = process.env.DEPLOY_REGISTRY_ECR_ASSUME_ROLE_ARN;
const externalId = process.env.DEPLOY_REGISTRY_ECR_ASSUME_ROLE_EXTERNAL_ID;
const assumeRole = {
roleArn,
externalId,
};
it("should return valid ECR credentials", async () => {
const auth = await getEcrAuthToken({
registryHost: testHost,
assumeRole,
});
// Check the structure and basic validation of the returned credentials
expect(auth).toHaveProperty("username");
expect(auth).toHaveProperty("password");
expect(auth.username).toBe("AWS");
expect(typeof auth.password).toBe("string");
expect(auth.password.length).toBeGreaterThan(0);
// Verify the token format (should be a base64-encoded string)
const base64Regex = /^[A-Za-z0-9+/=]+$/;
expect(base64Regex.test(auth.password)).toBe(true);
});
it("should throw error for invalid region", async () => {
await expect(
getEcrAuthToken({
registryHost: "invalid.ecr.amazonaws.com",
assumeRole,
})
).rejects.toThrow();
});
});
describe("parseEcrRegistry", () => {
it("should correctly parse a valid ECR registry host", () => {
const result = parseEcrRegistryDomain("123456789012.dkr.ecr.us-east-1.amazonaws.com");
expect(result).toEqual({
accountId: "123456789012",
region: "us-east-1",
});
});
it("should handle invalid ECR registry hosts", () => {
const invalidHosts = [
"invalid.ecr.amazonaws.com",
"registry.hub.docker.com",
"123456789012.dkr.ecr.us-east-1.not-amazon.com",
"123456789012.wrong.ecr.us-east-1.amazonaws.com",
];
for (const host of invalidHosts) {
expect(() => parseEcrRegistryDomain(host)).toThrow("Invalid ECR registry host");
}
});
});
describe("parseRegistryTags", () => {
it("should handle empty or null input", () => {
expect(parseRegistryTags("")).toEqual([]);
expect(parseRegistryTags(",,,")).toEqual([]);
});
it("should parse key-only tags", () => {
expect(parseRegistryTags("key1,key2")).toEqual([
{ Key: "key1", Value: "" },
{ Key: "key2", Value: "" },
]);
});
it("should parse key-value tags", () => {
expect(parseRegistryTags("key1=value1,key2=value2")).toEqual([
{ Key: "key1", Value: "value1" },
{ Key: "key2", Value: "value2" },
]);
});
it("should handle mixed key-only and key-value tags", () => {
expect(parseRegistryTags("key1,key2=value2,key3")).toEqual([
{ Key: "key1", Value: "" },
{ Key: "key2", Value: "value2" },
{ Key: "key3", Value: "" },
]);
});
it("should handle whitespace", () => {
expect(parseRegistryTags(" key1 , key2 = value2 ")).toEqual([
{ Key: "key1", Value: "" },
{ Key: "key2", Value: "value2" },
]);
});
it("should skip invalid tags", () => {
expect(parseRegistryTags("=value,key1,=,key2=value2")).toEqual([
{ Key: "key1", Value: "" },
{ Key: "key2", Value: "value2" },
]);
});
});
+1164 -3
View File
File diff suppressed because it is too large Load Diff