# TiDB Multi-AZ NodeGroup Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add a new opt-in code path that creates a TiDB EKS ManagedNodeGroup / ASG spanning 3 AZs (one ASG, three subnets) via a new fleet config key (`tidb-maz-fleet` / `tidb-maz-nodegroup`), leaving the existing single-AZ `tidb-fleet` path untouched.

**Architecture:** Parallel functions in `pool/eks-freetier/asg.ts` (`createMultiAZFleet` → `createMultiAZASGWithOptions` → `createWithMultiAZ` + `mixedNodeGroupMultiAZ`) that build one `eks.ManagedNodeGroup` with `subnetIds` containing all 3 AZ subnets, no placement group, and no `serverless.tidbcloud.com/zone` label. A new helper `createServerlessMultiAZASG` in `serverless.ts` is wired in once from `index.ts` before the existing per-AZ loop. All new code paths are no-ops when the stack yaml does not define `tidb-maz-fleet`.

**Tech Stack:** TypeScript, Pulumi, `@pulumi/eks`, `@pulumi/aws`. No unit-test framework in this project — verification is `npm run fmt && npm run lint` after each change (TypeScript compile via eslint) and `pulumi preview` against a dev stack for the final smoke test.

**Spec reference:** `docs/superpowers/specs/2026-04-17-tidb-multiaz-nodegroup-design.md`

---

## Working Directory

All paths below are relative to the repo root `aws-shared-cd/`. Lint commands run from `pool/eks-freetier/`:

```bash
cd aws-shared-cd/pool/eks-freetier
```

Install deps once at the start:

```bash
npm ci
```

---

### Task 1: Add `multiAZ` field + `AZSubnet` type to `asg.ts`

**Files:**
- Modify: `pool/eks-freetier/asg.ts` (around line 92-120 for `ManagedOptions`; around line 71-90 for `Override`; near top for new `AZSubnet`)

- [ ] **Step 1: Add `AZSubnet` interface near the top of `asg.ts`** (just after the `ZoneType` exports around line 25-30)

```ts
export interface AZSubnet {
  az: string;
  subnetId: string;
}
```

- [ ] **Step 2: Add `multiAZ?: boolean` to `ManagedOptions` interface**

Locate the `ManagedOptions` interface (starts at `asg.ts:92`). Add the new field near the end of the interface, above `zones`:

```ts
export interface ManagedOptions {
  // ... existing fields unchanged ...
  tags: Record<string, string>;
  mixedAsgOptions?: MixedAsgOptions;
  multiAZ?: boolean;          // NEW: default false. When true, the fleet uses a multi-AZ ASG (one ASG spanning all 3 subnets).
  zones: ZoneType[];
  fleetName?: string;
  primary?: ZoneTypeOverride;
  standby?: ZoneTypeOverride;
  witness?: ZoneTypeOverride;
}
```

- [ ] **Step 3: Add `multiAZ?: boolean` to `Override` interface**

Locate `Override` (starts at `asg.ts:71`). Add the same field similarly:

```ts
interface Override {
  // ... existing fields unchanged ...
  tags: Record<string, string>;
  mixedAsgOptions?: MixedAsgOptions;
  multiAZ?: boolean;          // NEW
  zones?: ZoneType[];
  primary?: ZoneTypeOverride;
  standby?: ZoneTypeOverride;
  witness?: ZoneTypeOverride;
}
```

- [ ] **Step 4: Format and lint**

Run:

```bash
npm run fmt
npm run lint
```

Expected: no errors. `multiAZ` is optional so every existing fleet config still type-checks.

- [ ] **Step 5: Commit**

```bash
git add pool/eks-freetier/asg.ts
git commit -m "eks-freetier: add multiAZ option type + AZSubnet"
```

---

### Task 2: Add `multiAZ` validation in `validateOptions`

**Files:**
- Modify: `pool/eks-freetier/asg.ts:791-828` (the `validateOptions` function)

- [ ] **Step 1: Append a `multiAZ` validation block at the end of `validateOptions`**

Add these checks just before the final closing brace of `validateOptions` (around `asg.ts:828`):

