diff --git a/.nx/version-plans/version-plan-1781190475359.md b/.nx/version-plans/version-plan-1781190475359.md new file mode 100644 index 0000000000..0514822f37 --- /dev/null +++ b/.nx/version-plans/version-plan-1781190475359.md @@ -0,0 +1,5 @@ +--- +'@pagopa/dx-cli': patch +--- + +Ensure `dx add environment` creates missing GitHub environments for non-prod deployments. diff --git a/apps/cli/src/adapters/azure/__tests__/cloud-account-service.test.ts b/apps/cli/src/adapters/azure/__tests__/cloud-account-service.test.ts index eef990c6b4..ea547a3b69 100644 --- a/apps/cli/src/adapters/azure/__tests__/cloud-account-service.test.ts +++ b/apps/cli/src/adapters/azure/__tests__/cloud-account-service.test.ts @@ -43,6 +43,12 @@ const { }), mockSetSecret: vi.fn().mockResolvedValue({}), })); +const { mockLookup } = vi.hoisted(() => ({ + mockLookup: vi.fn().mockResolvedValue([{ address: "127.0.0.1", family: 4 }]), +})); +const { mockSleep } = vi.hoisted(() => ({ + mockSleep: vi.fn().mockResolvedValue(undefined), +})); vi.mock("@azure/arm-authorization", () => ({ AuthorizationManagementClient: class { @@ -111,6 +117,12 @@ vi.mock("@azure/keyvault-secrets", () => ({ setSecret = mockSetSecret; }, })); +vi.mock("node:dns/promises", () => ({ + lookup: mockLookup, +})); +vi.mock("node:timers/promises", () => ({ + setTimeout: mockSleep, +})); const test = baseTest.extend<{ cloudAccountService: AzureCloudAccountService }>( { @@ -147,6 +159,9 @@ beforeEach(() => { mockRoleAssignmentsCreate.mockClear(); mockRoleAssignmentsListForScope.mockReset(); mockSetSecret.mockClear(); + mockLookup.mockReset(); + mockLookup.mockResolvedValue([{ address: "127.0.0.1", family: 4 }]); + mockSleep.mockClear(); }); describe("getTerraformBackend", () => { @@ -307,6 +322,7 @@ describe("isInitialized", () => { }); }); +// eslint-disable-next-line max-lines-per-function describe("initialize", () => { test("assigns bootstrap roles and creates bootstrap environment secrets", async ({ cloudAccountService, @@ -488,4 +504,54 @@ describe("initialize", () => { }, ); }); + + test("waits for the key vault endpoint to become resolvable", async ({ + cloudAccountService, + }) => { + mockLookup + .mockRejectedValueOnce( + Object.assign(new Error("not found"), { + code: "ENOTFOUND", + }), + ) + .mockResolvedValueOnce([{ address: "127.0.0.1", family: 4 }]); + + await cloudAccountService.initialize( + { + csp: "azure", + defaultLocation: "italynorth", + displayName: "Test subscription", + id: "sub-1", + }, + { + name: "dev", + prefix: "dx", + }, + { + clientId: "app-client-id", + id: "app-id", + installationId: "installation-id", + key: "private-key\n", + }, + { + owner: "pagopa", + repo: "dx", + }, + { + createBranch: vi.fn(), + createOrUpdateEnvironmentSecret: vi.fn().mockResolvedValue(undefined), + createPullRequest: vi.fn(), + getFileContent: vi.fn(), + getRepository: vi.fn(), + updateFile: vi.fn(), + }, + ); + + expect(mockLookup).toHaveBeenCalledWith( + "dx-d-itn-common-kv-01.vault.azure.net", + ); + expect(mockLookup).toHaveBeenCalledTimes(2); + expect(mockSleep).toHaveBeenCalledWith(10_000); + expect(mockSetSecret).toHaveBeenCalledTimes(3); + }); }); diff --git a/apps/cli/src/adapters/azure/cloud-account-service.ts b/apps/cli/src/adapters/azure/cloud-account-service.ts index 66f72a127d..0579b8ca49 100644 --- a/apps/cli/src/adapters/azure/cloud-account-service.ts +++ b/apps/cli/src/adapters/azure/cloud-account-service.ts @@ -13,6 +13,8 @@ import { getLogger } from "@logtape/logtape"; import { Client } from "@microsoft/microsoft-graph-client"; import * as assert from "node:assert/strict"; import { createHash } from "node:crypto"; +import { lookup } from "node:dns/promises"; +import { setTimeout as sleep } from "node:timers/promises"; import { z } from "zod/v4"; import { @@ -70,6 +72,12 @@ const bootstrapIdentityRoleDefinitionIds = [ builtInRoleDefinitionIds.storageBlobDataContributor, ] as const; +const keyVaultDnsErrorSchema = z.object({ + code: z.enum(["EAI_AGAIN", "ENOTFOUND"]), +}); +const keyVaultDnsReadyMaxAttempts = 30; +const keyVaultDnsReadyRetryDelayMs = 10_000; + export class AzureCloudAccountService implements CloudAccountService { #credential: TokenCredential; #requiredResourceProviders = [ @@ -823,6 +831,11 @@ export class AzureCloudAccountService implements CloudAccountService { }): Promise { const logger = getLogger(["gen", "env"]); + await this.#waitForKeyVaultDnsResolution({ + cloudAccountId, + keyVaultName, + }); + const secretClient = new SecretClient( `https://${keyVaultName}.vault.azure.net/`, this.#credential, @@ -845,4 +858,38 @@ export class AzureCloudAccountService implements CloudAccountService { { keyVaultName, subscriptionId: cloudAccountId }, ); } + + async #waitForKeyVaultDnsResolution({ + cloudAccountId, + keyVaultName, + }: { + cloudAccountId: string; + keyVaultName: string; + }): Promise { + const logger = getLogger(["gen", "env"]); + const hostname = `${keyVaultName}.vault.azure.net`; + + for (let attempt = 1; attempt <= keyVaultDnsReadyMaxAttempts; attempt++) { + try { + await lookup(hostname); + return; + } catch (cause) { + if ( + !keyVaultDnsErrorSchema.safeParse(cause).success || + attempt === keyVaultDnsReadyMaxAttempts + ) { + throw new Error( + `Key vault endpoint '${hostname}' is not resolvable`, + { cause }, + ); + } + + logger.debug( + "Waiting for key vault endpoint {hostname} to become resolvable in subscription {subscriptionId}", + { hostname, subscriptionId: cloudAccountId }, + ); + await sleep(keyVaultDnsReadyRetryDelayMs); + } + } + } } diff --git a/apps/cli/src/adapters/plop/actions/__tests__/sync-repository-environments.test.ts b/apps/cli/src/adapters/plop/actions/__tests__/sync-repository-environments.test.ts new file mode 100644 index 0000000000..1f9e7a2bea --- /dev/null +++ b/apps/cli/src/adapters/plop/actions/__tests__/sync-repository-environments.test.ts @@ -0,0 +1,66 @@ +/** + * Unit tests for keeping repository Terraform environments in sync. + */ +import { describe, expect, it } from "vitest"; + +import { syncRepositoryTerraformEnvironments } from "../sync-repository-environments.js"; + +const repositoryConfig = [ + 'module "github_repository" {', + ' source = "pagopa-dx/github-environment-bootstrap/github"', + ' version = "~> 1.0"', + "", + " repository = {", + ' name = "my-project"', + ' description = "My project"', + " topics = []", + " reviewers_teams = []", + " }", + "}", + "", +].join("\n"); + +describe("syncRepositoryTerraformEnvironments", () => { + it("adds a non-prod environment while preserving the implicit prod default", () => { + const result = syncRepositoryTerraformEnvironments(repositoryConfig, "dev"); + + expect(result).toContain(' environments = ["dev", "prod"]'); + }); + + it("does not add an explicit environments property when prod is already implicit", () => { + const result = syncRepositoryTerraformEnvironments( + repositoryConfig, + "prod", + ); + + expect(result).toBe(repositoryConfig); + }); + + it("adds the selected environment to an existing explicit list", () => { + const content = repositoryConfig.replace( + " reviewers_teams = []\n", + ' reviewers_teams = []\n environments = ["prod"]\n', + ); + + const result = syncRepositoryTerraformEnvironments(content, "uat"); + + expect(result).toContain(' environments = ["uat", "prod"]'); + }); + + it("is idempotent when the selected environment already exists", () => { + const content = repositoryConfig.replace( + " reviewers_teams = []\n", + ' reviewers_teams = []\n environments = ["dev", "prod"]\n', + ); + + const result = syncRepositoryTerraformEnvironments(content, "dev"); + + expect(result).toBe(content); + }); + + it("throws when the repository block is missing", () => { + expect(() => + syncRepositoryTerraformEnvironments('resource "x" "y" {}', "dev"), + ).toThrow("Cannot find the repository configuration"); + }); +}); diff --git a/apps/cli/src/adapters/plop/actions/sync-repository-environments.ts b/apps/cli/src/adapters/plop/actions/sync-repository-environments.ts new file mode 100644 index 0000000000..b6cb440ad6 --- /dev/null +++ b/apps/cli/src/adapters/plop/actions/sync-repository-environments.ts @@ -0,0 +1,146 @@ +/** + * Synchronizes GitHub repository environments required by deployment scaffolding. + * + * The repository Terraform module remains the source of truth: after adding the + * selected environment to its inputs, this action applies it so GitHub receives + * the same policies and reviewers configured by Terraform. + */ +import { type NodePlopAPI } from "node-plop"; +import fs from "node:fs/promises"; +import path from "node:path"; + +import type { Environment } from "../../../domain/environment.js"; + +import { tf$ } from "../../execa/terraform.js"; +import { + type Payload, + payloadSchema, +} from "../generators/environment/prompts.js"; + +const KNOWN_ENVIRONMENTS: Environment["name"][] = ["dev", "uat", "prod"]; +const KNOWN_ENVIRONMENT_NAMES: readonly string[] = KNOWN_ENVIRONMENTS; + +const environmentList = (environments: Set): string => + `[${[ + ...KNOWN_ENVIRONMENTS.filter((environment) => + environments.has(environment), + ), + ...Array.from(environments).filter( + (environment) => !KNOWN_ENVIRONMENT_NAMES.includes(environment), + ), + ] + .map((environment) => `"${environment}"`) + .join(", ")}]`; + +const findRepositoryBlock = ( + content: string, +): { block: string; end: number; start: number } => { + const match = + /\n(?[^\S\r\n]*)repository[^\S\r\n]*=[^\S\r\n]*\{\n/.exec(content); + if (!match) { + throw new Error( + "Cannot find the repository configuration in infra/repository/main.tf", + ); + } + + const blockStart = match.index + match[0].length; + const closingMatch = new RegExp(`\\n${match.groups?.indent ?? ""}\\}`).exec( + content.slice(blockStart), + ); + if (!closingMatch) { + throw new Error( + "Cannot find the end of the repository configuration in infra/repository/main.tf", + ); + } + + const end = blockStart + closingMatch.index; + return { block: content.slice(blockStart, end), end, start: blockStart }; +}; + +const repositoryPropertyIndent = (block: string): string => { + const propertyMatch = /^(?\s*)\w+\s*=/m.exec(block); + return propertyMatch?.groups?.indent ?? " "; +}; + +const readRepositoryConfig = async (repositoryMainPath: string) => { + try { + return await fs.readFile(repositoryMainPath, "utf8"); + } catch (cause) { + throw new Error( + `Cannot synchronize GitHub repository environments because ${path.relative(process.cwd(), repositoryMainPath)} does not exist or is not readable.`, + { cause }, + ); + } +}; + +export const syncRepositoryTerraformEnvironments = ( + content: string, + environmentName: Environment["name"], +): string => { + const repositoryBlock = findRepositoryBlock(content); + const environmentsMatch = + /^(?\s*)environments\s*=\s*\[(?[\s\S]*?)\]\s*$/m.exec( + repositoryBlock.block, + ); + + if (!environmentsMatch) { + if (environmentName === "prod") { + return content; + } + + const environments = new Set([environmentName, "prod"]); + const propertyIndent = repositoryPropertyIndent(repositoryBlock.block); + const separator = repositoryBlock.block.endsWith("\n") ? "" : "\n"; + const updatedBlock = `${repositoryBlock.block}${separator}${propertyIndent}environments = ${environmentList(environments)}`; + return `${content.slice(0, repositoryBlock.start)}${updatedBlock}${content.slice(repositoryBlock.end)}`; + } + + const existingEnvironments = new Set( + Array.from((environmentsMatch.groups?.values ?? "").matchAll(/"([^"]+)"/g)) + .map((match) => match[1]) + .filter((environment) => environment.length > 0), + ); + if (existingEnvironments.has(environmentName)) { + return content; + } + + existingEnvironments.add(environmentName); + const indent = environmentsMatch.groups?.indent ?? " "; + const updatedLine = `${indent}environments = ${environmentList(existingEnvironments)}`; + const updatedBlock = repositoryBlock.block.replace( + environmentsMatch[0], + updatedLine, + ); + + return `${content.slice(0, repositoryBlock.start)}${updatedBlock}${content.slice(repositoryBlock.end)}`; +}; + +export const syncRepositoryEnvironments = async ( + payload: Payload, +): Promise => { + const repositoryPath = path.join(process.cwd(), "infra", "repository"); + const repositoryMainPath = path.join(repositoryPath, "main.tf"); + + const currentRepositoryConfig = + await readRepositoryConfig(repositoryMainPath); + const updatedRepositoryConfig = syncRepositoryTerraformEnvironments( + currentRepositoryConfig, + payload.env.name, + ); + + if (updatedRepositoryConfig !== currentRepositoryConfig) { + await fs.writeFile(repositoryMainPath, updatedRepositoryConfig, "utf8"); + } + + const repositoryTerraform = tf$({ cwd: repositoryPath }); + await repositoryTerraform`terraform init`; + await repositoryTerraform`terraform apply -auto-approve`; +}; + +export default function (plop: NodePlopAPI): void { + plop.setActionType("syncRepositoryEnvironments", async (data) => { + const payload = payloadSchema.parse(data); + await syncRepositoryEnvironments(payload); + return "Repository environments synchronized"; + }); +} diff --git a/apps/cli/src/adapters/plop/generators/environment/__tests__/__snapshots__/generation.test.ts.snap b/apps/cli/src/adapters/plop/generators/environment/__tests__/__snapshots__/generation.test.ts.snap index 30cc35684c..3effafd2ef 100644 --- a/apps/cli/src/adapters/plop/generators/environment/__tests__/__snapshots__/generation.test.ts.snap +++ b/apps/cli/src/adapters/plop/generators/environment/__tests__/__snapshots__/generation.test.ts.snap @@ -158,6 +158,19 @@ provider "azurerm" { provider "github" { owner = "pagopa" } +", + "infra/repository/main.tf": "module "github_repository" { + source = "pagopa-dx/github-environment-bootstrap/github" + version = "~> 1.0" + + repository = { + name = "my-project" + description = "My project" + topics = [] + reviewers_teams = [] + environments = ["dev", "prod"] + } +} ", } `; diff --git a/apps/cli/src/adapters/plop/generators/environment/__tests__/actions.test.ts b/apps/cli/src/adapters/plop/generators/environment/__tests__/actions.test.ts index 8a950f1e82..7ced2e147f 100644 --- a/apps/cli/src/adapters/plop/generators/environment/__tests__/actions.test.ts +++ b/apps/cli/src/adapters/plop/generators/environment/__tests__/actions.test.ts @@ -66,6 +66,7 @@ describe("actions", () => { ])("correct order of actions", ({ payload }) => { const actionsOrder = [ "getTerraformBackend", + "syncRepositoryEnvironments", "addMany", "addMany", "addMany", diff --git a/apps/cli/src/adapters/plop/generators/environment/__tests__/generation.test.ts b/apps/cli/src/adapters/plop/generators/environment/__tests__/generation.test.ts index 287e34cb6c..e391327f85 100644 --- a/apps/cli/src/adapters/plop/generators/environment/__tests__/generation.test.ts +++ b/apps/cli/src/adapters/plop/generators/environment/__tests__/generation.test.ts @@ -21,6 +21,7 @@ import type { TerraformBackend } from "../../../../../domain/remote-backend.js"; import setGetTerraformBackend from "../../../actions/get-terraform-backend.js"; import setInitCloudAccountsAction from "../../../actions/init-cloud-accounts.js"; import setProvisionTerraformBackendAction from "../../../actions/provision-terraform-backend.js"; +import setSyncRepositoryEnvironmentsAction from "../../../actions/sync-repository-environments.js"; import setEnvShortHelper from "../../../helpers/env-short.js"; import setEqHelper from "../../../helpers/eq.js"; import setResourcePrefixHelper from "../../../helpers/resource-prefix.js"; @@ -36,6 +37,12 @@ import { Payload, PLOP_ENVIRONMENT_GENERATOR_NAME } from "../index.js"; vi.mock("../../../../terraform/fmt.js", () => ({ formatTerraformCode: vi.fn((content: string) => content), })); +vi.mock("../../../../execa/terraform.js", () => { + const terraformCommand = vi.fn(async () => undefined); + const tf$ = vi.fn(() => terraformCommand); + + return { tf$ }; +}); /** * Register helpers and stub action types for the environment generator. @@ -54,6 +61,7 @@ const registerEnvironmentSetup = ( setGetTerraformBackend(plop, mockCloudAccountService); setProvisionTerraformBackendAction(plop, mockCloudAccountService); setInitCloudAccountsAction(plop, mockCloudAccountService, mockGitHubService); + setSyncRepositoryEnvironmentsAction(plop); }; const mockTerraformBackend: TerraformBackend = { @@ -100,6 +108,26 @@ const runEnvironmentGenerator = async ({ const originalCwd = process.cwd(); const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), tmpDirPrefix)); process.chdir(tmpDir); + await fs.mkdir(path.join(tmpDir, "infra", "repository"), { + recursive: true, + }); + await fs.writeFile( + path.join(tmpDir, "infra", "repository", "main.tf"), + [ + 'module "github_repository" {', + ' source = "pagopa-dx/github-environment-bootstrap/github"', + ' version = "~> 1.0"', + "", + " repository = {", + ' name = "my-project"', + ' description = "My project"', + " topics = []", + " reviewers_teams = []", + " }", + "}", + "", + ].join("\n"), + ); const plop = await nodePlop(); registerEnvironmentSetup(plop, mockCloudAccountService, mockGitHubService); @@ -177,6 +205,7 @@ describe("environment generator — file generation (no init)", () => { it("materializes bootstrapper files from payload and backend state", async () => { const generatedFiles = await readGeneratedFiles(tmpDir, [ `.github/workflows/_release-terraform-apply-bootstrapper-${payload.env.name}.yaml`, + "infra/repository/main.tf", `infra/bootstrapper/${payload.env.name}/main.tf`, `infra/bootstrapper/${payload.env.name}/providers.tf`, `infra/bootstrapper/${payload.env.name}/backend.tf`, diff --git a/apps/cli/src/adapters/plop/generators/environment/__tests__/prompts.test.ts b/apps/cli/src/adapters/plop/generators/environment/__tests__/prompts.test.ts index 22dde469e9..2229abb552 100644 --- a/apps/cli/src/adapters/plop/generators/environment/__tests__/prompts.test.ts +++ b/apps/cli/src/adapters/plop/generators/environment/__tests__/prompts.test.ts @@ -228,4 +228,78 @@ describe("prompts", () => { consoleLogSpy.mockRestore(); } }); + + it("does not block initialization when the permission preflight is negative", async () => { + const cloudAccount: CloudAccount = { + csp: "azure", + defaultLocation: "italynorth", + displayName: "UAT-FooBar", + id: "sub-123", + }; + + const cloudAccountService: CloudAccountService = { + getTerraformBackend: vi.fn().mockResolvedValue(undefined), + hasUserPermissionToInitialize: vi.fn().mockResolvedValue(false), + initialize: vi.fn().mockResolvedValue(undefined), + isInitialized: vi.fn().mockResolvedValue(false), + provisionTerraformBackend: vi.fn().mockResolvedValue(undefined), + }; + + const promptSpy = vi.spyOn(inquirer, "prompt"); + const consoleLogSpy = vi + .spyOn(console, "log") + .mockImplementation(() => undefined); + + promptSpy + .mockResolvedValueOnce({ + env: { + cloudAccounts: [cloudAccount], + name: "uat", + prefix: "dx", + }, + tags: { + BusinessUnit: "Platform", + CostCenter: "TS000", + ManagementTeam: "Engineering", + }, + workspace: { + domain: "payments", + }, + }) + .mockResolvedValueOnce({ + [cloudAccount.id]: "italynorth", + }) + .mockResolvedValueOnce({ + init: true, + }) + .mockResolvedValueOnce({ + runnerAppCredentials: { + clientId: "app-client-id", + id: "app-id", + installationId: "installation-id", + key: "private-key", + }, + }); + + try { + const result = await prompts({ + cloudAccountRepository: { + list: vi.fn().mockResolvedValue([cloudAccount]), + }, + cloudAccountService, + github: { + owner: "pagopa", + repo: "dx", + }, + })(inquirer); + + expect(result.init?.runnerAppCredentials).toBeDefined(); + expect( + cloudAccountService.hasUserPermissionToInitialize, + ).not.toHaveBeenCalled(); + } finally { + promptSpy.mockRestore(); + consoleLogSpy.mockRestore(); + } + }); }); diff --git a/apps/cli/src/adapters/plop/generators/environment/actions.ts b/apps/cli/src/adapters/plop/generators/environment/actions.ts index 49c098de41..ee57a07688 100644 --- a/apps/cli/src/adapters/plop/generators/environment/actions.ts +++ b/apps/cli/src/adapters/plop/generators/environment/actions.ts @@ -82,6 +82,9 @@ export default function getActions( { type: "getTerraformBackend", }, + { + type: "syncRepositoryEnvironments", + }, addWorkflowModule(templatesPath), ...addEnvironmentModule("bootstrapper"), ]; diff --git a/apps/cli/src/adapters/plop/generators/environment/index.ts b/apps/cli/src/adapters/plop/generators/environment/index.ts index ce98f4b22b..3564f2428f 100644 --- a/apps/cli/src/adapters/plop/generators/environment/index.ts +++ b/apps/cli/src/adapters/plop/generators/environment/index.ts @@ -9,6 +9,7 @@ import { type GitHubService } from "../../../../domain/github.js"; import setGetTerraformBackend from "../../actions/get-terraform-backend.js"; import setInitCloudAccountsAction from "../../actions/init-cloud-accounts.js"; import setProvisionTerraformBackendAction from "../../actions/provision-terraform-backend.js"; +import setSyncRepositoryEnvironmentsAction from "../../actions/sync-repository-environments.js"; import setEnvShortHelper from "../../helpers/env-short.js"; import setEqHelper from "../../helpers/eq.js"; import setResourcePrefixHelper from "../../helpers/resource-prefix.js"; @@ -35,6 +36,7 @@ export default function ( setGetTerraformBackend(plop, cloudAccountService); setProvisionTerraformBackendAction(plop, cloudAccountService); setInitCloudAccountsAction(plop, cloudAccountService, gitHubService); + setSyncRepositoryEnvironmentsAction(plop); plop.setGenerator(PLOP_ENVIRONMENT_GENERATOR_NAME, { actions: getActions(templatesPath), diff --git a/apps/cli/src/adapters/plop/generators/environment/prompts.ts b/apps/cli/src/adapters/plop/generators/environment/prompts.ts index 447a6e84fc..1a7dab5989 100644 --- a/apps/cli/src/adapters/plop/generators/environment/prompts.ts +++ b/apps/cli/src/adapters/plop/generators/environment/prompts.ts @@ -14,7 +14,6 @@ import { EnvironmentInitStatus, environmentSchema, getInitializationStatus, - hasUserPermissionToInitialize, } from "../../../../domain/environment.js"; import * as azure from "../../../azure/locations.js"; @@ -203,14 +202,6 @@ const prompts: (deps: PromptsDependencies) => DynamicPromptsFunction = assert.ok(initConfirm.init, "Can't proceed without initialization"); - assert.ok( - await hasUserPermissionToInitialize( - deps.cloudAccountService, - payload.env, - ), - "You don't have permission to initialize this environment. Ask your Engineering Leader to initialize it for you.", - ); - const missingRemoteBackend = initStatus.issues.some( (issue) => issue.type === "MISSING_REMOTE_BACKEND", ); diff --git a/apps/cli/src/domain/authorization.ts b/apps/cli/src/domain/authorization.ts index 0b44621199..209599dbbe 100644 --- a/apps/cli/src/domain/authorization.ts +++ b/apps/cli/src/domain/authorization.ts @@ -36,13 +36,13 @@ const BootstrapIdentityId = z /** * Branded type for resource prefix (e.g., "dx", "io"). - * Validates that the prefix contains only lowercase letters to prevent injection. + * Validates that the prefix matches the environment naming rules. */ const ResourcePrefix = z .string() .min(1) - .regex(/^[a-z]+$/, { - message: "Resource prefix may contain only lowercase letters", + .regex(/^[a-z0-9]+$/, { + message: "Resource prefix may contain only lowercase letters and numbers", }) .brand<"ResourcePrefix">(); diff --git a/apps/cli/src/use-cases/__tests__/request-authorization.test.ts b/apps/cli/src/use-cases/__tests__/request-authorization.test.ts index 0946a5bcb0..0593efd713 100644 --- a/apps/cli/src/use-cases/__tests__/request-authorization.test.ts +++ b/apps/cli/src/use-cases/__tests__/request-authorization.test.ts @@ -25,6 +25,18 @@ const makeSampleInput = (): RequestAuthorizationInput => }); describe("requestAuthorization", () => { + it("accepts resource prefixes containing numbers", () => { + const result = requestAuthorizationInputSchema.safeParse({ + bootstrapIdentityId: "dxt0-d-itn-bootstrap-id-01", + envShort: "d", + prefix: "dxt0", + repoName: "test-repo", + subscriptionName: "DEV-DEVEX", + }); + + expect(result.success).toBe(true); + }); + it("should return the authorization result on success", async () => { const authorizationService = mock(); const input = makeSampleInput();