@pagopa/dx-cli 0.19.1 → 0.19.3

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.
@@ -45,6 +45,8 @@ const azureAccountSchema = z.object({
45
45
  });
46
46
  const ensureAzLogin = async () => {
47
47
  const { stdout } = await tf$ `az account show`;
48
+ // `az account show` reads the cached CLI context, but `az group list`
49
+ // fails when the current session token has expired.
48
50
  await tf$ `az group list`;
49
51
  const parsed = JSON.parse(stdout);
50
52
  const { user } = azureAccountSchema.parse(parsed);
@@ -79,12 +81,13 @@ const initializeGitRepository = (repository) => {
79
81
  });
80
82
  const pushToOrigin = async () => {
81
83
  await git$ `git init`;
82
- await git$ `git add README.md`;
83
- await git$ `git commit --no-gpg-sign -m "Create README.md"`;
84
- await git$ `git branch -M main`;
85
84
  await git$ `git remote add origin ${repository.origin}`;
86
- await git$ `git push -u origin main`;
87
- await git$ `git switch -c ${branchName}`;
85
+ await git$ `git fetch origin main`;
86
+ await git$ `git checkout -b ${branchName}`;
87
+ // Terraform creates `main` with an initial README commit.
88
+ // Reset to `origin/main` so this branch is based on the remote default branch,
89
+ // while keeping the scaffolded local files in the working tree for a clean PR diff.
90
+ await git$ `git reset origin/main`;
88
91
  await git$ `git add .`;
89
92
  await git$ `git commit --no-gpg-sign -m "Scaffold workspace"`;
90
93
  await git$ `git push -u origin ${branchName}`;
@@ -19,17 +19,14 @@ const makeSampleInput = () => requestAuthorizationInputSchema.parse({
19
19
  repoName: "test-repo",
20
20
  subscriptionName: "test-subscription",
21
21
  });
22
+ const FILE_PATH = "src/azure-subscriptions/subscriptions/test-subscription/terraform.tfvars.json";
22
23
  // eslint-disable-next-line max-lines-per-function
23
24
  describe("PagoPA AuthorizationService", () => {
24
25
  describe("happy path", () => {
25
26
  it("should create a pull request when all steps succeed", async () => {
26
27
  const { authorizationService, gitHubService } = makeEnv();
27
28
  const input = makeSampleInput();
28
- const originalContent = `
29
- directory_readers = {
30
- service_principals_name = []
31
- }
32
- `.trim();
29
+ const originalContent = JSON.stringify({ directory_readers: { service_principals_name: [] } }, null, 2);
33
30
  gitHubService.createBranch.mockResolvedValue(undefined);
34
31
  gitHubService.getFileContent.mockResolvedValue({
35
32
  content: originalContent,
@@ -50,7 +47,7 @@ directory_readers = {
50
47
  });
51
48
  expect(gitHubService.getFileContent).toHaveBeenCalledWith({
52
49
  owner: "pagopa",
53
- path: "src/azure-subscriptions/subscriptions/test-subscription/terraform.tfvars",
50
+ path: FILE_PATH,
54
51
  ref: "feats/add-test-repo-test-subscription-bootstrap-identity",
55
52
  repo: "eng-azure-authorization",
56
53
  });
@@ -58,7 +55,7 @@ directory_readers = {
58
55
  branch: "feats/add-test-repo-test-subscription-bootstrap-identity",
59
56
  message: "Add directory reader for test-subscription",
60
57
  owner: "pagopa",
61
- path: "src/azure-subscriptions/subscriptions/test-subscription/terraform.tfvars",
58
+ path: FILE_PATH,
62
59
  repo: "eng-azure-authorization",
63
60
  sha: "original-sha-123",
64
61
  }));
@@ -71,30 +68,88 @@ directory_readers = {
71
68
  title: "Add directory reader for test-subscription",
72
69
  });
73
70
  });
71
+ it("should preserve existing fields in the JSON file", async () => {
72
+ const { authorizationService, gitHubService } = makeEnv();
73
+ const input = makeSampleInput();
74
+ const originalContent = JSON.stringify({
75
+ directory_readers: {
76
+ service_principals_name: ["existing-identity"],
77
+ some_other_field: "keep-me",
78
+ },
79
+ entra_groups: {
80
+ readers: ["reader-group"],
81
+ },
82
+ other_top_level: true,
83
+ }, null, 2);
84
+ gitHubService.createBranch.mockResolvedValue(undefined);
85
+ gitHubService.getFileContent.mockResolvedValue({
86
+ content: originalContent,
87
+ sha: "sha-789",
88
+ });
89
+ gitHubService.updateFile.mockResolvedValue(undefined);
90
+ gitHubService.createPullRequest.mockResolvedValue(new PullRequest("https://github.com/pagopa/eng-azure-authorization/pull/44"));
91
+ const result = await authorizationService.requestAuthorization(input);
92
+ expect(result.isOk()).toBe(true);
93
+ const updateCall = gitHubService.updateFile.mock.calls[0][0];
94
+ const updatedParsed = JSON.parse(updateCall.content);
95
+ expect(updatedParsed).toStrictEqual({
96
+ directory_readers: {
97
+ service_principals_name: [
98
+ "existing-identity",
99
+ "test-bootstrap-identity-id",
100
+ ],
101
+ some_other_field: "keep-me",
102
+ },
103
+ entra_groups: {
104
+ readers: ["reader-group"],
105
+ },
106
+ other_top_level: true,
107
+ });
108
+ });
109
+ it("should append identity to an existing non-empty list", async () => {
110
+ const { authorizationService, gitHubService } = makeEnv();
111
+ const input = makeSampleInput();
112
+ const originalContent = JSON.stringify({
113
+ directory_readers: {
114
+ service_principals_name: ["existing-identity"],
115
+ },
116
+ }, null, 2);
117
+ gitHubService.createBranch.mockResolvedValue(undefined);
118
+ gitHubService.getFileContent.mockResolvedValue({
119
+ content: originalContent,
120
+ sha: "sha-456",
121
+ });
122
+ gitHubService.updateFile.mockResolvedValue(undefined);
123
+ gitHubService.createPullRequest.mockResolvedValue(new PullRequest("https://github.com/pagopa/eng-azure-authorization/pull/43"));
124
+ const result = await authorizationService.requestAuthorization(input);
125
+ expect(result.isOk()).toBe(true);
126
+ const updateCall = gitHubService.updateFile.mock.calls[0][0];
127
+ const updatedParsed = JSON.parse(updateCall.content);
128
+ expect(updatedParsed.directory_readers.service_principals_name).toContain("test-bootstrap-identity-id");
129
+ expect(updatedParsed.directory_readers.service_principals_name).toContain("existing-identity");
130
+ });
74
131
  });
75
132
  describe("error handling", () => {
76
133
  it("should return error when file is not found", async () => {
77
134
  const { authorizationService, gitHubService } = makeEnv();
78
135
  const input = makeSampleInput();
79
136
  gitHubService.createBranch.mockResolvedValue(undefined);
80
- gitHubService.getFileContent.mockRejectedValue(new FileNotFoundError("src/azure-subscriptions/subscriptions/test-subscription/terraform.tfvars"));
137
+ gitHubService.getFileContent.mockRejectedValue(new FileNotFoundError(FILE_PATH));
81
138
  const result = await authorizationService.requestAuthorization(input);
82
139
  expect(result.isErr()).toBe(true);
83
140
  const error = result._unsafeUnwrapErr();
84
141
  expect(error.message).toContain("Unable to get");
85
- expect(error.message).toContain("test-subscription/terraform.tfvars");
142
+ expect(error.message).toContain("terraform.tfvars.json");
86
143
  expect(gitHubService.updateFile).not.toHaveBeenCalled();
87
144
  });
88
145
  it("should return error when identity already exists", async () => {
89
146
  const { authorizationService, gitHubService } = makeEnv();
90
147
  const input = makeSampleInput();
91
- const content = `
92
- directory_readers = {
93
- service_principals_name = [
94
- "test-bootstrap-identity-id"
95
- ]
96
- }
97
- `.trim();
148
+ const content = JSON.stringify({
149
+ directory_readers: {
150
+ service_principals_name: ["test-bootstrap-identity-id"],
151
+ },
152
+ }, null, 2);
98
153
  gitHubService.createBranch.mockResolvedValue(undefined);
99
154
  gitHubService.getFileContent.mockResolvedValue({
100
155
  content,
@@ -105,13 +160,25 @@ directory_readers = {
105
160
  expect(result._unsafeUnwrapErr()).toBeInstanceOf(IdentityAlreadyExistsError);
106
161
  expect(gitHubService.updateFile).not.toHaveBeenCalled();
107
162
  });
108
- it("should return error when tfvars format is invalid", async () => {
163
+ it("should return error when file content is not valid JSON", async () => {
164
+ const { authorizationService, gitHubService } = makeEnv();
165
+ const input = makeSampleInput();
166
+ gitHubService.createBranch.mockResolvedValue(undefined);
167
+ gitHubService.getFileContent.mockResolvedValue({
168
+ content: "not valid json {{",
169
+ sha: "sha-123",
170
+ });
171
+ const result = await authorizationService.requestAuthorization(input);
172
+ expect(result.isErr()).toBe(true);
173
+ expect(result._unsafeUnwrapErr()).toBeInstanceOf(InvalidAuthorizationFileFormatError);
174
+ expect(gitHubService.updateFile).not.toHaveBeenCalled();
175
+ });
176
+ it("should return error when JSON is missing expected keys", async () => {
109
177
  const { authorizationService, gitHubService } = makeEnv();
110
178
  const input = makeSampleInput();
111
- const invalidContent = "invalid content without directory_readers";
112
179
  gitHubService.createBranch.mockResolvedValue(undefined);
113
180
  gitHubService.getFileContent.mockResolvedValue({
114
- content: invalidContent,
181
+ content: JSON.stringify({ unexpected_key: {} }),
115
182
  sha: "sha-123",
116
183
  });
117
184
  const result = await authorizationService.requestAuthorization(input);
@@ -132,11 +199,7 @@ directory_readers = {
132
199
  it("should return error when file update fails", async () => {
133
200
  const { authorizationService, gitHubService } = makeEnv();
134
201
  const input = makeSampleInput();
135
- const content = `
136
- directory_readers = {
137
- service_principals_name = []
138
- }
139
- `.trim();
202
+ const content = JSON.stringify({ directory_readers: { service_principals_name: [] } }, null, 2);
140
203
  gitHubService.createBranch.mockResolvedValue(undefined);
141
204
  gitHubService.getFileContent.mockResolvedValue({
142
205
  content,
@@ -151,11 +214,7 @@ directory_readers = {
151
214
  it("should return error when PR creation fails", async () => {
152
215
  const { authorizationService, gitHubService } = makeEnv();
153
216
  const input = makeSampleInput();
154
- const content = `
155
- directory_readers = {
156
- service_principals_name = []
157
- }
158
- `.trim();
217
+ const content = JSON.stringify({ directory_readers: { service_principals_name: [] } }, null, 2);
159
218
  gitHubService.createBranch.mockResolvedValue(undefined);
160
219
  gitHubService.getFileContent.mockResolvedValue({
161
220
  content,
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Implements the AuthorizationService interface for the PagoPA Azure
5
5
  * authorization workflow. Encapsulates all platform-specific details:
6
- * the target GitHub repository, file paths, branch naming, HCL file
6
+ * the target GitHub repository, file paths, branch naming, JSON file
7
7
  * parsing, and pull request creation.
8
8
  */
9
9
  import { AuthorizationService } from "../../domain/authorization.js";
@@ -3,29 +3,49 @@
3
3
  *
4
4
  * Implements the AuthorizationService interface for the PagoPA Azure
5
5
  * authorization workflow. Encapsulates all platform-specific details:
6
- * the target GitHub repository, file paths, branch naming, HCL file
6
+ * the target GitHub repository, file paths, branch naming, JSON file
7
7
  * parsing, and pull request creation.
8
8
  */
9
9
  import { getLogger } from "@logtape/logtape";
10
10
  import { err, errAsync, ok, okAsync, ResultAsync } from "neverthrow";
11
+ import { z } from "zod";
11
12
  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]*?})/;
13
+ const authorizationFileSchema = z
14
+ .object({
15
+ directory_readers: z
16
+ .object({
17
+ service_principals_name: z.array(z.string()),
18
+ })
19
+ .loose(),
20
+ })
21
+ .loose();
14
22
  const addIdentity = (content, identityId) => {
15
- const match = content.match(DIRECTORY_READERS_REGEX);
16
- if (!match) {
23
+ let parsed;
24
+ try {
25
+ parsed = JSON.parse(content);
26
+ }
27
+ catch {
28
+ return err(new InvalidAuthorizationFileFormatError("File content is not valid JSON"));
29
+ }
30
+ const result = authorizationFileSchema.safeParse(parsed);
31
+ if (!result.success) {
17
32
  return err(new InvalidAuthorizationFileFormatError("Could not find directory_readers.service_principals_name list"));
18
33
  }
19
- const [, prefix, existingItems, suffix] = match;
20
- if (existingItems.includes(`"${identityId}"`)) {
34
+ const jsonContent = result.data;
35
+ if (jsonContent.directory_readers.service_principals_name.includes(identityId)) {
21
36
  return err(new IdentityAlreadyExistsError(identityId));
22
37
  }
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}`));
38
+ const updated = {
39
+ ...jsonContent,
40
+ directory_readers: {
41
+ ...jsonContent.directory_readers,
42
+ service_principals_name: [
43
+ ...jsonContent.directory_readers.service_principals_name,
44
+ identityId,
45
+ ],
46
+ },
47
+ };
48
+ return ok(JSON.stringify(updated, null, 2));
29
49
  };
30
50
  const REPO_OWNER = "pagopa";
31
51
  const REPO_NAME = "eng-azure-authorization";
@@ -34,7 +54,7 @@ export const makeAuthorizationService = (gitHubService) => ({
34
54
  requestAuthorization(input) {
35
55
  const logger = getLogger(["dx-cli", "pagopa-authorization"]);
36
56
  const { bootstrapIdentityId, repoName, subscriptionName } = input;
37
- const filePath = `src/azure-subscriptions/subscriptions/${subscriptionName}/terraform.tfvars`;
57
+ const filePath = `src/azure-subscriptions/subscriptions/${subscriptionName}/terraform.tfvars.json`;
38
58
  const branchName = `feats/add-${repoName}-${subscriptionName}-bootstrap-identity`;
39
59
  return (
40
60
  // Step 1: Create branch first to avoid race condition with main branch updates
@@ -2,14 +2,14 @@ import { getLogger } from "@logtape/logtape";
2
2
  import * as path from "node:path";
3
3
  import { formatTerraformCode } from "../../../terraform/fmt.js";
4
4
  import { payloadSchema } from "./prompts.js";
5
- const addModule = (env, templatesPath) => {
5
+ const addModule = (env, templatesPath, init = false) => {
6
6
  const cloudAccountsByCsp = Object.groupBy(env.cloudAccounts, (account) => account.csp);
7
7
  const includesProdIO = env.cloudAccounts.some((account) => account.displayName === "PROD-IO");
8
8
  const cwd = process.cwd();
9
9
  return (name, terraformBackendKey) => [
10
10
  {
11
11
  base: templatesPath,
12
- data: { cloudAccountsByCsp, includesProdIO },
12
+ data: { cloudAccountsByCsp, includesProdIO, init },
13
13
  destination: path.join(cwd, "infra"),
14
14
  force: true,
15
15
  templateFiles: path.join(templatesPath, name),
@@ -19,7 +19,7 @@ const addModule = (env, templatesPath) => {
19
19
  },
20
20
  {
21
21
  base: path.join(templatesPath, "shared"),
22
- data: { cloudAccountsByCsp, terraformBackendKey },
22
+ data: { cloudAccountsByCsp, init, terraformBackendKey },
23
23
  destination: path.join(cwd, "infra", name, "{{env.name}}"),
24
24
  force: true,
25
25
  templateFiles: path.join(templatesPath, "shared"),
@@ -34,7 +34,7 @@ export default function getActions(templatesPath) {
34
34
  const logger = getLogger(["gen", "env"]);
35
35
  logger.debug("payload {payload}", { payload });
36
36
  const { env, github, init } = payloadSchema.parse(payload);
37
- const addEnvironmentModule = addModule(env, templatesPath);
37
+ const addEnvironmentModule = addModule(env, templatesPath, !!init);
38
38
  const actions = [
39
39
  {
40
40
  type: "getTerraformBackend",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pagopa/dx-cli",
3
- "version": "0.19.1",
3
+ "version": "0.19.3",
4
4
  "type": "module",
5
5
  "description": "A CLI useful to manage DX tools.",
6
6
  "repository": {
@@ -28,14 +28,14 @@
28
28
  "@azure/arm-resources": "^7.0.0",
29
29
  "@azure/arm-resources-subscriptions": "^2.1.0",
30
30
  "@azure/arm-storage": "^19.1.0",
31
- "@azure/identity": "^4.13.0",
32
- "@azure/keyvault-secrets": "^4.9.0",
33
- "@azure/storage-blob": "^12.29.1",
31
+ "@azure/identity": "^4.13.1",
32
+ "@azure/keyvault-secrets": "^4.11.1",
33
+ "@azure/storage-blob": "^12.31.0",
34
34
  "@logtape/logtape": "^1.3.7",
35
35
  "@microsoft/microsoft-graph-client": "^3.0.7",
36
36
  "chalk": "^5.6.2",
37
37
  "commander": "^14.0.3",
38
- "core-js": "^3.48.0",
38
+ "core-js": "^3.49.0",
39
39
  "execa": "^9.6.1",
40
40
  "glob": "^11.1.0",
41
41
  "inquirer": "^9.3.8",
@@ -45,24 +45,24 @@
45
45
  "ora": "^9.3.0",
46
46
  "replace-in-file": "^8.4.0",
47
47
  "semver": "^7.7.4",
48
- "yaml": "^2.8.2",
48
+ "yaml": "^2.8.3",
49
49
  "zod": "^4.3.6",
50
- "@pagopa/dx-savemoney": "^0.2.3"
50
+ "@pagopa/dx-savemoney": "^0.2.4"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@tsconfig/node24": "24.0.4",
54
54
  "@types/inquirer": "^9.0.9",
55
- "@types/node": "^22.19.15",
55
+ "@types/node": "^22.19.17",
56
56
  "@types/semver": "^7.7.1",
57
57
  "@vitest/coverage-v8": "^3.2.4",
58
- "eslint": "^10.1.0",
59
- "memfs": "^4.51.1",
58
+ "eslint": "^10.2.0",
59
+ "memfs": "^4.57.1",
60
60
  "plop": "^4.0.5",
61
- "prettier": "3.8.1",
61
+ "prettier": "3.8.3",
62
62
  "typescript": "~5.9.3",
63
63
  "vitest": "^3.2.4",
64
- "vitest-mock-extended": "^3.1.0",
65
- "@pagopa/eslint-config": "^6.0.2"
64
+ "vitest-mock-extended": "^3.1.1",
65
+ "@pagopa/eslint-config": "^6.0.3"
66
66
  },
67
67
  "engines": {
68
68
  "node": ">=22.0.0"
@@ -120,6 +120,32 @@ module "azure-{{displayName}}_bootstrap" {
120
120
 
121
121
  tags = local.bootstrapper_tags
122
122
  }
123
+
124
+ {{#if @root.init}}
125
+ resource "azurerm_role_assignment" "infra_cd_user_access_admin_common_rg_{{displayName}}" {
126
+ provider = azurerm.{{displayName}}
127
+
128
+ scope = module.azure-{{displayName}}_core_values.common_resource_group_id
129
+ role_definition_name = "User Access Administrator"
130
+ principal_id = module.azure-{{displayName}}_bootstrap.identities.infra.cd.principal_id
131
+ }
132
+
133
+ resource "azurerm_role_assignment" "infra_cd_kv_secrets_officer_common_{{displayName}}" {
134
+ provider = azurerm.{{displayName}}
135
+
136
+ scope = module.azure-{{displayName}}_core_values.common_key_vault.id
137
+ role_definition_name = "Key Vault Secrets Officer"
138
+ principal_id = module.azure-{{displayName}}_bootstrap.identities.infra.cd.principal_id
139
+ }
140
+
141
+ resource "azurerm_role_assignment" "infra_ci_kv_secrets_user_common_{{displayName}}" {
142
+ provider = azurerm.{{displayName}}
143
+
144
+ scope = module.azure-{{displayName}}_core_values.common_key_vault.id
145
+ role_definition_name = "Key Vault Secrets User"
146
+ principal_id = module.azure-{{displayName}}_bootstrap.identities.infra.ci.principal_id
147
+ }
148
+ {{/if}}
123
149
  {{/if}}
124
150
  {{/each}}
125
151