diff --git a/.github/skills/dx-cli-companion/SKILL.md b/.github/skills/dx-cli-companion/SKILL.md index 478e101d5e..e583f7a930 100644 --- a/.github/skills/dx-cli-companion/SKILL.md +++ b/.github/skills/dx-cli-companion/SKILL.md @@ -1,6 +1,6 @@ --- name: dx-cli-companion -description: 'Guide the use of the DX CLI for bootstrap tasks. Use when the user wants to inspect the CLI contract with `spec`, create a repository with `init`, or create an environment with `add environment`. Default to the published npm package `@pagopa/dx-cli`, show the exact parameters before execution, ask for missing inputs upfront, and avoid separate prerequisite checks unless the user explicitly asks to run the local compiled JS entrypoint.' +description: 'Guide the use of the DX CLI for bootstrap tasks. Use when the user wants to inspect the CLI contract with `spec`, create a repository with `init`, create an environment with `add environment`, or scaffold multi-tenant environments such as `ced-prod`/`cgn-dev` with custom prefixes. Default to the published npm package `@pagopa/dx-cli`, show the exact parameters before execution, ask for missing inputs upfront, and avoid separate prerequisite checks unless the user explicitly asks to run the local compiled JS entrypoint.' --- # DX CLI Companion @@ -13,6 +13,7 @@ Use this skill when the user wants guided execution of the DX CLI, especially fo - Inspect the command contract with `spec`. - Initialize a new repository with `init`. - Add a deployment environment with `add environment`. +- Add a tenant-qualified environment such as `ced-prod` or `cgn-dev` with a tenant-specific prefix. - Turn an interactive DX CLI flow into a non-interactive command sequence. ## Default Workflow @@ -101,6 +102,7 @@ printf '\nn\n' | CI=1 npx -y @pagopa/dx-cli init \ - Run `spec` first even if the command shape seems obvious. - `--description ""` does not suppress the description prompt; treat an empty description as a deliberate blank-line answer on stdin. - There is no `--no-publish` flag. If the user does not want publication, answer the publish prompt explicitly on stdin instead of guessing. +- Do not try to configure deployment environments, tenants, prefixes, Azure subscriptions, or `infra/bootstrapper/` folders with `init`. Use `add environment` for those concerns. - If the user explicitly wants the local JS entrypoint, replace `npx -y @pagopa/dx-cli` with `node apps/cli/bin/index.js`. ## Preparing `add environment` @@ -109,6 +111,18 @@ printf '\nn\n' | CI=1 npx -y @pagopa/dx-cli init \ - Collect every value needed to avoid follow-up prompts. In practice, expect to ask for the environment name, subscription or account IDs, location mappings, prefix, domain, business unit, management team, auto-confirm choice, and runner app credentials when environment initialization still needs them. - Before execution, show a short preview with the exact resolved values for the current run, including subscription and location pairs plus runner app values when they are in scope. - For a first-time environment setup, ask for the four GitHub Runner App values (`runner-app-id`, `client-id`, `installation-id`, `private-key-path`) before the first `add environment` execution unless the user explicitly confirms runner setup is already initialized and those values are not needed. +- For multi-tenant architectures, collect both the tenant-qualified environment name and the prefix. The environment name identifies the generated GitHub environment and infrastructure folder (for example `ced-prod` creates `infra/bootstrapper/ced-prod` and `infra/core/ced-prod`), while the prefix identifies the Azure resource naming prefix (for example `ced`). These are separate values and are not passed as `name=prefix`. + +### Multi-Tenant Environment Names + +Use tenant-qualified environment names when a repository hosts multiple tenants or product scopes that share the same base lifecycle environment. + +| Input | Meaning | Example | +| --- | --- | --- | +| `--name` | Environment identifier. It can be `dev`, `uat`, `prod`, or a tenant-qualified name ending with `-dev`, `-uat`, or `-prod`. | `ced-prod` | +| `--prefix` | Short 2–4 character tenant/product prefix used in Azure resource names. | `ced` | + +The suffix of `--name` determines the base environment short code used in Azure names: `*-dev` maps to `d`, `*-uat` maps to `u`, and `*-prod` maps to `p`. For example, `--name ced-prod --prefix ced` uses `p` as the environment short code and generates infrastructure under `infra/bootstrapper/ced-prod` and `infra/core/ced-prod`. ### GitHub Runner App Value Sources @@ -145,7 +159,7 @@ Run this command from inside the generated repository: cd /path/to/generated-repo CI=1 npx -y @pagopa/dx-cli add environment \ --account \ - --name \ + --name \ --prefix \ --domain \ --location = \ @@ -170,6 +184,8 @@ If the initialization path requires GitHub Runner App credentials, extend the co - The spec output lists the Runner App flags as required because they are declared with value placeholders, but the runtime only needs them when the initialization flow reaches GitHub Runner App setup. Ask for them upfront when the goal is a fully non-interactive initialization of an environment that still needs setup. - Do not defer Runner App value collection to runtime prompts when performing first-time setup: collect them upfront unless the user explicitly confirms runner setup is already completed for the target environment. - Repeat `--account` and `--location` for multi-subscription environments. +- For multi-tenant environments, repeat the command once per tenant/environment combination, using a different `--name` and `--prefix` when needed. Do not encode both values into one flag. +- If the user wants to minimize resource creation while trying a new tenant-qualified name, run without `-y` first so the DX CLI shows the resources to be created and lets the user decline the initialization confirmation. There is no scaffold-only mode for `add environment`: generating bootstrapper/core files for an uninitialized environment also involves repository environment synchronization and, when confirmed, cloud initialization. - If a subscription was inferred from Azure CLI, show both its ID and name to the user and ask for confirmation before using it. - The current CLI spec does not expose a separate cost center flag. If the user provides a cost center requirement, surface that mismatch and ask how it should be mapped instead of inventing an unsupported flag. - If the user explicitly wants the local JS entrypoint, replace `npx -y @pagopa/dx-cli` with `node /absolute/path/to/apps/cli/bin/index.js`. diff --git a/.nx/version-plans/version-plan-1784551273866.md b/.nx/version-plans/version-plan-1784551273866.md new file mode 100644 index 0000000000..ca16fc9aa2 --- /dev/null +++ b/.nx/version-plans/version-plan-1784551273866.md @@ -0,0 +1,5 @@ +--- +'@pagopa/dx-cli': minor +--- + +Support tenant-qualified environment names in add environment for multi-tenant scaffolding. 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 5a9c6cebfe..7787b01ec9 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 @@ -28,6 +28,7 @@ const { mockCreateKeyVault, mockCreateResourceGroup, mockDeleteResourceGroup, + mockGetIdentity, mockGetSubscription, mockKeyVaultNameAvailability, mockSetSecret, @@ -39,6 +40,7 @@ const { mockCreateKeyVault: vi.fn().mockResolvedValue({}), mockCreateResourceGroup: vi.fn().mockResolvedValue({}), mockDeleteResourceGroup: vi.fn().mockResolvedValue({}), + mockGetIdentity: vi.fn().mockResolvedValue({ clientId: "client-1" }), mockGetSubscription: vi.fn().mockResolvedValue({ tenantId: "tenant-1" }), mockKeyVaultNameAvailability: vi.fn().mockResolvedValue({ nameAvailable: true, @@ -78,6 +80,7 @@ vi.mock("@azure/arm-msi", () => ({ userAssignedIdentities = { createOrUpdate: mockCreateIdentity, + get: mockGetIdentity, }; }, })); @@ -143,6 +146,8 @@ beforeEach(() => { queryResources.mockReset(); queryResources.mockResolvedValue({ data: [], totalRecords: 0 }); mockCreateFederatedIdentityCredential.mockClear(); + mockGetIdentity.mockClear(); + mockGetIdentity.mockResolvedValue({ clientId: "client-1" }); mockCreateIdentity.mockClear(); mockCreateIdentity.mockResolvedValue({ clientId: "client-1", @@ -191,16 +196,25 @@ const expectBootstrapperEnvironmentSecrets = ( { cdClientId = "client-1", ciClientId = "client-1", - }: { cdClientId?: string; ciClientId?: string } = {}, + includesRunnerSecrets = true, + }: { + cdClientId?: string; + ciClientId?: string; + includesRunnerSecrets?: boolean; + } = {}, ) => { const expectedSecrets = [ ["bootstrapper-dev-cd", "ARM_CLIENT_ID", cdClientId], ["bootstrapper-dev-cd", "ARM_TENANT_ID", "tenant-1"], ["bootstrapper-dev-cd", "ARM_SUBSCRIPTION_ID", "sub-1"], - ["bootstrapper-dev-cd", "GH_APP_ID", "app-id"], - ["bootstrapper-dev-cd", "GH_APP_CLIENT_ID", "app-client-id"], - ["bootstrapper-dev-cd", "GH_APP_INSTALLATION_ID", "installation-id"], - ["bootstrapper-dev-cd", "GH_APP_KEY", "private-key"], + ...(includesRunnerSecrets + ? [ + ["bootstrapper-dev-cd", "GH_APP_ID", "app-id"], + ["bootstrapper-dev-cd", "GH_APP_CLIENT_ID", "app-client-id"], + ["bootstrapper-dev-cd", "GH_APP_INSTALLATION_ID", "installation-id"], + ["bootstrapper-dev-cd", "GH_APP_KEY", "private-key"], + ] + : []), ["bootstrapper-dev-ci", "ARM_CLIENT_ID", ciClientId], ["bootstrapper-dev-ci", "ARM_TENANT_ID", "tenant-1"], ["bootstrapper-dev-ci", "ARM_SUBSCRIPTION_ID", "sub-1"], @@ -495,6 +509,61 @@ describe("initialize", () => { }); }); + describe("configureGitHubEnvironment", () => { + test("creates repository-specific federated credentials for existing bootstrap identities", async ({ + cloudAccountService, + }) => { + const createOrUpdateEnvironmentSecret = vi + .fn() + .mockResolvedValue(undefined); + mockGetIdentity + .mockResolvedValueOnce({ clientId: "cd-client-1" }) + .mockResolvedValueOnce({ clientId: "ci-client-1" }); + + await cloudAccountService.configureGitHubEnvironment( + { + csp: "azure", + defaultLocation: "italynorth", + displayName: "Test subscription", + id: "sub-1", + }, + { + name: "dev", + prefix: "dx", + }, + { + owner: "pagopa", + repo: "dx", + }, + { + createBranch: vi.fn(), + createOrUpdateEnvironmentSecret, + createPullRequest: vi.fn(), + getFileContent: vi.fn(), + getRepository: vi.fn(), + updateFile: vi.fn(), + }, + ); + + expect(mockCreateIdentity).not.toHaveBeenCalled(); + expect(mockGetIdentity).toHaveBeenCalledWith( + "dx-d-itn-common-rg-01", + "dx-d-itn-bootstrap-id-01", + ); + expect(mockGetIdentity).toHaveBeenCalledWith( + "dx-d-itn-common-rg-01", + "dx-d-itn-bootstrap-ci-id-01", + ); + expect(mockCreateFederatedIdentityCredential).toHaveBeenCalledTimes(2); + expectBootstrapperFederatedCredentials("dx"); + expectBootstrapperEnvironmentSecrets(createOrUpdateEnvironmentSecret, { + cdClientId: "cd-client-1", + ciClientId: "ci-client-1", + includesRunnerSecrets: false, + }); + }); + }); + test("uses a repository-specific federated credential name", async ({ cloudAccountService, }) => { diff --git a/apps/cli/src/adapters/azure/cloud-account-service.ts b/apps/cli/src/adapters/azure/cloud-account-service.ts index b46a89c2be..6fbb1e02fa 100644 --- a/apps/cli/src/adapters/azure/cloud-account-service.ts +++ b/apps/cli/src/adapters/azure/cloud-account-service.ts @@ -23,8 +23,8 @@ import { } from "../../domain/cloud-account.js"; import { type EnvironmentId, - environmentShort, EnvironmentShortValue, + getEnvironmentShort, } from "../../domain/environment.js"; import { type GitHubRepo } from "../../domain/github-repo.js"; import { @@ -100,6 +100,9 @@ type BootstrapperIdentityParameters = { tags: Record; }; +type BootstrapperResourceGroupName = + `${string}-${EnvironmentShortValue}-${LocationShortValue}-common-rg-${string}`; + const keyVaultDnsErrorSchema = z.object({ code: z.enum(["EAI_AGAIN", "ENOTFOUND"]), }); @@ -133,12 +136,74 @@ export class AzureCloudAccountService implements CloudAccountService { this.#credential = credential; } + async configureGitHubEnvironment( + cloudAccount: CloudAccount, + { name, prefix }: EnvironmentId, + github: GitHubRepo, + gitHubService: GitHubService, + runnerAppCredentials?: GitHubAppCredentials, + ): Promise { + assert.equal(cloudAccount.csp, "azure", "Cloud account must be Azure"); + assert.ok( + isAzureLocation(cloudAccount.defaultLocation), + "The default location of the cloud account is not a valid Azure location", + ); + + const short = { + env: getEnvironmentShort(name), + location: locationShort[cloudAccount.defaultLocation], + }; + const resourceGroupName: BootstrapperResourceGroupName = `${prefix}-${short.env}-${short.location}-common-rg-01`; + const cdIdentityName: BootstrapperIdentityName = `${prefix}-${short.env}-${short.location}-bootstrap-id-01`; + const ciIdentityName: BootstrapperIdentityName = `${prefix}-${short.env}-${short.location}-bootstrap-ci-id-01`; + const msiClient = new ManagedServiceIdentityClient( + this.#credential, + cloudAccount.id, + ); + + const [cdIdentity, ciIdentity] = await Promise.all([ + this.#getBootstrapperIdentity({ + cloudAccountId: cloudAccount.id, + identityName: cdIdentityName, + msiClient, + resourceGroupName, + }), + this.#getBootstrapperIdentity({ + cloudAccountId: cloudAccount.id, + identityName: ciIdentityName, + msiClient, + resourceGroupName, + }), + ]); + + const subscriptionClient = new SubscriptionClient(this.#credential); + const subscription = await subscriptionClient.subscriptions.get( + cloudAccount.id, + ); + assert.ok(subscription.tenantId, "Subscription tenant ID is undefined"); + + await this.#configureBootstrapperGitHubEnvironments({ + cdIdentityClientId: cdIdentity.clientId, + cdIdentityName: cdIdentity.name, + ciIdentityClientId: ciIdentity.clientId, + ciIdentityName: ciIdentity.name, + cloudAccountId: cloudAccount.id, + github, + gitHubService, + msiClient, + name, + resourceGroupName, + runnerAppCredentials, + tenantId: subscription.tenantId, + }); + } + async getTerraformBackend( cloudAccountId: CloudAccount["id"], { name, prefix }: EnvironmentId, ): Promise { const allLocations = Object.values(locationShort).join("|"); - const shortEnv = environmentShort[name]; + const shortEnv = getEnvironmentShort(name); // Check if a storage account with the expected name exists // $prefix + environment short + location + "tfstatest" + suffix (e.g., "dxpitntfstatest01") // it can return multiple results (e.g. for different location or instance number) @@ -272,11 +337,11 @@ export class AzureCloudAccountService implements CloudAccountService { ); const short = { - env: environmentShort[name], + env: getEnvironmentShort(name), location: locationShort[cloudAccount.defaultLocation], }; - const resourceGroupName = `${prefix}-${short.env}-${short.location}-common-rg-01`; + const resourceGroupName: BootstrapperResourceGroupName = `${prefix}-${short.env}-${short.location}-common-rg-01`; const parameters = { location: cloudAccount.defaultLocation, @@ -401,7 +466,7 @@ export class AzureCloudAccountService implements CloudAccountService { { name, prefix }: EnvironmentId, ): Promise { const allLocations = Object.values(locationShort).join("|"); - const shortEnv = environmentShort[name]; + const shortEnv = getEnvironmentShort(name); const identityResourceName = `${prefix}-${shortEnv}-(${allLocations})-bootstrap-id-(0[1-9]|[1-9]\\d)`; const identityQuery = `resources @@ -462,7 +527,7 @@ export class AzureCloudAccountService implements CloudAccountService { ); const short = { - env: environmentShort[name], + env: getEnvironmentShort(name), location: locationShort[cloudAccount.defaultLocation], }; @@ -570,7 +635,7 @@ export class AzureCloudAccountService implements CloudAccountService { scope, }: { authorizationManagementClient: AuthorizationManagementClient; - cloudAccountId: string; + cloudAccountId: CloudAccount["id"]; identity: BootstrapperIdentity; roleDefinitionIds: readonly string[]; scope: string; @@ -622,13 +687,13 @@ export class AzureCloudAccountService implements CloudAccountService { cdIdentityName: string; ciIdentityClientId: string; ciIdentityName: string; - cloudAccountId: string; + cloudAccountId: CloudAccount["id"]; github: GitHubRepo; gitHubService: GitHubService; msiClient: ManagedServiceIdentityClient; name: EnvironmentId["name"]; - resourceGroupName: string; - runnerAppCredentials: GitHubAppCredentials; + resourceGroupName: BootstrapperResourceGroupName; + runnerAppCredentials?: GitHubAppCredentials; tenantId: string; }): Promise { const logger = getLogger(["gen", "env"]); @@ -696,11 +761,11 @@ export class AzureCloudAccountService implements CloudAccountService { parameters, resourceGroupName, }: { - cloudAccountId: string; + cloudAccountId: CloudAccount["id"]; identityName: BootstrapperIdentityName; msiClient: ManagedServiceIdentityClient; parameters: BootstrapperIdentityParameters; - resourceGroupName: string; + resourceGroupName: BootstrapperResourceGroupName; }): Promise { const logger = getLogger(["gen", "env"]); const identity = await msiClient.userAssignedIdentities.createOrUpdate( @@ -740,7 +805,7 @@ export class AzureCloudAccountService implements CloudAccountService { cloudAccount: CloudAccount; name: EnvironmentId["name"]; prefix: string; - resourceGroupName: string; + resourceGroupName: BootstrapperResourceGroupName; shortEnv: string; shortLocation: string; tags: Record; @@ -827,6 +892,36 @@ export class AzureCloudAccountService implements CloudAccountService { return `/subscriptions/${subscriptionId}/providers/Microsoft.Authorization/roleDefinitions/${roleDefinitionId}`; } + async #getBootstrapperIdentity({ + cloudAccountId, + identityName, + msiClient, + resourceGroupName, + }: { + cloudAccountId: CloudAccount["id"]; + identityName: BootstrapperIdentityName; + msiClient: ManagedServiceIdentityClient; + resourceGroupName: BootstrapperResourceGroupName; + }): Promise> { + const logger = getLogger(["gen", "env"]); + const identity = await msiClient.userAssignedIdentities.get( + resourceGroupName, + identityName, + ); + + assert.ok(identity.clientId, "Managed identity client ID is undefined"); + + logger.debug( + "Read identity {identityName} in subscription {subscriptionId}", + { identityName, subscriptionId: cloudAccountId }, + ); + + return { + clientId: identity.clientId, + name: identityName, + }; + } + async #getCurrentPrincipalIds(): Promise> { // Create Graph client with custom auth provider that fetches fresh tokens const graphClient = Client.init({ diff --git a/apps/cli/src/adapters/commander/commands/__tests__/add-command.test.ts b/apps/cli/src/adapters/commander/commands/__tests__/add-command.test.ts index 2df10c8eba..a4df7eae45 100644 --- a/apps/cli/src/adapters/commander/commands/__tests__/add-command.test.ts +++ b/apps/cli/src/adapters/commander/commands/__tests__/add-command.test.ts @@ -272,3 +272,47 @@ describe("makeAddCommand", () => { ).toThrow(/Invalid add environment command options/); }); }); + +describe("makeAddCommand tenant-qualified environments", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getPlopInstance.mockResolvedValue({}); + mocks.collectDeploymentEnvironmentPayload.mockResolvedValue({ + generator: {}, + payload, + }); + mocks.runDeploymentEnvironmentActions.mockResolvedValue(payload); + vi.spyOn(console, "log").mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("accepts tenant-qualified environment names for multi-tenant scaffolding", async () => { + const program = makeProgram(); + + await program.parseAsync([ + "node", + "dx", + "add", + "environment", + "--name", + "CED-Prod", + "--prefix", + "CED", + ]); + + expect(mocks.collectDeploymentEnvironmentPayload).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + undefined, + { + env: { + name: "ced-prod", + prefix: "ced", + }, + }, + ); + }); +}); diff --git a/apps/cli/src/adapters/commander/commands/add.ts b/apps/cli/src/adapters/commander/commands/add.ts index d61c311da1..41df24baa3 100644 --- a/apps/cli/src/adapters/commander/commands/add.ts +++ b/apps/cli/src/adapters/commander/commands/add.ts @@ -29,7 +29,7 @@ import { import { GitHubAuthFactory } from "../../../domain/dependencies.js"; import { environmentSchema, - environmentShort, + getEnvironmentShort, } from "../../../domain/environment.js"; import { type GitHubService } from "../../../domain/github.js"; import { @@ -263,7 +263,7 @@ export const authorizeCloudAccounts = const logger = getLogger(["dx-cli", "add-environment"]); const { name, prefix } = envPayload.env; - const envShort = environmentShort[name]; + const envShort = getEnvironmentShort(name); const requestAll = async (): Promise => { const results: AuthorizationResult[] = []; @@ -419,11 +419,10 @@ export const makeAddCommand = ( new Command("environment") .description("Add a new deployment environment") .addOption( - new Option("--name ", "Environment name").choices([ - "dev", - "uat", - "prod", - ]), + new Option( + "--name ", + "Environment name (dev, uat, prod, or tenant-qualified like cgn-prod)", + ), ) .addOption( new Option( diff --git a/apps/cli/src/adapters/plop/actions/__tests__/configure-github-environments.test.ts b/apps/cli/src/adapters/plop/actions/__tests__/configure-github-environments.test.ts new file mode 100644 index 0000000000..fd2892d50e --- /dev/null +++ b/apps/cli/src/adapters/plop/actions/__tests__/configure-github-environments.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + CloudAccount, + CloudAccountService, +} from "../../../../domain/cloud-account.js"; +import { GitHubService } from "../../../../domain/github.js"; +import { Payload } from "../../generators/environment/prompts.js"; +import { configureGitHubEnvironments } from "../configure-github-environments.js"; + +const createMockGitHubService = (): GitHubService => ({ + createBranch: vi.fn(), + createOrUpdateEnvironmentSecret: vi.fn().mockResolvedValue(undefined), + createPullRequest: vi.fn(), + getFileContent: vi.fn(), + getRepository: vi.fn(), + updateFile: vi.fn(), +}); + +const createMockCloudAccount = ( + overrides: Partial = {}, +): CloudAccount => ({ + csp: "azure", + defaultLocation: "westeurope", + displayName: "Test-Account", + id: "test-subscription-id", + ...overrides, +}); + +const createMockPayload = (overrides: Partial = {}): Payload => ({ + env: { + cloudAccounts: [createMockCloudAccount()], + name: "dev", + prefix: "dx", + }, + github: { + owner: "pagopa", + repo: "dx", + }, + tags: {}, + workspace: { + domain: "test", + }, + ...overrides, +}); + +const createMockCloudAccountService = ( + configureGitHubEnvironment: CloudAccountService["configureGitHubEnvironment"], +): CloudAccountService => ({ + configureGitHubEnvironment, + getTerraformBackend: vi.fn().mockResolvedValue(undefined), + hasUserPermissionToInitialize: vi.fn().mockResolvedValue(true), + initialize: vi.fn().mockResolvedValue(undefined), + isInitialized: vi.fn().mockResolvedValue(true), + provisionTerraformBackend: vi.fn().mockResolvedValue(undefined), +}); + +describe("configureGitHubEnvironments", () => { + it("configures GitHub environment access for every selected cloud account", async () => { + const account1 = createMockCloudAccount({ id: "account-1" }); + const account2 = createMockCloudAccount({ id: "account-2" }); + const configureGitHubEnvironment = vi.fn().mockResolvedValue(undefined); + const cloudAccountService = createMockCloudAccountService( + configureGitHubEnvironment, + ); + const gitHubService = createMockGitHubService(); + const payload = createMockPayload({ + env: { + cloudAccounts: [account1, account2], + name: "ced-prod", + prefix: "ced", + }, + }); + + await configureGitHubEnvironments( + payload, + cloudAccountService, + gitHubService, + ); + + expect(configureGitHubEnvironment).toHaveBeenCalledTimes(2); + expect(configureGitHubEnvironment).toHaveBeenCalledWith( + account1, + payload.env, + payload.github, + gitHubService, + undefined, + ); + expect(configureGitHubEnvironment).toHaveBeenCalledWith( + account2, + payload.env, + payload.github, + gitHubService, + undefined, + ); + }); + + it("passes runner app credentials when the environment was initialized in the same run", async () => { + const configureGitHubEnvironment = vi.fn().mockResolvedValue(undefined); + const cloudAccountService = createMockCloudAccountService( + configureGitHubEnvironment, + ); + const gitHubService = createMockGitHubService(); + const runnerAppCredentials = { + clientId: "client-id", + id: "app-id", + installationId: "installation-id", + key: "private-key", + }; + const payload = createMockPayload({ + init: { + cloudAccountsToInitialize: [createMockCloudAccount()], + runnerAppCredentials, + }, + }); + + await configureGitHubEnvironments( + payload, + cloudAccountService, + gitHubService, + ); + + expect(configureGitHubEnvironment).toHaveBeenCalledWith( + expect.any(Object), + payload.env, + payload.github, + gitHubService, + runnerAppCredentials, + ); + }); +}); diff --git a/apps/cli/src/adapters/plop/actions/__tests__/init-cloud-accounts.test.ts b/apps/cli/src/adapters/plop/actions/__tests__/init-cloud-accounts.test.ts index b2f185014a..a0b62bde8d 100644 --- a/apps/cli/src/adapters/plop/actions/__tests__/init-cloud-accounts.test.ts +++ b/apps/cli/src/adapters/plop/actions/__tests__/init-cloud-accounts.test.ts @@ -20,6 +20,7 @@ const createMockGitHubService = (): GitHubService => ({ const createMockCloudAccountService = ( overrides: Partial = {}, ): CloudAccountService => ({ + configureGitHubEnvironment: vi.fn().mockResolvedValue(undefined), getTerraformBackend: vi.fn().mockResolvedValue(undefined), hasUserPermissionToInitialize: vi.fn().mockResolvedValue(true), initialize: vi.fn().mockResolvedValue(undefined), diff --git a/apps/cli/src/adapters/plop/actions/__tests__/provision-terraform-backend.test.ts b/apps/cli/src/adapters/plop/actions/__tests__/provision-terraform-backend.test.ts index c0e0af8465..7af282d702 100644 --- a/apps/cli/src/adapters/plop/actions/__tests__/provision-terraform-backend.test.ts +++ b/apps/cli/src/adapters/plop/actions/__tests__/provision-terraform-backend.test.ts @@ -11,6 +11,7 @@ import { provisionTerraformBackend } from "../provision-terraform-backend.js"; const createMockCloudAccountService = ( overrides: Partial = {}, ): CloudAccountService => ({ + configureGitHubEnvironment: vi.fn().mockResolvedValue(undefined), getTerraformBackend: vi.fn().mockResolvedValue(undefined), hasUserPermissionToInitialize: vi.fn().mockResolvedValue(true), initialize: vi.fn().mockResolvedValue(undefined), 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 index 8281c31651..ff6d351654 100644 --- 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 @@ -47,6 +47,17 @@ describe("syncRepositoryTerraformEnvironments", () => { expect(result).toContain(' environments = ["uat", "prod"]'); }); + it("adds a tenant-qualified environment while preserving the implicit prod default", () => { + const result = syncRepositoryTerraformEnvironments( + repositoryConfig, + "ced-prod", + ); + + expect(result).toContain( + ' environments = ["prod", "ced-prod"]', + ); + }); + it("preserves prod when an existing explicit list does not include it", () => { const content = repositoryConfig.replace( " reviewers_teams = []\n", diff --git a/apps/cli/src/adapters/plop/actions/configure-github-environments.ts b/apps/cli/src/adapters/plop/actions/configure-github-environments.ts new file mode 100644 index 0000000000..950e6983ba --- /dev/null +++ b/apps/cli/src/adapters/plop/actions/configure-github-environments.ts @@ -0,0 +1,44 @@ +import { type NodePlopAPI } from "node-plop"; + +import { CloudAccountService } from "../../../domain/cloud-account.js"; +import { type GitHubService } from "../../../domain/github.js"; +import { + type Payload, + payloadSchema, +} from "../generators/environment/prompts.js"; + +export const configureGitHubEnvironments = async ( + payload: Payload, + cloudAccountService: CloudAccountService, + gitHubService: GitHubService, +) => { + const runnerAppCredentials = payload.init?.runnerAppCredentials; + + await Promise.all( + payload.env.cloudAccounts.map((cloudAccount) => + cloudAccountService.configureGitHubEnvironment( + cloudAccount, + payload.env, + payload.github, + gitHubService, + runnerAppCredentials, + ), + ), + ); +}; + +export default function ( + plop: NodePlopAPI, + cloudAccountService: CloudAccountService, + gitHubService: GitHubService, +) { + plop.setActionType("configureGitHubEnvironments", async (data) => { + const payload = payloadSchema.parse(data); + await configureGitHubEnvironments( + payload, + cloudAccountService, + gitHubService, + ); + return "GitHub environments configured"; + }); +} diff --git a/apps/cli/src/adapters/plop/actions/sync-repository-environments.ts b/apps/cli/src/adapters/plop/actions/sync-repository-environments.ts index 0a8fae90ce..04f40d3daf 100644 --- a/apps/cli/src/adapters/plop/actions/sync-repository-environments.ts +++ b/apps/cli/src/adapters/plop/actions/sync-repository-environments.ts @@ -19,11 +19,13 @@ import { payloadSchema, } from "../generators/environment/prompts.js"; -const KNOWN_ENVIRONMENTS: Environment["name"][] = ["dev", "uat", "prod"]; +const KNOWN_ENVIRONMENTS = ["dev", "uat", "prod"] as const; const KNOWN_ENVIRONMENT_NAMES: readonly string[] = KNOWN_ENVIRONMENTS; const environmentList = (environments: Set): string => `[${[ + // Keep the historical lifecycle environments first for stable diffs, then + // append tenant-qualified names in their existing insertion order. ...KNOWN_ENVIRONMENTS.filter((environment) => environments.has(environment), ), 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 7ced2e147f..82c4b5e279 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 @@ -67,6 +67,7 @@ describe("actions", () => { const actionsOrder = [ "getTerraformBackend", "syncRepositoryEnvironments", + "configureGitHubEnvironments", "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 e391327f85..9c19389bd1 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 @@ -18,6 +18,7 @@ import type { CloudAccountService } from "../../../../../domain/cloud-account.js import type { GitHubService } from "../../../../../domain/github.js"; import type { TerraformBackend } from "../../../../../domain/remote-backend.js"; +import setConfigureGitHubEnvironmentsAction from "../../../actions/configure-github-environments.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"; @@ -61,6 +62,11 @@ const registerEnvironmentSetup = ( setGetTerraformBackend(plop, mockCloudAccountService); setProvisionTerraformBackendAction(plop, mockCloudAccountService); setInitCloudAccountsAction(plop, mockCloudAccountService, mockGitHubService); + setConfigureGitHubEnvironmentsAction( + plop, + mockCloudAccountService, + mockGitHubService, + ); setSyncRepositoryEnvironmentsAction(plop); }; @@ -75,6 +81,7 @@ const createMockCloudAccountService = ( backend: TerraformBackend, isInitialized: boolean, ): CloudAccountService => ({ + configureGitHubEnvironment: vi.fn().mockResolvedValue(undefined), getTerraformBackend: vi.fn().mockResolvedValue(backend), hasUserPermissionToInitialize: vi.fn().mockResolvedValue(true), initialize: vi.fn().mockResolvedValue(undefined), 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 7a8620d6a1..b432becadc 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 @@ -142,6 +142,7 @@ describe("prompts with prefilled answers", () => { }; const cloudAccountService: CloudAccountService = { + configureGitHubEnvironment: vi.fn().mockResolvedValue(undefined), getTerraformBackend: vi.fn().mockResolvedValue({ resourceGroupName: "rg-test", storageAccountName: "sttest", @@ -249,6 +250,7 @@ describe("prompts", () => { }; const cloudAccountService: CloudAccountService = { + configureGitHubEnvironment: vi.fn().mockResolvedValue(undefined), getTerraformBackend: vi.fn().mockResolvedValue(undefined), hasUserPermissionToInitialize: vi.fn().mockResolvedValue(true), initialize: vi.fn().mockResolvedValue(undefined), @@ -318,6 +320,7 @@ describe("prompts", () => { }; const cloudAccountService: CloudAccountService = { + configureGitHubEnvironment: vi.fn().mockResolvedValue(undefined), getTerraformBackend: vi.fn().mockResolvedValue(undefined), hasUserPermissionToInitialize: vi.fn().mockResolvedValue(true), initialize: vi.fn().mockResolvedValue(undefined), @@ -415,6 +418,7 @@ describe("prompts", () => { }; const cloudAccountService: CloudAccountService = { + configureGitHubEnvironment: vi.fn().mockResolvedValue(undefined), getTerraformBackend: vi.fn().mockResolvedValue(undefined), hasUserPermissionToInitialize: vi.fn().mockResolvedValue(false), initialize: vi.fn().mockResolvedValue(undefined), @@ -489,6 +493,7 @@ describe("prompts with prefilled initialization answers", () => { }; const cloudAccountService: CloudAccountService = { + configureGitHubEnvironment: vi.fn().mockResolvedValue(undefined), getTerraformBackend: vi.fn().mockResolvedValue(undefined), hasUserPermissionToInitialize: vi.fn().mockResolvedValue(true), initialize: vi.fn().mockResolvedValue(undefined), diff --git a/apps/cli/src/adapters/plop/generators/environment/actions.ts b/apps/cli/src/adapters/plop/generators/environment/actions.ts index ee57a07688..e57310feba 100644 --- a/apps/cli/src/adapters/plop/generators/environment/actions.ts +++ b/apps/cli/src/adapters/plop/generators/environment/actions.ts @@ -85,6 +85,12 @@ export default function getActions( { type: "syncRepositoryEnvironments", }, + // Bootstrap Azure resources can pre-exist, but OIDC credentials and + // GitHub environment secrets are repository-specific and must be ensured + // on every add-environment run. + { + type: "configureGitHubEnvironments", + }, addWorkflowModule(templatesPath), ...addEnvironmentModule("bootstrapper"), ]; diff --git a/apps/cli/src/adapters/plop/generators/environment/prompts.ts b/apps/cli/src/adapters/plop/generators/environment/prompts.ts index 738df2f6fd..e2f79097cb 100644 --- a/apps/cli/src/adapters/plop/generators/environment/prompts.ts +++ b/apps/cli/src/adapters/plop/generators/environment/prompts.ts @@ -174,15 +174,12 @@ const getBaseQuestions = ( if (initialAnswers.env?.name === undefined) { questions.push({ - choices: [ - { name: "PROD", value: "prod" }, - { name: "UAT", value: "uat" }, - { name: "DEV", value: "dev" }, - ], default: "prod", - message: "Environment name", + filter: (value: string) => value.trim().toLowerCase(), + message: "Environment name (dev, uat, prod, or tenant-env like cgn-prod)", name: "env.name", - type: "list", + type: "input", + validate: validatePrompt(environmentSchema.shape.name), }); } diff --git a/apps/cli/src/adapters/plop/helpers/__tests__/resource-prefix.test.ts b/apps/cli/src/adapters/plop/helpers/__tests__/resource-prefix.test.ts index 3c260f3710..ff30499f1e 100644 --- a/apps/cli/src/adapters/plop/helpers/__tests__/resource-prefix.test.ts +++ b/apps/cli/src/adapters/plop/helpers/__tests__/resource-prefix.test.ts @@ -133,4 +133,21 @@ describe("resourcePrefix", () => { expect(result).toBe("ab-d"); }); + + it("should derive the environment short code from tenant-qualified environment names", () => { + const payload = createMockPayload({ + env: { + cloudAccounts: [], + name: "ced-prod", + prefix: "ced", + }, + workspace: { + domain: "bonus", + }, + }); + + const result = resourcePrefix(payload); + + expect(result).toBe("ced-p-bonus"); + }); }); diff --git a/apps/cli/src/adapters/plop/helpers/env-short.ts b/apps/cli/src/adapters/plop/helpers/env-short.ts index b641205672..aa87137b5c 100644 --- a/apps/cli/src/adapters/plop/helpers/env-short.ts +++ b/apps/cli/src/adapters/plop/helpers/env-short.ts @@ -3,13 +3,13 @@ import * as assert from "node:assert/strict"; import { environmentSchema, - environmentShort, + getEnvironmentShort, } from "../../../domain/environment.js"; export default (plop: NodePlopAPI) => { plop.setHelper("envShort", (ctx) => { const result = environmentSchema.shape.name.safeParse(ctx); assert.ok(result.success, "envShort: Invalid Environment name provided"); - return environmentShort[result.data]; + return getEnvironmentShort(result.data); }); }; diff --git a/apps/cli/src/adapters/plop/helpers/resource-prefix.ts b/apps/cli/src/adapters/plop/helpers/resource-prefix.ts index 631c6d0a6b..260aa58dce 100644 --- a/apps/cli/src/adapters/plop/helpers/resource-prefix.ts +++ b/apps/cli/src/adapters/plop/helpers/resource-prefix.ts @@ -1,14 +1,14 @@ import { type NodePlopAPI } from "node-plop"; import * as assert from "node:assert/strict"; -import { environmentShort } from "../../../domain/environment.js"; +import { getEnvironmentShort } from "../../../domain/environment.js"; import { Payload, payloadSchema } from "../generators/environment/prompts.js"; export const resourcePrefix = ( payload: Pick, ): string => { const { env, workspace } = payload; - const prefix = [env.prefix, environmentShort[env.name]]; + const prefix = [env.prefix, getEnvironmentShort(env.name)]; if (workspace.domain) { prefix.push(workspace.domain); } diff --git a/apps/cli/src/adapters/plop/index.ts b/apps/cli/src/adapters/plop/index.ts index 3f5602260c..6b714ef1ac 100644 --- a/apps/cli/src/adapters/plop/index.ts +++ b/apps/cli/src/adapters/plop/index.ts @@ -21,6 +21,7 @@ import createMonorepoGenerator, { payloadSchema as monorepoPayloadSchema, PLOP_MONOREPO_GENERATOR_NAME, } from "../plop/generators/monorepo/index.js"; +import setConfigureGitHubEnvironmentsAction from "./actions/configure-github-environments.js"; import { resolveTemplatesPath } from "./templates-path.js"; export const setMonorepoGenerator = ( @@ -198,6 +199,12 @@ export const setDeploymentEnvironmentGenerator = ( const cloudAccountRepository = new AzureSubscriptionRepository(credential); const cloudAccountService = new AzureCloudAccountService(credential); + setConfigureGitHubEnvironmentsAction( + plop, + cloudAccountService, + gitHubService, + ); + createDeploymentEnvironmentGenerator( plop, resolveTemplatesPath("environment"), diff --git a/apps/cli/src/domain/__tests__/environment.test.ts b/apps/cli/src/domain/__tests__/environment.test.ts index a92644e0d8..e01dbf9dc3 100644 --- a/apps/cli/src/domain/__tests__/environment.test.ts +++ b/apps/cli/src/domain/__tests__/environment.test.ts @@ -4,6 +4,8 @@ import { CloudAccount, CloudAccountService } from "../cloud-account.js"; import { Environment, environmentSchema, + getBaseEnvironmentName, + getEnvironmentShort, getInitializationStatus, getTerraformBackend, hasUserPermissionToInitialize, @@ -13,6 +15,7 @@ import { TerraformBackend } from "../remote-backend.js"; const createMockCloudAccountService = ( overrides: Partial = {}, ): CloudAccountService => ({ + configureGitHubEnvironment: vi.fn().mockResolvedValue(undefined), getTerraformBackend: vi.fn().mockResolvedValue(undefined), hasUserPermissionToInitialize: vi.fn().mockResolvedValue(true), initialize: vi.fn().mockResolvedValue(undefined), @@ -375,7 +378,7 @@ describe("environmentSchema — prefix transforms", () => { id: "test-account-id", }, ], - name: "dev" as const, + name: "dev", prefix: "dx", }; @@ -429,3 +432,52 @@ describe("environmentSchema — prefix transforms", () => { expect(result.success && result.data.prefix).toBe("dx"); }); }); + +describe("environmentSchema — tenant-qualified names", () => { + const baseEnv = { + cloudAccounts: [ + { + csp: "azure" as const, + defaultLocation: "westeurope", + displayName: "Test Account", + id: "test-account-id", + }, + ], + name: "prod", + prefix: "dx", + }; + + it("accepts tenant-qualified environment names ending with a known environment", () => { + const result = environmentSchema.safeParse({ + ...baseEnv, + name: "ced-prod", + }); + + expect(result.success).toBe(true); + expect(result.success && result.data.name).toBe("ced-prod"); + }); + + it("trims and lowercases tenant-qualified environment names", () => { + const result = environmentSchema.safeParse({ + ...baseEnv, + name: " CED-Prod ", + }); + + expect(result.success).toBe(true); + expect(result.success && result.data.name).toBe("ced-prod"); + }); + + it("rejects names that do not end with dev, prod, or uat", () => { + const result = environmentSchema.safeParse({ + ...baseEnv, + name: "ced-test", + }); + + expect(result.success).toBe(false); + }); + + it("derives the base environment from tenant-qualified names", () => { + expect(getBaseEnvironmentName("ced-prod")).toBe("prod"); + expect(getEnvironmentShort("ced-prod")).toBe("p"); + }); +}); diff --git a/apps/cli/src/domain/cloud-account.ts b/apps/cli/src/domain/cloud-account.ts index 675d547a8e..8a99488b1c 100644 --- a/apps/cli/src/domain/cloud-account.ts +++ b/apps/cli/src/domain/cloud-account.ts @@ -19,6 +19,14 @@ export type CloudAccountRepository = { }; export type CloudAccountService = { + configureGitHubEnvironment( + cloudAccount: CloudAccount, + environment: EnvironmentId, + github: GitHubRepo, + gitHubService: GitHubService, + runnerAppCredentials?: GitHubAppCredentials, + ): Promise; + getTerraformBackend( cloudAccountId: CloudAccount["id"], environment: EnvironmentId, diff --git a/apps/cli/src/domain/environment.ts b/apps/cli/src/domain/environment.ts index 74d2e4b99c..7f8acf5589 100644 --- a/apps/cli/src/domain/environment.ts +++ b/apps/cli/src/domain/environment.ts @@ -7,18 +7,41 @@ import { } from "./cloud-account.js"; import { TerraformBackend } from "./remote-backend.js"; +export const baseEnvironmentNames = ["dev", "prod", "uat"] as const; + export const environmentShort = { dev: "d", prod: "p", uat: "u", -} as const satisfies Record; +} as const satisfies Record; +export type BaseEnvironmentName = (typeof baseEnvironmentNames)[number]; export type EnvironmentShortValue = (typeof environmentShort)[keyof typeof environmentShort]; +const baseEnvironmentNameSchema = z.enum(baseEnvironmentNames); + +const tenantNameSchema = z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/); + +const tenantQualifiedEnvironmentNameSchema = z.templateLiteral([ + tenantNameSchema, + z.literal("-"), + baseEnvironmentNameSchema, +]); + +const environmentNameSchema = z + .string() + .trim() + .toLowerCase() + // Keep the lifecycle suffix explicit so tenant-qualified names such as + // `ced-prod` can still reuse the same Azure short code as `prod`. + .pipe( + z.union([baseEnvironmentNameSchema, tenantQualifiedEnvironmentNameSchema]), + ); + export const environmentSchema = z.object({ cloudAccounts: z.array(cloudAccountSchema).min(1), - name: z.enum(["dev", "prod", "uat"]), + name: environmentNameSchema, // Trim and lowercase at the schema level so the constraint is enforced // regardless of how the data enters the system (prompt, test, or API). prefix: z @@ -33,6 +56,20 @@ export type Environment = z.infer; export type EnvironmentId = Pick; +export const getBaseEnvironmentName = ( + environmentName: Environment["name"], +): BaseEnvironmentName => { + // Tenant-qualified environments encode the lifecycle as the last segment: + // `cgn-dev` behaves like `dev`, `ced-prod` behaves like `prod`. + const baseName = environmentName.split("-").at(-1); + return baseEnvironmentNameSchema.parse(baseName); +}; + +export const getEnvironmentShort = ( + environmentName: Environment["name"], +): EnvironmentShortValue => + environmentShort[getBaseEnvironmentName(environmentName)]; + export type EnvironmentInitStatus = | { initialized: false; diff --git a/apps/website/docs/dx-cli/usage.md b/apps/website/docs/dx-cli/usage.md index fad6659d7f..5e500c2d7c 100644 --- a/apps/website/docs/dx-cli/usage.md +++ b/apps/website/docs/dx-cli/usage.md @@ -100,7 +100,7 @@ prompt expects and where to find the required values. | Prompt | What to enter | | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -| **Environment name** | Select `PROD`, `UAT`, or `DEV`. | +| **Environment name** | Enter `prod`, `uat`, `dev`, or a tenant-qualified name ending with one of them (for example `ced-prod` or `cgn-dev`). | | **Cloud provider(s)** | Select `Microsoft Azure` (currently the only supported provider). | | **Account(s)** | Select one or more Azure subscriptions belonging to your product. Contact your Engineering Leader if you are unsure which subscriptions to pick. | | **Prefix** | A short identifier (2–4 characters) used in Azure resource names. It should match the prefix already in use for your product (e.g., `dx`, `io`, `pn`). | @@ -110,6 +110,38 @@ prompt expects and where to find the required values. | **Management team** | The team responsible for managing the environment (free text, e.g., `devex`). | | **Default location for \** | The primary Azure region for the selected account (e.g., `Italy North`). Asked once per selected account. | +#### Multi-tenant environments + +Use a tenant-qualified environment name when the same monorepo needs separate +deployment environments for different tenants or product scopes. + +```bash +npx @pagopa/dx-cli add environment \ + --name ced-prod \ + --prefix ced \ + --account \ + --location =italynorth \ + --domain bonus \ + --business-unit devex \ + --management-team devex +``` + +The `--name` and `--prefix` values have different purposes: + +| Option | Purpose | Example | +| ---------- | -------------------------------------------------------------------------------------------------------------------------------------- | ---------- | +| `--name` | GitHub environment and infrastructure folder name. It can be `dev`, `uat`, `prod`, or a tenant-qualified name ending with one of them. | `ced-prod` | +| `--prefix` | Short 2–4 character prefix used in Azure resource names. | `ced` | + +The lifecycle suffix determines the Azure environment short code used by the +generated resources: `*-dev` maps to `d`, `*-uat` maps to `u`, and `*-prod` maps +to `p`. For example, `--name ced-prod --prefix ced` creates files under +`infra/bootstrapper/ced-prod` and `infra/core/ced-prod`, while Azure resource +names use the `ced-p-...` prefix convention. + +Run `add environment` once for each tenant/environment pair you need, changing +both `--name` and `--prefix` when the tenant changes. + **Initialization** _(conditional — only asked when the environment is new)_ | Prompt | What to enter | @@ -128,7 +160,7 @@ npx @pagopa/dx-cli add environment ✔ Terraform is installed! ✔ You are logged in to Azure (andrea.grillo@pagopa.it) -? Environment name PROD +? Environment name prod ? Cloud provider(s) Microsoft Azure ? Account(s) (Press to select, to toggle all, to invert selection, and to proceed) ◯ DEV-ENGINEERING