@pagopa/dx-cli 0.20.0 → 0.20.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import { DefaultAzureCredential } from "@azure/identity";
2
- import { test as baseTest, describe, expect, vi } from "vitest";
2
+ import { test as baseTest, beforeEach, describe, expect, vi } from "vitest";
3
3
  import { AzureCloudAccountService } from "../cloud-account-service.js";
4
4
  const { queryResources } = vi.hoisted(() => ({
5
5
  queryResources: vi.fn().mockRejectedValue(new Error("Not implemented")),
@@ -10,6 +10,50 @@ const { mockProviderGet, mockProviderRegister } = vi.hoisted(() => ({
10
10
  .mockResolvedValue({ registrationState: "Registered" }),
11
11
  mockProviderRegister: vi.fn().mockResolvedValue({}),
12
12
  }));
13
+ const { mockRoleAssignmentsCreate, mockRoleAssignmentsListForScope } = vi.hoisted(() => ({
14
+ mockRoleAssignmentsCreate: vi.fn().mockResolvedValue({}),
15
+ mockRoleAssignmentsListForScope: vi.fn(),
16
+ }));
17
+ const { mockCreateFederatedIdentityCredential, mockCreateIdentity, mockCreateKeyVault, mockCreateResourceGroup, mockDeleteResourceGroup, mockGetSubscription, mockKeyVaultNameAvailability, mockSetSecret, } = vi.hoisted(() => ({
18
+ mockCreateFederatedIdentityCredential: vi.fn().mockResolvedValue({}),
19
+ mockCreateIdentity: vi
20
+ .fn()
21
+ .mockResolvedValue({ clientId: "client-1", principalId: "principal-1" }),
22
+ mockCreateKeyVault: vi.fn().mockResolvedValue({}),
23
+ mockCreateResourceGroup: vi.fn().mockResolvedValue({}),
24
+ mockDeleteResourceGroup: vi.fn().mockResolvedValue({}),
25
+ mockGetSubscription: vi.fn().mockResolvedValue({ tenantId: "tenant-1" }),
26
+ mockKeyVaultNameAvailability: vi.fn().mockResolvedValue({
27
+ nameAvailable: true,
28
+ }),
29
+ mockSetSecret: vi.fn().mockResolvedValue({}),
30
+ }));
31
+ vi.mock("@azure/arm-authorization", () => ({
32
+ AuthorizationManagementClient: class {
33
+ roleAssignments = {
34
+ create: mockRoleAssignmentsCreate,
35
+ listForScope: mockRoleAssignmentsListForScope,
36
+ };
37
+ },
38
+ }));
39
+ vi.mock("@azure/arm-keyvault", () => ({
40
+ KeyVaultManagementClient: class {
41
+ vaults = {
42
+ beginCreateOrUpdateAndWait: mockCreateKeyVault,
43
+ checkNameAvailability: mockKeyVaultNameAvailability,
44
+ };
45
+ },
46
+ }));
47
+ vi.mock("@azure/arm-msi", () => ({
48
+ ManagedServiceIdentityClient: class {
49
+ federatedIdentityCredentials = {
50
+ createOrUpdate: mockCreateFederatedIdentityCredential,
51
+ };
52
+ userAssignedIdentities = {
53
+ createOrUpdate: mockCreateIdentity,
54
+ };
55
+ },
56
+ }));
13
57
  vi.mock("@azure/identity", () => ({
14
58
  DefaultAzureCredential: vi.fn(),
15
59
  }));
@@ -24,6 +68,22 @@ vi.mock("@azure/arm-resources", () => ({
24
68
  get: mockProviderGet,
25
69
  register: mockProviderRegister,
26
70
  };
71
+ resourceGroups = {
72
+ beginDeleteAndWait: mockDeleteResourceGroup,
73
+ createOrUpdate: mockCreateResourceGroup,
74
+ };
75
+ },
76
+ }));
77
+ vi.mock("@azure/arm-resources-subscriptions", () => ({
78
+ SubscriptionClient: class {
79
+ subscriptions = {
80
+ get: mockGetSubscription,
81
+ };
82
+ },
83
+ }));
84
+ vi.mock("@azure/keyvault-secrets", () => ({
85
+ SecretClient: class {
86
+ setSecret = mockSetSecret;
27
87
  },
28
88
  }));
29
89
  const test = baseTest.extend({
@@ -34,6 +94,29 @@ const test = baseTest.extend({
34
94
  await use(cloudAccountService);
35
95
  },
36
96
  });
97
+ beforeEach(() => {
98
+ queryResources.mockReset();
99
+ queryResources.mockResolvedValue({ data: [], totalRecords: 0 });
100
+ mockCreateFederatedIdentityCredential.mockClear();
101
+ mockCreateIdentity.mockClear();
102
+ mockCreateIdentity.mockResolvedValue({
103
+ clientId: "client-1",
104
+ principalId: "principal-1",
105
+ });
106
+ mockCreateKeyVault.mockClear();
107
+ mockCreateResourceGroup.mockClear();
108
+ mockDeleteResourceGroup.mockClear();
109
+ mockGetSubscription.mockClear();
110
+ mockGetSubscription.mockResolvedValue({ tenantId: "tenant-1" });
111
+ mockKeyVaultNameAvailability.mockClear();
112
+ mockKeyVaultNameAvailability.mockResolvedValue({ nameAvailable: true });
113
+ mockProviderGet.mockClear();
114
+ mockProviderGet.mockResolvedValue({ registrationState: "Registered" });
115
+ mockProviderRegister.mockClear();
116
+ mockRoleAssignmentsCreate.mockClear();
117
+ mockRoleAssignmentsListForScope.mockReset();
118
+ mockSetSecret.mockClear();
119
+ });
37
120
  describe("getTerraformBackend", () => {
38
121
  test("returns undefined when no matching storage account is found", async ({ cloudAccountService, }) => {
39
122
  queryResources.mockResolvedValueOnce({
@@ -157,3 +240,51 @@ describe("isInitialized", () => {
157
240
  expect(result).toBe(false);
158
241
  });
159
242
  });
243
+ describe("initialize", () => {
244
+ test("assigns bootstrap roles to the bootstrap identity", async ({ cloudAccountService, }) => {
245
+ await cloudAccountService.initialize({
246
+ csp: "azure",
247
+ defaultLocation: "italynorth",
248
+ displayName: "Test subscription",
249
+ id: "sub-1",
250
+ }, {
251
+ name: "dev",
252
+ prefix: "dx",
253
+ }, {
254
+ id: "app-id",
255
+ installationId: "installation-id",
256
+ key: "private-key\n",
257
+ }, {
258
+ owner: "pagopa",
259
+ repo: "dx",
260
+ }, {
261
+ createBranch: vi.fn(),
262
+ createOrUpdateEnvironmentSecret: vi.fn().mockResolvedValue(undefined),
263
+ createPullRequest: vi.fn(),
264
+ getFileContent: vi.fn(),
265
+ getRepository: vi.fn(),
266
+ updateFile: vi.fn(),
267
+ });
268
+ expect(mockRoleAssignmentsCreate).toHaveBeenCalledTimes(3);
269
+ expect(mockRoleAssignmentsCreate).toHaveBeenCalledWith("/subscriptions/sub-1", expect.any(String), expect.objectContaining({
270
+ principalId: "principal-1",
271
+ principalType: "ServicePrincipal",
272
+ roleDefinitionId: "/subscriptions/sub-1/providers/Microsoft.Authorization/roleDefinitions/f58310d9-a9f6-439a-9e8d-f62e7b41a168",
273
+ }));
274
+ expect(mockRoleAssignmentsCreate).toHaveBeenCalledWith("/subscriptions/sub-1", expect.any(String), expect.objectContaining({
275
+ principalId: "principal-1",
276
+ principalType: "ServicePrincipal",
277
+ roleDefinitionId: "/subscriptions/sub-1/providers/Microsoft.Authorization/roleDefinitions/b24988ac-6180-42a0-ab88-20f7382dd24c",
278
+ }));
279
+ expect(mockRoleAssignmentsCreate).toHaveBeenCalledWith("/subscriptions/sub-1", expect.any(String), expect.objectContaining({
280
+ principalId: "principal-1",
281
+ principalType: "ServicePrincipal",
282
+ roleDefinitionId: "/subscriptions/sub-1/providers/Microsoft.Authorization/roleDefinitions/ba92f5b4-2d11-453d-a403-e96b0029c9fe",
283
+ }));
284
+ expect(mockCreateFederatedIdentityCredential).toHaveBeenCalledWith("dx-d-itn-common-rg-01", "dx-d-itn-bootstrap-id-01", "bootstrapper-dev-cd", {
285
+ audiences: ["api://AzureADTokenExchange"],
286
+ issuer: "https://token.actions.githubusercontent.com",
287
+ subject: "repo:pagopa/dx:environment:bootstrapper-dev-cd",
288
+ });
289
+ });
290
+ });
@@ -2,7 +2,8 @@ import type { TokenCredential } from "@azure/identity";
2
2
  import { z } from "zod/v4";
3
3
  import { CloudAccount, type CloudAccountService } from "../../domain/cloud-account.js";
4
4
  import { type EnvironmentId } from "../../domain/environment.js";
5
- import { GitHubAppCredentials } from "../../domain/github.js";
5
+ import { type GitHubRepo } from "../../domain/github-repo.js";
6
+ import { GitHubAppCredentials, type GitHubService } from "../../domain/github.js";
6
7
  import { type TerraformBackend } from "../../domain/remote-backend.js";
7
8
  export declare const resourceGraphDataSchema: z.ZodObject<{
8
9
  location: z.ZodEnum<{
@@ -17,7 +18,7 @@ export declare class AzureCloudAccountService implements CloudAccountService {
17
18
  constructor(credential: TokenCredential);
18
19
  getTerraformBackend(cloudAccountId: CloudAccount["id"], { name, prefix }: EnvironmentId): Promise<TerraformBackend | undefined>;
19
20
  hasUserPermissionToInitialize(cloudAccountId: CloudAccount["id"]): Promise<boolean>;
20
- initialize(cloudAccount: CloudAccount, { name, prefix }: EnvironmentId, runnerAppCredentials: GitHubAppCredentials, tags?: Record<string, string>): Promise<void>;
21
+ initialize(cloudAccount: CloudAccount, { name, prefix }: EnvironmentId, runnerAppCredentials: GitHubAppCredentials, github: GitHubRepo, gitHubService: GitHubService, tags?: Record<string, string>): Promise<void>;
21
22
  isInitialized(cloudAccountId: CloudAccount["id"], { name, prefix }: EnvironmentId): Promise<boolean>;
22
23
  provisionTerraformBackend(cloudAccount: CloudAccount, { name, prefix }: EnvironmentId, tags?: Record<string, string>): Promise<TerraformBackend>;
23
24
  }
@@ -10,6 +10,7 @@ import { BlobServiceClient } from "@azure/storage-blob";
10
10
  import { getLogger } from "@logtape/logtape";
11
11
  import { Client } from "@microsoft/microsoft-graph-client";
12
12
  import * as assert from "node:assert/strict";
13
+ import { createHash } from "node:crypto";
13
14
  import { z } from "zod/v4";
14
15
  import { environmentShort, } from "../../domain/environment.js";
15
16
  import { terraformBackendSchema, } from "../../domain/remote-backend.js";
@@ -31,6 +32,19 @@ const graphGroupMembershipResponseSchema = z.object({
31
32
  "@odata.nextLink": z.string().optional(),
32
33
  value: z.array(graphGroupMembershipItemSchema),
33
34
  });
35
+ const builtInRoleDefinitionIds = {
36
+ contributor: "b24988ac-6180-42a0-ab88-20f7382dd24c",
37
+ keyVaultSecretsOfficer: "b86a8fe4-44ce-4948-aee5-eccb2c155cd7",
38
+ owner: "8e3af657-a8ff-443c-a75c-2fe8c4bcb635",
39
+ roleBasedAccessControlAdministrator: "f58310d9-a9f6-439a-9e8d-f62e7b41a168",
40
+ storageBlobDataContributor: "ba92f5b4-2d11-453d-a403-e96b0029c9fe",
41
+ };
42
+ const bootstrapIdentityRoleDefinitionIds = [
43
+ // These roles let the bootstrap identity run the bootstrapper module from GitHub Actions without extra manual grants.
44
+ builtInRoleDefinitionIds.roleBasedAccessControlAdministrator,
45
+ builtInRoleDefinitionIds.contributor,
46
+ builtInRoleDefinitionIds.storageBlobDataContributor,
47
+ ];
34
48
  export class AzureCloudAccountService {
35
49
  #credential;
36
50
  #requiredResourceProviders = [
@@ -101,9 +115,9 @@ export class AzureCloudAccountService {
101
115
  // Get role assignments for the subscription
102
116
  const authClient = new AuthorizationManagementClient(this.#credential, cloudAccountId);
103
117
  const requiredRoles = [
104
- "8e3af657-a8ff-443c-a75c-2fe8c4bcb635", // Owner
105
- "ba92f5b4-2d11-453d-a403-e96b0029c9fe", // Storage Blob Data Contributor
106
- "b86a8fe4-44ce-4948-aee5-eccb2c155cd7", // Key Vault Secrets Officer
118
+ builtInRoleDefinitionIds.owner, // Owner
119
+ builtInRoleDefinitionIds.storageBlobDataContributor, // Storage Blob Data Contributor
120
+ builtInRoleDefinitionIds.keyVaultSecretsOfficer, // Key Vault Secrets Officer
107
121
  ];
108
122
  const scope = `/subscriptions/${cloudAccountId}`;
109
123
  // Collect all role definition IDs assigned to the user or their groups
@@ -140,7 +154,7 @@ export class AzureCloudAccountService {
140
154
  throw error;
141
155
  }
142
156
  }
143
- async initialize(cloudAccount, { name, prefix }, runnerAppCredentials, tags = {}) {
157
+ async initialize(cloudAccount, { name, prefix }, runnerAppCredentials, github, gitHubService, tags = {}) {
144
158
  assert.equal(cloudAccount.csp, "azure", "Cloud account must be Azure");
145
159
  assert.ok(isAzureLocation(cloudAccount.defaultLocation), "The default location of the cloud account is not a valid Azure location");
146
160
  const logger = getLogger(["gen", "env"]);
@@ -164,46 +178,67 @@ export class AzureCloudAccountService {
164
178
  try {
165
179
  const identityName = `${prefix}-${short.env}-${short.location}-bootstrap-id-01`;
166
180
  const msiClient = new ManagedServiceIdentityClient(this.#credential, cloudAccount.id);
167
- await msiClient.userAssignedIdentities.createOrUpdate(resourceGroupName, identityName, parameters);
181
+ const identity = await msiClient.userAssignedIdentities.createOrUpdate(resourceGroupName, identityName, parameters);
182
+ assert.ok(identity.principalId, "Managed identity principal ID is undefined");
183
+ const identityPrincipalId = identity.principalId;
184
+ assert.ok(identity.clientId, "Managed identity client ID is undefined");
185
+ const identityClientId = identity.clientId;
168
186
  logger.debug("Created identity {identityName} in subscription {subscriptionId}", { identityName, subscriptionId: cloudAccount.id });
169
- // Retrieve tenant ID from the subscription
170
- const subscriptionClient = new SubscriptionClient(this.#credential);
171
- const subscription = await subscriptionClient.subscriptions.get(cloudAccount.id);
172
- assert.ok(subscription.tenantId, "Subscription tenant ID is undefined");
173
- const kvClient = new KeyVaultManagementClient(this.#credential, cloudAccount.id);
174
- const keyVaultName = `${prefix}-${short.env}-${short.location}-common-kv-01`;
175
- const secretsProtectionEnabled = short.env === "p";
176
- const result = await kvClient.vaults.checkNameAvailability({
177
- name: keyVaultName,
178
- type: "Microsoft.KeyVault/vaults",
187
+ const authorizationManagementClient = new AuthorizationManagementClient(this.#credential, cloudAccount.id);
188
+ const subscriptionScope = `/subscriptions/${cloudAccount.id}`;
189
+ // Grant the bootstrap identity the Azure permissions it needs to operate autonomously in the bootstrap workflow.
190
+ await Promise.all(bootstrapIdentityRoleDefinitionIds.map((roleDefinitionId) => authorizationManagementClient.roleAssignments.create(subscriptionScope, this.#createRoleAssignmentName(subscriptionScope, identityPrincipalId, roleDefinitionId), {
191
+ principalId: identityPrincipalId,
192
+ principalType: "ServicePrincipal",
193
+ roleDefinitionId: this.#createRoleDefinitionResourceId(cloudAccount.id, roleDefinitionId),
194
+ })));
195
+ logger.debug("Assigned bootstrap roles to identity {identityName} in subscription {subscriptionId}", { identityName, subscriptionId: cloudAccount.id });
196
+ const githubEnvironmentName = `bootstrapper-${name}-cd`;
197
+ // Federate the bootstrap identity with the GitHub environment so workflows can exchange their OIDC token for Azure access.
198
+ await msiClient.federatedIdentityCredentials.createOrUpdate(resourceGroupName, identityName, githubEnvironmentName, {
199
+ audiences: ["api://AzureADTokenExchange"],
200
+ issuer: "https://token.actions.githubusercontent.com",
201
+ subject: `repo:${github.owner}/${github.repo}:environment:${githubEnvironmentName}`,
179
202
  });
180
- await kvClient.vaults.beginCreateOrUpdateAndWait(resourceGroupName, keyVaultName, {
181
- location: cloudAccount.defaultLocation,
182
- properties: {
183
- createMode: result.nameAvailable ? "default" : "recover",
184
- enabledForDiskEncryption: true,
185
- enablePurgeProtection: secretsProtectionEnabled ? true : undefined,
186
- enableRbacAuthorization: true,
187
- sku: {
188
- family: "A",
189
- name: "standard",
190
- },
191
- softDeleteRetentionInDays: secretsProtectionEnabled ? 14 : 7,
192
- tenantId: subscription.tenantId,
193
- },
194
- tags: {
195
- Environment: name,
196
- ...tags,
197
- },
203
+ logger.debug("Configured federated identity credential {credentialName} for identity {identityName} in subscription {subscriptionId}", {
204
+ credentialName: githubEnvironmentName,
205
+ identityName,
206
+ subscriptionId: cloudAccount.id,
198
207
  });
199
- logger.debug("Created key vault {keyVaultName} in subscription {subscriptionId}", { keyVaultName, subscriptionId: cloudAccount.id });
200
- const secretClient = new SecretClient(`https://${keyVaultName}.vault.azure.net/`, this.#credential);
208
+ // These secrets let the GitHub workflow target the bootstrap identity and subscription without extra setup.
201
209
  await Promise.all([
202
- secretClient.setSecret("github-runner-app-id", runnerAppCredentials.id),
203
- secretClient.setSecret("github-runner-app-installation-id", runnerAppCredentials.installationId),
204
- secretClient.setSecret("github-runner-app-key", runnerAppCredentials.key.trimEnd()),
210
+ gitHubService.createOrUpdateEnvironmentSecret({
211
+ environmentName: githubEnvironmentName,
212
+ owner: github.owner,
213
+ repo: github.repo,
214
+ secretName: "ARM_CLIENT_ID",
215
+ secretValue: identityClientId,
216
+ }),
217
+ gitHubService.createOrUpdateEnvironmentSecret({
218
+ environmentName: githubEnvironmentName,
219
+ owner: github.owner,
220
+ repo: github.repo,
221
+ secretName: "ARM_SUBSCRIPTION_ID",
222
+ secretValue: cloudAccount.id,
223
+ }),
205
224
  ]);
206
- logger.debug("Created secrets in key vault {keyVaultName} in subscription {subscriptionId}", { keyVaultName, subscriptionId: cloudAccount.id });
225
+ logger.debug("Set GitHub environment secrets for {environmentName}", {
226
+ environmentName: githubEnvironmentName,
227
+ });
228
+ const keyVaultName = await this.#createCommonKeyVault({
229
+ cloudAccount,
230
+ name,
231
+ prefix,
232
+ resourceGroupName,
233
+ shortEnv: short.env,
234
+ shortLocation: short.location,
235
+ tags,
236
+ });
237
+ await this.#storeRunnerAppSecrets({
238
+ cloudAccountId: cloudAccount.id,
239
+ keyVaultName,
240
+ runnerAppCredentials,
241
+ });
207
242
  }
208
243
  catch (cause) {
209
244
  // Cleanup resource group if initialization fails
@@ -313,6 +348,49 @@ export class AzureCloudAccountService {
313
348
  }));
314
349
  return results.every(Boolean);
315
350
  }
351
+ async #createCommonKeyVault({ cloudAccount, name, prefix, resourceGroupName, shortEnv, shortLocation, tags, }) {
352
+ const logger = getLogger(["gen", "env"]);
353
+ const subscriptionClient = new SubscriptionClient(this.#credential);
354
+ const subscription = await subscriptionClient.subscriptions.get(cloudAccount.id);
355
+ assert.ok(subscription.tenantId, "Subscription tenant ID is undefined");
356
+ const kvClient = new KeyVaultManagementClient(this.#credential, cloudAccount.id);
357
+ const keyVaultName = `${prefix}-${shortEnv}-${shortLocation}-common-kv-01`;
358
+ const secretsProtectionEnabled = shortEnv === "p";
359
+ const result = await kvClient.vaults.checkNameAvailability({
360
+ name: keyVaultName,
361
+ type: "Microsoft.KeyVault/vaults",
362
+ });
363
+ await kvClient.vaults.beginCreateOrUpdateAndWait(resourceGroupName, keyVaultName, {
364
+ location: cloudAccount.defaultLocation,
365
+ properties: {
366
+ createMode: result.nameAvailable ? "default" : "recover",
367
+ enabledForDiskEncryption: true,
368
+ enablePurgeProtection: secretsProtectionEnabled ? true : undefined,
369
+ enableRbacAuthorization: true,
370
+ sku: {
371
+ family: "A",
372
+ name: "standard",
373
+ },
374
+ softDeleteRetentionInDays: secretsProtectionEnabled ? 14 : 7,
375
+ tenantId: subscription.tenantId,
376
+ },
377
+ tags: {
378
+ Environment: name,
379
+ ...tags,
380
+ },
381
+ });
382
+ logger.debug("Created key vault {keyVaultName} in subscription {subscriptionId}", { keyVaultName, subscriptionId: cloudAccount.id });
383
+ return keyVaultName;
384
+ }
385
+ #createRoleAssignmentName(scope, principalId, roleDefinitionId) {
386
+ const hash = createHash("sha256")
387
+ .update(`${scope}:${principalId}:${roleDefinitionId}`)
388
+ .digest("hex");
389
+ return `${hash.slice(0, 8)}-${hash.slice(8, 12)}-${hash.slice(12, 16)}-${hash.slice(16, 20)}-${hash.slice(20, 32)}`;
390
+ }
391
+ #createRoleDefinitionResourceId(subscriptionId, roleDefinitionId) {
392
+ return `/subscriptions/${subscriptionId}/providers/Microsoft.Authorization/roleDefinitions/${roleDefinitionId}`;
393
+ }
316
394
  async #getCurrentPrincipalIds() {
317
395
  // Create Graph client with custom auth provider that fetches fresh tokens
318
396
  const graphClient = Client.init({
@@ -359,4 +437,14 @@ export class AzureCloudAccountService {
359
437
  }));
360
438
  logger.info("All resource providers registered on subscription {subscriptionId}", { subscriptionId });
361
439
  }
440
+ async #storeRunnerAppSecrets({ cloudAccountId, keyVaultName, runnerAppCredentials, }) {
441
+ const logger = getLogger(["gen", "env"]);
442
+ const secretClient = new SecretClient(`https://${keyVaultName}.vault.azure.net/`, this.#credential);
443
+ await Promise.all([
444
+ secretClient.setSecret("github-runner-app-id", runnerAppCredentials.id),
445
+ secretClient.setSecret("github-runner-app-installation-id", runnerAppCredentials.installationId),
446
+ secretClient.setSecret("github-runner-app-key", runnerAppCredentials.key.trimEnd()),
447
+ ]);
448
+ logger.debug("Created secrets in key vault {keyVaultName} in subscription {subscriptionId}", { keyVaultName, subscriptionId: cloudAccountId });
449
+ }
362
450
  }
@@ -11,11 +11,13 @@ import { Command } from "commander";
11
11
  import { ResultAsync } from "neverthrow";
12
12
  import type { Payload as EnvironmentPayload } from "../../plop/generators/environment/index.js";
13
13
  import { AuthorizationResult, AuthorizationService } from "../../../domain/authorization.js";
14
+ import { type GitHubService } from "../../../domain/github.js";
14
15
  /**
15
16
  * Authorize a Cloud Account (Azure Subscription, AWS Account, ...), creating a Pull Request for each account that requires authorization.
16
17
  */
17
18
  export declare const authorizeCloudAccounts: (authorizationService: AuthorizationService) => (envPayload: EnvironmentPayload) => ResultAsync<AuthorizationResult[], never>;
18
19
  export type AddCommandDependencies = {
19
20
  authorizationService: AuthorizationService;
21
+ gitHubService: GitHubService;
20
22
  };
21
23
  export declare const makeAddCommand: (deps: AddCommandDependencies) => Command;
@@ -69,9 +69,9 @@ const displaySummary = (result) => {
69
69
  }
70
70
  console.log(`${step}. Visit ${chalk.underline("https://dx.pagopa.it/getting-started")} to deploy your first project\n`);
71
71
  };
72
- const addEnvironmentAction = (authorizationService) => checkPreconditions()
72
+ const addEnvironmentAction = (authorizationService, gitHubService) => checkPreconditions()
73
73
  .andThen(() => ResultAsync.fromPromise(getPlopInstance(), () => new Error("Failed to initialize plop")))
74
- .andThen((plop) => ResultAsync.fromPromise(runDeploymentEnvironmentGenerator(plop), () => new Error("Failed to run the deployment environment generator")))
74
+ .andThen((plop) => ResultAsync.fromPromise(runDeploymentEnvironmentGenerator(plop, gitHubService), () => new Error("Failed to run the deployment environment generator")))
75
75
  .andThen((payload) => authorizeCloudAccounts(authorizationService)(payload).map((authorizationPrs) => ({
76
76
  authorizationPrs,
77
77
  })));
@@ -81,7 +81,7 @@ export const makeAddCommand = (deps) => new Command()
81
81
  .addCommand(new Command("environment")
82
82
  .description("Add a new deployment environment")
83
83
  .action(async function () {
84
- const result = await addEnvironmentAction(deps.authorizationService);
84
+ const result = await addEnvironmentAction(deps.authorizationService, deps.gitHubService);
85
85
  if (result.isErr()) {
86
86
  this.error(result.error.message);
87
87
  }
@@ -1,6 +1,6 @@
1
1
  import { ResultAsync } from "neverthrow";
2
2
  import { Octokit } from "octokit";
3
- import { CreateBranchParams, FileContent, GetFileContentParams, GitHubService, PullRequest, Repository, UpdateFileParams } from "../../domain/github.js";
3
+ import { CreateBranchParams, CreateOrUpdateEnvironmentSecretParams, FileContent, GetFileContentParams, GitHubService, PullRequest, Repository, UpdateFileParams } from "../../domain/github.js";
4
4
  type GitHubReleaseParam = {
5
5
  client: Octokit;
6
6
  owner: string;
@@ -10,6 +10,7 @@ export declare class OctokitGitHubService implements GitHubService {
10
10
  #private;
11
11
  constructor(octokit: Octokit);
12
12
  createBranch(params: CreateBranchParams): Promise<void>;
13
+ createOrUpdateEnvironmentSecret(params: CreateOrUpdateEnvironmentSecretParams): Promise<void>;
13
14
  createPullRequest(params: {
14
15
  base: string;
15
16
  body: string;
@@ -1,4 +1,5 @@
1
1
  import { $ } from "execa";
2
+ import sodium from "libsodium-wrappers";
2
3
  import { ResultAsync } from "neverthrow";
3
4
  import { RequestError } from "octokit";
4
5
  import semverParse from "semver/functions/parse.js";
@@ -31,6 +32,38 @@ export class OctokitGitHubService {
31
32
  });
32
33
  }
33
34
  }
35
+ async createOrUpdateEnvironmentSecret(params) {
36
+ try {
37
+ // GitHub requires environment secrets to be encrypted client-side with libsodium.
38
+ await sodium.ready;
39
+ // Ensure the target environment exists before resolving its public key and storing secrets.
40
+ await this.#octokit.request("PUT /repos/{owner}/{repo}/environments/{environment_name}", {
41
+ environment_name: params.environmentName,
42
+ owner: params.owner,
43
+ repo: params.repo,
44
+ });
45
+ const { data: publicKeyData } = await this.#octokit.rest.actions.getEnvironmentPublicKey({
46
+ environment_name: params.environmentName,
47
+ owner: params.owner,
48
+ repo: params.repo,
49
+ });
50
+ const publicKeyBytes = sodium.from_base64(publicKeyData.key, sodium.base64_variants.ORIGINAL);
51
+ const secretBytes = sodium.from_string(params.secretValue);
52
+ const encryptedBytes = sodium.crypto_box_seal(secretBytes, publicKeyBytes);
53
+ const encryptedValue = sodium.to_base64(encryptedBytes, sodium.base64_variants.ORIGINAL);
54
+ await this.#octokit.rest.actions.createOrUpdateEnvironmentSecret({
55
+ encrypted_value: encryptedValue,
56
+ environment_name: params.environmentName,
57
+ key_id: publicKeyData.key_id,
58
+ owner: params.owner,
59
+ repo: params.repo,
60
+ secret_name: params.secretName,
61
+ });
62
+ }
63
+ catch (error) {
64
+ throw new Error(`Failed to create or update secret ${params.secretName} in environment ${params.environmentName}`, { cause: error });
65
+ }
66
+ }
34
67
  async createPullRequest(params) {
35
68
  try {
36
69
  const { data } = await this.#octokit.rest.pulls.create({
@@ -1,5 +1,13 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
2
  import { initCloudAccounts } from "../init-cloud-accounts.js";
3
+ const createMockGitHubService = () => ({
4
+ createBranch: vi.fn(),
5
+ createOrUpdateEnvironmentSecret: vi.fn().mockResolvedValue(undefined),
6
+ createPullRequest: vi.fn(),
7
+ getFileContent: vi.fn(),
8
+ getRepository: vi.fn(),
9
+ updateFile: vi.fn(),
10
+ });
3
11
  const createMockCloudAccountService = (overrides = {}) => ({
4
12
  getTerraformBackend: vi.fn().mockResolvedValue(undefined),
5
13
  hasUserPermissionToInitialize: vi.fn().mockResolvedValue(true),
@@ -68,18 +76,24 @@ describe("initCloudAccounts", () => {
68
76
  },
69
77
  },
70
78
  });
71
- await initCloudAccounts(payload, mockService);
79
+ await initCloudAccounts(payload, mockService, createMockGitHubService());
72
80
  expect(initializeMock).toHaveBeenCalledTimes(2);
73
81
  expect(initializeMock).toHaveBeenCalledWith(cloudAccount1, expect.objectContaining({ name: "prod", prefix: "io" }), {
74
82
  id: "test-app-id",
75
83
  installationId: "test-installation-id",
76
84
  key: "test-private-key",
77
- }, {});
85
+ }, {
86
+ owner: "pagopa",
87
+ repo: "dx",
88
+ }, expect.any(Object), {});
78
89
  expect(initializeMock).toHaveBeenCalledWith(cloudAccount2, expect.objectContaining({ name: "prod", prefix: "io" }), {
79
90
  id: "test-app-id",
80
91
  installationId: "test-installation-id",
81
92
  key: "test-private-key",
82
- }, {});
93
+ }, {
94
+ owner: "pagopa",
95
+ repo: "dx",
96
+ }, expect.any(Object), {});
83
97
  });
84
98
  it("should not call initialize when cloudAccountsToInitialize is empty", async () => {
85
99
  const initializeMock = vi.fn().mockResolvedValue(undefined);
@@ -99,7 +113,7 @@ describe("initCloudAccounts", () => {
99
113
  },
100
114
  },
101
115
  });
102
- await initCloudAccounts(payload, mockService);
116
+ await initCloudAccounts(payload, mockService, createMockGitHubService());
103
117
  expect(initializeMock).not.toHaveBeenCalled();
104
118
  });
105
119
  it("should not call initialize when payload.init is undefined", async () => {
@@ -110,7 +124,7 @@ describe("initCloudAccounts", () => {
110
124
  const payload = createMockPayload({
111
125
  init: undefined,
112
126
  });
113
- await initCloudAccounts(payload, mockService);
127
+ await initCloudAccounts(payload, mockService, createMockGitHubService());
114
128
  expect(initializeMock).not.toHaveBeenCalled();
115
129
  });
116
130
  it("should use prefix and environment name from payload.env", async () => {
@@ -137,11 +151,14 @@ describe("initCloudAccounts", () => {
137
151
  },
138
152
  },
139
153
  });
140
- await initCloudAccounts(payload, mockService);
154
+ await initCloudAccounts(payload, mockService, createMockGitHubService());
141
155
  expect(initializeMock).toHaveBeenCalledWith(cloudAccount, expect.objectContaining({ name: "uat", prefix: "pagopa" }), {
142
156
  id: "test-app-id",
143
157
  installationId: "test-installation-id",
144
158
  key: "test-private-key",
145
- }, {});
159
+ }, {
160
+ owner: "pagopa",
161
+ repo: "dx",
162
+ }, expect.any(Object), {});
146
163
  });
147
164
  });
