@pagopa/dx-cli 0.20.1 → 0.21.0

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.
Files changed (36) hide show
  1. package/README.md +12 -1
  2. package/bin/index.js +0 -20
  3. package/dist/adapters/azure/__tests__/cloud-account-service.test.js +132 -1
  4. package/dist/adapters/azure/cloud-account-service.d.ts +3 -2
  5. package/dist/adapters/azure/cloud-account-service.js +127 -39
  6. package/dist/adapters/commander/__tests__/error-reporting.test.d.ts +1 -0
  7. package/dist/adapters/commander/__tests__/error-reporting.test.js +63 -0
  8. package/dist/adapters/commander/__tests__/exit-with-error.test.d.ts +1 -0
  9. package/dist/adapters/commander/__tests__/exit-with-error.test.js +92 -0
  10. package/dist/adapters/commander/commands/add.d.ts +2 -0
  11. package/dist/adapters/commander/commands/add.js +8 -5
  12. package/dist/adapters/commander/commands/codemod.js +3 -2
  13. package/dist/adapters/commander/commands/init.js +2 -2
  14. package/dist/adapters/commander/commands/savemoney.js +6 -3
  15. package/dist/adapters/commander/error-reporting.d.ts +10 -0
  16. package/dist/adapters/commander/error-reporting.js +68 -0
  17. package/dist/adapters/commander/index.d.ts +17 -1
  18. package/dist/adapters/commander/index.js +23 -2
  19. package/dist/adapters/octokit/index.d.ts +2 -1
  20. package/dist/adapters/octokit/index.js +33 -0
  21. package/dist/adapters/plop/__tests__/run-actions.test.d.ts +1 -0
  22. package/dist/adapters/plop/__tests__/run-actions.test.js +68 -0
  23. package/dist/adapters/plop/actions/__tests__/init-cloud-accounts.test.js +24 -7
  24. package/dist/adapters/plop/actions/init-cloud-accounts.d.ts +3 -2
  25. package/dist/adapters/plop/actions/init-cloud-accounts.js +4 -4
  26. package/dist/adapters/plop/generators/environment/__tests__/actions.test.js +6 -1
  27. package/dist/adapters/plop/generators/environment/actions.js +12 -0
  28. package/dist/adapters/plop/generators/environment/index.d.ts +2 -1
  29. package/dist/adapters/plop/generators/environment/index.js +2 -2
  30. package/dist/adapters/plop/index.d.ts +5 -3
  31. package/dist/adapters/plop/index.js +20 -12
  32. package/dist/domain/cloud-account.d.ts +3 -2
  33. package/dist/domain/github.d.ts +13 -0
  34. package/dist/index.js +36 -0
  35. package/package.json +4 -2
  36. package/templates/environment/workflow/_release-terraform-apply-bootstrapper-{{env.name}}.yaml.hbs +19 -0
package/README.md CHANGED
@@ -184,9 +184,10 @@ dx savemoney [options]
184
184
  | `--format` | `-f` | Report format: `table`, `json`, `detailed-json`, or `lint`. | `table` |
185
185
  | `--days` | `-d` | Metric analysis period in days (overrides config file). | `30` |
186
186
  | `--location` | `-l` | Preferred Azure location for resources (overrides config file). | `italynorth` |
187
- | `--verbose` | `-v` | Enable verbose mode with detailed logging for each resource analyzed. | `false` |
188
187
  | `--tags` | `-t` | Filter resources by tags (`key=value key2=value2`). Only resources matching **all** specified tags are analyzed (variadic: space-separated). | N/A |
189
188
 
