From 3bc4ac68e83b2902f07f75ea8fcad58487c3d704 Mon Sep 17 00:00:00 2001 From: Lloyd Mangnall Date: Sun, 25 Oct 2026 13:14:53 -0600 Subject: [PATCH 1/3] feat(iac-core): stack and project names read both grammars through one map Implements Pl.Architecture.Meta.NamingConventionsAcrossPlatforms 9 (EA 2026-10-25: "yes, approved"): pulumi-project-is-one-account, pulumi-stack-shape and names-migrate-through-an-alias-map. - The account grammar: project {tenant}-{provider}-{account-purpose}-{env}, stack {target-env}-{stack-purpose}[-{concern}]-{region} (glb for a global platform). The project decides the grammar: four segments is the account grammar, three the legacy one. - parseStackIdentity(project, stack) reads either grammar. A legacy name and its account-grammar name give the same identity, and detectStackContext the same context apart from projectName and stackName. targetEnvironment stays present only for a centralized stack, so StackContext keeps its meaning. - NameAliasMap is the consumer's one declared map from legacy {project}/{stack} names to account-grammar ones. iac-core holds no tenant's names. validateNameAliases checks a map: keys legacy, targets account-grammar, both sides the same identity, one target per key, one new project per legacy project. canonicalStackName resolves either name. - detectStackContext({ nameAliases }) refuses a legacy name the map doesn't declare. With no map it reads both grammars, as before for legacy names. - projectNameOf and stackNameOf build the account-grammar names. generateStackName and buildStackReference still build the legacy ones. - StackContextSchema and ProjectNameSchema accept the account grammar. Tests: both grammars, every one of iac-worx's 16 renames (as a fixture; the declared map lives in iac-worx), the map's refusals, and a name in neither grammar. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../iac-core/src/schemas/core/core-schemas.ts | 25 +- .../src/utils/naming-grammar.unit.test.ts | 4 +- .../src/utils/stack-names.unit.test.ts | 222 ++++++++++++++++++ packages/iac-core/src/utils/stack-utils.ts | 203 +++++++++++++++- 4 files changed, 436 insertions(+), 18 deletions(-) create mode 100644 packages/iac-core/src/utils/stack-names.unit.test.ts diff --git a/packages/iac-core/src/schemas/core/core-schemas.ts b/packages/iac-core/src/schemas/core/core-schemas.ts index 44cb652..d87f353 100644 --- a/packages/iac-core/src/schemas/core/core-schemas.ts +++ b/packages/iac-core/src/schemas/core/core-schemas.ts @@ -191,15 +191,19 @@ export const OrgPrefixSchema = z }); /** - * Project name: {tenant}-{provider}-{env}, where provider is one of - * {@link ProviderSchema} (e.g. worx-aws-dev, worx-cloudflare-sec, care-gcp-prd). + * Project name, in one of two grammars (Pl.Architecture.Meta.NamingConventionsAcrossPlatforms 9): + * account (4 segments): {tenant}-{provider}-{account-purpose}-{env} worx-aws-ops-sec + * legacy (3 segments): {tenant}-{provider}-{env} worx-aws-sec + * The provider is one of {@link ProviderSchema}. The legacy grammar is read until every + * project is renamed (see NameAliasMap in utils/stack-utils). */ export const ProjectNameSchema = z .string() .min(7, "Project name too short") .regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, "Project name must be kebab-case") - .refine(val => val.split("-").length === 3, { - message: "Project name must follow pattern: {tenant}-{provider}-{env}", + .refine(val => [3, 4].includes(val.split("-").length), { + message: + "Project name must follow pattern: {tenant}-{provider}-{account-purpose}-{env} (or the legacy {tenant}-{provider}-{env})", }) .refine(val => ProviderSchema.safeParse(val.split("-")[1]).success, { message: `Project name's provider segment must be one of: ${ProviderSchema.options.join(", ")}`, @@ -269,8 +273,11 @@ export const StackContextSchema = z } const fullRegion = resolveProviderRegion(provider, data.region); - // Agent guardrail: Validate project name consistency - const expectedProjectName = `${data.tenant}-${provider}-${data.environment}`; + // Agent guardrail: Validate project name consistency (the account grammar has 4 segments) + const accountGrammar = data.projectName.split("-").length === 4; + const expectedProjectName = accountGrammar + ? `${data.tenant}-${provider}-${data.accountPurpose}-${data.environment}` + : `${data.tenant}-${provider}-${data.environment}`; if (data.projectName !== expectedProjectName) { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -284,7 +291,11 @@ export const StackContextSchema = z const hasTargetEnv = data.targetEnvironment !== undefined; const hasConcern = data.concern !== undefined && data.concern.trim() !== ""; - if (hasTargetEnv && hasConcern) { + if (accountGrammar) { + // Fixed position: {target-env}-{stack-purpose}[-{concern}]-{region}; the target + // environment is the project's own unless the stack is centralized + expectedStackName = `${data.targetEnvironment ?? data.environment}-${data.stackPurpose}${hasConcern ? `-${data.concern}` : ""}-${data.region}`; + } else if (hasTargetEnv && hasConcern) { // 5-part: target-account-stack-concern-region expectedStackName = `${data.targetEnvironment}-${data.accountPurpose}-${data.stackPurpose}-${data.concern}-${data.region}`; } else if (hasTargetEnv) { diff --git a/packages/iac-core/src/utils/naming-grammar.unit.test.ts b/packages/iac-core/src/utils/naming-grammar.unit.test.ts index c8727f4..6ef57be 100644 --- a/packages/iac-core/src/utils/naming-grammar.unit.test.ts +++ b/packages/iac-core/src/utils/naming-grammar.unit.test.ts @@ -433,7 +433,9 @@ describe("naming grammar", () => { }); it("rejects a malformed project name", () => { - for (const project of ["worx-aws", "worx-aws-sec-x", "Worx-aws-sec", "worx_aws_sec"]) { + // Four segments is the account grammar ({tenant}-{provider}-{account-purpose}-{env}); + // five is malformed in both grammars + for (const project of ["worx-aws", "worx-aws-ops-sec-x", "Worx-aws-sec", "worx_aws_sec"]) { expect(ProjectNameSchema.safeParse(project).success).toBe(false); } expect(() => parseProjectName("worx-cloudflare")).toThrow("{tenant}-{provider}-{env}"); diff --git a/packages/iac-core/src/utils/stack-names.unit.test.ts b/packages/iac-core/src/utils/stack-names.unit.test.ts new file mode 100644 index 0000000..287d252 --- /dev/null +++ b/packages/iac-core/src/utils/stack-names.unit.test.ts @@ -0,0 +1,222 @@ +/** + * AdaptiveWorX™ Flow + * Copyright (c) 2023-2026 Adaptive Intelligence, LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * The account grammar and the legacy grammar side by side + * (Pl.Architecture.Meta.NamingConventionsAcrossPlatforms 9): + * + * project: {tenant}-{provider}-{account-purpose}-{env} (legacy {tenant}-{provider}-{env}) + * stack: {target-env}-{stack-purpose}[-{concern}]-{region} + * (legacy [{target-env}-]{account-purpose}-{stack-purpose}[-{concern}]-{region}) + * + * The fixture is iac-worx's 16 stacks in its 5 projects as they are named today, each with + * the name it takes. It is a fixture, not a declaration: the one declared map lives with the + * consumer (iac-worx), which validates it with validateNameAliases. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@pulumi/pulumi", () => ({ + getProject: vi.fn(), + getStack: vi.fn(), + log: { info: vi.fn(), warn: vi.fn(), debug: vi.fn(), error: vi.fn() }, +})); + +import * as pulumi from "@pulumi/pulumi"; +import { ProjectNameSchema } from "../schemas/core/core-schemas.js"; +import { + canonicalStackName, + detectStackContext, + isAccountGrammarProject, + type NameAliasMap, + parseStackIdentity, + projectNameOf, + stackNameOf, + validateNameAliases, +} from "./stack-utils.js"; + +const WORX_RENAMES: NameAliasMap = { + "worx-aws-dev/app-flow-use1": "worx-aws-app-dev/dev-flow-use1", + "worx-aws-dev/app-iam-github-use1": "worx-aws-app-dev/dev-iam-github-use1", + "worx-aws-dev/app-iam-pulumi-use1": "worx-aws-app-dev/dev-iam-pulumi-use1", + "worx-aws-stg/app-iam-github-use1": "worx-aws-app-stg/stg-iam-github-use1", + "worx-aws-stg/app-iam-pulumi-use1": "worx-aws-app-stg/stg-iam-pulumi-use1", + "worx-aws-prd/app-iam-github-use1": "worx-aws-app-prd/prd-iam-github-use1", + "worx-aws-prd/app-iam-pulumi-use1": "worx-aws-app-prd/prd-iam-pulumi-use1", + "worx-aws-sec/dev-ops-vpc-use1": "worx-aws-ops-sec/dev-vpc-use1", + "worx-aws-sec/dev-ops-vpc-usw2": "worx-aws-ops-sec/dev-vpc-usw2", + "worx-aws-sec/dev-ops-vpn-use1": "worx-aws-ops-sec/dev-vpn-use1", + "worx-aws-sec/stg-ops-vpc-use1": "worx-aws-ops-sec/stg-vpc-use1", + "worx-aws-sec/stg-ops-vpc-usw2": "worx-aws-ops-sec/stg-vpc-usw2", + "worx-aws-sec/prd-ops-vpc-use1": "worx-aws-ops-sec/prd-vpc-use1", + "worx-aws-sec/ops-iam-github-use1": "worx-aws-ops-sec/sec-iam-github-use1", + "worx-aws-sec/ops-iam-pulumi-use1": "worx-aws-ops-sec/sec-iam-pulumi-use1", + "worx-cloudflare-sec/ops-ztna-glb": "worx-cloudflare-ops-sec/sec-ztna-glb", +}; + +function contextFor(project: string, stack: string, nameAliases?: NameAliasMap) { + vi.mocked(pulumi.getProject).mockReturnValue(project); + vi.mocked(pulumi.getStack).mockReturnValue(stack); + return detectStackContext(nameAliases === undefined ? {} : { nameAliases }); +} + +function withoutNames(context: T) { + const { projectName: _p, stackName: _s, ...rest } = context; + return rest; +} + +describe("the account grammar", () => { + beforeEach(() => { + process.env.PULUMI_ORG = "adaptiveworx"; + }); + afterEach(() => { + delete process.env.PULUMI_ORG; + }); + + it("reads a project as one account", () => { + expect(isAccountGrammarProject("worx-aws-ops-sec")).toBe(true); + expect(isAccountGrammarProject("worx-aws-sec")).toBe(false); + expect(ProjectNameSchema.safeParse("worx-cloudflare-ops-sec").success).toBe(true); + }); + + it("reads a stack by position, the target environment first", () => { + expect(parseStackIdentity("worx-aws-ops-sec", "sec-iam-github-use1")).toEqual({ + tenant: "worx", + provider: "aws", + accountPurpose: "ops", + environment: "sec", + stackPurpose: "iam", + region: "use1", + concern: "github", + }); + }); + + it("keeps the target environment only for a centralized stack", () => { + expect(parseStackIdentity("worx-aws-ops-sec", "dev-vpc-use1")).toMatchObject({ + targetEnvironment: "dev", + stackPurpose: "vpc", + }); + expect(parseStackIdentity("worx-aws-app-dev", "dev-flow-use1")).not.toHaveProperty( + "targetEnvironment" + ); + }); + + it("reads glb as the region of a global platform", () => { + const context = contextFor("worx-cloudflare-ops-sec", "sec-ztna-glb"); + expect(context).toMatchObject({ provider: "cloudflare", region: "glb", stackPurpose: "ztna" }); + }); + + it("builds both names back from the identity", () => { + const identity = parseStackIdentity("worx-aws-ops-sec", "dev-vpn-use1"); + expect(projectNameOf(identity)).toBe("worx-aws-ops-sec"); + expect(stackNameOf(identity)).toBe("dev-vpn-use1"); + }); + + it("refuses a stack that is neither 3 nor 4 segments", () => { + expect(() => parseStackIdentity("worx-aws-ops-sec", "sec-iam-github-x-use1")).toThrow( + /Invalid stack name format/ + ); + expect(() => parseStackIdentity("worx-aws-ops-sec", "sec-use1")).toThrow( + /Invalid stack name format/ + ); + }); + + it("refuses a centralized stack outside the ops-sec project", () => { + expect(() => contextFor("worx-aws-app-dev", "stg-flow-use1")).toThrow( + /Stack context detection failed/ + ); + }); +}); + +describe("every iac-worx rename", () => { + beforeEach(() => { + process.env.PULUMI_ORG = "adaptiveworx"; + }); + afterEach(() => { + delete process.env.PULUMI_ORG; + }); + + it("covers 16 stacks in 5 projects", () => { + const projects = new Set(Object.keys(WORX_RENAMES).map(name => name.split("/")[0])); + expect(Object.keys(WORX_RENAMES)).toHaveLength(16); + expect(projects.size).toBe(5); + }); + + it("passes validateNameAliases", () => { + expect(() => validateNameAliases(WORX_RENAMES)).not.toThrow(); + }); + + for (const [from, to] of Object.entries(WORX_RENAMES)) { + it(`${from} and ${to} give the same context`, () => { + const [oldProject, oldStack] = from.split("/") as [string, string]; + const [newProject, newStack] = to.split("/") as [string, string]; + const legacy = contextFor(oldProject, oldStack); + const account = contextFor(newProject, newStack); + expect(withoutNames(account)).toEqual(withoutNames(legacy)); + expect(account.projectName).toBe(newProject); + expect(account.stackName).toBe(newStack); + expect(canonicalStackName(from, WORX_RENAMES)).toBe(to); + expect(canonicalStackName(to, WORX_RENAMES)).toBe(to); + }); + } +}); + +describe("the alias map", () => { + beforeEach(() => { + process.env.PULUMI_ORG = "adaptiveworx"; + }); + afterEach(() => { + delete process.env.PULUMI_ORG; + }); + + it("refuses a target that changes what the name means", () => { + expect(() => + validateNameAliases({ "worx-aws-dev/app-flow-use1": "worx-aws-app-dev/dev-web-use1" }) + ).toThrow(/changes what the name means/); + }); + + it("refuses a key already in the account grammar, and a legacy target", () => { + expect(() => + validateNameAliases({ "worx-aws-app-dev/dev-flow-use1": "worx-aws-app-dev/dev-flow-use1" }) + ).toThrow(/already an account-grammar name/); + expect(() => + validateNameAliases({ "worx-aws-dev/app-flow-use1": "worx-aws-dev/app-flow-use1" }) + ).toThrow(/not an account-grammar name/); + }); + + it("refuses two legacy names for one target", () => { + expect(() => + validateNameAliases({ + "worx-aws-dev/app-flow-use1": "worx-aws-app-dev/dev-flow-use1", + "worx-aws-dev/app-flow-use1x": "worx-aws-app-dev/dev-flow-use1", + }) + ).toThrow(); + }); + + it("with a map given, refuses a legacy name the map doesn't declare", () => { + expect(() => contextFor("worx-aws-dev", "app-web-use1", WORX_RENAMES)).toThrow( + /not in the name alias map/ + ); + expect(() => canonicalStackName("worx-aws-dev/app-web-use1", WORX_RENAMES)).toThrow( + /not in the name alias map/ + ); + }); + + it("with a map given, still reads a declared legacy name and any account-grammar name", () => { + expect(contextFor("worx-aws-sec", "ops-iam-github-use1", WORX_RENAMES)).toMatchObject({ + stackPurpose: "iam", + concern: "github", + }); + expect(contextFor("worx-aws-app-dev", "dev-web-use1", WORX_RENAMES)).toMatchObject({ + stackPurpose: "web", + }); + }); + + it("refuses a name in neither grammar", () => { + expect(() => contextFor("worx-aws", "flow")).toThrow(/Stack context detection failed/); + expect(() => parseStackIdentity("worx-aws-ops-sec", "use1")).toThrow(); + }); +}); diff --git a/packages/iac-core/src/utils/stack-utils.ts b/packages/iac-core/src/utils/stack-utils.ts index 2aa431f..f296128 100644 --- a/packages/iac-core/src/utils/stack-utils.ts +++ b/packages/iac-core/src/utils/stack-utils.ts @@ -30,22 +30,43 @@ function requirePulumiOrg(): string { return value; } +/** Options for {@link detectStackContext}. */ +export interface DetectStackContextOptions { + /** + * The consumer's one declared map from legacy names to account-grammar names + * (Pl.Architecture.Meta.NamingConventionsAcrossPlatforms names-migrate-through-an-alias-map). + * Given, a legacy-grammar name must be a key of the map: a legacy name the map doesn't + * declare is refused, so no new legacy name appears while the renames are under way. + */ + readonly nameAliases?: NameAliasMap; +} + /** * Detect stack context from current Pulumi project and stack * Agent guardrail: Comprehensive validation with structured error handling - * Uses hierarchical naming: {org}/{tenant}-{provider}-{env}/[{target-env}-]{account-purpose}-{stack-purpose}[-{concern}]-{region} - * (e.g. adaptiveworx/worx-aws-sec/ops-iam-github-use1, adaptiveworx/worx-cloudflare-sec/ops-ztna-glb). + * Reads both grammars (Pl.Architecture.Meta.NamingConventionsAcrossPlatforms 9): + * account: {org}/{tenant}-{provider}-{account-purpose}-{env}/{target-env}-{stack-purpose}[-{concern}]-{region} + * (e.g. adaptiveworx/worx-aws-ops-sec/sec-iam-github-use1) + * legacy: {org}/{tenant}-{provider}-{env}/[{target-env}-]{account-purpose}-{stack-purpose}[-{concern}]-{region} + * (e.g. adaptiveworx/worx-aws-sec/ops-iam-github-use1) + * A legacy name and its account-grammar name give the same context apart from the names. */ -export function detectStackContext(): StackContext { +export function detectStackContext(options: DetectStackContextOptions = {}): StackContext { try { - const projectName = pulumi.getProject(); // e.g., "worx-aws-dev" - const stackName = pulumi.getStack(); // e.g., "ops-iam-github-use1" or "dev-ops-vpc-shared-use1" - - // Parse project name using helper - const { tenant, provider, environment } = parseProjectName(projectName); + const projectName = pulumi.getProject(); // e.g., "worx-aws-ops-sec" or legacy "worx-aws-sec" + const stackName = pulumi.getStack(); // e.g., "sec-iam-github-use1" or legacy "ops-iam-github-use1" + + if (options.nameAliases !== undefined && !isAccountGrammarProject(projectName)) { + const key = `${projectName}/${stackName}`; + if (options.nameAliases[key] === undefined) { + throw new Error( + `Legacy name ${key} is not in the name alias map: a legacy name is read only while its rename is declared` + ); + } + } - // Parse stack name using centralized parser - const parsed = parseStackName(stackName); + const parsed = parseStackIdentity(projectName, stackName); + const { tenant, provider, environment } = parsed; const { accountPurpose, stackPurpose, region, concern, targetEnvironment } = parsed; // Build and validate context @@ -328,9 +349,171 @@ export function parseStackName(stackName: string): { } } +/** The components a project and stack name carry, whichever grammar they use. */ +export interface StackIdentity { + readonly tenant: string; + readonly provider: string; + readonly accountPurpose: string; + readonly environment: string; + readonly stackPurpose: string; + readonly region: string; + readonly concern?: string; + /** Present only when the stack is centralized: it serves another environment than its project's. */ + readonly targetEnvironment?: string; +} + +/** True when a project name uses the account grammar: {tenant}-{provider}-{account-purpose}-{env}. */ +export function isAccountGrammarProject(projectName: string): boolean { + return projectName.split("-").length === 4; +} + +/** + * Parse a project and stack name in either grammar. The project decides the grammar: four + * segments is the account grammar (fixed-position stack, target environment always first), + * three is the legacy grammar ({@link parseStackName}). + */ +export function parseStackIdentity(projectName: string, stackName: string): StackIdentity { + if (!isAccountGrammarProject(projectName)) { + const { tenant, provider, environment } = parseProjectName(projectName); + const { accountPurpose, stackPurpose, region, concern, targetEnvironment } = + parseStackName(stackName); + return { + tenant, + provider, + accountPurpose, + environment, + stackPurpose, + region, + ...(concern === undefined ? {} : { concern }), + ...(targetEnvironment === undefined ? {} : { targetEnvironment }), + }; + } + + const projectParts = projectName.split("-").map(part => part.trim()); + const [tenant, provider, accountPurpose, environment] = projectParts; + if ( + tenant === undefined || + provider === undefined || + accountPurpose === undefined || + environment === undefined || + projectParts.some(part => part.length === 0) + ) { + throw new Error(`Incomplete project name components: ${projectName}`); + } + + const stackParts = stackName.split("-").map(part => part.trim()); + if (stackParts.length !== 3 && stackParts.length !== 4) { + throw new Error( + `Invalid stack name format: ${stackName}. Expected: {target-env}-{stack-purpose}[-{concern}]-{region}` + ); + } + if (stackParts.some(part => part.length === 0)) { + throw new Error(`Incomplete stack name components: ${stackName}`); + } + const targetEnv = stackParts[0] as string; + const stackPurpose = stackParts[1] as string; + const concern = stackParts.length === 4 ? (stackParts[2] as string) : undefined; + const region = stackParts[stackParts.length - 1] as string; + + return { + tenant, + provider, + accountPurpose, + environment, + stackPurpose, + region, + ...(concern === undefined ? {} : { concern }), + ...(targetEnv === environment ? {} : { targetEnvironment: targetEnv }), + }; +} + +/** The account-grammar project name: {tenant}-{provider}-{account-purpose}-{env}. */ +export function projectNameOf(identity: StackIdentity): string { + return `${identity.tenant}-${identity.provider}-${identity.accountPurpose}-${identity.environment}`; +} + +/** The account-grammar stack name: {target-env}-{stack-purpose}[-{concern}]-{region}. */ +export function stackNameOf(identity: StackIdentity): string { + const concern = + identity.concern !== undefined && identity.concern !== "" ? `-${identity.concern}` : ""; + return `${identity.targetEnvironment ?? identity.environment}-${identity.stackPurpose}${concern}-${identity.region}`; +} + +/** + * A consumer's one declared map from legacy names to account-grammar names, each side a + * `{project}/{stack}` pair: `"worx-aws-sec/ops-iam-github-use1": "worx-aws-ops-sec/sec-iam-github-use1"`. + * It lives with the consumer (iac-core holds no tenant's names) and is removed when the last + * rename is applied (names-migrate-through-an-alias-map). + */ +export type NameAliasMap = Readonly>; + +function splitQualified(name: string): { project: string; stack: string } { + const parts = name.split("/"); + if (parts.length !== 2 || parts[0] === "" || parts[1] === "") { + throw new Error(`Expected {project}/{stack}, got '${name}'`); + } + return { project: parts[0] as string, stack: parts[1] as string }; +} + +/** + * Check a name alias map, failing closed on the first problem: every key is a legacy name, + * every value an account-grammar name; both sides parse to the same identity; no two keys + * name the same target; and every stack of one legacy project moves to the same new project. + */ +export function validateNameAliases(map: NameAliasMap): void { + const targets = new Set(); + const projectMoves = new Map(); + for (const [from, to] of Object.entries(map)) { + const old = splitQualified(from); + const next = splitQualified(to); + if (isAccountGrammarProject(old.project)) { + throw new Error(`Alias key ${from} is already an account-grammar name`); + } + if (!isAccountGrammarProject(next.project)) { + throw new Error(`Alias target ${to} is not an account-grammar name`); + } + const oldIdentity = parseStackIdentity(old.project, old.stack); + const newIdentity = parseStackIdentity(next.project, next.stack); + if (projectNameOf(oldIdentity) !== next.project || stackNameOf(oldIdentity) !== next.stack) { + throw new Error( + `Alias ${from} -> ${to} changes what the name means: it parses to ${projectNameOf(oldIdentity)}/${stackNameOf(oldIdentity)}` + ); + } + if (JSON.stringify(oldIdentity) !== JSON.stringify(newIdentity)) { + throw new Error(`Alias ${from} -> ${to}: the two names parse to different identities`); + } + if (targets.has(to)) { + throw new Error(`Alias target ${to} is named by more than one legacy name`); + } + targets.add(to); + const movedTo = projectMoves.get(old.project); + if (movedTo !== undefined && movedTo !== next.project) { + throw new Error(`Legacy project ${old.project} moves to both ${movedTo} and ${next.project}`); + } + projectMoves.set(old.project, next.project); + } +} + +/** + * The account-grammar `{project}/{stack}` for a name in either grammar: a legacy name through + * the map (refused when the map doesn't declare it), an account-grammar name as it is. + */ +export function canonicalStackName(name: string, map: NameAliasMap): string { + const { project } = splitQualified(name); + if (isAccountGrammarProject(project)) { + return name; + } + const target = map[name]; + if (target === undefined) { + throw new Error(`Legacy name ${name} is not in the name alias map`); + } + return target; +} + /** * Generate stack name from components * Agent utility: Ensures consistent stack naming with 3-part, 4-part, or 5-part convention + * (the legacy grammar; {@link stackNameOf} builds the account grammar) */ export function generateStackName( accountPurpose: string, From f5f7a900f7bb7db4da702aa69acc31bf4c27a0ba Mon Sep 17 00:00:00 2001 From: Lloyd Mangnall Date: Sun, 25 Oct 2026 13:19:48 -0600 Subject: [PATCH 2/3] feat(iac-aws)!: SharedVpc tiers require role; shareViaRam removed Implements Bh.Flow.Platform.NetworkDualStack 9 (SubnetTier: role and shareWith required, replacing shareViaRam), ending the one-release deprecation that #75 (0.5.1) opened. - SubnetTier.role and SubnetTier.shareWith are required. validateTierSharing refuses a tier without a role, without shareWith, or still setting shareViaRam, and names the replacement: shareViaRam true is every account in sharedAccounts, false is []. - The main share holds the tiers whose shareWith names every account in sharedAccounts (isInMainShare), as shareViaRam: true did. legacyRamAssociationNames derives a stack's pre-0.6.0 association names from those tiers, so a stack that moves shareViaRam: true to shareWith every account keeps every association with no legacyRamSharedTiers. - DEFAULT_SUBNET_TIERS is now defaultSubnetTiers(sharedAccounts): public in the owner account, private and data shared with every account. tierShareTargets takes the tier only. - The shared route table's SharedByTiers tag joins tier names with "+", not ",", which the tag-charset rule flags. The live tag (e.g. "private,data") updates in place. BREAKING CHANGE: a SubnetTier without role or shareWith, or with shareViaRam, is refused. With no sharedAccounts, the default private and data tiers are no longer associated with the main share (sharing with no principal shared nothing). Consumers: iac-worx dev-ops-vpc-usw2, stg-ops-vpc-use1, stg-ops-vpc-usw2 and prd-ops-vpc-use1 still set shareViaRam. dev-ops-vpc-use1 already uses role and shareWith (#151). Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/iac-aws/README.md | 8 +- .../src/shared-vpc.ipv6-layout.unit.test.ts | 37 ++--- .../src/shared-vpc.rename.unit.test.ts | 118 ++++++++++---- packages/iac-aws/src/shared-vpc.ts | 152 +++++++++--------- packages/iac-aws/src/shared-vpc.unit.test.ts | 26 ++- 5 files changed, 205 insertions(+), 136 deletions(-) diff --git a/packages/iac-aws/README.md b/packages/iac-aws/README.md index a4abd24..be2c80a 100644 --- a/packages/iac-aws/README.md +++ b/packages/iac-aws/README.md @@ -76,11 +76,13 @@ Details: [docs/security-implementation.md](https://github.com/AdaptiveWorX/iac-c ### Tier roles, sharing and renames - `role` names the workloads a tier holds. It is tagged `Role` on the tier's subnets and NACL. - Optional in this release (a tier without it logs a warning), required after. + Required (0.6.0). - `shareWith` lists the accounts (by their names in `sharedAccounts`) a tier's subnets are shared with through RAM. A tier sharing with every account goes in the main share; a narrower set gets - a share of its own; `[]` shares with no account. `shareViaRam` is deprecated. Every tier gives - exactly one of the two, and every `shareWith` name must be in `sharedAccounts` (fail closed). + a share of its own; `[]` shares with no account. Required (0.6.0); every name must be in + `sharedAccounts` (fail closed). `shareViaRam` was removed in 0.6.0 and is refused: replace + `shareViaRam: true` with every account in `sharedAccounts`, `false` with `[]`. The default tiers + (`defaultSubnetTiers(sharedAccounts)`) share private and data with every account. - `formerNames` renames a tier without replacing anything: every resource named after the tier carries its old logical name as a Pulumi alias, so the preview shows tag updates only. The standard outputs (`publicSubnetIds`, `privateSubnetIds`, `dataSubnetIds`) and NAT placement diff --git a/packages/iac-aws/src/shared-vpc.ipv6-layout.unit.test.ts b/packages/iac-aws/src/shared-vpc.ipv6-layout.unit.test.ts index 9694d8b..c0a0927 100644 --- a/packages/iac-aws/src/shared-vpc.ipv6-layout.unit.test.ts +++ b/packages/iac-aws/src/shared-vpc.ipv6-layout.unit.test.ts @@ -11,13 +11,15 @@ import { describe, expect, it } from "vitest"; import { azIndexOf, - DEFAULT_SUBNET_TIERS, + defaultSubnetTiers, IPV6_SLOT_COUNT, ipv6SubnetCidr, type SubnetTier, validateIpv6Layout, } from "./shared-vpc.js"; +const DEFAULT_TIERS = defaultSubnetTiers({}); + // IPv6 documentation prefix (RFC 3849), never a live block. const VPC_V6 = "2001:db8:1234:5600::/56"; @@ -84,25 +86,20 @@ describe("stability: adding an AZ or a tier never renumbers an existing subnet", } return out; }; - const before = layout(DEFAULT_SUBNET_TIERS, ["us-east-1a", "us-east-1b", "us-east-1c"]); + const before = layout(DEFAULT_TIERS, ["us-east-1a", "us-east-1b", "us-east-1c"]); it("adding an AZ (in any list position) keeps every existing /64", () => { - const after = layout(DEFAULT_SUBNET_TIERS, [ - "us-east-1d", - "us-east-1a", - "us-east-1b", - "us-east-1c", - ]); + const after = layout(DEFAULT_TIERS, ["us-east-1d", "us-east-1a", "us-east-1b", "us-east-1c"]); for (const [key, cidr] of before) { expect(after.get(key)).toBe(cidr); } }); it("inserting a tier (anywhere in the array) keeps every existing /64", () => { - const [publicTier, privateTier, dataTier] = DEFAULT_SUBNET_TIERS; + const [publicTier, privateTier, dataTier] = DEFAULT_TIERS; const tiers = [ publicTier, - { name: "dmz", routeToInternet: true, shareViaRam: false, ipv6Slot: 3 }, + { name: "dmz", routeToInternet: true, role: "test", shareWith: [], ipv6Slot: 3 }, privateTier, dataTier, ] as SubnetTier[]; @@ -117,28 +114,28 @@ describe("validateIpv6Layout", () => { const azs = ["us-east-1a", "us-east-1b"]; it("accepts the default tiers (slots 0, 1, 2)", () => { - expect(DEFAULT_SUBNET_TIERS.map(t => t.ipv6Slot)).toEqual([0, 1, 2]); - expect(() => validateIpv6Layout(DEFAULT_SUBNET_TIERS, azs)).not.toThrow(); + expect(DEFAULT_TIERS.map(t => t.ipv6Slot)).toEqual([0, 1, 2]); + expect(() => validateIpv6Layout(DEFAULT_TIERS, azs)).not.toThrow(); }); it("rejects a tier without an explicit ipv6Slot (no array-position default)", () => { const tiers: SubnetTier[] = [ - { name: "public", routeToInternet: true, shareViaRam: false, ipv6Slot: 0 }, - { name: "private", routeToInternet: false, shareViaRam: true }, + { name: "public", routeToInternet: true, role: "test", shareWith: [], ipv6Slot: 0 }, + { name: "private", routeToInternet: false, role: "test", shareWith: ["shared"] }, ]; expect(() => validateIpv6Layout(tiers, azs)).toThrow(/'private' has no ipv6Slot/); }); it("rejects duplicate, out-of-range and non-integer slots", () => { const dup: SubnetTier[] = [ - { name: "a", routeToInternet: true, shareViaRam: false, ipv6Slot: 1 }, - { name: "b", routeToInternet: false, shareViaRam: true, ipv6Slot: 1 }, + { name: "a", routeToInternet: true, role: "test", shareWith: [], ipv6Slot: 1 }, + { name: "b", routeToInternet: false, role: "test", shareWith: ["shared"], ipv6Slot: 1 }, ]; expect(() => validateIpv6Layout(dup, azs)).toThrow(/both declare ipv6Slot 1/); for (const bad of [16, -1, 1.5]) { expect(() => validateIpv6Layout( - [{ name: "a", routeToInternet: true, shareViaRam: false, ipv6Slot: bad }], + [{ name: "a", routeToInternet: true, role: "test", shareWith: [], ipv6Slot: bad }], azs ) ).toThrow(/integer 0–15/); @@ -146,8 +143,8 @@ describe("validateIpv6Layout", () => { }); it("rejects AZs that map to the same index", () => { - expect(() => - validateIpv6Layout(DEFAULT_SUBNET_TIERS, ["us-east-1a", "us-east-1-bos-1a"]) - ).toThrow(/same IPv6 AZ index 0/); + expect(() => validateIpv6Layout(DEFAULT_TIERS, ["us-east-1a", "us-east-1-bos-1a"])).toThrow( + /same IPv6 AZ index 0/ + ); }); }); diff --git a/packages/iac-aws/src/shared-vpc.rename.unit.test.ts b/packages/iac-aws/src/shared-vpc.rename.unit.test.ts index c2fce05..9d91ffa 100644 --- a/packages/iac-aws/src/shared-vpc.rename.unit.test.ts +++ b/packages/iac-aws/src/shared-vpc.rename.unit.test.ts @@ -98,9 +98,27 @@ async function settle(): Promise { } const OLD = (shared: [boolean, boolean, boolean]) => [ - { name: "public", routeToInternet: true, shareViaRam: shared[0], ipv6Slot: 0 }, - { name: "private", routeToInternet: false, shareViaRam: shared[1], ipv6Slot: 1 }, - { name: "data", routeToInternet: false, shareViaRam: shared[2], ipv6Slot: 2 }, + { + name: "public", + role: "public", + routeToInternet: true, + shareWith: shared[0] ? ["app-dev"] : [], + ipv6Slot: 0, + }, + { + name: "private", + role: "private", + routeToInternet: false, + shareWith: shared[1] ? ["app-dev"] : [], + ipv6Slot: 1, + }, + { + name: "data", + role: "data", + routeToInternet: false, + shareWith: shared[2] ? ["app-dev"] : [], + ipv6Slot: 2, + }, ]; /** @@ -147,7 +165,7 @@ beforeAll(async () => { name: "edge", role: "ztna-connectors", routeToInternet: true, - shareViaRam: true, + shareWith: ["app-dev"], ipv6Slot: 0, formerNames: ["public"], }, @@ -155,7 +173,7 @@ beforeAll(async () => { name: "flow", role: "flow-hosts", routeToInternet: false, - shareViaRam: true, + shareWith: ["app-dev"], ipv6Slot: 1, formerNames: ["private"], }, @@ -163,7 +181,7 @@ beforeAll(async () => { name: "analytics", role: "analytics", routeToInternet: false, - shareViaRam: true, + shareWith: ["app-dev"], ipv6Slot: 2, formerNames: ["data"], }, @@ -238,15 +256,21 @@ beforeAll(async () => { name: "edge", role: "ztna-connectors", routeToInternet: true, - shareViaRam: false, + shareWith: [], ipv6Slot: 0, }, - { name: "flow", role: "flow-hosts", routeToInternet: false, shareViaRam: true, ipv6Slot: 1 }, + { + name: "flow", + role: "flow-hosts", + routeToInternet: false, + shareWith: ["app-dev"], + ipv6Slot: 1, + }, { name: "analytics", role: "analytics", routeToInternet: false, - shareViaRam: true, + shareWith: ["app-dev"], ipv6Slot: 2, }, ]); @@ -388,22 +412,25 @@ describe("sharing is per tier and fails closed", () => { expect(shares).toEqual(["s6-vpc-share", "s6-vpc-share-analytics-dev", "s6-vpc-share-app-dev"]); }); - it("refuses a tier that gives both shareWith and shareViaRam, or neither", () => { + it("refuses the removed shareViaRam, a tier without shareWith, and a tier without a role", () => { + const legacy = { name: "x", role: "x", routeToInternet: false, shareViaRam: true }; expect(() => - mod.validateTierSharing( - [{ name: "x", routeToInternet: false, shareViaRam: true, shareWith: [] }], - {} - ) - ).toThrow(/exactly one/); - expect(() => mod.validateTierSharing([{ name: "x", routeToInternet: false }], {})).toThrow( - /exactly one/ - ); + mod.validateTierSharing([legacy as unknown as import("./shared-vpc.js").SubnetTier], {}) + ).toThrow(/shareViaRam, removed in 0.6.0/); + const noShare = { name: "x", role: "x", routeToInternet: false }; + expect(() => + mod.validateTierSharing([noShare as unknown as import("./shared-vpc.js").SubnetTier], {}) + ).toThrow(/must give shareWith/); + const noRole = { name: "x", routeToInternet: false, shareWith: [] }; + expect(() => + mod.validateTierSharing([noRole as unknown as import("./shared-vpc.js").SubnetTier], {}) + ).toThrow(/must name its role/); }); it("refuses an account that is not in sharedAccounts", () => { expect(() => mod.validateTierSharing( - [{ name: "x", routeToInternet: false, shareWith: ["nobody"] }], + [{ name: "x", role: "x", routeToInternet: false, shareWith: ["nobody"] }], ACCOUNTS ) ).toThrow(/not in sharedAccounts/); @@ -413,8 +440,14 @@ describe("sharing is per tier and fails closed", () => { expect(() => mod.validateTierSharing( [ - { name: "edge", routeToInternet: true, shareWith: [], formerNames: ["public"] }, - { name: "public", routeToInternet: true, shareWith: [] }, + { + name: "edge", + role: "ztna-connectors", + routeToInternet: true, + shareWith: [], + formerNames: ["public"], + }, + { name: "public", role: "public", routeToInternet: true, shareWith: [] }, ], {} ) @@ -425,7 +458,13 @@ describe("sharing is per tier and fails closed", () => { describe("helpers", () => { it("tierNamed finds a tier by its former name", () => { const tiers = [ - { name: "flow", routeToInternet: false, shareWith: [], formerNames: ["private"] }, + { + name: "flow", + role: "flow-hosts", + routeToInternet: false, + shareWith: [], + formerNames: ["private"], + }, ]; expect(mod.tierNamed(tiers, "private")?.name).toBe("flow"); expect(mod.tierNamed(tiers, "data")).toBeUndefined(); @@ -434,33 +473,48 @@ describe("helpers", () => { it("legacyRamAssociationNames counts subnets across the old shared tiers", () => { const tier = { name: "flow", + role: "flow-hosts", routeToInternet: false, shareWith: ["a"], formerNames: ["private"], }; expect( - mod.legacyRamAssociationNames("dev", ["public", "private", "data"], [tier], tier, 3, 6) + mod.legacyRamAssociationNames( + "dev", + ["public", "private", "data"], + [tier], + tier, + 3, + 6, + ACCOUNTS + ) ).toEqual(["dev-ram-private-9"]); - expect(mod.legacyRamAssociationNames("dev", ["data"], [tier], tier, 0, 6)).toEqual([]); + expect(mod.legacyRamAssociationNames("dev", ["data"], [tier], tier, 0, 6, ACCOUNTS)).toEqual( + [] + ); }); - it("without a layout, a renamed shareViaRam tier gets its index under every name", () => { + it("without a layout, a renamed tier in the main share gets its index under every name", () => { const edge = { name: "edge", + role: "ztna-connectors", routeToInternet: true, - shareViaRam: false, + shareWith: [], formerNames: ["public"], }; const flow = { name: "flow", + role: "flow-hosts", routeToInternet: false, - shareViaRam: true, + shareWith: ["app-dev"], formerNames: ["private"], }; - expect(mod.legacyRamAssociationNames("dev", undefined, [edge, flow], flow, 1, 6)).toEqual([ - "dev-ram-flow-1", - "dev-ram-private-1", - ]); - expect(mod.legacyRamAssociationNames("dev", undefined, [edge, flow], edge, 0, 6)).toEqual([]); + const accounts = { "111111111111": "app-dev" }; + expect( + mod.legacyRamAssociationNames("dev", undefined, [edge, flow], flow, 1, 6, accounts) + ).toEqual(["dev-ram-flow-1", "dev-ram-private-1"]); + expect( + mod.legacyRamAssociationNames("dev", undefined, [edge, flow], edge, 0, 6, accounts) + ).toEqual([]); }); }); diff --git a/packages/iac-aws/src/shared-vpc.ts b/packages/iac-aws/src/shared-vpc.ts index f7280ca..45f71c2 100644 --- a/packages/iac-aws/src/shared-vpc.ts +++ b/packages/iac-aws/src/shared-vpc.ts @@ -40,24 +40,17 @@ export interface SubnetTier { /** * The workload role this tier holds (e.g. "ztna-connectors", "flow-hosts", "analytics"): * which workloads may have an interface here. Tagged `Role` on the tier's subnets and NACL, - * so a tier's /64s can serve as a network identity. Optional for one release (a tier without - * it logs a deprecation warning); required after that. + * so a tier's /64s can serve as a network identity. Required (0.6.0). */ - role?: string; - - /** - * Deprecated: use `shareWith`. Share this tier via AWS RAM with every account in - * `sharedAccounts` (true) or with none (false). Give exactly one of `shareViaRam` and - * `shareWith`. - */ - shareViaRam?: boolean; + role: string; /** * The accounts this tier's subnets are shared with through AWS RAM, by their names in * `sharedAccounts` (e.g. ["worx-app-dev"]). Empty: the owner account only. Every name must be - * in `sharedAccounts` (fail closed). Give exactly one of `shareViaRam` and `shareWith`. + * in `sharedAccounts` (fail closed). Required (0.6.0: it replaces the removed `shareViaRam`; + * `shareViaRam: true` is `shareWith` naming every account in `sharedAccounts`). */ - shareWith?: string[]; + shareWith: string[]; /** * Names this tier was applied under before a rename. Every resource SharedVpc names after the @@ -125,22 +118,23 @@ export interface SharedVpcArgs { availabilityZones: string[]; /** - * Subnet tier configuration (optional, defaults to 3-tier: public/private/data) - * Allows custom network isolation patterns per tenant/compliance requirement + * Subnet tier configuration (optional, defaults to 3-tier: public/private/data, see + * {@link defaultSubnetTiers}). Allows custom network isolation patterns per tenant/compliance + * requirement. Tiers are named by the workloads they hold. * - * Default (if not specified): + * Example (a shared dev VPC whose app account is worx-app-dev): * [ - * { name: "public", routeToInternet: true, shareViaRam: false }, - * { name: "private", routeToInternet: false, shareViaRam: true }, - * { name: "data", routeToInternet: false, shareViaRam: true } + * { name: "edge", role: "ztna-connectors", routeToInternet: true, shareWith: [], ipv6Slot: 0 }, + * { name: "flow", role: "flow-hosts", routeToInternet: false, shareWith: ["worx-app-dev"], ipv6Slot: 1 }, + * { name: "analytics", role: "analytics", routeToInternet: false, shareWith: [], ipv6Slot: 2 } * ] * * Healthcare tenant example: * [ - * { name: "public", routeToInternet: true, shareViaRam: false }, - * { name: "app", routeToInternet: false, shareViaRam: true }, - * { name: "hipaa-data", routeToInternet: false, shareViaRam: true }, - * { name: "phi-isolated", routeToInternet: false, shareViaRam: true, cidrBits: 8 } + * { name: "public", role: "load-balancers", routeToInternet: true, shareWith: [] }, + * { name: "app", role: "app-services", routeToInternet: false, shareWith: ["care-app-prd"] }, + * { name: "hipaa-data", role: "phi-stores", routeToInternet: false, shareWith: ["care-phi-prd"] }, + * { name: "phi-isolated", role: "phi-isolated", routeToInternet: false, shareWith: [], cidrBits: 8 } * ] */ subnetTiers?: SubnetTier[]; @@ -255,11 +249,12 @@ export interface SharedVpcArgs { * before 0.6.0, in tier order. Before 0.6.0 each subnet's RAM association was named by a running * index across the shared tiers (`-ram--`); it is now named by tier and AZ * (`-ram--`) and carries its old name as an alias. Without this list the - * old names are derived from the tiers that still set `shareViaRam: true` (under their current - * and former names), which is right for a stack that keeps its sharing as it was, renamed or - * not. A stack that moves to `shareWith`, or stops sharing a tier, - * in the same apply must list its old layout here (e.g. ["public", "private", "data"]), or - * every association it keeps is deleted and recreated, which briefly unshares the subnet. + * old names are derived from the tiers in the main share (those whose `shareWith` names every + * account in `sharedAccounts`, what `shareViaRam: true` meant), under their current and former + * names, which is right for a stack that keeps its sharing as it was, renamed or not. A stack + * that narrows or stops sharing a tier in the same apply must list its old layout here (e.g. + * ["public", "private", "data"]), or every association it keeps is deleted and recreated, + * which briefly unshares the subnet. */ legacyRamSharedTiers?: string[]; @@ -390,12 +385,18 @@ export const IPV6_NACL_RULE_OFFSET = 1000; export const IPV6_UDP_RETURN_RULE_NUMBER = 1130; export const IPV6_PACKET_TOO_BIG_RULE_NUMBER = 1150; -/** Default tiers (3-tier: public/private/data), with their IPv6 slots. */ -export const DEFAULT_SUBNET_TIERS: readonly SubnetTier[] = [ - { name: "public", role: "public", routeToInternet: true, shareViaRam: false, ipv6Slot: 0 }, // Ops-only (NAT, LB) - { name: "private", role: "private", routeToInternet: false, shareViaRam: true, ipv6Slot: 1 }, // Shared (app) - { name: "data", role: "data", routeToInternet: false, shareViaRam: true, ipv6Slot: 2 }, // Shared (databases) -]; +/** + * Default tiers (3-tier: public/private/data), with their IPv6 slots: public kept in the owner + * account (NAT, load balancers), private and data shared with every account in `sharedAccounts`. + */ +export function defaultSubnetTiers(sharedAccounts: Readonly>): SubnetTier[] { + const every = [...new Set(Object.values(sharedAccounts))].sort(); + return [ + { name: "public", role: "public", routeToInternet: true, shareWith: [], ipv6Slot: 0 }, + { name: "private", role: "private", routeToInternet: false, shareWith: every, ipv6Slot: 1 }, + { name: "data", role: "data", routeToInternet: false, shareWith: [...every], ipv6Slot: 2 }, + ]; +} /** * The aliases of one resource SharedVpc names after a tier: one per former name, built with the @@ -414,9 +415,10 @@ export function tierNamed(tiers: readonly SubnetTier[], name: string): SubnetTie } /** - * Validate tier names and sharing before any resource is created (fail closed): names and - * former names are unique across tiers; every tier gives exactly one of `shareViaRam` and - * `shareWith`; every `shareWith` name is an account in `sharedAccounts`. + * Validate tier names, roles and sharing before any resource is created (fail closed): names + * and former names are unique across tiers; every tier names its role and the accounts it is + * shared with (`shareViaRam` was removed in 0.6.0); every `shareWith` name is an account in + * `sharedAccounts`. */ export function validateTierSharing( tiers: readonly SubnetTier[], @@ -431,16 +433,22 @@ export function validateTierSharing( } names.set(name, tier.name); } - const legacy = tier.shareViaRam !== undefined; - const explicit = tier.shareWith !== undefined; - if (legacy === explicit) { + if (typeof tier.role !== "string" || tier.role.trim() === "") { + throw new Error(`Tier '${tier.name}' must name its role: the workloads it holds.`); + } + if ("shareViaRam" in tier) { + throw new Error( + `Tier '${tier.name}' sets shareViaRam, removed in 0.6.0: use shareWith (every account in ` + + "sharedAccounts for shareViaRam: true, [] for false)." + ); + } + if (!Array.isArray(tier.shareWith)) { throw new Error( - `Tier '${tier.name}' must give exactly one of shareWith (the accounts it is shared with) ` + - "and the deprecated shareViaRam." + `Tier '${tier.name}' must give shareWith: the accounts it is shared with ([] for none).` ); } const known = new Set(Object.values(sharedAccounts)); - for (const account of tier.shareWith ?? []) { + for (const account of tier.shareWith) { if (!known.has(account)) { throw new Error( `Tier '${tier.name}' shares with '${account}', which is not in sharedAccounts.` @@ -450,15 +458,19 @@ export function validateTierSharing( } } -/** The account names a tier's subnets are shared with (shareViaRam: every shared account). */ -export function tierShareTargets( +/** The account names a tier's subnets are shared with, sorted and without repeats. */ +export function tierShareTargets(tier: SubnetTier): string[] { + return [...new Set(tier.shareWith)].sort(); +} + +/** True when a tier is in the main share: its `shareWith` names every account in `sharedAccounts`. */ +export function isInMainShare( tier: SubnetTier, sharedAccounts: Readonly> -): string[] { - if (tier.shareWith !== undefined) { - return [...new Set(tier.shareWith)].sort(); - } - return tier.shareViaRam === true ? [...new Set(Object.values(sharedAccounts))].sort() : []; +): boolean { + const every = [...new Set(Object.values(sharedAccounts))].sort(); + const targets = tierShareTargets(tier); + return targets.length > 0 && targets.join() === every.join(); } /** @@ -466,8 +478,9 @@ export function tierShareTargets( * across the tiers that were in the main share, in tier order, `azCount` subnets each. * - With `legacySharedTiers` (the old layout, by the names then applied): the one name for the * tier's position in it, or none when the tier was not in it. - * - Without it: the tier's position among the tiers that set `shareViaRam: true`, under each of - * its current and former names (the state holds one of them; extra aliases match nothing). + * - Without it: the tier's position among the tiers in the main share ({@link isInMainShare}, + * what `shareViaRam: true` meant), under each of its current and former names (the state + * holds one of them; extra aliases match nothing). */ export function legacyRamAssociationNames( environment: string, @@ -475,7 +488,8 @@ export function legacyRamAssociationNames( tiers: readonly SubnetTier[], tier: SubnetTier, azPosition: number, - azCount: number + azCount: number, + sharedAccounts: Readonly> ): string[] { const names = [tier.name, ...(tier.formerNames ?? [])]; if (legacySharedTiers !== undefined) { @@ -485,7 +499,7 @@ export function legacyRamAssociationNames( ? [] : [`${environment}-ram-${name}-${position * azCount + azPosition}`]; } - const position = tiers.filter(t => t.shareViaRam === true).indexOf(tier); + const position = tiers.filter(t => isInMainShare(t, sharedAccounts)).indexOf(tier); return position < 0 ? [] : names.map(n => `${environment}-ram-${n}-${position * azCount + azPosition}`); @@ -732,17 +746,8 @@ export class SharedVpc extends pulumi.ComponentResource { } // Subnet tiers (default 3-tier: public/private/data, with IPv6 slots 0/1/2) - const subnetTiers: SubnetTier[] = args.subnetTiers ?? DEFAULT_SUBNET_TIERS.map(t => ({ ...t })); + const subnetTiers: SubnetTier[] = args.subnetTiers ?? defaultSubnetTiers(args.sharedAccounts); validateTierSharing(subnetTiers, args.sharedAccounts); - for (const tier of subnetTiers) { - if (tier.role === undefined) { - void pulumi.log.warn( - `Tier '${tier.name}' declares no role. role becomes required after this release: name ` + - "the workloads the tier holds.", - this - ); - } - } // Options for a resource named after a tier: its former names ride along as aliases. const tierOpts = ( tier: SubnetTier, @@ -892,7 +897,7 @@ export class SharedVpc extends pulumi.ComponentResource { Tier: tier.name, ...(tier.role === undefined ? {} : { Role: tier.role }), Type: tier.routeToInternet ? "public" : "private", - ShareViaRam: String(tierShareTargets(tier, args.sharedAccounts).length > 0), + ShareViaRam: String(tierShareTargets(tier).length > 0), AZ: az, AzId: zoneIdOf(az), }, @@ -1404,7 +1409,7 @@ export class SharedVpc extends pulumi.ComponentResource { Environment: args.environment, Tier: tier.name, Type: "private", - ShareViaRam: String(tierShareTargets(tier, args.sharedAccounts).length > 0), + ShareViaRam: String(tierShareTargets(tier).length > 0), }, }, tierOpts(tier, defaultOpts, t => `${args.environment}-${t}-rt-${azSuffix}`) @@ -1461,7 +1466,7 @@ export class SharedVpc extends pulumi.ComponentResource { Name: sharedRtName, Environment: args.environment, Type: "shared", - SharedByTiers: privateTiers.map(t => t.name).join(","), + SharedByTiers: privateTiers.map(t => t.name).join("+"), }, }, defaultOpts @@ -1807,17 +1812,13 @@ export class SharedVpc extends pulumi.ComponentResource { this.ramShareArn = ramShare.arn; // Each tier's subnets go to the share whose principals are exactly the tier's accounts: the - // main share (every account in sharedAccounts, and every shareViaRam tier), or a share of - // their own for a narrower set. A tier sharing with no account is in no share. - const allAccounts = [...new Set(Object.values(args.sharedAccounts))].sort(); + // main share (every account in sharedAccounts), or a share of their own for a narrower + // set. A tier sharing with no account is in no share. const accountIds = new Map(Object.entries(args.sharedAccounts).map(([id, n]) => [n, id])); const narrowShares = new Map(); const shareFor = (tier: SubnetTier): pulumi.Output | undefined => { - const targets = tierShareTargets(tier, args.sharedAccounts); - if ( - tier.shareViaRam === true || - (targets.length > 0 && targets.join() === allAccounts.join()) - ) { + const targets = tierShareTargets(tier); + if (isInMainShare(tier, args.sharedAccounts)) { return ramShare.arn; } if (targets.length === 0) { @@ -1868,7 +1869,8 @@ export class SharedVpc extends pulumi.ComponentResource { subnetTiers, tier, i, - args.availabilityZones.length + args.availabilityZones.length, + args.sharedAccounts ); const aliases = [ ...tierAliases(tier, t => `${args.environment}-ram-${t}-${azSuffix}`), diff --git a/packages/iac-aws/src/shared-vpc.unit.test.ts b/packages/iac-aws/src/shared-vpc.unit.test.ts index 22d10cd..a20dd7b 100644 --- a/packages/iac-aws/src/shared-vpc.unit.test.ts +++ b/packages/iac-aws/src/shared-vpc.unit.test.ts @@ -105,6 +105,8 @@ beforeAll(async () => { // Dev shape: IPv6, no NAT, default tiers, endpoints incl. one IPv4-only service const dev = new mod.SharedVpc("v6dev", { ...baseArgs("v6dev"), + // The default tiers share private and data with every shared account + sharedAccounts: { "111111111111": "app-dev" }, availabilityZones: ["us-east-1a", "us-east-1b"], natGatewayCount: 0, enableIpv6: true, @@ -348,8 +350,8 @@ describe("Construction-time validation", () => { natGatewayCount: 1, enableIpv6: true, subnetTiers: [ - { name: "public", routeToInternet: true, shareViaRam: false, ipv6Slot: 0 }, - { name: "private", routeToInternet: false, shareViaRam: true }, + { name: "public", role: "public", routeToInternet: true, shareWith: [], ipv6Slot: 0 }, + { name: "private", role: "private", routeToInternet: false, shareWith: [] }, ], }) ).toThrow(/'private' has no ipv6Slot/); @@ -364,8 +366,14 @@ describe("Construction-time validation", () => { natGatewayCount: 1, enableIpv6: true, subnetTiers: [ - { name: "public", routeToInternet: true, shareViaRam: false, ipv6Slot: 0 }, - { name: "private", routeToInternet: false, shareViaRam: true, ipv6Slot: 0 }, + { name: "public", role: "public", routeToInternet: true, shareWith: [], ipv6Slot: 0 }, + { + name: "private", + role: "private", + routeToInternet: false, + shareWith: [], + ipv6Slot: 0, + }, ], }) ).toThrow(/both declare ipv6Slot 0/); @@ -378,7 +386,7 @@ describe("Construction-time validation", () => { ...baseArgs("v4tiers"), availabilityZones: ["us-east-1a"], natGatewayCount: 1, - subnetTiers: [{ name: "public", routeToInternet: true, shareViaRam: false }], + subnetTiers: [{ name: "public", role: "public", routeToInternet: true, shareWith: [] }], }) ).not.toThrow(); }); @@ -538,7 +546,13 @@ describe("Security controls", () => { }); }); - it("shares only shareViaRam tiers, never to external principals", { + it("tags the shared route table with a SharedByTiers value AWS accepts (tiers joined by +)", () => { + const tags = named("v6dev-shared-rt")?.["tags"] as Record | undefined; + expect(tags?.["SharedByTiers"]).toBe("private+data"); + expect(tags?.["SharedByTiers"]).toMatch(/^[\p{L}\p{Z}\p{N}_.:/=+\-@]*$/u); + }); + + it("shares only the tiers whose shareWith names accounts, never to external principals", { meta: { id: "ram-sharing-tier-isolation", compliance: ["ISO27001:A.13.1.3", "ISO27001:A.9.4.1"], From e7a0b33f1387b66e4d994fd06e5e24082f287ebc Mon Sep 17 00:00:00 2001 From: Lloyd Mangnall Date: Sun, 25 Oct 2026 13:20:08 -0600 Subject: [PATCH 3/3] docs(repo): SharedVpc tier examples use role and shareWith The root README and docs/security-implementation.md showed the shareViaRam tiers that iac-aws 0.6.0 removes. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 8 ++++---- docs/security-implementation.md | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index a179907..3e924bd 100644 --- a/README.md +++ b/README.md @@ -55,12 +55,12 @@ const vpc = new SharedVpc("dev-use1", { natGatewayCount: 3, enableIpv6: true, subnetTiers: [ - { name: "public", routeToInternet: true, shareViaRam: false, ipv6Slot: 0 }, - { name: "private", routeToInternet: false, shareViaRam: true, ipv6Slot: 1 }, - { name: "data", routeToInternet: false, shareViaRam: true, ipv6Slot: 2 }, + { name: "public", role: "load-balancers", routeToInternet: true, shareWith: [], ipv6Slot: 0 }, + { name: "private", role: "app-services", routeToInternet: false, shareWith: ["worx-app-dev"], ipv6Slot: 1 }, + { name: "data", role: "data-stores", routeToInternet: false, shareWith: ["worx-app-dev"], ipv6Slot: 2 }, ], flowLogs: { enabled: true, trafficType: "ALL" }, - sharedAccounts: {}, + sharedAccounts: { "111111111111": "worx-app-dev" }, tags: {}, }); diff --git a/docs/security-implementation.md b/docs/security-implementation.md index 061c9df..dad7af2 100644 --- a/docs/security-implementation.md +++ b/docs/security-implementation.md @@ -567,10 +567,10 @@ const vpc = new SharedVpc("care-prd-vpc", { "kms", // Encryption key management ], subnetTiers: [ - { name: "public", routeToInternet: true, shareViaRam: false, ipv6Slot: 0 }, - { name: "app", routeToInternet: false, shareViaRam: true, ipv6Slot: 1 }, - { name: "hipaa-data", routeToInternet: false, shareViaRam: true, ipv6Slot: 2 }, - { name: "phi-isolated", routeToInternet: false, shareViaRam: true, cidrBits: 8, ipv6Slot: 3 }, + { name: "public", role: "load-balancers", routeToInternet: true, shareWith: [], ipv6Slot: 0 }, + { name: "app", role: "app-services", routeToInternet: false, shareWith: ["care-app-prd"], ipv6Slot: 1 }, + { name: "hipaa-data", role: "phi-stores", routeToInternet: false, shareWith: ["care-phi-prd"], ipv6Slot: 2 }, + { name: "phi-isolated", role: "phi-isolated", routeToInternet: false, shareWith: [], cidrBits: 8, ipv6Slot: 3 }, ], }); ``` @@ -697,7 +697,7 @@ const privateRt = new aws.ec2.RouteTable({ ...args.tags, Tier: tier.name, Type: "private", - ShareViaRam: tier.shareViaRam.toString(), // ← Audit visibility + ShareViaRam: String(tierShareTargets(tier).length > 0), // ← Audit visibility (tier.shareWith) }, }); ```