| 1 | import * as AWS from "@/AWS"; |
| 2 | import { Subnet, Vpc } from "@/AWS/EC2"; |
| 3 | import { |
| 4 | Listener, |
| 5 | Rule, |
| 6 | Service, |
| 7 | ServiceNetwork, |
| 8 | ServiceNetworkServiceAssociation, |
| 9 | TargetGroup, |
| 10 | } from "@/AWS/VpcLattice"; |
| 11 | import * as Test from "@/Test/Alchemy"; |
| 12 | import * as vpclattice from "@distilled.cloud/aws/vpc-lattice"; |
| 13 | import { expect } from "alchemy-test"; |
| 14 | import * as Data from "effect/Data"; |
| 15 | import * as Effect from "effect/Effect"; |
| 16 | import * as Schedule from "effect/Schedule"; |
| 17 | |
| 18 | const { test } = Test.make({ providers: AWS.providers() }); |
| 19 | |
| 20 | class StillExists extends Data.TaggedError("StillExists")<{ |
| 21 | readonly id: string; |
| 22 | }> {} |
| 23 | |
| 24 | const assertTargetGroupDeleted = (id: string) => |
| 25 | vpclattice.getTargetGroup({ targetGroupIdentifier: id }).pipe( |
| 26 | Effect.flatMap(() => Effect.fail(new StillExists({ id }))), |
| 27 | Effect.catchTag("ResourceNotFoundException", () => Effect.void), |
| 28 | Effect.retry({ |
| 29 | while: (e): boolean => e._tag === "StillExists", |
| 30 | schedule: Schedule.max([ |
| 31 | Schedule.spaced("3 seconds"), |
| 32 | Schedule.recurs(15), |
| 33 | ]), |
| 34 | }), |
| 35 | ); |
| 36 | |
| 37 | const assertServiceDeleted = (id: string) => |
| 38 | vpclattice.getService({ serviceIdentifier: id }).pipe( |
| 39 | Effect.flatMap(() => Effect.fail(new StillExists({ id }))), |
| 40 | Effect.catchTag("ResourceNotFoundException", () => Effect.void), |
| 41 | Effect.retry({ |
| 42 | while: (e): boolean => e._tag === "StillExists", |
| 43 | schedule: Schedule.max([ |
| 44 | Schedule.spaced("3 seconds"), |
| 45 | Schedule.recurs(15), |
| 46 | ]), |
| 47 | }), |
| 48 | ); |
| 49 | |
| 50 | test.provider( |
| 51 | "service chain: target group, listener, rule, service association", |
| 52 | (stack) => |
| 53 | Effect.gen(function* () { |
| 54 | yield* stack.destroy(); |
| 55 | |
| 56 | const program = (options: { |
| 57 | defaultStatusCode: number; |
| 58 | rulePriority: number; |
| 59 | targetIp: string; |
| 60 | healthCheckEnabled: boolean; |
| 61 | }) => |
| 62 | Effect.gen(function* () { |
| 63 | const vpc = yield* Vpc("ChainVpc", { cidrBlock: "10.31.0.0/16" }); |
| 64 | // IP targets must fall inside a subnet of the VPC. |
| 65 | const subnet = yield* Subnet("ChainSubnet", { |
| 66 | vpcId: vpc.vpcId, |
| 67 | cidrBlock: "10.31.0.0/24", |
| 68 | }); |
| 69 | const network = yield* ServiceNetwork("ChainNetwork", {}); |
| 70 | const service = yield* Service("ChainService", {}); |
| 71 | const association = yield* ServiceNetworkServiceAssociation( |
| 72 | "ChainAssociation", |
| 73 | { |
| 74 | serviceNetworkIdentifier: network.serviceNetworkId, |
| 75 | serviceIdentifier: service.serviceId, |
| 76 | }, |
| 77 | ); |
| 78 | const targetGroup = yield* TargetGroup("ChainTargets", { |
| 79 | type: "IP", |
| 80 | port: 80, |
| 81 | protocol: "HTTP", |
| 82 | // Reference the VPC through the subnet so target registration |
| 83 | // (which requires a subnet covering the target IP) is ordered |
| 84 | // after the subnet exists. |
| 85 | vpcIdentifier: subnet.vpcId, |
| 86 | healthCheck: { |
| 87 | enabled: options.healthCheckEnabled, |
| 88 | protocol: "HTTP", |
| 89 | path: "/health", |
| 90 | healthCheckInterval: "30 seconds", |
| 91 | healthCheckTimeout: "5 seconds", |
| 92 | }, |
| 93 | targets: [{ id: options.targetIp, port: 80 }], |
| 94 | }); |
| 95 | const listener = yield* Listener("ChainListener", { |
| 96 | serviceIdentifier: service.serviceId, |
| 97 | protocol: "HTTP", |
| 98 | port: 80, |
| 99 | defaultAction: { |
| 100 | fixedResponse: { statusCode: options.defaultStatusCode }, |
| 101 | }, |
| 102 | }); |
| 103 | const rule = yield* Rule("ChainRule", { |
| 104 | serviceIdentifier: service.serviceId, |
| 105 | listenerIdentifier: listener.listenerId, |
| 106 | priority: options.rulePriority, |
| 107 | match: { |
| 108 | httpMatch: { pathMatch: { match: { prefix: "/api" } } }, |
| 109 | }, |
| 110 | action: { |
| 111 | forward: { |
| 112 | targetGroups: [ |
| 113 | { |
| 114 | targetGroupIdentifier: targetGroup.targetGroupId, |
| 115 | weight: 100, |
| 116 | }, |
| 117 | ], |
| 118 | }, |
| 119 | }, |
| 120 | }); |
| 121 | return { association, listener, network, rule, service, targetGroup }; |
| 122 | }); |
| 123 | |
| 124 | const first = yield* stack.deploy( |
| 125 | program({ |
| 126 | defaultStatusCode: 404, |
| 127 | rulePriority: 10, |
| 128 | targetIp: "10.31.0.10", |
| 129 | healthCheckEnabled: false, |
| 130 | }), |
| 131 | ); |
| 132 | |
| 133 | expect(first.targetGroup.targetGroupId).toMatch(/^tg-/); |
| 134 | expect(first.targetGroup.type).toBe("IP"); |
| 135 | expect(first.listener.listenerId).toMatch(/^listener-/); |
| 136 | expect(first.rule.ruleId).toMatch(/^rule-/); |
| 137 | expect(first.association.associationId).toMatch(/^snsa-/); |
| 138 | |
| 139 | // Live listener has the fixed default action. |
| 140 | const liveListener = yield* vpclattice.getListener({ |
| 141 | serviceIdentifier: first.service.serviceId, |
| 142 | listenerIdentifier: first.listener.listenerId, |
| 143 | }); |
| 144 | expect(liveListener.defaultAction).toEqual({ |
| 145 | fixedResponse: { statusCode: 404 }, |
| 146 | }); |
| 147 | |
| 148 | // Live rule forwards /api to the target group at priority 10. |
| 149 | const liveRule = yield* vpclattice.getRule({ |
| 150 | serviceIdentifier: first.service.serviceId, |
| 151 | listenerIdentifier: first.listener.listenerId, |
| 152 | ruleIdentifier: first.rule.ruleId, |
| 153 | }); |
| 154 | expect(liveRule.priority).toBe(10); |
| 155 | expect(liveRule.action).toEqual({ |
| 156 | forward: { |
| 157 | targetGroups: [ |
| 158 | { |
| 159 | targetGroupIdentifier: first.targetGroup.targetGroupId, |
| 160 | weight: 100, |
| 161 | }, |
| 162 | ], |
| 163 | }, |
| 164 | }); |
| 165 | |
| 166 | // The IP target is registered. |
| 167 | const liveTargets = yield* vpclattice.listTargets({ |
| 168 | targetGroupIdentifier: first.targetGroup.targetGroupId, |
| 169 | }); |
| 170 | expect(liveTargets.items.map((t) => t.id)).toEqual(["10.31.0.10"]); |
| 171 | |
| 172 | // The service is associated with the network. |
| 173 | const liveAssociation = |
| 174 | yield* vpclattice.getServiceNetworkServiceAssociation({ |
| 175 | serviceNetworkServiceAssociationIdentifier: |
| 176 | first.association.associationId, |
| 177 | }); |
| 178 | expect(liveAssociation.serviceId).toBe(first.service.serviceId); |
| 179 | expect(["ACTIVE", "CREATE_IN_PROGRESS"]).toContain( |
| 180 | liveAssociation.status, |
| 181 | ); |
| 182 | |
| 183 | // Update in place: default action, rule priority, health check, target. |
| 184 | const second = yield* stack.deploy( |
| 185 | program({ |
| 186 | defaultStatusCode: 500, |
| 187 | rulePriority: 20, |
| 188 | targetIp: "10.31.0.11", |
| 189 | healthCheckEnabled: true, |
| 190 | }), |
| 191 | ); |
| 192 | expect(second.listener.listenerId).toBe(first.listener.listenerId); |
| 193 | expect(second.rule.ruleId).toBe(first.rule.ruleId); |
| 194 | expect(second.targetGroup.targetGroupId).toBe( |
| 195 | first.targetGroup.targetGroupId, |
| 196 | ); |
| 197 | expect(second.association.associationId).toBe( |
| 198 | first.association.associationId, |
| 199 | ); |
| 200 | |
| 201 | const updatedListener = yield* vpclattice.getListener({ |
| 202 | serviceIdentifier: first.service.serviceId, |
| 203 | listenerIdentifier: first.listener.listenerId, |
| 204 | }); |
| 205 | expect(updatedListener.defaultAction).toEqual({ |
| 206 | fixedResponse: { statusCode: 500 }, |
| 207 | }); |
| 208 | |
| 209 | const updatedRule = yield* vpclattice.getRule({ |
| 210 | serviceIdentifier: first.service.serviceId, |
| 211 | listenerIdentifier: first.listener.listenerId, |
| 212 | ruleIdentifier: first.rule.ruleId, |
| 213 | }); |
| 214 | expect(updatedRule.priority).toBe(20); |
| 215 | |
| 216 | const updatedGroup = yield* vpclattice.getTargetGroup({ |
| 217 | targetGroupIdentifier: first.targetGroup.targetGroupId, |
| 218 | }); |
| 219 | expect(updatedGroup.config?.healthCheck?.enabled).toBe(true); |
| 220 | expect(updatedGroup.config?.healthCheck?.path).toBe("/health"); |
| 221 | expect(updatedGroup.config?.healthCheck?.healthCheckIntervalSeconds).toBe( |
| 222 | 30, |
| 223 | ); |
| 224 | |
| 225 | const updatedTargets = yield* vpclattice.listTargets({ |
| 226 | targetGroupIdentifier: first.targetGroup.targetGroupId, |
| 227 | }); |
| 228 | expect( |
| 229 | updatedTargets.items |
| 230 | .filter((t) => t.status !== "DRAINING") |
| 231 | .map((t) => t.id), |
| 232 | ).toEqual(["10.31.0.11"]); |
| 233 | |
| 234 | yield* stack.destroy(); |
| 235 | yield* assertTargetGroupDeleted(first.targetGroup.targetGroupId); |
| 236 | yield* assertServiceDeleted(first.service.serviceId); |
| 237 | }).pipe( |
| 238 | // A mid-test failure must not orphan the VPC (default quota is 5 per |
| 239 | // region) — always tear the stack down. |
| 240 | Effect.ensuring(Effect.ignore(stack.destroy())), |
| 241 | ), |
| 242 | { |
| 243 | tags: [ |
| 244 | "provider:aws", |
| 245 | "provider:aws:ec2", |
| 246 | "provider:aws:vpclattice", |
| 247 | "live", |
| 248 | ], |
| 249 | timeout: 600_000, |
| 250 | }, |
| 251 | ); |