```ts
  if (options.multiAZ) {
    if (options.placement.strategy !== "") {
      throw new Error(
        `multiAZ nodegroups cannot use a placement group (got strategy "${options.placement.strategy}"). AWS placement groups bind to a single AZ.`
      );
    }
    if (options.scaleInProtected) {
      throw new Error(
        `multiAZ nodegroups cannot set scaleInProtected: true. Scale-in protection defeats AWS cross-AZ rebalancing.`
      );
    }
    if (options.category !== "compute") {
      throw new Error(
        `multiAZ is only supported for compute fleets (got category "${options.category}").`
      );
    }
    // primary / standby / witness overrides are meaningless under multiAZ.
    if (options.primary || options.standby || options.witness) {
      throw new Error(
        `multiAZ nodegroups must not set zone-specific overrides (primary/standby/witness).`
      );
    }
  }
```

Note: we do **not** validate `options.zones`. `defaultOptions.zones` is always merged in as `[primary, standby]` (asg.ts:674), so every option object has a non-empty `zones` regardless of user intent — there is no reliable way to distinguish "user set it" from "default". The field is simply ignored by the multi-AZ code path.

- [ ] **Step 2: Format and lint**

```bash
npm run fmt
npm run lint
```

Expected: no errors.

- [ ] **Step 3: Commit**

```bash
git add pool/eks-freetier/asg.ts
git commit -m "eks-freetier: validate multiAZ-incompatible options"
```

---

### Task 3: Skip multi-AZ fleets in the old per-AZ path

**Files:**
- Modify: `pool/eks-freetier/asg.ts:832-910` (`createASGWithOptions`)

- [ ] **Step 1: Add a short-circuit at the top of `createASGWithOptions`**

Find `createASGWithOptions` (starts at `asg.ts:832`). Right after the opening `validateOptions(options)` call (around line 840), add:

```ts
  if (options.multiAZ) {
    // multiAZ fleets are created by createMultiAZFleet, not by the per-AZ loop.
    return null;
  }
```

This makes the per-AZ loop safe even if someone accidentally points it at a multi-AZ fleet — no ASG is created, no existing resource is touched.

- [ ] **Step 2: Format and lint**

```bash
npm run fmt
npm run lint
```

- [ ] **Step 3: Commit**

```bash
git add pool/eks-freetier/asg.ts
git commit -m "eks-freetier: skip multiAZ fleets in single-AZ path"
```

---

### Task 4: Add `mixedNodeGroupMultiAZ` helper

**Files:**
- Modify: `pool/eks-freetier/asg.ts` (new function, add after `mixedNodeGroup` around line 378)

This is a parallel of `mixedNodeGroup` that omits AZ-specific LaunchTemplate placement and node group name suffix.

- [ ] **Step 1: Read the existing `mixedNodeGroup`** (`asg.ts:246-378`) to understand its structure. It builds a `LaunchTemplate` for the alternative arch when present, constructs a `MixedInstancesPolicy` JSON, and shells out `aws autoscaling update-auto-scaling-group` to apply the policy.

- [ ] **Step 2: Append the new function right after `mixedNodeGroup`**

