@pagopa/dx-cli 0.20.0 → 0.20.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/azure/__tests__/cloud-account-service.test.js +132 -1
- package/dist/adapters/azure/cloud-account-service.d.ts +3 -2
- package/dist/adapters/azure/cloud-account-service.js +127 -39
- package/dist/adapters/commander/commands/add.d.ts +2 -0
- package/dist/adapters/commander/commands/add.js +3 -3
- package/dist/adapters/octokit/index.d.ts +2 -1
- package/dist/adapters/octokit/index.js +33 -0
- package/dist/adapters/plop/actions/__tests__/init-cloud-accounts.test.js +24 -7
- package/dist/adapters/plop/actions/init-cloud-accounts.d.ts +3 -2
- package/dist/adapters/plop/actions/init-cloud-accounts.js +4 -4
- package/dist/adapters/plop/generators/environment/__tests__/actions.test.js +6 -1
- package/dist/adapters/plop/generators/environment/actions.js +12 -0
- package/dist/adapters/plop/generators/environment/index.d.ts +2 -1
- package/dist/adapters/plop/generators/environment/index.js +2 -2
- package/dist/adapters/plop/index.d.ts +2 -2
- package/dist/adapters/plop/index.js +4 -4
- package/dist/domain/cloud-account.d.ts +3 -2
- package/dist/domain/github.d.ts +13 -0
- package/package.json +3 -1
- package/templates/environment/core/{{env.name}}/main.tf.hbs +1 -3
- package/templates/environment/workflow/_release-terraform-apply-bootstrapper-{{env.name}}.yaml.hbs +19 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { DefaultAzureCredential } from "@azure/identity";
|
|
2
|
-
import { test as baseTest, describe, expect, vi } from "vitest";
|
|
2
|
+
import { test as baseTest, beforeEach, describe, expect, vi } from "vitest";
|
|
3
3
|
import { AzureCloudAccountService } from "../cloud-account-service.js";
|
|
4
4
|
const { queryResources } = vi.hoisted(() => ({
|
|
5
5
|
queryResources: vi.fn().mockRejectedValue(new Error("Not implemented")),
|
|
@@ -10,6 +10,50 @@ const { mockProviderGet, mockProviderRegister } = vi.hoisted(() => ({
|
|
|
10
10
|
.mockResolvedValue({ registrationState: "Registered" }),
|
|
11
11
|
mockProviderRegister: vi.fn().mockResolvedValue({}),
|
|
12
12
|
}));
|
|
13
|
+
const { mockRoleAssignmentsCreate, mockRoleAssignmentsListForScope } = vi.hoisted(() => ({
|
|
14
|
+
mockRoleAssignmentsCreate: vi.fn().mockResolvedValue({}),
|
|
15
|
+
mockRoleAssignmentsListForScope: vi.fn(),
|
|
16
|
+
}));
|
|
17
|
+
const { mockCreateFederatedIdentityCredential, mockCreateIdentity, mockCreateKeyVault, mockCreateResourceGroup, mockDeleteResourceGroup, mockGetSubscription, mockKeyVaultNameAvailability, mockSetSecret, } = vi.hoisted(() => ({
|
|
18
|
+
mockCreateFederatedIdentityCredential: vi.fn().mockResolvedValue({}),
|
|
19
|
+
mockCreateIdentity: vi
|
|
20
|
+
.fn()
|
|
21
|
+
.mockResolvedValue({ clientId: "client-1", principalId: "principal-1" }),
|
|
22
|
+
mockCreateKeyVault: vi.fn().mockResolvedValue({}),
|
|
23
|
+
mockCreateResourceGroup: vi.fn().mockResolvedValue({}),
|
|
24
|
+
mockDeleteResourceGroup: vi.fn().mockResolvedValue({}),
|
|
25
|
+
mockGetSubscription: vi.fn().mockResolvedValue({ tenantId: "tenant-1" }),
|
|
26
|
+
mockKeyVaultNameAvailability: vi.fn().mockResolvedValue({
|
|
27
|
+
nameAvailable: true,
|
|
28
|
+
}),
|
|
29
|
+
mockSetSecret: vi.fn().mockResolvedValue({}),
|
|
30
|
+
}));
|
|
31
|
+
vi.mock("@azure/arm-authorization", () => ({
|
|
32
|
+
AuthorizationManagementClient: class {
|
|
33
|
+
roleAssignments = {
|
|
34
|
+
create: mockRoleAssignmentsCreate,
|
|
35
|
+
listForScope: mockRoleAssignmentsListForScope,
|
|
36
|
+
};
|
|
37
|
+
},
|
|
38
|
+
}));
|
|
39
|
+
vi.mock("@azure/arm-keyvault", () => ({
|
|
40
|
+
KeyVaultManagementClient: class {
|
|
41
|
+
vaults = {
|
|
42
|
+
beginCreateOrUpdateAndWait: mockCreateKeyVault,
|
|
43
|
+
checkNameAvailability: mockKeyVaultNameAvailability,
|
|
44
|
+
};
|
|
45
|
+
},
|
|
46
|
+
}));
|
|
47
|
+
vi.mock("@azure/arm-msi", () => ({
|
|
48
|
+
ManagedServiceIdentityClient: class {
|
|
49
|
+
federatedIdentityCredentials = {
|
|
50
|
+
createOrUpdate: mockCreateFederatedIdentityCredential,
|
|
51
|
+
};
|
|
52
|
+
userAssignedIdentities = {
|
|
53
|
+
createOrUpdate: mockCreateIdentity,
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
}));
|
|
13
57
|
vi.mock("@azure/identity", () => ({
|
|
14
58
|
DefaultAzureCredential: vi.fn(),
|
|
15
59
|
}));
|
|
@@ -24,6 +68,22 @@ vi.mock("@azure/arm-resources", () => ({
|
|
|
24
68
|
get: mockProviderGet,
|
|
25
69
|
register: mockProviderRegister,
|
|
26
70
|
};
|
|
71
|
+
resourceGroups = {
|
|
72
|
+
beginDeleteAndWait: mockDeleteResourceGroup,
|
|
73
|
+
createOrUpdate: mockCreateResourceGroup,
|
|
74
|
+
};
|
|
75
|
+
},
|
|
76
|
+
}));
|
|
77
|
+
vi.mock("@azure/arm-resources-subscriptions", () => ({
|
|
78
|
+
SubscriptionClient: class {
|
|
79
|
+
subscriptions = {
|
|
80
|
+
get: mockGetSubscription,
|
|
81
|
+
};
|
|
82
|
+
},
|
|
83
|
+
}));
|
|
84
|
+
vi.mock("@azure/keyvault-secrets", () => ({
|
|
85
|
+
SecretClient: class {
|
|
86
|
+
setSecret = mockSetSecret;
|
|
27
87
|
},
|
|
28
88
|
}));
|
|
29
89
|
const test = baseTest.extend({
|
|
@@ -34,6 +94,29 @@ const test = baseTest.extend({
|
|
|
34
94
|
await use(cloudAccountService);
|
|
35
95
|
},
|
|
36
96
|
});
|
|
97
|
+
beforeEach(() => {
|
|
98
|
+
queryResources.mockReset();
|
|
99
|
+
queryResources.mockResolvedValue({ data: [], totalRecords: 0 });
|
|
100
|
+
mockCreateFederatedIdentityCredential.mockClear();
|
|
101
|
+
mockCreateIdentity.mockClear();
|
|
102
|
+
mockCreateIdentity.mockResolvedValue({
|
|
103
|
+
clientId: "client-1",
|
|
104
|
+
principalId: "principal-1",
|
|
105
|
+
});
|
|
106
|
+
mockCreateKeyVault.mockClear();
|
|
107
|
+
mockCreateResourceGroup.mockClear();
|
|
108
|
+
mockDeleteResourceGroup.mockClear();
|
|
109
|
+
mockGetSubscription.mockClear();
|
|
110
|
+
mockGetSubscription.mockResolvedValue({ tenantId: "tenant-1" });
|
|
111
|
+
mockKeyVaultNameAvailability.mockClear();
|
|
112
|
+
mockKeyVaultNameAvailability.mockResolvedValue({ nameAvailable: true });
|
|
113
|
+
mockProviderGet.mockClear();
|
|
114
|
+
mockProviderGet.mockResolvedValue({ registrationState: "Registered" });
|
|
115
|
+
mockProviderRegister.mockClear();
|
|
116
|
+
mockRoleAssignmentsCreate.mockClear();
|
|
117
|
+
mockRoleAssignmentsListForScope.mockReset();
|
|
118
|
+
mockSetSecret.mockClear();
|
|
119
|
+
});
|
|
37
120
|
describe("getTerraformBackend", () => {
|
|
38
121
|
test("returns undefined when no matching storage account is found", async ({ cloudAccountService, }) => {
|
|
39
122
|
queryResources.mockResolvedValueOnce({
|
|
@@ -157,3 +240,51 @@ describe("isInitialized", () => {
|
|
|
157
240
|
expect(result).toBe(false);
|
|
158
241
|
});
|
|
159
242
|
});
|
|
243
|
+
describe("initialize", () => {
|
|
244
|
+
test("assigns bootstrap roles to the bootstrap identity", async ({ cloudAccountService, }) => {
|
|
245
|
+
await cloudAccountService.initialize({
|
|
246
|
+
csp: "azure",
|
|
247
|
+
defaultLocation: "italynorth",
|
|
248
|
+
displayName: "Test subscription",
|
|
249
|
+
id: "sub-1",
|
|
250
|
+
}, {
|
|
251
|
+
name: "dev",
|
|
252
|
+
prefix: "dx",
|
|
253
|
+
}, {
|
|
254
|
+
id: "app-id",
|
|
255
|
+
installationId: "installation-id",
|
|
256
|
+
key: "private-key\n",
|
|
257
|
+
}, {
|
|
258
|
+
owner: "pagopa",
|
|
259
|
+
repo: "dx",
|
|
260
|
+
}, {
|
|
261
|
+
createBranch: vi.fn(),
|
|
262
|
+
createOrUpdateEnvironmentSecret: vi.fn().mockResolvedValue(undefined),
|
|
263
|
+
createPullRequest: vi.fn(),
|
|
264
|
+
getFileContent: vi.fn(),
|
|
265
|
+
getRepository: vi.fn(),
|
|
266
|
+
updateFile: vi.fn(),
|
|
267
|
+
});
|
|
268
|
+
expect(mockRoleAssignmentsCreate).toHaveBeenCalledTimes(3);
|
|
269
|
+
expect(mockRoleAssignmentsCreate).toHaveBeenCalledWith("/subscriptions/sub-1", expect.any(String), expect.objectContaining({
|
|
270
|
+
principalId: "principal-1",
|
|
271
|
+
principalType: "ServicePrincipal",
|
|
272
|
+
roleDefinitionId: "/subscriptions/sub-1/providers/Microsoft.Authorization/roleDefinitions/f58310d9-a9f6-439a-9e8d-f62e7b41a168",
|
|
273
|
+
}));
|
|
274
|
+
expect(mockRoleAssignmentsCreate).toHaveBeenCalledWith("/subscriptions/sub-1", expect.any(String), expect.objectContaining({
|
|
275
|
+
principalId: "principal-1",
|
|
276
|
+
principalType: "ServicePrincipal",
|
|
277
|
+
roleDefinitionId: "/subscriptions/sub-1/providers/Microsoft.Authorization/roleDefinitions/b24988ac-6180-42a0-ab88-20f7382dd24c",
|
|
278
|
+
}));
|
|
279
|
+
expect(mockRoleAssignmentsCreate).toHaveBeenCalledWith("/subscriptions/sub-1", expect.any(String), expect.objectContaining({
|
|
280
|
+
principalId: "principal-1",
|
|
281
|
+
principalType: "ServicePrincipal",
|
|
282
|
+
roleDefinitionId: "/subscriptions/sub-1/providers/Microsoft.Authorization/roleDefinitions/ba92f5b4-2d11-453d-a403-e96b0029c9fe",
|
|
283
|
+
}));
|
|
284
|
+
expect(mockCreateFederatedIdentityCredential).toHaveBeenCalledWith("dx-d-itn-common-rg-01", "dx-d-itn-bootstrap-id-01", "bootstrapper-dev-cd", {
|
|
285
|
+
audiences: ["api://AzureADTokenExchange"],
|
|
286
|
+
issuer: "https://token.actions.githubusercontent.com",
|
|
287
|
+
subject: "repo:pagopa/dx:environment:bootstrapper-dev-cd",
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
});
|
|
@@ -2,7 +2,8 @@ import type { TokenCredential } from "@azure/identity";
|
|
|
2
2
|
import { z } from "zod/v4";
|
|
3
3
|
import { CloudAccount, type CloudAccountService } from "../../domain/cloud-account.js";
|
|
4
4
|
import { type EnvironmentId } from "../../domain/environment.js";
|
|
5
|
-
import {
|
|
5
|
+
import { type GitHubRepo } from "../../domain/github-repo.js";
|
|
6
|
+
import { GitHubAppCredentials, type GitHubService } from "../../domain/github.js";
|
|
6
7
|
import { type TerraformBackend } from "../../domain/remote-backend.js";
|
|
7
8
|
export declare const resourceGraphDataSchema: z.ZodObject<{
|
|
8
9
|
location: z.ZodEnum<{
|
|
@@ -17,7 +18,7 @@ export declare class AzureCloudAccountService implements CloudAccountService {
|
|
|
17
18
|
constructor(credential: TokenCredential);
|
|
18
19
|
getTerraformBackend(cloudAccountId: CloudAccount["id"], { name, prefix }: EnvironmentId): Promise<TerraformBackend | undefined>;
|
|
19
20
|
hasUserPermissionToInitialize(cloudAccountId: CloudAccount["id"]): Promise<boolean>;
|
|
20
|
-
initialize(cloudAccount: CloudAccount, { name, prefix }: EnvironmentId, runnerAppCredentials: GitHubAppCredentials, tags?: Record<string, string>): Promise<void>;
|
|
21
|
+
initialize(cloudAccount: CloudAccount, { name, prefix }: EnvironmentId, runnerAppCredentials: GitHubAppCredentials, github: GitHubRepo, gitHubService: GitHubService, tags?: Record<string, string>): Promise<void>;
|
|
21
22
|
isInitialized(cloudAccountId: CloudAccount["id"], { name, prefix }: EnvironmentId): Promise<boolean>;
|
|
22
23
|
provisionTerraformBackend(cloudAccount: CloudAccount, { name, prefix }: EnvironmentId, tags?: Record<string, string>): Promise<TerraformBackend>;
|
|
23
24
|
}
|
|
@@ -10,6 +10,7 @@ import { BlobServiceClient } from "@azure/storage-blob";
|
|
|
10
10
|
import { getLogger } from "@logtape/logtape";
|
|
11
11
|
import { Client } from "@microsoft/microsoft-graph-client";
|
|
12
12
|
import * as assert from "node:assert/strict";
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
13
14
|
import { z } from "zod/v4";
|
|
14
15
|
import { environmentShort, } from "../../domain/environment.js";
|
|
15
16
|
import { terraformBackendSchema, } from "../../domain/remote-backend.js";
|
|
@@ -31,6 +32,19 @@ const graphGroupMembershipResponseSchema = z.object({
|
|
|
31
32
|
"@odata.nextLink": z.string().optional(),
|
|
32
33
|
value: z.array(graphGroupMembershipItemSchema),
|
|
33
34
|
});
|
|
35
|
+
const builtInRoleDefinitionIds = {
|
|
36
|
+
contributor: "b24988ac-6180-42a0-ab88-20f7382dd24c",
|
|
37
|
+
keyVaultSecretsOfficer: "b86a8fe4-44ce-4948-aee5-eccb2c155cd7",
|
|
38
|
+
owner: "8e3af657-a8ff-443c-a75c-2fe8c4bcb635",
|
|
39
|
+
roleBasedAccessControlAdministrator: "f58310d9-a9f6-439a-9e8d-f62e7b41a168",
|
|
40
|
+
storageBlobDataContributor: "ba92f5b4-2d11-453d-a403-e96b0029c9fe",
|
|
41
|
+
};
|
|
42
|
+
const bootstrapIdentityRoleDefinitionIds = [
|
|
43
|
+
// These roles let the bootstrap identity run the bootstrapper module from GitHub Actions without extra manual grants.
|
|
44
|
+
builtInRoleDefinitionIds.roleBasedAccessControlAdministrator,
|
|
45
|
+
builtInRoleDefinitionIds.contributor,
|
|
46
|
+
builtInRoleDefinitionIds.storageBlobDataContributor,
|
|
47
|
+
];
|
|
34
48
|
export class AzureCloudAccountService {
|
|
35
49
|
#credential;
|
|
36
50
|
#requiredResourceProviders = [
|
|
@@ -101,9 +115,9 @@ export class AzureCloudAccountService {
|
|
|
101
115
|
// Get role assignments for the subscription
|
|
102
116
|
const authClient = new AuthorizationManagementClient(this.#credential, cloudAccountId);
|
|
103
117
|
const requiredRoles = [
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
118
|
+
builtInRoleDefinitionIds.owner, // Owner
|
|
119
|
+
builtInRoleDefinitionIds.storageBlobDataContributor, // Storage Blob Data Contributor
|
|
120
|
+
builtInRoleDefinitionIds.keyVaultSecretsOfficer, // Key Vault Secrets Officer
|
|
107
121
|
];
|
|
108
122
|
const scope = `/subscriptions/${cloudAccountId}`;
|
|
109
123
|
// Collect all role definition IDs assigned to the user or their groups
|
|
@@ -140,7 +154,7 @@ export class AzureCloudAccountService {
|
|
|
140
154
|
throw error;
|
|
141
155
|
}
|
|
142
156
|
}
|
|
143
|
-
async initialize(cloudAccount, { name, prefix }, runnerAppCredentials, tags = {}) {
|
|
157
|
+
async initialize(cloudAccount, { name, prefix }, runnerAppCredentials, github, gitHubService, tags = {}) {
|
|
144
158
|
assert.equal(cloudAccount.csp, "azure", "Cloud account must be Azure");
|
|
145
159
|
assert.ok(isAzureLocation(cloudAccount.defaultLocation), "The default location of the cloud account is not a valid Azure location");
|
|
146
160
|
const logger = getLogger(["gen", "env"]);
|
|
@@ -164,46 +178,67 @@ export class AzureCloudAccountService {
|
|
|
164
178
|
try {
|
|
165
179
|
const identityName = `${prefix}-${short.env}-${short.location}-bootstrap-id-01`;
|
|
166
180
|
const msiClient = new ManagedServiceIdentityClient(this.#credential, cloudAccount.id);
|
|
167
|
-
await msiClient.userAssignedIdentities.createOrUpdate(resourceGroupName, identityName, parameters);
|
|
181
|
+
const identity = await msiClient.userAssignedIdentities.createOrUpdate(resourceGroupName, identityName, parameters);
|
|
182
|
+
assert.ok(identity.principalId, "Managed identity principal ID is undefined");
|
|
183
|
+
const identityPrincipalId = identity.principalId;
|
|
184
|
+
assert.ok(identity.clientId, "Managed identity client ID is undefined");
|
|
185
|
+
const identityClientId = identity.clientId;
|
|
168
186
|
logger.debug("Created identity {identityName} in subscription {subscriptionId}", { identityName, subscriptionId: cloudAccount.id });
|
|
169
|
-
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
187
|
+
const authorizationManagementClient = new AuthorizationManagementClient(this.#credential, cloudAccount.id);
|
|
188
|
+
const subscriptionScope = `/subscriptions/${cloudAccount.id}`;
|
|
189
|
+
// Grant the bootstrap identity the Azure permissions it needs to operate autonomously in the bootstrap workflow.
|
|
190
|
+
await Promise.all(bootstrapIdentityRoleDefinitionIds.map((roleDefinitionId) => authorizationManagementClient.roleAssignments.create(subscriptionScope, this.#createRoleAssignmentName(subscriptionScope, identityPrincipalId, roleDefinitionId), {
|
|
191
|
+
principalId: identityPrincipalId,
|
|
192
|
+
principalType: "ServicePrincipal",
|
|
193
|
+
roleDefinitionId: this.#createRoleDefinitionResourceId(cloudAccount.id, roleDefinitionId),
|
|
194
|
+
})));
|
|
195
|
+
logger.debug("Assigned bootstrap roles to identity {identityName} in subscription {subscriptionId}", { identityName, subscriptionId: cloudAccount.id });
|
|
196
|
+
const githubEnvironmentName = `bootstrapper-${name}-cd`;
|
|
197
|
+
// Federate the bootstrap identity with the GitHub environment so workflows can exchange their OIDC token for Azure access.
|
|
198
|
+
await msiClient.federatedIdentityCredentials.createOrUpdate(resourceGroupName, identityName, githubEnvironmentName, {
|
|
199
|
+
audiences: ["api://AzureADTokenExchange"],
|
|
200
|
+
issuer: "https://token.actions.githubusercontent.com",
|
|
201
|
+
subject: `repo:${github.owner}/${github.repo}:environment:${githubEnvironmentName}`,
|
|
179
202
|
});
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
enabledForDiskEncryption: true,
|
|
185
|
-
enablePurgeProtection: secretsProtectionEnabled ? true : undefined,
|
|
186
|
-
enableRbacAuthorization: true,
|
|
187
|
-
sku: {
|
|
188
|
-
family: "A",
|
|
189
|
-
name: "standard",
|
|
190
|
-
},
|
|
191
|
-
softDeleteRetentionInDays: secretsProtectionEnabled ? 14 : 7,
|
|
192
|
-
tenantId: subscription.tenantId,
|
|
193
|
-
},
|
|
194
|
-
tags: {
|
|
195
|
-
Environment: name,
|
|
196
|
-
...tags,
|
|
197
|
-
},
|
|
203
|
+
logger.debug("Configured federated identity credential {credentialName} for identity {identityName} in subscription {subscriptionId}", {
|
|
204
|
+
credentialName: githubEnvironmentName,
|
|
205
|
+
identityName,
|
|
206
|
+
subscriptionId: cloudAccount.id,
|
|
198
207
|
});
|
|
199
|
-
|
|
200
|
-
const secretClient = new SecretClient(`https://${keyVaultName}.vault.azure.net/`, this.#credential);
|
|
208
|
+
// These secrets let the GitHub workflow target the bootstrap identity and subscription without extra setup.
|
|
201
209
|
await Promise.all([
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
210
|
+
gitHubService.createOrUpdateEnvironmentSecret({
|
|
211
|
+
environmentName: githubEnvironmentName,
|
|
212
|
+
owner: github.owner,
|
|
213
|
+
repo: github.repo,
|
|
214
|
+
secretName: "ARM_CLIENT_ID",
|
|
215
|
+
secretValue: identityClientId,
|
|
216
|
+
}),
|
|
217
|
+
gitHubService.createOrUpdateEnvironmentSecret({
|
|
218
|
+
environmentName: githubEnvironmentName,
|
|
219
|
+
owner: github.owner,
|
|
220
|
+
repo: github.repo,
|
|
221
|
+
secretName: "ARM_SUBSCRIPTION_ID",
|
|
222
|
+
secretValue: cloudAccount.id,
|
|
223
|
+
}),
|
|
205
224
|
]);
|
|
206
|
-
logger.debug("
|
|
225
|
+
logger.debug("Set GitHub environment secrets for {environmentName}", {
|
|
226
|
+
environmentName: githubEnvironmentName,
|
|
227
|
+
});
|
|
228
|
+
const keyVaultName = await this.#createCommonKeyVault({
|
|
229
|
+
cloudAccount,
|
|
230
|
+
name,
|
|
231
|
+
prefix,
|
|
232
|
+
resourceGroupName,
|
|
233
|
+
shortEnv: short.env,
|
|
234
|
+
shortLocation: short.location,
|
|
235
|
+
tags,
|
|
236
|
+
});
|
|
237
|
+
await this.#storeRunnerAppSecrets({
|
|
238
|
+
cloudAccountId: cloudAccount.id,
|
|
239
|
+
keyVaultName,
|
|
240
|
+
runnerAppCredentials,
|
|
241
|
+
});
|
|
207
242
|
}
|
|
208
243
|
catch (cause) {
|
|
209
244
|
// Cleanup resource group if initialization fails
|
|
@@ -313,6 +348,49 @@ export class AzureCloudAccountService {
|
|
|
313
348
|
}));
|
|
314
349
|
return results.every(Boolean);
|
|
315
350
|
}
|
|
351
|
+
async #createCommonKeyVault({ cloudAccount, name, prefix, resourceGroupName, shortEnv, shortLocation, tags, }) {
|
|
352
|
+
const logger = getLogger(["gen", "env"]);
|
|
353
|
+
const subscriptionClient = new SubscriptionClient(this.#credential);
|
|
354
|
+
const subscription = await subscriptionClient.subscriptions.get(cloudAccount.id);
|
|
355
|
+
assert.ok(subscription.tenantId, "Subscription tenant ID is undefined");
|
|
356
|
+
const kvClient = new KeyVaultManagementClient(this.#credential, cloudAccount.id);
|
|
357
|
+
const keyVaultName = `${prefix}-${shortEnv}-${shortLocation}-common-kv-01`;
|
|
358
|
+
const secretsProtectionEnabled = shortEnv === "p";
|
|
359
|
+
const result = await kvClient.vaults.checkNameAvailability({
|
|
360
|
+
name: keyVaultName,
|
|
361
|
+
type: "Microsoft.KeyVault/vaults",
|
|
362
|
+
});
|
|
363
|
+
await kvClient.vaults.beginCreateOrUpdateAndWait(resourceGroupName, keyVaultName, {
|
|
364
|
+
location: cloudAccount.defaultLocation,
|
|
365
|
+
properties: {
|
|
366
|
+
createMode: result.nameAvailable ? "default" : "recover",
|
|
367
|
+
enabledForDiskEncryption: true,
|
|
368
|
+
enablePurgeProtection: secretsProtectionEnabled ? true : undefined,
|
|
369
|
+
enableRbacAuthorization: true,
|
|
370
|
+
sku: {
|
|
371
|
+
family: "A",
|
|
372
|
+
name: "standard",
|
|
373
|
+
},
|
|
374
|
+
softDeleteRetentionInDays: secretsProtectionEnabled ? 14 : 7,
|
|
375
|
+
tenantId: subscription.tenantId,
|
|
376
|
+
},
|
|
377
|
+
tags: {
|
|
378
|
+
Environment: name,
|
|
379
|
+
...tags,
|
|
380
|
+
},
|
|
381
|
+
});
|
|
382
|
+
logger.debug("Created key vault {keyVaultName} in subscription {subscriptionId}", { keyVaultName, subscriptionId: cloudAccount.id });
|
|
383
|
+
return keyVaultName;
|
|
384
|
+
}
|
|
385
|
+
#createRoleAssignmentName(scope, principalId, roleDefinitionId) {
|
|
386
|
+
const hash = createHash("sha256")
|
|
387
|
+
.update(`${scope}:${principalId}:${roleDefinitionId}`)
|
|
388
|
+
.digest("hex");
|
|
389
|
+
return `${hash.slice(0, 8)}-${hash.slice(8, 12)}-${hash.slice(12, 16)}-${hash.slice(16, 20)}-${hash.slice(20, 32)}`;
|
|
390
|
+
}
|
|
391
|
+
#createRoleDefinitionResourceId(subscriptionId, roleDefinitionId) {
|
|
392
|
+
return `/subscriptions/${subscriptionId}/providers/Microsoft.Authorization/roleDefinitions/${roleDefinitionId}`;
|
|
393
|
+
}
|
|
316
394
|
async #getCurrentPrincipalIds() {
|
|
317
395
|
// Create Graph client with custom auth provider that fetches fresh tokens
|
|
318
396
|
const graphClient = Client.init({
|
|
@@ -359,4 +437,14 @@ export class AzureCloudAccountService {
|
|
|
359
437
|
}));
|
|
360
438
|
logger.info("All resource providers registered on subscription {subscriptionId}", { subscriptionId });
|
|
361
439
|
}
|
|
440
|
+
async #storeRunnerAppSecrets({ cloudAccountId, keyVaultName, runnerAppCredentials, }) {
|
|
441
|
+
const logger = getLogger(["gen", "env"]);
|
|
442
|
+
const secretClient = new SecretClient(`https://${keyVaultName}.vault.azure.net/`, this.#credential);
|
|
443
|
+
await Promise.all([
|
|
444
|
+
secretClient.setSecret("github-runner-app-id", runnerAppCredentials.id),
|
|
445
|
+
secretClient.setSecret("github-runner-app-installation-id", runnerAppCredentials.installationId),
|
|
446
|
+
secretClient.setSecret("github-runner-app-key", runnerAppCredentials.key.trimEnd()),
|
|
447
|
+
]);
|
|
448
|
+
logger.debug("Created secrets in key vault {keyVaultName} in subscription {subscriptionId}", { keyVaultName, subscriptionId: cloudAccountId });
|
|
449
|
+
}
|
|
362
450
|
}
|
|
@@ -11,11 +11,13 @@ import { Command } from "commander";
|
|
|
11
11
|
import { ResultAsync } from "neverthrow";
|
|
12
12
|
import type { Payload as EnvironmentPayload } from "../../plop/generators/environment/index.js";
|
|
13
13
|
import { AuthorizationResult, AuthorizationService } from "../../../domain/authorization.js";
|
|
14
|
+
import { type GitHubService } from "../../../domain/github.js";
|
|
14
15
|
/**
|
|
15
16
|
* Authorize a Cloud Account (Azure Subscription, AWS Account, ...), creating a Pull Request for each account that requires authorization.
|
|
16
17
|
*/
|
|
17
18
|
export declare const authorizeCloudAccounts: (authorizationService: AuthorizationService) => (envPayload: EnvironmentPayload) => ResultAsync<AuthorizationResult[], never>;
|
|
18
19
|
export type AddCommandDependencies = {
|
|
19
20
|
authorizationService: AuthorizationService;
|
|
21
|
+
gitHubService: GitHubService;
|
|
20
22
|
};
|
|
21
23
|
export declare const makeAddCommand: (deps: AddCommandDependencies) => Command;
|
|
@@ -69,9 +69,9 @@ const displaySummary = (result) => {
|
|
|
69
69
|
}
|
|
70
70
|
console.log(`${step}. Visit ${chalk.underline("https://dx.pagopa.it/getting-started")} to deploy your first project\n`);
|
|
71
71
|
};
|
|
72
|
-
const addEnvironmentAction = (authorizationService) => checkPreconditions()
|
|
72
|
+
const addEnvironmentAction = (authorizationService, gitHubService) => checkPreconditions()
|
|
73
73
|
.andThen(() => ResultAsync.fromPromise(getPlopInstance(), () => new Error("Failed to initialize plop")))
|
|
74
|
-
.andThen((plop) => ResultAsync.fromPromise(runDeploymentEnvironmentGenerator(plop), () => new Error("Failed to run the deployment environment generator")))
|
|
74
|
+
.andThen((plop) => ResultAsync.fromPromise(runDeploymentEnvironmentGenerator(plop, gitHubService), () => new Error("Failed to run the deployment environment generator")))
|
|
75
75
|
.andThen((payload) => authorizeCloudAccounts(authorizationService)(payload).map((authorizationPrs) => ({
|
|
76
76
|
authorizationPrs,
|
|
77
77
|
})));
|
|
@@ -81,7 +81,7 @@ export const makeAddCommand = (deps) => new Command()
|
|
|
81
81
|
.addCommand(new Command("environment")
|
|
82
82
|
.description("Add a new deployment environment")
|
|
83
83
|
.action(async function () {
|
|
84
|
-
const result = await addEnvironmentAction(deps.authorizationService);
|
|
84
|
+
const result = await addEnvironmentAction(deps.authorizationService, deps.gitHubService);
|
|
85
85
|
if (result.isErr()) {
|
|
86
86
|
this.error(result.error.message);
|
|
87
87
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ResultAsync } from "neverthrow";
|
|
2
2
|
import { Octokit } from "octokit";
|
|
3
|
-
import { CreateBranchParams, FileContent, GetFileContentParams, GitHubService, PullRequest, Repository, UpdateFileParams } from "../../domain/github.js";
|
|
3
|
+
import { CreateBranchParams, CreateOrUpdateEnvironmentSecretParams, FileContent, GetFileContentParams, GitHubService, PullRequest, Repository, UpdateFileParams } from "../../domain/github.js";
|
|
4
4
|
type GitHubReleaseParam = {
|
|
5
5
|
client: Octokit;
|
|
6
6
|
owner: string;
|
|
@@ -10,6 +10,7 @@ export declare class OctokitGitHubService implements GitHubService {
|
|
|
10
10
|
#private;
|
|
11
11
|
constructor(octokit: Octokit);
|
|
12
12
|
createBranch(params: CreateBranchParams): Promise<void>;
|
|
13
|
+
createOrUpdateEnvironmentSecret(params: CreateOrUpdateEnvironmentSecretParams): Promise<void>;
|
|
13
14
|
createPullRequest(params: {
|
|
14
15
|
base: string;
|
|
15
16
|
body: string;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { $ } from "execa";
|
|
2
|
+
import sodium from "libsodium-wrappers";
|
|
2
3
|
import { ResultAsync } from "neverthrow";
|
|
3
4
|
import { RequestError } from "octokit";
|
|
4
5
|
import semverParse from "semver/functions/parse.js";
|
|
@@ -31,6 +32,38 @@ export class OctokitGitHubService {
|
|
|
31
32
|
});
|
|
32
33
|
}
|
|
33
34
|
}
|
|
35
|
+
async createOrUpdateEnvironmentSecret(params) {
|
|
36
|
+
try {
|
|
37
|
+
// GitHub requires environment secrets to be encrypted client-side with libsodium.
|
|
38
|
+
await sodium.ready;
|
|
39
|
+
// Ensure the target environment exists before resolving its public key and storing secrets.
|
|
40
|
+
await this.#octokit.request("PUT /repos/{owner}/{repo}/environments/{environment_name}", {
|
|
41
|
+
environment_name: params.environmentName,
|
|
42
|
+
owner: params.owner,
|
|
43
|
+
repo: params.repo,
|
|
44
|
+
});
|
|
45
|
+
const { data: publicKeyData } = await this.#octokit.rest.actions.getEnvironmentPublicKey({
|
|
46
|
+
environment_name: params.environmentName,
|
|
47
|
+
owner: params.owner,
|
|
48
|
+
repo: params.repo,
|
|
49
|
+
});
|
|
50
|
+
const publicKeyBytes = sodium.from_base64(publicKeyData.key, sodium.base64_variants.ORIGINAL);
|
|
51
|
+
const secretBytes = sodium.from_string(params.secretValue);
|
|
52
|
+
const encryptedBytes = sodium.crypto_box_seal(secretBytes, publicKeyBytes);
|
|
53
|
+
const encryptedValue = sodium.to_base64(encryptedBytes, sodium.base64_variants.ORIGINAL);
|
|
54
|
+
await this.#octokit.rest.actions.createOrUpdateEnvironmentSecret({
|
|
55
|
+
encrypted_value: encryptedValue,
|
|
56
|
+
environment_name: params.environmentName,
|
|
57
|
+
key_id: publicKeyData.key_id,
|
|
58
|
+
owner: params.owner,
|
|
59
|
+
repo: params.repo,
|
|
60
|
+
secret_name: params.secretName,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
throw new Error(`Failed to create or update secret ${params.secretName} in environment ${params.environmentName}`, { cause: error });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
34
67
|
async createPullRequest(params) {
|
|
35
68
|
try {
|
|
36
69
|
const { data } = await this.#octokit.rest.pulls.create({
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { initCloudAccounts } from "../init-cloud-accounts.js";
|
|
3
|
+
const createMockGitHubService = () => ({
|
|
4
|
+
createBranch: vi.fn(),
|
|
5
|
+
createOrUpdateEnvironmentSecret: vi.fn().mockResolvedValue(undefined),
|
|
6
|
+
createPullRequest: vi.fn(),
|
|
7
|
+
getFileContent: vi.fn(),
|
|
8
|
+
getRepository: vi.fn(),
|
|
9
|
+
updateFile: vi.fn(),
|
|
10
|
+
});
|
|
3
11
|
const createMockCloudAccountService = (overrides = {}) => ({
|
|
4
12
|
getTerraformBackend: vi.fn().mockResolvedValue(undefined),
|
|
5
13
|
hasUserPermissionToInitialize: vi.fn().mockResolvedValue(true),
|
|
@@ -68,18 +76,24 @@ describe("initCloudAccounts", () => {
|
|
|
68
76
|
},
|
|
69
77
|
},
|
|
70
78
|
});
|
|
71
|
-
await initCloudAccounts(payload, mockService);
|
|
79
|
+
await initCloudAccounts(payload, mockService, createMockGitHubService());
|
|
72
80
|
expect(initializeMock).toHaveBeenCalledTimes(2);
|
|
73
81
|
expect(initializeMock).toHaveBeenCalledWith(cloudAccount1, expect.objectContaining({ name: "prod", prefix: "io" }), {
|
|
74
82
|
id: "test-app-id",
|
|
75
83
|
installationId: "test-installation-id",
|
|
76
84
|
key: "test-private-key",
|
|
77
|
-
}, {
|
|
85
|
+
}, {
|
|
86
|
+
owner: "pagopa",
|
|
87
|
+
repo: "dx",
|
|
88
|
+
}, expect.any(Object), {});
|
|
78
89
|
expect(initializeMock).toHaveBeenCalledWith(cloudAccount2, expect.objectContaining({ name: "prod", prefix: "io" }), {
|
|
79
90
|
id: "test-app-id",
|
|
80
91
|
installationId: "test-installation-id",
|
|
81
92
|
key: "test-private-key",
|
|
82
|
-
}, {
|
|
93
|
+
}, {
|
|
94
|
+
owner: "pagopa",
|
|
95
|
+
repo: "dx",
|
|
96
|
+
}, expect.any(Object), {});
|
|
83
97
|
});
|
|
84
98
|
it("should not call initialize when cloudAccountsToInitialize is empty", async () => {
|
|
85
99
|
const initializeMock = vi.fn().mockResolvedValue(undefined);
|
|
@@ -99,7 +113,7 @@ describe("initCloudAccounts", () => {
|
|
|
99
113
|
},
|
|
100
114
|
},
|
|
101
115
|
});
|
|
102
|
-
await initCloudAccounts(payload, mockService);
|
|
116
|
+
await initCloudAccounts(payload, mockService, createMockGitHubService());
|
|
103
117
|
expect(initializeMock).not.toHaveBeenCalled();
|
|
104
118
|
});
|
|
105
119
|
it("should not call initialize when payload.init is undefined", async () => {
|
|
@@ -110,7 +124,7 @@ describe("initCloudAccounts", () => {
|
|
|
110
124
|
const payload = createMockPayload({
|
|
111
125
|
init: undefined,
|
|
112
126
|
});
|
|
113
|
-
await initCloudAccounts(payload, mockService);
|
|
127
|
+
await initCloudAccounts(payload, mockService, createMockGitHubService());
|
|
114
128
|
expect(initializeMock).not.toHaveBeenCalled();
|
|
115
129
|
});
|
|
116
130
|
it("should use prefix and environment name from payload.env", async () => {
|
|
@@ -137,11 +151,14 @@ describe("initCloudAccounts", () => {
|
|
|
137
151
|
},
|
|
138
152
|
},
|
|
139
153
|
});
|
|
140
|
-
await initCloudAccounts(payload, mockService);
|
|
154
|
+
await initCloudAccounts(payload, mockService, createMockGitHubService());
|
|
141
155
|
expect(initializeMock).toHaveBeenCalledWith(cloudAccount, expect.objectContaining({ name: "uat", prefix: "pagopa" }), {
|
|
142
156
|
id: "test-app-id",
|
|
143
157
|
installationId: "test-installation-id",
|
|
144
158
|
key: "test-private-key",
|
|
145
|
-
}, {
|
|
159
|
+
}, {
|
|
160
|
+
owner: "pagopa",
|
|
161
|
+
repo: "dx",
|
|
162
|
+
}, expect.any(Object), {});
|
|
146
163
|
});
|
|
147
164
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type NodePlopAPI } from "node-plop";
|
|
2
2
|
import { CloudAccountService } from "../../../domain/cloud-account.js";
|
|
3
|
+
import { type GitHubService } from "../../../domain/github.js";
|
|
3
4
|
import { type Payload } from "../generators/environment/prompts.js";
|
|
4
|
-
export declare const initCloudAccounts: (payload: Payload, cloudAccountService: CloudAccountService) => Promise<void>;
|
|
5
|
-
export default function (plop: NodePlopAPI, cloudAccountService: CloudAccountService): void;
|
|
5
|
+
export declare const initCloudAccounts: (payload: Payload, cloudAccountService: CloudAccountService, gitHubService: GitHubService) => Promise<void>;
|
|
6
|
+
export default function (plop: NodePlopAPI, cloudAccountService: CloudAccountService, gitHubService: GitHubService): void;
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { payloadSchema, } from "../generators/environment/prompts.js";
|
|
3
|
-
export const initCloudAccounts = async (payload, cloudAccountService) => {
|
|
3
|
+
export const initCloudAccounts = async (payload, cloudAccountService, gitHubService) => {
|
|
4
4
|
if (payload.init && payload.init.cloudAccountsToInitialize.length > 0) {
|
|
5
5
|
const { runnerAppCredentials } = payload.init;
|
|
6
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)));
|
|
7
|
+
await Promise.all(payload.init.cloudAccountsToInitialize.map((cloudAccount) => cloudAccountService.initialize(cloudAccount, payload.env, runnerAppCredentials, payload.github, gitHubService, payload.tags)));
|
|
8
8
|
}
|
|
9
9
|
};
|
|
10
|
-
export default function (plop, cloudAccountService) {
|
|
10
|
+
export default function (plop, cloudAccountService, gitHubService) {
|
|
11
11
|
plop.setActionType("initCloudAccounts", async (data) => {
|
|
12
12
|
const payload = payloadSchema.parse(data);
|
|
13
|
-
await initCloudAccounts(payload, cloudAccountService);
|
|
13
|
+
await initCloudAccounts(payload, cloudAccountService, gitHubService);
|
|
14
14
|
return "Cloud Accounts Initialized";
|
|
15
15
|
});
|
|
16
16
|
}
|
|
@@ -46,7 +46,12 @@ describe("actions", () => {
|
|
|
46
46
|
payload: getPayload(false),
|
|
47
47
|
},
|
|
48
48
|
])("correct order of actions", ({ payload }) => {
|
|
49
|
-
const actionsOrder = [
|
|
49
|
+
const actionsOrder = [
|
|
50
|
+
"getTerraformBackend",
|
|
51
|
+
"addMany",
|
|
52
|
+
"addMany",
|
|
53
|
+
"addMany",
|
|
54
|
+
];
|
|
50
55
|
if (payload.init) {
|
|
51
56
|
actionsOrder.unshift("initCloudAccounts", "provisionTerraformBackend");
|
|
52
57
|
actionsOrder.push("addMany", "addMany");
|
|
@@ -29,6 +29,17 @@ const addModule = (env, templatesPath, init = false) => {
|
|
|
29
29
|
},
|
|
30
30
|
];
|
|
31
31
|
};
|
|
32
|
+
const addWorkflowModule = (templatesPath) => {
|
|
33
|
+
const cwd = process.cwd();
|
|
34
|
+
return {
|
|
35
|
+
base: path.join(templatesPath, "workflow"),
|
|
36
|
+
destination: path.join(cwd, ".github", "workflows"),
|
|
37
|
+
force: true,
|
|
38
|
+
templateFiles: path.join(templatesPath, "workflow"),
|
|
39
|
+
type: "addMany",
|
|
40
|
+
verbose: true,
|
|
41
|
+
};
|
|
42
|
+
};
|
|
32
43
|
export default function getActions(templatesPath) {
|
|
33
44
|
return (payload) => {
|
|
34
45
|
const logger = getLogger(["gen", "env"]);
|
|
@@ -39,6 +50,7 @@ export default function getActions(templatesPath) {
|
|
|
39
50
|
{
|
|
40
51
|
type: "getTerraformBackend",
|
|
41
52
|
},
|
|
53
|
+
addWorkflowModule(templatesPath),
|
|
42
54
|
...addEnvironmentModule("bootstrapper", `${github.repo}.bootstrapper.${env.name}.tfstate`),
|
|
43
55
|
];
|
|
44
56
|
if (init) {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { type NodePlopAPI } from "node-plop";
|
|
2
2
|
import { CloudAccountRepository, CloudAccountService } from "../../../../domain/cloud-account.js";
|
|
3
3
|
import { GitHubRepo } from "../../../../domain/github-repo.js";
|
|
4
|
+
import { type GitHubService } from "../../../../domain/github.js";
|
|
4
5
|
import { Payload, payloadSchema } from "./prompts.js";
|
|
5
6
|
export declare const PLOP_ENVIRONMENT_GENERATOR_NAME = "DX_DeploymentEnvironment";
|
|
6
7
|
export { Payload, payloadSchema };
|
|
7
|
-
export default function (plop: NodePlopAPI, templatesPath: string, cloudAccountRepository: CloudAccountRepository, cloudAccountService: CloudAccountService, github?: GitHubRepo): void;
|
|
8
|
+
export default function (plop: NodePlopAPI, templatesPath: string, cloudAccountRepository: CloudAccountRepository, cloudAccountService: CloudAccountService, gitHubService: GitHubService, github?: GitHubRepo): void;
|
|
@@ -8,13 +8,13 @@ import getActions from "./actions.js";
|
|
|
8
8
|
import getPrompts, { payloadSchema } from "./prompts.js";
|
|
9
9
|
export const PLOP_ENVIRONMENT_GENERATOR_NAME = "DX_DeploymentEnvironment";
|
|
10
10
|
export { payloadSchema };
|
|
11
|
-
export default function (plop, templatesPath, cloudAccountRepository, cloudAccountService, github) {
|
|
11
|
+
export default function (plop, templatesPath, cloudAccountRepository, cloudAccountService, gitHubService, github) {
|
|
12
12
|
setEnvShortHelper(plop);
|
|
13
13
|
setResourcePrefixHelper(plop);
|
|
14
14
|
setEqHelper(plop);
|
|
15
15
|
setGetTerraformBackend(plop, cloudAccountService);
|
|
16
16
|
setProvisionTerraformBackendAction(plop, cloudAccountService);
|
|
17
|
-
setInitCloudAccountsAction(plop, cloudAccountService);
|
|
17
|
+
setInitCloudAccountsAction(plop, cloudAccountService, gitHubService);
|
|
18
18
|
plop.setGenerator(PLOP_ENVIRONMENT_GENERATOR_NAME, {
|
|
19
19
|
actions: getActions(templatesPath),
|
|
20
20
|
description: "Generate a new deployment environment",
|
|
@@ -14,7 +14,7 @@ export declare const runMonorepoGenerator: (plop: NodePlopAPI, githubService: Gi
|
|
|
14
14
|
* uses the explicitly passed repository. When omitted (by add command),
|
|
15
15
|
* the generator infers it from the local git context.
|
|
16
16
|
*/
|
|
17
|
-
export declare const runDeploymentEnvironmentGenerator: (plop: NodePlopAPI, github?: GitHubRepo) => Promise<EnvironmentPayload>;
|
|
17
|
+
export declare const runDeploymentEnvironmentGenerator: (plop: NodePlopAPI, gitHubService: GitHubService, github?: GitHubRepo) => Promise<EnvironmentPayload>;
|
|
18
18
|
/**
|
|
19
19
|
* Configure the deployment environment generator
|
|
20
20
|
*
|
|
@@ -23,4 +23,4 @@ export declare const runDeploymentEnvironmentGenerator: (plop: NodePlopAPI, gith
|
|
|
23
23
|
* uses the explicitly passed repository. When omitted (by add command),
|
|
24
24
|
* the generator infers it from the local git context.
|
|
25
25
|
*/
|
|
26
|
-
export declare const setDeploymentEnvironmentGenerator: (plop: NodePlopAPI, github?: GitHubRepo) => void;
|
|
26
|
+
export declare const setDeploymentEnvironmentGenerator: (plop: NodePlopAPI, gitHubService: GitHubService, github?: GitHubRepo) => void;
|
|
@@ -62,8 +62,8 @@ export const runMonorepoGenerator = async (plop, githubService) => {
|
|
|
62
62
|
* uses the explicitly passed repository. When omitted (by add command),
|
|
63
63
|
* the generator infers it from the local git context.
|
|
64
64
|
*/
|
|
65
|
-
export const runDeploymentEnvironmentGenerator = async (plop, github) => {
|
|
66
|
-
setDeploymentEnvironmentGenerator(plop, github);
|
|
65
|
+
export const runDeploymentEnvironmentGenerator = async (plop, gitHubService, github) => {
|
|
66
|
+
setDeploymentEnvironmentGenerator(plop, gitHubService, github);
|
|
67
67
|
const generator = plop.getGenerator(PLOP_ENVIRONMENT_GENERATOR_NAME);
|
|
68
68
|
const answers = await generator.runPrompts();
|
|
69
69
|
const payload = environmentPayloadSchema.parse(answers);
|
|
@@ -82,10 +82,10 @@ export const runDeploymentEnvironmentGenerator = async (plop, github) => {
|
|
|
82
82
|
* uses the explicitly passed repository. When omitted (by add command),
|
|
83
83
|
* the generator infers it from the local git context.
|
|
84
84
|
*/
|
|
85
|
-
export const setDeploymentEnvironmentGenerator = (plop, github) => {
|
|
85
|
+
export const setDeploymentEnvironmentGenerator = (plop, gitHubService, github) => {
|
|
86
86
|
const credential = new AzureCliCredential();
|
|
87
87
|
const cloudAccountRepository = new AzureSubscriptionRepository(credential);
|
|
88
88
|
const cloudAccountService = new AzureCloudAccountService(credential);
|
|
89
89
|
const templatesPath = path.join(import.meta.dirname, "../../../templates/environment");
|
|
90
|
-
createDeploymentEnvironmentGenerator(plop, templatesPath, cloudAccountRepository, cloudAccountService, github);
|
|
90
|
+
createDeploymentEnvironmentGenerator(plop, templatesPath, cloudAccountRepository, cloudAccountService, gitHubService, github);
|
|
91
91
|
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from "zod/v4";
|
|
2
2
|
import { type EnvironmentId } from "./environment.js";
|
|
3
|
-
import { type
|
|
3
|
+
import { type GitHubRepo } from "./github-repo.js";
|
|
4
|
+
import { type GitHubAppCredentials, type GitHubService } from "./github.js";
|
|
4
5
|
import { TerraformBackend } from "./remote-backend.js";
|
|
5
6
|
export declare const cloudAccountSchema: z.ZodObject<{
|
|
6
7
|
csp: z.ZodDefault<z.ZodEnum<{
|
|
@@ -17,7 +18,7 @@ export type CloudAccountRepository = {
|
|
|
17
18
|
export type CloudAccountService = {
|
|
18
19
|
getTerraformBackend(cloudAccountId: CloudAccount["id"], environment: EnvironmentId): Promise<TerraformBackend | undefined>;
|
|
19
20
|
hasUserPermissionToInitialize(cloudAccountId: CloudAccount["id"]): Promise<boolean>;
|
|
20
|
-
initialize(cloudAccount: CloudAccount, environment: EnvironmentId, runnerAppCredentials: GitHubAppCredentials, tags?: Record<string, string>): Promise<void>;
|
|
21
|
+
initialize(cloudAccount: CloudAccount, environment: EnvironmentId, runnerAppCredentials: GitHubAppCredentials, github: GitHubRepo, gitHubService: GitHubService, tags?: Record<string, string>): Promise<void>;
|
|
21
22
|
isInitialized(cloudAccountId: CloudAccount["id"], environment: EnvironmentId): Promise<boolean>;
|
|
22
23
|
provisionTerraformBackend(cloudAccount: CloudAccount, environment: EnvironmentId, tags?: Record<string, string>): Promise<TerraformBackend>;
|
|
23
24
|
};
|
package/dist/domain/github.d.ts
CHANGED
|
@@ -5,6 +5,13 @@ export type CreateBranchParams = {
|
|
|
5
5
|
owner: string;
|
|
6
6
|
repo: string;
|
|
7
7
|
};
|
|
8
|
+
export type CreateOrUpdateEnvironmentSecretParams = {
|
|
9
|
+
environmentName: string;
|
|
10
|
+
owner: string;
|
|
11
|
+
repo: string;
|
|
12
|
+
secretName: string;
|
|
13
|
+
secretValue: string;
|
|
14
|
+
};
|
|
8
15
|
export type FileContent = {
|
|
9
16
|
content: string;
|
|
10
17
|
sha: string;
|
|
@@ -21,6 +28,12 @@ export interface GitHubService {
|
|
|
21
28
|
* @throws Error if branch creation fails
|
|
22
29
|
*/
|
|
23
30
|
createBranch(params: CreateBranchParams): Promise<void>;
|
|
31
|
+
/**
|
|
32
|
+
* Creates or updates a secret in a GitHub repository environment.
|
|
33
|
+
* The value is automatically encrypted using the environment's public key.
|
|
34
|
+
* @throws Error if secret creation fails
|
|
35
|
+
*/
|
|
36
|
+
createOrUpdateEnvironmentSecret(params: CreateOrUpdateEnvironmentSecretParams): Promise<void>;
|
|
24
37
|
/**
|
|
25
38
|
* Creates a pull request in a GitHub repository.
|
|
26
39
|
* @throws Error if pull request creation fails
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pagopa/dx-cli",
|
|
3
|
-
"version": "0.20.
|
|
3
|
+
"version": "0.20.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A CLI useful to manage DX tools.",
|
|
6
6
|
"repository": {
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"execa": "^9.6.1",
|
|
40
40
|
"glob": "^11.1.0",
|
|
41
41
|
"inquirer": "^9.3.8",
|
|
42
|
+
"libsodium-wrappers": "^0.8.2",
|
|
42
43
|
"neverthrow": "^8.2.0",
|
|
43
44
|
"node-plop": "^0.32.3",
|
|
44
45
|
"octokit": "^5.0.5",
|
|
@@ -52,6 +53,7 @@
|
|
|
52
53
|
"devDependencies": {
|
|
53
54
|
"@tsconfig/node24": "24.0.4",
|
|
54
55
|
"@types/inquirer": "^9.0.9",
|
|
56
|
+
"@types/libsodium-wrappers": "^0.8.2",
|
|
55
57
|
"@types/node": "^22.19.17",
|
|
56
58
|
"@types/semver": "^7.7.1",
|
|
57
59
|
"@vitest/coverage-v8": "^3.2.4",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
{{#each this}}
|
|
3
3
|
module "azure-{{displayName}}_core" {
|
|
4
4
|
source = "pagopa-dx/azure-core-infra/azurerm"
|
|
5
|
-
version = "~>
|
|
5
|
+
version = "~> 4.0"
|
|
6
6
|
|
|
7
7
|
providers = {
|
|
8
8
|
azurerm = azurerm.{{displayName}}
|
|
@@ -12,8 +12,6 @@ module "azure-{{displayName}}_core" {
|
|
|
12
12
|
app_name = "core"
|
|
13
13
|
})
|
|
14
14
|
|
|
15
|
-
vpn_enabled = true
|
|
16
|
-
|
|
17
15
|
tags = merge(local.tags, {
|
|
18
16
|
Source = "https://github.com/{{@root.github.owner}}/{{@root.github.repo}}/blob/main/infra/core/{{@root.env.name}}"
|
|
19
17
|
})
|
package/templates/environment/workflow/_release-terraform-apply-bootstrapper-{{env.name}}.yaml.hbs
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
name: Release Bootstrapper Infrastructure - {{@root.env.name}}
|
|
2
|
+
on:
|
|
3
|
+
workflow_dispatch:
|
|
4
|
+
push:
|
|
5
|
+
branches:
|
|
6
|
+
- main
|
|
7
|
+
paths:
|
|
8
|
+
- "infra/bootstrapper/**"
|
|
9
|
+
|
|
10
|
+
permissions:
|
|
11
|
+
contents: read
|
|
12
|
+
|
|
13
|
+
jobs:
|
|
14
|
+
release:
|
|
15
|
+
uses: pagopa/dx/.github/workflows/release-terraform-bootstrapper-v1.yaml@main
|
|
16
|
+
name: Release Bootstrapper ({{@root.env.name}})
|
|
17
|
+
secrets: inherit
|
|
18
|
+
with:
|
|
19
|
+
environment: '{{@root.env.name}}'
|