1import * as AWS from "@/AWS";
2import { AWSEnvironment } from "@/AWS/Environment.ts";
3import { Role } from "@/AWS/IAM/Role.ts";
4import { Model } from "@/AWS/SageMaker";
5import * as Test from "@/Test/Alchemy";
6import * as sagemaker from "@distilled.cloud/aws/sagemaker";
7import { expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import { sklearnImage } from "./images.ts";
10
11const { test } = Test.make({ providers: AWS.providers() });
12
13// Ungated typed-error probe: prove the distilled patch carves ModelNotFound
14// out of the overloaded ValidationException. Runs in every CI pass.
15test.provider(
16 "describeModel on a nonexistent model fails with ModelNotFound",
17 () =>
18 Effect.gen(function* () {
19 const error = yield* Effect.flip(
20 sagemaker.describeModel({
21 ModelName: "alchemy-nonexistent-sagemaker-model-probe",
22 }),
23 );
24 expect(error._tag).toBe("ModelNotFound");
25 }),
26 { tags: ["provider:aws", "provider:aws:sagemaker", "live"] },
27);
28
29const findModel = (modelName: string) =>
30 sagemaker
31 .describeModel({ ModelName: modelName })
32 .pipe(Effect.catchTag("ModelNotFound", () => Effect.succeed(undefined)));
33
34const sagemakerRole = Role("SageMakerModelRole", {
35 assumeRolePolicyDocument: {
36 Version: "2012-10-17",
37 Statement: [
38 {
39 Effect: "Allow",
40 Principal: { Service: "sagemaker.amazonaws.com" },
41 Action: ["sts:AssumeRole"],
42 },
43 ],
44 },
45 // CreateModel validates the execution role can pull the container image.
46 managedPolicyArns: [
47 "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly",
48 ],
49});
50
51test.provider(
52 "create model, verify out-of-band, replace on container change, destroy",
53 (stack) =>
54 Effect.gen(function* () {
55 yield* stack.destroy();
56
57 const { region } = yield* AWSEnvironment.current;
58 const image = sklearnImage(region);
59
60 const deployModel = (env: Record<string, string>) =>
61 stack.deploy(
62 Effect.gen(function* () {
63 const role = yield* sagemakerRole;
64 const model = yield* Model("TestModel", {
65 executionRoleArn: role.roleArn,
66 primaryContainer: {
67 Image: image,
68 Environment: env,
69 },
70 tags: { purpose: "alchemy-test" },
71 });
72 return { model };
73 }),
74 );
75
76 const { model } = yield* deployModel({ MODEL_VERSION: "1" });
77 expect(model.modelName).toBeDefined();
78 expect(model.modelArn).toContain(":model/");
79
80 // out-of-band verification via distilled
81 const observed = yield* findModel(model.modelName);
82 expect(observed?.ModelArn).toBe(model.modelArn);
83 expect(observed?.PrimaryContainer?.Image).toBe(image);
84 expect(observed?.PrimaryContainer?.Environment?.MODEL_VERSION).toBe("1");
85
86 // internal ownership + user tags applied
87 const tags = yield* sagemaker
88 .listTags({ ResourceArn: model.modelArn })
89 .pipe(Effect.map((r) => r.Tags ?? []));
90 const tagMap = Object.fromEntries(tags.map((t) => [t.Key, t.Value]));
91 expect(tagMap["alchemy::id"]).toBe("TestModel");
92 expect(tagMap.purpose).toBe("alchemy-test");
93
94 // Models are immutable: a container change must REPLACE the model.
95 const { model: replaced } = yield* deployModel({ MODEL_VERSION: "2" });
96 expect(replaced.modelName).not.toBe(model.modelName);
97 const observedReplacement = yield* findModel(replaced.modelName);
98 expect(
99 observedReplacement?.PrimaryContainer?.Environment?.MODEL_VERSION,
100 ).toBe("2");
101 // the replaced model is deleted
102 expect(yield* findModel(model.modelName)).toBeUndefined();
103
104 yield* stack.destroy();
105 expect(yield* findModel(replaced.modelName)).toBeUndefined();
106 }),
107 {
108 tags: [
109 "provider:aws",
110 "provider:aws:iam",
111 "provider:aws:sagemaker",
112 "live",
113 ],
114 timeout: 240_000,
115 },
116);