aws2-7 Exercise 2: The Cluster in the CDK: L1 versus L2, Lint Mutations, Tests ========================================================================= Run with Windows 11, Python 3.14 (py launcher) and a venv with jsonschema 4.26.0, PyYAML, cfn-lint 1.57.2; eksctl 0.231.0 and kind 0.31.0 downloaded as binaries into C:/lwaws/c7/bin (SHA-256 checked against the published checksums; not installed); Node 24.18.0 with aws-cdk 2.1145.0, aws-cdk-lib 2.273.0, constructs 10.8.1, typescript 7.0.2, jest 30.5.2 (the chapter 5 install, reached from a junction); kubectl client 1.32.2 (Docker Desktop's). Scratch folder C:/lwaws/c7, nothing installed globally. VERIFICATION LEVEL: 1 for the offline checks below (no AWS account, no credentials, no cluster created). A kind cluster (Kubernetes v1.35.0) was created but Docker Desktop's engine stopped before any experiment ran, so no Kubernetes API server result is reported. Outputs below are saved from the real runs of 2026-10-13. --- package.json --- { "name": "reports-cdk", "version": "1.0.0", "private": true, "scripts": { "build": "tsc -p tsconfig.json", "test": "node node_modules/jest/bin/jest.js" }, "jest": { "testMatch": [ "**/dist/test/**/*.test.js" ], "testEnvironment": "node" }, "dependencies": { "@types/jest": "30.0.0", "@types/node": "26.6.5", "aws-cdk": "2.1145.0", "aws-cdk-lib": "2.273.0", "constructs": "10.8.1", "jest": "30.5.2", "typescript": "7.0.2" } } --- tsconfig.json --- { "compilerOptions": { "target": "ES2022", "module": "node16", "moduleResolution": "node16", "lib": ["es2022"], "outDir": "dist", "rootDir": ".", "strict": true, "noUnusedLocals": true, "noImplicitReturns": true, "esModuleInterop": true, "skipLibCheck": true, "types": ["node", "jest"] }, "include": ["bin/**/*.ts", "lib/**/*.ts", "test/**/*.ts"], "exclude": ["node_modules", "dist", "cdk.out"] } --- cdk.json --- { "app": "node dist/bin/app.js", "context": { "environment": "dev" } } --- bin/app.ts --- import * as cdk from "aws-cdk-lib"; import { EksL1Stack } from "../lib/eks-l1-stack"; import { EksL2Stack } from "../lib/eks-l2-stack"; const app = new cdk.App(); new EksL1Stack(app, "EksL1"); new EksL2Stack(app, "EksL2"); --- lib/eks-l1-stack.ts --- import * as cdk from "aws-cdk-lib"; import { Construct } from "constructs"; import * as ec2 from "aws-cdk-lib/aws-ec2"; import * as eks from "aws-cdk-lib/aws-eks"; import * as iam from "aws-cdk-lib/aws-iam"; // Only L1 constructs for the EKS parts: every property is one you wrote, so the template is short and nothing is created behind your back. export class EksL1Stack extends cdk.Stack { constructor(scope: Construct, id: string, props?: cdk.StackProps) { super(scope, id, props); const vpc = new ec2.Vpc(this, "Vpc", { maxAzs: 2, natGateways: 0, subnetConfiguration: [{ name: "public", subnetType: ec2.SubnetType.PUBLIC }] }); const clusterRole = new iam.Role(this, "ClusterRole", { assumedBy: new iam.ServicePrincipal("eks.amazonaws.com"), managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName("AmazonEKSClusterPolicy")], }); const nodeRole = new iam.Role(this, "NodeRole", { assumedBy: new iam.ServicePrincipal("ec2.amazonaws.com"), managedPolicies: [ iam.ManagedPolicy.fromAwsManagedPolicyName("AmazonEKSWorkerNodePolicy"), iam.ManagedPolicy.fromAwsManagedPolicyName("AmazonEC2ContainerRegistryReadOnly"), iam.ManagedPolicy.fromAwsManagedPolicyName("AmazonEKS_CNI_Policy"), ], }); const cluster = new eks.CfnCluster(this, "Cluster", { name: "demo", version: "1.35", roleArn: clusterRole.roleArn, resourcesVpcConfig: { subnetIds: vpc.publicSubnets.map((s) => s.subnetId), endpointPublicAccess: true, endpointPrivateAccess: true, publicAccessCidrs: ["203.0.113.0/24"], }, accessConfig: { authenticationMode: "API" }, upgradePolicy: { supportType: "STANDARD" }, logging: { clusterLogging: { enabledTypes: [{ type: "api" }, { type: "audit" }, { type: "authenticator" }] } }, }); const nodes = new eks.CfnNodegroup(this, "Nodes", { clusterName: cluster.ref, nodeRole: nodeRole.roleArn, subnets: vpc.publicSubnets.map((s) => s.subnetId), instanceTypes: ["t3.medium"], amiType: "AL2023_x86_64_STANDARD", scalingConfig: { minSize: 1, maxSize: 3, desiredSize: 2 }, updateConfig: { maxUnavailable: 1 }, }); for (const addon of ["vpc-cni", "kube-proxy", "coredns", "eks-pod-identity-agent"]) { const a = new eks.CfnAddon(this, "Addon-" + addon, { clusterName: cluster.ref, addonName: addon }); a.addDependency(nodes); } const appRole = new iam.Role(this, "AppRole", { assumedBy: new iam.ServicePrincipal("pods.eks.amazonaws.com") }); appRole.assumeRolePolicy!.addStatements(new iam.PolicyStatement({ principals: [new iam.ServicePrincipal("pods.eks.amazonaws.com")], actions: ["sts:TagSession"] })); new eks.CfnPodIdentityAssociation(this, "Assoc", { clusterName: cluster.ref, namespace: "apps", serviceAccount: "reports", roleArn: appRole.roleArn }); } } --- lib/eks-l2-stack.ts (kubectlLayer is a placeholder ARN so the stack can be synthesised; it could not deploy as written) --- import * as cdk from "aws-cdk-lib"; import { Construct } from "constructs"; import * as ec2 from "aws-cdk-lib/aws-ec2"; import * as eks from "aws-cdk-lib/aws-eks"; import * as lambda from "aws-cdk-lib/aws-lambda"; // The same idea with the L2 Cluster construct. The kubectl layer is required by the construct; a placeholder ARN is used // so that the stack can be synthesised offline (it is never deployed). export class EksL2Stack extends cdk.Stack { constructor(scope: Construct, id: string, props?: cdk.StackProps) { super(scope, id, props); const vpc = new ec2.Vpc(this, "Vpc", { maxAzs: 2, natGateways: 1 }); const cluster = new eks.Cluster(this, "Cluster", { version: eks.KubernetesVersion.V1_35, vpc, defaultCapacity: 0, kubectlLayer: lambda.LayerVersion.fromLayerVersionArn(this, "Kubectl", "arn:aws:lambda:eu-west-1:111122223333:layer:kubectl:1"), }); cluster.addNodegroupCapacity("Nodes", { instanceTypes: [new ec2.InstanceType("t3.medium")], minSize: 1, maxSize: 3, desiredSize: 2 }); cluster.addServiceAccount("AppSa", { name: "reports", namespace: "apps" }); } } --- cdk synth -q (stderr/time saved in out/synth_run.txt; exit status 0, about 16 s; it prints a deprecation warning for CfnResource#addDependency) --- This API will be removed in the next major release. [WARNING] aws-cdk-lib.CfnResource#addDependency is deprecated. Use `addResourceDependency` instead. This API will be removed in the next major release. [WARNING] aws-cdk-lib.CfnResource#addDependency is deprecated. Use `addResourceDependency` instead. This API will be removed in the next major release. Successfully synthesized to C:\lwaws\c7\cdk.out Supply a stack id (EksL1, EksL2) to display its template. 84 feature flags are not configured. Run 'cdk flags --unstable=flags' to learn more. real 0m15.775s user 0m0.015s sys 0m0.015s --- analyze.py (reads the files in cdk.out, not the printed YAML) --- """Summarise the synthesised EKS templates in cdk.out (read from the files, not the printed YAML).""" import collections, glob, json, os def load(p): return json.load(open(p, encoding="utf-8")) def types(t): return collections.Counter(v["Type"] for v in t["Resources"].values()) files = {"EksL1 (L1 only)": "cdk.out/EksL1.template.json", "EksL2 (L2 Cluster)": "cdk.out/EksL2.template.json"} nested = sorted(glob.glob("cdk.out/EksL2*.nested.template.json")) for label, p in files.items(): t = load(p); c = types(t) print("== %s: %d resources, %d types" % (label, len(t["Resources"]), len(c))) if label.startswith("EksL1"): for k, n in sorted(c.items()): print(" %2d %s" % (n, k)) else: print(" top-level types:", ", ".join("%s x%d" % (k.replace("AWS::", ""), n) for k, n in sorted(c.items()))) for p in nested: t = load(p); c = types(t) print("== nested template %s: %d resources: %s" % (os.path.basename(p)[:60], len(t["Resources"]), ", ".join("%s x%d" % (k.replace("AWS::", ""), n) for k, n in sorted(c.items())))) l2 = load(files["EksL2 (L2 Cluster)"]) print() print("L2 stack: custom resources:", sorted({v["Type"] for v in l2["Resources"].values() if v["Type"].startswith("Custom::") or v["Type"] == "AWS::CloudFormation::Stack"})) print("L2 stack: NAT gateways:", sum(v["Type"] == "AWS::EC2::NatGateway" for v in l2["Resources"].values()), "| Lambda functions in nested stacks:", sum(v["Type"] == "AWS::Lambda::Function" for p in nested for v in load(p)["Resources"].values())) def policies(t): return sorted({m for v in t["Resources"].values() if v["Type"] == "AWS::IAM::Role" for mp in v["Properties"].get("ManagedPolicyArns", []) for m in [json.dumps(mp)] }) import re def mpols(t): out = set() for v in t["Resources"].values(): if v["Type"] == "AWS::IAM::Role": for mp in v["Properties"].get("ManagedPolicyArns", []): m = re.findall(r"policy/([A-Za-z0-9_+=,.@/-]+)", json.dumps(mp)) out.update(m) return out l1 = load(files["EksL1 (L1 only)"]) print("managed policies, L1 (my names):", sorted(mpols(l1))) print("managed policies, L2 (library's):", sorted(mpols(l2) | {m for p in nested for m in mpols(load(p))})) print() for k, v in l2["Resources"].items(): if v["Type"] == "AWS::IAM::Role" and "AppSa" in k: print("IRSA role trust policy generated by cluster.addServiceAccount (%s):" % k) print(json.dumps(v["Properties"]["AssumeRolePolicyDocument"], indent=1)[:1500]) for k, v in l2["Resources"].items(): if "OpenIdConnect" in v["Type"] or v["Type"] == "Custom::AWSCDKOpenIdConnectProvider": print("OIDC provider resource:", k, v["Type"]) cl = [v for v in l2["Resources"].values() if v["Type"] == "Custom::AWSCDK-EKS-Cluster"] if cl: cfg = cl[0]["Properties"]["Config"]; print("cluster custom resource config keys:", sorted(cfg)) print(" version", cfg.get("version"), "| resourcesVpcConfig keys", sorted(cfg.get("resourcesVpcConfig", {}))) --- python analyze.py (saved output of the real run) --- == EksL1 (L1 only): 22 resources, 13 types 1 AWS::CDK::Metadata 1 AWS::EC2::InternetGateway 2 AWS::EC2::Route 2 AWS::EC2::RouteTable 2 AWS::EC2::Subnet 2 AWS::EC2::SubnetRouteTableAssociation 1 AWS::EC2::VPC 1 AWS::EC2::VPCGatewayAttachment 4 AWS::EKS::Addon 1 AWS::EKS::Cluster 1 AWS::EKS::Nodegroup 1 AWS::EKS::PodIdentityAssociation 3 AWS::IAM::Role == EksL2 (L2 Cluster): 43 resources, 21 types top-level types: CDK::Metadata x1, CloudFormation::Stack x2, EC2::EIP x1, EC2::InternetGateway x1, EC2::NatGateway x1, EC2::Route x4, EC2::RouteTable x4, EC2::SecurityGroup x1, EC2::Subnet x4, EC2::SubnetRouteTableAssociation x4, EC2::VPC x1, EC2::VPCGatewayAttachment x1, EKS::Nodegroup x1, IAM::Policy x2, IAM::Role x7, Lambda::Function x2, SSM::Parameter x1, Custom::AWSCDK-EKS-Cluster x1, Custom::AWSCDK-EKS-KubernetesResource x2, Custom::AWSCDKCfnJson x1, Custom::AWSCDKOpenIdConnectProvider x1 == nested template EksL2awscdkawseksClusterResourceProviderC158A3E5.nested.temp: 18 resources: CDK::Metadata x1, IAM::Policy x4, IAM::Role x6, Lambda::Function x5, Lambda::LayerVersion x1, StepFunctions::StateMachine x1 == nested template EksL2awscdkawseksKubectlProvider86078FD4.nested.template.jso: 6 resources: CDK::Metadata x1, IAM::Policy x1, IAM::Role x1, Lambda::Function x2, Lambda::LayerVersion x1 L2 stack: custom resources: ['AWS::CloudFormation::Stack', 'Custom::AWSCDK-EKS-Cluster', 'Custom::AWSCDK-EKS-KubernetesResource', 'Custom::AWSCDKCfnJson', 'Custom::AWSCDKOpenIdConnectProvider'] L2 stack: NAT gateways: 1 | Lambda functions in nested stacks: 7 managed policies, L1 (my names): ['AmazonEC2ContainerRegistryReadOnly', 'AmazonEKSClusterPolicy', 'AmazonEKSWorkerNodePolicy', 'AmazonEKS_CNI_Policy'] managed policies, L2 (library's): ['AmazonEC2ContainerRegistryPullOnly', 'AmazonEC2ContainerRegistryReadOnly', 'AmazonEKSClusterPolicy', 'AmazonEKSWorkerNodePolicy', 'AmazonEKS_CNI_Policy', 'AmazonElasticContainerRegistryPublicReadOnly', 'service-role/AWSLambdaBasicExecutionRole', 'service-role/AWSLambdaVPCAccessExecutionRole'] IRSA role trust policy generated by cluster.addServiceAccount (ClusterAppSaRole69C69767): { "Statement": [ { "Action": "sts:AssumeRoleWithWebIdentity", "Condition": { "StringEquals": { "Fn::GetAtt": [ "ClusterAppSaConditionJsonCE35DCA9", "Value" ] } }, "Effect": "Allow", "Principal": { "Federated": { "Ref": "ClusterOpenIdConnectProviderE7EB0530" } } } ], "Version": "2012-10-17" } OIDC provider resource: ClusterOpenIdConnectProviderE7EB0530 Custom::AWSCDKOpenIdConnectProvider cluster custom resource config keys: ['accessConfig', 'kubernetesNetworkConfig', 'resourcesVpcConfig', 'roleArn', 'version'] version 1.35 | resourcesVpcConfig keys ['endpointPrivateAccess', 'endpointPublicAccess', 'securityGroupIds', 'subnetIds'] --- roles (saved output of the real run) --- managed policies attached to each IAM role in the EksL2 stack (top-level and nested templates): ClusterKubectlHandlerRole94549F93 service-role/AWSLambdaBasicExecutionRole, service-role/AWSLambdaVPCAccessExecutionRole, AmazonEC2ContainerRegistryPullOnly, AmazonElasticContainerRegistryPublicReadOnly ClusterRoleFA261979 AmazonEKSClusterPolicy ClusterNodegroupNodesNodeGroupRole7AC14175 AmazonEKSWorkerNodePolicy, AmazonEKS_CNI_Policy, AmazonEC2ContainerRegistryReadOnly (roles that only carry AWSLambdaBasicExecutionRole are the other Lambda execution roles of the custom resource providers and are left out) --- cfn-lint on the four templates (saved output of the real run) --- $ cfn-lint EksL1.template.json (exit status 0) $ cfn-lint EksL2.template.json W3005 'ClusterCreationRole360249B6' dependency already enforced by a 'GetAtt' at 'Resources/Cluster9EE0221C/Properties/AssumeRoleArn' cdk.out\EksL2.template.json:864:5 W3005 'VpcPrivateSubnet1Subnet536B997A' dependency already enforced by a 'Ref' at 'Resources/Cluster9EE0221C/Properties/Config/resourcesVpcConfig/subnetIds/2' cdk.out\EksL2.template.json:869:5 W3005 'VpcPrivateSubnet2Subnet3788AAA1' dependency already enforced by a 'Ref' at 'Resources/Cluster9EE0221C/Properties/Config/resourcesVpcConfig/subnetIds/3' cdk.out\EksL2.template.json:873:5 W3005 'VpcPublicSubnet1Subnet5C2D37C4' dependency already enforced by a 'Ref' at 'Resources/Cluster9EE0221C/Properties/Config/resourcesVpcConfig/subnetIds/0' cdk.out\EksL2.template.json:879:5 W3005 'VpcPublicSubnet2Subnet691E08A3' dependency already enforced by a 'Ref' at 'Resources/Cluster9EE0221C/Properties/Config/resourcesVpcConfig/subnetIds/1' cdk.out\EksL2.template.json:883:5 W3005 'ClusterKubectlHandlerRole94549F93' dependency already enforced by a 'GetAtt' at 'Resources/awscdkawseksKubectlProviderNestedStackawscdkawseksKubectlProviderNestedStackResourceA7AEBA6B/Properties/Parameters/referencetoEksL2C cdk.out\EksL2.template.json:1277:5 W3005 'CustomAWSCDKOpenIdConnectProviderCustomResourceProviderRole517FED65' dependency already enforced by a 'GetAtt' at 'Resources/CustomAWSCDKOpenIdConnectProviderCustomResourceProviderHandlerF2C543E0/Properties/Role' cdk.out\EksL2.template.json:1358:5 W3005 'AWSCDKCfnUtilsProviderCustomResourceProviderRoleFE0EE867' dependency already enforced by a 'GetAtt' at 'Resources/AWSCDKCfnUtilsProviderCustomResourceProviderHandlerCF82AA57/Properties/Role' cdk.out\EksL2.template.json:1412:5 (exit status 4) $ cfn-lint EksL2awscdkawseksClusterResourceProvider W3005 'OnEventHandlerServiceRole15A26729' dependency already enforced by a 'GetAtt' at 'Resources/OnEventHandler42BEBAE0/Properties/Role' cdk.out\EksL2awscdkawseksClusterResourceProviderC158A3E5.nested.template.json:86:5 W3005 'IsCompleteHandlerServiceRole5810CC58' dependency already enforced by a 'GetAtt' at 'Resources/IsCompleteHandler7073F4DA/Properties/Role' cdk.out\EksL2awscdkawseksClusterResourceProviderC158A3E5.nested.template.json:160:5 W3005 'ProviderframeworkonEventServiceRole9FF04296' dependency already enforced by a 'GetAtt' at 'Resources/ProviderframeworkonEvent83C1D0A7/Properties/Role' cdk.out\EksL2awscdkawseksClusterResourceProviderC158A3E5.nested.template.json:346:5 W3005 'ProviderframeworkisCompleteServiceRoleB1087139' dependency already enforced by a 'GetAtt' at 'Resources/ProviderframeworkisComplete26D7B0CB/Properties/Role' cdk.out\EksL2awscdkawseksClusterResourceProviderC158A3E5.nested.template.json:522:5 W3005 'ProviderframeworkonTimeoutServiceRole28643D26' dependency already enforced by a 'GetAtt' at 'Resources/ProviderframeworkonTimeout0B47CA38/Properties/Role' cdk.out\EksL2awscdkawseksClusterResourceProviderC158A3E5.nested.template.json:698:5 W3005 'ProviderwaiterstatemachineRole0C7159F9' dependency already enforced by a 'GetAtt' at 'Resources/Providerwaiterstatemachine5D4A9DF0/Properties/RoleArn' cdk.out\EksL2awscdkawseksClusterResourceProviderC158A3E5.nested.template.json:832:5 (exit status 4) $ cfn-lint EksL2awscdkawseksKubectlProvider86078FD4 W3005 'ProviderframeworkonEventServiceRole9FF04296' dependency already enforced by a 'GetAtt' at 'Resources/ProviderframeworkonEvent83C1D0A7/Properties/Role' cdk.out\EksL2awscdkawseksKubectlProvider86078FD4.nested.template.json:224:5 (exit status 4) --- mutate_lint.py --- """Edit one property of the synthesised EksL1 template at a time and see whether cfn-lint objects (it validates against the AWS resource schemas it bundles).""" import copy, json, subprocess t = json.load(open("cdk.out/EksL1.template.json", encoding="utf-8")) R = t["Resources"] find = lambda ty: [k for k, v in R.items() if v["Type"] == ty] cl, ng, pa = find("AWS::EKS::Cluster")[0], find("AWS::EKS::Nodegroup")[0], find("AWS::EKS::PodIdentityAssociation")[0] ad = find("AWS::EKS::Addon")[0] def P(k): return lambda x: x["Resources"][k]["Properties"] cases = [ ("the template as synthesised", lambda x: None), ("cluster version 1.35 -> 135", lambda x: P(cl)(x).update(Version="135")), ("cluster version 1.35 -> 'latest'", lambda x: P(cl)(x).update(Version="latest")), ("upgradePolicy.supportType 'STANDARD' -> 'PREMIUM'", lambda x: P(cl)(x)["UpgradePolicy"].update(SupportType="PREMIUM")), ("accessConfig.authenticationMode -> 'SAML'", lambda x: P(cl)(x)["AccessConfig"].update(AuthenticationMode="SAML")), ("logging type 'api' -> 'apis'", lambda x: P(cl)(x)["Logging"]["ClusterLogging"]["EnabledTypes"][0].update(Type="apis")), ("endpointPublicAccess 'true' -> 'yes'", lambda x: P(cl)(x)["ResourcesVpcConfig"].update(EndpointPublicAccess="yes")), ("publicAccessCidrs -> 'anywhere'", lambda x: P(cl)(x)["ResourcesVpcConfig"].update(PublicAccessCidrs=["anywhere"])), ("nodegroup amiType -> 'AL3'", lambda x: P(ng)(x).update(AmiType="AL3")), ("nodegroup minSize 1 -> 5 (above maxSize 3)", lambda x: P(ng)(x)["ScalingConfig"].update(MinSize=5)), ("nodegroup scalingConfig.desiredSize -> 'two'", lambda x: P(ng)(x)["ScalingConfig"].update(DesiredSize="two")), ("nodegroup instance type 't3.medium' -> 't3.huge'", lambda x: P(ng)(x).update(InstanceTypes=["t3.huge"])), ("nodegroup updateConfig.maxUnavailable -> 0", lambda x: P(ng)(x)["UpdateConfig"].update(MaxUnavailable=0)), ("addon name -> 'vpc-cni2'", lambda x: P(ad)(x).update(AddonName="vpc-cni2")), ("pod identity namespace missing", lambda x: P(pa)(x).pop("Namespace")), ("pod identity serviceAccount -> 'Reports_SA' (not a DNS name)", lambda x: P(pa)(x).update(ServiceAccount="Reports_SA")), ] print("%-62s %s" % ("change", "cfn-lint")) for label, f in cases: x = copy.deepcopy(t); f(x); json.dump(x, open("mut.json", "w")) r = subprocess.run(["cfn-lint", "-f", "parseable", "mut.json"], capture_output=True, text=True) msgs = [l.split(":", 5)[-1].strip() for l in r.stdout.splitlines() if l.strip()] print("%-62s exit %d %s" % (label, r.returncode, ("; ".join(m[:95] for m in msgs[:2]) or "-"))) --- python mutate_lint.py (saved output of the real run) --- change cfn-lint the template as synthesised exit 0 - cluster version 1.35 -> 135 exit 2 E3031:'135' does not match '1\\.\\d\\d' cluster version 1.35 -> 'latest' exit 2 E3031:'latest' does not match '1\\.\\d\\d' upgradePolicy.supportType 'STANDARD' -> 'PREMIUM' exit 2 E3030:'PREMIUM' is not one of ['STANDARD', 'EXTENDED'] accessConfig.authenticationMode -> 'SAML' exit 2 E3030:'SAML' is not one of ['CONFIG_MAP', 'API_AND_CONFIG_MAP', 'API'] logging type 'api' -> 'apis' exit 2 E3030:'apis' is not one of ['api', 'audit', 'authenticator', 'controllerManager', 'scheduler'] endpointPublicAccess 'true' -> 'yes' exit 2 E3012:'yes' is not of type 'boolean' publicAccessCidrs -> 'anywhere' exit 0 - nodegroup amiType -> 'AL3' exit 2 E3030:'AL3' is not one of ['AL2023_ARM_64_NVIDIA', 'AL2023_ARM_64_STANDARD', 'AL2023_x86_64_NEU nodegroup minSize 1 -> 5 (above maxSize 3) exit 0 - nodegroup scalingConfig.desiredSize -> 'two' exit 2 E3012:'two' is not of type 'integer' nodegroup instance type 't3.medium' -> 't3.huge' exit 0 - nodegroup updateConfig.maxUnavailable -> 0 exit 2 E3034:0 is less than the minimum of 1 addon name -> 'vpc-cni2' exit 0 - pod identity namespace missing exit 2 E3003:'Namespace' is a required property pod identity serviceAccount -> 'Reports_SA' (not a DNS name) exit 0 - --- test/eks.test.ts --- import * as cdk from "aws-cdk-lib"; import { Match, Template } from "aws-cdk-lib/assertions"; import { EksL1Stack } from "../lib/eks-l1-stack"; import { EksL2Stack } from "../lib/eks-l2-stack"; const l1 = Template.fromStack(new EksL1Stack(new cdk.App(), "EksL1")); const l2 = Template.fromStack(new EksL2Stack(new cdk.App(), "EksL2")); const cluster = () => Object.values(l1.findResources("AWS::EKS::Cluster"))[0] as any; const roles = () => Object.values(l1.findResources("AWS::IAM::Role")) as any[]; const roleFor = (principal: string) => roles().find((r) => JSON.stringify(r.Properties.AssumeRolePolicyDocument).includes(principal)); test("the cluster version is a pinned string and the upgrade policy is STANDARD", () => { l1.hasResourceProperties("AWS::EKS::Cluster", { Version: "1.35", UpgradePolicy: { SupportType: "STANDARD" } }); }); test("the public endpoint is limited to listed CIDRs, never 0.0.0.0/0", () => { const v = cluster().Properties.ResourcesVpcConfig; expect(v.EndpointPublicAccess).toBe(true); expect(v.PublicAccessCidrs.length).toBeGreaterThan(0); expect(v.PublicAccessCidrs).not.toContain("0.0.0.0/0"); }); test("API-only authentication mode (access entries, no aws-auth ConfigMap)", () => { l1.hasResourceProperties("AWS::EKS::Cluster", { AccessConfig: { AuthenticationMode: "API" } }); }); test("audit and authenticator logs are enabled", () => { const types = cluster().Properties.Logging.ClusterLogging.EnabledTypes.map((t: any) => t.Type); expect(types).toEqual(expect.arrayContaining(["audit", "authenticator"])); }); test("the node role has exactly the three worker policies", () => { const r = roleFor("ec2.amazonaws.com"); const names = r.Properties.ManagedPolicyArns.map((a: any) => JSON.stringify(a).match(/policy\/([A-Za-z0-9_]+)/)![1]).sort(); expect(names).toEqual(["AmazonEC2ContainerRegistryReadOnly", "AmazonEKSWorkerNodePolicy", "AmazonEKS_CNI_Policy"]); }); test("the pod role trusts only pods.eks.amazonaws.com and the association names one namespace and service account", () => { const r = roleFor("pods.eks.amazonaws.com"); const principals = r.Properties.AssumeRolePolicyDocument.Statement.map((s: any) => JSON.stringify(s.Principal)); for (const p of principals) expect(p).toBe(JSON.stringify({ Service: "pods.eks.amazonaws.com" })); l1.hasResourceProperties("AWS::EKS::PodIdentityAssociation", { Namespace: "apps", ServiceAccount: "reports" }); }); test("node group updates one node at a time and the Pod Identity agent add-on is installed", () => { l1.hasResourceProperties("AWS::EKS::Nodegroup", { UpdateConfig: { MaxUnavailable: 1 } }); l1.hasResourceProperties("AWS::EKS::Addon", { AddonName: "eks-pod-identity-agent" }); }); test("no NAT gateway in the L1 stack", () => { l1.resourceCountIs("AWS::EC2::NatGateway", 0); }); test("the L2 Cluster does not create an AWS::EKS::Cluster: it uses a custom resource", () => { l2.resourceCountIs("AWS::EKS::Cluster", 0); l2.hasResourceProperties("Custom::AWSCDK-EKS-Cluster", Match.anyValue()); }); test("the L2 IRSA trust policy condition is not visible in the template (it is a custom resource value)", () => { const roles2 = Object.values(l2.findResources("AWS::IAM::Role")) as any[]; const irsa = roles2.find((r) => JSON.stringify(r.Properties.AssumeRolePolicyDocument).includes("AssumeRoleWithWebIdentity")); const cond = irsa.Properties.AssumeRolePolicyDocument.Statement[0].Condition.StringEquals; expect(JSON.stringify(cond)).toContain("Fn::GetAtt"); expect(JSON.stringify(cond)).not.toContain("system:serviceaccount"); }); --- jest (saved output of the real run) --- passed 10 failed 0 PASSED - the cluster version is a pinned string and the upgrade policy is STANDARD PASSED - the public endpoint is limited to listed CIDRs, never 0.0.0.0/0 PASSED - API-only authentication mode (access entries, no aws-auth ConfigMap) PASSED - audit and authenticator logs are enabled PASSED - the node role has exactly the three worker policies PASSED - the pod role trusts only pods.eks.amazonaws.com and the association names one namespace and service account PASSED - node group updates one node at a time and the Pod Identity agent add-on is installed PASSED - no NAT gateway in the L1 stack PASSED - the L2 Cluster does not create an AWS::EKS::Cluster: it uses a custom resource PASSED - the L2 IRSA trust policy condition is not visible in the template (it is a custom resource value) --- control: sed 's#203.0.113.0/24#0.0.0.0/0#' on dist/lib/eks-l1-stack.js, then jest -t 'public endpoint' (saved output) --- ● the public endpoint is limited to listed CIDRs, never 0.0.0.0/0 Tests: 1 failed, 9 skipped, 10 total (rebuilding with tsc restored the original and the test passed again: 'Tests: 9 skipped, 1 passed, 10 total') WHY THIS WORKS AS AN ANSWER --------------------------- The two stacks describe the same cluster. The L1 template has 22 resources, every property written by hand. The L2 stack has 43 resources plus 24 in two nested stacks: it creates the cluster through a custom resource (Custom::AWSCDK-EKS-Cluster), with 7 Lambda functions and a Step Functions state machine, a kubectl handler, an OIDC provider custom resource and a NAT gateway, and it needs a kubectl layer. cfn-lint validates property types and enumerations against its bundled schemas: it caught 10 of 15 changes and missed a malformed CIDR, a minimum above a maximum, a nonexistent instance type, a nonexistent add-on and an invalid service account name. All ten Jest tests passed on the first run; to show the tests can fail, the allowed CIDR was changed to 0.0.0.0/0 and the endpoint test failed, then passed again after rebuilding. The L2 IRSA trust policy's condition is a custom resource value, so the test that pins this checks for Fn::GetAtt and the absence of 'system:serviceaccount' in the template. Limits of the answer: nothing was deployed; the managed policy names were cross-checked between my hand-written stack and the library's roles, which shows they match each other and not that they are right for a real cluster; aws-cdk-lib 2.273.0 lists Kubernetes versions only up to V1_36 although the lifecycle page lists 1.37.