1import * as medialive from "@distilled.cloud/aws/medialive";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { createInternalTags, hasAlchemyTags } from "../../Tags.ts";
9import type { Providers } from "../Providers.ts";
10import { ensureIdentified, syncMlTags, toTagRecord } from "./internal.ts";
11
12// Explicitly-typed pipeable retry helper. Inlining `Effect.retry` in a
13// provider lifecycle op leaks `Retry.Return`'s conditional into declaration
14// emit and widens the provider layer to `unknown` R for every consumer of
15// `AWS.providers()`.
16//
17// An Input Security Group attached to a still-deleting Input rejects
18// deletion with a `BadRequestException` until the Input is fully gone.
19const retryWhileInUse = <A, E extends { readonly _tag: string }, R>(
20 self: Effect.Effect<A, E, R>,
21): Effect.Effect<A, E, R> =>
22 Effect.retry(self, {
23 while: (e) =>
24 e._tag === "BadRequestException" || e._tag === "ConflictException",
25 schedule: Schedule.max([Schedule.fixed("3 seconds"), Schedule.recurs(10)]),
26 });
27
28/** An input security group whose server-assigned identity is present. */
29type IdentifiedGroup = medialive.InputSecurityGroup & {
30 Id: string;
31 Arn: string;
32};
33
34const hasIdentity = (
35 isg: medialive.InputSecurityGroup,
36): isg is IdentifiedGroup => isg.Id !== undefined && isg.Arn !== undefined;
37
38export interface InputSecurityGroupProps {
39 /**
40 * IPv4 CIDR ranges allowed to push content to inputs attached to this
41 * security group (e.g. `"10.0.0.0/16"`, `"0.0.0.0/0"`).
42 * @default ["0.0.0.0/0"]
43 */
44 whitelistRules?: string[];
45 /**
46 * User-defined tags for the input security group.
47 */
48 tags?: Record<string, string>;
49}
50
51export interface InputSecurityGroup extends Resource<
52 "AWS.MediaLive.InputSecurityGroup",
53 InputSecurityGroupProps,
54 {
55 /** Server-assigned unique id of the input security group. */
56 inputSecurityGroupId: string;
57 /** ARN of the input security group. */
58 inputSecurityGroupArn: string;
59 /** Current state (e.g. `IDLE`, `IN_USE`). */
60 state: medialive.InputSecurityGroupState | undefined;
61 /** Allowlisted source CIDR blocks. */
62 whitelistRules: string[];
63 },
64 never,
65 Providers
66> {}
67
68/**
69 * An AWS Elemental MediaLive input security group — an IP allowlist that
70 * gates which source networks may push content to attached PUSH inputs
71 * (RTMP_PUSH, RTP_PUSH, UDP_PUSH).
72 *
73 * ### Creating an Input Security Group
74 * **Example:** Allow a single network
75 * ```typescript
76 * const isg = yield* MediaLive.InputSecurityGroup("Allowlist", {
77 * whitelistRules: ["10.0.0.0/16"],
78 * });
79 * ```
80 *
81 * **Example:** Open to the world (test-only)
82 * ```typescript
83 * const isg = yield* MediaLive.InputSecurityGroup("Open", {
84 * whitelistRules: ["0.0.0.0/0"],
85 * tags: { team: "media" },
86 * });
87 * ```
88 *
89 * ### Attaching to an Input
90 * **Example:** Gate an RTMP push input
91 * ```typescript
92 * const input = yield* MediaLive.Input("Stream", {
93 * type: "RTMP_PUSH",
94 * inputSecurityGroups: [isg.inputSecurityGroupId],
95 * destinations: [{ StreamName: "live/stream" }],
96 * });
97 * ```
98 *
99 * @resource
100 */
101export const InputSecurityGroup = Resource<InputSecurityGroup>(
102 "AWS.MediaLive.InputSecurityGroup",
103);
104
105export const InputSecurityGroupProvider = () =>
106 Provider.effect(
107 InputSecurityGroup,
108 Effect.gen(function* () {
109 const toAttrs = (isg: IdentifiedGroup) => ({
110 inputSecurityGroupId: isg.Id,
111 inputSecurityGroupArn: isg.Arn,
112 state: isg.State,
113 whitelistRules: (isg.WhitelistRules ?? [])
114 .map((r) => r.Cidr)
115 .filter((cidr): cidr is string => cidr !== undefined),
116 });
117
118 /** Describe by id; typed not-found (or tombstone state) → undefined. */
119 const getGroup = Effect.fn(function* (id: string) {
120 const isg = yield* medialive
121 .describeInputSecurityGroup({ InputSecurityGroupId: id })
122 .pipe(
123 Effect.catchTag("NotFoundException", () =>
124 Effect.succeed(undefined),
125 ),
126 );
127 if (isg === undefined || isg.State === "DELETED") return undefined;
128 if (!hasIdentity(isg)) return undefined;
129 return isg;
130 });
131
132 /**
133 * Input security groups have server-assigned ids and no name, so a
134 * read without cached output scans the account list for the group
135 * carrying this logical id's Alchemy tags.
136 */
137 const findByTags = Effect.fn(function* (id: string) {
138 const groups = yield* medialive.listInputSecurityGroups.items({}).pipe(
139 Stream.runCollect,
140 Effect.map((chunk) => Array.from(chunk)),
141 );
142 for (const isg of groups) {
143 if (isg.State === "DELETED" || !hasIdentity(isg)) continue;
144 if (yield* hasAlchemyTags(id, toTagRecord(isg.Tags))) {
145 return isg;
146 }
147 }
148 return undefined;
149 });
150
151 const desiredRules = (props: InputSecurityGroupProps) =>
152 (props.whitelistRules ?? ["0.0.0.0/0"]).map((cidr) => ({
153 Cidr: cidr,
154 }));
155
156 return InputSecurityGroup.Provider.of({
157 stables: ["inputSecurityGroupId", "inputSecurityGroupArn"],
158
159 list: () =>
160 medialive.listInputSecurityGroups.items({}).pipe(
161 Stream.runCollect,
162 Effect.map((chunk) =>
163 Array.from(chunk)
164 .filter(
165 (isg): isg is IdentifiedGroup =>
166 hasIdentity(isg) && isg.State !== "DELETED",
167 )
168 .map(toAttrs),
169 ),
170 ),
171
172 read: Effect.fn(function* ({ id, output }) {
173 const isg =
174 output?.inputSecurityGroupId !== undefined
175 ? yield* getGroup(output.inputSecurityGroupId)
176 : yield* findByTags(id);
177 if (isg === undefined) return undefined;
178 const attrs = toAttrs(isg);
179 return (yield* hasAlchemyTags(id, toTagRecord(isg.Tags)))
180 ? attrs
181 : Unowned(attrs);
182 }),
183
184 // No immutable props — every drift is an in-place update.
185 diff: Effect.fn(function* () {}),
186
187 reconcile: Effect.fn(function* ({ id, news, output, session }) {
188 const internalTags = yield* createInternalTags(id);
189 const desiredTags = { ...internalTags, ...news.tags };
190 const rules = desiredRules(news);
191
192 // 1. Observe — cloud state is authoritative; output is an id cache.
193 let isg =
194 output?.inputSecurityGroupId !== undefined
195 ? yield* getGroup(output.inputSecurityGroupId)
196 : yield* findByTags(id);
197
198 // 2. Ensure — create if missing.
199 if (isg === undefined) {
200 const created = yield* medialive.createInputSecurityGroup({
201 WhitelistRules: rules,
202 Tags: desiredTags,
203 });
204 isg = yield* ensureIdentified(
205 created.SecurityGroup,
206 "CreateInputSecurityGroup SecurityGroup Id/Arn",
207 );
208 } else {
209 // 3. Sync — apply whitelist rules only when they drift.
210 const observed = (isg.WhitelistRules ?? [])
211 .map((r) => r.Cidr)
212 .filter((cidr): cidr is string => cidr !== undefined)
213 .sort();
214 const wanted = rules
215 .map((r) => r.Cidr)
216 .slice()
217 .sort();
218 if (JSON.stringify(observed) !== JSON.stringify(wanted)) {
219 const updated = yield* medialive.updateInputSecurityGroup({
220 InputSecurityGroupId: isg.Id,
221 WhitelistRules: rules,
222 });
223 isg = yield* ensureIdentified(
224 updated.SecurityGroup,
225 "UpdateInputSecurityGroup SecurityGroup Id/Arn",
226 );
227 }
228 }
229
230 // 3b. Sync tags — diff against OBSERVED cloud tags.
231 yield* syncMlTags(isg.Arn, desiredTags);
232
233 yield* session.note(isg.Id);
234 return toAttrs(isg);
235 }),
236
237 delete: Effect.fn(function* ({ output }) {
238 yield* medialive
239 .deleteInputSecurityGroup({
240 InputSecurityGroupId: output.inputSecurityGroupId,
241 })
242 .pipe(
243 retryWhileInUse,
244 Effect.catchTag("NotFoundException", () => Effect.void),
245 );
246 }),
247 });
248 }),
249 );