```ts
function mixedNodeGroupMultiAZ(
  c: eks.Cluster,
  prefix: string,
  options: ManagedOptions,
  asgName: string,
  nodeGroupName: string,
  otherArch: string | null,
  managedNodeGroupOptions: eks.ManagedNodeGroupOptions,
) {
  let launchTemplate: any = null;
  if (otherArch != null) {
    launchTemplate = aws.ec2
      .getLaunchTemplateOutput({
        tags: {
          "eks:cluster-name": prefix,
          "eks:nodegroup-name": nodeGroupName,
        },
      })
      .apply((lt) => {
        const metadataOptions = lt.metadataOptions[0];
        const blockDeviceMappings = lt.blockDeviceMappings[0];
        const amiId = eksImageID(otherArch, options.releaseTag);
        const launchTemplateName = `${prefix}-${options.name}-${options.releaseTag}-${otherArch}-launchTemplate`;
        return new aws.ec2.LaunchTemplate(launchTemplateName, {
          namePrefix: launchTemplateName + "-",
          imageId: amiId,
          iamInstanceProfile: { name: lt.iamInstanceProfiles[0].name },
          metadataOptions: {
            httpEndpoint: metadataOptions.httpEndpoint,
            httpTokens: metadataOptions.httpTokens,
            httpPutResponseHopLimit: metadataOptions.httpPutResponseHopLimit,
          },
          blockDeviceMappings: [
            {
              deviceName: blockDeviceMappings.deviceName,
              ebs: {
                volumeType: blockDeviceMappings.ebs[0].volumeType,
                volumeSize: blockDeviceMappings.ebs[0].volumeSize,
              },
            },
          ],
          userData: lt.userData,
          vpcSecurityGroupIds: [c.nodeSecurityGroup.id],
          updateDefaultVersion: true,
          tagSpecifications: lt.tagSpecifications,
          // NOTE: no `placement` field — multi-AZ ASG cannot bind a LaunchTemplate to a single AZ.
        });
      });
  }

  let mixedInstancesPolicy = pulumi.concat(`'{
    "LaunchTemplate": {
      "Overrides": [`);
  let firstOverride = true;
  let numOfInstanceTypes = 0;
  for (const instanceType of options.instanceTypes) {
    numOfInstanceTypes++;
    if (firstOverride) {
      firstOverride = false;
    } else {
      mixedInstancesPolicy = pulumi.concat(mixedInstancesPolicy, ",");
    }
    if (instanceTypeMeta(instanceType).arch == otherArch) {
      mixedInstancesPolicy = pulumi.concat(
        mixedInstancesPolicy,
        `
        {
          "InstanceType": "${instanceType}",
          "LaunchTemplateSpecification": {
            "LaunchTemplateId": "`,
        launchTemplate.id,
        `",
            "Version": "`,
        launchTemplate.defaultVersion,
        `"
          }
        }`
      );
    } else {
      mixedInstancesPolicy = pulumi.concat(
        mixedInstancesPolicy,
        `
        {
          "InstanceType": "${instanceType}"
        }`
      );
    }
  }
  let allocationStrategyName = "SpotAllocationStrategy";
  let allocationStrategy = "price-capacity-optimized";
  if (options.capacityType != "SPOT" && numOfInstanceTypes > 1) {
    allocationStrategyName = "OnDemandAllocationStrategy";
    allocationStrategy = "lowest-price";
  }
  let instancesDistributionBody = `"${allocationStrategyName}": "${allocationStrategy}"`;
  if (options.capacityType == "SPOT" && options.spot?.onDemandBaseCapacity != null) {
    instancesDistributionBody = `${instancesDistributionBody}, "OnDemandBaseCapacity": ${options.spot.onDemandBaseCapacity}`;
  }
  mixedInstancesPolicy = pulumi.concat(
    mixedInstancesPolicy,
    `
      ]
    },
    "InstancesDistribution": {
        ${instancesDistributionBody}
    }}'`
  );

  let updateAsgCmd = pulumi.interpolate`aws autoscaling update-auto-scaling-group --region ${region} --auto-scaling-group-name ${asgName} --mixed-instances-policy ${mixedInstancesPolicy} --capacity-rebalance --min-size ${options.min} --max-size ${options.max}`;

  // NOTE: no --new-instances-protected-from-scale-in (multiAZ forbids scaleInProtected via validation).

  new local.Command(`new-update-managed-asg-${asgName}`, {
    create: updateAsgCmd,
    update: updateAsgCmd,
    triggers: [ignoreFields(managedNodeGroupOptions)],
  });
}
```

Key differences from `mixedNodeGroup`:
- Takes `prefix` and `c: eks.Cluster` directly (no `Context` with `availabilityZone` / `subnetId`)
- Launch template name uses `{prefix}-{name}-{releaseTag}` with no AZ suffix
- Launch template has **no `placement` field**
- No `--new-instances-protected-from-scale-in` branch

- [ ] **Step 3: Format and lint**

```bash
npm run fmt
npm run lint
```

- [ ] **Step 4: Commit**

```bash
git add pool/eks-freetier/asg.ts
git commit -m "eks-freetier: add mixedNodeGroupMultiAZ helper"
```

---

### Task 5: Add `createWithMultiAZ` (core multi-AZ node group creation)

**Files:**
- Modify: `pool/eks-freetier/asg.ts` (new function, add after `createWithPlacementGroup` which ends around line 533)

- [ ] **Step 1: Append the new function after `createWithPlacementGroup`**

```ts
function createWithMultiAZ(
  c: eks.Cluster,
  prefix: string,
  azSubnets: AZSubnet[],
  role: aws.iam.Role,
  options: ManagedOptions,
): eks.ManagedNodeGroup {
  const suffix = ""; // multiAZ uses no AZ suffix
  const theGroupName = `${prefix}-${options.name}-${options.releaseTag}`;
  const tags = {
    "tidbcloud.com/env": `${env}`,
    servicetype: serviceType,
    environment: `${env}`,
    tenant: "admin",
    usedby: `${prefix}`,
    component: `${options.category}/${options.component}`,
  };
  const taints = Object.entries(options.taints).map(([key, value]) => {
    return {
      effect: options.taintEffect,
      key: key,
      value: value,
    };
  });
  if (options.exclusive) {
    taints.push({
      effect: "NO_SCHEDULE",
      key: "serverless.tidbcloud.com/node",
      value: options.component,
    });
  }

  let defaultInstanceType = arm64InstanceType(options.instanceTypes);
  let defaultArch = ARM64;
  let otherArch: string | null = AMD64;
  if (defaultInstanceType == null) {
    defaultInstanceType = amd64InstanceType(options.instanceTypes);
    defaultArch = AMD64;
    otherArch = null;
  }

  const launchTemplateName = `${theGroupName}-${defaultArch}-launchTemplate`;
  const launchTemplate = new aws.ec2.LaunchTemplate(launchTemplateName, {
    namePrefix: launchTemplateName + "-",
    vpcSecurityGroupIds: [c.nodeSecurityGroup.id],
    metadataOptions: {
      httpEndpoint: "enabled",
      httpTokens: "optional",
      httpPutResponseHopLimit: 2,
    },
    blockDeviceMappings: [
      {
        deviceName: "/dev/xvda",
        ebs: { volumeType: "gp3", volumeSize: options.rootVolumeSize },
      },
    ],
    updateDefaultVersion: true,
    tagSpecifications: [
      { resourceType: "instance", tags: tags },
      { resourceType: "volume", tags: tags },
    ],
    // NOTE: no `placement` field — multi-AZ ASG has no single AZ to bind.
  });

  const minSize = options.min; // multiAZ forbids scaleInProtected, so no minSize=0 trick

  // serverlessZone intentionally undefined — multiAZ nodes do NOT carry serverless.tidbcloud.com/zone.
  const labels = nodeLabels(options);
  const tagsForASG = asgTags(taints, options);

  const subnetIds = azSubnets.map((s) => s.subnetId);

  const managedNodeGroupOptions: eks.ManagedNodeGroupOptions = {
    cluster: c,
    nodeGroupName: theGroupName,
    subnetIds: subnetIds,
    capacityType: options.capacityType,
    amiType: amiType(defaultArch, options.releaseTag),
    scalingConfig: {
      desiredSize: minSize,
      minSize: minSize,
      maxSize: options.max,
    },
    instanceTypes: [defaultInstanceType as string],
    nodeRole: role,
    launchTemplate: {
      id: launchTemplate.id,
      version: pulumi.interpolate`${launchTemplate.defaultVersion}`,
    },
    updateConfig: {
      maxUnavailable: options.maxUnavailable,
    },
    tags: { ...options.tags, ...tags },
    labels: labels,
    taints: taints,
    releaseVersion: eksReleaseVersion(options.releaseTag),
  };

  const managedNodeGroup = new eks.ManagedNodeGroup(theGroupName, managedNodeGroupOptions, {
    parent: c,
    transformations: [
      (args) => {
        if (args.type === "aws:eks/nodeGroup:NodeGroup") {
          return {
            props: args.props,
            opts: pulumi.mergeOptions(args.opts, {
              ignoreChanges: ["scalingConfig", "instanceTypes"],
            }),
          };
        }
        return undefined;
      },
    ],
  });

  pulumi
    .all([managedNodeGroup.nodeGroup.resources, managedNodeGroup.nodeGroup.nodeGroupName])
    .apply(([res, nodeGroupName]) => {
      setupASGTags(res[0].autoscalingGroups[0].name, tagsForASG);
      mixedNodeGroupMultiAZ(
        c,
        prefix,
        options,
        res[0].autoscalingGroups[0].name,
        nodeGroupName,
        otherArch,
        managedNodeGroupOptions,
      );
    });

  return managedNodeGroup;
}
```

Key differences from `createWithPlacementGroup`:
- Takes `azSubnets: AZSubnet[]` and uses all subnet IDs
- Node group name has no AZ suffix (`theGroupName = {prefix}-{name}-{releaseTag}`)
- LaunchTemplate has no `placement` field
- `nodeLabels(options)` called with no `serverlessZone` arg → no `serverless.tidbcloud.com/zone` label
- `asgTags(taints, options)` called with no `serverlessZone` → no zone tag for cluster-autoscaler
- Calls `mixedNodeGroupMultiAZ` (not `mixedNodeGroup`)

- [ ] **Step 2: Format and lint**

```bash
npm run fmt
npm run lint
```

- [ ] **Step 3: Commit**

```bash
git add pool/eks-freetier/asg.ts
git commit -m "eks-freetier: add createWithMultiAZ core function"
```

---

### Task 6: Add `createMultiAZASGWithOptions` (capacity-type dispatcher)

**Files:**
- Modify: `pool/eks-freetier/asg.ts` (new function, add after `createASGWithOptions` ends around line 910)

- [ ] **Step 1: Append the new function**

```ts
function createMultiAZASGWithOptions(
  c: eks.Cluster,
  prefix: string,
  azSubnets: AZSubnet[],
  role: aws.iam.Role,
  options: ManagedOptions,
) {
  validateOptions(options);

  if (options.capacityType == "MIXED") {
    const onDemandOptions = deepMerge(
      options,
      { name: `${options.name}-on-demand`, capacityType: "ON_DEMAND" },
      options.onDemand,
    );
    onDemandOptions.labels = {
      ...onDemandOptions.labels,
      "serverless.tidbcloud.com/capacity-type": "ON_DEMAND",
    };
    createWithMultiAZ(c, prefix, azSubnets, role, onDemandOptions);

    const spotOptions = deepMerge(
      options,
      { name: `${options.name}-spot`, capacityType: "SPOT", min: 0 },
      options.spot,
    );
    spotOptions.tags[autoscalerOptionTagPrefix + "alternative"] = `${prefix}-${onDemandOptions.name}-${onDemandOptions.releaseTag}`;
    if (options.mixedAsgOptions != null) {
      if (options.mixedAsgOptions.alternativeAutoReplace != undefined) {
        spotOptions.tags[autoscalerOptionTagPrefix + "alternativeAutoReplace"] =
          options.mixedAsgOptions.alternativeAutoReplace.toString();
      }
      if (options.mixedAsgOptions.alternativeParallelScaleUp != undefined) {
        spotOptions.tags[autoscalerOptionTagPrefix + "alternativeParallelScaleUp"] =
          options.mixedAsgOptions.alternativeParallelScaleUp.toString();
      }
      if (options.mixedAsgOptions.alternativeScaleUpWait != undefined) {
        spotOptions.tags[autoscalerOptionTagPrefix + "alternativeScaleUpWait"] =
          options.mixedAsgOptions.alternativeScaleUpWait;
      }
    }
    return createWithMultiAZ(c, prefix, azSubnets, role, spotOptions);
  }

  return createWithMultiAZ(c, prefix, azSubnets, role, options);
}
```

Note: the `alternative` tag value here is the multi-AZ node group name (`{prefix}-{name}-{releaseTag}`) — no AZ suffix, different from `placementGroupName` used in the single-AZ path. The autoscaler uses this to pair spot and on-demand ASGs for the same capacity pool.

- [ ] **Step 2: Format and lint**

```bash
npm run fmt
npm run lint
```

- [ ] **Step 3: Commit**

```bash
git add pool/eks-freetier/asg.ts
git commit -m "eks-freetier: add createMultiAZASGWithOptions dispatcher"
```

---

### Task 7: Add public `createMultiAZFleet` entry point

**Files:**
- Modify: `pool/eks-freetier/asg.ts` (new exported function, add near the other `createFleet` exports around line 762-789)

- [ ] **Step 1: Append the new function after `createFleetIfExists`**

```ts
export function createMultiAZFleet(
  c: eks.Cluster,
  prefix: string,
  azSubnets: AZSubnet[],
  role: aws.iam.Role,
  name: string,
) {
  if (azSubnets.length !== 3) {
    throw new Error(`createMultiAZFleet requires exactly 3 AZ subnets, got ${azSubnets.length}`);
  }
  let asgRef: any | null = null;
  for (const options of loadFleetOptions(name)) {
    if (!options.multiAZ) {
      throw new Error(
        `Fleet "${name}" was passed to createMultiAZFleet but does not have multiAZ:true in its nodegroup defaults.`
      );
    }
    asgRef = createMultiAZASGWithOptions(c, prefix, azSubnets, role, options);
  }
  return asgRef;
}
```

- [ ] **Step 2: Format and lint**

```bash
npm run fmt
npm run lint
```

- [ ] **Step 3: Commit**

```bash
git add pool/eks-freetier/asg.ts
git commit -m "eks-freetier: export createMultiAZFleet entry point"
```

---

### Task 8: Add `createServerlessMultiAZASG` in `serverless.ts`

**Files:**
- Modify: `pool/eks-freetier/serverless.ts` (new exported function, add after `createServerlessASG` around line 2293)

- [ ] **Step 1: Append the new function**

```ts
export function createServerlessMultiAZASG(
  c: eks.Cluster,
  prefix: string,
  azSubnets: asg.AZSubnet[],
  role: aws.iam.Role,
) {
  if (config.getObject(`tidb-maz-fleet`) != null) {
    asg.createMultiAZFleet(c, prefix, azSubnets, role, "tidb-maz");
  }
  if (config.getObject(`tidb-on-demand-maz-fleet`) != null) {
    asg.createMultiAZFleet(c, prefix, azSubnets, role, "tidb-on-demand-maz");
  }
}
```

- [ ] **Step 2: Confirm `asg.AZSubnet` is re-exported**

`serverless.ts` already imports `asg` via `import * as asg from "./asg"` (check the top of the file — if not, add that import or use a named import for `AZSubnet`). Adjust the type reference if needed.

- [ ] **Step 3: Format and lint**

```bash
npm run fmt
npm run lint
```

- [ ] **Step 4: Commit**

```bash
git add pool/eks-freetier/serverless.ts
git commit -m "eks-freetier: add createServerlessMultiAZASG entry"
```

---

### Task 9: Wire `createServerlessMultiAZASG` into `index.ts`

**Files:**
- Modify: `pool/eks-freetier/index.ts:20` (add import)
- Modify: `pool/eks-freetier/index.ts:559-568` (the subnet iteration block inside `buildServerlessEKS`)

- [ ] **Step 1: Import the new function**

Find the existing import around `index.ts:20`:

```ts
import {
  createServerlessASG,
  // ... other imports
  installAuxiliaryTiDBClusters,
} from "./serverless";
```

Add `createServerlessMultiAZASG` to the list:

```ts
import {
  createServerlessASG,
  createServerlessMultiAZASG,
  // ... other imports
  installAuxiliaryTiDBClusters,
} from "./serverless";
```

- [ ] **Step 2: Add the multi-AZ call alongside the existing per-AZ loop (keep the existing loop byte-for-byte)**

Replace the block at `index.ts:559-568`:

```ts
  awsvpc.privateSubnetIds.then((subNets) => {
    subNets.map((subnet) => {
      subnet.apply((id) => {
        aws.ec2.getSubnet({ id: id }).then((s) => {
          asg.createFleet(c, prefix, s.availabilityZone, s.id, managedASGRole, "default");
          createServerlessASG(c, prefix, s.availabilityZone, s.id, managedASGRole);
        });
      });
    });
  });
