@pagopa/dx-cli 0.16.3 → 0.18.1
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 +35 -0
- package/dist/adapters/azure/cloud-account-service.d.ts +2 -1
- package/dist/adapters/azure/cloud-account-service.js +74 -14
- package/dist/adapters/commander/commands/add.d.ts +11 -0
- package/dist/adapters/commander/commands/add.js +27 -0
- package/dist/adapters/commander/commands/init.d.ts +2 -0
- package/dist/adapters/commander/commands/init.js +1 -1
- package/dist/adapters/commander/index.js +2 -0
- package/dist/adapters/octokit/__tests__/index.test.js +218 -1
- package/dist/adapters/octokit/index.d.ts +4 -1
- package/dist/adapters/octokit/index.js +65 -1
- package/dist/adapters/pagopa-technology/__tests__/authorization.test.d.ts +4 -0
- package/dist/adapters/pagopa-technology/__tests__/authorization.test.js +170 -0
- package/dist/adapters/pagopa-technology/authorization.d.ts +11 -0
- package/dist/adapters/pagopa-technology/authorization.js +104 -0
- package/dist/adapters/plop/actions/__tests__/init-cloud-accounts.test.js +35 -3
- package/dist/adapters/plop/actions/__tests__/provision-terraform-backend.test.js +15 -0
- package/dist/adapters/plop/actions/init-cloud-accounts.js +5 -2
- package/dist/adapters/plop/generators/environment/__tests__/actions.test.js +5 -0
- package/dist/adapters/plop/generators/environment/prompts.d.ts +14 -0
- package/dist/adapters/plop/generators/environment/prompts.js +67 -31
- package/dist/adapters/plop/index.d.ts +18 -2
- package/dist/adapters/plop/index.js +16 -0
- package/dist/domain/__tests__/data.d.ts +2 -0
- package/dist/domain/__tests__/data.js +1 -0
- package/dist/domain/authorization.d.ts +49 -0
- package/dist/domain/authorization.js +73 -0
- package/dist/domain/cloud-account.d.ts +2 -1
- package/dist/domain/dependencies.d.ts +2 -0
- package/dist/domain/github.d.ts +51 -0
- package/dist/domain/github.js +12 -0
- package/dist/index.js +5 -0
- package/dist/use-cases/__tests__/request-authorization.test.d.ts +4 -0
- package/dist/use-cases/__tests__/request-authorization.test.js +40 -0
- package/dist/use-cases/request-authorization.d.ts +15 -0
- package/dist/use-cases/request-authorization.js +13 -0
- package/package.json +5 -3
- package/templates/environment/bootstrapper/{{env.name}}/main.tf.hbs +2 -0
- package/templates/environment/core/{{env.name}}/imports.tf.hbs +34 -0
- package/templates/environment/core/{{env.name}}/providers.tf.hbs +4 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the PagoPA technology authorization adapter.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { mock } from "vitest-mock-extended";
|
|
6
|
+
import { AuthorizationResult, IdentityAlreadyExistsError, InvalidAuthorizationFileFormatError, requestAuthorizationInputSchema, } from "../../../domain/authorization.js";
|
|
7
|
+
import { FileNotFoundError, PullRequest, } from "../../../domain/github.js";
|
|
8
|
+
import { makeAuthorizationService } from "../authorization.js";
|
|
9
|
+
const makeEnv = () => {
|
|
10
|
+
const gitHubService = mock();
|
|
11
|
+
const authorizationService = makeAuthorizationService(gitHubService);
|
|
12
|
+
return {
|
|
13
|
+
authorizationService,
|
|
14
|
+
gitHubService,
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
const makeSampleInput = () => requestAuthorizationInputSchema.parse({
|
|
18
|
+
bootstrapIdentityId: "test-bootstrap-identity-id",
|
|
19
|
+
subscriptionName: "test-subscription",
|
|
20
|
+
});
|
|
21
|
+
// eslint-disable-next-line max-lines-per-function
|
|
22
|
+
describe("PagoPA AuthorizationService", () => {
|
|
23
|
+
describe("happy path", () => {
|
|
24
|
+
it("should create a pull request when all steps succeed", async () => {
|
|
25
|
+
const { authorizationService, gitHubService } = makeEnv();
|
|
26
|
+
const input = makeSampleInput();
|
|
27
|
+
const originalContent = `
|
|
28
|
+
directory_readers = {
|
|
29
|
+
service_principals_name = []
|
|
30
|
+
}
|
|
31
|
+
`.trim();
|
|
32
|
+
gitHubService.createBranch.mockResolvedValue(undefined);
|
|
33
|
+
gitHubService.getFileContent.mockResolvedValue({
|
|
34
|
+
content: originalContent,
|
|
35
|
+
sha: "original-sha-123",
|
|
36
|
+
});
|
|
37
|
+
gitHubService.updateFile.mockResolvedValue(undefined);
|
|
38
|
+
gitHubService.createPullRequest.mockResolvedValue(new PullRequest("https://github.com/pagopa/eng-azure-authorization/pull/42"));
|
|
39
|
+
const result = await authorizationService.requestAuthorization(input);
|
|
40
|
+
expect(result.isOk()).toBe(true);
|
|
41
|
+
const authResult = result._unsafeUnwrap();
|
|
42
|
+
expect(authResult).toBeInstanceOf(AuthorizationResult);
|
|
43
|
+
expect(authResult.url).toBe("https://github.com/pagopa/eng-azure-authorization/pull/42");
|
|
44
|
+
expect(gitHubService.createBranch).toHaveBeenCalledWith({
|
|
45
|
+
branchName: "feats/add-test-subscription-bootstrap-identity",
|
|
46
|
+
fromRef: "main",
|
|
47
|
+
owner: "pagopa",
|
|
48
|
+
repo: "eng-azure-authorization",
|
|
49
|
+
});
|
|
50
|
+
expect(gitHubService.getFileContent).toHaveBeenCalledWith({
|
|
51
|
+
owner: "pagopa",
|
|
52
|
+
path: "src/azure-subscriptions/subscriptions/test-subscription/terraform.tfvars",
|
|
53
|
+
ref: "feats/add-test-subscription-bootstrap-identity",
|
|
54
|
+
repo: "eng-azure-authorization",
|
|
55
|
+
});
|
|
56
|
+
expect(gitHubService.updateFile).toHaveBeenCalledWith(expect.objectContaining({
|
|
57
|
+
branch: "feats/add-test-subscription-bootstrap-identity",
|
|
58
|
+
message: "Add directory reader for test-subscription",
|
|
59
|
+
owner: "pagopa",
|
|
60
|
+
path: "src/azure-subscriptions/subscriptions/test-subscription/terraform.tfvars",
|
|
61
|
+
repo: "eng-azure-authorization",
|
|
62
|
+
sha: "original-sha-123",
|
|
63
|
+
}));
|
|
64
|
+
expect(gitHubService.createPullRequest).toHaveBeenCalledWith({
|
|
65
|
+
base: "main",
|
|
66
|
+
body: "This PR adds the bootstrap identity `test-bootstrap-identity-id` to the directory readers for subscription `test-subscription`.",
|
|
67
|
+
head: "feats/add-test-subscription-bootstrap-identity",
|
|
68
|
+
owner: "pagopa",
|
|
69
|
+
repo: "eng-azure-authorization",
|
|
70
|
+
title: "Add directory reader for test-subscription",
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
describe("error handling", () => {
|
|
75
|
+
it("should return error when file is not found", async () => {
|
|
76
|
+
const { authorizationService, gitHubService } = makeEnv();
|
|
77
|
+
const input = makeSampleInput();
|
|
78
|
+
gitHubService.createBranch.mockResolvedValue(undefined);
|
|
79
|
+
gitHubService.getFileContent.mockRejectedValue(new FileNotFoundError("src/azure-subscriptions/subscriptions/test-subscription/terraform.tfvars"));
|
|
80
|
+
const result = await authorizationService.requestAuthorization(input);
|
|
81
|
+
expect(result.isErr()).toBe(true);
|
|
82
|
+
const error = result._unsafeUnwrapErr();
|
|
83
|
+
expect(error.message).toContain("Unable to get");
|
|
84
|
+
expect(error.message).toContain("test-subscription/terraform.tfvars");
|
|
85
|
+
expect(gitHubService.updateFile).not.toHaveBeenCalled();
|
|
86
|
+
});
|
|
87
|
+
it("should return error when identity already exists", async () => {
|
|
88
|
+
const { authorizationService, gitHubService } = makeEnv();
|
|
89
|
+
const input = makeSampleInput();
|
|
90
|
+
const content = `
|
|
91
|
+
directory_readers = {
|
|
92
|
+
service_principals_name = [
|
|
93
|
+
"test-bootstrap-identity-id"
|
|
94
|
+
]
|
|
95
|
+
}
|
|
96
|
+
`.trim();
|
|
97
|
+
gitHubService.createBranch.mockResolvedValue(undefined);
|
|
98
|
+
gitHubService.getFileContent.mockResolvedValue({
|
|
99
|
+
content,
|
|
100
|
+
sha: "sha-123",
|
|
101
|
+
});
|
|
102
|
+
const result = await authorizationService.requestAuthorization(input);
|
|
103
|
+
expect(result.isErr()).toBe(true);
|
|
104
|
+
expect(result._unsafeUnwrapErr()).toBeInstanceOf(IdentityAlreadyExistsError);
|
|
105
|
+
expect(gitHubService.updateFile).not.toHaveBeenCalled();
|
|
106
|
+
});
|
|
107
|
+
it("should return error when tfvars format is invalid", async () => {
|
|
108
|
+
const { authorizationService, gitHubService } = makeEnv();
|
|
109
|
+
const input = makeSampleInput();
|
|
110
|
+
const invalidContent = "invalid content without directory_readers";
|
|
111
|
+
gitHubService.createBranch.mockResolvedValue(undefined);
|
|
112
|
+
gitHubService.getFileContent.mockResolvedValue({
|
|
113
|
+
content: invalidContent,
|
|
114
|
+
sha: "sha-123",
|
|
115
|
+
});
|
|
116
|
+
const result = await authorizationService.requestAuthorization(input);
|
|
117
|
+
expect(result.isErr()).toBe(true);
|
|
118
|
+
expect(result._unsafeUnwrapErr()).toBeInstanceOf(InvalidAuthorizationFileFormatError);
|
|
119
|
+
expect(gitHubService.updateFile).not.toHaveBeenCalled();
|
|
120
|
+
});
|
|
121
|
+
it("should return error when branch creation fails", async () => {
|
|
122
|
+
const { authorizationService, gitHubService } = makeEnv();
|
|
123
|
+
const input = makeSampleInput();
|
|
124
|
+
gitHubService.createBranch.mockRejectedValue(new Error("Failed to create branch: branch already exists"));
|
|
125
|
+
const result = await authorizationService.requestAuthorization(input);
|
|
126
|
+
expect(result.isErr()).toBe(true);
|
|
127
|
+
expect(result._unsafeUnwrapErr().message).toContain("Unable to create branch");
|
|
128
|
+
expect(gitHubService.getFileContent).not.toHaveBeenCalled();
|
|
129
|
+
expect(gitHubService.updateFile).not.toHaveBeenCalled();
|
|
130
|
+
});
|
|
131
|
+
it("should return error when file update fails", async () => {
|
|
132
|
+
const { authorizationService, gitHubService } = makeEnv();
|
|
133
|
+
const input = makeSampleInput();
|
|
134
|
+
const content = `
|
|
135
|
+
directory_readers = {
|
|
136
|
+
service_principals_name = []
|
|
137
|
+
}
|
|
138
|
+
`.trim();
|
|
139
|
+
gitHubService.createBranch.mockResolvedValue(undefined);
|
|
140
|
+
gitHubService.getFileContent.mockResolvedValue({
|
|
141
|
+
content,
|
|
142
|
+
sha: "sha-123",
|
|
143
|
+
});
|
|
144
|
+
gitHubService.updateFile.mockRejectedValue(new Error("Failed to update file: conflict"));
|
|
145
|
+
const result = await authorizationService.requestAuthorization(input);
|
|
146
|
+
expect(result.isErr()).toBe(true);
|
|
147
|
+
expect(result._unsafeUnwrapErr().message).toContain("Unable to update");
|
|
148
|
+
expect(gitHubService.createPullRequest).not.toHaveBeenCalled();
|
|
149
|
+
});
|
|
150
|
+
it("should return error when PR creation fails", async () => {
|
|
151
|
+
const { authorizationService, gitHubService } = makeEnv();
|
|
152
|
+
const input = makeSampleInput();
|
|
153
|
+
const content = `
|
|
154
|
+
directory_readers = {
|
|
155
|
+
service_principals_name = []
|
|
156
|
+
}
|
|
157
|
+
`.trim();
|
|
158
|
+
gitHubService.createBranch.mockResolvedValue(undefined);
|
|
159
|
+
gitHubService.getFileContent.mockResolvedValue({
|
|
160
|
+
content,
|
|
161
|
+
sha: "sha-123",
|
|
162
|
+
});
|
|
163
|
+
gitHubService.updateFile.mockResolvedValue(undefined);
|
|
164
|
+
gitHubService.createPullRequest.mockRejectedValue(new Error("Failed to create pull request"));
|
|
165
|
+
const result = await authorizationService.requestAuthorization(input);
|
|
166
|
+
expect(result.isErr()).toBe(true);
|
|
167
|
+
expect(result._unsafeUnwrapErr().message).toContain("Unable to create pull request");
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PagoPA Technology Authorization Adapter
|
|
3
|
+
*
|
|
4
|
+
* Implements the AuthorizationService interface for the PagoPA Azure
|
|
5
|
+
* authorization workflow. Encapsulates all platform-specific details:
|
|
6
|
+
* the target GitHub repository, file paths, branch naming, HCL file
|
|
7
|
+
* parsing, and pull request creation.
|
|
8
|
+
*/
|
|
9
|
+
import { AuthorizationService } from "../../domain/authorization.js";
|
|
10
|
+
import { GitHubService } from "../../domain/github.js";
|
|
11
|
+
export declare const makeAuthorizationService: (gitHubService: GitHubService) => AuthorizationService;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PagoPA Technology Authorization Adapter
|
|
3
|
+
*
|
|
4
|
+
* Implements the AuthorizationService interface for the PagoPA Azure
|
|
5
|
+
* authorization workflow. Encapsulates all platform-specific details:
|
|
6
|
+
* the target GitHub repository, file paths, branch naming, HCL file
|
|
7
|
+
* parsing, and pull request creation.
|
|
8
|
+
*/
|
|
9
|
+
import { getLogger } from "@logtape/logtape";
|
|
10
|
+
import { err, errAsync, ok, okAsync, ResultAsync } from "neverthrow";
|
|
11
|
+
import { AuthorizationError, AuthorizationResult, IdentityAlreadyExistsError, InvalidAuthorizationFileFormatError, } from "../../domain/authorization.js";
|
|
12
|
+
// Matches the service_principals_name list inside the directory_readers block.
|
|
13
|
+
const DIRECTORY_READERS_REGEX = /(directory_readers\s*=\s*\{[\s\S]*?service_principals_name\s*=\s*\[)([\s\S]*?)(][\s\S]*?})/;
|
|
14
|
+
const addIdentity = (content, identityId) => {
|
|
15
|
+
const match = content.match(DIRECTORY_READERS_REGEX);
|
|
16
|
+
if (!match) {
|
|
17
|
+
return err(new InvalidAuthorizationFileFormatError("Could not find directory_readers.service_principals_name list"));
|
|
18
|
+
}
|
|
19
|
+
const [, prefix, existingItems, suffix] = match;
|
|
20
|
+
if (existingItems.includes(`"${identityId}"`)) {
|
|
21
|
+
return err(new IdentityAlreadyExistsError(identityId));
|
|
22
|
+
}
|
|
23
|
+
// Build the new list content following HCL formatting rules:
|
|
24
|
+
// - Items are indented with 4 spaces; the LAST item must NOT have a trailing comma
|
|
25
|
+
const newListContent = existingItems.trim().length > 0
|
|
26
|
+
? `${existingItems.replace(/,?\s*$/, "")},\n "${identityId}"\n `
|
|
27
|
+
: `\n "${identityId}"\n `;
|
|
28
|
+
return ok(content.replace(DIRECTORY_READERS_REGEX, `${prefix}${newListContent}${suffix}`));
|
|
29
|
+
};
|
|
30
|
+
const REPO_OWNER = "pagopa";
|
|
31
|
+
const REPO_NAME = "eng-azure-authorization";
|
|
32
|
+
const BASE_BRANCH = "main";
|
|
33
|
+
export const makeAuthorizationService = (gitHubService) => ({
|
|
34
|
+
requestAuthorization(input) {
|
|
35
|
+
const logger = getLogger(["dx-cli", "pagopa-authorization"]);
|
|
36
|
+
const { bootstrapIdentityId, subscriptionName } = input;
|
|
37
|
+
const filePath = `src/azure-subscriptions/subscriptions/${subscriptionName}/terraform.tfvars`;
|
|
38
|
+
const branchName = `feats/add-${subscriptionName}-bootstrap-identity`;
|
|
39
|
+
return (
|
|
40
|
+
// Step 1: Create branch first to avoid race condition with main branch updates
|
|
41
|
+
ResultAsync.fromPromise(gitHubService.createBranch({
|
|
42
|
+
branchName,
|
|
43
|
+
fromRef: BASE_BRANCH,
|
|
44
|
+
owner: REPO_OWNER,
|
|
45
|
+
repo: REPO_NAME,
|
|
46
|
+
}), () => new AuthorizationError(`Unable to create branch ${branchName} in ${REPO_OWNER}/${REPO_NAME}`))
|
|
47
|
+
.orTee((error) => {
|
|
48
|
+
logger.error(error.message);
|
|
49
|
+
})
|
|
50
|
+
// Step 2: Fetch file content from the newly created branch
|
|
51
|
+
.andThen(() => ResultAsync.fromPromise(gitHubService.getFileContent({
|
|
52
|
+
owner: REPO_OWNER,
|
|
53
|
+
path: filePath,
|
|
54
|
+
ref: branchName,
|
|
55
|
+
repo: REPO_NAME,
|
|
56
|
+
}), () => new AuthorizationError(`Unable to get ${filePath} in ${REPO_OWNER}/${REPO_NAME}`)))
|
|
57
|
+
.orTee((error) => {
|
|
58
|
+
logger.error(error.message);
|
|
59
|
+
})
|
|
60
|
+
// Modify the file content, detecting duplicates and format errors
|
|
61
|
+
.andThen(({ content, sha }) => addIdentity(content, bootstrapIdentityId)
|
|
62
|
+
.mapErr((error) => {
|
|
63
|
+
if (error instanceof IdentityAlreadyExistsError) {
|
|
64
|
+
logger.warn("Identity already exists", {
|
|
65
|
+
identityId: bootstrapIdentityId,
|
|
66
|
+
subscription: subscriptionName,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
logger.error("Failed to modify tfvars", {
|
|
71
|
+
error: error.message,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return error;
|
|
75
|
+
})
|
|
76
|
+
.match((updatedContent) => okAsync({ sha, updatedContent }), (error) => errAsync(error)))
|
|
77
|
+
// Update the file on the new branch
|
|
78
|
+
.andThen(({ sha, updatedContent }) => ResultAsync.fromPromise(gitHubService.updateFile({
|
|
79
|
+
branch: branchName,
|
|
80
|
+
content: updatedContent,
|
|
81
|
+
message: `Add directory reader for ${subscriptionName}`,
|
|
82
|
+
owner: REPO_OWNER,
|
|
83
|
+
path: filePath,
|
|
84
|
+
repo: REPO_NAME,
|
|
85
|
+
sha,
|
|
86
|
+
}), () => new AuthorizationError(`Unable to update ${filePath} on branch ${branchName} in ${REPO_OWNER}/${REPO_NAME}`)))
|
|
87
|
+
.orTee((error) => {
|
|
88
|
+
logger.error(error.message);
|
|
89
|
+
})
|
|
90
|
+
// Create a pull request for review
|
|
91
|
+
.andThen(() => ResultAsync.fromPromise(gitHubService.createPullRequest({
|
|
92
|
+
base: BASE_BRANCH,
|
|
93
|
+
body: `This PR adds the bootstrap identity \`${bootstrapIdentityId}\` to the directory readers for subscription \`${subscriptionName}\`.`,
|
|
94
|
+
head: branchName,
|
|
95
|
+
owner: REPO_OWNER,
|
|
96
|
+
repo: REPO_NAME,
|
|
97
|
+
title: `Add directory reader for ${subscriptionName}`,
|
|
98
|
+
}), () => new AuthorizationError(`Unable to create pull request from ${branchName} to ${BASE_BRANCH} in ${REPO_OWNER}/${REPO_NAME}`)))
|
|
99
|
+
.orTee((error) => {
|
|
100
|
+
logger.error(error.message);
|
|
101
|
+
})
|
|
102
|
+
.map((pr) => new AuthorizationResult(pr.url)));
|
|
103
|
+
},
|
|
104
|
+
});
|
|
@@ -27,6 +27,11 @@ const createMockPayload = (overrides = {}) => ({
|
|
|
27
27
|
},
|
|
28
28
|
init: {
|
|
29
29
|
cloudAccountsToInitialize: [],
|
|
30
|
+
runnerAppCredentials: {
|
|
31
|
+
id: "test-app-id",
|
|
32
|
+
installationId: "test-installation-id",
|
|
33
|
+
key: "test-private-key",
|
|
34
|
+
},
|
|
30
35
|
terraformBackend: {
|
|
31
36
|
cloudAccount: createMockCloudAccount(),
|
|
32
37
|
},
|
|
@@ -53,6 +58,11 @@ describe("initCloudAccounts", () => {
|
|
|
53
58
|
},
|
|
54
59
|
init: {
|
|
55
60
|
cloudAccountsToInitialize: [cloudAccount1, cloudAccount2],
|
|
61
|
+
runnerAppCredentials: {
|
|
62
|
+
id: "test-app-id",
|
|
63
|
+
installationId: "test-installation-id",
|
|
64
|
+
key: "test-private-key",
|
|
65
|
+
},
|
|
56
66
|
terraformBackend: {
|
|
57
67
|
cloudAccount: createMockCloudAccount(),
|
|
58
68
|
},
|
|
@@ -60,8 +70,16 @@ describe("initCloudAccounts", () => {
|
|
|
60
70
|
});
|
|
61
71
|
await initCloudAccounts(payload, mockService);
|
|
62
72
|
expect(initializeMock).toHaveBeenCalledTimes(2);
|
|
63
|
-
expect(initializeMock).toHaveBeenCalledWith(cloudAccount1, expect.objectContaining({ name: "prod", prefix: "io" }), {
|
|
64
|
-
|
|
73
|
+
expect(initializeMock).toHaveBeenCalledWith(cloudAccount1, expect.objectContaining({ name: "prod", prefix: "io" }), {
|
|
74
|
+
id: "test-app-id",
|
|
75
|
+
installationId: "test-installation-id",
|
|
76
|
+
key: "test-private-key",
|
|
77
|
+
}, {});
|
|
78
|
+
expect(initializeMock).toHaveBeenCalledWith(cloudAccount2, expect.objectContaining({ name: "prod", prefix: "io" }), {
|
|
79
|
+
id: "test-app-id",
|
|
80
|
+
installationId: "test-installation-id",
|
|
81
|
+
key: "test-private-key",
|
|
82
|
+
}, {});
|
|
65
83
|
});
|
|
66
84
|
it("should not call initialize when cloudAccountsToInitialize is empty", async () => {
|
|
67
85
|
const initializeMock = vi.fn().mockResolvedValue(undefined);
|
|
@@ -71,6 +89,11 @@ describe("initCloudAccounts", () => {
|
|
|
71
89
|
const payload = createMockPayload({
|
|
72
90
|
init: {
|
|
73
91
|
cloudAccountsToInitialize: [],
|
|
92
|
+
runnerAppCredentials: {
|
|
93
|
+
id: "test-app-id",
|
|
94
|
+
installationId: "test-installation-id",
|
|
95
|
+
key: "test-private-key",
|
|
96
|
+
},
|
|
74
97
|
terraformBackend: {
|
|
75
98
|
cloudAccount: createMockCloudAccount(),
|
|
76
99
|
},
|
|
@@ -104,12 +127,21 @@ describe("initCloudAccounts", () => {
|
|
|
104
127
|
},
|
|
105
128
|
init: {
|
|
106
129
|
cloudAccountsToInitialize: [cloudAccount],
|
|
130
|
+
runnerAppCredentials: {
|
|
131
|
+
id: "test-app-id",
|
|
132
|
+
installationId: "test-installation-id",
|
|
133
|
+
key: "test-private-key",
|
|
134
|
+
},
|
|
107
135
|
terraformBackend: {
|
|
108
136
|
cloudAccount: createMockCloudAccount(),
|
|
109
137
|
},
|
|
110
138
|
},
|
|
111
139
|
});
|
|
112
140
|
await initCloudAccounts(payload, mockService);
|
|
113
|
-
expect(initializeMock).toHaveBeenCalledWith(cloudAccount, expect.objectContaining({ name: "uat", prefix: "pagopa" }), {
|
|
141
|
+
expect(initializeMock).toHaveBeenCalledWith(cloudAccount, expect.objectContaining({ name: "uat", prefix: "pagopa" }), {
|
|
142
|
+
id: "test-app-id",
|
|
143
|
+
installationId: "test-installation-id",
|
|
144
|
+
key: "test-private-key",
|
|
145
|
+
}, {});
|
|
114
146
|
});
|
|
115
147
|
});
|
|
@@ -34,6 +34,11 @@ const createMockPayload = (overrides = {}) => ({
|
|
|
34
34
|
},
|
|
35
35
|
init: {
|
|
36
36
|
cloudAccountsToInitialize: [],
|
|
37
|
+
runnerAppCredentials: {
|
|
38
|
+
id: "test-app-id",
|
|
39
|
+
installationId: "test-installation-id",
|
|
40
|
+
key: "test-private-key",
|
|
41
|
+
},
|
|
37
42
|
terraformBackend: {
|
|
38
43
|
cloudAccount: createMockCloudAccount(),
|
|
39
44
|
},
|
|
@@ -71,6 +76,11 @@ describe("provisionTerraformBackend", () => {
|
|
|
71
76
|
},
|
|
72
77
|
init: {
|
|
73
78
|
cloudAccountsToInitialize: [],
|
|
79
|
+
runnerAppCredentials: {
|
|
80
|
+
id: "test-app-id",
|
|
81
|
+
installationId: "test-installation-id",
|
|
82
|
+
key: "test-private-key",
|
|
83
|
+
},
|
|
74
84
|
terraformBackend: {
|
|
75
85
|
cloudAccount,
|
|
76
86
|
},
|
|
@@ -105,6 +115,11 @@ describe("provisionTerraformBackend", () => {
|
|
|
105
115
|
},
|
|
106
116
|
init: {
|
|
107
117
|
cloudAccountsToInitialize: [],
|
|
118
|
+
runnerAppCredentials: {
|
|
119
|
+
id: "test-app-id",
|
|
120
|
+
installationId: "test-installation-id",
|
|
121
|
+
key: "test-private-key",
|
|
122
|
+
},
|
|
108
123
|
terraformBackend: {
|
|
109
124
|
cloudAccount: backendCloudAccount,
|
|
110
125
|
},
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
1
2
|
import { payloadSchema, } from "../generators/environment/prompts.js";
|
|
2
3
|
export const initCloudAccounts = async (payload, cloudAccountService) => {
|
|
3
|
-
if (payload.init) {
|
|
4
|
-
|
|
4
|
+
if (payload.init && payload.init.cloudAccountsToInitialize.length > 0) {
|
|
5
|
+
const { runnerAppCredentials } = payload.init;
|
|
6
|
+
assert.ok(runnerAppCredentials, "Runner app credentials are required");
|
|
7
|
+
await Promise.all(payload.init.cloudAccountsToInitialize.map((cloudAccount) => cloudAccountService.initialize(cloudAccount, payload.env, runnerAppCredentials, payload.tags)));
|
|
5
8
|
}
|
|
6
9
|
};
|
|
7
10
|
export default function (plop, cloudAccountService) {
|
|
@@ -25,6 +25,11 @@ export const getPayload = (includeInit = false) => {
|
|
|
25
25
|
if (includeInit) {
|
|
26
26
|
payload.init = {
|
|
27
27
|
cloudAccountsToInitialize: [cloudAccount],
|
|
28
|
+
runnerAppCredentials: {
|
|
29
|
+
id: "test-app-id",
|
|
30
|
+
installationId: "test-installation-id",
|
|
31
|
+
key: "test-private-key",
|
|
32
|
+
},
|
|
28
33
|
terraformBackend: {
|
|
29
34
|
cloudAccount,
|
|
30
35
|
},
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import inquirer from "inquirer";
|
|
2
2
|
import { type DynamicPromptsFunction } from "node-plop";
|
|
3
3
|
import { CloudAccount, CloudAccountRepository, CloudAccountService, CloudRegion } from "../../../../domain/cloud-account.js";
|
|
4
|
+
import { EnvironmentInitStatus } from "../../../../domain/environment.js";
|
|
4
5
|
type InquirerChoice<T> = inquirer.Separator | {
|
|
5
6
|
name: string;
|
|
6
7
|
value: T;
|
|
@@ -40,6 +41,11 @@ export declare const payloadSchema: z.ZodObject<{
|
|
|
40
41
|
displayName: z.ZodString;
|
|
41
42
|
id: z.ZodString;
|
|
42
43
|
}, z.core.$strip>>;
|
|
44
|
+
runnerAppCredentials: z.ZodOptional<z.ZodObject<{
|
|
45
|
+
id: z.ZodString;
|
|
46
|
+
installationId: z.ZodString;
|
|
47
|
+
key: z.ZodString;
|
|
48
|
+
}, z.core.$strip>>;
|
|
43
49
|
terraformBackend: z.ZodOptional<z.ZodObject<{
|
|
44
50
|
cloudAccount: z.ZodObject<{
|
|
45
51
|
csp: z.ZodDefault<z.ZodEnum<{
|
|
@@ -65,4 +71,12 @@ export type PromptsDependencies = {
|
|
|
65
71
|
declare const prompts: (deps: PromptsDependencies) => DynamicPromptsFunction;
|
|
66
72
|
export declare const getCloudAccountChoices: (cloudAccounts: CloudAccount[]) => InquirerChoice<CloudAccount>[];
|
|
67
73
|
export declare const getCloudLocationChoices: (regions: CloudRegion[]) => InquirerChoice<CloudRegion["name"]>[];
|
|
74
|
+
export declare const getCloudAccountToInitialize: (initStatus: EnvironmentInitStatus & {
|
|
75
|
+
initialized: false;
|
|
76
|
+
}) => {
|
|
77
|
+
csp: "azure";
|
|
78
|
+
defaultLocation: string;
|
|
79
|
+
displayName: string;
|
|
80
|
+
id: string;
|
|
81
|
+
}[];
|
|
68
82
|
export default prompts;
|
|
@@ -5,9 +5,11 @@ import * as azure from "../../../azure/locations.js";
|
|
|
5
5
|
import { getLogger } from "@logtape/logtape";
|
|
6
6
|
import { z } from "zod/v4";
|
|
7
7
|
import { githubRepoSchema, } from "../../../../domain/github-repo.js";
|
|
8
|
+
import { githubAppCredentialsSchema } from "../../../../domain/github.js";
|
|
8
9
|
import { getGithubRepo } from "../../../github/github-repo.js";
|
|
9
10
|
const initSchema = z.object({
|
|
10
11
|
cloudAccountsToInitialize: z.array(cloudAccountSchema),
|
|
12
|
+
runnerAppCredentials: githubAppCredentialsSchema.optional(),
|
|
11
13
|
terraformBackend: z
|
|
12
14
|
.object({
|
|
13
15
|
cloudAccount: cloudAccountSchema,
|
|
@@ -25,6 +27,7 @@ export const payloadSchema = z.object({
|
|
|
25
27
|
tags: tagsSchema,
|
|
26
28
|
workspace: workspaceSchema,
|
|
27
29
|
});
|
|
30
|
+
/* eslint-disable max-lines-per-function */
|
|
28
31
|
const prompts = (deps) => async (inquirer) => {
|
|
29
32
|
const logger = getLogger(["gen", "env"]);
|
|
30
33
|
const github = deps.github ?? (await getGithubRepo());
|
|
@@ -93,16 +96,16 @@ const prompts = (deps) => async (inquirer) => {
|
|
|
93
96
|
: "Please select a Cost Center.",
|
|
94
97
|
},
|
|
95
98
|
{
|
|
99
|
+
filter: (value) => value.trim(),
|
|
96
100
|
message: "Business unit",
|
|
97
101
|
name: "tags.BusinessUnit",
|
|
98
|
-
|
|
99
|
-
validate: (value) => value.trim().length > 0 ? true : "Business Unit cannot be empty.",
|
|
102
|
+
validate: (value) => value.length > 0 ? true : "Business Unit cannot be empty.",
|
|
100
103
|
},
|
|
101
104
|
{
|
|
105
|
+
filter: (value) => value.trim(),
|
|
102
106
|
message: "Management team",
|
|
103
107
|
name: "tags.ManagementTeam",
|
|
104
|
-
|
|
105
|
-
validate: (value) => value.trim().length > 0 ? true : "Management Team cannot be empty.",
|
|
108
|
+
validate: (value) => value.length > 0 ? true : "Management Team cannot be empty.",
|
|
106
109
|
},
|
|
107
110
|
]);
|
|
108
111
|
const payload = payloadSchema.parse({ ...answers, github });
|
|
@@ -124,36 +127,66 @@ const prompts = (deps) => async (inquirer) => {
|
|
|
124
127
|
if (initStatus.initialized) {
|
|
125
128
|
return payload;
|
|
126
129
|
}
|
|
130
|
+
const initConfirm = await inquirer.prompt({
|
|
131
|
+
default: true,
|
|
132
|
+
message: "The environment is not initialized. Do you want to initialize it now?",
|
|
133
|
+
name: "init",
|
|
134
|
+
type: "confirm",
|
|
135
|
+
});
|
|
136
|
+
assert.ok(initConfirm.init, "Can't proceed without initialization");
|
|
127
137
|
assert.ok(await hasUserPermissionToInitialize(deps.cloudAccountService, payload.env), "You don't have permission to initialize this environment. Ask your Engineering Leader to initialize it for you.");
|
|
128
138
|
const missingRemoteBackend = initStatus.issues.some((issue) => issue.type === "MISSING_REMOTE_BACKEND");
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
|
|
139
|
+
const questions = [];
|
|
140
|
+
let terraformBackend;
|
|
141
|
+
if (missingRemoteBackend) {
|
|
142
|
+
if (payload.env.cloudAccounts.length === 1) {
|
|
143
|
+
terraformBackend = {
|
|
144
|
+
cloudAccount: payload.env.cloudAccounts[0],
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
questions.push({
|
|
149
|
+
choices: getCloudAccountChoices(payload.env.cloudAccounts),
|
|
150
|
+
message: "Cloud Account to use for the remote Terraform backend",
|
|
151
|
+
name: "terraformBackend.cloudAccount",
|
|
152
|
+
type: "list",
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const cloudAccountsNotInitialized = initStatus.issues.some((issue) => issue.type === "CLOUD_ACCOUNT_NOT_INITIALIZED");
|
|
157
|
+
let runnerAppCredentials;
|
|
158
|
+
if (cloudAccountsNotInitialized) {
|
|
159
|
+
questions.push({
|
|
160
|
+
filter: (value) => value.trim(),
|
|
161
|
+
message: "GitHub Runner App ID",
|
|
162
|
+
name: "runnerAppCredentials.id",
|
|
163
|
+
type: "input",
|
|
164
|
+
validate: (value) => value.length > 0,
|
|
165
|
+
}, {
|
|
166
|
+
filter: (value) => value.trim(),
|
|
167
|
+
message: "GitHub Runner App Installation ID",
|
|
168
|
+
name: "runnerAppCredentials.installationId",
|
|
169
|
+
type: "input",
|
|
170
|
+
validate: (value) => value.length > 0,
|
|
171
|
+
}, {
|
|
172
|
+
filter: (value) => value.trim(),
|
|
173
|
+
message: "GitHub Runner App Private Key",
|
|
174
|
+
name: "runnerAppCredentials.key",
|
|
175
|
+
type: "editor",
|
|
176
|
+
validate: (value) => value.length > 0,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
const initInput = await inquirer.prompt(questions);
|
|
180
|
+
if (initInput.runnerAppCredentials) {
|
|
181
|
+
runnerAppCredentials = initInput.runnerAppCredentials;
|
|
182
|
+
}
|
|
183
|
+
if (initInput.terraformBackend) {
|
|
184
|
+
terraformBackend = initInput.terraformBackend;
|
|
185
|
+
}
|
|
147
186
|
payload.init = payloadSchema.shape.init.parse({
|
|
148
|
-
cloudAccountsToInitialize: initStatus
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
terraformBackend: missingRemoteBackend
|
|
152
|
-
? {
|
|
153
|
-
cloudAccount: initInput.terraformBackend?.cloudAccount ||
|
|
154
|
-
payload.env.cloudAccounts[0],
|
|
155
|
-
}
|
|
156
|
-
: undefined,
|
|
187
|
+
cloudAccountsToInitialize: getCloudAccountToInitialize(initStatus),
|
|
188
|
+
runnerAppCredentials,
|
|
189
|
+
terraformBackend,
|
|
157
190
|
});
|
|
158
191
|
return payload;
|
|
159
192
|
};
|
|
@@ -163,4 +196,7 @@ export const getCloudAccountChoices = (cloudAccounts) => cloudAccounts.map((acco
|
|
|
163
196
|
value: account,
|
|
164
197
|
}));
|
|
165
198
|
export const getCloudLocationChoices = (regions) => regions.map((r) => ({ name: r.displayName, value: r.name }));
|
|
199
|
+
export const getCloudAccountToInitialize = (initStatus) => initStatus.issues
|
|
200
|
+
.filter((issue) => issue.type === "CLOUD_ACCOUNT_NOT_INITIALIZED")
|
|
201
|
+
.map((issue) => issue.cloudAccount);
|
|
166
202
|
export default prompts;
|
|
@@ -5,5 +5,21 @@ import { Payload as MonorepoPayload } from "../plop/generators/monorepo/index.js
|
|
|
5
5
|
export declare const setMonorepoGenerator: (plop: NodePlopAPI) => void;
|
|
6
6
|
export declare const getPlopInstance: () => Promise<NodePlopAPI>;
|
|
7
7
|
export declare const runMonorepoGenerator: (plop: NodePlopAPI, githubService: GitHubService) => Promise<MonorepoPayload>;
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Run the deployment environment generator
|
|
10
|
+
*
|
|
11
|
+
* @param plop - The plop instance
|
|
12
|
+
* @param github - Optional GitHub repository info. When provided (by init command),
|
|
13
|
+
* uses the explicitly passed repository. When omitted (by add command),
|
|
14
|
+
* the generator infers it from the local git context.
|
|
15
|
+
*/
|
|
16
|
+
export declare const runDeploymentEnvironmentGenerator: (plop: NodePlopAPI, github?: GitHubRepo) => Promise<void>;
|
|
17
|
+
/**
|
|
18
|
+
* Configure the deployment environment generator
|
|
19
|
+
*
|
|
20
|
+
* @param plop - The plop instance
|
|
21
|
+
* @param github - Optional GitHub repository info. When provided (by init command),
|
|
22
|
+
* uses the explicitly passed repository. When omitted (by add command),
|
|
23
|
+
* the generator infers it from the local git context.
|
|
24
|
+
*/
|
|
25
|
+
export declare const setDeploymentEnvironmentGenerator: (plop: NodePlopAPI, github?: GitHubRepo) => void;
|
|
@@ -54,6 +54,14 @@ export const runMonorepoGenerator = async (plop, githubService) => {
|
|
|
54
54
|
});
|
|
55
55
|
return payload;
|
|
56
56
|
};
|
|
57
|
+
/**
|
|
58
|
+
* Run the deployment environment generator
|
|
59
|
+
*
|
|
60
|
+
* @param plop - The plop instance
|
|
61
|
+
* @param github - Optional GitHub repository info. When provided (by init command),
|
|
62
|
+
* uses the explicitly passed repository. When omitted (by add command),
|
|
63
|
+
* the generator infers it from the local git context.
|
|
64
|
+
*/
|
|
57
65
|
export const runDeploymentEnvironmentGenerator = async (plop, github) => {
|
|
58
66
|
setDeploymentEnvironmentGenerator(plop, github);
|
|
59
67
|
const generator = plop.getGenerator(PLOP_ENVIRONMENT_GENERATOR_NAME);
|
|
@@ -64,6 +72,14 @@ export const runDeploymentEnvironmentGenerator = async (plop, github) => {
|
|
|
64
72
|
text: "Creating environment...",
|
|
65
73
|
});
|
|
66
74
|
};
|
|
75
|
+
/**
|
|
76
|
+
* Configure the deployment environment generator
|
|
77
|
+
*
|
|
78
|
+
* @param plop - The plop instance
|
|
79
|
+
* @param github - Optional GitHub repository info. When provided (by init command),
|
|
80
|
+
* uses the explicitly passed repository. When omitted (by add command),
|
|
81
|
+
* the generator infers it from the local git context.
|
|
82
|
+
*/
|
|
67
83
|
export const setDeploymentEnvironmentGenerator = (plop, github) => {
|
|
68
84
|
const credential = new AzureCliCredential();
|
|
69
85
|
const cloudAccountRepository = new AzureSubscriptionRepository(credential);
|