@pagopa/dx-cli 0.15.2 → 0.15.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.
Files changed (195) hide show
  1. package/bin/index.js +8 -1280
  2. package/dist/adapters/azure/__tests__/cloud-account-repository.test.d.ts +1 -0
  3. package/dist/adapters/azure/__tests__/cloud-account-repository.test.js +95 -0
  4. package/dist/adapters/azure/__tests__/cloud-account-service.test.d.ts +1 -0
  5. package/dist/adapters/azure/__tests__/cloud-account-service.test.js +95 -0
  6. package/dist/adapters/azure/cloud-account-repository.d.ts +12 -0
  7. package/dist/adapters/azure/cloud-account-repository.js +23 -0
  8. package/dist/adapters/azure/cloud-account-service.d.ts +22 -0
  9. package/dist/adapters/azure/cloud-account-service.js +255 -0
  10. package/dist/adapters/azure/locations.d.ts +7 -0
  11. package/dist/adapters/azure/locations.js +21 -0
  12. package/dist/adapters/codemods/__tests__/registry.test.d.ts +1 -0
  13. package/dist/adapters/codemods/__tests__/registry.test.js +59 -0
  14. package/dist/adapters/codemods/__tests__/use-azure-appsvc.test.d.ts +1 -0
  15. package/dist/adapters/codemods/__tests__/use-azure-appsvc.test.js +77 -0
  16. package/dist/adapters/codemods/__tests__/use-pnpm.test.d.ts +1 -0
  17. package/dist/adapters/codemods/__tests__/use-pnpm.test.js +148 -0
  18. package/dist/adapters/codemods/git.d.ts +2 -0
  19. package/dist/adapters/codemods/git.js +18 -0
  20. package/dist/adapters/codemods/index.d.ts +3 -0
  21. package/dist/adapters/codemods/index.js +9 -0
  22. package/dist/adapters/codemods/registry.d.ts +8 -0
  23. package/dist/adapters/codemods/registry.js +16 -0
  24. package/dist/adapters/codemods/update-code-review.d.ts +3 -0
  25. package/dist/adapters/codemods/update-code-review.js +60 -0
  26. package/dist/adapters/codemods/use-azure-appsvc.d.ts +3 -0
  27. package/dist/adapters/codemods/use-azure-appsvc.js +84 -0
  28. package/dist/adapters/codemods/use-pnpm.d.ts +22 -0
  29. package/dist/adapters/codemods/use-pnpm.js +214 -0
  30. package/dist/adapters/codemods/yaml.d.ts +2 -0
  31. package/dist/adapters/codemods/yaml.js +8 -0
  32. package/dist/adapters/commander/commands/codemod.d.ts +8 -0
  33. package/dist/adapters/commander/commands/codemod.js +22 -0
  34. package/dist/adapters/commander/commands/doctor.d.ts +4 -0
  35. package/dist/adapters/commander/commands/doctor.js +12 -0
  36. package/dist/adapters/commander/commands/info.d.ts +3 -0
  37. package/dist/adapters/commander/commands/info.js +9 -0
  38. package/dist/adapters/commander/commands/init.d.ts +7 -0
  39. package/dist/adapters/commander/commands/init.js +126 -0
  40. package/dist/adapters/commander/commands/savemoney.d.ts +2 -0
  41. package/dist/adapters/commander/commands/savemoney.js +26 -0
  42. package/dist/adapters/commander/index.d.ts +7 -0
  43. package/dist/adapters/commander/index.js +19 -0
  44. package/dist/adapters/execa/terraform.d.ts +27 -0
  45. package/dist/adapters/execa/terraform.js +28 -0
  46. package/dist/adapters/github/__tests__/github-repo.spec.d.ts +1 -0
  47. package/dist/adapters/github/__tests__/github-repo.spec.js +67 -0
  48. package/dist/adapters/github/github-repo.d.ts +2 -0
  49. package/dist/adapters/github/github-repo.js +31 -0
  50. package/dist/adapters/logtape/validation-reporter.d.ts +2 -0
  51. package/dist/adapters/logtape/validation-reporter.js +14 -0
  52. package/dist/adapters/node/__tests__/data.d.ts +18 -0
  53. package/dist/adapters/node/__tests__/data.js +22 -0
  54. package/dist/adapters/node/__tests__/package-json.test.d.ts +1 -0
  55. package/dist/adapters/node/__tests__/package-json.test.js +86 -0
  56. package/dist/adapters/node/__tests__/repository.test.d.ts +1 -0
  57. package/dist/adapters/node/__tests__/repository.test.js +77 -0
  58. package/dist/adapters/node/fs/__tests__/file-reader.test.d.ts +1 -0
  59. package/dist/adapters/node/fs/__tests__/file-reader.test.js +80 -0
  60. package/dist/adapters/node/fs/file-reader.d.ts +24 -0
  61. package/dist/adapters/node/fs/file-reader.js +26 -0
  62. package/dist/adapters/node/json/__tests__/index.test.d.ts +1 -0
  63. package/dist/adapters/node/json/__tests__/index.test.js +14 -0
  64. package/dist/adapters/node/json/index.d.ts +2 -0
  65. package/dist/adapters/node/json/index.js +2 -0
  66. package/dist/adapters/node/package-json.d.ts +2 -0
  67. package/dist/adapters/node/package-json.js +22 -0
  68. package/dist/adapters/node/release.d.ts +2 -0
  69. package/dist/adapters/node/release.js +33 -0
  70. package/dist/adapters/node/repository.d.ts +2 -0
  71. package/dist/adapters/node/repository.js +47 -0
  72. package/dist/adapters/octokit/__tests__/index.test.d.ts +1 -0
  73. package/dist/adapters/octokit/__tests__/index.test.js +197 -0
  74. package/dist/adapters/octokit/index.d.ts +24 -0
  75. package/dist/adapters/octokit/index.js +65 -0
  76. package/dist/adapters/plop/actions/__tests__/init-cloud-accounts.test.d.ts +1 -0
  77. package/dist/adapters/plop/actions/__tests__/init-cloud-accounts.test.js +115 -0
  78. package/dist/adapters/plop/actions/__tests__/provision-terraform-backend.test.d.ts +1 -0
  79. package/dist/adapters/plop/actions/__tests__/provision-terraform-backend.test.js +116 -0
  80. package/dist/adapters/plop/actions/fetch-github-release.d.ts +12 -0
  81. package/dist/adapters/plop/actions/fetch-github-release.js +20 -0
  82. package/dist/adapters/plop/actions/get-node-version.d.ts +2 -0
  83. package/dist/adapters/plop/actions/get-node-version.js +9 -0
  84. package/dist/adapters/plop/actions/get-terraform-backend.d.ts +3 -0
  85. package/dist/adapters/plop/actions/get-terraform-backend.js +17 -0
  86. package/dist/adapters/plop/actions/init-cloud-accounts.d.ts +5 -0
  87. package/dist/adapters/plop/actions/init-cloud-accounts.js +13 -0
  88. package/dist/adapters/plop/actions/provision-terraform-backend.d.ts +10 -0
  89. package/dist/adapters/plop/actions/provision-terraform-backend.js +16 -0
  90. package/dist/adapters/plop/actions/semver.d.ts +19 -0
  91. package/dist/adapters/plop/actions/semver.js +27 -0
  92. package/dist/adapters/plop/actions/setup-pnpm.d.ts +2 -0
  93. package/dist/adapters/plop/actions/setup-pnpm.js +26 -0
  94. package/dist/adapters/plop/generators/environment/__tests__/actions.test.d.ts +2 -0
  95. package/dist/adapters/plop/generators/environment/__tests__/actions.test.js +55 -0
  96. package/dist/adapters/plop/generators/environment/actions.d.ts +2 -0
  97. package/dist/adapters/plop/generators/environment/actions.js +54 -0
  98. package/dist/adapters/plop/generators/environment/index.d.ts +3 -0
  99. package/dist/adapters/plop/generators/environment/index.js +19 -0
  100. package/dist/adapters/plop/generators/environment/prompts.d.ts +66 -0
  101. package/dist/adapters/plop/generators/environment/prompts.js +166 -0
  102. package/dist/adapters/plop/generators/monorepo/actions.d.ts +51 -0
  103. package/dist/adapters/plop/generators/monorepo/actions.js +35 -0
  104. package/dist/adapters/plop/generators/monorepo/index.d.ts +6 -0
  105. package/dist/adapters/plop/generators/monorepo/index.js +17 -0
  106. package/dist/adapters/plop/generators/monorepo/prompts.d.ts +10 -0
  107. package/dist/adapters/plop/generators/monorepo/prompts.js +31 -0
  108. package/dist/adapters/plop/helpers/__tests__/resource-prefix.test.d.ts +1 -0
  109. package/dist/adapters/plop/helpers/__tests__/resource-prefix.test.js +113 -0
  110. package/dist/adapters/plop/helpers/env-short.d.ts +3 -0
  111. package/dist/adapters/plop/helpers/env-short.js +9 -0
  112. package/dist/adapters/plop/helpers/resource-prefix.d.ts +5 -0
  113. package/dist/adapters/plop/helpers/resource-prefix.js +18 -0
  114. package/dist/adapters/plop/index.d.ts +8 -0
  115. package/dist/adapters/plop/index.js +24 -0
  116. package/dist/adapters/terraform/fmt.d.ts +1 -0
  117. package/dist/adapters/terraform/fmt.js +17 -0
  118. package/dist/adapters/yaml/__tests__/index.test.d.ts +1 -0
  119. package/dist/adapters/yaml/__tests__/index.test.js +53 -0
  120. package/dist/adapters/yaml/index.d.ts +8 -0
  121. package/dist/adapters/yaml/index.js +9 -0
  122. package/dist/adapters/zod/index.d.ts +23 -0
  123. package/dist/adapters/zod/index.js +22 -0
  124. package/dist/config.d.ts +6 -0
  125. package/dist/config.js +5 -0
  126. package/dist/domain/__tests__/data.d.ts +17 -0
  127. package/dist/domain/__tests__/data.js +27 -0
  128. package/dist/domain/__tests__/environment.test.d.ts +1 -0
  129. package/dist/domain/__tests__/environment.test.js +282 -0
  130. package/dist/domain/__tests__/info.test.d.ts +1 -0
  131. package/dist/domain/__tests__/info.test.js +77 -0
  132. package/dist/domain/__tests__/package-json.test.d.ts +1 -0
  133. package/dist/domain/__tests__/package-json.test.js +39 -0
  134. package/dist/domain/__tests__/repository.test.d.ts +1 -0
  135. package/dist/domain/__tests__/repository.test.js +101 -0
  136. package/dist/domain/__tests__/workspace.test.d.ts +1 -0
  137. package/dist/domain/__tests__/workspace.test.js +57 -0
  138. package/dist/domain/cloud-account.d.ts +28 -0
  139. package/dist/domain/cloud-account.js +12 -0
  140. package/dist/domain/codemod.d.ts +11 -0
  141. package/dist/domain/codemod.js +1 -0
  142. package/dist/domain/dependencies.d.ts +10 -0
  143. package/dist/domain/dependencies.js +1 -0
  144. package/dist/domain/doctor.d.ts +10 -0
  145. package/dist/domain/doctor.js +50 -0
  146. package/dist/domain/environment.d.ts +40 -0
  147. package/dist/domain/environment.js +57 -0
  148. package/dist/domain/github-repo.d.ts +6 -0
  149. package/dist/domain/github-repo.js +8 -0
  150. package/dist/domain/github.d.ts +37 -0
  151. package/dist/domain/github.js +29 -0
  152. package/dist/domain/info.d.ts +11 -0
  153. package/dist/domain/info.js +52 -0
  154. package/dist/domain/package-json.d.ts +42 -0
  155. package/dist/domain/package-json.js +69 -0
  156. package/dist/domain/remote-backend.d.ts +8 -0
  157. package/dist/domain/remote-backend.js +9 -0
  158. package/dist/domain/repository.d.ts +13 -0
  159. package/dist/domain/repository.js +72 -0
  160. package/dist/domain/validation.d.ts +16 -0
  161. package/dist/domain/validation.js +1 -0
  162. package/dist/domain/workspace.d.ts +9 -0
  163. package/dist/domain/workspace.js +32 -0
  164. package/dist/index.d.ts +2 -0
  165. package/dist/index.js +35 -0
  166. package/dist/use-cases/__tests__/apply-codemod.test.d.ts +1 -0
  167. package/dist/use-cases/__tests__/apply-codemod.test.js +73 -0
  168. package/dist/use-cases/__tests__/list-codemods.test.d.ts +1 -0
  169. package/dist/use-cases/__tests__/list-codemods.test.js +38 -0
  170. package/dist/use-cases/apply-codemod.d.ts +5 -0
  171. package/dist/use-cases/apply-codemod.js +14 -0
  172. package/dist/use-cases/list-codemods.d.ts +4 -0
  173. package/dist/use-cases/list-codemods.js +1 -0
  174. package/package.json +19 -8
  175. package/templates/environment/bootstrapper/{{env.name}}/data.tf.hbs +13 -0
  176. package/templates/environment/bootstrapper/{{env.name}}/main.tf.hbs +70 -0
  177. package/templates/environment/bootstrapper/{{env.name}}/providers.tf.hbs +26 -0
  178. package/templates/environment/core/{{env.name}}/main.tf.hbs +21 -0
  179. package/templates/environment/core/{{env.name}}/outputs.tf.hbs +8 -0
  180. package/templates/environment/core/{{env.name}}/providers.tf.hbs +21 -0
  181. package/templates/environment/shared/backend.tf.hbs +14 -0
  182. package/templates/environment/shared/locals.tf.hbs +26 -0
  183. package/templates/monorepo/.editorconfig +8 -0
  184. package/templates/monorepo/.node-version.hbs +1 -0
  185. package/templates/monorepo/.pre-commit-config.yaml.hbs +34 -0
  186. package/templates/monorepo/.prettierignore +5 -0
  187. package/templates/monorepo/.terraform-version.hbs +1 -0
  188. package/templates/monorepo/.trivyignore +16 -0
  189. package/templates/monorepo/README.md.hbs +163 -0
  190. package/templates/monorepo/infra/repository/main.tf.hbs +11 -0
  191. package/templates/monorepo/infra/repository/outputs.tf.hbs +11 -0
  192. package/templates/monorepo/infra/repository/providers.tf.hbs +13 -0
  193. package/templates/monorepo/package.json.hbs +7 -0
  194. package/templates/monorepo/pnpm-workspace.yaml +7 -0
  195. package/templates/monorepo/turbo.json +29 -0