```

with:

```ts
  awsvpc.privateSubnetIds.then((subNets) => {
    // Collect {az, subnetId} for all private subnets so the multi-AZ call gets the full list at once.
    const azSubnetsPromise: Promise<asg.AZSubnet[]> = Promise.all(
      subNets.map(
        (subnet) =>
          new Promise<asg.AZSubnet>((resolve) => {
            subnet.apply((id) => {
              aws.ec2.getSubnet({ id: id }).then((s) => {
                resolve({ az: s.availabilityZone, subnetId: s.id });
              });
            });
          }),
      ),
    );

    // Multi-AZ fleets: created once using all 3 subnets. No-op when tidb-maz-fleet is absent.
    azSubnetsPromise.then((azSubnets) => {
      createServerlessMultiAZASG(c, prefix, azSubnets, managedASGRole);
    });

    // Single-AZ fleets: unchanged per-AZ iteration (byte-for-byte same as before, to preserve Pulumi resource identity).
    subNets.map((subnet) => {
      subnet.apply((id) => {
        aws.ec2.getSubnet({ id: id }).then((s) => {
          asg.createFleet(c, prefix, s.availabilityZone, s.id, managedASGRole, "default");
          createServerlessASG(c, prefix, s.availabilityZone, s.id, managedASGRole);
        });
      });
    });
  });
