1import * as vpclattice from "@distilled.cloud/aws/vpc-lattice";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import {
10 createInternalTags,
11 diffTags,
12 hasAlchemyTags,
13 tagRecord,
14} from "../../Tags.ts";
15import type { Providers } from "../Providers.ts";
16import { retryOnConflict, waitUntilAbsent } from "./internal.ts";
17
18/**
19 * The match conditions of a listener rule (HTTP method, path, and header
20 * matches).
21 */
22export type RuleMatch = vpclattice.RuleMatch;
23
24/**
25 * The action a rule applies to matched traffic: forward to weighted target
26 * groups or answer with a fixed HTTP status.
27 */
28export type RuleAction = vpclattice.RuleAction;
29
30export interface RuleProps {
31 /**
32 * ID or ARN of the lattice service the rule's listener belongs to.
33 * Immutable — changing it replaces the rule.
34 */
35 serviceIdentifier: string;
36 /**
37 * ID or ARN of the listener the rule belongs to. Immutable — changing it
38 * replaces the rule.
39 */
40 listenerIdentifier: string;
41 /**
42 * Name of the rule. If omitted, a unique name is generated. Immutable —
43 * changing it replaces the rule.
44 */
45 name?: string;
46 /**
47 * Match conditions for the rule, e.g.
48 * `{ httpMatch: { pathMatch: { match: { prefix: "/api" } } } }`.
49 */
50 match: RuleMatch;
51 /**
52 * Rule priority (1–100). Lower numbers are evaluated first; each rule on a
53 * listener must have a unique priority.
54 */
55 priority: number;
56 /**
57 * Action applied to matched requests: forward to weighted target groups or
58 * return a fixed response status.
59 */
60 action: RuleAction;
61 /**
62 * User-defined tags to apply to the rule.
63 */
64 tags?: Record<string, string>;
65}
66
67export interface Rule extends Resource<
68 "AWS.VpcLattice.Rule",
69 RuleProps,
70 {
71 /**
72 * Service-assigned unique ID of the rule.
73 */
74 ruleId: string;
75 /**
76 * ARN of the rule.
77 */
78 ruleArn: string;
79 /**
80 * Physical name of the rule.
81 */
82 name: string;
83 /**
84 * Current rule priority.
85 */
86 priority: number;
87 /**
88 * ID or ARN of the owning lattice service (as configured).
89 */
90 serviceIdentifier: string;
91 /**
92 * ID or ARN of the owning listener (as configured).
93 */
94 listenerIdentifier: string;
95 /**
96 * Current tags reported for the rule.
97 */
98 tags: Record<string, string>;
99 },
100 never,
101 Providers
102> {}
103
104/**
105 * An Amazon VPC Lattice listener rule — matches HTTP requests by method,
106 * path, or headers and forwards them to target groups (or answers with a
107 * fixed response), evaluated in priority order before the listener's default
108 * action.
109 *
110 * ### Creating Rules
111 * **Example:** Path-Prefix Rule Forwarding to a Target Group
112 * ```typescript
113 * const rule = yield* Rule("ApiRule", {
114 * serviceIdentifier: service.serviceId,
115 * listenerIdentifier: listener.listenerId,
116 * priority: 10,
117 * match: { httpMatch: { pathMatch: { match: { prefix: "/api" } } } },
118 * action: {
119 * forward: {
120 * targetGroups: [
121 * { targetGroupIdentifier: targets.targetGroupId, weight: 100 },
122 * ],
123 * },
124 * },
125 * });
126 * ```
127 *
128 * **Example:** Method Match with a Fixed Response
129 * ```typescript
130 * const rule = yield* Rule("BlockDeletes", {
131 * serviceIdentifier: service.serviceId,
132 * listenerIdentifier: listener.listenerId,
133 * priority: 1,
134 * match: { httpMatch: { method: "DELETE" } },
135 * action: { fixedResponse: { statusCode: 403 } },
136 * });
137 * ```
138 *
139 * @resource
140 */
141export const Rule = Resource<Rule>("AWS.VpcLattice.Rule");
142
143export const RuleProvider = () =>
144 Provider.effect(
145 Rule,
146 Effect.gen(function* () {
147 const toName = (id: string, props: { name?: string } = {}) =>
148 props.name
149 ? Effect.succeed(props.name)
150 : createPhysicalName({ id, maxLength: 63, lowercase: true });
151
152 const observe = (
153 serviceIdentifier: string,
154 listenerIdentifier: string,
155 ruleIdentifier: string,
156 ) =>
157 vpclattice
158 .getRule({ serviceIdentifier, listenerIdentifier, ruleIdentifier })
159 .pipe(
160 Effect.catchTag("ResourceNotFoundException", () =>
161 Effect.succeed(undefined),
162 ),
163 );
164
165 const findByName = (
166 serviceIdentifier: string,
167 listenerIdentifier: string,
168 name: string,
169 ) =>
170 vpclattice.listRules
171 .pages({ serviceIdentifier, listenerIdentifier })
172 .pipe(
173 Stream.runCollect,
174 Effect.map((chunk) =>
175 Array.from(chunk)
176 .flatMap((page) => page.items ?? [])
177 .find((r) => r.name === name),
178 ),
179 Effect.flatMap((summary) =>
180 summary?.id
181 ? observe(serviceIdentifier, listenerIdentifier, summary.id)
182 : Effect.succeed(undefined),
183 ),
184 )
185 .pipe(
186 // The owning listener/service may already be gone during
187 // teardown races.
188 Effect.catchTag("ResourceNotFoundException", () =>
189 Effect.succeed(undefined),
190 ),
191 );
192
193 const syncTags = Effect.fn(function* (
194 arn: string,
195 desiredTags: Record<string, string>,
196 ) {
197 const listed = yield* vpclattice.listTagsForResource({
198 resourceArn: arn,
199 });
200 const { removed, upsert } = diffTags(
201 tagRecord(listed.tags),
202 desiredTags,
203 );
204 if (upsert.length > 0) {
205 yield* vpclattice.tagResource({
206 resourceArn: arn,
207 tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
208 });
209 }
210 if (removed.length > 0) {
211 yield* vpclattice.untagResource({
212 resourceArn: arn,
213 tagKeys: removed,
214 });
215 }
216 });
217
218 return {
219 stables: [
220 "ruleId",
221 "ruleArn",
222 "name",
223 "serviceIdentifier",
224 "listenerIdentifier",
225 ],
226 diff: Effect.fn(function* ({ id, olds, news }) {
227 if (!isResolved(news)) return;
228 if (
229 (yield* toName(id, olds ?? {})) !== (yield* toName(id, news ?? {}))
230 ) {
231 return { action: "replace" } as const;
232 }
233 if (
234 olds?.serviceIdentifier !== news.serviceIdentifier ||
235 olds?.listenerIdentifier !== news.listenerIdentifier
236 ) {
237 return { action: "replace" } as const;
238 }
239 }),
240 read: Effect.fn(function* ({ id, olds, output }) {
241 const serviceIdentifier =
242 output?.serviceIdentifier ?? olds?.serviceIdentifier;
243 const listenerIdentifier =
244 output?.listenerIdentifier ?? olds?.listenerIdentifier;
245 if (!serviceIdentifier || !listenerIdentifier) return undefined;
246 const rule = output?.ruleId
247 ? yield* observe(
248 serviceIdentifier,
249 listenerIdentifier,
250 output.ruleId,
251 )
252 : yield* findByName(
253 serviceIdentifier,
254 listenerIdentifier,
255 yield* toName(id, olds ?? {}),
256 );
257 if (!rule?.arn || !rule.id) return undefined;
258 const listed = yield* vpclattice.listTagsForResource({
259 resourceArn: rule.arn,
260 });
261 const attrs = {
262 ruleId: rule.id,
263 ruleArn: rule.arn,
264 name: rule.name!,
265 priority: rule.priority ?? 0,
266 serviceIdentifier,
267 listenerIdentifier,
268 tags: tagRecord(listed.tags),
269 };
270 return (yield* hasAlchemyTags(id, listed.tags))
271 ? attrs
272 : Unowned(attrs);
273 }),
274 reconcile: Effect.fn(function* ({ id, news, output, session }) {
275 const name = yield* toName(id, news);
276 const internalTags = yield* createInternalTags(id);
277 const desiredTags = { ...internalTags, ...news.tags };
278
279 // Observe — prefer the stable id cache, fall back to name lookup.
280 let rule = output?.ruleId
281 ? yield* observe(
282 news.serviceIdentifier,
283 news.listenerIdentifier,
284 output.ruleId,
285 )
286 : yield* findByName(
287 news.serviceIdentifier,
288 news.listenerIdentifier,
289 name,
290 );
291
292 // Ensure — create if missing.
293 if (!rule?.arn || !rule.id) {
294 rule = yield* retryOnConflict(
295 vpclattice.createRule({
296 serviceIdentifier: news.serviceIdentifier,
297 listenerIdentifier: news.listenerIdentifier,
298 name,
299 match: news.match,
300 priority: news.priority,
301 action: news.action,
302 }),
303 ).pipe(
304 Effect.catchTag("ConflictException", () =>
305 findByName(
306 news.serviceIdentifier,
307 news.listenerIdentifier,
308 name,
309 ),
310 ),
311 );
312 if (!rule?.arn || !rule.id) {
313 return yield* Effect.fail(
314 new Error(`Failed to create rule ${name}`),
315 );
316 }
317 } else if (
318 JSON.stringify(rule.match) !== JSON.stringify(news.match) ||
319 rule.priority !== news.priority ||
320 JSON.stringify(rule.action) !== JSON.stringify(news.action)
321 ) {
322 // Sync match/priority/action — all mutable in place.
323 yield* retryOnConflict(
324 vpclattice.updateRule({
325 serviceIdentifier: news.serviceIdentifier,
326 listenerIdentifier: news.listenerIdentifier,
327 ruleIdentifier: rule.id,
328 match: news.match,
329 priority: news.priority,
330 action: news.action,
331 }),
332 );
333 }
334
335 yield* syncTags(rule.arn, desiredTags);
336
337 yield* session.note(rule.arn);
338 return {
339 ruleId: rule.id,
340 ruleArn: rule.arn,
341 name,
342 priority: news.priority,
343 serviceIdentifier: news.serviceIdentifier,
344 listenerIdentifier: news.listenerIdentifier,
345 tags: desiredTags,
346 };
347 }),
348 // Sub-resource: rules are keyed by their owning service/listener and
349 // are removed with them, so nuke has nothing to enumerate.
350 list: () => Effect.succeed([] as Rule["Attributes"][]),
351 delete: Effect.fn(function* ({ output }) {
352 yield* retryOnConflict(
353 vpclattice.deleteRule({
354 serviceIdentifier: output.serviceIdentifier,
355 listenerIdentifier: output.listenerIdentifier,
356 ruleIdentifier: output.ruleId,
357 }),
358 ).pipe(
359 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
360 );
361 yield* waitUntilAbsent(
362 observe(
363 output.serviceIdentifier,
364 output.listenerIdentifier,
365 output.ruleId,
366 ),
367 );
368 }),
369 };
370 }),
371 );