@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.
- package/README.md +12 -1
- package/bin/index.js +0 -20
- package/dist/adapters/azure/__tests__/cloud-account-service.test.js +132 -1
- package/dist/adapters/azure/cloud-account-service.d.ts +3 -2
- package/dist/adapters/azure/cloud-account-service.js +127 -39
- package/dist/adapters/commander/__tests__/error-reporting.test.d.ts +1 -0
- package/dist/adapters/commander/__tests__/error-reporting.test.js +63 -0
- package/dist/adapters/commander/__tests__/exit-with-error.test.d.ts +1 -0
- package/dist/adapters/commander/__tests__/exit-with-error.test.js +92 -0
- package/dist/adapters/commander/commands/add.d.ts +2 -0
- package/dist/adapters/commander/commands/add.js +8 -5
- package/dist/adapters/commander/commands/codemod.js +3 -2
- package/dist/adapters/commander/commands/init.js +2 -2
- package/dist/adapters/commander/commands/savemoney.js +6 -3
- package/dist/adapters/commander/error-reporting.d.ts +10 -0
- package/dist/adapters/commander/error-reporting.js +68 -0
- package/dist/adapters/commander/index.d.ts +17 -1
- package/dist/adapters/commander/index.js +23 -2
- package/dist/adapters/octokit/index.d.ts +2 -1
- package/dist/adapters/octokit/index.js +33 -0
- package/dist/adapters/plop/__tests__/run-actions.test.d.ts +1 -0
- package/dist/adapters/plop/__tests__/run-actions.test.js +68 -0
- package/dist/adapters/plop/actions/__tests__/init-cloud-accounts.test.js +24 -7
- package/dist/adapters/plop/actions/init-cloud-accounts.d.ts +3 -2
- package/dist/adapters/plop/actions/init-cloud-accounts.js +4 -4
- package/dist/adapters/plop/generators/environment/__tests__/actions.test.js +6 -1
- package/dist/adapters/plop/generators/environment/actions.js +12 -0
- package/dist/adapters/plop/generators/environment/index.d.ts +2 -1
- package/dist/adapters/plop/generators/environment/index.js +2 -2
- package/dist/adapters/plop/index.d.ts +5 -3
- package/dist/adapters/plop/index.js +20 -12
- package/dist/domain/cloud-account.d.ts +3 -2
- package/dist/domain/github.d.ts +13 -0
- package/dist/index.js +36 -0
- package/package.json +4 -2
- 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 {
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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("
|
|
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 @@
|
|
|
1
|
+
export {};
|
|
@@ -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 @@
|
|
|
1
|
+
export {};
|
|
@@ -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;
|