@pagopa/dx-cli 0.21.2 → 0.21.4
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 +9 -1
- package/dist/adapters/azure/cloud-account-service.js +7 -0
- package/dist/adapters/commander/commands/__tests__/add.test.js +12 -4
- package/dist/adapters/commander/commands/add.js +5 -2
- package/dist/adapters/pagopa-technology/__tests__/authorization.test.js +349 -31
- package/dist/adapters/pagopa-technology/azure-authorization-config.d.ts +13 -0
- package/dist/adapters/pagopa-technology/azure-authorization-config.js +43 -0
- package/dist/adapters/pagopa-technology/{authorization.d.ts → azure-authorization.d.ts} +2 -2
- package/dist/adapters/pagopa-technology/azure-authorization.js +239 -0
- package/dist/adapters/plop/actions/__tests__/init-cloud-accounts.test.js +7 -0
- package/dist/adapters/plop/actions/__tests__/provision-terraform-backend.test.js +3 -0
- package/dist/adapters/plop/generators/environment/__tests__/actions.test.js +1 -0
- package/dist/adapters/plop/generators/environment/__tests__/prompts.test.js +96 -2
- package/dist/adapters/plop/generators/environment/prompts.d.ts +1 -0
- package/dist/adapters/plop/generators/environment/prompts.js +6 -0
- package/dist/domain/authorization.d.ts +6 -9
- package/dist/domain/authorization.js +27 -10
- package/dist/domain/github.d.ts +1 -0
- package/dist/domain/github.js +1 -0
- package/dist/index.js +2 -2
- package/dist/use-cases/__tests__/request-authorization.test.js +5 -3
- package/dist/use-cases/request-authorization.d.ts +2 -2
- package/dist/use-cases/request-authorization.js +2 -2
- package/package.json +1 -1
- package/templates/environment/bootstrapper/{{env.name}}/data.tf.hbs +0 -16
- package/templates/environment/bootstrapper/{{env.name}}/main.tf.hbs +2 -14
- package/dist/adapters/pagopa-technology/authorization.js +0 -124
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Azure authorization group configuration
|
|
3
|
+
*
|
|
4
|
+
* Defines the default Azure AD groups that the PagoPA Technology adapter
|
|
5
|
+
* keeps aligned inside the subscription authorization repository.
|
|
6
|
+
*/
|
|
7
|
+
export const DEFAULT_GROUP_SPECS = [
|
|
8
|
+
{ groupName: "admin", roles: ["Owner"] },
|
|
9
|
+
{ groupName: "developers", roles: ["Owner"] },
|
|
10
|
+
{
|
|
11
|
+
groupName: "operations",
|
|
12
|
+
roles: [
|
|
13
|
+
"Reader",
|
|
14
|
+
"Monitoring Contributor",
|
|
15
|
+
"Support Request Contributor",
|
|
16
|
+
"Storage Blob Data Reader",
|
|
17
|
+
"Storage Queue Data Reader",
|
|
18
|
+
"Cosmos DB Account Reader Role",
|
|
19
|
+
],
|
|
20
|
+
},
|
|
21
|
+
{ groupName: "security", roles: ["Reader", "Support Request Contributor"] },
|
|
22
|
+
{
|
|
23
|
+
groupName: "technical-project-managers",
|
|
24
|
+
roles: ["Reader", "Monitoring Contributor", "Support Request Contributor"],
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
groupName: "product-owners",
|
|
28
|
+
roles: ["Reader", "Support Request Contributor"],
|
|
29
|
+
},
|
|
30
|
+
{ groupName: "externals", roles: ["Owner"] },
|
|
31
|
+
{
|
|
32
|
+
groupName: "oncall",
|
|
33
|
+
roles: [
|
|
34
|
+
"Reader",
|
|
35
|
+
"Monitoring Contributor",
|
|
36
|
+
"Support Request Contributor",
|
|
37
|
+
"Storage Blob Data Reader",
|
|
38
|
+
"Storage Queue Data Reader",
|
|
39
|
+
"Cosmos DB Account Reader Role",
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
];
|
|
43
|
+
export const makeAzureAdGroupName = (prefix, envShort, groupName) => `${prefix}-${envShort}-adgroup-${groupName}`;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* PagoPA Technology Authorization Adapter
|
|
2
|
+
* PagoPA Technology Azure Authorization Adapter
|
|
3
3
|
*
|
|
4
4
|
* Implements the AuthorizationService interface for the PagoPA Azure
|
|
5
5
|
* authorization workflow. Encapsulates all platform-specific details:
|
|
@@ -8,4 +8,4 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { AuthorizationService } from "../../domain/authorization.js";
|
|
10
10
|
import { GitHubService } from "../../domain/github.js";
|
|
11
|
-
export declare const
|
|
11
|
+
export declare const makeAzureAuthorizationService: (gitHubService: GitHubService) => AuthorizationService;
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PagoPA Technology Azure 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, JSON 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 { z } from "zod";
|
|
12
|
+
import { AuthorizationError, AuthorizationResult, InvalidAuthorizationFileFormatError, } from "../../domain/authorization.js";
|
|
13
|
+
import { DEFAULT_GROUP_SPECS, makeAzureAdGroupName, } from "./azure-authorization-config.js";
|
|
14
|
+
const azureAuthorizationFileSchema = z
|
|
15
|
+
.object({
|
|
16
|
+
directory_readers: z
|
|
17
|
+
.object({
|
|
18
|
+
service_principals_name: z.array(z.string()),
|
|
19
|
+
})
|
|
20
|
+
.loose(),
|
|
21
|
+
groups: z
|
|
22
|
+
.array(z
|
|
23
|
+
.object({
|
|
24
|
+
members: z.array(z.string()),
|
|
25
|
+
name: z.string(),
|
|
26
|
+
roles: z.array(z.string()),
|
|
27
|
+
})
|
|
28
|
+
.loose())
|
|
29
|
+
.optional(),
|
|
30
|
+
})
|
|
31
|
+
.loose();
|
|
32
|
+
/**
|
|
33
|
+
* Checks if two role arrays are equivalent (same roles, order-independent).
|
|
34
|
+
*/
|
|
35
|
+
const rolesAreEqual = (roles1, roles2) => {
|
|
36
|
+
if (roles1.length !== roles2.length) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
const sorted1 = [...roles1].sort();
|
|
40
|
+
const sorted2 = [...roles2].sort();
|
|
41
|
+
return sorted1.every((role, idx) => role === sorted2[idx]);
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Adds or updates the AD groups array in the parsed authorization JSON.
|
|
45
|
+
* - Missing default groups are added with empty members.
|
|
46
|
+
* - Existing groups with wrong roles have their roles updated; members are preserved.
|
|
47
|
+
* - Custom (non-default) groups are preserved unchanged.
|
|
48
|
+
* Returns the updated JSON along with a flag indicating whether anything changed.
|
|
49
|
+
*/
|
|
50
|
+
const upsertGroups = (jsonContent, prefix, envShort) => {
|
|
51
|
+
const expectedByName = new Map(DEFAULT_GROUP_SPECS.map((spec) => [
|
|
52
|
+
makeAzureAdGroupName(prefix, envShort, spec.groupName),
|
|
53
|
+
spec,
|
|
54
|
+
]));
|
|
55
|
+
const existingGroups = jsonContent.groups ?? [];
|
|
56
|
+
const seenDefaults = new Set();
|
|
57
|
+
// Walk existing groups in their original order, updating roles where needed
|
|
58
|
+
const finalGroups = [];
|
|
59
|
+
let groupsChanged = false;
|
|
60
|
+
for (const existing of existingGroups) {
|
|
61
|
+
const spec = expectedByName.get(existing.name);
|
|
62
|
+
if (!spec) {
|
|
63
|
+
// Custom group — preserve as-is
|
|
64
|
+
finalGroups.push(existing);
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
seenDefaults.add(existing.name);
|
|
68
|
+
if (!rolesAreEqual(existing.roles, spec.roles)) {
|
|
69
|
+
// Roles differ — update roles, preserve members and any extra fields
|
|
70
|
+
finalGroups.push({ ...existing, roles: [...spec.roles] });
|
|
71
|
+
groupsChanged = true;
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
finalGroups.push(existing);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// Append missing default groups at the end
|
|
79
|
+
for (const spec of DEFAULT_GROUP_SPECS) {
|
|
80
|
+
const name = makeAzureAdGroupName(prefix, envShort, spec.groupName);
|
|
81
|
+
if (!seenDefaults.has(name)) {
|
|
82
|
+
finalGroups.push({ members: [], name, roles: [...spec.roles] });
|
|
83
|
+
groupsChanged = true;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return { groupsChanged, json: { ...jsonContent, groups: finalGroups } };
|
|
87
|
+
};
|
|
88
|
+
/**
|
|
89
|
+
* Parses and validates the authorization file content (JSON parsing + schema validation).
|
|
90
|
+
*/
|
|
91
|
+
const parseAuthorizationFile = (content) => {
|
|
92
|
+
let parsed;
|
|
93
|
+
try {
|
|
94
|
+
parsed = JSON.parse(content);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return err(new InvalidAuthorizationFileFormatError("File content is not valid JSON"));
|
|
98
|
+
}
|
|
99
|
+
const result = azureAuthorizationFileSchema.safeParse(parsed);
|
|
100
|
+
if (!result.success) {
|
|
101
|
+
return err(new InvalidAuthorizationFileFormatError("Could not find directory_readers.service_principals_name list"));
|
|
102
|
+
}
|
|
103
|
+
return ok(result.data);
|
|
104
|
+
};
|
|
105
|
+
/**
|
|
106
|
+
* Produces commit message, PR title, and PR body tailored to what actually changed.
|
|
107
|
+
*/
|
|
108
|
+
const makeChangeDescription = (subscriptionName, bootstrapIdentityId, identityAdded, groupsChanged) => {
|
|
109
|
+
if (identityAdded && groupsChanged) {
|
|
110
|
+
const title = `Add bootstrap identity and AD groups for ${subscriptionName}`;
|
|
111
|
+
return {
|
|
112
|
+
body: `This PR adds the bootstrap identity \`${bootstrapIdentityId}\` to the directory readers and configures AD groups for subscription \`${subscriptionName}\`.`,
|
|
113
|
+
message: title,
|
|
114
|
+
title,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
if (identityAdded) {
|
|
118
|
+
const title = `Add bootstrap identity for ${subscriptionName}`;
|
|
119
|
+
return {
|
|
120
|
+
body: `This PR adds the bootstrap identity \`${bootstrapIdentityId}\` to the directory readers for subscription \`${subscriptionName}\`.`,
|
|
121
|
+
message: title,
|
|
122
|
+
title,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
const title = `Configure AD groups for ${subscriptionName}`;
|
|
126
|
+
return {
|
|
127
|
+
body: `This PR configures AD groups for subscription \`${subscriptionName}\`.`,
|
|
128
|
+
message: title,
|
|
129
|
+
title,
|
|
130
|
+
};
|
|
131
|
+
};
|
|
132
|
+
/**
|
|
133
|
+
* Ensures the given identity is present in the service_principals_name list.
|
|
134
|
+
* Returns the (possibly updated) JSON and whether the identity was newly added.
|
|
135
|
+
* Never fails: if the identity already exists it is a no-op with identityAdded = false.
|
|
136
|
+
*/
|
|
137
|
+
const ensureIdentity = (jsonContent, identityId) => {
|
|
138
|
+
if (jsonContent.directory_readers.service_principals_name.includes(identityId)) {
|
|
139
|
+
return { identityAdded: false, json: jsonContent };
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
identityAdded: true,
|
|
143
|
+
json: {
|
|
144
|
+
...jsonContent,
|
|
145
|
+
directory_readers: {
|
|
146
|
+
...jsonContent.directory_readers,
|
|
147
|
+
service_principals_name: [
|
|
148
|
+
...jsonContent.directory_readers.service_principals_name,
|
|
149
|
+
identityId,
|
|
150
|
+
],
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
};
|
|
155
|
+
const REPO_OWNER = "pagopa";
|
|
156
|
+
const REPO_NAME = "eng-azure-authorization";
|
|
157
|
+
const BASE_BRANCH = "main";
|
|
158
|
+
export const makeAzureAuthorizationService = (gitHubService) => ({
|
|
159
|
+
requestAuthorization(input) {
|
|
160
|
+
const logger = getLogger(["dx-cli", "pagopa-azure-authorization"]);
|
|
161
|
+
const { bootstrapIdentityId, envShort, prefix, repoName, subscriptionName, } = input;
|
|
162
|
+
const filePath = `src/azure-subscriptions/subscriptions/${subscriptionName}/terraform.tfvars.json`;
|
|
163
|
+
const branchName = `feats/add-${repoName}-${subscriptionName}-bootstrap-identity`;
|
|
164
|
+
return (
|
|
165
|
+
// Step 1: Read file from main to determine if changes are needed
|
|
166
|
+
ResultAsync.fromPromise(gitHubService.getFileContent({
|
|
167
|
+
owner: REPO_OWNER,
|
|
168
|
+
path: filePath,
|
|
169
|
+
ref: BASE_BRANCH,
|
|
170
|
+
repo: REPO_NAME,
|
|
171
|
+
}), () => new AuthorizationError(`Unable to get ${filePath} in ${REPO_OWNER}/${REPO_NAME}`))
|
|
172
|
+
.orTee((error) => {
|
|
173
|
+
logger.error(error.message);
|
|
174
|
+
})
|
|
175
|
+
// Step 2: Parse file, ensure identity, upsert groups, detect no-op
|
|
176
|
+
.andThen(({ content, sha }) => {
|
|
177
|
+
const parseResult = parseAuthorizationFile(content);
|
|
178
|
+
if (parseResult.isErr()) {
|
|
179
|
+
logger.error("Failed to modify tfvars", {
|
|
180
|
+
error: parseResult.error.message,
|
|
181
|
+
});
|
|
182
|
+
return errAsync(parseResult.error);
|
|
183
|
+
}
|
|
184
|
+
const parsed = parseResult.value;
|
|
185
|
+
const { identityAdded, json: withIdentity } = ensureIdentity(parsed, bootstrapIdentityId);
|
|
186
|
+
if (!identityAdded) {
|
|
187
|
+
logger.warn("Identity already exists, checking groups", {
|
|
188
|
+
identityId: bootstrapIdentityId,
|
|
189
|
+
subscription: subscriptionName,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
const { groupsChanged, json: updatedJson } = upsertGroups(withIdentity, prefix, envShort);
|
|
193
|
+
if (!identityAdded && !groupsChanged) {
|
|
194
|
+
// Nothing to do — no branch created, no PR needed.
|
|
195
|
+
logger.info("No changes needed, skipping PR", {
|
|
196
|
+
subscription: subscriptionName,
|
|
197
|
+
});
|
|
198
|
+
return okAsync(new AuthorizationResult());
|
|
199
|
+
}
|
|
200
|
+
const { body, message, title } = makeChangeDescription(subscriptionName, bootstrapIdentityId, identityAdded, groupsChanged);
|
|
201
|
+
// Step 3: Create branch only when changes are needed
|
|
202
|
+
return (ResultAsync.fromPromise(gitHubService.createBranch({
|
|
203
|
+
branchName,
|
|
204
|
+
fromRef: BASE_BRANCH,
|
|
205
|
+
owner: REPO_OWNER,
|
|
206
|
+
repo: REPO_NAME,
|
|
207
|
+
}), () => new AuthorizationError(`Unable to create branch ${branchName} in ${REPO_OWNER}/${REPO_NAME}`))
|
|
208
|
+
.orTee((error) => {
|
|
209
|
+
logger.error(error.message);
|
|
210
|
+
})
|
|
211
|
+
// Step 4: Update file on the branch using the SHA read from main
|
|
212
|
+
.andThen(() => ResultAsync.fromPromise(gitHubService.updateFile({
|
|
213
|
+
branch: branchName,
|
|
214
|
+
content: JSON.stringify(updatedJson, null, 2),
|
|
215
|
+
message,
|
|
216
|
+
owner: REPO_OWNER,
|
|
217
|
+
path: filePath,
|
|
218
|
+
repo: REPO_NAME,
|
|
219
|
+
sha,
|
|
220
|
+
}), () => new AuthorizationError(`Unable to update ${filePath} on branch ${branchName} in ${REPO_OWNER}/${REPO_NAME}`)))
|
|
221
|
+
.orTee((error) => {
|
|
222
|
+
logger.error(error.message);
|
|
223
|
+
})
|
|
224
|
+
// Step 5: Create PR
|
|
225
|
+
.andThen(() => ResultAsync.fromPromise(gitHubService.createPullRequest({
|
|
226
|
+
base: BASE_BRANCH,
|
|
227
|
+
body,
|
|
228
|
+
head: branchName,
|
|
229
|
+
owner: REPO_OWNER,
|
|
230
|
+
repo: REPO_NAME,
|
|
231
|
+
title,
|
|
232
|
+
}), () => new AuthorizationError(`Unable to create pull request from ${branchName} to ${BASE_BRANCH} in ${REPO_OWNER}/${REPO_NAME}`)))
|
|
233
|
+
.orTee((error) => {
|
|
234
|
+
logger.error(error.message);
|
|
235
|
+
})
|
|
236
|
+
.map((pr) => new AuthorizationResult(pr.url)));
|
|
237
|
+
}));
|
|
238
|
+
},
|
|
239
|
+
});
|
|
@@ -36,6 +36,7 @@ const createMockPayload = (overrides = {}) => ({
|
|
|
36
36
|
init: {
|
|
37
37
|
cloudAccountsToInitialize: [],
|
|
38
38
|
runnerAppCredentials: {
|
|
39
|
+
clientId: "test-app-client-id",
|
|
39
40
|
id: "test-app-id",
|
|
40
41
|
installationId: "test-installation-id",
|
|
41
42
|
key: "test-private-key",
|
|
@@ -67,6 +68,7 @@ describe("initCloudAccounts", () => {
|
|
|
67
68
|
init: {
|
|
68
69
|
cloudAccountsToInitialize: [cloudAccount1, cloudAccount2],
|
|
69
70
|
runnerAppCredentials: {
|
|
71
|
+
clientId: "test-app-client-id",
|
|
70
72
|
id: "test-app-id",
|
|
71
73
|
installationId: "test-installation-id",
|
|
72
74
|
key: "test-private-key",
|
|
@@ -79,6 +81,7 @@ describe("initCloudAccounts", () => {
|
|
|
79
81
|
await initCloudAccounts(payload, mockService, createMockGitHubService());
|
|
80
82
|
expect(initializeMock).toHaveBeenCalledTimes(2);
|
|
81
83
|
expect(initializeMock).toHaveBeenCalledWith(cloudAccount1, expect.objectContaining({ name: "prod", prefix: "io" }), {
|
|
84
|
+
clientId: "test-app-client-id",
|
|
82
85
|
id: "test-app-id",
|
|
83
86
|
installationId: "test-installation-id",
|
|
84
87
|
key: "test-private-key",
|
|
@@ -87,6 +90,7 @@ describe("initCloudAccounts", () => {
|
|
|
87
90
|
repo: "dx",
|
|
88
91
|
}, expect.any(Object), {});
|
|
89
92
|
expect(initializeMock).toHaveBeenCalledWith(cloudAccount2, expect.objectContaining({ name: "prod", prefix: "io" }), {
|
|
93
|
+
clientId: "test-app-client-id",
|
|
90
94
|
id: "test-app-id",
|
|
91
95
|
installationId: "test-installation-id",
|
|
92
96
|
key: "test-private-key",
|
|
@@ -104,6 +108,7 @@ describe("initCloudAccounts", () => {
|
|
|
104
108
|
init: {
|
|
105
109
|
cloudAccountsToInitialize: [],
|
|
106
110
|
runnerAppCredentials: {
|
|
111
|
+
clientId: "test-app-client-id",
|
|
107
112
|
id: "test-app-id",
|
|
108
113
|
installationId: "test-installation-id",
|
|
109
114
|
key: "test-private-key",
|
|
@@ -142,6 +147,7 @@ describe("initCloudAccounts", () => {
|
|
|
142
147
|
init: {
|
|
143
148
|
cloudAccountsToInitialize: [cloudAccount],
|
|
144
149
|
runnerAppCredentials: {
|
|
150
|
+
clientId: "test-app-client-id",
|
|
145
151
|
id: "test-app-id",
|
|
146
152
|
installationId: "test-installation-id",
|
|
147
153
|
key: "test-private-key",
|
|
@@ -153,6 +159,7 @@ describe("initCloudAccounts", () => {
|
|
|
153
159
|
});
|
|
154
160
|
await initCloudAccounts(payload, mockService, createMockGitHubService());
|
|
155
161
|
expect(initializeMock).toHaveBeenCalledWith(cloudAccount, expect.objectContaining({ name: "uat", prefix: "pagopa" }), {
|
|
162
|
+
clientId: "test-app-client-id",
|
|
156
163
|
id: "test-app-id",
|
|
157
164
|
installationId: "test-installation-id",
|
|
158
165
|
key: "test-private-key",
|
|
@@ -35,6 +35,7 @@ const createMockPayload = (overrides = {}) => ({
|
|
|
35
35
|
init: {
|
|
36
36
|
cloudAccountsToInitialize: [],
|
|
37
37
|
runnerAppCredentials: {
|
|
38
|
+
clientId: "test-app-client-id",
|
|
38
39
|
id: "test-app-id",
|
|
39
40
|
installationId: "test-installation-id",
|
|
40
41
|
key: "test-private-key",
|
|
@@ -77,6 +78,7 @@ describe("provisionTerraformBackend", () => {
|
|
|
77
78
|
init: {
|
|
78
79
|
cloudAccountsToInitialize: [],
|
|
79
80
|
runnerAppCredentials: {
|
|
81
|
+
clientId: "test-app-client-id",
|
|
80
82
|
id: "test-app-id",
|
|
81
83
|
installationId: "test-installation-id",
|
|
82
84
|
key: "test-private-key",
|
|
@@ -116,6 +118,7 @@ describe("provisionTerraformBackend", () => {
|
|
|
116
118
|
init: {
|
|
117
119
|
cloudAccountsToInitialize: [],
|
|
118
120
|
runnerAppCredentials: {
|
|
121
|
+
clientId: "test-app-client-id",
|
|
119
122
|
id: "test-app-id",
|
|
120
123
|
installationId: "test-installation-id",
|
|
121
124
|
key: "test-private-key",
|
|
@@ -26,6 +26,7 @@ export const getPayload = (includeInit = false) => {
|
|
|
26
26
|
payload.init = {
|
|
27
27
|
cloudAccountsToInitialize: [cloudAccount],
|
|
28
28
|
runnerAppCredentials: {
|
|
29
|
+
clientId: "test-app-client-id",
|
|
29
30
|
id: "test-app-id",
|
|
30
31
|
installationId: "test-installation-id",
|
|
31
32
|
key: "test-private-key",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Tests for workspaceSchema transforms (lowercase and trim on domain).
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
2
|
+
import inquirer from "inquirer";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import prompts, { formatInitializationDetails, workspaceSchema, } from "../prompts.js";
|
|
4
5
|
describe("workspaceSchema — domain transforms", () => {
|
|
5
6
|
it("lowercases an uppercase domain", () => {
|
|
6
7
|
const result = workspaceSchema.safeParse({ domain: "API" });
|
|
@@ -83,3 +84,96 @@ describe("formatInitializationDetails", () => {
|
|
|
83
84
|
expect(formatInitializationDetails(status)).toBe("");
|
|
84
85
|
});
|
|
85
86
|
});
|
|
87
|
+
describe("prompts", () => {
|
|
88
|
+
it("prompts for the GitHub runner app client ID when initialization is required", async () => {
|
|
89
|
+
const cloudAccount = {
|
|
90
|
+
csp: "azure",
|
|
91
|
+
defaultLocation: "italynorth",
|
|
92
|
+
displayName: "DEV-FooBar",
|
|
93
|
+
id: "sub-123",
|
|
94
|
+
};
|
|
95
|
+
const cloudAccountRepository = {
|
|
96
|
+
list: vi.fn().mockResolvedValue([cloudAccount]),
|
|
97
|
+
};
|
|
98
|
+
const cloudAccountService = {
|
|
99
|
+
getTerraformBackend: vi.fn().mockResolvedValue(undefined),
|
|
100
|
+
hasUserPermissionToInitialize: vi.fn().mockResolvedValue(true),
|
|
101
|
+
initialize: vi.fn().mockResolvedValue(undefined),
|
|
102
|
+
isInitialized: vi.fn().mockResolvedValue(false),
|
|
103
|
+
provisionTerraformBackend: vi.fn().mockResolvedValue(undefined),
|
|
104
|
+
};
|
|
105
|
+
const promptSpy = vi.spyOn(inquirer, "prompt");
|
|
106
|
+
const consoleLogSpy = vi
|
|
107
|
+
.spyOn(console, "log")
|
|
108
|
+
.mockImplementation(() => undefined);
|
|
109
|
+
promptSpy
|
|
110
|
+
.mockResolvedValueOnce({
|
|
111
|
+
env: {
|
|
112
|
+
cloudAccounts: [cloudAccount],
|
|
113
|
+
name: "dev",
|
|
114
|
+
prefix: "dx",
|
|
115
|
+
},
|
|
116
|
+
tags: {
|
|
117
|
+
BusinessUnit: "Platform",
|
|
118
|
+
CostCenter: "TS000",
|
|
119
|
+
ManagementTeam: "Engineering",
|
|
120
|
+
},
|
|
121
|
+
workspace: {
|
|
122
|
+
domain: "payments",
|
|
123
|
+
},
|
|
124
|
+
})
|
|
125
|
+
.mockResolvedValueOnce({
|
|
126
|
+
[cloudAccount.id]: "italynorth",
|
|
127
|
+
})
|
|
128
|
+
.mockResolvedValueOnce({
|
|
129
|
+
init: true,
|
|
130
|
+
})
|
|
131
|
+
.mockResolvedValueOnce({
|
|
132
|
+
runnerAppCredentials: {
|
|
133
|
+
clientId: "app-client-id",
|
|
134
|
+
id: "app-id",
|
|
135
|
+
installationId: "installation-id",
|
|
136
|
+
key: "private-key",
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
try {
|
|
140
|
+
const result = await prompts({
|
|
141
|
+
cloudAccountRepository,
|
|
142
|
+
cloudAccountService,
|
|
143
|
+
github: {
|
|
144
|
+
owner: "pagopa",
|
|
145
|
+
repo: "dx",
|
|
146
|
+
},
|
|
147
|
+
})(inquirer);
|
|
148
|
+
const runnerCredentialQuestions = promptSpy.mock.calls[3]?.[0];
|
|
149
|
+
expect(runnerCredentialQuestions).toEqual(expect.arrayContaining([
|
|
150
|
+
expect.objectContaining({
|
|
151
|
+
message: "GitHub Runner App ID",
|
|
152
|
+
name: "runnerAppCredentials.id",
|
|
153
|
+
}),
|
|
154
|
+
expect.objectContaining({
|
|
155
|
+
message: "GitHub Runner App Client ID",
|
|
156
|
+
name: "runnerAppCredentials.clientId",
|
|
157
|
+
}),
|
|
158
|
+
expect.objectContaining({
|
|
159
|
+
message: "GitHub Runner App Installation ID",
|
|
160
|
+
name: "runnerAppCredentials.installationId",
|
|
161
|
+
}),
|
|
162
|
+
expect.objectContaining({
|
|
163
|
+
message: "GitHub Runner App Private Key",
|
|
164
|
+
name: "runnerAppCredentials.key",
|
|
165
|
+
}),
|
|
166
|
+
]));
|
|
167
|
+
expect(result.init?.runnerAppCredentials).toEqual({
|
|
168
|
+
clientId: "app-client-id",
|
|
169
|
+
id: "app-id",
|
|
170
|
+
installationId: "installation-id",
|
|
171
|
+
key: "private-key",
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
promptSpy.mockRestore();
|
|
176
|
+
consoleLogSpy.mockRestore();
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
});
|
|
@@ -163,6 +163,12 @@ const prompts = (deps) => async (inquirer) => {
|
|
|
163
163
|
name: "runnerAppCredentials.id",
|
|
164
164
|
type: "input",
|
|
165
165
|
validate: (value) => value.length > 0,
|
|
166
|
+
}, {
|
|
167
|
+
filter: (value) => value.trim(),
|
|
168
|
+
message: "GitHub Runner App Client ID",
|
|
169
|
+
name: "runnerAppCredentials.clientId",
|
|
170
|
+
type: "input",
|
|
171
|
+
validate: (value) => value.length > 0,
|
|
166
172
|
}, {
|
|
167
173
|
filter: (value) => value.trim(),
|
|
168
174
|
message: "GitHub Runner App Installation ID",
|
|
@@ -8,10 +8,12 @@
|
|
|
8
8
|
import { ResultAsync } from "neverthrow";
|
|
9
9
|
import { z } from "zod/v4";
|
|
10
10
|
/**
|
|
11
|
-
* Input validation schema for the
|
|
11
|
+
* Input validation schema for the authorization workflow.
|
|
12
12
|
*/
|
|
13
13
|
export declare const requestAuthorizationInputSchema: z.ZodObject<{
|
|
14
14
|
bootstrapIdentityId: z.core.$ZodBranded<z.ZodString, "BootstrapIdentityId", "out">;
|
|
15
|
+
envShort: z.core.$ZodBranded<z.ZodString, "EnvShort", "out">;
|
|
16
|
+
prefix: z.core.$ZodBranded<z.ZodString, "ResourcePrefix", "out">;
|
|
15
17
|
repoName: z.ZodString;
|
|
16
18
|
subscriptionName: z.core.$ZodBranded<z.ZodString, "SubscriptionName", "out">;
|
|
17
19
|
}, z.core.$strip>;
|
|
@@ -31,16 +33,11 @@ export declare class AuthorizationError extends Error {
|
|
|
31
33
|
}
|
|
32
34
|
/**
|
|
33
35
|
* Result returned by a successful authorization request.
|
|
36
|
+
* When url is undefined, the workflow was a no-op (nothing changed, no PR created).
|
|
34
37
|
*/
|
|
35
38
|
export declare class AuthorizationResult {
|
|
36
|
-
readonly url
|
|
37
|
-
constructor(url
|
|
38
|
-
}
|
|
39
|
-
/**
|
|
40
|
-
* Error thrown when attempting to add an identity that already exists.
|
|
41
|
-
*/
|
|
42
|
-
export declare class IdentityAlreadyExistsError extends AuthorizationError {
|
|
43
|
-
constructor(identityId: string);
|
|
39
|
+
readonly url?: string | undefined;
|
|
40
|
+
constructor(url?: string | undefined);
|
|
44
41
|
}
|
|
45
42
|
/**
|
|
46
43
|
* Error thrown when the authorization configuration format is invalid or cannot be parsed.
|
|
@@ -29,10 +29,35 @@ const BootstrapIdentityId = z
|
|
|
29
29
|
})
|
|
30
30
|
.brand();
|
|
31
31
|
/**
|
|
32
|
-
*
|
|
32
|
+
* Branded type for resource prefix (e.g., "dx", "io").
|
|
33
|
+
* Validates that the prefix contains only lowercase letters to prevent injection.
|
|
34
|
+
*/
|
|
35
|
+
const ResourcePrefix = z
|
|
36
|
+
.string()
|
|
37
|
+
.min(1)
|
|
38
|
+
.regex(/^[a-z]+$/, {
|
|
39
|
+
message: "Resource prefix may contain only lowercase letters",
|
|
40
|
+
})
|
|
41
|
+
.brand();
|
|
42
|
+
/**
|
|
43
|
+
* Branded type for environment short name (e.g., "d", "u", "p").
|
|
44
|
+
* Validates single lowercase letter for environment.
|
|
45
|
+
*/
|
|
46
|
+
const EnvShort = z
|
|
47
|
+
.string()
|
|
48
|
+
.min(1)
|
|
49
|
+
.max(1)
|
|
50
|
+
.regex(/^[a-z]$/, {
|
|
51
|
+
message: "Environment short name must be a single lowercase letter",
|
|
52
|
+
})
|
|
53
|
+
.brand();
|
|
54
|
+
/**
|
|
55
|
+
* Input validation schema for the authorization workflow.
|
|
33
56
|
*/
|
|
34
57
|
export const requestAuthorizationInputSchema = z.object({
|
|
35
58
|
bootstrapIdentityId: BootstrapIdentityId,
|
|
59
|
+
envShort: EnvShort,
|
|
60
|
+
prefix: ResourcePrefix,
|
|
36
61
|
repoName: z.string().min(1),
|
|
37
62
|
subscriptionName: SubscriptionName,
|
|
38
63
|
});
|
|
@@ -47,6 +72,7 @@ export class AuthorizationError extends Error {
|
|
|
47
72
|
}
|
|
48
73
|
/**
|
|
49
74
|
* Result returned by a successful authorization request.
|
|
75
|
+
* When url is undefined, the workflow was a no-op (nothing changed, no PR created).
|
|
50
76
|
*/
|
|
51
77
|
export class AuthorizationResult {
|
|
52
78
|
url;
|
|
@@ -54,15 +80,6 @@ export class AuthorizationResult {
|
|
|
54
80
|
this.url = url;
|
|
55
81
|
}
|
|
56
82
|
}
|
|
57
|
-
/**
|
|
58
|
-
* Error thrown when attempting to add an identity that already exists.
|
|
59
|
-
*/
|
|
60
|
-
export class IdentityAlreadyExistsError extends AuthorizationError {
|
|
61
|
-
constructor(identityId) {
|
|
62
|
-
super(`Identity "${identityId}" already exists`);
|
|
63
|
-
this.name = "IdentityAlreadyExistsError";
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
83
|
/**
|
|
67
84
|
* Error thrown when the authorization configuration format is invalid or cannot be parsed.
|
|
68
85
|
*/
|
package/dist/domain/github.d.ts
CHANGED
|
@@ -94,6 +94,7 @@ export declare class RepositoryNotFoundError extends Error {
|
|
|
94
94
|
constructor(owner: string, name: string);
|
|
95
95
|
}
|
|
96
96
|
export declare const githubAppCredentialsSchema: z.ZodObject<{
|
|
97
|
+
clientId: z.ZodString;
|
|
97
98
|
id: z.ZodString;
|
|
98
99
|
installationId: z.ZodString;
|
|
99
100
|
key: z.ZodString;
|
package/dist/domain/github.js
CHANGED
package/dist/index.js
CHANGED
|
@@ -8,7 +8,7 @@ import { makeValidationReporter } from "./adapters/logtape/validation-reporter.j
|
|
|
8
8
|
import { makePackageJsonReader } from "./adapters/node/package-json.js";
|
|
9
9
|
import { makeRepositoryReader } from "./adapters/node/repository.js";
|
|
10
10
|
import { getGitHubPAT, OctokitGitHubService, } from "./adapters/octokit/index.js";
|
|
11
|
-
import {
|
|
11
|
+
import { makeAzureAuthorizationService } from "./adapters/pagopa-technology/azure-authorization.js";
|
|
12
12
|
import { getConfig } from "./config.js";
|
|
13
13
|
import { getInfo } from "./domain/info.js";
|
|
14
14
|
import { applyCodemodById } from "./use-cases/apply-codemod.js";
|
|
@@ -60,7 +60,7 @@ export const runCli = async (version) => {
|
|
|
60
60
|
auth,
|
|
61
61
|
});
|
|
62
62
|
const gitHubService = new OctokitGitHubService(octokit);
|
|
63
|
-
const authorizationService =
|
|
63
|
+
const authorizationService = makeAzureAuthorizationService(gitHubService);
|
|
64
64
|
const deps = {
|
|
65
65
|
authorizationService,
|
|
66
66
|
gitHubService,
|
|
@@ -4,10 +4,12 @@
|
|
|
4
4
|
import { errAsync, okAsync } from "neverthrow";
|
|
5
5
|
import { describe, expect, it } from "vitest";
|
|
6
6
|
import { mock } from "vitest-mock-extended";
|
|
7
|
-
import { AuthorizationError, AuthorizationResult,
|
|
7
|
+
import { AuthorizationError, AuthorizationResult, requestAuthorizationInputSchema, } from "../../domain/authorization.js";
|
|
8
8
|
import { requestAuthorization } from "../request-authorization.js";
|
|
9
9
|
const makeSampleInput = () => requestAuthorizationInputSchema.parse({
|
|
10
10
|
bootstrapIdentityId: "test-bootstrap-identity-id",
|
|
11
|
+
envShort: "d",
|
|
12
|
+
prefix: "test",
|
|
11
13
|
repoName: "test-repo",
|
|
12
14
|
subscriptionName: "test-subscription",
|
|
13
15
|
});
|
|
@@ -25,10 +27,10 @@ describe("requestAuthorization", () => {
|
|
|
25
27
|
it("should propagate errors from the authorization service", async () => {
|
|
26
28
|
const authorizationService = mock();
|
|
27
29
|
const input = makeSampleInput();
|
|
28
|
-
authorizationService.requestAuthorization.mockReturnValue(errAsync(new
|
|
30
|
+
authorizationService.requestAuthorization.mockReturnValue(errAsync(new AuthorizationError("Unable to update file")));
|
|
29
31
|
const result = await requestAuthorization(authorizationService)(input);
|
|
30
32
|
expect(result.isErr()).toBe(true);
|
|
31
|
-
expect(result._unsafeUnwrapErr()).toBeInstanceOf(
|
|
33
|
+
expect(result._unsafeUnwrapErr()).toBeInstanceOf(AuthorizationError);
|
|
32
34
|
});
|
|
33
35
|
it("should propagate generic authorization errors", async () => {
|
|
34
36
|
const authorizationService = mock();
|