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) }, }); ``` 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"], 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,