@@ -0,0 +1,95 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { AzureSubscriptionRepository } from "../cloud-account-repository.js";
3
+ const createMockSubscription = (overrides = {}) => ({
4
+ displayName: "Test Subscription",
5
+ state: "Enabled",
6
+ subscriptionId: "00000000-0000-0000-0000-000000000001",
7
+ ...overrides,
8
+ });
9
+ const createMockSubscriptionClient = (subscriptions) => {
10
+ const listIterator = async function* () {
11
+ for (const sub of subscriptions) {
12
+ yield sub;
13
+ }
14
+ };
15
+ return {
16
+ subscriptions: {
17
+ list: listIterator,
18
+ },
19
+ };
20
+ };
21
+ vi.mock("@azure/arm-resources-subscriptions", () => ({
22
+ SubscriptionClient: vi.fn(),
23
+ }));
24
+ vi.mock("@azure/identity", () => ({
25
+ DefaultAzureCredential: vi.fn(),
26
+ }));
27
+ import { SubscriptionClient } from "@azure/arm-resources-subscriptions";
28
+ import { DefaultAzureCredential } from "@azure/identity";
29
+ const MockedSubscriptionClient = SubscriptionClient;
30
+ describe("AzureSubscriptionRepository", () => {
31
+ it("should return a list of enabled subscriptions", async () => {
32
+ const subscriptions = [
33
+ createMockSubscription({
34
+ displayName: "Sub 1",
35
+ subscriptionId: "sub-1",
36
+ }),
37
+ createMockSubscription({
38
+ displayName: "Sub 2",
39
+ subscriptionId: "sub-2",
40
+ }),
41
+ ];
42
+ MockedSubscriptionClient.mockImplementation(() => createMockSubscriptionClient(subscriptions));
43
+ const repository = new AzureSubscriptionRepository(new DefaultAzureCredential());
44
+ const result = await repository.list();
45
+ expect(result).toHaveLength(2);
46
+ expect(result[0]).toMatchObject({
47
+ displayName: "Sub 1",
48
+ id: "sub-1",
49
+ });
50
+ expect(result[1]).toMatchObject({
51
+ displayName: "Sub 2",
52
+ id: "sub-2",
53
+ });
54
+ });
55
+ it("should filter out disabled subscriptions", async () => {
56
+ const subscriptions = [
57
+ createMockSubscription({
58
+ displayName: "Enabled Sub",
59
+ state: "Enabled",
60
+ subscriptionId: "enabled-sub",
61
+ }),
62
+ createMockSubscription({
63
+ displayName: "Disabled Sub",
64
+ state: "Disabled",
65
+ subscriptionId: "disabled-sub",
66
+ }),
67
+ ];
68
+ MockedSubscriptionClient.mockImplementation(() => createMockSubscriptionClient(subscriptions));
69
+ const repository = new AzureSubscriptionRepository(new DefaultAzureCredential());
70
+ const result = await repository.list();
71
+ expect(result).toHaveLength(1);
72
+ expect(result[0]).toMatchObject({
73
+ displayName: "Enabled Sub",
74
+ id: "enabled-sub",
75
+ });
76
+ });
77
+ it("should return an empty array when no subscriptions exist", async () => {
78
+ MockedSubscriptionClient.mockImplementation(() => createMockSubscriptionClient([]));
79
+ const repository = new AzureSubscriptionRepository(new DefaultAzureCredential());
80
+ const result = await repository.list();
81
+ expect(result).toEqual([]);
82
+ });
83
+ it("should filter out subscriptions with other states", async () => {
84
+ const subscriptions = [
85
+ createMockSubscription({ state: "Enabled" }),
86
+ createMockSubscription({ state: "Warned" }),
87
+ createMockSubscription({ state: "PastDue" }),
88
+ createMockSubscription({ state: "Deleted" }),
89
+ ];
90
+ MockedSubscriptionClient.mockImplementation(() => createMockSubscriptionClient(subscriptions));
91
+ const repository = new AzureSubscriptionRepository(new DefaultAzureCredential());
92
+ const result = await repository.list();
93
+ expect(result).toHaveLength(1);
94
+ });
95
+ });
@@ -0,0 +1,95 @@
1
+ import { DefaultAzureCredential } from "@azure/identity";
2
+ import { test as baseTest, describe, expect, vi } from "vitest";
3
+ import { AzureCloudAccountService } from "../cloud-account-service.js";
4
+ const { queryResources } = vi.hoisted(() => ({
5
+ queryResources: vi.fn().mockRejectedValue(new Error("Not implemented")),
6
+ }));
7
+ vi.mock("@azure/identity", () => ({
8
+ DefaultAzureCredential: vi.fn(),
9
+ }));
10
+ vi.mock("@azure/arm-resourcegraph", () => ({
11
+ ResourceGraphClient: class {
12
+ resources = queryResources;
13
+ },
14
+ }));
15
+ const test = baseTest.extend({
16
+ // the empty pattern is required by vitest!!!
17
+ // eslint-disable-next-line no-empty-pattern
18
+ cloudAccountService: async ({}, use) => {
19
+ const cloudAccountService = new AzureCloudAccountService(new DefaultAzureCredential());
20
+ await use(cloudAccountService);
21
+ },
22
+ });
23
+ describe("getTerraformBackend", () => {
24
+ test("returns undefined when no matching storage account is found", async ({ cloudAccountService, }) => {
25
+ queryResources.mockResolvedValueOnce({
26
+ data: [],
27
+ totalRecords: 0,
28
+ });
29
+ const result = await cloudAccountService.getTerraformBackend("sub-1", {
30
+ name: "dev",
31
+ prefix: "dx",
32
+ });
33
+ expect(result).toBeUndefined();
34
+ });
35
+ test("return the only matching storage account", async ({ cloudAccountService, }) => {
36
+ queryResources.mockResolvedValueOnce({
37
+ data: [
38
+ {
39
+ location: "italynorth",
40
+ name: "dxditntfstatest01",
41
+ resourceGroup: "dx-d-itn-tfstate-rg-01",
42
+ subscriptionId: "sub-1",
43
+ type: "microsoft.storage/storageaccounts",
44
+ },
45
+ ],
46
+ totalRecords: 1,
47
+ });
48
+ const result = await cloudAccountService.getTerraformBackend("sub-1", {
49
+ name: "dev",
50
+ prefix: "dx",
51
+ });
52
+ expect(result).toEqual(expect.objectContaining({
53
+ resourceGroupName: "dx-d-itn-tfstate-rg-01",
54
+ storageAccountName: "dxditntfstatest01",
55
+ type: "azurerm",
56
+ }));
57
+ });
58
+ test("returns the best matching storage account among multiple", async ({ cloudAccountService, }) => {
59
+ queryResources.mockResolvedValueOnce({
60
+ data: [
61
+ {
62
+ location: "italynorth",
63
+ name: "dxditntfstatest01",
64
+ resourceGroup: "dx-d-itn-tfstate-rg-01",
65
+ subscriptionId: "sub-1",
66
+ type: "microsoft.storage/storageaccounts",
67
+ },
68
+ {
69
+ location: "italynorth",
70
+ name: "dxditntfstatest02",
71
+ resourceGroup: "dx-d-itn-tfstate-rg-01",
72
+ subscriptionId: "sub-1",
73
+ type: "microsoft.storage/storageaccounts",
74
+ },
75
+ {
76
+ location: "westeurope",
77
+ name: "dxdweutfstatest01",
78
+ resourceGroup: "dx-d-weu-tfstate-rg-01",
79
+ subscriptionId: "sub-1",
80
+ type: "microsoft.storage/storageaccounts",
81
+ },
82
+ ],
83
+ totalRecords: 3,
84
+ });
85
+ const result = await cloudAccountService.getTerraformBackend("sub-1", {
86
+ name: "dev",
87
+ prefix: "dx",
88
+ });
89
+ expect(result).toEqual(expect.objectContaining({
90
+ resourceGroupName: "dx-d-itn-tfstate-rg-01",
91
+ storageAccountName: "dxditntfstatest02",
92
+ type: "azurerm",
93
+ }));
94
+ });
95
+ });
@@ -0,0 +1,12 @@
1
+ import type { TokenCredential } from "@azure/identity";
2
+ import { type CloudAccountRepository } from "../../domain/cloud-account.js";
3
+ export declare class AzureSubscriptionRepository implements CloudAccountRepository {
4
+ #private;
5
+ constructor(credential: TokenCredential);
6
+ list(): Promise<{
7
+ csp: "azure";
8
+ defaultLocation: string;
9
+ displayName: string;
10
+ id: string;
11
+ }[]>;
12
+ }
@@ -0,0 +1,23 @@
1
+ import { SubscriptionClient } from "@azure/arm-resources-subscriptions";
2
+ import { z } from "zod/v4";
3
+ import { cloudAccountSchema, } from "../../domain/cloud-account.js";
4
+ import { defaultLocation } from "./locations.js";
5
+ export class AzureSubscriptionRepository {
6
+ #subscriptionClient;
7
+ constructor(credential) {
8
+ this.#subscriptionClient = new SubscriptionClient(credential);
9
+ }
10
+ async list() {
11
+ const subscriptions = [];
12
+ for await (const subscription of this.#subscriptionClient.subscriptions.list()) {
13
+ if (subscription.state === "Enabled") {
14
+ subscriptions.push({
15
+ defaultLocation,
16
+ displayName: subscription.displayName,
17
+ id: subscription.subscriptionId,
18
+ });
19
+ }
20
+ }
21
+ return z.array(cloudAccountSchema).parse(subscriptions);
22
+ }
23
+ }
@@ -0,0 +1,22 @@
1
+ import type { TokenCredential } from "@azure/identity";
2
+ import { z } from "zod/v4";
3
+ import { CloudAccount, type CloudAccountService } from "../../domain/cloud-account.js";
4
+ import { type EnvironmentId } from "../../domain/environment.js";
5
+ import { type TerraformBackend } from "../../domain/remote-backend.js";
6
+ export declare const resourceGraphDataSchema: z.ZodObject<{
7
+ location: z.ZodEnum<{
8
+ italynorth: "italynorth";
9
+ westeurope: "westeurope";
10
+ }>;
11
+ name: z.ZodString;
12
+ resourceGroup: z.ZodString;
13
+ }, z.core.$strip>;
14
+ export declare class AzureCloudAccountService implements CloudAccountService {
15
+ #private;
16
+ constructor(credential: TokenCredential);
17
+ getTerraformBackend(cloudAccountId: CloudAccount["id"], { name, prefix }: EnvironmentId): Promise<TerraformBackend | undefined>;
18
+ hasUserPermissionToInitialize(cloudAccountId: CloudAccount["id"]): Promise<boolean>;
19
+ initialize(cloudAccount: CloudAccount, { name, prefix }: EnvironmentId, tags?: Record<string, string>): Promise<void>;
20
+ isInitialized(cloudAccountId: CloudAccount["id"], { name, prefix }: EnvironmentId): Promise<boolean>;
21
+ provisionTerraformBackend(cloudAccount: CloudAccount, { name, prefix }: EnvironmentId, tags?: Record<string, string>): Promise<TerraformBackend>;
22
+ }
@@ -0,0 +1,255 @@
1
+ import { AuthorizationManagementClient } from "@azure/arm-authorization";
2
+ import { ManagedServiceIdentityClient } from "@azure/arm-msi";
3
+ import { ResourceGraphClient } from "@azure/arm-resourcegraph";
4
+ import { ResourceManagementClient } from "@azure/arm-resources";
5
+ import { StorageManagementClient } from "@azure/arm-storage";
6
+ import { BlobServiceClient } from "@azure/storage-blob";
7
+ import { getLogger } from "@logtape/logtape";
8
+ import { Client } from "@microsoft/microsoft-graph-client";
9
+ import * as assert from "node:assert/strict";
10
+ import { z } from "zod/v4";
11
+ import { environmentShort, } from "../../domain/environment.js";
12
+ import { terraformBackendSchema, } from "../../domain/remote-backend.js";
13
+ import { isAzureLocation, locations, locationShort } from "./locations.js";
14
+ // We are only interested in these properties for now;
15
+ // the actual result structure contains the full cloud resource object
16
+ export const resourceGraphDataSchema = z.object({
17
+ location: z.enum(locations),
18
+ name: z.string(),
19
+ resourceGroup: z.string(),
20
+ });
21
+ const graphUserResponseSchema = z.object({
22
+ id: z.string(),
23
+ });
24
+ const graphGroupMembershipItemSchema = z.object({
25
+ id: z.string(),
26
+ });
27
+ const graphGroupMembershipResponseSchema = z.object({
28
+ "@odata.nextLink": z.string().optional(),
29
+ value: z.array(graphGroupMembershipItemSchema),
30
+ });
31
+ export class AzureCloudAccountService {
32
+ #credential;
33
+ #resourceGraphClient;
34
+ constructor(credential) {
35
+ this.#resourceGraphClient = new ResourceGraphClient(credential);
36
+ this.#credential = credential;
37
+ }
38
+ async getTerraformBackend(cloudAccountId, { name, prefix }) {
39
+ const allLocations = Object.values(locationShort).join("|");
40
+ const shortEnv = environmentShort[name];
41
+ // Check if a storage account with the expected name exists
42
+ // $prefix + environment short + location + "tfstatest" + suffix (e.g., "dxpitntfstatest01")
43
+ // it can return multiple results (e.g. for different location or instance number)
44
+ const resourceName = `${prefix}${shortEnv}(${allLocations})tfstatest(0[1-9]|[1-9]\\d)`;
45
+ const query = `resources
46
+ | where type == 'microsoft.storage/storageaccounts'
47
+ | where name matches regex @'${resourceName}'
48
+ `;
49
+ const result = await this.#resourceGraphClient.resources({
50
+ query,
51
+ subscriptions: [cloudAccountId],
52
+ });
53
+ if (result.totalRecords === 0) {
54
+ return undefined;
55
+ }
56
+ const storageAccounts = z.array(resourceGraphDataSchema).parse(result.data);
57
+ // on multiple results, rank storage accounts by location priority and instance number
58
+ if (storageAccounts.length > 0) {
59
+ storageAccounts.sort((a, b) => {
60
+ // compare locations priority
61
+ const locationComparison = locations.indexOf(a.location) - locations.indexOf(b.location);
62
+ if (locationComparison === 0) {
63
+ // same location, compare by name (to get the highest instance number)
64
+ return b.name.localeCompare(a.name);
65
+ }
66
+ return locationComparison;
67
+ });
68
+ }
69
+ return terraformBackendSchema.parse({
70
+ resourceGroupName: storageAccounts[0].resourceGroup,
71
+ storageAccountName: storageAccounts[0].name,
72
+ subscriptionId: cloudAccountId,
73
+ type: "azurerm",
74
+ });
75
+ }
76
+ async hasUserPermissionToInitialize(cloudAccountId) {
77
+ try {
78
+ // All principal IDs to check (user + all groups)
79
+ const allPrincipalIds = await this.#getCurrentPrincipalIds();
80
+ // Get role assignments for the subscription
81
+ const authClient = new AuthorizationManagementClient(this.#credential, cloudAccountId);
82
+ const requiredRoles = [
83
+ "8e3af657-a8ff-443c-a75c-2fe8c4bcb635", // Owner
84
+ "ba92f5b4-2d11-453d-a403-e96b0029c9fe", // Storage Blob Data Contributor
85
+ ];
86
+ const scope = `/subscriptions/${cloudAccountId}`;
87
+ // Collect all role definition IDs assigned to the user or their groups
88
+ const assignedRoleDefinitionIds = new Set();
89
+ for await (const assignment of authClient.roleAssignments.listForScope(scope)) {
90
+ // Check if this assignment is for the user or any of their groups
91
+ if (assignment.principalId &&
92
+ allPrincipalIds.has(assignment.principalId)) {
93
+ // Extract role definition ID from the full resource ID
94
+ // Format: /subscriptions/{sub}/providers/Microsoft.Authorization/roleDefinitions/{roleId}
95
+ const roleDefId = assignment.roleDefinitionId?.split("/").pop();
96
+ if (roleDefId) {
97
+ assignedRoleDefinitionIds.add(roleDefId);
98
+ }
99
+ }
100
+ // Short-circuit: stop if all required roles have been found
101
+ if (requiredRoles.every((requiredRole) => assignedRoleDefinitionIds.has(requiredRole))) {
102
+ return true;
103
+ }
104
+ }
105
+ // Check if all required roles are present
106
+ const hasAllRoles = requiredRoles.every((requiredRole) => assignedRoleDefinitionIds.has(requiredRole));
107
+ return hasAllRoles;
108
+ }
109
+ catch (error) {
110
+ // Handle authorization errors (403 Forbidden) - user lacks permissions
111
+ if (error &&
112
+ typeof error === "object" &&
113
+ "statusCode" in error &&
114
+ error.statusCode === 403) {
115
+ return false;
116
+ }
117
+ // Re-throw other errors
118
+ throw error;
119
+ }
120
+ }
121
+ async initialize(cloudAccount, { name, prefix }, tags = {}) {
122
+ assert.equal(cloudAccount.csp, "azure", "Cloud account must be Azure");
123
+ assert.ok(isAzureLocation(cloudAccount.defaultLocation), "The default location of the cloud account is not a valid Azure location");
124
+ const logger = getLogger(["gen", "env"]);
125
+ const resourceManagementClient = new ResourceManagementClient(this.#credential, cloudAccount.id);
126
+ const short = {
127
+ env: environmentShort[name],
128
+ location: locationShort[cloudAccount.defaultLocation],
129
+ };
130
+ const resourceGroupName = `${prefix}-${short.env}-${short.location}-bootstrap-rg-01`;
131
+ const parameters = {
132
+ location: cloudAccount.defaultLocation,
133
+ tags: {
134
+ Environment: name,
135
+ ...tags,
136
+ },
137
+ };
138
+ await resourceManagementClient.resourceGroups.createOrUpdate(resourceGroupName, parameters);
139
+ logger.debug("Created resource group {resourceGroupName} in subscription {subscriptionId}", { resourceGroupName, subscriptionId: cloudAccount.id });
140
+ const msiClient = new ManagedServiceIdentityClient(this.#credential, cloudAccount.id);
141
+ const identityName = `${prefix}-${short.env}-${short.location}-bootstrap-id-01`;
142
+ await msiClient.userAssignedIdentities.createOrUpdate(resourceGroupName, identityName, parameters);
143
+ logger.debug("Created identity {identityName} in subscription {subscriptionId}", { identityName, subscriptionId: cloudAccount.id });
144
+ }
145
+ async isInitialized(cloudAccountId, { name, prefix }) {
146
+ const allLocations = Object.values(locationShort).join("|");
147
+ const shortEnv = environmentShort[name];
148
+ const resourceName = `${prefix}-${shortEnv}-(${allLocations})-bootstrap-id-(0[1-9]|[1-9]\\d)`;
149
+ const query = `resources
150
+ | where type == 'microsoft.managedidentity/userassignedidentities'
151
+ | where name matches regex @'${resourceName}'
152
+ `;
153
+ const result = await this.#resourceGraphClient.resources({
154
+ query,
155
+ subscriptions: [cloudAccountId],
156
+ });
157
+ const initialized = result.totalRecords > 0;
158
+ const logger = getLogger(["gen", "env"]);
159
+ logger.debug("subscription {subscriptionId} initialized: {initialized}", {
160
+ initialized,
161
+ subscriptionId: cloudAccountId,
162
+ });
163
+ return initialized;
164
+ }
165
+ async provisionTerraformBackend(cloudAccount, { name, prefix }, tags = {}) {
166
+ assert.equal(cloudAccount.csp, "azure", "Cloud account must be Azure");
167
+ assert.ok(isAzureLocation(cloudAccount.defaultLocation), "The default location of the cloud account is not a valid Azure location");
168
+ const logger = getLogger(["gen", "env"]);
169
+ const resourceManagementClient = new ResourceManagementClient(this.#credential, cloudAccount.id);
170
+ const short = {
171
+ env: environmentShort[name],
172
+ location: locationShort[cloudAccount.defaultLocation],
173
+ };
174
+ const parameters = {
175
+ location: cloudAccount.defaultLocation,
176
+ tags: {
177
+ Environment: name,
178
+ ...tags,
179
+ },
180
+ };
181
+ const resourceGroupName = `${prefix}-${short.env}-${short.location}-tfstate-rg-01`;
182
+ await resourceManagementClient.resourceGroups.createOrUpdate(resourceGroupName, parameters);
183
+ logger.debug("Created resource group {resourceGroupName} in subscription {subscriptionId}", { resourceGroupName, subscriptionId: cloudAccount.id });
184
+ const storageManagementClient = new StorageManagementClient(this.#credential, cloudAccount.id);
185
+ const storageAccount = await storageManagementClient.storageAccounts.beginCreateAndWait(resourceGroupName, `${prefix}${short.env}${short.location}tfstatest01`, {
186
+ kind: "StorageV2",
187
+ sku: {
188
+ name: "Standard_LRS",
189
+ tier: "Standard",
190
+ },
191
+ ...parameters,
192
+ });
193
+ assert.ok(storageAccount.primaryEndpoints?.blob, "Storage account blob endpoint is undefined");
194
+ assert.ok(storageAccount.name, "Storage account name is undefined");
195
+ logger.debug("Created storage account {storageAccountName} in subscription {subscriptionId}", {
196
+ storageAccountName: storageAccount.name,
197
+ subscriptionId: cloudAccount.id,
198
+ });
199
+ const blobServiceClient = new BlobServiceClient(storageAccount.primaryEndpoints?.blob, this.#credential);
200
+ const containerClient = blobServiceClient.getContainerClient("terraform-state");
201
+ try {
202
+ await containerClient.create();
203
+ }
204
+ catch (e) {
205
+ // Cleanup resource group if blob container creation fails
206
+ // resource group deletion also deletes all contained resources
207
+ await resourceManagementClient.resourceGroups.beginDeleteAndWait(resourceGroupName);
208
+ throw new Error(`Error during the creation of the blob container`, {
209
+ cause: e,
210
+ });
211
+ }
212
+ return terraformBackendSchema.parse({
213
+ resourceGroupName,
214
+ storageAccountName: storageAccount.name,
215
+ subscriptionId: cloudAccount.id,
216
+ type: "azurerm",
217
+ });
218
+ }
219
+ async #getCurrentPrincipalIds() {
220
+ // Create Graph client with custom auth provider that fetches fresh tokens
221
+ const graphClient = Client.init({
222
+ authProvider: async (done) => {
223
+ try {
224
+ const tokenResponse = await this.#credential.getToken("https://graph.microsoft.com/.default");
225
+ if (!tokenResponse) {
226
+ done(new Error("Failed to acquire token for Microsoft Graph"), null);
227
+ return;
228
+ }
229
+ done(null, tokenResponse.token);
230
+ }
231
+ catch (error) {
232
+ done(error, null);
233
+ }
234
+ },
235
+ });
236
+ // Get current user's info
237
+ const meResponse = await graphClient.api("/me").get();
238
+ const me = graphUserResponseSchema.parse(meResponse);
239
+ const userObjectId = me.id;
240
+ // Get all group memberships (transitive - includes nested groups)
241
+ const groupIds = [];
242
+ let nextLink = "/me/transitiveMemberOf?$select=id";
243
+ while (nextLink) {
244
+ const rawResponse = await graphClient.api(nextLink).get();
245
+ const response = graphGroupMembershipResponseSchema.parse(rawResponse);
246
+ for (const item of response.value) {
247
+ groupIds.push(item.id);
248
+ }
249
+ nextLink = response["@odata.nextLink"];
250
+ }
251
+ // All principal IDs to check (user + all groups)
252
+ const allPrincipalIds = new Set([userObjectId, ...groupIds]);
253
+ return allPrincipalIds;
254
+ }
255
+ }
@@ -0,0 +1,7 @@
1
+ import type { CloudRegion } from "../../domain/cloud-account.js";
2
+ export declare const locations: readonly ["italynorth", "westeurope"];
3
+ export type AzureLocation = (typeof locations)[number];
4
+ export declare function isAzureLocation(location: string): location is AzureLocation;
5
+ export declare const defaultLocation: "italynorth";
6
+ export declare const locationShort: Record<AzureLocation, string>;
7
+ export declare const cloudRegions: CloudRegion[];
@@ -0,0 +1,21 @@
1
+ // These are the only regions we currently support for DX
2
+ // Order matters! they go from the preferred to the less preferred ones
3
+ export const locations = ["italynorth", "westeurope"];
4
+ export function isAzureLocation(location) {
5
+ return locations.includes(location);
6
+ }
7
+ // The default location is the first one
8
+ export const defaultLocation = locations[0];
9
+ export const locationShort = {
10
+ italynorth: "itn",
11
+ westeurope: "weu",
12
+ };
13
+ const displayName = {
14
+ italynorth: "Italy North",
15
+ westeurope: "West Europe",
16
+ };
17
+ export const cloudRegions = locations.map((l) => ({
18
+ displayName: displayName[l],
19
+ name: l,
20
+ short: locationShort[l],
21
+ }));
@@ -0,0 +1,59 @@
1
+ import { ok } from "neverthrow";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { LocalCodemodRegistry } from "../registry.js";
4
+ describe("LocalCodemodRegistry", () => {
5
+ const makeCodemod = (id, description = id) => ({
6
+ apply: vi.fn().mockResolvedValue(undefined),
7
+ description,
8
+ id,
9
+ });
10
+ it("returns empty list when no codemods are registered", async () => {
11
+ const registry = new LocalCodemodRegistry();
12
+ const result = await registry.getAll();
13
+ expect(result).toStrictEqual(ok([]));
14
+ });
15
+ it("adds codemods and lists them via getAll", async () => {
16
+ const registry = new LocalCodemodRegistry();
17
+ const a = makeCodemod("a", "A");
18
+ const b = makeCodemod("b", "B");
19
+ registry.add(a);
20
+ registry.add(b);
21
+ const result = await registry.getAll();
22
+ expect(result.isOk()).toBe(true);
23
+ if (result.isOk()) {
24
+ expect(result.value).toHaveLength(2);
25
+ expect(result.value).toEqual(expect.arrayContaining([
26
+ expect.objectContaining({ id: "a" }),
27
+ expect.objectContaining({ id: "b" }),
28
+ ]));
29
+ }
30
+ });
31
+ it("retrieves a codemod by id and returns undefined when missing", async () => {
32
+ const registry = new LocalCodemodRegistry();
33
+ const a = makeCodemod("a", "A");
34
+ registry.add(a);
35
+ const found = await registry.getById("a");
36
+ expect(found.isOk()).toBe(true);
37
+ if (found.isOk()) {
38
+ expect(found.value).toBe(a);
39
+ }
40
+ const missing = await registry.getById("nope");
41
+ expect(missing.isOk()).toBe(true);
42
+ if (missing.isOk()) {
43
+ expect(missing.value).toBeUndefined();
44
+ }
45
+ });
46
+ it("overwrites an existing codemod when adding with the same id", async () => {
47
+ const registry = new LocalCodemodRegistry();
48
+ const first = makeCodemod("a", "first");
49
+ const second = makeCodemod("a", "second");
50
+ registry.add(first);
51
+ registry.add(second);
52
+ const byId = await registry.getById("a");
53
+ expect(byId.isOk()).toBe(true);
54
+ if (byId.isOk()) {
55
+ expect(byId.value).toBe(second);
56
+ expect(byId.value).not.toBe(first);
57
+ }
58
+ });
59
+ });