189
+ > `--verbose` / `-v` is inherited from the root command. See [Global Options](#global-options).
190
+
190
191
  **Example usage:**
191
192
 
192
193
  ```bash
@@ -245,9 +246,19 @@ azure:
245
246
 
246
247
  ### Global Options
247
248
 
249
+ These options are available on every subcommand:
250
+
251
+ - `--verbose, -v`: Enable verbose output. Lowers the log level to `debug` so that detailed progress information is emitted, and — when a command fails — prints the full error details, including the underlying `cause` chain and stack trace, instead of only the top-level message. Defaults to `false`.
248
252
  - `--version, -V`: Display version number
249
253
  - `--help, -h`: Display help information
250
254
 
255
+ **Example:**
256
+
257
+ ```bash
258
+ # Re-run a failing command with full diagnostics
259
+ dx --verbose init project
260
+ ```
261
+
251
262
  ---
252
263
 
253
264
  <div align="center">
package/bin/index.js CHANGED
@@ -1,26 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { configure, getConsoleSink } from "@logtape/logtape";
4
3
  import { runCli } from "../dist/index.js";
5
4
  import packageJson from "../package.json" with { type: "json" };
6
5
 
7
- await configure({
8
- loggers: [
9
- { category: ["dx-cli"], lowestLevel: "info", sinks: ["console"] },
10
- { category: ["savemoney"], lowestLevel: "debug", sinks: ["console"] },
11
- { category: ["json"], lowestLevel: "info", sinks: ["rawJson"] },
12
- {
13
- category: ["logtape", "meta"],
14
- lowestLevel: "warning",
15
- sinks: ["console"],
16
- },
17
- ],
18
- sinks: {
19
- console: getConsoleSink(),
20
- rawJson(record) {
21
- console.log(record.rawMessage);
22
- },
23
- },
24
- });
25
-
26
6
  runCli(packageJson.version).catch((error) => console.error(error.message));
@@ -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
  }
@@ -0,0 +1,63 @@
1
+ import { ExecaError } from "execa";
2
+ import { describe, expect, it } from "vitest";
3
+ import { formatErrorDetailed, toErrorMessage } from "../error-reporting.js";
4
+ describe("toErrorMessage", () => {
5
+ it("returns the string as-is when given a string", () => {
6
+ expect(toErrorMessage("oops")).toBe("oops");
7
+ });
8
+ it("returns 'Unknown error' for null/undefined", () => {
9
+ expect(toErrorMessage(null)).toBe("Unknown error");
10
+ expect(toErrorMessage(undefined)).toBe("Unknown error");
11
+ });
12
+ it("returns Error.message when given a plain Error", () => {
13
+ expect(toErrorMessage(new Error("boom"))).toBe("boom");
14
+ });
15
+ it("prefers ExecaError.shortMessage over message", () => {
16
+ const execaError = Object.assign(Object.create(ExecaError.prototype), {
17
+ message: "long noisy message with stderr",
18
+ shortMessage: "Command failed: terraform init",
19
+ });
20
+ expect(toErrorMessage(execaError)).toBe("Command failed: terraform init");
21
+ });
22
+ it("flattens AggregateError into a bulleted message", () => {
23
+ const aggregate = new AggregateError([new Error("first"), new Error("second")], "parent");
24
+ expect(toErrorMessage(aggregate)).toBe("parent\n - first\n - second");
25
+ });
26
+ it("extracts `message` property from plain objects when present", () => {
27
+ expect(toErrorMessage({ message: "from object" })).toBe("from object");
28
+ });
29
+ it("falls back to JSON.stringify for objects without message", () => {
30
+ expect(toErrorMessage({ code: 42 })).toBe('{"code":42}');
31
+ });
32
+ });
33
+ describe("formatErrorDetailed", () => {
34
+ it("renders name, message and stack for a single error", () => {
35
+ const err = new Error("top");
36
+ const formatted = formatErrorDetailed(err);
37
+ expect(formatted).toContain("Error: top");
38
+ expect(formatted).toContain("at ");
39
+ });
40
+ it("walks the cause chain", () => {
41
+ const root = new Error("root failure");
42
+ const middle = new Error("middle", { cause: root });
43
+ const top = new Error("top", { cause: middle });
44
+ const formatted = formatErrorDetailed(top);
45
+ expect(formatted).toContain("Error: top");
46
+ expect(formatted).toContain("Caused by: Error: middle");
47
+ expect(formatted).toContain("Caused by: Error: root failure");
48
+ });
49
+ it("terminates when encountering a cycle in the cause chain", () => {
50
+ const a = new Error("a");
51
+ const b = new Error("b", { cause: a });
52
+ a.cause = b;
53
+ const formatted = formatErrorDetailed(a);
54
+ expect(formatted).toContain("Error: a");
55
+ expect(formatted).toContain("Caused by: Error: b");
56
+ });
57
+ it("handles non-Error causes gracefully", () => {
58
+ const err = new Error("wrapped", { cause: "raw string cause" });
59
+ const formatted = formatErrorDetailed(err);
60
+ expect(formatted).toContain("Error: wrapped");
61
+ expect(formatted).toContain("Caused by: raw string cause");
62
+ });
63
+ });
@@ -0,0 +1,92 @@
1
+ import { Command } from "commander";
2
+ import { describe, expect, it } from "vitest";
3
+ import { exitWithError, isVerbose } from "../index.js";
4
+ /**
5
+ * Builds a parent command that exposes the global `--verbose` flag so that
6
+ * `optsWithGlobals()` behaves the same way it does on the real CLI.
7
+ */
8
+ const makeProgramWith = (child, argv) => {
9
+ const program = new Command()
10
+ .name("dx")
11
+ .option("-v, --verbose", "verbose output", false)
12
+ .exitOverride()
13
+ .configureOutput({
14
+ writeErr: () => {
15
+ /* silence stderr in tests */
16
+ },
17
+ writeOut: () => {
18
+ /* silence stdout in tests */
19
+ },
20
+ });
21
+ child.exitOverride().configureOutput({
22
+ writeErr: () => {
23
+ /* silence */
24
+ },
25
+ writeOut: () => {
26
+ /* silence */
27
+ },
28
+ });
29
+ program.addCommand(child);
30
+ program.parse(argv, { from: "user" });
31
+ return program;
32
+ };
33
+ /**
34
+ * `exitWithError` always throws (Commander's `exitOverride()` converts the
35
+ * process.exit call into a CommanderError throw). This helper captures that
36
+ * throw so tests can assert on the thrown payload without putting `expect`
37
+ * inside a `catch` block (which vitest/no-conditional-expect disallows).
38
+ */
39
+ const captureThrown = (fn) => {
40
+ try {
41
+ fn();
42
+ }
43
+ catch (error) {
44
+ return error;
45
+ }
46
+ throw new Error("expected the callback to throw");
47
+ };
48
+ describe("isVerbose", () => {
49
+ it("is false when --verbose is not provided", () => {
50
+ const cmd = new Command("run").action(() => undefined);
51
+ makeProgramWith(cmd, ["run"]);
52
+ expect(isVerbose(cmd)).toBe(false);
53
+ });
54
+ it("is true when -v is provided at the root", () => {
55
+ const cmd = new Command("run").action(() => undefined);
56
+ makeProgramWith(cmd, ["-v", "run"]);
57
+ expect(isVerbose(cmd)).toBe(true);
58
+ });
59
+ it("is true when --verbose is provided at the root", () => {
60
+ const cmd = new Command("run").action(() => undefined);
61
+ makeProgramWith(cmd, ["--verbose", "run"]);
62
+ expect(isVerbose(cmd)).toBe(true);
63
+ });
64
+ });
65
+ describe("exitWithError", () => {
66
+ it("reports only the message in normal mode", () => {
67
+ const cmd = new Command("run").action(() => undefined);
68
+ makeProgramWith(cmd, ["run"]);
69
+ const err = new Error("outer", { cause: new Error("inner secret") });
70
+ expect(() => exitWithError(cmd)(err)).toThrow(/outer/);
71
+ const thrown = captureThrown(() => exitWithError(cmd)(err));
72
+ const message = String(thrown?.message ?? thrown);
73
+ expect(message).not.toContain("Caused by");
74
+ expect(message).not.toContain("inner secret");
75
+ });
76
+ it("includes the cause chain and stack trace in verbose mode", () => {
77
+ const cmd = new Command("run").action(() => undefined);
78
+ makeProgramWith(cmd, ["--verbose", "run"]);
79
+ const root = new Error("root cause");
80
+ const err = new Error("surface", { cause: root });
81
+ const thrown = captureThrown(() => exitWithError(cmd)(err));
82
+ const message = String(thrown?.message ?? thrown);
83
+ expect(message).toContain("Error: surface");
84
+ expect(message).toContain("Caused by: Error: root cause");
85
+ expect(message).toContain("at ");
86
+ });
87
+ it("works with non-Error values", () => {
88
+ const cmd = new Command("run").action(() => undefined);
89
+ makeProgramWith(cmd, ["run"]);
90
+ expect(() => exitWithError(cmd)("plain string failure")).toThrow(/plain string failure/);
91
+ });
92
+ });
@@ -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;