1import * as sagemaker from "@distilled.cloud/aws/sagemaker";
2import * as Effect from "effect/Effect";
3import * as EffectStream 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 type Tags,
14} from "../../Tags.ts";
15import type { Providers } from "../Providers.ts";
16
17export interface EndpointConfigProps {
18 /**
19 * Name of the endpoint configuration. Maximum 63 characters.
20 * @default ${app}-${stage}-${id}
21 */
22 endpointConfigName?: string;
23 /**
24 * The models to host and the resources (instances or serverless capacity)
25 * to deploy each on. At least one variant is required. Use
26 * `ServerlessConfig` for pay-per-request serverless inference or
27 * `InstanceType` + `InitialInstanceCount` for provisioned instances.
28 */
29 productionVariants: sagemaker.ProductionVariant[];
30 /**
31 * Shadow variants that receive a copy of the traffic for testing a new
32 * model behind the production variant.
33 */
34 shadowProductionVariants?: sagemaker.ProductionVariant[];
35 /**
36 * Capture inference request/response payloads to S3 (for monitoring or
37 * retraining).
38 */
39 dataCaptureConfig?: sagemaker.DataCaptureConfig;
40 /**
41 * KMS key that SageMaker uses to encrypt data on the storage volume of the
42 * hosting instances. Not supported for serverless variants.
43 */
44 kmsKeyId?: string;
45 /**
46 * Configure the endpoint for asynchronous inference.
47 */
48 asyncInferenceConfig?: sagemaker.AsyncInferenceConfig;
49 /**
50 * SageMaker Clarify explainer configuration.
51 */
52 explainerConfig?: sagemaker.ExplainerConfig;
53 /**
54 * IAM role used by the endpoint (when variants require one).
55 */
56 executionRoleArn?: string;
57 /**
58 * VPC configuration for the hosted models.
59 */
60 vpcConfig?: sagemaker.VpcConfig;
61 /**
62 * Isolate the hosted containers from the network.
63 * @default false
64 */
65 enableNetworkIsolation?: boolean;
66 /**
67 * Tags to associate with the endpoint configuration. Merged with internal
68 * Alchemy tags.
69 */
70 tags?: Record<string, string>;
71}
72
73export interface EndpointConfig extends Resource<
74 "AWS.SageMaker.EndpointConfig",
75 EndpointConfigProps,
76 {
77 /**
78 * The endpoint configuration's name.
79 */
80 endpointConfigName: string;
81 /**
82 * ARN of the endpoint configuration.
83 */
84 endpointConfigArn: string;
85 },
86 never,
87 Providers
88> {}
89
90/**
91 * An Amazon SageMaker EndpointConfig — the deployment recipe that maps one
92 * or more `Model`s to hosting resources (provisioned instances or serverless
93 * capacity). Pure configuration: it costs nothing until an `Endpoint`
94 * references it.
95 *
96 * Endpoint configurations are immutable — any change other than tags
97 * replaces the configuration. To roll a live endpoint onto new settings,
98 * point the `Endpoint` at the replacement config (alchemy creates the new
99 * config first, updates the endpoint, then deletes the old config).
100 * ### Creating Endpoint Configurations
101 * **Example:** Serverless Variant
102 * ```typescript
103 * import * as AWS from "alchemy/AWS";
104 *
105 * const config = yield* AWS.SageMaker.EndpointConfig("MyConfig", {
106 * productionVariants: [{
107 * VariantName: "AllTraffic",
108 * ModelName: model.modelName,
109 * ServerlessConfig: { MemorySizeInMB: 2048, MaxConcurrency: 5 },
110 * }],
111 * });
112 * ```
113 *
114 * **Example:** Provisioned Instances
115 * ```typescript
116 * const config = yield* AWS.SageMaker.EndpointConfig("MyConfig", {
117 * productionVariants: [{
118 * VariantName: "AllTraffic",
119 * ModelName: model.modelName,
120 * InstanceType: "ml.m5.large",
121 * InitialInstanceCount: 1,
122 * }],
123 * });
124 * ```
125 *
126 * @resource
127 */
128export const EndpointConfig = Resource<EndpointConfig>(
129 "AWS.SageMaker.EndpointConfig",
130);
131
132const createConfigName = (
133 id: string,
134 props: { endpointConfigName?: string | undefined },
135) =>
136 props.endpointConfigName
137 ? Effect.succeed(props.endpointConfigName)
138 : createPhysicalName({ id, maxLength: 63 });
139
140const fetchConfigTags = Effect.fn(function* (arn: string) {
141 const response = yield* sagemaker
142 .listTags({ ResourceArn: arn })
143 .pipe(
144 Effect.catchTag("AccessDeniedException", () => Effect.succeed(undefined)),
145 );
146 return Object.fromEntries(
147 (response?.Tags ?? []).flatMap((tag) =>
148 tag.Key !== undefined ? [[tag.Key, tag.Value ?? ""]] : [],
149 ),
150 );
151});
152
153const readEndpointConfig = Effect.fn(function* (name: string) {
154 const described = yield* sagemaker
155 .describeEndpointConfig({ EndpointConfigName: name })
156 .pipe(
157 Effect.catchTag("EndpointConfigNotFound", () =>
158 Effect.succeed(undefined),
159 ),
160 );
161 if (!described) return undefined;
162 return {
163 endpointConfigName: described.EndpointConfigName,
164 endpointConfigArn: described.EndpointConfigArn,
165 };
166});
167
168export const EndpointConfigProvider = () =>
169 Provider.effect(
170 EndpointConfig,
171 Effect.gen(function* () {
172 return {
173 stables: ["endpointConfigName", "endpointConfigArn"],
174 list: () =>
175 Effect.gen(function* () {
176 const summaries = yield* sagemaker.listEndpointConfigs
177 .pages({})
178 .pipe(
179 EffectStream.runCollect,
180 Effect.map((chunk) =>
181 Array.from(chunk).flatMap(
182 (page) => page.EndpointConfigs ?? [],
183 ),
184 ),
185 );
186 return summaries.flatMap((s) =>
187 s.EndpointConfigName !== undefined &&
188 s.EndpointConfigArn !== undefined
189 ? [
190 {
191 endpointConfigName: s.EndpointConfigName,
192 endpointConfigArn: s.EndpointConfigArn,
193 },
194 ]
195 : [],
196 );
197 }),
198 read: Effect.fn(function* ({ id, olds, output }) {
199 const name =
200 output?.endpointConfigName ??
201 (yield* createConfigName(id, olds ?? {}));
202 const attrs = yield* readEndpointConfig(name);
203 if (!attrs) return undefined;
204 const tags = yield* fetchConfigTags(attrs.endpointConfigArn);
205 return (yield* hasAlchemyTags(id, tags as Tags))
206 ? attrs
207 : Unowned(attrs);
208 }),
209 diff: Effect.fn(function* ({ id, news, olds }) {
210 if (!isResolved(news)) return;
211 if (olds === undefined) return;
212 // Endpoint configs are immutable — everything except tags replaces.
213 const identity = (props: EndpointConfigProps) => [
214 props.productionVariants,
215 props.shadowProductionVariants,
216 props.dataCaptureConfig,
217 props.kmsKeyId,
218 props.asyncInferenceConfig,
219 props.explainerConfig,
220 props.executionRoleArn,
221 props.vpcConfig,
222 props.enableNetworkIsolation ?? false,
223 ];
224 const oldName = yield* createConfigName(id, olds);
225 const newName = yield* createConfigName(id, news);
226 if (
227 oldName !== newName ||
228 JSON.stringify(identity(olds)) !== JSON.stringify(identity(news))
229 ) {
230 return { action: "replace" } as const;
231 }
232 }),
233 reconcile: Effect.fn(function* ({ id, news, output, session }) {
234 if (!news) {
235 return yield* Effect.fail(
236 new Error("SageMaker EndpointConfig requires props"),
237 );
238 }
239 const name =
240 output?.endpointConfigName ?? (yield* createConfigName(id, news));
241 const internalTags = yield* createInternalTags(id);
242 const desiredTags = { ...internalTags, ...news.tags };
243
244 // Observe.
245 let attrs = yield* readEndpointConfig(name);
246
247 // Ensure — create if missing; tolerate the already-exists race.
248 if (attrs === undefined) {
249 yield* sagemaker
250 .createEndpointConfig({
251 EndpointConfigName: name,
252 ProductionVariants: news.productionVariants,
253 ShadowProductionVariants: news.shadowProductionVariants,
254 DataCaptureConfig: news.dataCaptureConfig,
255 KmsKeyId: news.kmsKeyId,
256 AsyncInferenceConfig: news.asyncInferenceConfig,
257 ExplainerConfig: news.explainerConfig,
258 ExecutionRoleArn: news.executionRoleArn,
259 VpcConfig: news.vpcConfig,
260 EnableNetworkIsolation: news.enableNetworkIsolation,
261 Tags: Object.entries(desiredTags).map(([Key, Value]) => ({
262 Key,
263 Value,
264 })),
265 })
266 .pipe(
267 Effect.catchTag(
268 "EndpointConfigAlreadyExists",
269 () => Effect.void,
270 ),
271 );
272 attrs = yield* readEndpointConfig(name);
273 if (attrs === undefined) {
274 return yield* Effect.fail(
275 new Error(`failed to read created endpoint config ${name}`),
276 );
277 }
278 }
279
280 // Sync tags — diff against OBSERVED cloud tags.
281 const currentTags = yield* fetchConfigTags(attrs.endpointConfigArn);
282 const { removed, upsert } = diffTags(currentTags, desiredTags);
283 if (removed.length > 0) {
284 yield* sagemaker.deleteTags({
285 ResourceArn: attrs.endpointConfigArn,
286 TagKeys: removed,
287 });
288 }
289 if (upsert.length > 0) {
290 yield* sagemaker.addTags({
291 ResourceArn: attrs.endpointConfigArn,
292 Tags: upsert.map(({ Key, Value }) => ({ Key, Value })),
293 });
294 }
295
296 yield* session.note(attrs.endpointConfigArn);
297 return attrs;
298 }),
299 delete: Effect.fn(function* ({ output }) {
300 yield* sagemaker
301 .deleteEndpointConfig({
302 EndpointConfigName: output.endpointConfigName,
303 })
304 .pipe(Effect.catchTag("EndpointConfigNotFound", () => Effect.void));
305 }),
306 };
307 }),
308 );