Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .nx/version-plans/version-plan-1781190475359.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@pagopa/dx-cli': patch
---

Ensure `dx add environment` creates missing GitHub environments for non-prod deployments.
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ const {
}),
mockSetSecret: vi.fn().mockResolvedValue({}),
}));
const { mockLookup } = vi.hoisted(() => ({
mockLookup: vi.fn().mockResolvedValue([{ address: "127.0.0.1", family: 4 }]),
}));
const { mockSleep } = vi.hoisted(() => ({
mockSleep: vi.fn().mockResolvedValue(undefined),
}));

vi.mock("@azure/arm-authorization", () => ({
AuthorizationManagementClient: class {
Expand Down Expand Up @@ -111,6 +117,12 @@ vi.mock("@azure/keyvault-secrets", () => ({
setSecret = mockSetSecret;
},
}));
vi.mock("node:dns/promises", () => ({
lookup: mockLookup,
}));
vi.mock("node:timers/promises", () => ({
setTimeout: mockSleep,
}));

const test = baseTest.extend<{ cloudAccountService: AzureCloudAccountService }>(
{
Expand Down Expand Up @@ -147,6 +159,9 @@ beforeEach(() => {
mockRoleAssignmentsCreate.mockClear();
mockRoleAssignmentsListForScope.mockReset();
mockSetSecret.mockClear();
mockLookup.mockReset();
mockLookup.mockResolvedValue([{ address: "127.0.0.1", family: 4 }]);
mockSleep.mockClear();
});

describe("getTerraformBackend", () => {
Expand Down Expand Up @@ -307,6 +322,7 @@ describe("isInitialized", () => {
});
});

// eslint-disable-next-line max-lines-per-function
describe("initialize", () => {
test("assigns bootstrap roles and creates bootstrap environment secrets", async ({
cloudAccountService,
Expand Down Expand Up @@ -488,4 +504,54 @@ describe("initialize", () => {
},
);
});

test("waits for the key vault endpoint to become resolvable", async ({
cloudAccountService,
}) => {
mockLookup
.mockRejectedValueOnce(
Object.assign(new Error("not found"), {
code: "ENOTFOUND",
}),
)
.mockResolvedValueOnce([{ address: "127.0.0.1", family: 4 }]);

await cloudAccountService.initialize(
{
csp: "azure",
defaultLocation: "italynorth",
displayName: "Test subscription",
id: "sub-1",
},
{
name: "dev",
prefix: "dx",
},
{
clientId: "app-client-id",
id: "app-id",
installationId: "installation-id",
key: "private-key\n",
},
{
owner: "pagopa",
repo: "dx",
},
{
createBranch: vi.fn(),
createOrUpdateEnvironmentSecret: vi.fn().mockResolvedValue(undefined),
createPullRequest: vi.fn(),
getFileContent: vi.fn(),
getRepository: vi.fn(),
updateFile: vi.fn(),
},
);

expect(mockLookup).toHaveBeenCalledWith(
"dx-d-itn-common-kv-01.vault.azure.net",
);
expect(mockLookup).toHaveBeenCalledTimes(2);
expect(mockSleep).toHaveBeenCalledWith(10_000);
expect(mockSetSecret).toHaveBeenCalledTimes(3);
});
});
47 changes: 47 additions & 0 deletions apps/cli/src/adapters/azure/cloud-account-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { getLogger } from "@logtape/logtape";
import { Client } from "@microsoft/microsoft-graph-client";
import * as assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { lookup } from "node:dns/promises";
import { setTimeout as sleep } from "node:timers/promises";
import { z } from "zod/v4";

import {
Expand Down Expand Up @@ -70,6 +72,12 @@ const bootstrapIdentityRoleDefinitionIds = [
builtInRoleDefinitionIds.storageBlobDataContributor,
] as const;

const keyVaultDnsErrorSchema = z.object({
code: z.enum(["EAI_AGAIN", "ENOTFOUND"]),
});
const keyVaultDnsReadyMaxAttempts = 30;
const keyVaultDnsReadyRetryDelayMs = 10_000;

export class AzureCloudAccountService implements CloudAccountService {
#credential: TokenCredential;
#requiredResourceProviders = [
Expand Down Expand Up @@ -823,6 +831,11 @@ export class AzureCloudAccountService implements CloudAccountService {
}): Promise<void> {
const logger = getLogger(["gen", "env"]);

await this.#waitForKeyVaultDnsResolution({
cloudAccountId,
keyVaultName,
});

const secretClient = new SecretClient(
`https://${keyVaultName}.vault.azure.net/`,
this.#credential,
Expand All @@ -845,4 +858,38 @@ export class AzureCloudAccountService implements CloudAccountService {
{ keyVaultName, subscriptionId: cloudAccountId },
);
}

async #waitForKeyVaultDnsResolution({
cloudAccountId,
keyVaultName,
}: {
cloudAccountId: string;
keyVaultName: string;
}): Promise<void> {
const logger = getLogger(["gen", "env"]);
const hostname = `${keyVaultName}.vault.azure.net`;

for (let attempt = 1; attempt <= keyVaultDnsReadyMaxAttempts; attempt++) {
try {
await lookup(hostname);
return;
} catch (cause) {
if (
!keyVaultDnsErrorSchema.safeParse(cause).success ||
attempt === keyVaultDnsReadyMaxAttempts
) {
throw new Error(
`Key vault endpoint '${hostname}' is not resolvable`,
{ cause },
);
}

logger.debug(
"Waiting for key vault endpoint {hostname} to become resolvable in subscription {subscriptionId}",
{ hostname, subscriptionId: cloudAccountId },
);
await sleep(keyVaultDnsReadyRetryDelayMs);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* Unit tests for keeping repository Terraform environments in sync.
*/
import { describe, expect, it } from "vitest";

import { syncRepositoryTerraformEnvironments } from "../sync-repository-environments.js";

const repositoryConfig = [
'module "github_repository" {',
' source = "pagopa-dx/github-environment-bootstrap/github"',
' version = "~> 1.0"',
"",
" repository = {",
' name = "my-project"',
' description = "My project"',
" topics = []",
" reviewers_teams = []",
" }",
"}",
"",
].join("\n");

describe("syncRepositoryTerraformEnvironments", () => {
it("adds a non-prod environment while preserving the implicit prod default", () => {
const result = syncRepositoryTerraformEnvironments(repositoryConfig, "dev");

expect(result).toContain(' environments = ["dev", "prod"]');
});

it("does not add an explicit environments property when prod is already implicit", () => {
const result = syncRepositoryTerraformEnvironments(
repositoryConfig,
"prod",
);

expect(result).toBe(repositoryConfig);
});

it("adds the selected environment to an existing explicit list", () => {
const content = repositoryConfig.replace(
" reviewers_teams = []\n",
' reviewers_teams = []\n environments = ["prod"]\n',
);

const result = syncRepositoryTerraformEnvironments(content, "uat");

expect(result).toContain(' environments = ["uat", "prod"]');
});

it("is idempotent when the selected environment already exists", () => {
const content = repositoryConfig.replace(
" reviewers_teams = []\n",
' reviewers_teams = []\n environments = ["dev", "prod"]\n',
);

const result = syncRepositoryTerraformEnvironments(content, "dev");

expect(result).toBe(content);
});

it("throws when the repository block is missing", () => {
expect(() =>
syncRepositoryTerraformEnvironments('resource "x" "y" {}', "dev"),
).toThrow("Cannot find the repository configuration");
});
});
146 changes: 146 additions & 0 deletions apps/cli/src/adapters/plop/actions/sync-repository-environments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/**
* Synchronizes GitHub repository environments required by deployment scaffolding.
*
* The repository Terraform module remains the source of truth: after adding the
* selected environment to its inputs, this action applies it so GitHub receives
* the same policies and reviewers configured by Terraform.
*/
import { type NodePlopAPI } from "node-plop";
import fs from "node:fs/promises";
import path from "node:path";

import type { Environment } from "../../../domain/environment.js";

import { tf$ } from "../../execa/terraform.js";
import {
type Payload,
payloadSchema,
} from "../generators/environment/prompts.js";

const KNOWN_ENVIRONMENTS: Environment["name"][] = ["dev", "uat", "prod"];
const KNOWN_ENVIRONMENT_NAMES: readonly string[] = KNOWN_ENVIRONMENTS;

const environmentList = (environments: Set<string>): string =>
`[${[
...KNOWN_ENVIRONMENTS.filter((environment) =>
environments.has(environment),
),
...Array.from(environments).filter(
(environment) => !KNOWN_ENVIRONMENT_NAMES.includes(environment),
),
]
.map((environment) => `"${environment}"`)
.join(", ")}]`;

const findRepositoryBlock = (
content: string,
): { block: string; end: number; start: number } => {
const match =
/\n(?<indent>[^\S\r\n]*)repository[^\S\r\n]*=[^\S\r\n]*\{\n/.exec(content);
if (!match) {
throw new Error(
"Cannot find the repository configuration in infra/repository/main.tf",
);
}

const blockStart = match.index + match[0].length;
const closingMatch = new RegExp(`\\n${match.groups?.indent ?? ""}\\}`).exec(
content.slice(blockStart),
);
if (!closingMatch) {
throw new Error(
"Cannot find the end of the repository configuration in infra/repository/main.tf",
);
}

const end = blockStart + closingMatch.index;
return { block: content.slice(blockStart, end), end, start: blockStart };
};

const repositoryPropertyIndent = (block: string): string => {
const propertyMatch = /^(?<indent>\s*)\w+\s*=/m.exec(block);
return propertyMatch?.groups?.indent ?? " ";
};

const readRepositoryConfig = async (repositoryMainPath: string) => {
try {
return await fs.readFile(repositoryMainPath, "utf8");
} catch (cause) {
throw new Error(
`Cannot synchronize GitHub repository environments because ${path.relative(process.cwd(), repositoryMainPath)} does not exist or is not readable.`,
{ cause },
);
}
};

export const syncRepositoryTerraformEnvironments = (
content: string,
environmentName: Environment["name"],
): string => {
const repositoryBlock = findRepositoryBlock(content);
const environmentsMatch =
/^(?<indent>\s*)environments\s*=\s*\[(?<values>[\s\S]*?)\]\s*$/m.exec(
repositoryBlock.block,
);

if (!environmentsMatch) {
if (environmentName === "prod") {
return content;
}

const environments = new Set<string>([environmentName, "prod"]);
const propertyIndent = repositoryPropertyIndent(repositoryBlock.block);
const separator = repositoryBlock.block.endsWith("\n") ? "" : "\n";
const updatedBlock = `${repositoryBlock.block}${separator}${propertyIndent}environments = ${environmentList(environments)}`;
return `${content.slice(0, repositoryBlock.start)}${updatedBlock}${content.slice(repositoryBlock.end)}`;
}

const existingEnvironments = new Set(
Array.from((environmentsMatch.groups?.values ?? "").matchAll(/"([^"]+)"/g))
.map((match) => match[1])
.filter((environment) => environment.length > 0),
);
if (existingEnvironments.has(environmentName)) {
return content;
}

existingEnvironments.add(environmentName);
const indent = environmentsMatch.groups?.indent ?? " ";
const updatedLine = `${indent}environments = ${environmentList(existingEnvironments)}`;
const updatedBlock = repositoryBlock.block.replace(
environmentsMatch[0],
updatedLine,
);

return `${content.slice(0, repositoryBlock.start)}${updatedBlock}${content.slice(repositoryBlock.end)}`;
};

export const syncRepositoryEnvironments = async (
payload: Payload,
): Promise<void> => {
const repositoryPath = path.join(process.cwd(), "infra", "repository");
const repositoryMainPath = path.join(repositoryPath, "main.tf");

const currentRepositoryConfig =
await readRepositoryConfig(repositoryMainPath);
const updatedRepositoryConfig = syncRepositoryTerraformEnvironments(
currentRepositoryConfig,
payload.env.name,
);

if (updatedRepositoryConfig !== currentRepositoryConfig) {
await fs.writeFile(repositoryMainPath, updatedRepositoryConfig, "utf8");
}

const repositoryTerraform = tf$({ cwd: repositoryPath });
await repositoryTerraform`terraform init`;
await repositoryTerraform`terraform apply -auto-approve`;
};

export default function (plop: NodePlopAPI): void {
plop.setActionType("syncRepositoryEnvironments", async (data) => {
const payload = payloadSchema.parse(data);
await syncRepositoryEnvironments(payload);
return "Repository environments synchronized";
});
}
Loading