1import * as vpclattice from "@distilled.cloud/aws/vpc-lattice";
2import type * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import {
11 createInternalTags,
12 diffTags,
13 hasAlchemyTags,
14 tagRecord,
15} from "../../Tags.ts";
16import { toWireSeconds } from "../../Util/Duration.ts";
17import type { Providers } from "../Providers.ts";
18import type { ServiceNetworkAuthType } from "./ServiceNetwork.ts";
19import {
20 retryOnConflict,
21 waitUntilAbsent,
22 waitUntilStable,
23} from "./internal.ts";
24
25export interface ServiceProps {
26 /**
27 * Name of the service. If omitted, a unique name is generated. Immutable —
28 * changing it replaces the resource.
29 */
30 name?: string;
31 /**
32 * Authorization type for the service.
33 * @default "NONE"
34 */
35 authType?: ServiceNetworkAuthType;
36 /**
37 * Custom domain name for the service. Immutable — changing it replaces the
38 * resource.
39 */
40 customDomainName?: string;
41 /**
42 * ARN of an ACM certificate for the custom domain (HTTPS listeners).
43 */
44 certificateArn?: string;
45 /**
46 * Idle timeout for connections to the service, e.g. `"60 seconds"` or
47 * `Duration.minutes(1)` (a bare number is milliseconds). Rounded to whole
48 * seconds on the wire.
49 */
50 idleTimeout?: Duration.Input;
51 /**
52 * User-defined tags to apply to the service.
53 */
54 tags?: Record<string, string>;
55}
56
57export interface Service extends Resource<
58 "AWS.VpcLattice.Service",
59 ServiceProps,
60 {
61 /**
62 * Service-assigned unique ID of the service.
63 */
64 serviceId: string;
65 /**
66 * ARN of the service.
67 */
68 serviceArn: string;
69 /**
70 * Physical name of the service.
71 */
72 name: string;
73 /**
74 * Current lifecycle status (e.g. `ACTIVE`).
75 */
76 status: string;
77 /**
78 * Lattice-generated DNS name clients resolve the service by.
79 */
80 dnsName?: string;
81 /**
82 * Effective authorization type.
83 */
84 authType: ServiceNetworkAuthType;
85 /**
86 * Current tags reported for the service.
87 */
88 tags: Record<string, string>;
89 },
90 never,
91 Providers
92> {}
93
94/**
95 * An Amazon VPC Lattice service — an independently deployable unit of software
96 * (running on Lambda, ECS, EC2, or elsewhere) that is made discoverable through
97 * a service network. Cheap control-plane resource.
98 *
99 * ### Creating Services
100 * **Example:** Basic Service
101 * ```typescript
102 * const service = yield* Service("PaymentsService", {});
103 * ```
104 *
105 * **Example:** Service with Custom Domain
106 * ```typescript
107 * const service = yield* Service("PaymentsService", {
108 * customDomainName: "payments.internal.example.com",
109 * certificateArn: cert.certificateArn,
110 * authType: "AWS_IAM",
111 * idleTimeout: "60 seconds",
112 * });
113 * ```
114 *
115 * @resource
116 */
117export const Service = Resource<Service>("AWS.VpcLattice.Service");
118
119export const ServiceProvider = () =>
120 Provider.effect(
121 Service,
122 Effect.gen(function* () {
123 const toName = (id: string, props: { name?: string } = {}) =>
124 props.name
125 ? Effect.succeed(props.name)
126 : createPhysicalName({ id, maxLength: 40, lowercase: true });
127
128 // getService only accepts an id or ARN — never a name.
129 const observe = (serviceIdentifier: string) =>
130 vpclattice
131 .getService({ serviceIdentifier })
132 .pipe(
133 Effect.catchTag("ResourceNotFoundException", () =>
134 Effect.succeed(undefined),
135 ),
136 );
137
138 const findByName = (name: string) =>
139 vpclattice.listServices.pages({}).pipe(
140 Stream.runCollect,
141 Effect.map((chunk) =>
142 Array.from(chunk)
143 .flatMap((page) => page.items ?? [])
144 .find((s) => s.name === name),
145 ),
146 Effect.flatMap((summary) =>
147 summary?.id ? observe(summary.id) : Effect.succeed(undefined),
148 ),
149 );
150
151 const syncTags = Effect.fn(function* (
152 arn: string,
153 desiredTags: Record<string, string>,
154 ) {
155 const listed = yield* vpclattice.listTagsForResource({
156 resourceArn: arn,
157 });
158 const { removed, upsert } = diffTags(
159 tagRecord(listed.tags),
160 desiredTags,
161 );
162 if (upsert.length > 0) {
163 yield* vpclattice.tagResource({
164 resourceArn: arn,
165 tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
166 });
167 }
168 if (removed.length > 0) {
169 yield* vpclattice.untagResource({
170 resourceArn: arn,
171 tagKeys: removed,
172 });
173 }
174 });
175
176 return {
177 stables: ["serviceId", "serviceArn", "name"],
178 diff: Effect.fn(function* ({ id, olds, news }) {
179 if (!isResolved(news)) return;
180 if (
181 (yield* toName(id, olds ?? {})) !== (yield* toName(id, news ?? {}))
182 ) {
183 return { action: "replace" } as const;
184 }
185 // Custom domain name is fixed at creation time.
186 if ((olds?.customDomainName ?? undefined) !== news.customDomainName) {
187 return { action: "replace" } as const;
188 }
189 }),
190 read: Effect.fn(function* ({ id, olds, output }) {
191 const service = output?.serviceId
192 ? yield* observe(output.serviceId)
193 : yield* findByName(yield* toName(id, olds ?? {}));
194 if (!service?.arn || !service.id) return undefined;
195 const listed = yield* vpclattice.listTagsForResource({
196 resourceArn: service.arn,
197 });
198 const attrs = {
199 serviceId: service.id,
200 serviceArn: service.arn,
201 name: service.name!,
202 status: service.status ?? "UNKNOWN",
203 dnsName: service.dnsEntry?.domainName,
204 authType: (service.authType as ServiceNetworkAuthType) ?? "NONE",
205 tags: tagRecord(listed.tags),
206 };
207 return (yield* hasAlchemyTags(id, listed.tags))
208 ? attrs
209 : Unowned(attrs);
210 }),
211 reconcile: Effect.fn(function* ({ id, news, output, session }) {
212 const name = yield* toName(id, news);
213 const internalTags = yield* createInternalTags(id);
214 const desiredTags = { ...internalTags, ...news.tags };
215 const desiredAuthType = news.authType ?? "NONE";
216 const desiredIdleTimeoutSeconds = toWireSeconds(news.idleTimeout);
217
218 const existing = output?.serviceId
219 ? yield* observe(output.serviceId)
220 : yield* findByName(name);
221
222 let serviceId: string;
223 let serviceArn: string;
224 if (!existing?.arn || !existing.id) {
225 const created = yield* vpclattice
226 .createService({
227 name,
228 authType: desiredAuthType,
229 customDomainName: news.customDomainName,
230 certificateArn: news.certificateArn,
231 idleTimeoutSeconds: desiredIdleTimeoutSeconds,
232 })
233 .pipe(
234 Effect.catchTag("ConflictException", () => findByName(name)),
235 );
236 if (!created?.arn || !created.id) {
237 return yield* Effect.fail(
238 new Error(`Failed to create service ${name}`),
239 );
240 }
241 serviceId = created.id;
242 serviceArn = created.arn;
243 } else {
244 serviceId = existing.id;
245 serviceArn = existing.arn;
246 }
247
248 // VPC Lattice services reject updates/tags while CREATE_IN_PROGRESS;
249 // wait for a stable status before syncing mutable settings.
250 const stable = yield* waitUntilStable(observe(serviceId));
251 const needsUpdate =
252 (stable?.authType ?? "NONE") !== desiredAuthType ||
253 stable?.certificateArn !== news.certificateArn ||
254 stable?.idleTimeoutSeconds !== desiredIdleTimeoutSeconds;
255 if (needsUpdate) {
256 yield* vpclattice.updateService({
257 serviceIdentifier: serviceId,
258 authType: desiredAuthType,
259 certificateArn: news.certificateArn,
260 idleTimeoutSeconds: desiredIdleTimeoutSeconds,
261 });
262 }
263
264 yield* syncTags(serviceArn, desiredTags);
265
266 const final = yield* observe(serviceId);
267 yield* session.note(serviceArn);
268 return {
269 serviceId,
270 serviceArn,
271 name,
272 status: final?.status ?? stable?.status ?? "ACTIVE",
273 dnsName:
274 final?.dnsEntry?.domainName ?? stable?.dnsEntry?.domainName,
275 authType: desiredAuthType,
276 tags: desiredTags,
277 };
278 }),
279 list: () =>
280 Effect.gen(function* () {
281 const summaries = yield* vpclattice.listServices.pages({}).pipe(
282 Stream.runCollect,
283 Effect.map((chunk) =>
284 Array.from(chunk).flatMap((page) => page.items ?? []),
285 ),
286 );
287 return yield* Effect.forEach(
288 summaries.filter(
289 (s): s is typeof s & { id: string; arn: string } =>
290 s.id != null && s.arn != null,
291 ),
292 (summary) =>
293 Effect.gen(function* () {
294 const service = yield* observe(summary.id);
295 const listed = yield* vpclattice.listTagsForResource({
296 resourceArn: summary.arn,
297 });
298 return {
299 serviceId: summary.id,
300 serviceArn: summary.arn,
301 name: summary.name!,
302 status: summary.status ?? "UNKNOWN",
303 dnsName: summary.dnsEntry?.domainName,
304 authType:
305 (service?.authType as ServiceNetworkAuthType) ?? "NONE",
306 tags: tagRecord(listed.tags),
307 };
308 }),
309 { concurrency: 10 },
310 );
311 }),
312 delete: Effect.fn(function* ({ output }) {
313 yield* retryOnConflict(
314 vpclattice.deleteService({ serviceIdentifier: output.serviceId }),
315 ).pipe(
316 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
317 );
318 yield* waitUntilAbsent(observe(output.serviceId));
319 }),
320 };
321 }),
322 );