@pagopa/dx-cli 0.21.0 → 0.21.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.
- package/dist/adapters/azure/__tests__/cloud-account-service.test.js +48 -2
- package/dist/adapters/azure/cloud-account-service.js +67 -24
- package/dist/adapters/commander/commands/__tests__/init.test.d.ts +4 -0
- package/dist/adapters/commander/commands/__tests__/init.test.js +48 -0
- package/dist/adapters/commander/commands/__tests__/preconditions.test.d.ts +1 -0
- package/dist/adapters/commander/commands/__tests__/preconditions.test.js +32 -0
- package/dist/adapters/commander/commands/add.js +2 -2
- package/dist/adapters/commander/commands/init.d.ts +11 -1
- package/dist/adapters/commander/commands/init.js +33 -6
- package/dist/adapters/plop/generators/environment/__tests__/prompts.test.js +61 -1
- package/dist/adapters/plop/generators/environment/prompts.d.ts +9 -0
- package/dist/adapters/plop/generators/environment/prompts.js +39 -1
- package/package.json +10 -10
- package/templates/environment/workflow/_release-terraform-apply-bootstrapper-{{env.name}}.yaml.hbs +1 -0
- package/templates/monorepo/.prettierignore +2 -1
|
@@ -241,7 +241,10 @@ describe("isInitialized", () => {
|
|
|
241
241
|
});
|
|
242
242
|
});
|
|
243
243
|
describe("initialize", () => {
|
|
244
|
-
test("assigns bootstrap roles
|
|
244
|
+
test("assigns bootstrap roles and creates bootstrap environment secrets", async ({ cloudAccountService, }) => {
|
|
245
|
+
const createOrUpdateEnvironmentSecret = vi
|
|
246
|
+
.fn()
|
|
247
|
+
.mockResolvedValue(undefined);
|
|
245
248
|
await cloudAccountService.initialize({
|
|
246
249
|
csp: "azure",
|
|
247
250
|
defaultLocation: "italynorth",
|
|
@@ -259,7 +262,7 @@ describe("initialize", () => {
|
|
|
259
262
|
repo: "dx",
|
|
260
263
|
}, {
|
|
261
264
|
createBranch: vi.fn(),
|
|
262
|
-
createOrUpdateEnvironmentSecret
|
|
265
|
+
createOrUpdateEnvironmentSecret,
|
|
263
266
|
createPullRequest: vi.fn(),
|
|
264
267
|
getFileContent: vi.fn(),
|
|
265
268
|
getRepository: vi.fn(),
|
|
@@ -286,5 +289,48 @@ describe("initialize", () => {
|
|
|
286
289
|
issuer: "https://token.actions.githubusercontent.com",
|
|
287
290
|
subject: "repo:pagopa/dx:environment:bootstrapper-dev-cd",
|
|
288
291
|
});
|
|
292
|
+
expect(createOrUpdateEnvironmentSecret).toHaveBeenCalledTimes(6);
|
|
293
|
+
expect(createOrUpdateEnvironmentSecret).toHaveBeenCalledWith({
|
|
294
|
+
environmentName: "bootstrapper-dev-cd",
|
|
295
|
+
owner: "pagopa",
|
|
296
|
+
repo: "dx",
|
|
297
|
+
secretName: "ARM_CLIENT_ID",
|
|
298
|
+
secretValue: "client-1",
|
|
299
|
+
});
|
|
300
|
+
expect(createOrUpdateEnvironmentSecret).toHaveBeenCalledWith({
|
|
301
|
+
environmentName: "bootstrapper-dev-cd",
|
|
302
|
+
owner: "pagopa",
|
|
303
|
+
repo: "dx",
|
|
304
|
+
secretName: "ARM_TENANT_ID",
|
|
305
|
+
secretValue: "tenant-1",
|
|
306
|
+
});
|
|
307
|
+
expect(createOrUpdateEnvironmentSecret).toHaveBeenCalledWith({
|
|
308
|
+
environmentName: "bootstrapper-dev-cd",
|
|
309
|
+
owner: "pagopa",
|
|
310
|
+
repo: "dx",
|
|
311
|
+
secretName: "ARM_SUBSCRIPTION_ID",
|
|
312
|
+
secretValue: "sub-1",
|
|
313
|
+
});
|
|
314
|
+
expect(createOrUpdateEnvironmentSecret).toHaveBeenCalledWith({
|
|
315
|
+
environmentName: "bootstrapper-dev-cd",
|
|
316
|
+
owner: "pagopa",
|
|
317
|
+
repo: "dx",
|
|
318
|
+
secretName: "GH_APP_ID",
|
|
319
|
+
secretValue: "app-id",
|
|
320
|
+
});
|
|
321
|
+
expect(createOrUpdateEnvironmentSecret).toHaveBeenCalledWith({
|
|
322
|
+
environmentName: "bootstrapper-dev-cd",
|
|
323
|
+
owner: "pagopa",
|
|
324
|
+
repo: "dx",
|
|
325
|
+
secretName: "GH_APP_INSTALLATION_ID",
|
|
326
|
+
secretValue: "installation-id",
|
|
327
|
+
});
|
|
328
|
+
expect(createOrUpdateEnvironmentSecret).toHaveBeenCalledWith({
|
|
329
|
+
environmentName: "bootstrapper-dev-cd",
|
|
330
|
+
owner: "pagopa",
|
|
331
|
+
repo: "dx",
|
|
332
|
+
secretName: "GH_APP_KEY",
|
|
333
|
+
secretValue: "private-key",
|
|
334
|
+
});
|
|
289
335
|
});
|
|
290
336
|
});
|
|
@@ -169,6 +169,7 @@ export class AzureCloudAccountService {
|
|
|
169
169
|
const parameters = {
|
|
170
170
|
location: cloudAccount.defaultLocation,
|
|
171
171
|
tags: {
|
|
172
|
+
CreatedBy: "DX CLI",
|
|
172
173
|
Environment: name,
|
|
173
174
|
...tags,
|
|
174
175
|
},
|
|
@@ -185,6 +186,10 @@ export class AzureCloudAccountService {
|
|
|
185
186
|
const identityClientId = identity.clientId;
|
|
186
187
|
logger.debug("Created identity {identityName} in subscription {subscriptionId}", { identityName, subscriptionId: cloudAccount.id });
|
|
187
188
|
const authorizationManagementClient = new AuthorizationManagementClient(this.#credential, cloudAccount.id);
|
|
189
|
+
const subscriptionClient = new SubscriptionClient(this.#credential);
|
|
190
|
+
const subscription = await subscriptionClient.subscriptions.get(cloudAccount.id);
|
|
191
|
+
assert.ok(subscription.tenantId, "Subscription tenant ID is undefined");
|
|
192
|
+
const tenantId = subscription.tenantId;
|
|
188
193
|
const subscriptionScope = `/subscriptions/${cloudAccount.id}`;
|
|
189
194
|
// Grant the bootstrap identity the Azure permissions it needs to operate autonomously in the bootstrap workflow.
|
|
190
195
|
await Promise.all(bootstrapIdentityRoleDefinitionIds.map((roleDefinitionId) => authorizationManagementClient.roleAssignments.create(subscriptionScope, this.#createRoleAssignmentName(subscriptionScope, identityPrincipalId, roleDefinitionId), {
|
|
@@ -205,25 +210,14 @@ export class AzureCloudAccountService {
|
|
|
205
210
|
identityName,
|
|
206
211
|
subscriptionId: cloudAccount.id,
|
|
207
212
|
});
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
}),
|
|
224
|
-
]);
|
|
225
|
-
logger.debug("Set GitHub environment secrets for {environmentName}", {
|
|
226
|
-
environmentName: githubEnvironmentName,
|
|
213
|
+
await this.#storeBootstrapperEnvironmentSecrets({
|
|
214
|
+
cloudAccountId: cloudAccount.id,
|
|
215
|
+
github,
|
|
216
|
+
githubEnvironmentName,
|
|
217
|
+
gitHubService,
|
|
218
|
+
identityClientId,
|
|
219
|
+
runnerAppCredentials,
|
|
220
|
+
tenantId,
|
|
227
221
|
});
|
|
228
222
|
const keyVaultName = await this.#createCommonKeyVault({
|
|
229
223
|
cloudAccount,
|
|
@@ -233,6 +227,7 @@ export class AzureCloudAccountService {
|
|
|
233
227
|
shortEnv: short.env,
|
|
234
228
|
shortLocation: short.location,
|
|
235
229
|
tags,
|
|
230
|
+
tenantId,
|
|
236
231
|
});
|
|
237
232
|
await this.#storeRunnerAppSecrets({
|
|
238
233
|
cloudAccountId: cloudAccount.id,
|
|
@@ -348,11 +343,8 @@ export class AzureCloudAccountService {
|
|
|
348
343
|
}));
|
|
349
344
|
return results.every(Boolean);
|
|
350
345
|
}
|
|
351
|
-
async #createCommonKeyVault({ cloudAccount, name, prefix, resourceGroupName, shortEnv, shortLocation, tags, }) {
|
|
346
|
+
async #createCommonKeyVault({ cloudAccount, name, prefix, resourceGroupName, shortEnv, shortLocation, tags, tenantId, }) {
|
|
352
347
|
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
348
|
const kvClient = new KeyVaultManagementClient(this.#credential, cloudAccount.id);
|
|
357
349
|
const keyVaultName = `${prefix}-${shortEnv}-${shortLocation}-common-kv-01`;
|
|
358
350
|
const secretsProtectionEnabled = shortEnv === "p";
|
|
@@ -372,7 +364,7 @@ export class AzureCloudAccountService {
|
|
|
372
364
|
name: "standard",
|
|
373
365
|
},
|
|
374
366
|
softDeleteRetentionInDays: secretsProtectionEnabled ? 14 : 7,
|
|
375
|
-
tenantId
|
|
367
|
+
tenantId,
|
|
376
368
|
},
|
|
377
369
|
tags: {
|
|
378
370
|
Environment: name,
|
|
@@ -437,6 +429,57 @@ export class AzureCloudAccountService {
|
|
|
437
429
|
}));
|
|
438
430
|
logger.info("All resource providers registered on subscription {subscriptionId}", { subscriptionId });
|
|
439
431
|
}
|
|
432
|
+
async #storeBootstrapperEnvironmentSecrets({ cloudAccountId, github, githubEnvironmentName, gitHubService, identityClientId, runnerAppCredentials, tenantId, }) {
|
|
433
|
+
const logger = getLogger(["gen", "env"]);
|
|
434
|
+
await Promise.all([
|
|
435
|
+
gitHubService.createOrUpdateEnvironmentSecret({
|
|
436
|
+
environmentName: githubEnvironmentName,
|
|
437
|
+
owner: github.owner,
|
|
438
|
+
repo: github.repo,
|
|
439
|
+
secretName: "ARM_CLIENT_ID",
|
|
440
|
+
secretValue: identityClientId,
|
|
441
|
+
}),
|
|
442
|
+
gitHubService.createOrUpdateEnvironmentSecret({
|
|
443
|
+
environmentName: githubEnvironmentName,
|
|
444
|
+
owner: github.owner,
|
|
445
|
+
repo: github.repo,
|
|
446
|
+
secretName: "ARM_TENANT_ID",
|
|
447
|
+
secretValue: tenantId,
|
|
448
|
+
}),
|
|
449
|
+
gitHubService.createOrUpdateEnvironmentSecret({
|
|
450
|
+
environmentName: githubEnvironmentName,
|
|
451
|
+
owner: github.owner,
|
|
452
|
+
repo: github.repo,
|
|
453
|
+
secretName: "ARM_SUBSCRIPTION_ID",
|
|
454
|
+
secretValue: cloudAccountId,
|
|
455
|
+
}),
|
|
456
|
+
gitHubService.createOrUpdateEnvironmentSecret({
|
|
457
|
+
environmentName: githubEnvironmentName,
|
|
458
|
+
owner: github.owner,
|
|
459
|
+
repo: github.repo,
|
|
460
|
+
secretName: "GH_APP_ID",
|
|
461
|
+
secretValue: runnerAppCredentials.id,
|
|
462
|
+
}),
|
|
463
|
+
gitHubService.createOrUpdateEnvironmentSecret({
|
|
464
|
+
environmentName: githubEnvironmentName,
|
|
465
|
+
owner: github.owner,
|
|
466
|
+
repo: github.repo,
|
|
467
|
+
secretName: "GH_APP_INSTALLATION_ID",
|
|
468
|
+
secretValue: runnerAppCredentials.installationId,
|
|
469
|
+
}),
|
|
470
|
+
gitHubService.createOrUpdateEnvironmentSecret({
|
|
471
|
+
environmentName: githubEnvironmentName,
|
|
472
|
+
owner: github.owner,
|
|
473
|
+
repo: github.repo,
|
|
474
|
+
secretName: "GH_APP_KEY",
|
|
475
|
+
secretValue: runnerAppCredentials.key.trimEnd(),
|
|
476
|
+
}),
|
|
477
|
+
]);
|
|
478
|
+
logger.debug("Set GitHub environment secrets for {environmentName}", {
|
|
479
|
+
environmentName: githubEnvironmentName,
|
|
480
|
+
subscriptionId: cloudAccountId,
|
|
481
|
+
});
|
|
482
|
+
}
|
|
440
483
|
async #storeRunnerAppSecrets({ cloudAccountId, keyVaultName, runnerAppCredentials, }) {
|
|
441
484
|
const logger = getLogger(["gen", "env"]);
|
|
442
485
|
const secretClient = new SecretClient(`https://${keyVaultName}.vault.azure.net/`, this.#credential);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for confirmGitHubRepoCreation in the init command.
|
|
3
|
+
*/
|
|
4
|
+
import inquirer from "inquirer";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { confirmGitHubRepoCreation } from "../init.js";
|
|
7
|
+
vi.mock("inquirer");
|
|
8
|
+
const makePayload = (overrides = {}) => ({
|
|
9
|
+
repoDescription: "A test repo",
|
|
10
|
+
repoName: "test-repo",
|
|
11
|
+
repoOwner: "pagopa",
|
|
12
|
+
...overrides,
|
|
13
|
+
});
|
|
14
|
+
describe("confirmGitHubRepoCreation", () => {
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
vi.restoreAllMocks();
|
|
17
|
+
});
|
|
18
|
+
it("returns true when the user confirms", async () => {
|
|
19
|
+
vi.mocked(inquirer.prompt).mockResolvedValue({ confirm: true });
|
|
20
|
+
const result = await confirmGitHubRepoCreation(makePayload());
|
|
21
|
+
expect(result.isOk()).toBe(true);
|
|
22
|
+
expect(result._unsafeUnwrap()).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
it("returns false when the user declines", async () => {
|
|
25
|
+
vi.mocked(inquirer.prompt).mockResolvedValue({ confirm: false });
|
|
26
|
+
const result = await confirmGitHubRepoCreation(makePayload());
|
|
27
|
+
expect(result.isOk()).toBe(true);
|
|
28
|
+
expect(result._unsafeUnwrap()).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
it("prompts with the correct repository name and owner", async () => {
|
|
31
|
+
vi.mocked(inquirer.prompt).mockResolvedValue({ confirm: true });
|
|
32
|
+
const payload = makePayload({ repoName: "my-repo", repoOwner: "my-org" });
|
|
33
|
+
await confirmGitHubRepoCreation(payload);
|
|
34
|
+
expect(inquirer.prompt).toHaveBeenCalledWith(expect.objectContaining({
|
|
35
|
+
message: expect.stringContaining("my-org/my-repo"),
|
|
36
|
+
type: "confirm",
|
|
37
|
+
}));
|
|
38
|
+
});
|
|
39
|
+
it("returns an error result when the prompt rejects", async () => {
|
|
40
|
+
const cause = new Error("non-interactive TTY");
|
|
41
|
+
vi.mocked(inquirer.prompt).mockRejectedValue(cause);
|
|
42
|
+
const result = await confirmGitHubRepoCreation(makePayload());
|
|
43
|
+
expect(result.isErr()).toBe(true);
|
|
44
|
+
const err = result._unsafeUnwrapErr();
|
|
45
|
+
expect(err).toBeInstanceOf(Error);
|
|
46
|
+
expect(err.cause).toBe(cause);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
const mocks = vi.hoisted(() => ({
|
|
3
|
+
oraPromise: vi.fn((promise) => promise),
|
|
4
|
+
tf$: vi.fn(async (...args) => {
|
|
5
|
+
void args;
|
|
6
|
+
return { stdout: '{"user":{"name":"test@example.com"}}' };
|
|
7
|
+
}),
|
|
8
|
+
}));
|
|
9
|
+
vi.mock("ora", () => ({ oraPromise: mocks.oraPromise }));
|
|
10
|
+
vi.mock("../../../execa/terraform.js", () => ({ tf$: mocks.tf$ }));
|
|
11
|
+
import { checkAddEnvironmentPreconditions, checkInitPreconditions, } from "../init.js";
|
|
12
|
+
const calledCommands = () => mocks.tf$.mock.calls.map(([strings, ...values]) => strings.reduce((command, part, index) => command + part + String(values[index] ?? ""), ""));
|
|
13
|
+
describe("init preconditions", () => {
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
vi.clearAllMocks();
|
|
16
|
+
});
|
|
17
|
+
it("checkInitPreconditions does not require Azure login", async () => {
|
|
18
|
+
const result = await checkInitPreconditions();
|
|
19
|
+
expect(result.isOk()).toBe(true);
|
|
20
|
+
expect(calledCommands()).toEqual(["terraform -version", "corepack -v"]);
|
|
21
|
+
});
|
|
22
|
+
it("checkAddEnvironmentPreconditions requires Azure login", async () => {
|
|
23
|
+
const result = await checkAddEnvironmentPreconditions();
|
|
24
|
+
expect(result.isOk()).toBe(true);
|
|
25
|
+
expect(calledCommands()).toEqual([
|
|
26
|
+
"terraform -version",
|
|
27
|
+
"az account show",
|
|
28
|
+
"az group list",
|
|
29
|
+
"corepack -v",
|
|
30
|
+
]);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -16,7 +16,7 @@ import { environmentShort } from "../../../domain/environment.js";
|
|
|
16
16
|
import { isAzureLocation, locationShort } from "../../azure/locations.js";
|
|
17
17
|
import { getPlopInstance, runDeploymentEnvironmentGenerator, } from "../../plop/index.js";
|
|
18
18
|
import { exitWithError } from "../index.js";
|
|
19
|
-
import {
|
|
19
|
+
import { checkAddEnvironmentPreconditions } from "./init.js";
|
|
20
20
|
/**
|
|
21
21
|
* Authorize a Cloud Account (Azure Subscription, AWS Account, ...), creating a Pull Request for each account that requires authorization.
|
|
22
22
|
*/
|
|
@@ -70,7 +70,7 @@ const displaySummary = (result) => {
|
|
|
70
70
|
}
|
|
71
71
|
console.log(`${step}. Visit ${chalk.underline("https://dx.pagopa.it/getting-started")} to deploy your first project\n`);
|
|
72
72
|
};
|
|
73
|
-
const addEnvironmentAction = (authorizationService, gitHubService) =>
|
|
73
|
+
const addEnvironmentAction = (authorizationService, gitHubService) => checkAddEnvironmentPreconditions()
|
|
74
74
|
.andThen(() => ResultAsync.fromPromise(getPlopInstance(), (cause) => new Error("Failed to initialize plop", { cause })))
|
|
75
75
|
.andThen((plop) => ResultAsync.fromPromise(runDeploymentEnvironmentGenerator(plop, gitHubService), (cause) => new Error("Failed to run the deployment environment generator", {
|
|
76
76
|
cause,
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import { ResultAsync } from "neverthrow";
|
|
3
3
|
import { GitHubService } from "../../../domain/github.js";
|
|
4
|
-
|
|
4
|
+
import { Payload as MonorepoPayload } from "../../plop/generators/monorepo/index.js";
|
|
5
|
+
export declare const checkInitPreconditions: () => ResultAsync<import("execa").Result<{
|
|
5
6
|
environment: {
|
|
6
7
|
NO_COLOR: string;
|
|
7
8
|
TF_IN_AUTOMATION: string;
|
|
@@ -9,6 +10,15 @@ export declare const checkPreconditions: () => ResultAsync<import("execa").Resul
|
|
|
9
10
|
};
|
|
10
11
|
shell: true;
|
|
11
12
|
}>, Error>;
|
|
13
|
+
export declare const checkAddEnvironmentPreconditions: () => ResultAsync<import("execa").Result<{
|
|
14
|
+
environment: {
|
|
15
|
+
NO_COLOR: string;
|
|
16
|
+
TF_IN_AUTOMATION: string;
|
|
17
|
+
TF_INPUT: string;
|
|
18
|
+
};
|
|
19
|
+
shell: true;
|
|
20
|
+
}>, Error>;
|
|
21
|
+
export declare const confirmGitHubRepoCreation: (payload: MonorepoPayload) => ResultAsync<boolean, Error>;
|
|
12
22
|
type InitCommandDependencies = {
|
|
13
23
|
gitHubService: GitHubService;
|
|
14
24
|
};
|
|
@@ -2,6 +2,7 @@ import { getLogger } from "@logtape/logtape";
|
|
|
2
2
|
import chalk from "chalk";
|
|
3
3
|
import { Command } from "commander";
|
|
4
4
|
import { $, ExecaError } from "execa";
|
|
5
|
+
import inquirer from "inquirer";
|
|
5
6
|
import { okAsync, ResultAsync } from "neverthrow";
|
|
6
7
|
import * as path from "node:path";
|
|
7
8
|
import { oraPromise } from "ora";
|
|
@@ -10,13 +11,28 @@ import { Repository, } from "../../../domain/github.js";
|
|
|
10
11
|
import { tf$ } from "../../execa/terraform.js";
|
|
11
12
|
import { getPlopInstance, runMonorepoGenerator } from "../../plop/index.js";
|
|
12
13
|
import { exitWithError } from "../index.js";
|
|
14
|
+
const isGitHubRepoCreationSkipped = (input) => "gitHubRepoCreationSkipped" in input;
|
|
13
15
|
const withSpinner = (text, successText, failText, promise) => ResultAsync.fromPromise(oraPromise(promise, {
|
|
14
16
|
failText,
|
|
15
17
|
successText,
|
|
16
18
|
text,
|
|
17
19
|
}), (cause) => new Error(failText, { cause }));
|
|
18
|
-
const displaySummary = (
|
|
19
|
-
const
|
|
20
|
+
const displaySummary = (input) => {
|
|
21
|
+
const docsUrl = "https://dx.pagopa.it/getting-started";
|
|
22
|
+
if (isGitHubRepoCreationSkipped(input)) {
|
|
23
|
+
const { payload } = input;
|
|
24
|
+
console.log(chalk.yellow.bold("\nGitHub repository creation skipped."));
|
|
25
|
+
console.log(`The workspace files have been scaffolded in ${chalk.cyan(payload.repoName + "/")}.`);
|
|
26
|
+
console.log(chalk.bold("\nTo finish the setup manually:"));
|
|
27
|
+
let step = 1;
|
|
28
|
+
console.log(`${step++}. Create the GitHub repository by applying the Terraform config scaffolded at ${chalk.cyan(`${payload.repoName}/infra/repository`)}:`);
|
|
29
|
+
console.log(` ${chalk.cyan(`cd ${payload.repoName}/infra/repository && terraform init && terraform apply`)}`);
|
|
30
|
+
console.log(`${step++}. Push the scaffolded code to the newly created repository:`);
|
|
31
|
+
console.log(` ${chalk.cyan(`cd ${payload.repoName} && git init && git remote add origin <url> && git push`)}`);
|
|
32
|
+
console.log(`${step}. Visit ${chalk.underline(docsUrl)} to deploy your first project\n`);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const { pr, repository } = input;
|
|
20
36
|
console.log(chalk.green.bold("\nWorkspace created successfully!"));
|
|
21
37
|
if (repository) {
|
|
22
38
|
console.log(`- Name: ${chalk.cyan(repository.name)}`);
|
|
@@ -29,7 +45,7 @@ const displaySummary = (initResult) => {
|
|
|
29
45
|
let step = 1;
|
|
30
46
|
console.log(chalk.green.bold("\nNext Steps:"));
|
|
31
47
|
console.log(`${step++}. Review the Pull Request in the GitHub repository: ${chalk.underline(pr.url)}`);
|
|
32
|
-
console.log(`${step}. Visit ${chalk.underline(
|
|
48
|
+
console.log(`${step}. Visit ${chalk.underline(docsUrl)} to deploy your first project\n`);
|
|
33
49
|
}
|
|
34
50
|
else {
|
|
35
51
|
console.log(chalk.yellow(`\n⚠️ There was an error during Pull Request creation.`));
|
|
@@ -54,7 +70,8 @@ const ensureAzLogin = async () => {
|
|
|
54
70
|
};
|
|
55
71
|
const checkAzLogin = () => withSpinner("Check Azure login status...", (userName) => `You are logged in to Azure (${userName})`, "Please log in to Azure CLI using `az login` before running this command.", ensureAzLogin());
|
|
56
72
|
// TODO(CES-1810): Make these checks concurrent to speed up the preconditions check phase
|
|
57
|
-
export const
|
|
73
|
+
export const checkInitPreconditions = () => checkTerraformCliIsInstalled().andThen(() => checkCorepackIsInstalled());
|
|
74
|
+
export const checkAddEnvironmentPreconditions = () => checkTerraformCliIsInstalled()
|
|
58
75
|
.andThen(() => checkAzLogin())
|
|
59
76
|
.andThen(() => checkCorepackIsInstalled());
|
|
60
77
|
const createRemoteRepository = ({ repoName, repoOwner, }) => {
|
|
@@ -117,16 +134,26 @@ const handleGeneratorError = (err) => {
|
|
|
117
134
|
}
|
|
118
135
|
return new Error("Failed to run the generator", { cause: err });
|
|
119
136
|
};
|
|
137
|
+
export const confirmGitHubRepoCreation = (payload) => ResultAsync.fromPromise(inquirer
|
|
138
|
+
.prompt({
|
|
139
|
+
default: true,
|
|
140
|
+
message: `The project is created on ${chalk.green(payload.repoName)}. Would you like to publish it to GitHub at ${chalk.green(`${payload.repoOwner}/${payload.repoName}`)} now?`,
|
|
141
|
+
name: "confirm",
|
|
142
|
+
type: "confirm",
|
|
143
|
+
})
|
|
144
|
+
.then(({ confirm }) => confirm), (cause) => new Error("Failed to read GitHub publish confirmation", { cause }));
|
|
120
145
|
export const makeInitCommand = ({ gitHubService, }) => new Command()
|
|
121
146
|
.name("init")
|
|
122
147
|
.description("Initialize a new DX workspace")
|
|
123
148
|
.action(async function () {
|
|
124
|
-
await
|
|
149
|
+
await checkInitPreconditions()
|
|
125
150
|
.andThen(() => ResultAsync.fromPromise(getPlopInstance(), (cause) => new Error("Failed to initialize plop", { cause })))
|
|
126
151
|
.andThen((plop) => ResultAsync.fromPromise(runMonorepoGenerator(plop, gitHubService), handleGeneratorError))
|
|
127
152
|
.andTee((payload) => {
|
|
128
153
|
process.chdir(payload.repoName);
|
|
129
154
|
})
|
|
130
|
-
.andThen((payload) =>
|
|
155
|
+
.andThen((payload) => confirmGitHubRepoCreation(payload).andThen((confirmed) => confirmed
|
|
156
|
+
? handleNewGitHubRepository(gitHubService)(payload)
|
|
157
|
+
: okAsync({ gitHubRepoCreationSkipped: true, payload })))
|
|
131
158
|
.match(displaySummary, exitWithError(this));
|
|
132
159
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Tests for workspaceSchema transforms (lowercase and trim on domain).
|
|
2
2
|
import { describe, expect, it } from "vitest";
|
|
3
|
-
import { workspaceSchema } from "../prompts.js";
|
|
3
|
+
import { formatInitializationDetails, workspaceSchema } from "../prompts.js";
|
|
4
4
|
describe("workspaceSchema — domain transforms", () => {
|
|
5
5
|
it("lowercases an uppercase domain", () => {
|
|
6
6
|
const result = workspaceSchema.safeParse({ domain: "API" });
|
|
@@ -23,3 +23,63 @@ describe("workspaceSchema — domain transforms", () => {
|
|
|
23
23
|
expect(result.success && result.data.domain).toBe("");
|
|
24
24
|
});
|
|
25
25
|
});
|
|
26
|
+
describe("formatInitializationDetails", () => {
|
|
27
|
+
const account = (overrides = {}) => ({
|
|
28
|
+
csp: "azure",
|
|
29
|
+
defaultLocation: "italynorth",
|
|
30
|
+
displayName: "DEV-FooBar",
|
|
31
|
+
id: "sub-123",
|
|
32
|
+
...overrides,
|
|
33
|
+
});
|
|
34
|
+
const notInitializedStatus = (issues) => ({
|
|
35
|
+
initialized: false,
|
|
36
|
+
issues,
|
|
37
|
+
});
|
|
38
|
+
it("lists each uninitialized cloud account by displayName", () => {
|
|
39
|
+
const status = notInitializedStatus([
|
|
40
|
+
{
|
|
41
|
+
cloudAccount: account({ displayName: "DEV-A" }),
|
|
42
|
+
type: "CLOUD_ACCOUNT_NOT_INITIALIZED",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
cloudAccount: account({ displayName: "DEV-B" }),
|
|
46
|
+
type: "CLOUD_ACCOUNT_NOT_INITIALIZED",
|
|
47
|
+
},
|
|
48
|
+
]);
|
|
49
|
+
const output = formatInitializationDetails(status);
|
|
50
|
+
expect(output).toContain('Azure subscription "DEV-A"');
|
|
51
|
+
expect(output).toContain('Azure subscription "DEV-B"');
|
|
52
|
+
expect(output).toContain("managed identity");
|
|
53
|
+
expect(output).toContain("OIDC");
|
|
54
|
+
expect(output).toContain("ARM_CLIENT_ID");
|
|
55
|
+
expect(output).toContain("ARM_SUBSCRIPTION_ID");
|
|
56
|
+
expect(output).not.toContain("ARM_TENANT_ID");
|
|
57
|
+
expect(output).toContain("Key Vault");
|
|
58
|
+
});
|
|
59
|
+
it("includes the Terraform backend section when MISSING_REMOTE_BACKEND issue is present", () => {
|
|
60
|
+
const status = notInitializedStatus([
|
|
61
|
+
{ cloudAccount: account(), type: "CLOUD_ACCOUNT_NOT_INITIALIZED" },
|
|
62
|
+
{ type: "MISSING_REMOTE_BACKEND" },
|
|
63
|
+
]);
|
|
64
|
+
const output = formatInitializationDetails(status);
|
|
65
|
+
expect(output).toContain("Terraform remote backend");
|
|
66
|
+
expect(output).toContain("Storage Account");
|
|
67
|
+
});
|
|
68
|
+
it("omits the Terraform backend section when no MISSING_REMOTE_BACKEND issue is present", () => {
|
|
69
|
+
const status = notInitializedStatus([
|
|
70
|
+
{ cloudAccount: account(), type: "CLOUD_ACCOUNT_NOT_INITIALIZED" },
|
|
71
|
+
]);
|
|
72
|
+
const output = formatInitializationDetails(status);
|
|
73
|
+
expect(output).not.toContain("Terraform remote backend");
|
|
74
|
+
});
|
|
75
|
+
it("omits the cloud account section when no CLOUD_ACCOUNT_NOT_INITIALIZED issues are present", () => {
|
|
76
|
+
const status = notInitializedStatus([{ type: "MISSING_REMOTE_BACKEND" }]);
|
|
77
|
+
const output = formatInitializationDetails(status);
|
|
78
|
+
expect(output).not.toContain("Azure subscription");
|
|
79
|
+
expect(output).toContain("Terraform remote backend");
|
|
80
|
+
});
|
|
81
|
+
it("returns an empty string when there are no relevant issues", () => {
|
|
82
|
+
const status = notInitializedStatus([]);
|
|
83
|
+
expect(formatInitializationDetails(status)).toBe("");
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -79,4 +79,13 @@ export declare const getCloudAccountToInitialize: (initStatus: EnvironmentInitSt
|
|
|
79
79
|
displayName: string;
|
|
80
80
|
id: string;
|
|
81
81
|
}[];
|
|
82
|
+
/**
|
|
83
|
+
* Build a human-readable description of the resources that will be created
|
|
84
|
+
* when initializing an environment, so users see the side effects before
|
|
85
|
+
* confirming. The exact resource names are intentionally omitted to avoid
|
|
86
|
+
* coupling the prompt copy to internal naming conventions.
|
|
87
|
+
*/
|
|
88
|
+
export declare const formatInitializationDetails: (initStatus: EnvironmentInitStatus & {
|
|
89
|
+
initialized: false;
|
|
90
|
+
}) => string;
|
|
82
91
|
export default prompts;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
1
2
|
import * as assert from "node:assert/strict";
|
|
2
3
|
import { cloudAccountSchema, } from "../../../../domain/cloud-account.js";
|
|
3
4
|
import { environmentSchema, getInitializationStatus, hasUserPermissionToInitialize, } from "../../../../domain/environment.js";
|
|
@@ -126,9 +127,10 @@ const prompts = (deps) => async (inquirer) => {
|
|
|
126
127
|
if (initStatus.initialized) {
|
|
127
128
|
return payload;
|
|
128
129
|
}
|
|
130
|
+
console.log(formatInitializationDetails(initStatus));
|
|
129
131
|
const initConfirm = await inquirer.prompt({
|
|
130
132
|
default: true,
|
|
131
|
-
message:
|
|
133
|
+
message: `The environment "${payload.env.name}" is not initialized. Proceed with the setup above?`,
|
|
132
134
|
name: "init",
|
|
133
135
|
type: "confirm",
|
|
134
136
|
});
|
|
@@ -198,4 +200,40 @@ export const getCloudLocationChoices = (regions) => regions.map((r) => ({ name:
|
|
|
198
200
|
export const getCloudAccountToInitialize = (initStatus) => initStatus.issues
|
|
199
201
|
.filter((issue) => issue.type === "CLOUD_ACCOUNT_NOT_INITIALIZED")
|
|
200
202
|
.map((issue) => issue.cloudAccount);
|
|
203
|
+
/**
|
|
204
|
+
* Build a human-readable description of the resources that will be created
|
|
205
|
+
* when initializing an environment, so users see the side effects before
|
|
206
|
+
* confirming. The exact resource names are intentionally omitted to avoid
|
|
207
|
+
* coupling the prompt copy to internal naming conventions.
|
|
208
|
+
*/
|
|
209
|
+
export const formatInitializationDetails = (initStatus) => {
|
|
210
|
+
const accountsToInit = getCloudAccountToInitialize(initStatus);
|
|
211
|
+
const missingBackend = initStatus.issues.some((issue) => issue.type === "MISSING_REMOTE_BACKEND");
|
|
212
|
+
const sections = [];
|
|
213
|
+
for (const account of accountsToInit) {
|
|
214
|
+
sections.push([
|
|
215
|
+
chalk.bold.cyan(` Azure subscription "${account.displayName}":`),
|
|
216
|
+
` • Bootstrap resource group and managed identity with subscription-scoped roles`,
|
|
217
|
+
` • GitHub OIDC federated identity credential`,
|
|
218
|
+
` • GitHub environment secrets (ARM_CLIENT_ID, ARM_SUBSCRIPTION_ID)`,
|
|
219
|
+
` • Common Key Vault storing the GitHub runner app credentials`,
|
|
220
|
+
].join("\n"));
|
|
221
|
+
}
|
|
222
|
+
if (missingBackend) {
|
|
223
|
+
sections.push([
|
|
224
|
+
chalk.bold.cyan(` Terraform remote backend:`),
|
|
225
|
+
` • Azure resource group and Storage Account for the Terraform state`,
|
|
226
|
+
].join("\n"));
|
|
227
|
+
}
|
|
228
|
+
if (sections.length === 0) {
|
|
229
|
+
return "";
|
|
230
|
+
}
|
|
231
|
+
return [
|
|
232
|
+
"",
|
|
233
|
+
chalk.bold("The following resources will be created:"),
|
|
234
|
+
"",
|
|
235
|
+
...sections,
|
|
236
|
+
"",
|
|
237
|
+
].join("\n");
|
|
238
|
+
};
|
|
201
239
|
export default prompts;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pagopa/dx-cli",
|
|
3
|
-
"version": "0.21.
|
|
3
|
+
"version": "0.21.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A CLI useful to manage DX tools.",
|
|
6
6
|
"repository": {
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"@azure/identity": "^4.13.1",
|
|
32
32
|
"@azure/keyvault-secrets": "^4.11.1",
|
|
33
33
|
"@azure/storage-blob": "^12.31.0",
|
|
34
|
-
"@logtape/logtape": "^1.3.
|
|
34
|
+
"@logtape/logtape": "^1.3.8",
|
|
35
35
|
"@microsoft/microsoft-graph-client": "^3.0.7",
|
|
36
36
|
"chalk": "^5.6.2",
|
|
37
37
|
"commander": "^14.0.3",
|
|
@@ -39,16 +39,16 @@
|
|
|
39
39
|
"execa": "^9.6.1",
|
|
40
40
|
"glob": "^11.1.0",
|
|
41
41
|
"inquirer": "^9.3.8",
|
|
42
|
-
"libsodium-wrappers": "^0.8.
|
|
42
|
+
"libsodium-wrappers": "^0.8.4",
|
|
43
43
|
"neverthrow": "^8.2.0",
|
|
44
44
|
"node-plop": "^0.32.3",
|
|
45
45
|
"octokit": "^5.0.5",
|
|
46
|
-
"ora": "^9.
|
|
46
|
+
"ora": "^9.4.0",
|
|
47
47
|
"replace-in-file": "^8.4.0",
|
|
48
48
|
"semver": "^7.7.4",
|
|
49
|
-
"yaml": "^2.8.
|
|
50
|
-
"zod": "^4.
|
|
51
|
-
"@pagopa/dx-savemoney": "^0.2.
|
|
49
|
+
"yaml": "^2.8.4",
|
|
50
|
+
"zod": "^4.4.2",
|
|
51
|
+
"@pagopa/dx-savemoney": "^0.2.5"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"@tsconfig/node24": "24.0.4",
|
|
@@ -57,14 +57,14 @@
|
|
|
57
57
|
"@types/node": "^22.19.17",
|
|
58
58
|
"@types/semver": "^7.7.1",
|
|
59
59
|
"@vitest/coverage-v8": "^3.2.4",
|
|
60
|
-
"eslint": "^10.
|
|
61
|
-
"memfs": "^4.57.
|
|
60
|
+
"eslint": "^10.3.0",
|
|
61
|
+
"memfs": "^4.57.2",
|
|
62
62
|
"plop": "^4.0.5",
|
|
63
63
|
"prettier": "3.8.3",
|
|
64
64
|
"typescript": "~5.9.3",
|
|
65
65
|
"vitest": "^3.2.4",
|
|
66
66
|
"vitest-mock-extended": "^3.1.1",
|
|
67
|
-
"@pagopa/eslint-config": "^6.0.
|
|
67
|
+
"@pagopa/eslint-config": "^6.0.4"
|
|
68
68
|
},
|
|
69
69
|
"engines": {
|
|
70
70
|
"node": ">=22.0.0"
|