@@ -1,5 +1,6 @@
1
1
  import { type NodePlopAPI } from "node-plop";
2
2
  import { CloudAccountService } from "../../../domain/cloud-account.js";
3
+ import { type GitHubService } from "../../../domain/github.js";
3
4
  import { type Payload } from "../generators/environment/prompts.js";
4
- export declare const initCloudAccounts: (payload: Payload, cloudAccountService: CloudAccountService) => Promise<void>;
5
- export default function (plop: NodePlopAPI, cloudAccountService: CloudAccountService): void;
5
+ export declare const initCloudAccounts: (payload: Payload, cloudAccountService: CloudAccountService, gitHubService: GitHubService) => Promise<void>;
6
+ export default function (plop: NodePlopAPI, cloudAccountService: CloudAccountService, gitHubService: GitHubService): void;
@@ -1,16 +1,16 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { payloadSchema, } from "../generators/environment/prompts.js";
3
- export const initCloudAccounts = async (payload, cloudAccountService) => {
3
+ export const initCloudAccounts = async (payload, cloudAccountService, gitHubService) => {
4
4
  if (payload.init && payload.init.cloudAccountsToInitialize.length > 0) {
5
5
  const { runnerAppCredentials } = payload.init;
6
6
  assert.ok(runnerAppCredentials, "Runner app credentials are required");
7
- await Promise.all(payload.init.cloudAccountsToInitialize.map((cloudAccount) => cloudAccountService.initialize(cloudAccount, payload.env, runnerAppCredentials, payload.tags)));
7
+ await Promise.all(payload.init.cloudAccountsToInitialize.map((cloudAccount) => cloudAccountService.initialize(cloudAccount, payload.env, runnerAppCredentials, payload.github, gitHubService, payload.tags)));
8
8
  }
9
9
  };
10
- export default function (plop, cloudAccountService) {
10
+ export default function (plop, cloudAccountService, gitHubService) {
11
11
  plop.setActionType("initCloudAccounts", async (data) => {
12
12
  const payload = payloadSchema.parse(data);
13
- await initCloudAccounts(payload, cloudAccountService);
13
+ await initCloudAccounts(payload, cloudAccountService, gitHubService);
14
14
  return "Cloud Accounts Initialized";
15
15
  });
16
16
  }
@@ -46,7 +46,12 @@ describe("actions", () => {
46
46
  payload: getPayload(false),
47
47
  },
48
48
  ])("correct order of actions", ({ payload }) => {
49
- const actionsOrder = ["getTerraformBackend", "addMany", "addMany"];
49
+ const actionsOrder = [
50
+ "getTerraformBackend",
51
+ "addMany",
52
+ "addMany",
53
+ "addMany",
54
+ ];
50
55
  if (payload.init) {
51
56
  actionsOrder.unshift("initCloudAccounts", "provisionTerraformBackend");
52
57
  actionsOrder.push("addMany", "addMany");
@@ -29,6 +29,17 @@ const addModule = (env, templatesPath, init = false) => {
29
29
  },
30
30
  ];
31
31
  };
32
+ const addWorkflowModule = (templatesPath) => {
33
+ const cwd = process.cwd();
34
+ return {
35
+ base: path.join(templatesPath, "workflow"),
36
+ destination: path.join(cwd, ".github", "workflows"),
37
+ force: true,
38
+ templateFiles: path.join(templatesPath, "workflow"),
39
+ type: "addMany",
40
+ verbose: true,
41
+ };
42
+ };
32
43
  export default function getActions(templatesPath) {
33
44
  return (payload) => {
34
45
  const logger = getLogger(["gen", "env"]);
@@ -39,6 +50,7 @@ export default function getActions(templatesPath) {
39
50
  {
40
51
  type: "getTerraformBackend",
41
52
  },
53
+ addWorkflowModule(templatesPath),
42
54
  ...addEnvironmentModule("bootstrapper", `${github.repo}.bootstrapper.${env.name}.tfstate`),
43
55
  ];
44
56
  if (init) {
@@ -1,7 +1,8 @@
1
1
  import { type NodePlopAPI } from "node-plop";
2
2
  import { CloudAccountRepository, CloudAccountService } from "../../../../domain/cloud-account.js";
3
3
  import { GitHubRepo } from "../../../../domain/github-repo.js";
4
+ import { type GitHubService } from "../../../../domain/github.js";
4
5
  import { Payload, payloadSchema } from "./prompts.js";
5
6
  export declare const PLOP_ENVIRONMENT_GENERATOR_NAME = "DX_DeploymentEnvironment";
6
7
  export { Payload, payloadSchema };
7
- export default function (plop: NodePlopAPI, templatesPath: string, cloudAccountRepository: CloudAccountRepository, cloudAccountService: CloudAccountService, github?: GitHubRepo): void;
8
+ export default function (plop: NodePlopAPI, templatesPath: string, cloudAccountRepository: CloudAccountRepository, cloudAccountService: CloudAccountService, gitHubService: GitHubService, github?: GitHubRepo): void;
@@ -8,13 +8,13 @@ import getActions from "./actions.js";
8
8
  import getPrompts, { payloadSchema } from "./prompts.js";
9
9
  export const PLOP_ENVIRONMENT_GENERATOR_NAME = "DX_DeploymentEnvironment";
10
10
  export { payloadSchema };
11
- export default function (plop, templatesPath, cloudAccountRepository, cloudAccountService, github) {
11
+ export default function (plop, templatesPath, cloudAccountRepository, cloudAccountService, gitHubService, github) {
12
12
  setEnvShortHelper(plop);
13
13
  setResourcePrefixHelper(plop);
14
14
  setEqHelper(plop);
15
15
  setGetTerraformBackend(plop, cloudAccountService);
16
16
  setProvisionTerraformBackendAction(plop, cloudAccountService);
17
- setInitCloudAccountsAction(plop, cloudAccountService);
17
+ setInitCloudAccountsAction(plop, cloudAccountService, gitHubService);
18
18
  plop.setGenerator(PLOP_ENVIRONMENT_GENERATOR_NAME, {
19
19
  actions: getActions(templatesPath),
20
20
  description: "Generate a new deployment environment",
@@ -14,7 +14,7 @@ export declare const runMonorepoGenerator: (plop: NodePlopAPI, githubService: Gi
14
14
  * uses the explicitly passed repository. When omitted (by add command),
15
15
  * the generator infers it from the local git context.
16
16
  */
17
- export declare const runDeploymentEnvironmentGenerator: (plop: NodePlopAPI, github?: GitHubRepo) => Promise<EnvironmentPayload>;
17
+ export declare const runDeploymentEnvironmentGenerator: (plop: NodePlopAPI, gitHubService: GitHubService, github?: GitHubRepo) => Promise<EnvironmentPayload>;
18
18
  /**
19
19
  * Configure the deployment environment generator
20
20
  *
@@ -23,4 +23,4 @@ export declare const runDeploymentEnvironmentGenerator: (plop: NodePlopAPI, gith
23
23
  * uses the explicitly passed repository. When omitted (by add command),
24
24
  * the generator infers it from the local git context.
25
25
  */
26
- export declare const setDeploymentEnvironmentGenerator: (plop: NodePlopAPI, github?: GitHubRepo) => void;
26
+ export declare const setDeploymentEnvironmentGenerator: (plop: NodePlopAPI, gitHubService: GitHubService, github?: GitHubRepo) => void;
@@ -62,8 +62,8 @@ export const runMonorepoGenerator = async (plop, githubService) => {
62
62
  * uses the explicitly passed repository. When omitted (by add command),
63
63
  * the generator infers it from the local git context.
64
64
  */
65
- export const runDeploymentEnvironmentGenerator = async (plop, github) => {
66
- setDeploymentEnvironmentGenerator(plop, github);
65
+ export const runDeploymentEnvironmentGenerator = async (plop, gitHubService, github) => {
66
+ setDeploymentEnvironmentGenerator(plop, gitHubService, github);
67
67
  const generator = plop.getGenerator(PLOP_ENVIRONMENT_GENERATOR_NAME);
68
68
  const answers = await generator.runPrompts();
69
69
  const payload = environmentPayloadSchema.parse(answers);
@@ -82,10 +82,10 @@ export const runDeploymentEnvironmentGenerator = async (plop, github) => {
82
82
  * uses the explicitly passed repository. When omitted (by add command),
83
83
  * the generator infers it from the local git context.
84
84
  */
85
- export const setDeploymentEnvironmentGenerator = (plop, github) => {
85
+ export const setDeploymentEnvironmentGenerator = (plop, gitHubService, github) => {
86
86
  const credential = new AzureCliCredential();
87
87
  const cloudAccountRepository = new AzureSubscriptionRepository(credential);
88
88
  const cloudAccountService = new AzureCloudAccountService(credential);
89
89
  const templatesPath = path.join(import.meta.dirname, "../../../templates/environment");
90
- createDeploymentEnvironmentGenerator(plop, templatesPath, cloudAccountRepository, cloudAccountService, github);
90
+ createDeploymentEnvironmentGenerator(plop, templatesPath, cloudAccountRepository, cloudAccountService, gitHubService, github);
91
91
  };
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod/v4";
2
2
  import { type EnvironmentId } from "./environment.js";
3
- import { type GitHubAppCredentials } from "./github.js";
3
+ import { type GitHubRepo } from "./github-repo.js";
4
+ import { type GitHubAppCredentials, type GitHubService } from "./github.js";
4
5
  import { TerraformBackend } from "./remote-backend.js";
5
6
  export declare const cloudAccountSchema: z.ZodObject<{
6
7
  csp: z.ZodDefault<z.ZodEnum<{
@@ -17,7 +18,7 @@ export type CloudAccountRepository = {
17
18
  export type CloudAccountService = {
18
19
  getTerraformBackend(cloudAccountId: CloudAccount["id"], environment: EnvironmentId): Promise<TerraformBackend | undefined>;
19
20
  hasUserPermissionToInitialize(cloudAccountId: CloudAccount["id"]): Promise<boolean>;
20
- initialize(cloudAccount: CloudAccount, environment: EnvironmentId, runnerAppCredentials: GitHubAppCredentials, tags?: Record<string, string>): Promise<void>;
21
+ initialize(cloudAccount: CloudAccount, environment: EnvironmentId, runnerAppCredentials: GitHubAppCredentials, github: GitHubRepo, gitHubService: GitHubService, tags?: Record<string, string>): Promise<void>;
21
22
  isInitialized(cloudAccountId: CloudAccount["id"], environment: EnvironmentId): Promise<boolean>;
22
23
  provisionTerraformBackend(cloudAccount: CloudAccount, environment: EnvironmentId, tags?: Record<string, string>): Promise<TerraformBackend>;
23
24
  };
@@ -5,6 +5,13 @@ export type CreateBranchParams = {
5
5
  owner: string;
6
6
  repo: string;
7
7
  };
8
+ export type CreateOrUpdateEnvironmentSecretParams = {
9
+ environmentName: string;
10
+ owner: string;
11
+ repo: string;
12
+ secretName: string;
13
+ secretValue: string;
14
+ };
8
15
  export type FileContent = {
9
16
  content: string;
10
17
  sha: string;
@@ -21,6 +28,12 @@ export interface GitHubService {
21
28
  * @throws Error if branch creation fails
22
29
  */
23
30
  createBranch(params: CreateBranchParams): Promise<void>;
31
+ /**
32
+ * Creates or updates a secret in a GitHub repository environment.
33
+ * The value is automatically encrypted using the environment's public key.
34
+ * @throws Error if secret creation fails
35
+ */
36
+ createOrUpdateEnvironmentSecret(params: CreateOrUpdateEnvironmentSecretParams): Promise<void>;
24
37
  /**
25
38
  * Creates a pull request in a GitHub repository.
26
39
  * @throws Error if pull request creation fails
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pagopa/dx-cli",
3
- "version": "0.20.0",
3
+ "version": "0.20.2",
4
4
  "type": "module",
5
5
  "description": "A CLI useful to manage DX tools.",
6
6
  "repository": {
@@ -39,6 +39,7 @@
39
39
  "execa": "^9.6.1",
40
40
  "glob": "^11.1.0",
41
41
  "inquirer": "^9.3.8",
42
+ "libsodium-wrappers": "^0.8.2",
42
43
  "neverthrow": "^8.2.0",
43
44
  "node-plop": "^0.32.3",
44
45
  "octokit": "^5.0.5",
@@ -52,6 +53,7 @@
52
53
  "devDependencies": {
53
54
  "@tsconfig/node24": "24.0.4",
54
55
  "@types/inquirer": "^9.0.9",
56
+ "@types/libsodium-wrappers": "^0.8.2",
55
57
  "@types/node": "^22.19.17",
56
58
  "@types/semver": "^7.7.1",
57
59
  "@vitest/coverage-v8": "^3.2.4",
@@ -2,7 +2,7 @@
2
2
  {{#each this}}
3
3
  module "azure-{{displayName}}_core" {
4
4
  source = "pagopa-dx/azure-core-infra/azurerm"
5
- version = "~> 3.0"
5
+ version = "~> 4.0"
6
6
 
7
7
  providers = {
8
8
  azurerm = azurerm.{{displayName}}
@@ -12,8 +12,6 @@ module "azure-{{displayName}}_core" {
12
12
  app_name = "core"
13
13
  })
14
14
 
15
- vpn_enabled = true
16
-
17
15
  tags = merge(local.tags, {
18
16
  Source = "https://github.com/{{@root.github.owner}}/{{@root.github.repo}}/blob/main/infra/core/{{@root.env.name}}"
19
17
  })
@@ -0,0 +1,19 @@
1
+ name: Release Bootstrapper Infrastructure - {{@root.env.name}}
2
+ on:
3
+ workflow_dispatch:
4
+ push:
5
+ branches:
6
+ - main
7
+ paths:
8
+ - "infra/bootstrapper/**"
9
+
10
+ permissions:
11
+ contents: read
12
+
13
+ jobs:
14
+ release:
15
+ uses: pagopa/dx/.github/workflows/release-terraform-bootstrapper-v1.yaml@main
16
+ name: Release Bootstrapper ({{@root.env.name}})
17
+ secrets: inherit
18
+ with:
19
+ environment: '{{@root.env.name}}'