```

Rationale: the original per-AZ loop is preserved exactly. The multi-AZ path is purely additive. Pulumi resource URNs for every existing resource stay identical, so `pulumi preview` on existing stacks shows no diff.

- [ ] **Step 3: Format and lint**

```bash
npm run fmt
npm run lint
```

Expected: no errors.

- [ ] **Step 4: Commit**

```bash
git add pool/eks-freetier/index.ts
git commit -m "eks-freetier: wire multi-AZ TiDB fleet into buildServerlessEKS"
```

---

### Task 10: Smoke-test with `pulumi preview` on a dev stack

No code change. Verify that on an existing stack (with no `tidb-maz-fleet` in yaml), the preview shows zero resource diffs.

- [ ] **Step 1: Ensure you have Pulumi credentials + passphrase configured**

```bash
echo "$PULUMI_CONFIG_PASSPHRASE" | head -c 1 >/dev/null && echo "passphrase set" || echo "MISSING: export PULUMI_CONFIG_PASSPHRASE=..."
aws sts get-caller-identity >/dev/null && echo "aws ok" || echo "MISSING: export AWS credentials"
```

Expected: both say "ok".

- [ ] **Step 2: Login and select stack**

```bash
cd pool/eks-freetier
pulumi login gs://tidbcloud-pulumi-state
pulumi stack select dev-us-east-1-f01
```

- [ ] **Step 3: Run preview**

```bash
pulumi preview 2>&1 | tee /tmp/pulumi-preview.log
```

Expected: output summary line `Resources: <N> unchanged` with no "+" (create), "-" (delete), or "~" (update) lines in the plan. If any diff appears, stop and investigate — it means the refactor accidentally perturbed an existing resource.

- [ ] **Step 4: Record result**

If the preview is clean: the existing single-AZ path is byte-for-byte preserved, and the multi-AZ code path is dormant until a stack opts in. Proceed.

If not clean: investigate the specific diff. Most likely culprits would be a reordered import or a dangling reference — do NOT proceed to a stack opt-in until the diff is zero.

- [ ] **Step 5: Commit the final state (no file changes)**

This step produces no commit if §1-§9 are already committed. It's a verification gate only.

---

### Task 11: Post-merge opt-in for the first target stack (separate PR)

**Intentionally outside this plan.** Per the spec §9 step 2, each stack's opt-in is a separate PR that adds the `tidb-maz-nodegroup` + `tidb-maz-fleet` yaml blocks. The infra code shipped by tasks 1-10 is a no-op until that yaml lands. Provider-side pod scheduling change is a parallel PR in the `aws-shared-provider` repo.

---

## Final Acceptance

After Task 10:

1. `git log --oneline` shows 9 commits (one per task 1-9) with the subjects above.
2. `npm run lint` in `pool/eks-freetier` is clean.
3. `pulumi preview` on `dev-us-east-1-f01` shows zero resource changes.
4. `asg.ts` now exports: `AZSubnet`, `createMultiAZFleet`.
5. `serverless.ts` now exports: `createServerlessMultiAZASG`.
6. `index.ts`'s `buildServerlessEKS` calls `createServerlessMultiAZASG` once before the per-AZ loop.

At that point the PR is ready for review. The end-to-end validation (§9 steps 2-5 in the spec) happens on stack opt-in, after merge.
